【源码&库】Vue3的模板转换为AST的过程

在上一章中我们了解到了Vue是如何将组件注册到全局的,我们使用的时候就直接使用一个标签的方式就可以使用这个组件;

那么这个组件是如何从这个模板中正确的解析出来的呢?这就是我们今天要学习的内容,还是继续从上一章的示例开始;

历史章节可以拉到文末查看,全系列文章链接都会放到文末。

1. Vue 的模板字符串

在上一章最后我们知道一个模板字符串最后会被创建成一个子树,首先我们来看看这个模板字符串是如何被创建的;

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id='app'>
    <my-component></my-component>
</div>

</body>
<script src="./vue.global.js"></script>
<script>
    const { createApp, h } = Vue;

    const app = createApp({});

    const MyComponent = {
        render() {
            return h('div', 'MyComponent');
        }
    };

    app.component('MyComponent', MyComponent);
    
    debugger;
    app.mount('#app');
</script>
</html>

这是我上一章的示例代码,我们可以mount的上面打上断点进行调试,查看模板生成的过程:

跟着调试结果可以看到,我们的模板有三种方式可以创建:

  1. 直接使用一个函数,这个函数的返回值就是我们的模板;
  2. 一个对象中有一个render函数,这个函数的返回值就是我们的模板;
  3. 一个对象中有一个template属性,这个属性的值就是我们的模板;

如果上面三种方式都没有,那么就会使用容器的innerHTML作为我们的模板,这个innerHTML最后会被赋值到template属性上;

app._component就是我们在createApp的时候传入的对象,这个对象就是我们的根组件;

后续就是挂载的过程,这一块可以查看我之前的章节:【源码&库】 Vue3 的组件是如何挂载的?

2. 模板字符串转换为AST

在之前的章节中只是讲解了一个组件的挂载流程,但是只是单组件的挂载,回顾上面提到的文章,可以看到有一个finishComponentSetup的调用,其中有一段代码:

js 复制代码
function finishComponentSetup(instance, isSSR, skipOptions) {
    // 删除影响阅读的代码
    
    // 根据之前的学习我们知道 type 其实就是组件对象
    const Component = instance.type;
    
    // 组件对象的 template 就是模板字符串
    const template = Component.template || resolveMergedOptions(instance).template;
    
    // 通过 compile 函数将模板字符串编译成 render 函数
    Component.render = compile$1(template, finalCompilerOptions);
    instance.render = Component.render || NOOP;
}

这里的compile$1函数指向的是compileToFunction,我们来看看这个函数的实现:

js 复制代码
function compileToFunction(template, options) {
    // 删除一些不会进入的分支

    // 将整个模板字符串作为缓存的 key
    const key = template;
    const cached = compileCache[key];
    if (cached) {
        return cached;
    }

    const opts = extend(
        {
            hoistStatic: true,
            onError: onError,
            onWarn: (e) => onError(e, true)
        },
        options
    );
    if (!opts.isCustomElement && typeof customElements !== "undefined") {
        opts.isCustomElement = (tag) => !!customElements.get(tag);
    }

    // 核心:通过 compile 函数生成一段代码字符串,然后通过 new Function(code) 生成 render 函数
    const {code} = compile(template, opts);

    function onError(err, asWarning = false) {
        // ...
    }

    const render = new Function(code)();
    render._rc = true;
    return compileCache[key] = render;
}

内部核心就是compile函数,他通过这个函数会产生一个代码字符串,然后通过new Function(code)的方式生成一个render函数;

我们现在来看看compile函数的实现:

js 复制代码
function compile(template, options = {}) {
    // 内部就是调用 baseCompile,只不过传入了一些默认的参数
    return baseCompile(
        template,
        extend({}, parserOptions, options, {
            nodeTransforms: [
                // ignore <script> and <tag>
                // this is not put inside DOMNodeTransforms because that list is used
                // by compiler-ssr to generate vnode fallback branches
                ignoreSideEffectTags,
                ...DOMNodeTransforms,
                ...options.nodeTransforms || []
            ],
            directiveTransforms: extend(
                {},
                DOMDirectiveTransforms,
                options.directiveTransforms || {}
            ),
            transformHoist: null
        })
    );
}

function baseCompile(template, options = {}) {
    // 删除一些目前调试情况不会进入的代码

    // 通过 baseParse 解析模板字符串,得到 AST
    const ast = baseParse(template, options);

    // 获取编译器选项
    const [nodeTransforms, directiveTransforms] = getBaseTransformPreset();
    // 转换 AST
    transform(
        ast,
        extend({}, options, {
            prefixIdentifiers,
            nodeTransforms: [
                ...nodeTransforms,
                ...options.nodeTransforms || []
                // user transforms
            ],
            directiveTransforms: extend(
                {},
                directiveTransforms,
                options.directiveTransforms || {}
                // user transforms
            )
        })
    );
    // 生成代码
    return generate(
        ast,
        extend({}, options, {
            prefixIdentifiers
        })
    );
}

这里的baseParse函数就是我们的模板字符串转换为AST的核心代码,看到这里终于是看到了ast;

3. 模板字符串转换为AST的核心代码

我们来看看baseParse函数的实现:

js 复制代码
function baseParse(content, options = {}) {
    // content 就是我们要解析的内容
    // 通过 createParserContext 创建一个解析上下文
    const context = createParserContext(content, options);
    // 获取解析上下文中的游标信息
    const start = getCursor(context);

    // 这里是三步操作
    // 1. parseChildren 解析子节点
    // 2. getSelection 获取从解析开始位置到当前位置的范围
    // 3. createRoot 返回整个模板结构的根节点
    return createRoot(
        parseChildren(context, 0, []),
        getSelection(context, start)
    );
}

baseParse虽然内容很少,但是里面包含的信息量是非常大的,我们接着分析createParserContext函数:

3.1 createParserContext

js 复制代码
function createParserContext(content, rawOptions) {
    const options = extend({}, defaultParserOptions);

    // 生成解析器选项,如果用户没有传入,则使用默认的
    // 这里的解析器比较多,感兴趣的同学可以深挖一下,后面这些解析器会用到
    let key;
    for (key in rawOptions) {
        options[key] = rawOptions[key] === void 0 ? defaultParserOptions[key] : rawOptions[key];
    }

    // 生成解析器上下文
    return {
        options, // 解析器选项
        column: 1, // 当前解析位置的列号,初始值为1
        line: 1, // 当前解析位置的行号,初始值为1
        offset: 0, // 当前解析位置相对于整个模板字符串的偏移量,初始值为0
        originalSource: content, // 整个模板字符串的原始内容
        source: content, // 当前解析位置之后的模板内容
        inPre: false, // 表示是否当前位于 <pre> 标签内,初始值为 false
        inVPre: false, // 表示是否当前位于带有 v-pre 指令的元素内,初始值为 false
        onWarn: options.onWarn, // 警告处理函数,从解析选项中获取,用于在解析过程中发出警告
    };
}

总体来说createParserContext函数就是生成一个解析上下文,这个上下文中包含了解析器的选项,当前解析位置的信息,以及一些解析过程中需要用到的函数;

生成的解析器上下文会用于后续的解析过程中,传递给其他解析函数进行使用;

3.2 getCursor

js 复制代码
function getCursor(context) {
    const { column, line, offset } = context;
    return { column, line, offset };
}

这个函数就是获取当前解析位置的信息,非常简单,直接返回解析上下文中的信息;

3.3 parseChildren

温馨提示:这里的代码量比较多,而且逻辑会比较复杂,建议大家通过调试的方式,然后对照着我这里的代码解释来学习;

js 复制代码
function parseChildren(context, mode, ancestors) {
    // 获取父节点,ancestors 是一个数组,里面存放的是当前节点的父节点
    // last 函数是一个辅助函数,用于获取数组的最后一个元素
    const parent = last(ancestors);

    // 获取当前节点的命名空间(ns),如果没有父节点,则命名空间为0
    const ns = parent ? parent.ns : 0;

    // 用于存储解析得到的子节点
    const nodes = [];

    // 通过 isEnd 函数判断当前节点是否是结束节点,如果不是则继续解析
    while (!isEnd(context, mode, ancestors)) {
        // 获取当前解析位置的源代码片段
        const s = context.source;

        // 存储解析得到的节点
        let node = void 0;

        // 这里的 mode 默认传入了 0,表示解析的是元素节点
        if (mode === 0 || mode === 1) {

            // 检查是否在非 v-pre 环境下并且当前源代码以插值表达式的开始标记为起始
            if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
                node = parseInterpolation(context, mode);
            }

            // 果不是插值表达式,则检查是否在元素模式下且源代码以 "<" 开头
            else if (mode === 0 && s[0] === "<") {

                // 如果只有一个 "<",则说明源代码不合法,报错
                if (s.length === 1) {
                    emitError(context, 5, 1);
                }

                // 如果第二个字符是 "!" 则有可能是下述几种情况:
                else if (s[1] === "!") {

                    // 1. 注释节点
                    if (startsWith(s, "<!--")) {
                        node = parseComment(context);
                    }

                    // 2. 伪注释节点
                    else if (startsWith(s, "<!DOCTYPE")) {
                        node = parseBogusComment(context);
                    }

                    // 3. 大块文本节点
                    else if (startsWith(s, "<![CDATA[")) {
                        // 大块文本节点不能是根节点
                        if (ns !== 0) {
                            node = parseCDATA(context, ancestors);
                        } else {
                            emitError(context, 1);
                            node = parseBogusComment(context);
                        }
                    }

                    // 其他情况表示不是一个合法的 xml 节点信息,都以普通文本来进行处理
                    else {
                        emitError(context, 11);
                        node = parseBogusComment(context);
                    }
                }

                // 结束标签标记
                else if (s[1] === "/") {
                    // 只有一个 "</",则报错
                    if (s.length === 2) {
                        emitError(context, 5, 2);
                    }

                    // "</>" 不合法,报错
                    else if (s[2] === ">") {
                        emitError(context, 14, 2);
                        advanceBy(context, 3);
                        continue;
                    }

                    // "</a" 这种情况,表示是一个结束标签,进一步进行解析
                    else if (/[a-z]/i.test(s[2])) {
                        emitError(context, 23);
                        parseTag(context, 1 /* End */, parent);
                        continue;
                    }

                    // 其他情况不合法,当做普通文本处理
                    else {
                        emitError(context, 12, 2);
                        node = parseBogusComment(context);
                    }
                }

                // 正常的开始标签 "<a" 这种情况
                else if (/[a-z]/i.test(s[1])) {
                    node = parseElement(context, ancestors);
                }

                // 开始标签是 "<?" 这种可能是 xml ,当做普通文本处理
                else if (s[1] === "?") {
                    emitError(context, 21, 1);
                    node = parseBogusComment(context);
                }

                // 其他情况都是不合法的情况
                else {
                    emitError(context, 12, 1);
                }
            }
        }

        // 如果 node 没有值就说明上面的解析都没有成功,那么就当做普通文本来处理
        if (!node) {
            node = parseText(context, mode);
        }

        // 如果 node 是一个数组,则说明解析得到的是多个节点,需要将其展开
        if (isArray(node)) {
            for (let i = 0; i < node.length; i++) {
                pushNode(nodes, node[i]);
            }
        }

        // 否则就直接加入到 nodes 数组中
        else {
            pushNode(nodes, node);
        }
    }

    // 标记是否移除了空白节点
    let removedWhitespace = false;

    // mode 是 0,会进入这个分支
    if (mode !== 2 && mode !== 1) {

        // 根据解析选项中的 whitespace 配置,判断是否需要进行空白字符压缩
        const shouldCondense = context.options.whitespace !== "preserve";

        // 遍历解析得到的节点
        for (let i = 0; i < nodes.length; i++) {
            // 获取当前子节点
            const node = nodes[i];

            // 如果当前节点是一个普通文本节点
            if (node.type === 2) {

                // 如果不在 <pre> 标签内
                if (!context.inPre) {
                    // 全空白字符的文本检测
                    if (!/[^\t\r\n\f ]/.test(node.content)) {

                        // 获取上一个节点和下一个节点
                        const prev = nodes[i - 1];
                        const next = nodes[i + 1];


                        // 这里的情况比较复杂,拆解解释为如下:
                        // 1. 如果前后节点存在其中一个为空,或者进行了空白字符压缩
                        // 2. 并且前后节点都是文本节点或前后节点都是元素节点或前后节点一个是文本节点一个是元素节点
                        // 3. 并且文本节点内容包含换行符或回车符
                        if (!prev || !next || shouldCondense && (prev.type === 3 && next.type === 3 || prev.type === 3 && next.type === 1 || prev.type === 1 && next.type === 3 || prev.type === 1 && next.type === 1 && /[\r\n]/.test(node.content))) {
                            // 这里主要处理的是换行符和回车符的情况,换行是没有意义的,会删除这个节点
                            removedWhitespace = true;
                            nodes[i] = null;
                        }

                        // 不满足上述的条件就替换成一个空格
                        else {
                            node.content = " ";
                        }
                    }

                    // 压缩空白字符为一个空格
                    else if (shouldCondense) {
                        node.content = node.content.replace(/[\t\r\n\f ]+/g, " ");
                    }
                }

                // 压缩空白字符为一个空格
                else {
                    node.content = node.content.replace(/\r\n/g, "\n");
                }
            }

            // 如果是注释节点,并且禁用了注释节点,那么就删除这个节点
            else if (node.type === 3 && !context.options.comments) {
                removedWhitespace = true;
                nodes[i] = null;
            }
        }

        // 如果当前处于 <pre> 标签内,并且存在父节点以及父节点的标签是一个 <pre> 标签:
        if (context.inPre && parent && context.options.isPreTag(parent.tag)) {
            const first = nodes[0];

            // 如果第一个节点存在且是文本节点
            if (first && first.type === 2) {
                // 将文本节点内容中开头的回车换行符替换为空字符串
                first.content = first.content.replace(/^\r?\n/, "");
            }
        }
    }

    // 返回解析得到的节点,如果 removedWhitespace 为 true,则说明移除了空白节点,需要过滤掉
    return removedWhitespace ? nodes.filter(Boolean) : nodes;
}

这个函数的作用就是解析子节点,这里的子节点包括元素节点、文本节点、注释节点等等;

在这里并没有跟进到每个不同类型的节点解析中,因为本身这里的代码量就比较大,更加细节的代码量就更大了,所以这里只是大概的了解一下整个解析过程,后续会详细分析每个节点的解析过程;

3.4 getSelection

js 复制代码
function getSelection(context, start, end) {
    end = end || getCursor(context);
    return {
        start,
        end,
        source: context.originalSource.slice(start.offset, end.offset)
    };
}

这个函数的作用就是获取从解析开始位置到当前位置的范围,这个范围包括开始位置、结束位置、以及源代码片段;

3.5 createRoot

js 复制代码
function createRoot(children, loc = locStub) {
    return {
        type: 0,
        children,
        helpers: /* @__PURE__ */ new Set(),
        components: [],
        directives: [],
        hoists: [],
        imports: [],
        cached: 0,
        temps: 0,
        codegenNode: void 0,
        loc
    };
}

到这里就可以看到整个模板字符串转换为AST的过程了,并且也看到了AST的结构;

这里主要的内容还是整个children字段,它存放的是整个模板字符串解析得到的节点树,这个就是我们的AST

4. 总结

这一章主要讲解了Vue是如何将模板字符串转换为AST的,这里的AST是一个树状结构,里面包含了我们模板字符串的所有信息;

到这里我们就可以知道,我们的模板字符串是如何被解析的,这里的AST后续还会被解析为render函数,然后通过new Function(code)的方式生成一个render函数;

由于篇幅限制加上这一章的内容比较难以理解,所以这里只是大概的讲解了一下整个过程,后续继续讲解后续的流程;

历史章节

相关推荐
半开半落4 分钟前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt
麦麦大数据14 分钟前
基于vue+neo4j 的中药方剂知识图谱可视化系统
vue.js·知识图谱·neo4j
customer0819 分钟前
【开源免费】基于SpringBoot+Vue.JS医院管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·开源·intellij-idea
理想不理想v32 分钟前
vue经典前端面试题
前端·javascript·vue.js
不收藏找不到我33 分钟前
浏览器交互事件汇总
前端·交互
小阮的学习笔记1 小时前
Vue3中使用LogicFlow实现简单流程图
javascript·vue.js·流程图
YBN娜1 小时前
Vue实现登录功能
前端·javascript·vue.js
阳光开朗大男孩 = ̄ω ̄=1 小时前
CSS——选择器、PxCook软件、盒子模型
前端·javascript·css