浅析Vue渲染机制

「这是我参与11月更文挑战的第6天,活动详情查看:2021最后一次更文挑战

因为最近从React转到了Vue,一些项目涉及的颗粒度比较细,顺便借着这个机会,阅读了下 Vue 的源码。从整体上看,整个渲染过程会经历如下步骤:在编译环境中,模版 HTML 字符串被编译成 render() 函数,然后在运行时环境中,调用 render() 函数得到 VNode,最后应用到真实 DOM 中。

这篇文章将关注第一步:从 HTML 字符串到 render() 函数。

render 方法

  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 节点一共有三种:

  • 元素。内置指令,例如 <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; }

    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() 函数的整个生成过程我们已经有了大概的了解。

Guess you like

Origin juejin.im/post/7034505448665382942