Vue源码解析:双向绑定原理

40次阅读

共计 6406 个字符,预计需要花费 17 分钟才能阅读完成。

通过对 Vue2.0 源码阅读,想写一写自己的理解,能力有限故从尤大佬 2016.4.11 第一次提交开始读,准备陆续写:

模版字符串转 AST 语法树
AST 语法树转 render 函数
Vue 双向绑定原理
Vue 虚拟 dom 比较原理

其中包含自己的理解和源码的分析,尽量通俗易懂!由于是 2.0 的最早提交,所以和最新版本有很多差异、bug,后续将陆续补充,敬请谅解!包含中文注释的 Vue 源码已上传 …

开始
在说双向绑定之前,我们先聊聊单向数据流的概念,引用一下 Vuex 官网的一张图:

这是单向数据流的极简示意,即状态(数据源)映射到视图,视图的变化(用户输入)触发行为,行为改变状态。但在实际的开发中,大部分的情况是多个视图依赖同一状态,多个行为影响同一状态,Vuex 的处理是将共同状态提取出来,转化成单向数据流实现。另外,在 Vue 的父子组件中 prop 传值中,也有用到单向数据流的概念,即父级 prop 的更新会向下流动到子组件中,但是反过来则不行。
无论是 react 还是 vue 都提倡单向数据流管理状态,那我们今天要谈的双向绑定是否和单向数据流理念有所违背?我觉得不是,从上篇文章 AST 语法树转 render 函数了解到,Vue 双向绑定,实质是 value 的单向绑定和 oninput/onchange 事件侦听的语法糖。这种机制在某些需要实时反馈用户输入的场合十分方便,这只是 Vue 内部对 action 进行了封装而形成的。
所以我们今天要说是,状态的变化怎么引起视图的变化?

第一个难点是如何监听状态的变化。Vue2.0 主要是采用 defineProperty,但它有个缺点是不能检测到对象和数组的变化。尤大佬说 3.0 将采用 proxy,不过兼容仍是问题,有兴趣的同学可以去了解下;
另外一个难点就是状态变化后如何触发视图的变化。Vue2.0 采用的发布 / 订阅模式,即每个状态都会有自己的一个订阅中心,订阅中心放着一个个订阅者,订阅者身上有关于 dom 的更新函数。当状态改变时会发布消息:我变了!订阅中心会挨个告诉订阅者,订阅者知道了就去执行自己的更新函数。

源码解析
今天涉及到的代码全在 observer 文件夹下。流程大致如下:
function Vue (options) {
// …
var data = options.data;
data = typeof data === ‘function’ ? data() : data || {};
observe(data, this);
Watcher(this, this.render, this._update);
// …
}
先对 data 进行数据劫持(observe),然后为当前实例创建一个订阅者(Watcher)。具体如何实现,下面将逐一阐述。
数据劫持
数据劫持的实质就是使用 defineProperty 重写对象属性的 getter/setter 方法。但由于 defineProperty 无法监测到对象和数组内部的变化,所以遇到子属性为对象时,会递归观察该属性直至简单数据类型;为数组时的处理是重写 push、pop、shift 等方法,方法内部通知订阅中心:状态变化了!这样就能对所有类型数据进行监听了。
我们先看看入口函数 observe():
function observe (value, vm) {
// 若检测数据不是对象,则退出
if (typeof value !== ‘object’) return;
var ob;
if (value.__ob__ && value.__ob__ instanceof Observer) {
ob = value.__ob__;
} else {
ob = new Observer(value);
}
return ob;
}
observe() 方法尝试为 value 创建观察者实例,观察成功则返回新的观察者或已有的观察者。__ob__属性下面将提到,即对象被观察过后会有__ob__属性,用于存储观察者实例。再来看看 Observer 类:
function Observer (value) {
this.value = value;
// 给 value 对象通过 defineProperty 追加__ob__属性
def(value, ‘__ob__’, this);
// 特殊处理数组
if (Array.isArray(value)) {
value.__proto__ = arrayMethods;
value.forEach(item => {
observe(item);
})
} else {
this.walk(value);
}
}
很明显看到,Observer 类除开属性的定义,就是对数组的特殊处理了。处理的方法是通过原型链去修改数组的 push、pop、shift… 等等方法,当然,还需要对数组的每个元素进行 observe(),因为数组元素也可能是对象,我们要继续劫持,直到基本类型!我们先来看下 arrayMethods 具体是怎么修改的这些方法:
const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);

[‘push’,’pop’,’shift’,’unshift’,’splice’,’sort’,’reverse’]
.forEach(method => {
// 拿到对应的原生方法
var original = arrayProto[method];
def(arrayMethods, method, () => {
// 参数处理
var i = arguments.length;
var args = new Array(i);
while (i–) {
args[i] = arguments[i];
}
// 运行原生方法
var result = original.apply(this, args);
var ob = this.__ob__;
// 特殊处理数组插入方法
var inserted;
switch (method) {
case ‘push’:
inserted = args;
break;
case ‘unshift’:
inserted = args;
break;
case ‘splice’:
inserted = args.slice(2);
break;
}
// 对插入的参数进行数据劫持
if (inserted) ob.observeArray(inserted);
// 发布改变通知
ob.dep.notify();
return result;
})
})
能看出 arrayMethods 的构造其实也很简单,首先是根据数组的 prototype 创建一个新对象,然后对数组方法进行逐个重写。方法重写的重点在于:

继续监听插入类方法(push、unshift、splice)带入的新数据
数组方法在调用时强行触发通知:dep.notify()

到这,defineProperty 无法监听数组内部变化的问题解决了,当然,你通过数组下标修改内部数据还是察觉不到的!
我们继续来看,walk() 函数:
Observer.prototype.walk = function (obj) {
var keys = Object.keys(obj);
for (var i = 0, l = keys.length; i < l; i++) {
this.convert(keys[i], obj[keys[i]]);
}
}
Observer.prototype.convert = function (key, val) {
defineReactive(this.value, key, val);
}
walk() 意思就是遍历对象的每个属性,并侵占(convert)它们的 getter/setter,接下来就是整个数据劫持的重点函数 defineReactive():
function defineReactive (obj, key, val) {
var dep = new Dep();

// 获取对象的对象描述
var property = Object.getOwnPropertyDescriptor(obj, key);
// 是否可配置
if (property && property.configurable === false) return;
// 获取原来的 get、set
var getter = property && property.get;
var setter = property && property.set;

// 递归:继续监听该属性值 (只有 val 为对象时才有 childOb)
var childOb = observe(val);

Object.defineProperty(obj, key, {
enumerable: true, // 可枚举
configurable: true, // 可配置
get: …,
set: …
})
}
以上为 defineReactive() 函数的内部结构,先定义了依赖中心 Dep,再获取对象的原生 get/set 方法,然后递归监听该属性,因为当前属性可能也是对象,最后通过 defineProperty 劫持 getter/setter 函数,依次看一下 get/set:
get: function reactiveGetter () {
// 计算 value
var value = getter ? getter.call(obj) : val
if (Dep.target) {
// 添加依赖
dep.depend();
// 如果有子观察者,也给它添加依赖
if (childOb) {
childOb.dep.depend();
}
// 如果该属性是数组,查看每项是否含观察者对象,有则添加依赖
if (isArray(value)) {
for (var e, i = 0, l = value.length; i < l; i++) {
e = value[i];
e && e.__ob__ && e.__ob__.dep.depend();
}
}
}
return value;
}
大家看完这个函数,除开 if 语句,其他的都是 get 的基本逻辑。至于 Dep.target 的含义,我的理解是它就像一个开关,当开关在打开的状态下访问该属性,则会被添加到订阅中心。至于什么时候开关打开、关闭,以及把谁添加到订阅中心,先留下疑问。继续看下 set:
set: function reactiveSetter (newVal) {
// 计算 value
var value = getter ? getter.call(obj) : val;
// 新旧值是否相等
if (newVal === value) return;
// 不相等,设置新值
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
// 劫持新值
childOb = observe(newVal);
// 发送变更通知
dep.notify();
}
set 也比较好理解,先是新旧值的比较,若不相等,则需要:设置新值,劫持新值,发布通知。
到这,数据劫持就完成了。总之,observe 对数据对象进行了递归遍历,递归包括数组和子对象,将每个属性的 getter/setter 进行了改造,使得在特殊情况下获取值(xxx.name)会添加到订阅中心,在设置值(xxx.name = ‘Tom’)会触发订阅中心的通知事件。
订阅中心
订阅中心也就是前面提到的 Dep,它要做的事情很简单,维护一个容器(数组)存储订阅者,也就是说它有添加订阅者功能和发布通知功能。简单看一下:
let uid = 0;
function Dep () {
this.id = uid++;
this.subs = [];
}
// 添加订阅者
Dep.prototype.addSub = function (sub) {
this.subs.push(sub);
}
// 将自己作为依赖传给目标订阅者
Dep.prototype.depend = function () {
Dep.target.addDep(this);
}
// 通知所有订阅者
Dep.prototype.notify = function () {
var subs = this.subs.slice();
for (var i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}
}
Dep.target = null;
数据劫持中提到,当 Dep.target 存在时调用 get,会触发 dep.depend() 添加订阅者,那么这个 Dep.target.addDep() 方法里肯定含添加订阅者 addSub() 方法。
注意 Dep.target 的默认值为 null。
订阅者
订阅者也就是前面提到的 Watcher,因为它也用于 $watch() 接口,所以这边对其简化分析。
Watcher 接收 3 个参数,vm:Vue 实例对象,fn:渲染函数,cb:更新函数。先看看 Watcher 对象:
function Watcher (vm, fn, cb) {
this.vm = vm;
this.fn = fn;
this.cb = cb;
this.depIds = new Set();

this.value = this.get();
}

// 向当前 watcher 添加依赖项
Watcher.prototype.addDep = function (dep) {
var id = dep.id;
// 防止重复向订阅中心添加订阅者
if (!this.depIds.has(id)) {
this.depIds.add(id);
dep.addSub(this);
}
}
Watcher 的 addDep() 方法内为了防止重复添加订阅者到订阅中心,故维护了一个 Set 用于存储订阅中心(Dep)的 id,每次添加前看是否已存在。Watcher 在初始化时,执行了 get() 函数,看看方法内部:
Watcher.prototype.get = function () {
// 打开开关,指向自身(Watcher)
Dep.target = this;
// 指向渲染函数,会触发 getter
var value = this.fn.call(this.vm);
// 关闭开关
Dep.target = null;
return value;
}
之前一直不理解这边为什么会将订阅者推入各个订阅中心,后来才发现巧妙的地方:Dep.target 指向当前 Watcher(打开开关),然后执行渲染函数,渲染函数用到的数据都会触发其 get,这样就把当前 Watcher 加入到这些数据的订阅中心了!然后 Dep.target = null(开关关闭)。
另外还有一个就是 update 函数,也就是数据的 set 被触发是,其订阅中心会发布通知(notify()),而 notify() 方法的本质就是依次执行订阅者的 update() 方法。让我们看一下:
Watcher.prototype.update = function () {
var value = this.get();
if (value !== this.value) {
var oldValue = this.value;
this.value = value;
this.cb.call(this.vm, value, oldValue);
}
}
update() 方法其实就是拿新值和旧值比较,如果不一样就把它们作为参数,执行更新回调函数。
到这,关于订阅者部分的已经说完了。再回看到前面的调用 Watcher(this, this.render, this._update);,这边的渲染函数也就是前篇文章讲的 render 函数,而_update 函数是用于比较 vdom 并更新的函数,这是下一篇文章要说的内容。
总结
最后再来理一遍,observe 递归遍历整个 data,给每个属性创建一个订阅中心,而且重写他们的 getter/setter 方法:在特殊情况(Dep.target 存在)下 get 会添加订阅者到订阅中心,在 set 时会通知订阅中心,继而通知每位订阅者;订阅者会特殊情况(Dep.target 存在)下,执行 render 函数,get 每一个涉及到的数据。这样,以后只要有数据发生变动,就会触发该订阅者的更新函数,就会引起 dom 的变化!
最近工作比较忙,博客写的比较慢,可能也会有各种问题 (┬_┬)…
溜了溜了

正文完
 0