前言
从第二章可以看出,_update 方法的调用时机有两个
- 首次渲染
- 数据更新
这里只看第一种情况,第二种在后面看响应式相关源码的时候会涉及到。
_update
代码在:\src\core\instance\lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this // vm实例
const prevEl = vm.$el // 真实dom
const prevVnode = vm._vnode
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode
// Vue.prototype.__patch__ 是在入口处注入的
if (!prevVnode) {
// 首次渲染
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 数据更新
vm.$el = vm.__patch__(prevVnode, vnode)
}
// ... 省略代码 ...
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这个方法中的前几行
const prevVnode = vm._vnode
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode2
3
还有下面省略的代码,都是和第二种情况“数据更新”时有关,这里可以先不看。
核心代码是
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)vm.__patch__
__patch__ 方法是在Vue入口处就注入了,挂载到了prototype上
代码在:\src\platforms\web\runtime\index.js
import { patch } from './patch'
// ... 省略代码 ...
// 判断是在浏览器环境还是服务端。如果在服务端,没有dom,所以返回空函数
Vue.prototype.__patch__ = inBrowser ? patch : noop2
3
4
5
6
我们是在浏览器端开发,所以返回的是 patch
对应代码在:\src\platforms\web\runtime\patch.js
import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'
// 指令模块应该最后应用,在所有内置模块都应用之后
const modules = platformModules.concat(baseModules)
// 函数科里化,将nodeOps, modules在此处传入,返回一个真正给VNode使用的patch函数
// 因为VueJs是个跨平台的库,不止可以在浏览器端使用,也可以在weex,或者更多环境下使用
// 每个环境对应的DOM API肯定是不一样的,从源码项目的目录结构就能看出来
// 一个平台一个单独的文件夹去管理
export const patch: Function = createPatchFunction({ nodeOps, modules })2
3
4
5
6
7
8
9
10
11
12
13
这个文件的代码很少,主要作用就是返回一个方法,方法名叫 patch,而 createPatchFunction 这个方法就相当于一个工厂模式,当传入你所在的环境的相关配置后,会返回一个能让你使用的 patch 方法。
代码中的 nodeOps 模块其实就是原生的dom操作方法,比如
export function createElement (tagName: string, vnode: VNode): Element {
const elm = document.createElement(tagName)
if (tagName !== 'select') {
return elm
}
// false or null will remove the attribute but undefined will not
if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {
elm.setAttribute('multiple', 'multiple')
}
return elm
}
// ... 省略代码 ...
export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
parentNode.insertBefore(newNode, referenceNode)
}
// ... 省略代码 ...2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
baseModules 和 platformModules 模块定义的是一些 Snabbdom 在 patch 过程中的钩子,上一章提到,VueJs的virtual dom是基于 Snabbdom 实现的,而 Snabbdom 会在patch的过程中执行某些特定的钩子,类似Vuejs中的生命周期,会在生成和销毁时触发钩子一样。
看命名就知道,baseModules 定义的是基础的钩子;
因为当前是浏览器环境,所以当前的 platformModules 定义的是 VDOM 的一些基础属性,比如 attrs、class、events、style 等。
接下来看一下 createPatchFunction 函数
createPatchFunction
代码在:\src\core\vdom\patch.js
// Snabbdom在patch过程中的钩子
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
// ... 省略代码 ...
export function createPatchFunction (backend) {
let i, j
const cbs = {}
const { modules, nodeOps } = backend
// 将当前所有模块的钩子存入对应的钩子的key中,放入cbs
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
if (isDef(modules[j][hooks[i]])) {
cbs[hooks[i]].push(modules[j][hooks[i]])
}
}
}
// ... ...
// ... 省略一大堆代码 ...
// ... ...
// 利用闭包,将当前环境下的 modules和nodeOps缓存起来
// 这样调用patch的时候就不用再传入对应的modules和nodeOps数据
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// 在首次渲染时,oldVnode就是真实的dom
// vnode就是对应的 virtual dom
// 后两个参数都是 false
// ... 省略代码 ...
let isInitialPatch = false
const insertedVnodeQueue = [] // 映射钩子
if (isUndef(oldVnode)) {
// ... 省略代码 ...
} else {
// 首次渲染,是真实的dom,isRealElement是true
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
if (isRealElement) {
// ... 省略代码 ...
// 首次渲染,将真实dom转VNode
oldVnode = emptyNodeAt(oldVnode)
}
const oldElm = oldVnode.elm // 替换已经存在的dom
const parentElm = nodeOps.parentNode(oldElm)
// 创建新的node,将VNode挂载到真实的dom上
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// 跟组件相关
if (isDef(vnode.parent)) {
// ... 省略代码 ...
}
// 如果已经有了节点,就删掉
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
// ... 省略代码 ...
return vnode.elm
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
这个方法内容很长,里面包含了很多辅助函数
重点只看这个函数返回的内容,返回了一个patch方法,结合上文的
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)可以知道,这个patch方法的第一个参数oldVnode就是真实的dom,vnode就是对应的 virtual dom,后两个参数都是 false。
所以,根据这几个参数,往下走,走到了 oldVnode = emptyNodeAt(oldVnode),这个emptyNodeAt方法就是在代码中 “省略一大堆代码” 的地方:
// 真实DOM 转换成 VNode
function emptyNodeAt (elm) {
return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}2
3
4
转换成VNode后,获取父节点,本文讲的是首次渲染,所以这里的父节点即:body。
然后是上面代码中,实现数据驱动最核心的代码,调用 createElm,将VNode渲染到真实DOM上。
createElm
这个 createElm 方法也是在上面代码中 “省略一大堆代码” 的地方
function createElm (
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
// ... 省略代码 ...
const data = vnode.data
const children = vnode.children
const tag = vnode.tag // 首次渲染时,这里是 div
if (isDef(tag)) {
if (process.env.NODE_ENV !== 'production') {
// ... 省略代码 ...
if (isUnknownElement(vnode, creatingElmInVPre)) {
warn(
'Unknown custom element: <' + tag + '> - did you ' +
'register the component correctly? For recursive components, ' +
'make sure to provide the "name" option.',
vnode.context
)
}
}
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
setScope(vnode)
if (__WEEX__) {
// ... 省略代码 ...
} else {
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue)
}
insert(parentElm, vnode.elm, refElm)
}
// ... 省略代码 ...
} else if (isTrue(vnode.isComment)) {
// ... 省略代码 ...
} else {
// 没有标签 就当做文本节点
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
结合上文调用函数的参数,可以知道:
第一个参数 vnode 是要渲染的真实dom对应的VNode, 第二个参数 insertedVnodeQueue 先不用管 第三个参数 parentElm 是第一个参数对应的父节点,本文指 body
那么根据参数信息看代码的逻辑,走到了
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)2
3
这里先不管vode.ns,于是变成:vnode.elm = nodeOps.createElement(tag, vnode)
nodeOps.createElement就是上文提到的浏览器端对应的VNode的属性/方法,createElement其实就是调用原生DOM的 createElement
继续往下走,到了 createChildren,这个方法的作用就是递归调用 createElm,期间会去检查VNode的key属性是否有重复。
执行完后,判断如果vnode的data有值,就执行 invokeCreateHooks,这个方法的作用是执行所有的 create 的钩子并把 vnode push 到 insertedVnodeQueue 中。
function invokeCreateHooks (vnode, insertedVnodeQueue) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, vnode)
}
i = vnode.data.hook // Reuse variable
if (isDef(i)) {
if (isDef(i.create)) i.create(emptyNode, vnode)
if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
}
}2
3
4
5
6
7
8
9
10
再往下,最后执行 insert(parentElm, vnode.elm, refElm),
// 真实插入dom的核心方法
function insert (parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) {
if (nodeOps.parentNode(ref) === parent) {
nodeOps.insertBefore(parent, elm, ref)
}
} else {
nodeOps.appendChild(parent, elm)
}
}
}2
3
4
5
6
7
8
9
10
11
12
insert 判断是否有参考元素,有的话就用 insertBefore,没有就直接 appendChild,nodeOps 里的操作都是原生的DOM操作。
因为上面的createChildren方法是递归调用,子元素会优先调用 insert,所以整个 vnode 树节点的插入顺序是先子后父。
回到开头,当我们调用
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)就会返回 patch方法的执行结果
// ... 省略代码 ...
export function createPatchFunction (backend) {
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// ... 省略代码 ...
return vnode.elm
}
}2
3
4
5
6
7
也就是 vnode.elm。在此期间调用dom原生的 createElement、insertBefore、appendChild 等等,将VNode转换为DOM。
到此,就完成了从 VNode 到 真实DOM 的挂载。
总结
到此已经将 “模板和数据如何渲染成DOM” 的最基础情况分析完成。
实际应用中会更复杂更绕,所以先搞懂这个一条最基础的主干流程,对接下来的其他模块的分析会有很大帮助。
以上步骤结合 Chrome dev tool 更加直观