Vue2 源码分析

2,215 阅读4分钟

源码版本:v2.1.10

分析目标

通过阅读源码,对 Vue2 的基础运行机制有所了解,主要是:

  • Vue2 中数据绑定的实现方式
  • Vue2 中对 Virtual DOM 机制的使用方式

源码初见

项目构建配置文件为 build/config.js,定位 vue.js 对应的入口文件为 src/entries/web-runtime-with-compiler.js,基于 rollup 进行模块打包。

代码中使用 flow 进行接口类型标记和检查,在打包过程中移除这些标记。为了阅读代码方便,在 VS Code 中安装了插件 Flow Language Support,然后关闭工作区 JS 代码检查,这样界面就清爽很多了。

Vue 应用启动一般是通过 new Vue({...}),所以,先从该构造函数着手。

注:本文只关注 Vue 在浏览器端的应用,不涉及服务器端代码。

Vue 构造函数

文件:src/core/instance/index.js

该文件只是构造函数,Vue 原型对象的声明分散在当前目录的多个文件中:

  • init.js:._init()
  • state.js:.$data .$set() .$delete() .$watch()
  • render.js:._render() ...
  • events.js:.$on() .$once() .$off() .$emit()
  • lifecycle.js:._mount() ._update() .$forceUpdate() .$destroy()

构造函数接收参数 options ,然后调用 this._init(options)

._init() 中进行初始化,其中会依次调用 lifecycle、events、render、state 模块中的初始化函数。

Vue2 中应该是为了代码更易管理,Vue 类的定义分散到了上面的多个文件中。

其中,对于 Vue.prototype 对象的定义,通过 mixin 的方式在入口文件 core/index.js 中依次调用。对于实例对象(代码中通常称为 vm)则通过 init 函数在 vm._init() 中依次调用。

Vue 公共接口

文件:src/core/index.js

这里调用了 initGlobalAPI() 来初始化 Vue 的公共接口,包括:

  • Vue.util
  • Vue.set
  • Vue.delete
  • Vue.nextTick
  • Vue.options
  • Vue.use
  • Vue.mixin
  • Vue.extend
  • asset相关接口:配置在 src/core/config.js

Vue 启动过程

调用 new Vue({...}) 后,在内部的 ._init() 的最后,是调用 .$mount() 方法来“启动”。

web-runtime-with-compiler.jsweb-runtime.js 中,定义了 Vue.prototype.$mount()。不过两个文件中的 $mount() 最终调用的是 ._mount() 内部方法,定义在文件 src/core/instance/lifecycle.js 中。

Vue.prototype._mount(el, hydrating)

简化逻辑后的伪代码:

vm = this
vm._watcher = new Watcher(vm, updateComponent)

接下来看 Watcher

Watcher

文件:src/core/observer/watcher.js

先看构造函数的简化逻辑:

// 参数:vm, expOrFn, cb, options
this.vm = vm
vm._watchers.push(this)
// 解析 options,略....
// 属性初始化,略....
this.getter = expOrFn // if `function`
this.value = this.lazy ? undefined : this.get()

由于缺省的 lazy 属性值为 false,接着看 .get() 的逻辑:

pushTarget(this) // !
value = this.getter.call(this.vm, this.vm)
popTarget()
this.cleanupDeps()
return value

先看这里对 getter 的调用,返回到 ._mount() 中,可以看到,是调用了 vm._update(vm._render(), hydrating),涉及两个方法:

  • vm._render():返回虚拟节点(VNode)
  • vm._update()

来看 _update() 的逻辑,这里应该是进行 Virtual DOM 的更新:

// 参数:vnode, hydrating
vm = this
prevEl = vm.$el
prevVnode = vm._vnode
prevActiveInstance = activeInstance
activeInstance = vm
vm._vnode = vnode
if (!prevVnode) {
  // 初次加载
  vm.$el = vm.__patch__(vm.$el, vnode, ...)
} else {
  // 更新
  vm.$el = vm.__patch__(prevVnode, vnode)
}
activeInstance = prevActiveInstance
// 后续属性配置,略....

参考 Virtual DOM 的一般逻辑,这里是差不多的处理过程,不再赘述。

综上,这里的 watcher 主要作用应该是在数据发生变更时,触发重新渲染和更新视图的处理:vm._update(vm._render())

接下来,我们看下 watcher 是如何发挥作用的,参考 Vue 1.0 的经验,下面应该是关于依赖收集、数据绑定方面的细节了,而这一部分,和 Vue 1.0 差别不大。

数据绑定

watcher.get() 中调用的 pushTarget()popTarget() 来自文件:src/core/observer/dep.js

pushTarget()popTarget() 两个方法,用于处理 Dep.target,显然 Dep.targetwather.getter 的调用过程中会用到,调用时会涉及到依赖收集,从而建立起数据绑定的关系。

Dep 类的 .dep() 方法中用到了 Dep.target,调用方式为:

Dep.target.addDep(this)

可以想见,在使用数据进行渲染的过程中,会对数据属性进行“读”操作,从而触发 dep.depend(),进而收集到这个依赖关系。下面来找一下这样的调用的位置。

state.js 中找到一处,makeComputedGetter() 函数中通过 watcher.depend() 间接调用了 dep.depend()。不过 computedGetter 应该不是最主要的地方,根据 Vue 1.0 的经验,还是要找对数据进行“数据劫持”的地方,应该是defineReactive()

defineReactive() 定义在文件 src/core/observer/index.js

// 参数:obj, key, val, customSetter?
dep = new Dep()
childOb = observe(val)
Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function () {
    // 略,调用了 dep.depend()
  },
  set: function () {
    // 略,调用 dep.notify()
  }
})

结合 Vue 1.0 经验,这里应该就是数据劫持的关键了。数据原有的属性被重新定义,属性的 get() 被调用时,会通过 dep.depend() 收集依赖关系,记录到 vm 中;而在 set() 被调用时,则会判断属性值是否发生变更,如果发生变更,则通过 dep.notify() 来通知 vm,从而触发 vm 的更新操作,实现 UI 与数据的同步,这也就是数据绑定后的效果了。

回过头来看 state.js,是在 initProps() 中调用了 defineReactive()。而 initProps()initState() 中调用,后者则是在 Vue.prototype._init() 中被调用。

不过最常用的其实是在 initData() 中,对初始传入的 data 进行劫持,不过里面的过程稍微绕一些,是将这里的 data 赋值到 vm._data 并且代理到了 vm 上,进一步的处理还涉及 observe()Observer 类。这里不展开了。

综上,数据绑定的实现过程为:

  • 初始化:new Vue() -> vm._init()
  • 数据劫持:initState(vm) -> initProps(), initData() -> dep.depend()
  • 依赖收集:vm.$mount() -> vm._mount() -> new Watcher() -> vm._render()

渲染

首先来看 initRender(),这里在 vm 上初始化了两个与创建虚拟元素相关的方法:

  • vm._c()
  • vm.$createElement()

其内部实现都是调用 createElement(),来自文件:src/core/vdom/create-element.js

而在 renderMixin() 中初始化了 Vue.prototype._render() 方法,其中创建 vnode 的逻辑为:

render = vm.$options.render
try {
  vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
  // ...
}

这里传入 render() 是一个会返回 vnode 的函数。

接下来看 vm._update() 的逻辑,这部分在前面有介绍,初次渲染时是通过调用 vm.__patch__() 来实现。那么 vm.__patch__() 是在哪里实现的呢?在 _update() 代码中有句注释,提到:

    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.

在文件 web-runtime.js 中,找到了:

Vue.prototype.__patch__ = inBrowser ? patch : noop

显然示在浏览器环境下使用 patch(),来自:src/platforms/web/runtime/patch.js,其实现是通过 createPatchFunction(),来自文件 src/core/vdom/patch

OK,以上线索都指向了 vdom 相关的模块,也就是说,显然是 vdom 也就是 Virtual DOM 参与了渲染和更新。

不过还有个问题没有解决,那就是原始的字符串模块,是如何转成用于 Virtual DOM 创建的函数调用的呢?这里会有一个解析的过程。

回到入口文件 web-runtime-with-compiler.js,在 Vue.prototype.$mount() 中,有一个关键的调用:compileToFunctions(template, ...)template 变量值为传入的参数解析得到的模板内容。

模板解析

文件:src/platforms/web/compiler/index.js

函数 compileToFunctions() 的基本逻辑:

// 参数:template, options?, vm?
res = {}
compiled = compile(template, options)
res.render = makeFunction(compiled.render)
// 拷贝数组元素:
// res.staticRenderFns <= compiled.staticRenderFns
return res

这里对模板进行了编译(compile()),最终返回了根据编译结果得到的 render()、staticRenderFns。再看 web-runtime-with-compiler.jsVue.prototype.$mount() 的逻辑,则是将这里得到的结果写入了 vm.$options 中,也就是说,后面 vm._render() 中会使用这里的 render()

再来看 compile() 函数,这里是实现模板解析的核心,来做文件 src/compiler/index.js,基本逻辑为:

// 参数:template, options
ast = parse(template.trim(), options)
optimize(ast, options)
code = generate(ast, options)
return {
  ast,
  render: code.render,
  staticRenderFns: code.staticRenderFns
}

逻辑很清晰,首先从模板进行解析得到抽象语法树(ast),进行优化,最后生成结果代码。整个过程中肯定会涉及到 Vue 的语法,包括指令、组件嵌套等等,不仅仅是得到构建 Virtual DOM 的代码。

需要注意的是,编译得到 render 其实是代码文本,通过 new Function(code) 的方式转为函数。

总结

Vue2 相比 Vue1 一个主要的区别在于引入了 Virtual DOM,但其 MVVM 的特性还在,也就是说仍有一套数据绑定的机制。

此外,Virtual DOM 的存在,使得原有的视图模板需要转变为函数调用的模式,从而在每次有更新时可以重新调用得到新的 vnode,从而应用 Virtual DOM 的更新机制。为此,Vue2 实现了编译器(compiler),这也意味着 Vue2 的模板可以是纯文本,而不必是 DOM 元素。

Vue2 基本运行机制总结为:

  • 文本模板,编译得到生成 vnode 的函数(render),该过程中会识别并记录 Vue 的指令和其他语法
  • new Vue() 得到 vm 对象,其中传入的数据会进行数据劫持处理,从而可以收集依赖,实现数据绑定
  • 渲染过程是将所有数据交由渲染函数(render)进行调用得到 vnode,应该 Virtual DOM 的机制实现初始渲染和更新

写在最后

对 Vue2 的源码分析,是基于我之前对 Vue1 的分析和对 Virtual DOM 的了解,见【链接】中之前的文章。

水平有限,错漏难免,欢迎指正。

感谢阅读!

链接