Vue 2.0 数据绑定实现一瞥

2,669
原文链接: jimliu.net

抽了点时间看了一下Vue 2.0的代码,主要着重于如何实现数据绑定这一块,在小右的指导下基本上算是知道了个六成吧。

代码可以在Vue的GitHub Repo上next分支里找到。cloc一下:

$ cloc src/ test/ examples/
     143 text files.
     143 unique files.                                          
      12 files ignored.

http://cloc.sourceforge.net v 1.62  T=0.58 s (241.3 files/s, 18204.4 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Javascript                     119            817           1003           7360
HTML                            11             35             25            591
CSS                              7             84             13            541
JSON                             2              0              0             19
-------------------------------------------------------------------------------
SUM:                           139            936           1041           8511
-------------------------------------------------------------------------------

其中,src是4000多行,可以不客气的说,Vue完全可以称为是轻量级。

结构

Vue 2不再是Browser-Only的,所以加入了renderruntime的概念。

render是将v-dom树(下文中v-dom和v-tree基本表示一个意思)进行输出的实现层,比如server就是一个实现。

runtime是对v-tree进行数据绑定、更新、事件处理等具体操作的实现层,比如web-runtime就是将抽象dom操作全部实现在DOM API上。

数据绑定实现分析

初始化

初始化一个Vue Instance的过程,本文不做重点描述,大概如下:

  • 模板编译——如果使用的是未进行预编译的模板,需要将其编译成一个构建v-dom的函数。
  • 生成初始v-dom——使用初始数据进行_render,得到一棵v-tree。
  • mount——如果使用的是服务端渲染,则将v-tree和元素建立一个mount关系;如果是客户端渲染,则建立一个新的dom-tree。

上述过程还包括对数据绑定的解析,对vm中的数据字段进行包装,通过getter/setter触发变化以此实现“Reactivity”,并收集依赖,注册Watcher。这个过程和现在的Vue差不多。

现在,我们有了一棵v-tree,并且它已经mount到了一个dom-tree上,初始化的过程差不多就先介绍到这里吧。

实现数据绑定

下面以一个简单的计数器例子来介绍一下Vue 2中是如何把getter/setter与v-dom结合起来实现数据绑定的。

已续命{{count}}s

喜+1

点击“喜+1”的时候,会执行(this.$data.)count++,这个count是一个“reactiveSetter”。reactiveSetter会将这个修改所涉及的,在初始化过程中收集到的一系列依赖进行notify()

// /core/observer/index.js

set: function reactiveSetter (newVal) {
  // ...
  dep.notify()
}

这里的dep是一个Dep实例,dep.notify()会对其对应的所有注册的Watcher实例(在最初parse时注册)逐一进行update()

// /core/observer/dep.js

Dep.prototype.notify = function () {
  // stablize the subscriber list first
  var subs = this.subs.slice()
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}

Watcher.prototype.update()会将自己添加到一个全局的batch queue里面:

// /core/observer/watcher.js

Watcher.prototype.update = function (shallow) {
  // ...
  pushWatcher(this)
}

然后等待下一个tick的来临(批量更新机制)。

当下一个tick来临时,会将batch queue里的每个Watcher实例都拿出来并且调用它的run()

// /core/observer/watcher.js

Watcher.prototype.run = function () {
  // ...
  var value = this.get()
  if (value !== this.value) {
    // set new value
    var oldValue = this.value
    this.value = value
    this.cb.call(this.vm, value, oldValue)
  }
}

其中的this.get()


// /core/observer/watcher.js

Watcher.prototype.get = function () {
  // ...
  const value = this.getter.call(this.vm, this.vm)
  return value
}

对于vm实例而言,这里的this.getter绑定的是vm._render,它会调用this.$options.render,也就是在初始化时,模板编译所生产的v-dom函数。

// /core/instance/render.js

Vue.prototype._render = function () {
  // ...
  const { render, _renderChildren } = this.$options
  const vnode = render.call(this._renderProxy)
  return vnode
}

于是这里,一个vm所关联的Watcher实例就通过vm._render()得到了一棵(更新后的)v-tree。

回到Watcher里,run()当中,接下来就会调用this.cb.call(this.vm, value, oldValue)。上面已经看到valueoldValue分别是this.vm所对应的新、老v-tree。而这里的this.cb则绑定的是vm._update

// /core/instance/lifecycle.js

Vue.prototype._update = function (vnode) {
  // ...
  this.__patch__(this._vnode, vnode)
}

可以看到,vm._update当中,调用了Vue.prototype.__patch__,那么这个函数又是从哪来的呢?

答案在/entries/web-runtime.js、/platforms/web/runtime/node-ops.js、/core/vdom/patch.js等几个文件里。

在程序启动的时候,xxx-runtime.js(比如web-runtime.js)会作为一个Provider,提供一系列dom操作,如熟悉的createElement()insertBefore()等。把这些操作的具体实现(如web-runtime就是把它们直接落在原生DOM函数上)交给v-dom的createPatchFunction()。后者则会生成这个__patch__方法,糅合了通用的tree-diff逻辑,以及因runtime而异的dom操作实现。

// /entries/web-runtime.js
Vue.prototype.__patch__ = createPatchFunction({ nodeOps, modules })

这个__patch__函数当中即包含了tree-diff过程又包含了patch过程,并且是在一遍里完成的,在__patch__(oldVTree, newVTree)被调用之后,oldVTree所关联的真实backend(在浏览器里,它就是DOM元素)已经被tree-diff算法所patch成newVTree所对应的样子。

上述过程就完成了一次[属性更新 -> UI自动更新]的过程。

优化

优化过程主要是在模板编译阶段通过/compiler/optimizer.js实现的。

主要的方法有两种:

  • 将元素的attributes中不会变化的那部分提取出来,在对比两个v-node的时候,直接跳过这部分字段。
  • 将v-tree中纯静态的sub-tree提取出来,在对比两棵v-tree的时候,直接跳过这棵子树。

其中第二点,在遇到static sub-tree的时候,会命中oldNode === newNode的全等逻辑,可以直接跳过整棵子树。不过我发现一些小问题,一个是对于喜+1这种v-dom,我不太确定它应该被当做是纯静态的还是动态的,这个我还没想明白,暂时就先不说了,至少在目前的optimizer中,还是把它当动态的。另一个问题是对于模板中的各种HTML注释和换行所带来的一些空白的TextNode,明显应该是静态的,但却被当做了“动态”节点——之所以加引号是因为这部分节点的确是不会变,但没有提取成static node,所以每次_render的时候它还是会被render成一个新的v-node,这样就命中不了全等逻辑,然后对它再进行一次比较(尽管是代价非常低的一次比较)。(关于这个问题的例子可以看这个Gist

另一个问题是,如果使用服务端渲染,初始化会将v-dom直接mount到服务端输出的dom树上。但在客户端渲染的情况下,直接在浏览器里进行模板编译的话,首次输出会生成一个新的dom节点并mount到它上面,原版的那个用来当模板的dom节点则没用了。这是个浪费,但可以理解,第一是因为模板里有很多最后不会输出的节点(比如v-if/v-else中未命中的分支),另一个是到了生产环境下应该大多数人都会选择模板预编译吧。

那么关于数据绑定的实现差不多就是这样了,后面有时间(不用掩饰了,肯定要坑)的话,再继续探索一下依赖追踪、computed属性的实现,以及更多内容(吧……