查看原文
其他

前端攻城狮该了解的 Vue.2x 响应式原理

2017-11-13 大师兄 GitChat技术杂谈


本文来自作者 大师兄 在 GitChat 上分享「Vue.2x 源码分析之响应式原理」,「阅读原文」查看交流实录

「文末高能」

编辑 | 黑石

原本文章的标题叫《源码解析》,不过后来一想还是以学习的态度写合适一点。在没有彻底掌握源码中每一个代码之前,说“解析”有点标题党了。

我们都知道 Vue 是一个非常典型的 MVVM 框架,它的核心功能:

  1. 双向数据绑定系统

  2. 组件化开发系统

本文我们就聊聊双向数据绑定,不管你是学过或者没学过,我相信看完本文你都会对 vue 有一个比较简单明确的了解。不过如果哪块有错误,还望指出。

很多朋友说自己读不懂,索性就“不敢”去读。要我说凡事均要去尝试,不尝试永远没有读懂的机会,如果你试着读了,并且坚持了,那你就真的能读懂。

读源码也是有技巧的,我的技巧就是: 抓住主线,从宏观到微观。

我们不能一开始就要求自己读懂所有的细节,基本不现实;最好是能找到一条主线,先把大体流程结构摸清楚,再深入到细节,逐项击破,形成对源码整体的认识。

比如,我们都知道 Vue 中更新数据后会采用 virtual DOM(虚拟dom)的方式更新 dom。

这个时候,如果你不了解 virtual DOM,那么听我一句“暂且不要去研究内部具体实现,因为这会使你丧失主线”,而你仅仅需要知道 virtual DOM 分为三个步骤:

  1. createElement( ): 用 JavaScript 对象(虚拟树) 描述 真实 DOM 对象(真实树)

  2. diff(oldNode, newNode) : 对比新旧两个虚拟树的区别,收集差异

  3. 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/setters

The 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() 实现:

这个最关键了,主要做了以下几件事:

  1. 把当前 watcher 赋值给 Dep.target

  2. 获取 value 值就会触发 Oberver 中定义的 get

  3. 执行 dep.append() 添加订阅者

  4. 重新将 Dep.target 赋值为 null

  5. 返回 vaule 值

关于 notify

当监听的数据赋值就会被 set 拦截,然后执行 dep.notify() ,遍历订阅者(watcher)执行其 update() 方法,update 调用的其实是 this.run() 自己的 run 方法。我们看看:

有一个值得注意的地方,this.cb.call(this.vm, value, oldVal); 这个 cb 是什么?没错就是我们在编译 template 的时候为每一个指令绑定的更新 dom 的函数 。

最后对 watcher 做一个总结:

  1. 每次调用 run() 的时候会触发相应属性的 get

  2. get 里面会触发 dep.depend(),继而触发这里的 addDep

  3. 假如相应属性的 dep.id 已经在当前 watcher 的 depIds 里,说明不是一个新的属性,仅仅是改变了其值而已。

    则不需要将当前 watcher 添加到该属性的 dep 里

  4. 假如相应属性是新的属性,则将当前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 就收不到通知,等于失效了。

  5. 每个子属性的 watcher 在添加到子属性的 dep 的同时,也会添加到父属性的 dep。

  6. 监听子属性的同时监听父属性的变更,这样,父属性改变时,子属性的 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() ,我们先看看它是做了什么?

  1. 获取对应指令的更新方法,并执行

  2. 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.

近期热文

前端工程师“应试”指南

如何用 Node.js 爬虫?

两款敏捷工具,治好你碎片化交付硬伤

改做人工智能之前,90%的人都没能给自己定位

想入行 AI,别让那些技术培训坑了你...


请给一个不学 Vue 的理由

如果非要让我说一个不学 Vue 的理由,可能是它的写法太方便了……

「阅读原文」看交流实录,你想知道的都在这里

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存