Vue源码学习和分析笔记
author: @TiffanysBear
准备工作
前序了解
Flow 静态类型检查工具
类型推断:通过变量的使用上下文来推断出变量类型,然后根据这些推断来检查类型。
1 |
|
类型注释:事先注释好我们期待的类型,Flow 会基于这些注释来判断。
1 |
|
源码目录设计
Vue.js 的源码都在 src 目录下,其目录结构如下。
1 | src |
compiler
compiler 目录包含 Vue.js 所有编译相关的代码。它包括把模板解析成 AST 语法树,AST语法树优化,代码生成等功能。
编译的工作可以在构建时做(可以借助 webpack、vue-loader 等插件);也可以在运行时做,使用包含构建功能的 Vue.js。编译是一项耗性能的工作,所以更推荐前者——离线编译。
core
core 目录包含了 Vue.js 的核心代码,包括有内置组件、全局 API 封装,Vue 实例化、Obsever、Virtual DOM、工具函数 Util 等等。
platform
Vue.js 是一个跨平台的 MVVM 框架,它可以跑在 web 上,也可以配合 weex 跑在 native 客户端上。platform 是 Vue.js 的入口,2 个目录代表 2 个主要入口,分别打包成运行在 web 上和 weex 上的 Vue.js。
server
Vue.js 2.0 支持了服务端渲染,所有服务端渲染相关的逻辑都在这个目录下。注意:这部分代码是跑在服务端的 Node.js,不要和跑在浏览器端的 Vue.js 混为一谈。
服务端渲染主要的工作是把组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将静态标记”混合”为客户端上完全交互的应用程序。
sfc
通常我们开发 Vue.js 都会借助 webpack 构建, 然后通过 .vue 单文件来编写组件。
这个目录下的代码逻辑会把 .vue 文件内容解析成一个 JavaScript 的对象。
shared
Vue.js 会定义一些工具方法,这里定义的工具方法都是会被浏览器端的 Vue.js 和服务端的 Vue.js 所共享的。
Vue入口文件
Vue入口文件目录 vue/src/core/instance/index.js
1 | // vue/src/core/instance/index.js |
采用的是ES5的写法,并不是ES6的Class写法的优点,是因为:
1、使用混入Mixin的方式传入Vue,为Vue的原型prototype上增加方法。class难以实现这种方法
2、此种方式将代码模块合理划分,将扩展分散到多个模块中去实现,使得代码文件不会过于庞大,便于维护和管理。这个编程技巧以后可以用于代码开发实现中。
通过Mixin增加的原型方法:
1 | // vue/src/core/instance/index.js |
initGlobalAPI
在 vue/src/core/index.js 中,调用的initGlobalAPI(Vue),是为Vue增加静态方法的,
在路径 vue/src/core/global-api/ 目录下的文件中,都是给Vue添加的静态方法
比如:
1 | Vue.use // 使用plugin |
new Vue 做了什么
从入口的文件看来,通过new关键字初始化,调用了
1 | // src/core/instance/index.js |
然后从Mixin增加的原型方法看,initMixin(Vue),调用的是为Vue增加的原型方法_init
1 | // src/core/instance/init.js |
从上面的函数名看来,new vue所做的事情,就像一个流程图一样展开了,分别是
- 合并配置
- 初始化生命周期
- 初始化事件中心
- 初始化渲染
- 调用beforeCreate钩子函数
- init injections and reactivity(这个阶段属性都已注入绑定,而且被$watch变成reactivity,但是$el还是没有生成,也就是DOM没有生成)
- 初始化state状态(初始化了data、props、computed、watcher)
- 调用created钩子函数。
在初始化的最后,检测到如果有 el 属性,则调用 vm.$mount 方法挂载 vm,挂载的目标就是把模板渲染成最终的 DOM。
Vue代码初始化的主线逻辑非常分明,使得逻辑和流程非常清楚,这种编程方法值得学习。
Vue实例挂载
实例挂载主要是$mount方法的实现,在 src/platforms/web/entry-runtime-with-compiler.js
& src/platforms/web/runtime/index.js
等文件中都有对Vue.prototype.$mount的定义:
1 | // vue/platforms/web/entry-runtime-with-compiler.js |
$mount方法进来会先进行缓存,之后再进行覆盖重写,再重写的方法里面会调用之前缓存的mount方法,这种做法是因为,多个平台platform的mount方法不同,在入口处进行重写,使后续的多入口能够复用公用定义的mount方法(原先原型上的 $mount 方法在 src/platform/web/runtime/index.js 中定义)。
在$mount方法中,会先判断options中 el 是否存在,再判断 render (有template存在的条件下也需要有render函数),之后再是再判断template,会对template做一定的校验,最后使用 compileToFunctions
将template转化为render
和 staticRenderFns
.
compileToFunctions编译过程就放在下面文章中再详细解释。
mountComponent方法定义在 src/core/instance/lifecycle.js
中,
1 | // src/core/instance/lifecycle.js |
从上面的代码可以看到,mountComponent 核心就是先实例化一个渲染Watcher(字段isRenderWatcher)
,在它的回调函数中会调用 updateComponent 方法,在此方法中调用 vm._render 方法先生成虚拟 Node,最终调用 vm._update 更新 DOM。
Watcher 在这里起到两个作用,一个是初始化的时候会执行回调函数,另一个是当 vm 实例中的监测的数据发生变化的时候执行回调函数,这块儿我们会在之后的章节中介绍。
函数最后判断为根节点的时候设置 vm._isMounted 为 true, 表示这个实例已经挂载了,同时执行 mounted 钩子函数。 这里注意 vm.$vnode 表示 Vue 实例的父虚拟 Node,所以它为 Null 则表示当前是根 Vue 的实例。
因此接下来分析的重点在于:vm._update
和 m._render
_render
Vue的_render是实例的一个私有方法,定义在 src/core/instance/render.js
文件中,返回一个虚拟节点vnode。
1 | // src/core/instance/render.js |
这段函数方法的重点在于render方法的调用,第一种是分为手写的render函数,这种并不常用,比较常用的是template模板,在之前的 mounted 方法的实现时,会将template编译为一个render函数。
其中vm._renderProxy是定义在/src/core/instance/proxy.js
文件中,判断如果支持Proxy,如果不支持,返回的是vm,支持的话返回用Proxy代理的vm。
1 | // src/core/instance/proxy.js |
其中vm.$createElement也就是在 src/core/instance/render.js
文件中:
1 | // src/core/instance/render.js |
可以从注释中看出:
vm._c是template模板编译为render function时使用的;
vm.$createElement是用户手写的render function时使用;
这两个函数的支持的参数相同,并且内部都调用了 vdom/create-element
的 createElement
方法。
Virtual DOM
在讲_update方法之前,了解下Virtual DOM到底是什么?
Virtual DOM也就是虚拟DOM,是真实数据和页面DOM元素之前的缓冲;数据一变化,并不是立马更新所有视图,而是先更新虚拟DOM,再将虚拟DOM和真实DOM进行对比diff,发生变化的部分再更新到真实DOM中,未发生变化的部分,则不进行更新。
下面是Vue对于VNode的定义:
1 | // vue/src/core/vdom/vnode.js |
实际上 Vue.js 中 Virtual DOM 是借鉴了一个开源库 snabbdom 的实现,如果对Virtual DOM感兴趣的话,可以参考virtual-dom,正如其介绍,
A JavaScript DOM model supporting element creation, diff computation and patch operations for efficient re-rendering
VNode是对真实DOM的抽象描述,主要是由几个关键属性、标签名等数据组成,并不是很复杂,主要复杂的对VNode的create、diff、patch等过程。
createElement是怎么实现的
方法入口
Vue.js通过文件 src/core/vdom/create-element.js
来创建VNode元素:
1 | // src/core/vdom/create-element.js |
重点是对于 simpleNormalizeChildren
和 normalizeChildren
的处理,基本的操作就是将树状结构的children数组打平成一维数组。
normalizeArrayChildren
也就是将createElement的第三个参数,即将children不断遍历打平,不断往res里面push数据,只要是数据Array类型就不断遍历,直到是基础类型TextNode,再进行createTextVNode进行创建。
还有对于组件Component的创建,此处先按下不讲,下文再讲。
1 | // The template compiler attempts to minimize the need for normalization by |
_update
_update这一步实际是VNode最终去生成真实DOM的过程。
对于_update方法的定义,在 src/core/instance/lifecycle.js
中:
1 | Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { |
可以看出,主要是对patch方法的调用,分别是首次渲染和数据更新的时候会调用;这次先是分析首次调用时,数据更新的部分会在之后响应式原理的时候再进行分析。
_update的主要目的就是将虚拟DOM渲染生成真实的DOM元素。
而patch方法在不同平台的调用是不同的,在浏览器中时,是patch方法,而在非浏览器环境中,比如node后端环境时,是一个noop空函数,主要也是因为只要在浏览器环境时才会有DOM元素。
文件:src/platforms/web/runtime/index.js
1 | import { patch } from './patch' |
最终 patch 调用的是 src/core/vdom/patch.js
中的 createPatchFunction
,其中有个采用闭包来判断环境的技巧,因为patch方法可能是会在 weex 或者 浏览器端 上调用,如果每次调用都 if else 判断一遍,浪费性能不说,还增加了冗余的判断。于是,它采用了通过闭包判断再返回函数覆盖 patch 的方法,这样环境差异就只会判断一次,进而再次执行的时候,就不会再次判断环境。
1 | export function createPatchFunction (backend) { |
同时,createPatchFunction
内部定义了一系列的辅助方法。
所以从例子来分析:
1 | var app = new Vue({ |
然后我们在 vm._update 的方法里是这么调用 patch 方法的:
1 | // initial render |
结合例子,在首次渲染时,所以在执行 patch 函数的时候,传入的 vm.$el 对应的是例子中 id 为 app 的 DOM 对象,这个也就是 <div id="app">
, vm.$el 的赋值是在之前 mountComponent 函数做的,vnode 对应的是调用 render 函数的返回值,hydrating 在非服务端渲染情况下为 false,removeOnly 为 false。
1 | function patch (oldVnode, vnode, hydrating, removeOnly) { |
由于我们传入的 oldVnode 实际上是一个 DOM container,所以 isRealElement 为 true,接下来又通过 emptyNodeAt 方法把 oldVnode 转换成 VNode 对象,然后再调用 createElm 方法。
1 | function createElm ( |
createElm
的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中。createComponent 方法目的是尝试创建子组件,接下来判断 vnode 是否包含 tag,如果包含,先简单对 tag 的合法性在非生产环境下做校验,看是否是一个合法标签;然后再去调用平台 DOM 的操作去创建一个占位符元素。
1 | vnode.elm = vnode.ns |
接下来是通过 createChildren
创建子元素:
1 | function createChildren (vnode, children, insertedVnodeQueue) { |
createChildren 的逻辑很简单,实际上是遍历子虚拟节点,递归调用 createElm,这是一种常用的深度优先的遍历算法,这里要注意的一点是在遍历过程中会把 vnode.elm 作为父容器的 DOM 节点占位符传入。
最后调用 insert
方法把 DOM 插入到父节点中,因为是递归调用,子元素会优先调用 insert
,所以整个 vnode 树节点的插入顺序是先子后父。来看一下 insert 方法,它的定义在 src/core/vdom/patch.js
上。
1 | insert(parentElm, vnode.elm, refElm) |
insert
逻辑很简单,调用一些 nodeOps 把子节点插入到父节点中,这些辅助方法定义在 src/platforms/web/runtime/node-ops.js
中:
1 | export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) { |
其实就是调用原生 DOM 的 API 进行 DOM 操作。
在 createElm 过程中,如果 vnode 节点不包含 tag,则它有可能是一个注释或者纯文本节点,可以直接插入到父元素中。在我们这个例子中,最内层就是一个文本 vnode,它的 text 值取的就是之前的
this.message` 的值 Hello Vue!。
再回到 patch
方法,首次渲染我们调用了 createElm
方法,这里传入的 parentElm 是 oldVnode.elm
的父元素,在我们的例子是 id 为 #app
div 的父元素,也就是 Body;实际上整个过程就是递归创建了一个完整的 DOM 树并插入到 Body 上。
最后,我们根据之前递归 createElm
生成的 vnode
插入顺序队列,执行相关的 insert
钩子函数。
这里只是分析了最简单的场景,在实际的项目中,会比这些复杂的很多。