【源码&库】Vue3的AST转换细节全解析

紧接上一章的节奏,我们了解到了Vue3AST转换的过程,整体来说就是将一段字符串进行解析,每一段字符串都对应着一个节点;

而每个节点都会有不同的类型,对应着不同的解析函数,今天我们就来深扒这些具体解析函数的实现细节;

解析函数

根据上一章最后的parseChildren来看,我们可以看到其中有几个解析函数,分别如下:

  1. parseInterpolation: 解析插值表达式
  2. parseComment: 解析注释
  3. parseBogusComment: 解析伪注释
  4. parseCDATA: 解析CDATA
  5. parseTag: 解析标签
  6. parseElement: 解析元素
  7. parseText: 解析文本

我们就按照这个顺序来一一分析这些解析函数的实现细节;

根据昨天写的demo我们分别修改不同的字符,然后通过断点的方式来看看每个解析函数的执行过程;

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id='app'>
    <!-- todo 主要修改这里的内容 -->
</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>

parseInterpolation

首先是parseInterpolation,这个函数的作用是解析插值表达式,我们修改todo的内容为{{ 1 + 1 }},然后通过断点的方式来看看这个函数的执行过程;

html 复制代码
<div id='app'>{{ 1 + 1 }}</div>
js 复制代码
function parseInterpolation(context, mode) {
    // 分隔符的模板,对应着 `{{` 和 `}}`,这个 vue 提供了配置项,可以自定义
    const [open, close] = context.options.delimiters;
    
    // 通过 indexOf 来查找是否存在结束符号,如果不存在则报错
    const closeIndex = context.source.indexOf(close, open.length);
    if (closeIndex === -1) {
        emitError(context, 25);
        return void 0;
    }
    
    // 获取当前的位置,用于表示当前插值表达式的开始位置
    const start = getCursor(context);
    
    // 移动指针到表达式的开始位置,例如 `↓{{ 1 + 1 }}`,则移动到 `{{↓ 1 + 1 }}` 的位置
    advanceBy(context, open.length);
    
    // 获取内部表达式开始的位置,就是上面注释中第二个箭头的位置
    const innerStart = getCursor(context);
    
    // 获取内部表达式结束的位置,目前和上面的 innerStart 是一样的
    const innerEnd = getCursor(context);
    
    // 计算插值表达式内部原始内容的长度
    const rawContentLength = closeIndex - open.length;
    
    // source 是当前解析位置之后的字符串,现在的位置是上面注释中第二个箭头的位置,所以这里的 rawContent 就是 `1 + 1 }}`
    // 通过字符串的 slice 方法来截取字符串,这时候的 rawContent 就是 ` 1 + 1 `,也就是表达式的内容
    const rawContent = context.source.slice(0, rawContentLength);
    
    // 通过 parseTextData 来解析表达式的内容,这里主要是用来解析特殊字符的,例如 `&lt;` 会被解析成 `<`
    // 同时内部还会移动指针,当这个函数执行完之后,指针就会移动到 `{{ 1 + 1 ↓}}` 的位置
    // 同时返回的就是一个完整地表达式,例如 ` 1 + 1 `
    const preTrimContent = parseTextData(context, rawContentLength, mode);
    
    // 通过 trim 方法来去除表达式两边的空格,这里的 content 就是 `1 + 1`
    const content = preTrimContent.trim();
    
    // 计算处理后的表达式的开始位置的偏移量,例如 ` 1 + 1 ` 去除两边空格之后,就是 `1 + 1`,所以这里的 startOffset 就是 1
    const startOffset = preTrimContent.indexOf(content);
    if (startOffset > 0) {
        // 移动 innerStart 的位置,这里主要是确定表达式的开始位置
        // 执行完成之后,innerStart 的位置在 `{{ ↓1 + 1 }}` 的位置
        advancePositionWithMutation(innerStart, rawContent, startOffset);
    }
    
    // 计算处理后的表达式的结束位置的偏移量,rawContentLength = 7  preTrimContent = 7  content = 5  startOffset = 1
    // 所以这里的 endOffset 就是 7 - (7 - 5 - 1) = 6
    const endOffset = rawContentLength - (preTrimContent.length - content.length - startOffset);
    // 移动 innerEnd 的位置,这里主要是确定表达式的结束位置
    // 执行完成之后,innerEnd 的位置在 `{{ 1 + 1↓ }}` 的位置
    advancePositionWithMutation(innerEnd, rawContent, endOffset);
    
    // 移动指针,这里主要是移动到 `{{ 1 + 1 }}↓` 的位置
    advanceBy(context, close.length);
    return {
        type: 5, // 节点类型,表示插值表达式
        content: { // 包含插值表达式内容的对象
            type: 4, // 节点类型,表示文本节点
            isStatic: false, // 表示内容是否为静态的,这里是 false,因为表达式的内容是动态的
            // Set `isConstant` to false by default and will decide in transformExpression
            // 翻译:默认情况下将 `isConstant` 设置为 false,并将在 transformExpression 中决定
            constType: 0, 
            content, // 插值表达式的内容
            loc: getSelection(context, innerStart, innerEnd), // 表示节点的位置信息
        },
        loc: getSelection(context, start), // 表示节点的位置信息
    };
}

最后解析完成之后的结果如下:

这里解析的所有内容并不会执行,这里只是解析并不会做任何操作,真正执行的时候会通过transform函数来转换成render函数,然后执行,这个函数不在这里分析,后面会有专门的章节来分析;

parseComment

接下来是parseComment,这个函数的作用是解析注释,我们修改todo的内容为<!-- 这是注释 -->,同样的方式来看看这个函数的执行过程;

html 复制代码
<div id='app'><!-- 这是注释 --></div>
js 复制代码
function parseComment(context) {
    // 获取开始位置
    const start = getCursor(context);
    
    // 存储注释的内容
    let content;
    
    // 使用正则表达式匹配注释结束标记 `-->`,匹配的结果会在 match 中
    const match = /--(\!)?>/.exec(context.source);
    
    // 没有命中注释结束标记
    if (!match) {
        // 获取剩余的字符串
        content = context.source.slice(4);
        // 移动指针到最后
        advanceBy(context, context.source.length);
        // 并提交一个错误信息
        emitError(context, 7);
    }
    
    // 命中
    else {
        // 如果结束标记在开始标记之前,则报错,例如 `<!-->`
        if (match.index <= 3) {
            emitError(context, 0);
        }
        
        // 如果`match[1]`存在,则报错,例如 `<!---!>`
        if (match[1]) {
            emitError(context, 10);
        }
        
        // 截取注释内容,不包括结束标记
        content = context.source.slice(4, match.index);
        
        // 截取注释起始位置到结束标记起始位置之间的源代码片段
        const s = context.source.slice(0, match.index);
        
        // 初始化两个索引变量,用于检查是否存在嵌套的注释
        let prevIndex = 1, nestedIndex = 0;
        // s.indexOf("<!--", prevIndex) 第二个参数表示开始查找的位置,这里表示从第一个字符开始查找,这样就会跳过第一个注释的开始标记
        while ((nestedIndex = s.indexOf("<!--", prevIndex)) !== -1) {
            // 移动指针
            advanceBy(context, nestedIndex - prevIndex + 1);
            
            // nestedIndex + 4 < s.length  表示剩余字符不足以包含注释结束标记
            if (nestedIndex + 4 < s.length) {
                emitError(context, 16);
            }
            
            // 重新计算开始位置
            prevIndex = nestedIndex + 1;
        }
        
        // 移动指针到注释结束标记的位置
        advanceBy(context, match.index + match[0].length - prevIndex + 1);
    }
    return {
        type: 3,
        content,
        loc: getSelection(context, start)
    };
}

可以看到Vue在处理注释上也识别了很多边界情况,最后解析完成之后的结果如下:

parseBogusComment

接下来是parseBogusComment,这个函数主要处理的是错误的注释节点,如果只是直接写在html文件中的是会被浏览器自动修正的;

简单来说就是<!开头的节点通常是注释节点,当然还有类型声明节点,例如<!DOCTYPE html>,这个函数也会处理这类节点;

当然浏览器是不会解析<!DOCTYPE html>这种节点的,其次这种节点也不会被渲染到页面上,在浏览器中<!ABC abc>这种节点会被自动修正为<!--ABC abc-->

我们如果想进入这个函数就不能将模板字符串写到html中,而是要写到js中,例如下面的代码;

js 复制代码
const app = createApp({
    template: `<!ABC abc>`
});
js 复制代码
function parseBogusComment(context) {
    // 获取开始位置
    const start = getCursor(context);
    
    // 根据源代码的第二个字符确定注释内容的起始位置
    const contentStart = context.source[1] === "?" ? 1 : 2;
    
    // 用于存储注释节点的内容
    let content;
    
    // 查找结束标记 ">" 的索引位置
    const closeIndex = context.source.indexOf(">");
    
    // 没有找到结束标记
    if (closeIndex === -1) {
        // 将所有剩余的字符串作为注释节点的内容
        content = context.source.slice(contentStart);
        // 移动指针到最后
        advanceBy(context, context.source.length);
    } else {
        // 将结束标记之前的字符串作为注释节点的内容
        content = context.source.slice(contentStart, closeIndex);
        // 移动指针到结束标记的位置
        advanceBy(context, closeIndex + 1);
    }
    return {
        type: 3,
        content,
        loc: getSelection(context, start)
    };
}

由于这个是一个错误的注释节点,所以Vue会提示一个警告信息,最后解析完成之后的结果如下:

parseCDATA

接下来是parseCDATA,这个函数的作用是解析CDATA,我们修改todo的内容为<![CDATA[这是CDATA]]>,同样的方式来看看这个函数的执行过程;

CDATA是一种特殊的文本节点,它的内容不会被解析,也就是说<![CDATA[<b>这是CDATA</b>]]>这段内容不会被解析,而是直接渲染到页面上;

因为它并不是一个标准的html标签,所以在html中会和注释节点一样被自动修正,例如<![CDATA[<b>这是CDATA</b>]]>会被自动修正为<!--<![CDATA[<b-->这是CDATA</b>]]>

但是我们常见的xml可以在html中正常使用的就是svg标签,所以这段代码需要包裹到svg标签中,例如<svg><![CDATA[<b>这是CDATA</b>]]></svg>

这段代码也需要写到js中,因为会被浏览器进行转义,例如下面的代码;

js 复制代码
const app = createApp({
    template: `<svg><![CDATA[<b>这是CDATA</b>]]></svg>`
});
js 复制代码
function parseCDATA(context, ancestors) {
    // 移动到 `<![CDATA[` 的位置
    advanceBy(context, 9);
    
    // 这里是递归调用 parseChildren 函数,这里的 3 表示的是 CDATA 节点
    const nodes = parseChildren(context, 3, ancestors);
    
    // 由于上一步会解析完所有的内容,所以会剩下结束标记 `]]>`,这里移动指针到结束标记的位置
    // 这里 source.length 为 0 代表没有结束标记,所以报错
    if (context.source.length === 0) {
        emitError(context, 6);
    } else {
        // 移动指针到结束标记的位置,3是结束标记的长度
        advanceBy(context, 3);
    }
    return nodes;
}

最后解析完成之后的结果如下:

parseTag

接下来是parseTag,这个函数的作用是解析标签,我们可以通过一个异常标签快速的进入这个函数,例如</div>

这里测试代码还是要写到js中,因为浏览器会修正这些异常情况,例如下面的代码;

js 复制代码
const app = createApp({
    template: `</div>`
});
js 复制代码
function parseTag(context, type, parent) {
    // 获取开始位置
    const start = getCursor(context);
    
    // 通过正则表达式匹配标签的开始或结束标记,匹配的结果会在 match 中
    const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source);
    
    // 拿到匹配的结果
    const tag = match[1];
    
    // 根据标签的父节点来确定命名空间,例如上面的CDATA节点的父节点就是svg
    // 这里就会返回 svg 的命名空间,这个感兴趣可以自行尝试阅读
    const ns = context.options.getNamespace(tag, parent);
    
    // 移动指针到标签的结束位置,例如 `↓</div>`,则移动到 `</div↓>` 的位置
    advanceBy(context, match[0].length);
    
    // 移动指针到下一个非空白字符的位置,这里没有下一个非空白字符,所以指针不会移动
    advanceSpaces(context);
    
    // 获取当前的位置,这里的位置就是上面注释中第二个箭头的位置
    const cursor = getCursor(context);
    
    // 获取当前的源代码,也就是 `>`
    const currentSource = context.source;
    
    // 判断当前标签是不是 pre 标签,如果是则设置 context.inPre 为 true
    if (context.options.isPreTag(tag)) {
        context.inPre = true;
    }
    
    // 解析标签的属性
    let props = parseAttributes(context, type);
    
    // 这里主要是在判断一次标签是否带有 v-pre 指令,如果有则设置 context.inVPre 为 true
    if (type === 0 /* Start */ && !context.inVPre && props.some((p) => p.type === 7 && p.name === "pre")) {
        context.inVPre = true;
        extend(context, cursor);
        context.source = currentSource;
        
        // 然后重新解析标签的属性
        props = parseAttributes(context, type).filter((p) => p.name !== "v-pre");
    }
    
    // 是否是自闭合标签
    let isSelfClosing = false;
    
    // 如果没有内容,例如 `</div`,则报错
    if (context.source.length === 0) {
        emitError(context, 9);
    } else {
        // 判断是否是自闭合标签,自闭合标签的结束标记是 `/>`
        isSelfClosing = startsWith(context.source, "/>");
        
        // type 为 1 表示是需要结束标签的标签,例如 div,但是这里没有结束标签,所以报错
        if (type === 1 /* End */ && isSelfClosing) {
            emitError(context, 4);
        }
        
        // 移动指针到标签的结束位置,例如 `</div↓>`,则移动到 `</div>↓` 的位置
        // 如果是自闭合标签,则移动到 `/>↓` 的位置,所以是 2
        advanceBy(context, isSelfClosing ? 2 : 1);
    }
    
    // 如果有自闭合标签就代表解析已完成
    if (type === 1 /* End */) {
        return;
    }
    
    // 表示标签的类型,默认为 0
    let tagType = 0;
    
    // 如果不在 v-pre 指令的上下文中,则进行以下检查
    if (!context.inVPre) {
        
        // 如果是 <slot> 标签,将 tagType 设置为 2
        if (tag === "slot") {
            tagType = 2;
        } 
        
        // 如果是 <template> 标签,并且存在特殊的模板指令,将 tagType 设置为 3
        else if (tag === "template") {
            if (props.some(
                (p) => p.type === 7 && isSpecialTemplateDirective(p.name)
            )) {
                tagType = 3;
            }
        } 
        
        // 如果是组件标签,将 tagType 设置为 1
        else if (isComponent(tag, props, context)) {
            tagType = 1;
        }
    }
    
    // 属性解释应该都介绍过,就不写了
    return {
        type: 1,
        ns,
        tag,
        tagType,
        props,
        isSelfClosing,
        children: [],
        loc: getSelection(context, start),
        codegenNode: void 0
        // to be created during transform phase
    };
}

当然上面的示例是一个错误的标签,所以Vue会提示一个警告信息,并没有正常去将其塞到AST中;

用一个正确的标签来测试一下,例如<div></div>,最后解析完成之后的结果如下:

parseElement

接下来是parseElement,这个函数的作用是解析元素,其实这个函数依赖的是上面的parseTag函数,所以上面最开始会用一个异常情况来测试;

我们可以接着上面的正常代码进行测试,例如下面的代码;

js 复制代码
const app = createApp({
    template: `<div></div>`
});
js 复制代码
function parseElement(context, ancestors) {
    // 缓存当前 inPre 和 inVPre 的值
    const wasInPre = context.inPre;
    const wasInVPre = context.inVPre;
    
    // 获取父节点
    const parent = last(ancestors);
    
    // 解析开始标签
    const element = parseTag(context, 0 /* Start */, parent);
    
    // 判断是否离开了 pre 标签
    const isPreBoundary = context.inPre && !wasInPre;
    const isVPreBoundary = context.inVPre && !wasInVPre;
    
    // 如果当前标签是自闭合标签,或者是一个空元素(img、br、hr等),则直接返回
    if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
        
        // 如果离开了 pre 标签,则设置 context.inPre 为 false
        if (isPreBoundary) {
            context.inPre = false;
        }
        
        // 如果离开了 v-pre 指令,则设置 context.inVPre 为 false
        if (isVPreBoundary) {
            context.inVPre = false;
        }
        
        return element;
    }
    
    // 将当前标签推入到父节点的 children 中,表示进入了当前标签的上下文
    ancestors.push(element);
    
    // 根据元素和父元素,确定当前元素的文本模式
    const mode = context.options.getTextMode(element, parent);
    
    // 递归解析子节点,这里还是会调用 parseChildren 函数
    const children = parseChildren(context, mode, ancestors);
    
    // 移除当前元素, 表示离开了当前元素的上下文
    ancestors.pop();
    
    // 设置子节点
    element.children = children;
    
    // 如果是以结束标记开头 `</`,则解析结束标记
    if (startsWithEndTagOpen(context.source, element.tag)) {
        parseTag(context, 1 /* End */, parent);
    } else {
        // 否则代表当前标签没有闭合,所以报错
        emitError(context, 24, 0, element.loc.start);
        
        // 如果元素是 <script> 并且没有结束标签,还会检查是否存在注释内容
        if (context.source.length === 0 && element.tag.toLowerCase() === "script") {
            const first = children[0];
            if (first && startsWith(first.loc.source, "<!--")) {
                emitError(context, 8);
            }
        }
    }
    
    // 更新元素节点的位置信息
    element.loc = getSelection(context, element.loc.start);
    
    // 如果是 pre 标签,则设置 context.inPre 为 false,表示离开了 pre 标签的上下文
    if (isPreBoundary) {
        context.inPre = false;
    }
    
    // 如果是 v-pre 指令,则设置 context.inVPre 为 false,表示离开了 v-pre 指令的上下文
    if (isVPreBoundary) {
        context.inVPre = false;
    }
    
    // 返回元素节点
    return element;
}

最后解析完成之后的结果如下:

parseText

最后是parseText,这个函数的作用是解析文本,我们随便写点文本内容,例如这是文本,同样的方式来看看这个函数的执行过程;

html 复制代码
<div id='app'>这是文本</div>
js 复制代码
function parseText(context, mode) {
    // 根据文本模式选择合适的结束标记,3 表示 CDATA 模式,使用的结束标记是 `]]>`
    // 其他模式使用 `<` 和模板插值的开始标记(通常是 `{{`,可以自定义)
    // 这里的 `<` 代表下一个标签的开始,所以是文本结束
    // 同理 `{{` 代表下一个插值表达式的开始,所以也是文本结束
    const endTokens = mode === 3 ? ["]]>"] : ["<", context.options.delimiters[0]];
    
    // 获取结束标记的索引位置
    let endIndex = context.source.length;
    
    // 遍历结束标记数组
    for (let i = 0; i < endTokens.length; i++) {
        // 查找每个结束标记在源代码中的索引位置
        const index = context.source.indexOf(endTokens[i], 1);
        
        // 如果存在,则更新 endIndex 为最小的索引位置
        if (index !== -1 && endIndex > index) {
            endIndex = index;
        }
    }
    
    // 获取开始位置
    const start = getCursor(context);
    
    // 获取文本内容,这里的 endIndex 就是结束标记的索引位置
    // parseTextData 在插值表达式中已经介绍过了,源码相对来说比较简单,就不再介绍了
    const content = parseTextData(context, endIndex, mode);
    return {
        type: 2,
        content,
        loc: getSelection(context, start)
    };
}

最后解析完成之后的结果如下:

总结

到这里我们已经分析完了所有的AST解析的函数,这里分为很多种情况,但是大体来说就是如下几种情况:

  1. 解析节点,节点包括标签的解析
  2. 解析注释
  3. 解析插值表达式
  4. 解析文本
  5. 其他一些特殊情况,例如CDATADOCTYPE

Vue在解析这些节点的时候,同时处理了很多的边界情况,但是主要的还是标签的语法是否正确的问题,例如标签是否闭合、标签是否正确嵌套等;

而解析这些标签都是递归的方式,最终会形成一个树形结构的数据,这个就是AST,通过这次的分析我们也对AST有了一个大概的了解;

下一章将分析AST转换的过程,这个过程就是将AST转换成render函数的过程,这个过程也是Vue的核心所在;

历史章节

相关推荐
腾讯TNTWeb前端团队4 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰8 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪8 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪8 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy9 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom9 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom9 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom9 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom10 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom10 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试