从 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

  1. Parse 词法分析得到 Tokens,语法分析生成 AST
  2. Transformation 操作 AST,做一些优化工作
  3. 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 节点一共有三种:

// 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 属性 datagenData() 负责生成,而子节点由 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 中再分析吧。

参考资料