likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 13

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

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

解析函数

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

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

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

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

<!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 }},然后通过断点的方式来看看这个函数的执行过程;

<div id='app'>{{ 1 + 1 }}</div>
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), // 表示节点的位置信息
    };
}

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

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

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

parseComment

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

<div id='app'><!-- 这是注释 --></div>
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在处理注释上也识别了很多边界情况,最后解析完成之后的结果如下:

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

parseBogusComment

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

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

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

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

const app = createApp({
    template: `<!ABC abc>`
});
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会提示一个警告信息,最后解析完成之后的结果如下:

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

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中,因为会被浏览器进行转义,例如下面的代码;

const app = createApp({
    template: `<svg><![CDATA[<b>这是CDATA</b>]]></svg>`
});
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;
}

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

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

parseTag

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

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

const app = createApp({
    template: `</div>`
});
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>,最后解析完成之后的结果如下:

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

parseElement

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

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

const app = createApp({
    template: `<div></div>`
});
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;
}

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

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

parseText

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

<div id='app'>这是文本</div>
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)
    };
}

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

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

总结

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

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

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

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

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

历史章节