模板渲染过程在实际使用vue的过程可能并不需要太深理解,但就vue来说,这些底层思想可以更好地让我们理解这个框架,以及了解为什么Vue的API要如此设计…
vue2+与vue1+的模板渲染过程完全不同,vue1使用的是DocumentFragment API,具体就不介绍了(可以直接跳到MDN去了解),而vue2开始则使用了Virtual DOM,基于Virtual DOM,vue2支持了服务端渲染SSR,以及JSX语法。介绍渲染流程之前,先说明两个数据结构:抽象语法树AST,以及VNode。
AST(抽象语法树)
AST 的全称是 Abstract Syntax Tree(抽象语法树),是源代码的抽象语法结构的树状表现形式,计算机学科中编译原理的概念。而vue就是将模板代码映射为AST数据结构,进行语法解析。这里采用了flow的语法,flow是一个JS静态类型检查工具。
在 Vue 中,ASTNode 分几种不同类型,关于 ASTNode 的定义在 flow/compile.js 里面,请看下图:
看一下 Vue 2.0 源码中 AST 数据结构 的定义:
declare type ASTNode = ASTElement | ASTText | ASTExpression
declare type ASTElement = { // 有关元素的一些定义
type: 1;
tag: string;
attrsList: Array<{ name: string; value: string }>;
attrsMap: { [key: string]: string | null };
parent: ASTElement | void;
children: Array<ASTNode>;
//......
}
declare type ASTExpression = {
type: 2;
expression: string;
text: string;
tokens: Array<string | Object>;
static?: boolean;
// 2.4 ssr optimization
ssrOptimizability?: number;
};
declare type ASTText = {
type: 3;
text: string;
static?: boolean;
isComment?: boolean;
// 2.4 ssr optimization
ssrOptimizability?: number;
};
我们用一个简单的例子来说明一下:
<div id="demo">
<h1>Latest Vue.js Commits</h1>
<p>{{1 + 1}}</p>
</div>
我们想一想这段代码会生成什么样的 AST 呢?
VNODE数据结构
VNODE就是vue中的虚拟dom节点,VNODE 数据结构 如下:
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
}
真实DOM存在什么问题,为什么要用虚拟DOM
我们为什么不直接使用原生 DOM 元素,而是使用真实 DOM 元素的简化版 VNode,最大的原因就是 document.createElement 这个方法创建的真实 DOM 元素会带来性能上的损失。我们来看一个 document.createElement 方法的例子
let div = document.createElement('div');
for(let k in div) {
console.log(k);
}
打开 console 运行一下上面的代码,会发现打印出来的属性多达 228 个,而这些属性有 90% 多对我们来说都是无用的。VNode 就是简化版的真实 DOM 元素,关联着真实的dom,比如属性elm,只包括我们需要的属性,并新增了一些在 diff 过程中需要使用的属性,例如 isStatic。
模板渲染流程
先来一张图:
首先从$mount开始,可以看到,mount其实就是拿到了html模板作为template,然后将这个template通过compileToFunctions方法编译成render函数:
if (template) {
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile')
}
const { render, staticRenderFns } = compileToFunctions(template, { //对获取到的template进行编译
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile end')
measure(`vue ${this._name} compile`, 'compile', 'compile end')
}
}
那么这个compileToFunctions做了什么呢?主要将 template 编译成 render 函数。首先读缓存,没有缓存就调用 compile 方法拿到 render 函数 的字符串形式,再通过 new Function 的方式生成 render 函数。
// 有缓存的话就直接在缓存里面拿
const key = options && options.delimiters
? String(options.delimiters) + template
: template
if (cache[key]) {
return cache[key]
}
const res = {}
const compiled = compile(template, options) // compile 后面会详细讲
res.render = makeFunction(compiled.render) //通过 new Function 的方式生成 render 函数并缓存
const l = compiled.staticRenderFns.length
res.staticRenderFns = new Array(l)
for (let i = 0; i < l; i++) {
res.staticRenderFns[i] = makeFunction(compiled.staticRenderFns[i])
}
......
}
return (cache[key] = res) // 记录至缓存中
现在我们具体看一下compile方法,上文中提到 compile 方法就是将 template 编译成 render 函数 的字符串形式。
export function compile (
template: string,
options: CompilerOptions
): CompiledResult {
const AST = parse(template.trim(), options) //1. parse
optimize(AST, options) //2.optimize
const code = generate(AST, options) //3.generate
return {
AST,
render: code.render,
staticRenderFns: code.staticRenderFns
}
}
这个函数主要有三个步骤组成:parse,optimize 和 generate,分别输出一个包含 AST,staticRenderFns 的对象和 render函数 的字符串。
parse 函数,主要功能是将 template字符串解析成 AST,采用了 jQuery 作者 John Resig 的 HTML Parser。前面定义了ASTElement的数据结构,parse 函数就是将template里的结构(指令,属性,标签等)转换为AST形式存进ASTElement中,最后解析生成AST。
optimize 函数(src/compiler/optimizer.js)主要功能就是标记静态节点,为后面 patch 过程中对比新旧 VNode 树形结构做优化。被标记为 static 的节点在后面的 diff 算法中会被直接忽略,不做详细的比较。
generate 函数(src/compiler/codegen/index.js)主要功能就是根据 AST 结构拼接生成 render 函数的字符串。
1. parse(解析器)
在 parse 函数中,我们先是定义了非常多的全局属性以及函数,然后调用了 parseHTML 这么一个函数,这也是 parse 最核心的函数,这个函数会不断的解析模板,填充 root,最后把 root(AST) 返回回去。
parseHTML
在这个函数中,最重要的是 while 循环中的代码,而在解析过程中发挥重要作用的有这么几个正则表达式。
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
const comment = /^<!\--/
const conditionalComment = /^<!\[/
Vue 通过上面几个正则表达式去匹配开始结束标签、标签名、属性等等。
关于 while 的详细注解我放在我仓库里了,有兴趣的可以去看看。
在 while 里,其实就是不断的去用 html.indexOf('<')
去匹配,然后根据返回的索引的不同去做不同的解析处理:
- 等于 0:这就代表这是注释、条件注释、doctype、开始标签、结束标签中的某一种
- 大于等于 0:这就说明是文本、表达式
- 小于 0:表示 html 标签解析完了,可能会剩下一些文本、表达式
parse 函数就是不断的重复这个工作,然后将 template 转换成 AST,在解析过程中,其实对于标签与标签之间的空格,Vue 也做了优化处理,有些元素之间的空格是没用的。
compile 其实要说要说非常多的篇幅,但是这里只能简单的理一下思路,具体代码还需要各位下去深扣。
2. optimize(优化器)
从代码中的注释我们可以看出,优化器的目的就是去找出 AST 中纯静态的子树:
1.把纯静态子树提升为常量,每次重新渲染的时候就不需要创建新的节点了
2.在 patch 的时候就可以跳过它们
optimize 的代码量没有 parse 那么多,我们来看看:
export function optimize (root: ?ASTElement, options: CompilerOptions) {
// 判断 root 是否存在
if (!root) return
// 判断是否是静态的属性
// 'type,tag,attrsList,attrsMap,plain,parent,children,attrs'
isStaticKey = genStaticKeysCached(options.staticKeys || '')
// 判断是否是平台保留的标签,html 或者 svg 的
isPlatformReservedTag = options.isReservedTag || no
// 第一遍遍历: 给所有静态节点打上是否是静态节点的标记
markStatic(root)
// 第二遍遍历:标记所有静态根节点
markStaticRoots(root, false)
}
下面两段代码我都剪切了一部分,因为有点多,这里就不贴太多代码了。
第一遍遍历
function markStatic (node: ASTNode) {
node.static = isStatic(node)
if (node.type === 1) {
...
}
}
其实 markStatic 就是一个递归的过程,不断地去检查 AST 上的节点,然后打上标记。
刚刚我们说过,AST 节点分三种,在 isStatic 这个函数中我们对不同类型的节点做了判断:
function isStatic (node: ASTNode): boolean {
if (node.type === 2) { // expression
return false
}
if (node.type === 3) { // text
return true
}
return !!(node.pre || (
!node.hasBindings && // no dynamic bindings
!node.if && !node.for && // not v-if or v-for or v-else
!isBuiltInTag(node.tag) && // not a built-in
isPlatformReservedTag(node.tag) && // not a component
!isDirectChildOfTemplateFor(node) &&
Object.keys(node).every(isStaticKey)
))
}
可以看到 Vue 对下面几种情况做了处理:
- 当这个节点的 type 为 2,也就是表达式节点的时候,很明显它不是一个静态节点,所以返回 false
- 当 type 为 3 的时候,也就是文本节点,那它就是一个静态节点,返回 true
- 如果你在元素节点中使用了 v-pre 或者使用了 <pre> 标签,就会在这个节点上加上 pre 为 true,那么这就是个静态节点
- 如果它是静态节点,那么需要它不能有动态的绑定、不能有 v-if、v-for、v-else 这些指令,不能是 slot 或者 component 标签、不是我们自定义的标签、没有父节点或者元素的父节点不能是带 v-for 的 template、 这个节点的属性都在 type,tag,attrsList,attrsMap,plain,parent,children,attrs 里面,满足这些条件,就认为它是静态的节点。
接下来,就开始对 AST 进行递归操作,标记静态的节点,至于里面做了哪些操作,可以到上面那个仓库里去看,这里就不展开了。
第二遍遍历
第二遍遍历的过程是标记静态根节点,那么我们对静态根节点的定义是什么,首先根节点的意思就是他不能是叶子节点,起码要有子节点,并且它是静态的。在这里 Vue 做了一个说明,如果一个静态节点它只拥有一个子节点并且这个子节点是文本节点,那么就不做静态处理,它的成本大于收益,不如直接渲染。
同样的,我们在函数中不断的递归进行标记,最后在所有静态根节点上加上 staticRoot 的标记,关于这段代码也可以去上面的仓库看一看
3. generate(代码生成器)
在这个函数中,我们将 AST 转换成为 render 函数字符串,代码量还是挺多的,我们可以来看一看。
export function generate (
ast: ASTElement | void,
options: CompilerOptions
): CodegenResult {
// 这就是编译的一些参数
const state = new CodegenState(options)
// 生成 render 字符串
const code = ast ? genElement(ast, state) : '_c("div")'
return {
render: `with(this){return ${code}}`,
staticRenderFns: state.staticRenderFns
}
}
可以看到在最后代码生成阶段,最重要的函数就是 genElement 这个函数,针对 AST 的属性(不同的指令、属性),我们会选择不同的代码生成函数。最后我们按照 AST 生成拼接成一个字符串,如下所示:
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)
} else if (el.for && !el.forProcessed) {
return genFor(el, state)
} 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 {
const data = el.plain ? undefined : genData(el, state)
const children = el.inlineTemplate ? null : genChildren(el, state, true)
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
}
}
在 render 这个函数字符串中,我们会看到一些函数,那么这些函数是在什么地方定义的呢?我们可以在 core/instance/index.js 这个文件中找到这些函数:
// v-once
target._o = markOnce
// 转换
target._n = toNumber
//把一个值转换为字符串。(eg: {{data}})
target._s = toString
// v-for
target._l = renderList
// slot
target._t = renderSlot
// 是否相等
target._q = looseEqual
// 检测数组里是否有相等的值
target._i = looseIndexOf
// 渲染静态树
target._m = renderStatic
// 过滤器处理
target._f = resolveFilter
// 检查关键字
target._k = checkKeyCodes
// v-bind
target._b = bindObjectProps
// 创建文本节点
target._v = createTextVNode
// 创建空节点
target._e = createEmptyVNode
// 处理 scopeslot
target._u = resolveScopedSlots
// 处理事件绑定
target._g = bindObjectListeners
// 创建 VNode 节点
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
生成 render 的 generate 函数的输入也是 AST,它递归了 AST 树,为不同的 AST 节点创建了不同的内部调用方法,等待后面的调用。生成 render 函数的过程如下:
假设我们有这么一段 template
<template>
<div id="test">
{{val}}
<img src="http://xx.jpg">
</div>
</template>
最终会被转换成这样子的函数字符串
{render: "with(this){return _c('div',{attrs:{"id":"test"}},[[_v(_s(val))]),_v(" "),_m(0)])}"}
以上就是 compile 函数中三个核心步骤的介绍,compile之后我们得到了 render 函数 的字符串形式,后面通过 new Function 得到真正的渲染函数。数据发现变化后,会执行 Watcher 中的 _update 函数(src/core/instance/lifecycle.js),_update 函数会执行这个渲染函数,输出一个新的 VNode 树形结构的数据。然后在调用 patch 函数,拿这个新的 VNode 与旧的 VNode 进行对比,只有发生了变化的节点才会被更新到真实 DOM 树上。
mount后续
通过compile生成render方法之后,会进一步执行mount方法,在$mount中可以看到最后一句话:return mount.call(this, el, hydrating),这个mount实际上就是runtime中的mount,执行的就是lifecycle中的mountComponent方法,看一下基本逻辑:
// 触发 beforeMount 生命周期钩子
callHook(vm, 'beforeMount')
let updateComponent //updateComponent是watcher更新时的回调,用于更新视图操作
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
updateComponent = () => {
const name = vm._name
const id = vm._uid
const startTag = `vue-perf-start:${id}`
const endTag = `vue-perf-end:${id}`
mark(startTag)
const vnode = vm._render()
mark(endTag)
measure(`vue ${name} render`, startTag, endTag)
mark(startTag)
vm._update(vnode, hydrating)
mark(endTag)
measure(`vue ${name} patch`, startTag, endTag)
}
} else {
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}
// 以前是直接new Watch赋值给vm._watcher,现在这一步放到了watcher的构造函数中
// we set this to vm._watcher inside the watcher's constructor
// since the watcher's initial patch may call $forceUpdate (e.g. inside child
// component's mounted hook), which relies on vm._watcher being already defined
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
hydrating = false
// 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
首先会new一个watcher对象(主要是将模板与数据建立联系),在watcher对象创建后,会运行传入的方法 vm._update(vm._render(), hydrating) 。其中的vm._render()主要作用就是运行前面compiler生成的render方法,并返回一个vNode对象。vm.update() 则会对比新的 vdom 和当前 vdom,并把差异的部分渲染到真正的 DOM 树上。
patch
patch.js 就是新旧 VNode 对比的 diff 函数,主要是为了优化dom,通过算法使操作dom的行为降到最低,diff 算法来源于 snabbdom,是 VDOM 思想的核心。snabbdom 的算法为了 DOM 操作跨层级增删节点较少的这一目标进行优化,它只会在同层级进行, 不会跨层级比较。