[TOC]
模板编译
前文在对 Vue 源码解析 - 主线流程 进行分析时,我们已经知道对于 Runtime + Compiler 的编译版本来说,Vue 在实例化前总共会经历两轮mount
过程,分别为:
定义于
src\platforms\web\runtime\index.js
的$mount
函数,主要负责组件挂载功能。定义于
src\platforms\web\entry-runtime-with-compiler.js
的$mount
函数,主要负责模板编译 + 组件挂载(其会缓存src\platforms\web\runtime\index.js
中定义的$mount
函数,最后的组件挂载转交给该函数进行处理)功能。
以下我们对src\platforms\web\entry-runtime-with-compiler.js
的$mount
函数进行解析,主要分析 模板编译 部分内容:
// src/platforms/web/entry-runtime-with-compiler.js
const mount = Vue.prototype.$mount;
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean,
): Component {
// 获取 el 元素对象,找不到则返回一个 div
el = el && query(el);
...
const options = this.$options;
// resolve template/el and convert to render function
if (!options.render) {
let template = options.template;
if (template) {
// Vue.$options.template 为字符串
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
// 由 id 取得对应的 DOM 元素的 innerHTML
template = idToTemplate(template);
...
}
} else if (template.nodeType) {
template = template.innerHTML;
} else {
if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this);
}
return this;
}
} else if (el) { // 没有 template
template = getOuterHTML(el);
}
if (template) {
...
// 对模板进行编译
const {render, staticRenderFns} = compileToFunctions(
template,
{
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments,
},
this,
);
options.render = render;
options.staticRenderFns = staticRenderFns;
...
}
}
return mount.call(this, el, hydrating);
};
function getOuterHTML(el: Element): string {
if (el.outerHTML) {
return el.outerHTML
} else {
const container = document.createElement('div')
container.appendChild(el.cloneNode(true))
return container.innerHTML
}
}
const idToTemplate = cached(id => {
const el = query(id)
return el && el.innerHTML
})
// src/shared/util.js
export function cached<F: Function>(fn: F): F {
const cache = Object.create(null)
return (function cachedFn(str: string) {
const hit = cache[str]
return hit || (cache[str] = fn(str))
}: any)
}
从源码中可以看到,只有在Options
没有定义render
函数时,才会进行模板编译。
模板编译步骤共分两步:
-
获取模板字符串:模板字符串的获取包含以下几种情况:
如果没有定义
template
,则直接获取el
元素的outerHTML
,即把el
元素作为template
。-
如果
template
为字符串,并且以#
开头,则表明template
是以id
进行指定,则通过该id
获取对应元素的innerHTML
。注:
cached
函数参数为一个函数,返回为一个参数为string
的函数,在该返回函数内部会调用cached
函数的函数参数,并做一个缓存处理。
对应于我们编译这部分,即会缓存以id
进行声明的template
的innerHTML
。 如果
template
为字符串,并且不以#
开头,则表明template
是一个完整的模板字符串,直接返回本身即可。如果
template
为nodeType
类型,直接返回其innerHTML
。如果定义了
template
,但格式无法识别(即不是字符串,也不是nodeType
类型),则给出警告,并退出编译流程。
将模板字符串编译为
render
函数:该功能主要由函数compileToFunctions
进行实现,其源码如下所示:
// src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile (...): CompiledResult {...})
// src/platforms/web/compiler/index.js
const { compile, compileToFunctions } = createCompiler(baseOptions)
compileToFunctions
是由createCompiler(baseOptions)
返回的,而createCompiler
为createCompilerCreator(function baseCompile (...){...})
,这里其实使用了 函数柯里化 的思想,将接收多个参数的函数转化为接收单一参数的函数,这样做的原因是 编译 这个流程和平台或构建方式相关,采用 函数柯里化,将与平台无关的东西固定化,只留出平台相关的内容作为参数,简化调用。比如,这里固定化参数为baseCompile
,其主要负责模板的解析,优化并最终生成模板代码的字符串(具体详情见后文),该操作是平台无关操作,而与平台相关的参数为baseOptions
,不同的平台该参数不同。
简而言之,compileToFunctions
会经由createCompilerCreator(function baseCompile (...){...}) --> createCompiler(baseOptions)
而得到。
因此,我们先来看下createCompilerCreator(function baseCompile (...){...})
的源码实现:
// src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile (...){...})
// src\compiler\create-compiler.js
export function createCompilerCreator (baseCompile: Function): Function {
return function createCompiler (baseOptions: CompilerOptions) {
function compile (...): CompiledResult {
...
const compiled = baseCompile(template.trim(), finalOptions)
...
}
return {
compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
}
所以createCompilerCreator
就是固定了参数baseCompile
,并返回一个函数createCompiler
,该函数内部又会返回一个包含两个函数的实例,这其中就有一个我们需要分析的函数compileToFunctions
(这个就是$mount
函数内部使用的createCompileToFunctionFn
),其指向为函数createCompileToFunctionFn(compile)
的执行结果,我们先对函数createCompileToFunctionFn
源码进行查看:
// src/compiler/to-function.js
export function createCompileToFunctionFn(compile: Function): Function {
...
return function compileToFunctions(...): CompiledFunctionResult {...}
}
可以看到又是一个 函数柯里化 的操作,固定了平台无关参数compile
,并返回了我们最终需要的compileToFunctions
函数。
注:compileToFunctions
函数获取这部分的代码由于采用了多个 函数柯里化 操作,导致代码逻辑比较混乱,下面是该部分代码的整个调用链:
// src/platforms/web/entry-runtime-with-compiler.js
const {render, staticRenderFns} = compileToFunctions(template, {...}, this)
// src/platforms/web/compiler/index.js
const {compile, compileToFunctions} = createCompiler(baseOptions)
// src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile(...) {...})
// src/compiler/create-compiler.js
export function createCompilerCreator(baseCompile: Function): Function {
return function createCompiler(baseOptions: CompilerOptions) {
function compile(...): CompiledResult {...}
return {
compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
}
// src/compiler/to-function.js
export function createCompileToFunctionFn(compile: Function): Function {
return function compileToFunctions(...): CompiledFunctionResult {
...
const compiled = compile(template, options)
...
}
}
可以看到,compileToFunctions
的获取调用链为:createCompilerCreator --> createCompiler --> createCompileToFunctionFn --> compileToFunctions
。
到这里我们才理清了compileToFunctions
函数的定义出处,现在回到主线流程,看下compileToFunctions
是怎样具体编译出render
函数:
// src/compiler/to-function.js
export function createCompileToFunctionFn(compile: Function): Function {
const cache = Object.create(null)
return function compileToFunctions(
template: string,
options?: CompilerOptions,
vm?: Component
): CompiledFunctionResult {
...
// check cache
const key = options.delimiters
? String(options.delimiters) + template
: template
if (cache[key]) {
return cache[key]
}
// compile
const compiled = compile(template, options)
...
// turn code into functions
const res = {}
const fnGenErrors = []
res.render = createFunction(compiled.render, fnGenErrors) // 生成渲染函数
res.staticRenderFns = compiled.staticRenderFns.map(code => {
return createFunction(code, fnGenErrors)
})
...
return (cache[key] = res)
}
}
function createFunction(code, errors) {
try {
return new Function(code) // 将字符串 code 渲染成函数
} catch (err) {
errors.push({err, code})
return noop
}
}
所以当我们调用compileToFunctions
时,其会做如下三件事:
模板编译:通过函数
compile
进行编译。生成渲染函数:通过函数
createFunction
将编译完成的模板生成相应的渲染函数(其实就是使用Function
构造函数将编译完成的模板代码字符串转换成函数)。缓存渲染函数:依据模板字符串内容作为键值,缓存其编译结果。
这里面最核心的就是 模板编译 步骤,目的就是编译出模板对应的渲染函数字符串。
我们着重对这步进行分析,对compile
函数进行源码查看:
// src/compiler/create-compiler.js
export function createCompilerCreator(baseCompile: Function): Function {
return function createCompiler(baseOptions: CompilerOptions) {
function compile(
template: string,
options?: CompilerOptions
): CompiledResult {
const finalOptions = Object.create(baseOptions)
...
const compiled = baseCompile(template.trim(), finalOptions)
...
return compiled
}
...
}
}
compile
内部会将编译过程交由参数baseCompile
进行实际处理,而根据我们前面的分析,baseCompile
就是函数createCompilerCreator
采用 函数柯里化 固定的平台无关的参数,其源码如下所示:
// src/compiler/index.js
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
}
}
因此,$mount
函数内部的compileToFunctions
函数最终调用的就是baseCompile
函数进行模板编译流程。
从源码中可以看到,baseCompile
函数内部主要做了三件事:
模板编译:由函数
parse
进行模板编译,并生成抽象语法树 AST。优化 AST:由函数
optimize
负责。生成代码:由函数
generate
负责根据 AST 和编译选项生成相应代码(字符串形式)。
我们下面针对这三个过程继续进行分析:
-
模板编译:编译过程的第一步就是解析模板字符串,生成抽象语法树 AST。
我们进入parse
函数,查看其源码:
// src/compiler/parser/index.js
/**
* Convert HTML string to AST.
*/
export function parse(
template: string,
options: CompilerOptions
): ASTElement | void {
...
let root
...
parseHTML(template, {
...
start(tag, attrs, unary, start, end) {
...
let element: ASTElement = createASTElement(tag, attrs, currentParent)
...
if (!root) {
root = element
...
}
...
},
end(tag, start, end) {
const element = stack[stack.length - 1]
// pop stack
stack.length -= 1
currentParent = stack[stack.length - 1]
...
closeElement(element)
},
chars(text: string, start: number, end: number) {
...
parseText(text, delimiters)
...
children.push(child)
...
},
comment(text: string, start, end) {
if (currentParent) {
...
currentParent.children.push(child)
}
}
})
return root
}
parse
函数最终通过调用函数parseHTML
对模板进行解析,查看parseHTML
源码:
// src/compiler/parser/html-parser.js
// Regular Expressions for parsing tags and attributes
...
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
const comment = /^<!\--/
const conditionalComment = /^<!\[/
...
export function parseHTML(html, options) {
...
let index = 0
let last, lastTag
while (html) {
last = html
// Make sure we're not in a plaintext content element like script/style
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// Comment:
if (comment.test(html)) {
...
advance(commentEnd + 3)
continue
}
}
// http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
if (conditionalComment.test(html)) {
...
advance(conditionalEnd + 2)
continue
}
}
// Doctype:
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
advance(doctypeMatch[0].length)
continue
}
// End tag:
const endTagMatch = html.match(endTag)
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// Start tag:
const startTagMatch = parseStartTag()
if (startTagMatch) {
...
advance(1)
}
continue
}
}
...
if (textEnd >= 0) {
rest = html.slice(textEnd)
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// < in plain text, be forgiving and treat it as text
...
text = html.substring(0, textEnd)
}
...
advance(text.length)
...
} else {
...
parseEndTag(stackedTag, index - endTagLength, index)
}
...
}
// Clean up any remaining tags
parseEndTag()
function advance(n) {
index += n
html = html.substring(n)
}
...
}
简单来说,parseHTML
函数采用正则表达式来解析模板template
,其解析步骤大概如下所示:
-
首先获取模板字符
<
的索引位置,如果索引为0
,则表明当前模板以<
开头,则使用正则依次判断template
是否匹配Comment
,conditionalComment
,Doctype
,End Tag
还是Start Tag
,匹配完成后,依据不同的匹配标签进行各自的解析,比如,对于Comment
标签,则会进行如下解析:// Comment: if (comment.test(html)) { const commentEnd = html.indexOf('-->') if (commentEnd >= 0) { ... options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3) ... advance(commentEnd + 3) continue } }
即如果
template
匹配Comment
标签,则找到-->
的索引位置,即找到注释标签的末尾位置,然后取出注释内容html.substring(4,commentEnd)
,交由options.comment
函数进行处理(options.comment
其实是parse
函数内调用parseHTML
传递进行的options.comment
,因此,这里其实起一个回调作用,parse
函数内就可以通过回调获取当前模板解析得到的注释节点的内容,从而可以进行处理或保存),解析完成后会通过advance
函数将当前template
的字符串进行截取,只保留还未进行解析的内容。其他节点解析处理与上述操作逻辑类似,均是根据节点特点进行解析,然后通过
advance
函数去除已解析的内容,只保留未解析的模板字符串,继续新一轮的解析。举个栗子:比如对于如下模板:
template: `<h2 style="color:red">Hi, {{message}}</h2>`
其解析流程如下图所示:
parseHTML
每次解析完成一个节点时,就会将结果回调给parse
函数,parse
函数就可以根据这些结果进行抽象语法树(AST)的构建,其实质就是构建一个javascript
对象,比如,上述模板构建得到的 AST 如下所示:
{
"type": 1,
"tag": "h2",
"attrsList": [],
"attrsMap": {
"style": "color:red"
},
"rawAttrsMap": {
"style": {
"name": "style",
"value": "color:red",
"start": 4,
"end": 21
}
},
"children": [
{
"type": 2,
"expression": "\"Hi, \"+_s(message)",
"tokens": [
"Hi, ",
{
"@binding": "message"
}
],
"text": "Hi, {{message}}",
"start": 22,
"end": 37
}
],
"start": 0,
"end": 42,
"plain": false,
"staticStyle": "{\"color\":\"red\"}"
}
到这里,我们就大概了解了模板字符串template
解析成抽象语法树(AST)的整个过程。
-
优化 AST:当完成 AST 的构建后,就可以对 AST 进行一些优化。
注:Vue 之所以有 优化 AST 这个过程,主要是因为 Vue 的特性之一是 数据驱动,并且数据具备响应式功能,因此,当更改数据的时候,模板会重新进行渲染,显示最新的数据。但是,模板中并不是所有的节点都需要进行重新渲染,对于不包含响应式数据的节点,从始至终只需一次渲染即可。
我们进入
optimize
函数,查看其源码:// src/compiler/optimizer.js /** * Goal of the optimizer: walk the generated template AST tree * and detect sub-trees that are purely static, i.e. parts of * the DOM that never needs to change. * * Once we detect these sub-trees, we can: * * 1. Hoist them into constants, so that we no longer need to * create fresh nodes for them on each re-render; * 2. Completely skip them in the patching process. */ export function optimize(root: ?ASTElement, options: CompilerOptions) { ... // first pass: mark all non-static nodes. markStatic(root) // second pass: mark static roots. markStaticRoots(root, false) }
optimize
主要就是做了两件事:-
markStatic
:标记静态节点。其源码如下:
// src/compiler/optimizer.js function markStatic(node: ASTNode) { node.static = isStatic(node) if (node.type === 1) { ... 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 } } } } }
注:
parse
函数解析生成的抽象语法树 AST 中,其元素节点总有三种类型,如下所示:type
类型 1
普通元素 2
表达式(expression) 3
文本(text) 从源码中可以看到,
markStatic
函数会对当前节点,以及当前节点的子节点和v-if
的子节点进行静态节点标记,静态节点的判定标准由函数isStatic
判定:// src/compiler/optimizer.js 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) )) }
可以看到,静态节点的判定标准为:纯文本类型 或者 没有动态绑定且没有
v-if
且没有v-for
指令且不是内置slot
/component
且是平台保留标签且不是带有v-for
指令的template
标签的直接子节点且节点的所有属性都是静态key
。
当满足静态节点的判定时,就会为该节点打上static=true
属性,作为标记。-
markStaticRoots
:标记静态根节点。其源码如下:
// src/compiler/optimizer.js function markStaticRoots(node: ASTNode, isInFor: boolean) { if (node.type === 1) { ... // 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(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) } } } }
根节点即为普通元素节点,从源码中可以看到,
markStaticRoots
函数会对当前节点,以及当前节点的子节点和带有v-if
的子节点进行静态根节点标记。
静态根节点的判定标准为:节点为静态节点,且其有子节点,并且子节点不能只是一个文本节点。
当满足静态根节点的判定时,就会为该节点打上staticRoot=true
属性,作为标记。因此,Vue 对模板解析生成的 AST 的优化就是对 AST 元素节点进行静态节点和静态根节点的标记,以避免重新渲染静态节点元素。
-
-
生成代码:当对 AST 进行优化后,编译的最后一步就是将优化过后的 AST 树转换成可执行代码。
我们进入
generate
函数,查看其源码:// 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 } }
generate
函数内部主要就是调用了函数genElement
对 AST 树进行解析并生成对应可执行代码,最后返回一个对象,该对象含有两个函数render
和state.staticRenderFns
,其中,render
函数会封装一下genElement
函数生成的代码。我们下面对
genElement
函数进行分析,看下其代码生成逻辑:// src/compiler/codegen/index.js 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 && !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) ... } ... return code } }
可以看到,
genElement
函数内部会依据 AST 树的节点类别分别调用不同的函数生成对应的代码,比如:- 对于静态根节点,使用
genStatic
函数进行代码生成。 - 对于带有
v-once
指令的节点,使用genOnce
函数进行代码生成。 - 对于带有
v-for
指令的节点,使用genFor
函数进行代码生成。 - 对于带有
v-if
指令的节点,使用genIf
函数进行代码生成。 - 对于不带
slot
指令的template
标签,会使用genChildren
函数遍历其子节点并进行代码生成。 - 对于
slot
标签,使用genSlot
函数进行代码生成。 - 对于组件或元素,使用
genComponent
等函数进行代码生成。
...
我们这里就随便找一个函数简单分析下代码生成的具体逻辑,比如:
genStatic
,其源码如下所示:// src/compiler/codegen/index.js function genStatic(el: ASTElement, state: CodegenState): string { ... state.staticRenderFns.push(`with(this){return ${genElement(el, state)}}`) ... return `_m(${ state.staticRenderFns.length - 1 }${ el.staticInFor ? ',true' : '' })` }
其实最终就是使用字符串拼接将对应元素的内容转换为字符串代码。
举个栗子:比如我们构造一个静态根节点的模板,如下所示:
template: ` <div> <h2 style="color:red">Hi</h2> </div> `
最后,经过
genStatic
后,最终生成的代码为:_m(0)
。
所以上述模板最终经过generate
函数后,生成的代码如下:{ "render": "with(this){return _m(0)}", "staticRenderFns": [ "with(this){return _c('div',[_c('h2',{staticStyle:{\"color\":\"red\"}},[_v(\"Hi\")])])}" ] }
注:
_m
函数其实是renderStatic
函数,Vue 中还设置了_o
,_l
,_v
等函数,如下所示:// src/core/instance/render-helpers/index.js export function installRenderHelpers (target: any) { target._o = markOnce target._n = toNumber target._s = toString target._l = renderList target._t = renderSlot 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 target._d = bindDynamicKeys target._p = prependModifier }
其余的代码生成函数就不进行分析了,大概的原理就是根据不同节点特征,在 AST 树中获取需要的数据,拼接成可执行代码的字符串代码。
- 对于静态根节点,使用
到这里,模板编译的一个大概完整过程便完成了。