前端攻城狮该了解的 Vue.2x 响应式原理
本文来自作者 大师兄 在 GitChat 上分享「Vue.2x 源码分析之响应式原理」,「阅读原文」查看交流实录
「文末高能」
编辑 | 黑石
原本文章的标题叫《源码解析》,不过后来一想还是以学习的态度写合适一点。在没有彻底掌握源码中每一个代码之前,说“解析”有点标题党了。
我们都知道 Vue 是一个非常典型的 MVVM 框架,它的核心功能:
双向数据绑定系统
组件化开发系统
本文我们就聊聊双向数据绑定,不管你是学过或者没学过,我相信看完本文你都会对 vue 有一个比较简单明确的了解。不过如果哪块有错误,还望指出。
很多朋友说自己读不懂,索性就“不敢”去读。要我说凡事均要去尝试,不尝试永远没有读懂的机会,如果你试着读了,并且坚持了,那你就真的能读懂。
读源码也是有技巧的,我的技巧就是: 抓住主线,从宏观到微观。
我们不能一开始就要求自己读懂所有的细节,基本不现实;最好是能找到一条主线,先把大体流程结构摸清楚,再深入到细节,逐项击破,形成对源码整体的认识。
比如,我们都知道 Vue 中更新数据后会采用 virtual DOM(虚拟dom)的方式更新 dom。
这个时候,如果你不了解 virtual DOM,那么听我一句“暂且不要去研究内部具体实现,因为这会使你丧失主线”,而你仅仅需要知道 virtual DOM 分为三个步骤:
createElement( ): 用 JavaScript 对象(虚拟树) 描述 真实 DOM 对象(真实树)
diff(oldNode, newNode) : 对比新旧两个虚拟树的区别,收集差异
patch( ) : 将差异应用到真实 DOM 树
回过头我们再去研究这个分支,仅此而已。
上图对于学习过 Vue 的朋友来说应该不陌生吧,来自 Vue 官网深入响应式原理,建议先看图一分钟。
为了说明原理,我们会把虚拟 dom 这块用 fragment 来代替(这个是1.x版本的实现)。
并且只考虑数据为对象的情况。记住今天的主线:搞清楚响应式原理,实现一个简单的 MVVM 框架。
由一个例子开始:
template:
javascript:
我们要解决的问题有:
如何将 data 中的数据渲染到真实的宿主环境中?
template 是如何被编译成真实环境中可用的 HTML 的?
如何通过“响应式”修改数据?
计算属性 getWeChatblog 如何和 data 中的数据绑定的?
带着这些问题开始我们 Vues 的开发。尽量和 Vue 代码保持一致。下面是目录结构:
入口文件:
export 构造函数 Vues,不清楚 ES6 中 module 可以可以点这里
初始化
进入到 ./instance/index.js 就可以看到 Vues 构造函数
其实就是调用 this._init(options),我们先不看这个函数是做什么的,这里有一个疑惑点,我们并没有在 Vues 构造函数内部申明 _init() 函数呀,那是因为我们调用了一个 initMixin 函数,我们来看看此函数:
ok,原来 _init() 是在这里定义的,在 Vues 的原型上扩展了此方法。Vue也用了这种形式。
在 _init() 首先调用了 initState(this) :
但是这里有个问题,从代码中可看出监听的数据对象是 $options.data,每次需要更新视图,则必须通过 vm._data.dsx= '前端开发大师兄';这样的方式来改变数据。
这显然不符合 Vue 中的赋值方式,我们所期望的调用方式应该是这样的: vm.dsx = '前端开发大师兄';
所以这里需要给 Vues 实例添加一个属性代理的方法 _proxyData(),使访问 vm 的属性代理为访问 vm._data 的属性,方法代码如下:
我们初始化计算属性 computed,具体就是调用了函数 _initComputed(vm),来看看代码:
我们可以看出我们已经两次用到同一个方法——Object.defineProperty(),这就是 Vue 实现响应式数据的利器之一。举个栗子来说明。
我们为对象a 通过该方法定义了一个b属性,然后定义了 set 和 get 属性,这样我们给b 赋值就会触发它的 set 属性,我们获取值就会触发它的 get 属性。
这样我们就可以劫持数据,然后执行我们的操作。详细点可以看这里。
observe
这是我们第一个重点,observe 很明显我们会用到观察者模式,事实上Vue也是这么干的。我们看一下 observe() 做了什么?
就是做了类型判断,之后就直接实例化 Oberver ,参数为 data 对象。
官网的 Reactivity in Depth 上有这么句话:
When you pass a plain JavaScript object to a Vue instance as its data
option, Vue.js will walk through all of its properties and convert
them to getter/settersThe getter/setters are invisible to the user, but under the hood they
enable Vue.js to perform dependency-tracking and change-notification
when properties are accessed or modified
observe 使 data 变成“发布者”,watcher 是订阅者,订阅 data 的变化。那如何使 data 变为“发布者”呢?
当然是我们的利器—-Object.defineProperty() ,将数据对象 data 的属性转换为访问器属性。看看我们的代码:
我们遍历 data 对象的所有可配置属性,最终调用了 defineReactive() 函数。
将需要 observe 的数据对象进行递归遍历,包括子属性对象的属性,都加上 set 和 get 。
先看看 defineReactive() 的我们是怎么实现的:
这个地方有一个值得思考的点,如果修改一个数组的成员,该成员是一个对象,那只需要递归对数组的成员进行双向绑定即可。
但这时候出现了一个问题,如果我们进行 pop、push 等操作的时候,push 进去的对象根本没有进行过双向绑定,更别说 pop 了,那么我们如何监听数组的这些变化呢?
Vue.js 提供的方法是重写 push、pop、shift、unshift、splice、sort、reverse 这七个数组方法。
修改数组原型方法的代码可以参考observer/array.js 以及 observer/index.js。
还有就是利用 vue.set() ,借用官方的 API
get 的方法主要用来进行依赖收集,就是添加订阅者。
所以我们只要在最开始进行一次 render,那么所有被渲染所依赖的 data 中的数据就会被 getter 收集到 Dep 的 subs 中去。
set 方法会在对象被修改的时候触发(不存在添加属性的情况,添加属性请用Vue.set),这时候 set 会通知闭包中的 Dep,Dep 中有一些订阅了这个对象改变的 Watcher 观察者对象,Dep 会通知 Watcher 对象更新视图。
我们用一个简单的栗子来说明观察者模式:
那应用到我们这里就是:每个 data 属性值在 defineReactive 函数监听处理的时候,添加一个主题对象,当 data 属性发生改变,通过 set 函数去通知所有的观察者们。
那么如何添加观察者们呢,就是在 complie 函数编译 template 时,通过初始化 value 值,触发 set 函数,在 set 函数中为主题对象添加观察者。有点难理解?直接看代码就明白了。
ok,我们继续。看看我们的 Dep :
那么你可能有疑问了。。谁是订阅者。。对,没错就是 Watcher。。一旦 dep.notify()
就遍历订阅者,也就是 Watcher,并调用他的 update() 方法。
Watcher
如何实现一个 Watcher,通过上面的分析我可以确定得要一个 update() 方法。见下图:
很关键的一个地方就是 this.value = this.get() ,这个就是我们之前说的最开始要进行一次 render,我们看 get() 实现:
这个最关键了,主要做了以下几件事:
把当前 watcher 赋值给 Dep.target
获取 value 值就会触发 Oberver 中定义的 get
执行 dep.append() 添加订阅者
重新将 Dep.target 赋值为 null
返回 vaule 值
关于 notify
当监听的数据赋值就会被 set 拦截,然后执行 dep.notify() ,遍历订阅者(watcher)执行其 update() 方法,update 调用的其实是 this.run() 自己的 run 方法。我们看看:
有一个值得注意的地方,this.cb.call(this.vm, value, oldVal); 这个 cb 是什么?没错就是我们在编译 template 的时候为每一个指令绑定的更新 dom 的函数 。
最后对 watcher 做一个总结:
每次调用 run() 的时候会触发相应属性的 get
get 里面会触发 dep.depend(),继而触发这里的 addDep
假如相应属性的 dep.id 已经在当前 watcher 的 depIds 里,说明不是一个新的属性,仅仅是改变了其值而已。
则不需要将当前 watcher 添加到该属性的 dep 里
假如相应属性是新的属性,则将当前watcher添加到新属性的dep里,如通过 vm.child = {name: 'a'} 改变了 child.name 的值,child.name 就是个新属性。
则需要将当前watcher(child.name)加入到新的 child.name 的 dep 里,因为此时 child.name 是个新值,之前的 set,dep 都已经失效,如果不把 watcher 加入到新的 child.name 的dep中。
通过 child.name = xxx 赋值的时候,对应的 watcher 就收不到通知,等于失效了。
每个子属性的 watcher 在添加到子属性的 dep 的同时,也会添加到父属性的 dep。
监听子属性的同时监听父属性的变更,这样,父属性改变时,子属性的 watcher 也能收到通知进行 update。
这一步是在 this.get() --> this.getVMVal() 里面完成,forEach 时会从父级开始取值,间接调用了它的 get,触发 addDep(),在整个 forEach 过程,当前 wacher 都会加入到每个父级过程属性的 dep。
例如:当前 watcher 的是 'child.child.name', 那么 child, child.child, child.child.name 这三个属性的 dep 都会加入当前watcher。
到时候看看 Compile 了。
Compile
还记得在 _init() 函数最后那行代码吗?
new Compile ,看看做了什么?
注释说的很明白,就不做解释。看看如何转化 fragment :
就是遍历子节点添加到 fragment 。
接下来我们就会对 fragment 节点包括子节点遍历,判断其节点类型,然后调用对应的解析函数解析其中的指令。
我们只看一个 compile:
内置的指令处理方法:
最终都调用同一个方法bind() ,我们先看看它是做了什么?
获取对应指令的更新方法,并执行
new Watcher,在回调函数执行 updaterFn
对于第一条,在执行 updaterFn 的时候会调用 this._getVMVal(vm, exp) :
很简单,就是获取对应的数据返回。我们看一个text类型的更新函数:
这个也是没毛病吧?ok。
第二条,new Watcher,在回调函数执行 updaterFn。还记得之前讲Watcher时提过一句:
还没记住,我就再贴一次图了
哈哈哈,明白了吧。那这个cb啥时执行呢?
当我们修改了数据就会触发对应的set ,然后就会调用 dep.notify();,通知订阅者,再调用订阅者的 update() 方法,update() 方法就会调用 this.run( ) ,run() 就会执行下面这一句:
最后补充,input 的 v-model 双向数据绑定,其实就是监听了 input 事件,还是贴代码:
在 input 事件回调执行 _setVMVal() 方法重新设置一次值。最后再贴一次代码,感觉我贴了好多,不过都是为了把事情说清楚。
到这我们就完成了一个缩减版的 Vue,当然 Vue 功能远远不止这些。我们这只是凤毛麟角。
学会这个对于你看真正的 Vue 源码帮助绝对很大,因为逻辑都是相似的。最后再看看下图,回味一下整个过程。
篇幅比较长,有时还很罗嗦,当然我只是想更清楚的讲解,真怕漏掉那个难点。
此次分享的所有源码均上传 git https://github.com/GGwujun/Vues.git。
Over. Thanks.
近期热文
请给一个不学 Vue 的理由
如果非要让我说一个不学 Vue 的理由,可能是它的写法太方便了……
「阅读原文」看交流实录,你想知道的都在这里