从 HTML 字符串到 `render()` 函数
最近因为项目需要,仔细阅读了下 Vue 的源码。网上有很多关于 Vue 的响应式设计及细节,我们这次聚焦在 $mount()
方法中。
从整体上看,整个渲染过程会经历如下步骤:在编译环境中,模版 HTML 字符串被编译成 render()
函数,然后在运行时环境中,调用 render()
函数得到 VNode,最后应用到真实 DOM 中。
这篇文章将关注第一步:从 HTML 字符串到 render()
函数。
在编译环境中,需要预先将 HTML 字符串编译成 render()
函数,其中也包含静态渲染函数,随后调用运行时环境中的 $mount()
方法。这里省略了模版的获取方法,比如 Vue 支持 template
属性传入字符串或者模版 DOM 节点。
// src/platforms/web/entry-runtime-with-compiler.js
const mount = Vue.prototype.$mount;
Vue.prototype.$mount = function (el, hydrating) {
if (!options.render) {
const {render, staticRenderFns} = compileToFunctions(template, {
shouldDecodeNewlines,
delimiters: options.delimiters,
comments: options.comments
}, this);
options.render = render;
options.staticRenderFns = staticRenderFns;
}
return mount.call(this, el, hydrating);
}
下面我们将深入这个 compileToFunctions
方法,在此之前不妨先看看最终的编译结果,有个直观的认识。
最终结果
由于 Vue 将编译方法暴露在了全局 API 中,我们可以看看下面这段包含了 HTML 文本节点的模版最终编译出来的 render 方法。在 Vue 官方文档中也可以在线修改查看:
var res = Vue.compile('<div>{{ msg }}</div>')
// res.render
function anonymous() {
with(this){return _c('div',[_v(_s(msg))])}
}
with(this)
传入了上下文对象,让我们在运行时能取得变量的值,例如 msg
。但是 _c
_v
_s
这些我们目前还不了解,只需要知道,执行 render 函数,我们就能得到模版对应的 VNode。
编译 render 方法
之前通过 the-super-tiny-compiler 学习了一下编译器的通用步骤,试着写了一个 vue-style-variables-loader:
- Parse 词法分析得到 Tokens,语法分析生成 AST
- Transformation 操作 AST,做一些优化工作
- Code Generation 生成代码
从 Vue compiler 代码中也能看出上面通用步骤的应用:
// src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile(template, options) {
const ast = parse(template.trim(), options);
optimize(ast, options);
const code = generate(ast, options);
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
};
});
先来看看 parse()
方法转换 AST 的实现。
生成 AST
对于原始的 HTML 字符串,首先需要进行词法分析。
词法分析
通过 parseHTML()
方法解析 HTML 字符串,在遇到开始标签,结束标签,文本和注释这四种 Token 时,调用相应的处理函数:start()
、end()
、chars()
和 comment()
。
// src/compiler/parser/index.js
export function parse(
template,
options
) {
const stack = [];
let root; // AST 根节点
let currentParent;
parseHTML(template, {
start(tag, attrs, unary) {},
end() {},
chars(text) {},
comment(text) {}
}
return root;
}
从源文件的注释来看,这个方法借鉴了 HTML Parser。
整个解析过程放在 while 循环中,通过检测起始标签 <
分为标签和文本两种情况,使用 advance()
移动当前指针不断截取 HTML 子串直至结束:
// src/compiler/parser/html-parser.js
export function parseHTML(html, options) {
const stack = [];
let index = 0;
let last;
let lastTag;
while (html) {
let textEnd = html.indexOf('<');
if (textEnd === 0) {
// Comment...
// End tag...
// Start tag...
}
if (textEnd >= 0) {
// Text...
}
}
function advance(n) {
index += n;
html = html.substring(n);
}
}
先来看看对于标签的处理,首先是起始标签,由于涉及了 Vue 的模版语法,例如 v-for
v-if
等等,整个过程十分复杂。值得一提的是,从代码中我第一次发现 v-pre
这个内置指令的用法,可以跳过编译直接输出模版语法内容:
// src/compiler/parser/index.js
start(tag, attrs, unary) {
let element = createASTElement(tag, attrs, currentParent);
// structural directives
processFor(element);
processIf(element);
processOnce(element);
// element-scope stuff
processElement(element, options);
}
完成了词法分析,下面要进行语法分析了。
AST 节点类型
从 flow 的类型定义可以看出 AST 节点一共有三种:
- 元素。内置指令,例如
<component name='xxx'>
- 表达式。双向绑定,例如 {{text}}`
- 文本。静态内容,包括注释(
isComment
标志),例如//plain text...
// flow/compiler.js
declare type ASTNode = ASTElement | ASTText | ASTExpression;
declare type ASTElement = {
type: 1;
tag: string;
attrsList: Array<{ name: string; value: any }>;
attrsMap: { [key: string]: any };
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;
};
这里我们处理 HTML 中的文本 Token 为例。对于起始标签的处理虽然复杂,但是道理都是一样的。
文本 Token
由于 HTML 文本节点中可能包含 Vue 的模版语法,所以这里会使用 parseText()
进一步解析内容,最终会生成两种 AST 节点,即 AST 表达式节点和 AST 文本节点。
// src/compiler/parser/index.js
chars(text) {
const children = currentParent.children;
let expression;
if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
children.push({
type: 2,
expression,
text
});
}
else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
children.push({
type: 3,
text
});
}
}
在 parseText()
中我们看到了处理模版插值的正则表达式,需要将这些插值包装到内置约定好的函数中,例如 _s()
,这样在运行 render 函数时插值能够被正确传入得到结果。还记得开始最终编译结果中那几个下划线开头的内置函数吗,这里就是其中一个。
// src/compiler/parser/text-parser.js
const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g;
export function parseText(
text,
delimiters
) {
const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE;
if (!tagRE.test(text)) {
return;
}
const tokens = [];
let lastIndex = tagRE.lastIndex = 0;
let match;
let index;
// 处理插值语法
while ((match = tagRE.exec(text))) {
index = match.index;
// push text token
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex, index)));
}
// tag token
const exp = parseFilters(match[1].trim());
tokens.push(`_s(${exp})`);
lastIndex = index + match[0].length;
}
// 普通文本
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)));
}
// 拼装成可执行表达式
return tokens.join('+');
}
对于 xxx {{a}} {{b}}
这样的文本 Token,最终会返回 'xxx'+_s(a)+_s(b)
这样的可执行的表达式。另外,上述代码中还使用了 parseFilters()
处理插值表达式中可能包含的的过滤器,这里就不展开了。
至此,我们终于看完了从 HTML 模版到 AST 的生成过程,在最终生成可执行代码之前,需要做一些优化工作。
优化 AST
还记得一开始我们提到过,除了最终要生成 render
方法,还需要 staticRenderFns
用来渲染那些静态节点。
这里就需要标记出 AST 中的静态节点以便后续的代码生成。
那么哪些节点被认为是静态的呢?首先纯文本节点肯定是,而表达式节点肯定不是。 对于剩下的元素节点,就需要通过应用在节点上的 Vue 指令、标签名来判断了。
// src/compiler/optimizer.js
function isStatic(node) {
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) // 节点上每一个属性都必须是静态的
));
}
另外介绍一个工具方法,可以缓存一些开销较大的函数的结果。 例如上面解析 HTML 文本 Token 时生成正则,以及在这里生成判断属性是否是静态的方法。
// src/shared/util.js
export function cached(fn) {
const cache = Object.create(null);
return function cachedFn(str) {
const hit = cache[str];
return hit || (cache[str] = fn(str));
};
}
优化工作也做完了,终于要进入最后一步,也就是代码生成工作了。
生成代码
已经非常接近最开始我们看到的最终效果了。
// src/compiler/codegen/index.js
export function generate(
ast,
options
) {
const state = new CodegenState(options);
const code = ast ? genElement(ast, state) : '_c("div")';
return {
render: `with(this){return ${code}}`,
staticRenderFns: state.staticRenderFns
};
}
在处理 ASTElement 类型的节点时,最终拼装成_c('div',${data},${children})
。这里的 _c()
便是 createElement()
的缩写,负责生成 VNode,其中 VNode 属性 data
由 genData()
负责生成,而子节点由 genChildren()
创建:
export function genElement(el, state) {
// 省略处理 v-for v-if...
let code;
// 自定义组件
if (el.component) {
code = genComponent(el.component, el, state);
}
// HTML 元素
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;
}
还是以 <div>{{msg}</div>
这个最简单的模版为例,由于没有节点属性,我们直接来看 genChildren()
。
export function genChildren(
el,
state,
checkSkip,
altGenElement,
altGenNode
) {
const children = el.children;
if (children.length) {
const el = children[0];
// 处理单个 v-for 子元素
const normalizationType = checkSkip
? getNormalizationType(children, state.maybeComponent)
: 0;
// 生成节点方法
const gen = altGenNode || genNode;
return `[${children.map(c => gen(c, state)).join(',')}]${
normalizationType ? `,${normalizationType}` : ''
}`;
}
}
包装成一个数组返回,其中对每个子节点调用 genNode()
。根据 AST 节点类型又会调用不同的代码生成方法,可见 genElement()
是一个递归的过程。我们这里的 {{msg}}
对应的是 ASTExpression 节点。
function genNode(node, state) {
if (node.type === 1) {
return genElement(node, state);
}
if (node.type === 3 && node.isComment) {
return genComment(node);
}
return genText(node);
}
这里简单包装了一层 _v()
,由于在生成 ASTExpression 时,已经将包装好的 _s()
放在了 expression
属性中,这里不需要额外的处理了。
export function genText(text) {
return `_v(${text.type === 2
? text.expression // no need for () because already wrapped in _s()
: transformSpecialNewlines(JSON.stringify(text.text))
})`;
}
终于我们弄明白了最终结果 _c('div',[_v(_s(msg))])
是怎么来的了。最后需要生成真正可执行的函数:
// src/compiler/to-function.js
function createFunction(code, errors) {
try {
return new Function(code);
}
catch (err) {
errors.push({err, code});
return noop;
}
}
总结
这里只选取了最最简单的模版来跟踪源代码的执行,内置指令等其他复杂的模版特性并没有涉及到。不过对于从 HTML 模板到 render()
函数的整个生成过程我们已经有了大概的了解。
那么执行渲染函数的结果是什么,在最终渲染成 DOM 的过程中又会经历哪些呢?让我们留到下一 Part 中再分析吧。