Vue源码解读:04模板编译篇
目录
第一节·先看目录结构
本篇研究的代码位置:src/compiler
├─compiler # 实现模板编译的代码
│ ├─codegen # 代码生成器,将ast树生成render渲染函数。
│ │ ├─events.js # 事件处理相关代码,例如键盘事件,stopPropagation()事件等。
│ │ └─index.js #
│ │
│ ├─directives # 处理相关指令的代码,例如v-model,v-bind,v-on等。
│ │ ├─bind.js # v-bind指令相关代码
│ │ ├─index.js #
│ │ ├─model.js # v-model指令相关代码
│ │ └─on.js # v-on指令相关代码
│ │
│ ├─parser # 模板解析器,将模板字符串转换成ast树
│ │ ├─entity-decoder.js # 实体解码器,返回元素节点的文本内容
│ │ ├─filter-parser.js # 过滤解析器
│ │ ├─html-parser.js # html解析器
│ │ ├─index.js #
│ │ └─text-parser.js # 文本解析器
│ │
│ ├─codeframe.js #
│ ├─create-compiler.js # 创建模板编译器相关代码
│ ├─error-detector.js # 错误检测器
│ ├─helpers.js # 辅助代码,里面引入过滤解析器和工具类util里面的emptyObject。
│ ├─index.js #
│ ├─optimizer.js # 优化器,优化ast树,主要是标记静态节点
│ └─to-function.js # 将render函数字符串转换成真正的函数
第二节·什么是模板编译
从写下的代码,到用户见到界面,大致经历以下几个流程:
模板字符串=》ast树=》render函数=》vnode=》用户见到的界面。
将开发者写下的模板字符串,经过一些列处理,生成render函数的这一过程就是模板编译。简单地说,模板编译解析就是为了得到render函数。
第三节·模板编译流程
1.模板编译的整体流程
从目录结构中,我们看到compiler下有一个parser和codegen的文件夹,这两个文件夹的内容就是模板编译的核心代码,parser负责将模板字符串解析成AST树,codegen则是负责将AST树生成可渲染的render函数。当然其中还有一个优化阶段,优化的内容主要是标记AST树中的静态节点,这部分代码则放在optimizer.js文件中。将开发者写下的模板字符串,编译解析成render函数的过程,我们称之为模板编译。大致的,我们将模板编译流程分为模板解析阶段,优化阶段,代码生成阶段,三个阶段。
模板编译的整体流程,大致如下图:
看下模板编译的入口文件index.js。
//源码位置 src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
const ast = parse(template.trim(), options)//解析器,解析模板字符串,生成AST树
if (options.optimize !== false) {
optimize(ast, options)//优化器,对代码优化,主要是标记静态节点
}
const code = generate(ast, options)//代码生成器,将ast树转换成render渲染函数
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
})
模板编译的入口文件代码不多,但也很清晰地将模板编译的三个阶段变现出来了。
2.模板编译的三个阶段
①解析阶段
模板解析阶段,解析模板字符串,并输出ast树,看下模板解析的入口文件src/compiler/parser/index.js。
源码中的注释就说得很清楚明确,主函数parse的作用就是讲html转换为ast树,解析流程大致是这样的,调用parserHTML函数,对模板进行解析,主要是解析模板中的原生html。在解析过程遇到文本信息,则调用parseText函数进行解析,遇到过滤器则调用parseFilters函数解析。大致流程如下。
模板解析的关键手段。正则表达式和js提供的字符串的方法,是解析模板字符串的核心手段,例如识别是原生html标签,还是文本内容,指令的识别,例如v-on,v-bind,v-if,v-for等。还有错误检查的作用,例如缺少结束标签等。
感受一下src/compiler/parser/index.js中使用的正则表达式。
模板解析的本质。前文已经提到,解析器的作用是将模板字符串生成ast树,ast树其实和vnode很相似,都是用js来描述的。感受一下解析前后对比。
// 解析前模板字符串
<div>
<p>{{name}}</p>
</div>
//解析后的的ast树
{
tag: "div"
type: 1,
staticRoot: false,
static: false,
plain: true,
parent: undefined,
attrsList: [],
attrsMap: {},
children: [
{
tag: "p"
type: 1,
staticRoot: false,
static: false,
plain: true,
parent: {tag: "div", ...},
attrsList: [],
attrsMap: {},
children: [{
type: 2,
text: "{{name}}",
static: false,
expression: "_s(name)"
}]
}
]
}
看过前后对比,我们可以试想一下,要是自己有没有办法做一个转换呢。没有什么是无理由的凭空出现的。我粗浅的下个定义,所有的解析器本质上都是基于原物做识别和截取。如果这种解析有输出,那么通常会识别和截取相结合,而解析的输出物就是基于原物的一种增删改查。
没错,ast就是基于模板字符串,截取出来,然后根据需要,进行自定义的增删改查得来的。源码中识别和截取的手段就是字符串的方法结合正则表达式。比如截取标签名。
let html = '<div><p>{{name}}</p></div>'
let tagNameStart = html.indexOf('<')
let tagNameEnd = html.indexOf('>')
let tagName = html.substring(tagNameStart+1, tagNameEnd)
console.log(tagName)
②优化阶段
优化的意义。优化阶段的目标是标记静态节点,而标记静态节点的意义在于,减小开销,优化性能。原本ast是可以直接生成render函数的了,但是为了性能,还是加了一个优化器进行优化,给vue点赞。标记静态节点,是如何与性能优化挂钩的呢,这在虚拟dom篇章中有解释,其实就是服务于虚拟dom的patch过程的,patch过程遇到静态节点,会跳过新旧vnode的比较过程,从而减小开销。
优化阶段做的两件事。优化阶段主要是做两件事,一是标记静态节点,而是标记静态根节点。看下源码。
//源码位置 src/compiler/optimizer.js
export function optimize (root: ?ASTElement, options: CompilerOptions) {
if (!root) return
isStaticKey = genStaticKeysCached(options.staticKeys || '')
isPlatformReservedTag = options.isReservedTag || no
// first pass: mark all non-static nodes.
//第一部分:标记所有静态节点
markStatic(root)
// second pass: mark static roots.
//第二部分:标记根节点。
markStaticRoots(root, false)
}
标记静态节点。标记静态节点大致流程是调用先标记根节点是否静态节点,然后根据节点的类型(node.type,type为1表示元素节点,2表示包含动态变量的文本节点,3表示纯文本节点)判断,如果是元素节点则递归调用markStatic方法,继续标记,直到递归结束,遍历标记完整个AST树。看下源码。
function markStatic (node: ASTNode) {
node.static = isStatic(node)//标记根节点
if (node.type === 1) {//type为1表示元素节点,2表示包含动态变量的文本节点,3表示纯文本节点
// do not make component slot content static. this avoids
// 1. components not able to mutate slot nodes
// 2. static slot content fails for hot-reloading
if (
!isPlatformReservedTag(node.tag) &&
node.tag !== 'slot' &&
node.attrsMap['inline-template'] == null
) {
return
}
for (let i = 0, l = node.children.length; i < l; i++) {
const child = node.children[i]
markStatic(child)//递归标记子节点
if (!child.static) {
node.static = false
}
}
if (node.ifConditions) {
for (let i = 1, l = node.ifConditions.length; i < l; i++) {
const block = node.ifConditions[i].block
markStatic(block)
if (!block.static) {
node.static = false
}
}
}
}
}
function isStatic (node: ASTNode): boolean {
if (node.type === 2) { // expression 包含变量的文本节点,不属于静态节点,返回false
return false
}
if (node.type === 3) { // text 纯文本节点,属于静态节点,返回true
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)
))
}
静态节点标记流程大致如下图所示。
标记静态根节点。关于静态根节点的条件,源码的写得很清楚,第一,必须是元素节点;第二,节点本身必须是静态节点;第三,有子节点;第四,不能只有静态文本的子节点。
function markStaticRoots (node: ASTNode, isInFor: boolean) {
if (node.type === 1) {
if (node.static || node.once) {
node.staticInFor = isInFor
}
// For a node to qualify as a static root, it should have children that
// are not just static text. Otherwise the cost of hoisting out will
// outweigh the benefits and it's better off to just always render it fresh.
//要使节点符合静态根节点的条件,它应该有且不只是静态文本的子节点。
// 否则,标记的成本将超过效益,最好还是让它更新。
if (node.static && node.children.length && !(
node.children.length === 1 &&
node.children[0].type === 3
)) {
node.staticRoot = true
return
} else {
node.staticRoot = false
}
if (node.children) {
for (let i = 0, l = node.children.length; i < l; i++) {
//递归调用markStaticRoots进行标记
markStaticRoots(node.children[i], isInFor || !!node.for)
}
}
if (node.ifConditions) {
for (let i = 1, l = node.ifConditions.length; i < l; i++) {
markStaticRoots(node.ifConditions[i].block, isInFor)
}
}
}
}
③代码生成阶段
代码生成阶段做的事。
代码生成阶段的做的事情就是将ast树,转换成render函数代码。这一点在在代码生成器的主函数generate的返回值就可以清晰地看到。
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}}`,//返回render函数代码
staticRenderFns: state.staticRenderFns
}
}
generate生成器函数的入参是ast树和编译器配置项,没错,这个ast就是优化阶段的输出,而代码生成器的输出结果集,返回的的就是render函数代码。在return前有一个判断,判断ast是否为空,非空则调用genElement生成节点;空则返回'_c("div")',实质是返回一个空div的vnode。
没错模板编译和虚拟dom的关联性极大,触发vnode的产生时机就是在代码生成阶段。generate 函数中起主要作用的是genElement,genElement的作用是生成vnode。
genElement根据入参,调用不同的代码生成器,比如静态节点生成器genStatic、组件生成器genComponent等,生成不同的vnode。genElement调用的众多生成器函数中,其中有一个生成器叫genChildren,这算是核心的了,这是在遇到标签名为template的元素的时候调用的。而genChildren又会调用genNode生成节点。在虚拟dom篇中有提到真正会挂载到dom上的节点,只有三种,即为元素节点,文本节点,注释节点。所以我们可以看到节点生成器的源码中,就是通过判断node的type等属性,来决定生成这三种节点中的哪种节点。源码如下。
export function genElement (el: ASTElement, state: CodegenState): string {
if (el.parent) {
el.pre = el.pre || el.parent.pre
}
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 && !state.pre) {
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 {
let data
if (!el.plain || (el.pre && state.maybeComponent(el))) {
data = 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
}
}
function genNode (node: ASTNode, state: CodegenState): string {
if (node.type === 1) {//生成元素节点
return genElement(node, state)
} else if (node.type === 3 && node.isComment) {//生成注释节点
return genComment(node)
} else {//生成文本节点
return genText(node)
}
}
有了代码生成器,我们就可以得到render函数代码,其实这里render并不是真正意义上的函数。这里可cue一下,模板编译文件夹的最后一个文件to-function.js了,没错,就是它将代码生成器输出的render代码串,转换成真正的render函数,一个function。Vue实例在挂载的时候,会调用对应的render函数来生成实例上的template选项所对应的VNode,没错,这个Vue调用render函数就是to-function的输出。
第四节·篇章小结
①模板编译的定义:将开发者写下的模板字符串,经过一些列处理,生成render函数的这一过程就是模板编译。
②模板编译的整体流程:解析器将模板字符串解析成ast树,然后优化器对ast进行标记静态节点的优化,最后代码生成器将优化后的ast树生成render函数。
③介绍模板编译三个阶段:解析阶段,优化阶段,代码生成阶段的工作过程,并回看了源码。