前言
- 告诉我自己:Vue是个考虑周全的框架,细枝末节做了太多处理,我只需要关注主线的逻辑原理,别钻牛角尖
- 告诉自己:别扯别的,我就想知道一个问题,怎么从template到render到vnode到真实DOM的
- 从template到render 整个parse过程 已经施工完毕
- 还是结合实际例子,不搞复杂例子
- 参考: ustbhuangyi.github.io/vue-analysi…
从例子出发
<div id="app">
{{ message }}
</div>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
这篇只关心目标是弄清楚模板和数据如何渲染成最终的 DOM,这个初始化patch逻辑,不考虑视图更新的相关的patch逻辑。
$mount
在vue初始化过程的最后会调用 $mount进行实例挂载, 这个是入口, 主要做了:
- 缓存了原型链上的$mount,这个方法是不带compiler的挂载方法, 中间经过编译生成了render函数后再调用他.
- 对配置项进行了一个优雅降级: 用户直接提供了render函数那就省的编译了, 如果没有定义
render方法,则会把el或者template字符串转换成render方法, 反正最后都得到了render方法, 本篇不关注编译过程, 整个parse过程 生成render函数如下:
with (this) {
return _c('div', { attrs: { id: 'app' } }, [
_v('\n' + _s(message) + '\n')
])
}
拿到了挂在vm的options上面
const { render, staticRenderFns } = compileToFunctions
options.render = render
options.staticRenderFns = staticRenderFns
- 然后在调用刚刚缓存的mount函数
return mount.call(this, el, hydrating), 就开始了挂载流程
实际上Vue.prototype.$mount调用了mountComponent方法
mountComponent
核心就是updateComponent回调函数, 涉及两个核心的方法vm._render 和 vm._update。
vm._render: render函数生成 VNode
vm._update: VNode 渲染成真实的 DOM
// 最核心的 2 个方法:`vm._render` 和 `vm._update`。
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// `Watcher` 在这里起到两个作用,一个是初始化的时候会执行回调函数,
// 另一个是当 vm 实例中的监测的数据发生变化的时候执行回调函数
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
在这里小结一下:
- Vue在实例化的时候, 调用完众多的initXXX之后, 会调用
vm.$mount(vm.$options.el), 进入挂载过程 - 检查是否提供了render函数 (要么手写到options里面, 要么通过loader编译得到), 要是没有就进行parse过程编译render.
- render 函数挂载vm.$options 上面 把 vm 扔给 mountComponent
- mountComponent 构建一个回调函数: 把render转VNode, 把VNode转DOM, 新建一个Watcher, 把这个回调函数交给这个Watcher (渲染watcher), Watcher初始化调用回调函数, 生成VNode, 再挂载DOM
OK 这就是主体框架, 现在来深究 1. vm._render: render函数生成 VNode 2. vm._update: VNode 渲染成真实的 DOM
render函数生成 VNode
vm._render
其实这里就做了一个核心的代码,其他都是异常捕获,错误处理啥的
const { render } = vm.$options
// ...
vnode = render.call(vm._renderProxy, vm.$createElement)
// ...
return vnode
也就是把之前挂载在vm.createElement, 也就是createElement才是实现业务的核心.
createElement
定义
// internal version is used by render functions compiled from templates
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// normalization is always applied for the public version, used in
// user-written render functions.
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
看注释, 一个是给编译生成的render函数用的, 一个是给手写的render函数用的, 其实都调用了createElement.
createElement 方法实际上是对 _createElement的封装, 咱简单点, 就直接以后还是说createElement.
对于我们的例子
_c('div', { attrs: { id: 'app' } }, [ _v('\n' + _s(message) + '\n') ])
- _c : createElement
- tag : 'div'
- data : { attrs: { id: 'app' }
- children: [ _v('\n' + _s(message) + '\n') ]
children 的规范化
毛主席说抓主要矛盾,我主要分析 2 个重点的流程 : children 的规范化以及 VNode 的创建
下面是createElement 中 children规范化的代码片段
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children)
}
simpleNormalizeChildren 方法调用场景是 render 函数是编译生成的, 这时候Children已经是一个VNode数组, 就像我们的例子, 就是这种情况, 因为是模板编译好的render函数
_c( 'div',
{ attrs: { id: 'app' } },
[_v('\n' + _s(message) + '\n')]
)
会先执行一下 _s _v
target._s = toString
target._v = createTextVNode 把children变成一个 VNode Array 后才传入_c, 所以可以跳过规范化过程 特别的: 针对functional component 还是会生成嵌套数组, 需要展开, 于是还是要调用
simpleNormalizeChildren
normalizeChildren 方法的调用场景有 2 种
-
render函数是用户手写的,当children只有一个节点的时候,Vue.js 从接口层面允许用户把children写成基础类型用来创建单个简单的文本节点,这种情况会调用createTextVNode创建一个文本节点的 VNode; - 另一个场景是当编译
slot、v-for的时候会产生二维数组, 这个时候调用normalizeArrayChildren
function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
const res = []
let i, c, lastIndex, last
// 遍历每个 c 进行检查
for (i = 0; i < children.length; i++) {
c = children[i]
if (isUndef(c) || typeof c === 'boolean') continue
// 这里的目的是记录res这个 Array<VNode> 的最后一个元素, 合并文字结点用
lastIndex = res.length - 1
last = res[lastIndex]
// 如果 c 是数组 递归调用
if (Array.isArray(c)) {
if (c.length > 0) {
c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
// merge adjacent text nodes
if (isTextNode(c[0]) && isTextNode(last)) {
res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
c.shift()
}
res.push.apply(res, c)
}
} else if (isPrimitive(c)) {
// 如果是基础类型,则通过 createTextVNode 方法转换成 VNode 类型
if (isTextNode(last)) {
// 优化 如果上个结点就是text类型, 那么合并这两个结点
res[lastIndex] = createTextVNode(last.text + c)
} else if (c !== '') {
res.push(createTextVNode(c))
}
} else {
// 否则 c 就已经是一个 VNode类型了
if (isTextNode(c) && isTextNode(last)) {
// 还是检查一下文字结点进行合并
res[lastIndex] = createTextVNode(last.text + c.text)
} else {
// ...
// 最后就是新的节点啦, push进入res
res.push(c)
}
}
}
return res
}
经过对 children 的规范化,children 变成了一个类型为 VNode 的 Array。
VNode的创建
规范化了children 保证得到了一个 Array, 就可以分情况创建VNode
if (typeof tag === 'string') :
if如果tag属于Vue内置结点 直接创建 普通VNodeelse if是已经注册的组件名,createComponent创建组件类型的VNodeelse根据tag 直接创建一个未知 VNode
如果是 tag 一个 Component 类型:
vnode = createComponent(tag, data, context, children)
VNode 到 DOM
_update
_update 是实例的一个私有方法,它被调用的时机有 2 个,一个是首次渲染,一个是数据更新的时候;本文只设计首次渲染过程. 函数就一个目的, 把VNode渲染成真实DOM
if (!prevVnode) {
// initial render
// function patch (oldVnode, vnode, hydrating, removeOnly)
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
_update 的核心就是调用 vm.__patch__ 方法,初次渲染的时候, 用真实dom作为oldVnode, 然后patch过程就会进行初始化的逻辑
// 第一次渲染调用了这个逻辑
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
patch
初始化
因为weex与web平台的patch方法有差异, 所以Vue用了函数柯里化的方式, 首先给平台写了自己的每个平台都有各自的 nodeOps(一些平台DOM的操作方法) 和 modules(平台的模块比如钩子函数), 用 createPatchFunction 生成平台的patch函数
主要逻辑
patch 有很多核心逻辑, 比如更新的diff算法, 这篇还是只研究初始化过程
之前说过, 调用patch的时候, 把dom对象作为第一个参数(oldVnode传入)
// 通过一个属性判断是否传入了dom对象
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
if (isRealElement) {
// ... 这里不重要直接省略
oldVnode = emptyNodeAt(oldVnode)
}
// 替换一些变量
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
sameVnode, patchVnode就是更新视图有关的逻辑了, 这里不命中, 会直接命中else分支, 把dom对象转换为一个Vnode, 赋值给oldVnode
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
}
createElm
function createElm (
vnode, // 需要生成dom的Vnode
insertedVnodeQueue,
parentElm, // 父容器的占位符, 生成dom后需要insert到上面
refElm,
nested,
ownerArray,
index
)
重点了xdm, createElm 的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中
这个标题下都是这个函数的代码, 但是拆开说不重要的就不贴了
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return
}
createComponent 方法目的是尝试创建子组件, 这个是另一个故事了, 这里我们的例子不会命中true
if (isDef(tag)) {
// ... tag的校验
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
首先 在vnode存在 tag的时候 , 一番参数校验先略去, 这里调用了 nodeOps.createElement 方法, 其实就是平台的创建dom的api, web平台就是封装了的createElement, 到这里 vnode.elm 就指向了一个新建的dom元素
if (__WEEX__) {
// ...
} else {
// 实际上是遍历子虚拟节点,递归调用 `createElm`
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue)
}
insert(parentElm, vnode.elm, refElm)
}
createChildren先对孩子结点递归调用了createELm, 把父亲的vnode.elm 作为 parentElm 传入,这样子子孙孙都会挂载成一棵dom树, 妙哉!
function insert (parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) {
if (ref.parentNode === parent) {
nodeOps.insertBefore(parent, elm, ref)
}
} else {
nodeOps.appendChild(parent, elm)
}
}
}
只要看到nodeOps.xxx 就想到web平台的dom api, 对应上就好了, 所以其实insert就是把子结点插入到父节点身上
上面这些是有tag的case, 如果没有tag
else if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
} else {
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
要么是注释结点, 要么是文本结点, 直接调用dom api生成就好了
总结
- Vue在实例化的时候, 调用完众多的initXXX之后, 会调用
vm.$mount(vm.$options.el), 进入挂载过程 - 检查是否提供了render函数 (要么手写到options里面, 要么通过loader编译得到), 要是没有就进行parse过程编译render.
- render 函数挂载vm.$options 上面 把 vm 扔给 mountComponent
- mountComponent 构建一个回调函数: 把render转VNode, 把VNode转DOM, 新建一个Watcher, 把这个回调函数交给这个Watcher (渲染watcher), Watcher初始化调用回调函数, 生成VNode, 再挂载DOM
- render到Vnode的入口是 vm._render , 调用的主要逻辑是
createElement:- 处理children为Vnode的数组
- 创建 VNode
- Vnode到DOM的入口时 vm._update, 调用patch后patch发现是新结点, 转调核心逻辑函数
createElm:- 调用 dom api 生成 vnode的 dom 对象
- 对其子孙递归调用 createElm
- 将生成的dom树挂载到父节点身上