vue 模板的编译到渲染,结合源码的分析介绍从 template 到 AST,到 VNode(虚拟 DOM),再将 VNode 挂载渲染成真是的 DOM。本文从思路流程方面分析模板编译的整个过程,不着重一字一句的具体代码解读。这个过程的代码比较机械枯燥,可以参看文末的参考链接。
从 vue 模板到渲染成 dom,整个流程如图:
整体而言,Vue 的处理方式大致分为三步:
- 将模板进行解析,得到一棵抽象语法树 AST(parse / optimize)
- 根据抽象语法树 AST 得到虚拟 DOM 树(generate / render)
- 将虚拟 DOM 渲染为真实的 DOM
注:抽象语法树(AST)是指对源码进行解析后形成的树状的语法结构。通俗地理解,有了 AST 以后,后续处理可以直接在树结构上进行,不用再处理源码中的各种书写格式、括号优先级等问题。
实现编译的核心源码入口:
javascript
// src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
const ast = parse(template.trim(), options)
if (options.optimize !== false) {
optimize(ast, options)
}
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
})
步骤一 将模板进行解析为抽象语法树 AST
parse() 方法是将源码转换成 AST 的方法,它的核心是调用 parseHTML() 方法解析。相关源码位于 src/compiler/parser/index.js。
parseHTML() 做的事就是从头扫描 HTML 字符串,按一定的规则判断当前字符是什么(标签、属性、文本、注释等等),并调用相应的回调方法,从而完成 HTML 字符串的解析,并返回 AST 树。
示例:
html
<!-- 模板 template -->
<div class="counter">
<div>{{ count }}</div>
<button @click="increment">+</button>
</div>
经过 parse / parseHTML 解析后生成的 AST 是这样的:
javascript
{
attrsList: [],
attrsMap: {
class: "counter"
},
children: [{
attrsList: [],
attrsMap: {},
children: [{
end: 37,
expression: "_s(count)",
start: 26,
text: "{{ count }}",
tokens: [{
@binding: "count"
}]
}],
end: 43,
parent: {...},
plain: true,
rawAttrsMap: {},
start: 21,
tag: "div",
type: 1,
},{
attrsList: [{
end: 69,
name: "@click",
start: 51,
value: "increment",
}],
attrsMap:{
@click: "increment",
},
children: [{
end: 71,
start: 70,
text: "+",
type: 3 // text
}],
end: 80,
events:{
click:{
dynamic: false,
end: 69,
start: 51,
value: "increment"
}
},
hasBindings: true,
parent: {...},
plain: false,
rawAttrsMap:{
@click:{
end: 69,
name: "@click",
start: 51,
value: "increment",
}
},
start: 43,
tag: "button",
type: 1
}],
end: 86,
parent: undefined,
plain: false,
rawAttrsMap:{
class:{
end: 20,
name: "class",
start: 5,
value: "counter"
}
}
start: 0,
staticClass: "\"counter\"",
tag: "div",
type: 1
}
这一步之后,进行 optimize() 处理。
optimize() 是对 AST 进行优化的过程,以提升后续渲染性能。这个方法位于 src/compiler/optimizer.js,作用是分析出纯静态的 DOM
(不含表达式,可以直接渲染的 DOM),将它们放入常量中,在后续 patch 的过程中可以忽略它们。
optimize() 处理逻辑,当一个元素有表达式时肯定就不是静态的,当一个元素是文本节点时,肯定是静态的,如果子元素是非静态的,则父元素也是非静态的。
optimize() 处理后的 AST:
javascript
{
attrsList: [],
attrsMap: {
class: "counter"
},
children: [{
attrsList: [],
attrsMap: {},
children: [{
end: 37,
expression: "_s(count)",
start: 26,
// 看这里
static: false,
text: "{{ count }}",
tokens: [{
@binding: "count"
}]
}],
end: 43,
parent: {...},
plain: true,
rawAttrsMap: {},
// 看这里
static: false,
staticRoot: false,
start: 21,
tag: "div",
type: 1,
},{
attrsList: [{
end: 69,
name: "@click",
start: 51,
value: "increment",
}],
attrsMap:{
@click: "increment",
},
children: [{
end: 71,
start: 70,
// 看这里
static: true,
text: "+",
type: 3
}],
end: 80,
events:{
click:{
dynamic: false,
end: 69,
start: 51,
value: "increment"
}
},
hasBindings: true,
parent: {...},
plain: false,
rawAttrsMap:{
@click:{
end: 69,
name: "@click",
start: 51,
value: "increment",
}
},
start: 43,
// 看这里
static: false,
staticRoot: false,
tag: "button",
type: 1
}],
end: 86,
parent: undefined,
plain: false,
rawAttrsMap:{
class:{
end: 20,
name: "class",
start: 5,
value: "counter"
}
}
start: 0,
// 看这里
static: false,
staticClass: "\"counter\"",
staticRoot: false,
tag: "div",
type: 1
}
步骤二 将 AST 树转化为 VNode (虚拟 DOM 树)
实现编译的核心源码入口:
javascript
// src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
const ast = parse(template.trim(), options)
if (options.optimize !== false) {
optimize(ast, options)
}
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
})
在 parse() 和 optimize() 运行完之后,执行 generate():
javascript
// src\compiler\codegen\index.js
export function generate (
ast: ASTElement | void,
options: CompilerOptions
): CodegenResult {
const state = new CodegenState(options)
const code = ast ? genElement(ast, state) : '_c("div")'
return {
render: `with(this){return ${code}}`,
staticRenderFns: state.staticRenderFns
}
}
export function genElement (el: ASTElement, state: CodegenState): string {
//对一些标签属性的处理
if (el.staticRoot && !el.staticProcessed) {
return genStatic(el, state)
} else if (el.once && !el.onceProcessed) {
return genOnce(el, state) // 处理v-once
} else if (el.for && !el.forProcessed) {
return genFor(el, state) // 处理v-for
} else if (el.if && !el.ifProcessed) {
return genIf(el, state)
} else if (el.tag === 'template' && !el.slotTarget) {
return genChildren(el, state) || 'void 0'
} else if (el.tag === 'slot') {
return genSlot(el, state)
} else {
// component or element
let code
//组件的处理
if (el.component) {
code = genComponent(el.component, el, state)
} else {
//核心的body部分
//1、生成节点的数据对象data的字符串
const data = el.plain ? undefined : genData(el, state)
//2、查找其子节点,生成子节点的字符串
const children = el.inlineTemplate ? null : genChildren(el, state, true)
//3、将tag,data,children拼装成字符串
code = `_c('${el.tag}'${
data ? `,${data}` : '' // data
}${
children ? `,${children}` : '' // children
})`
}
// module transforms
for (let i = 0; i < state.transforms.length; i++) {
code = state.transforms[i](el, code)
}
return code
}
generate() 返回的 render 表达式结构像这样:
javascript
_c(
// 1、标签
'div',
//2、模板相关属性的数据对象
{
...
},
//3、子节点,循环其模型
[
_c(...)
]
javascript
<template>
<div id="app">
<h1>Hello</h1>
<span>{{message}}</span>
</div>
</template>
// 上述dom 对应的虚拟 render 表达式
with(this){
return _c('div',{
attrs:{"id":"app"}
},
[
_c('h1',[
_v("Hello")
]),
_c('span',[
_v(_s(message))
])
])
}
示例中出现较多的方法是_c(),即 vm._c(),这个方法本质是 createElement() 的封装(源码在src\core\instance\render.js)。除此之外,在 render() 方法中,还有可能出现_v()、_s()、_h()、_m() 等诸多辅助方法。
javascript
// src/core/instance/render-helpers/index.js
export function installRenderHelpers (target: any) {
target._o = markOnce // 处理 v-once
target._n = toNumber // 处理修饰符.number <input v-model.number="age" type="number">
target._s = toString // ......
target._l = renderList
target._t = renderSlot // 处理 slot
target._q = looseEqual
target._i = looseIndexOf
target._m = renderStatic
target._f = resolveFilter
target._k = checkKeyCodes
target._b = bindObjectProps
target._v = createTextVNode
target._e = createEmptyVNode
target._u = resolveScopedSlots
target._g = bindObjectListeners
在后续组件进行挂载时,render 方法会被调用,这些辅助方法会将 render 转化为虚拟 DOM(VNode)。
虚拟 DOM(VNode)是什么样的?
可看一下 VNode 类:
javascript
export default class VNode {
tag: string | void;
data: VNodeData | void;
children: ?Array<VNode>;
text: string | void;
elm: Node | void;
ns: string | void;
context: Component | void; // rendered in this component's scope
key: string | number | void;
componentOptions: VNodeComponentOptions | void;
componentInstance: Component | void; // component instance
parent: VNode | void; // component placeholder node
// strictly internal
raw: boolean; // contains raw HTML? (server only)
isStatic: boolean; // hoisted static node
isRootInsert: boolean; // necessary for enter transition check
isComment: boolean; // empty comment placeholder?
isCloned: boolean; // is a cloned node?
isOnce: boolean; // is a v-once node?
asyncFactory: Function | void; // async component factory function
asyncMeta: Object | void;
isAsyncPlaceholder: boolean;
ssrContext: Object | void;
fnContext: Component | void; // real context vm for functional nodes
fnOptions: ?ComponentOptions; // for SSR caching
devtoolsMeta: ?Object; // used to store functional render context for devtools
fnScopeId: ?string; // functional scope id support
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag
this.data = data
this.children = children
this.text = text
this.elm = elm
this.ns = undefined
this.context = context
this.fnContext = undefined
this.fnOptions = undefined
this.fnScopeId = undefined
this.key = data && data.key
this.componentOptions = componentOptions
this.componentInstance = undefined
this.parent = undefined
this.raw = false
this.isStatic = false
this.isRootInsert = true
this.isComment = false
this.isCloned = false
this.isOnce = false
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
// DEPRECATED: alias for componentInstance for backwards compat.
/* istanbul ignore next */
get child (): Component | void {
return this.componentInstance
}
}
说白了,虚拟 DOM 就是一种使用 JS 数据结构模拟 DOM 元素及其关系的方法。
DOM 操作之所以慢,主要有两方面原因:
- DOM 操作属于使用 JavaScript 调用浏览器提供的接口的过程,并不都是在 JS 引擎中直接完成,中间有不少的性能开销
- DOM 本身非常庞大,属性和方法极多,构建、销毁、修改都有比较大的性能开销
使用 VNode 不再需要关注 DOM 元素所有的属性和方法,仅仅只需要关注元素类型、属性、子内容等即可,因此 VNode 可以解决上述两个导致 DOM 操作慢的问题。
除此之外,VNode 元素之间也会形成和 DOM 树类似的树状结构,开发者可以将 DOM 元素的对比、变更提前到 VNode 层面去完成,直到 VNode 完成变更以后,计算出发生变动的 VNode,最后再根据这些 VNode 去进行真实 DOM 元素的变更。这样就可以大大减少需要进行的 DOM 操作,从而提升性能。
虚拟 DOM(VNode) 因为是纯 JavaScript 数据结构,因此具有很好的跨平台性。
步骤三 挂载,将虚拟 DOM 渲染为真实的 DOM
Vue.prototype. <math xmlns="http://www.w3.org/1998/Math/MathML"> m o u n t 定义在 p l a t f o r m s / w e b / r u n t i m e / i n d e x . j s , mount 定义在 platforms/web/runtime/index.js, </math>mount定义在platforms/web/runtime/index.js,mount 本质上是调用了 mountComponent()
。
javascript
// core/instance/lifecycle.js
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
// 省略一大段对render的判断
callHook(vm, 'beforeMount')
let updateComponent
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// 定义Watcher
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true)
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
vm._update() 的第一个参数 render 方法返回的虚拟 DOM,其负责将虚拟 DOM 渲染到真实的 DOM 中。
javascript
// core/instance/lifecycle.js
// _update() 主要源码
const prevVnode = vm._vnode
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
这段代码首先判断了 vm._vnode
是否存在,如果不存在,则说明这个组件是初次渲染,否则说明之前渲染过,这一次渲染是需要进行更新。针对这两种情况,分别用不同的参数调用了 __patch__()
方法:
- 如果是初次渲染,第一个参数是真实的 DOM 元素,后续使用 createElm() 根据虚拟 DOM 创建新 DOM。
- 如果不是初次渲染,第一个参数是前一次渲染的虚拟 DOM,后续调用 patchVnode() 方法,进入虚拟 DOM 对比和更新的流程
最底层的处理逻辑,还是通过原始的 dom 操作方法 insertBefore、appendChild 等处理渲染的。