跳到主要内容

认识

2023年07月29日
柏拉文
越努力,越幸运

一、认识


Vue.js 中的编译源码是组件模版,目标代码是渲染函数。详细而言: Vue.js 模版编译器会首先对模版进行词法分析和语法分析, 得到 AST 模版。接着, 将模版 AST 转换成 JavaScript AST。 最后根据 JavaScript AST 生成 JavaScript 代码,即渲染函数代码。Vue.js 模版编译器的工作流程如下:

Template 模版  -->  词法分析 --> 语法分析 --> Template 模版 AST --> Transformer --> JavaScript AST --> 代码生成 --> 渲染函数

1.1 模版 AST

ASTabstract syntax tree 的首字母缩写, 即抽象语法树。所谓模版AST, 其实是用来描述模版的抽象语法树。如下所示:

<div>
<h1 v-if="ok">Vue Template</h1>
</div>

这段模版会被编译成如下 AST

const ast = {
type: 'Root',
children: [
{
type: 'Element',
tag: 'div',
children: [
……
]
}
]
}

可以看到, AST 其实就是一个具有层级结构的对象。**模版AST**具有与模版同构的嵌套结构。每一颗AST都有一个逻辑上的根节点,其类型为Root。模版中真正的根节点则作为Root节点的children存在。

通过封装 parse 函数完成对模版的词法分析和语法分析,得到模版AST。过程如下:

const template = `
<div>
<h1 v-if="ok">Vue Template</h1>
</div>
`
const templateAST = parse(template);

得到 templateAST 之后, 我们对其进行语义分析, 分析如下:

  1. 检查 v-else 指令是否存在相符的 v-if 指令

  2. 分析属性值是否是静态的, 是否是常量等

  3. 插槽是否引用上层作用域的变量等

1.2 模版解析

parser 解析器用来将模版字符串解析为模版AST。解析器的入参是 Template 模版字符串, 解析器通过有限自动状态机会逐个读取字符串模版中的字符, 完成对模版的标记化,并根据一定的规则将整个字符切割为一个个的 Token

有限状态机的状态如下:

  1. 初始状态:

  2. 标签开始状态: 在初始状态下, 遇到 < 字符, 状态机会迁移到标签开始状态

  3. 标签名称状态: 在标签开始状态下, 遇到 d 或者 p 等字符串, 状态机会进入标签名称状态, 遇到 > 字符时, 状态机会从标签名称状态迁移到初始状态, 并记录在标签名称状态下产生的标签名称。

  4. 文本状态: 读取到字符串, 状态机进入到文本状态

  5. 结束标签状态: 读取到 < , 状态机进入结束标签状态

有以下模版:

<div>
<p> hello </p>
<p> world </p>
</div>

通过 有限状态机 转化为 Tokens 如下:

Tokens 

开始标签 <div>
开始标签 <p>
文本节点 hello
结束标签 </p>
开始标签 <p>
文本标签 world
结束标签 </p>
结束标签 </div>

1.3 构造 AST

通过有限自动状态机Template 模版字符串解析成了一个个的 Token, 这些个 Token 通过递归下降算法构造生成 AST递归下降算法(Recursive Descent Parsing) 左边是一个非终结符(Non-terminal),右边是它的产生式(Production Rule)。在语法解析的过程中,左边会被右边的替代。如果替代之后还有非终结符,那么继续这个替代过程,直到最后全部都是终结符(Terminal),也就是Token。只有终结符才可以成为AST的叶子节点,这个过程,也叫做推导(Derivation)的过程。上级文法嵌套下级文法,上级的算法调用下级的算法,表现在生成AST中,上级算法生成上级节点,下级算法生成下级节点。这就是下降的含义。

递归下降算法具体逻辑为: 根据 Token 列表构建 AST 的过程, 其实就是对 Token 列表进行扫描的过程。从第一个 Token 开始, 顺序地扫描整个 Token 列表, 直到列表中的所有 Token 处理完毕。在这个过程中, 我们需要维护一个 Stack 栈结构, 这个栈用来维护元素间的父子关系。每遇到一个开始标签节点, 我们就要构造一个 Element 类型的 AST 节点, 并将其压入栈中。类似的, 每遇到一个结束标签节点, 我们就将当前栈顶的节点弹出。这样, 栈顶的节点将始终充当父节点的角色。扫描过程中遇到的所有节点, 都会作为当前栈顶节点的子节点,并添加到栈顶节点的 children 属性下。

有以下模版:

<div>
<p> hello </p>
<p> world </p>
</div>

通过 有限状态机 转化为 Tokens 如下:

Tokens

开始标签 <div>
开始标签 <p>
文本节点 hello
结束标签 </p>
开始标签 <p>
文本标签 world
结束标签 </p>
结束标签 </div>

通过 递归下降算法 转化为 AST 如下:

Tokens                       (Array)                  AST

开始标签 <div>
开始标签 <p>
文本节点 hello
结束标签 </p> => =>
开始标签 <p>
文本标签 world
结束标签 </p>
结束标签 </div> Root

然后从开始标签 <div> 开始自上而下进行扫描, 依次进入栈Array, 并加入 AST。遇到结束标签, 将结束标签 Tokens 和之前的 Tokens 全部出栈。

二、Parser 细节


2.1 移动游标

advanceBy()source 待解析的字符串去除已解析的部分,并向右移动游标

function advanceBy(context, numberOfCharacters) {
const { source } = context;
context.source = source.slice(numberOfCharacters);
}

2.2 提取标签

标签Tag正则表达式: 用于解析标签Tag, 提取之后, 注意要通过 advanceBy 截断 tag

function advanceBy(context,numberOfString){
const { source } = context;
context.source = source.slice(numberOfString);
}
function parseTag(context){
const tagReg = /^<\/?([a-z][^\r\t\n\f\s/>]*)/i;
const match = tagReg.exec(context.source);
const tag = match[1];
advanceBy(context,match[0].length);
return tag;
}

const context = {
source: '<div></div>'
};

const tag = parseTag(context);
console.log(tag)
console.log(context)

2.3 提取属性

单个属性

function advanceBy(context, numberOfString) {
const { source } = context;
context.source = source.slice(numberOfString);
}

function advanceSpaces(context) {
const spaceReg = /^[\r\t\n\f\s]+/;
const match = spaceReg.exec(context.source);
if (match) {
advanceBy(context, match[0].length);
}
}

function parseTextData(context, length) {
const rawText = context.source.slice(0, length);
advanceBy(context, length);
return rawText;
}

function parseAttribute(context) {
const attrReg = /^[^\r\t\n\f\s/>][^\r\t\n\f\s/>=]*/;
const match = attrReg.exec(context.source);
const name = match[0];
advanceBy(context, name.length);
return name;
}

function parseAttributeValue(context) {
let value = '';
const quote = context.source[0];
const isQuote = quote === '"' || quote === `'`;
if (isQuote) {
advanceBy(context, 1);
const endIndex = context.source.indexOf(quote);
if (endIndex === -1) {
value = parseTextData(context, context.source.length);
} else {
value = parseTextData(context, endIndex);
}
}
return value;
}

const context = {
source: 'key = "value"'
};

const key = parseAttribute(context);
console.log('key', key);
advanceSpaces(context);
advanceBy(context, 1);
advanceSpaces(context);
const value = parseAttributeValue(context);
console.log('value', value);

多个属性

function advanceBy(context, numberOfString) {
const { source } = context;
context.source = source.slice(numberOfString);
}

function advanceSpaces(context) {
const spaceReg = /^[\r\t\n\f\s]+/;
const match = spaceReg.exec(context.source);
if (match) {
advanceBy(context, match[0].length);
}
}

function parseTextData(context, length) {
const rawText = context.source.slice(0, length);
advanceBy(context, length);
return rawText;
}

function parseAttributeValue(context) {
let value = '';
const quote = context.source[0];
const isQuote = quote === '"' || quote === `'`;
if (isQuote) {
advanceBy(context, 1);
const endIndex = context.source.indexOf(quote);
if (endIndex === -1) {
value = parseTextData(context, context.source.length);
} else {
value = parseTextData(context, endIndex);
advanceBy(context,1);
}
}
return value;
}

function parseAttribute(context) {
const attrReg = /^[^\r\t\n\f\s/>][^\r\t\n\f\s/>=]*/;
const match = attrReg.exec(context.source);
const name = match[0];
advanceBy(context, name.length);

let value;
advanceSpaces(context);
advanceBy(context, 1);
advanceSpaces(context);
value = parseAttributeValue(context);

return {
name,
value
};
}

function parseAttributes(context) {
const props = [];
while (context.source.length > 0) {
const attr = parseAttribute(context);
props.push(attr);
advanceSpaces(context);
}
return props;
}

const context = {
source: 'key1 = "value1" key2="value2" key3="value3"'
};

const props = parseAttributes(context);
console.log("props",props);

2.4 结束开始标签

结束开始标签 的时候有两种情况:

  • 单标签: /> , 需要移动两个字符

  • 双标签: > , 需要移动一个字符

function advanceBy(context, numberOfString) {
const { source } = context;
context.source = source.slice(numberOfString);
}

function startsWith(source, searchString) {
return source.startsWith(searchString);
}

function parseTag(context) {
const tagReg = /^<\/?([a-z][^\r\t\n\f\s/>]*)/i;
const match = tagReg.exec(context.source);
const tag = match[1];
advanceBy(context, match[0].length);

let isSelfClosing = startsWith(context.source, '/>');
advanceBy(context, isSelfClosing ? 2 : 1);

return tag;
}

const context = {
source: '<div></div>'
};

const tag = parseTag(context);
console.log(tag);
console.log(context);

2.5 提取文字内容

提取文字内容 就是说提取截止到 < 或者 {{ 之前的文字内容

function advanceBy(context, numberOfString) {
const { source } = context;
context.source = source.slice(numberOfString);
}

function startsWith(source, searchString) {
return source.startsWith(searchString);
}

function parseTextData(context, length) {
const rawText = context.source.slice(0, length);
advanceBy(context, length);
return rawText;
}

function parseText(context) {
const endTokens = ['<'];
let endIndex = context.source.length;
for (let i = 0; i < endTokens.length; i++) {
const index = context.source.indexOf(endTokens[i], 1);
if (index !== -1 && endIndex > index) {
endIndex = index;
}
}

const content = parseTextData(context, endIndex);
return content;
}

const context = {
source: 'fdsfdsfdslkj<'
};

const tag = parseText(context);
console.log(tag);
console.log(context);