现在,进入编译模块compiler-core,Vue3的编译分为以下三个阶段:
- parse:由template生成AST。
- transform:修改AST的结点内容,使其满足我们的需求。
- codegen:由AST生成render函数。
后两个阶段的耦合较强,而parse和它们是独立的。
AST的结构
在parse中,我们需要处理三种类型,分别是:
- 插值。例如
{{ message }}。 - 标签。例如
<div></div>。 - 文本。例如
some text
以及它们的组合:<div>some text {{ message }}</div>
该template生成的AST:
test('hello world', () => {
const ast = baseParse('<div>hi,{{message}}</div>');
expect(ast.children[0]).toStrictEqual({
type: NodeTypes.ELEMENT,
tag: 'div',
children: [
{
type: NodeTypes.TEXT,
content: 'hi,'
},
{
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'message'
}
}
]
});
});
可见,ast是一个对象,children属性是数组。由于外层只有一个根标签,数组中只有一个结点,如果是element类型结点,具有children...
每种类型的结点都是一个对象,具有type属性。
export const enum NodeTypes {
INTERPOLATION,
SIMPLE_EXPRESSION,
ELEMENT,
TEXT
}
AST中不同结点的结构:
- INTERPOLATION:content属性,是一个对象,其类型是表达式,值是插值的内容。
- ELEMENT:tag属性为标签名,children属性是数组。
- TEXT:content属性是文本内容。
生成AST的流程
和runtime-core时一样,先看看函数调用流程:
baseParse -> createParserContext -> parseChildren -> isEnd -> parseInterpolation
(用context.source保存template) (while循环,生成nodes)
-> parseElement -> parseChildren => createRoot => AST
(返回拥有children属性的对象)
-> parseText
baseParse是入口,生成context,调用createRoot,生成一个对象,该对象的children属性就是parseChildren得到的数组。
export function baseParse(content: string) {
const context = createParserContext(content);
return createRoot(parseChildren(context, []));
}
function parseChildren(context, ancestors) {
const nodes: any = [];
let node;
// while循环,调用不同结点的处理函数得到node,然后加入nodes数组
return nodes;
}
function createRoot(children) {
return {
children
};
}
context中,使用source保存传入的模板。每当我们处理一个结点,就要删除已经处理过的部分,source会不断进行slice操作,模板剩下的部分越来越短,直到处理完毕。使用advancedBy来进行“前进”操作。
function createParserContext(content: string): any {
return {
source: content
};
}
function advanceBy(context: any, length: number) {
context.source = context.source.slice(length);
}
插值和文本的处理
插值
如果当前context.source以插值开头,则进入插值的处理。
// parseChildren中的逻辑
if (context.source.startsWith('{{')) {
node = parseInterpolation(context);
}
插值的处理,假设插值是 {{message}}:
- 计算中间“message”的长度,先计算当前 }} 开始的下标,再减2({{ 的长度)即可。
- 移除 {{,使用
advancedBy。 - 提取出“message”并赋值给content。
- 移除“message”。
- 移除 }}。
把3、4步封装成函数parseTextData。
function parseInterpolation(context) {
// {{ message }}
// =>
// return {
// type: NodeTypes.INTERPOLATION,
// content: {
// type: NodeTypes.SIMPLE_EXPRESSION,
// content: 'message'
// }
// };
const openDelimiter = '{{';
const closeDelimiter = '}}';
const closeIndex = context.source.indexOf(
closeDelimiter,
openDelimiter.length
);
// 移除 {{
advanceBy(context, openDelimiter.length);
const rawContentLength = closeIndex - openDelimiter.length;
// 提取中间的content,并且移除
const rawContent = parseTextData(context, rawContentLength);
// edge case:去掉多余空格
const content = rawContent.trim();
// 移除 }}
advanceBy(context, closeDelimiter.length);
return {
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: content
}
};
}
// 封装:提取 + 移除
function parseTextData(context, length) {
const content = context.source.slice(0, length);
advanceBy(context, length);
return content;
}
有时候用户会在插值中加入空格,比如 {{ message }},为了使得content不包含多余空格,需要调用trim。
文本的处理
如果当前context.source不命中插值和tag的判断,则认为是text。
if (s.startsWith('{{')) {
node = parseInterpolation(context);
} else if (/^<[a-z]+>/i.test(s)) {
node = parseElement(context, ancestors);
} else {
node = parseText(context);
}
拿到文本,需要计算这段文本结束位置的索引endIndex,因为文本不像插值或标签有那么明确的结尾,文本之后可能会有<标签>或{{。
分别查找文本之后标签或插值的位置,取其较小值作为endIndex,然后调用parseTextData提取这段文本,并且从source中删除。
function parseText(context) {
// some text
// =>
// return {
// type: NodeTypes.TEXT,
// content: 'some text'
// };
const s = context.source;
const endTokens = ['<', '{{'];
let endIndex = s.length;
for (const token of endTokens) {
const index = s.indexOf(token);
if (index !== -1 && index < endIndex) {
endIndex = index;
}
}
const content = parseTextData(context, endIndex);
return {
type: NodeTypes.TEXT,
content: content
};
}
标签的处理
标签的处理是三种结点中最复杂的,如果只是考虑<div></div>自然很简单:
const enum TagType {
Start,
End
}
function parseElement(context) {
// <div></div>
// =>
// return {
// type: NodeTypes.ELEMENT,
// tag: 'div'
// };
const element: any = parseTag(context, TagType.Start);
// 处理tag中间的children,暂略
parseTag(context, TagType.End);
return element;
}
function parseTag(context, type: TagType) {
// 这里正则末尾不要加$,因为加$指context.source以这个标签结尾
// 只要匹配到标签就行了,不一定要让它是结尾的
const match: any = /^<\/?([a-z]*)>/i.exec(context.source);
const tag = match[1];
advanceBy(context, match[0].length);
if (type === TagType.End) return;
return {
type: NodeTypes.ELEMENT,
tag
};
}
给Tag区分Start和End两种类型。
如果匹配成功,
exec()方法返回一个数组。完全匹配成功的文本将作为返回数组的第一项,从第二项起,后续每项都对应一个匹配的捕获组。数组还具有以下额外的属性: 先处理Start,需要生成结点。
因此match[1]可以拿到标签名,match[0]可以拿到完全匹配的文本,即"<标签>",生成结点,并将它们从source中删除。
这只是标签的基本处理,我们还需要给结点添加chilren属性,存放它的子结点。方法是递归调用parseChildren。
// 待会说ancestors参数的作用
function parseElement(context, ancestors) {
const element: any = parseTag(context, TagType.Start);
// 处理tag中间的children
ancestors.push(element.tag);
element.children = parseChildren(context, ancestors);
parseTag(context, TagType.End);
return element;
}
在parseChildren中,由于孩子可能不止一个结点,需要用while循环。通过context.source,给出循环中止的条件。
function parseChildren(context, ancestors) {
const nodes: any = [];
let node;
while (!isEnd(context, ancestors)) {
const s = context.source;
// 这三种结点在处理时,都会用到advancedBy去更新source
if (s.startsWith('{{')) {
node = parseInterpolation(context);
} else if (/^<[a-z]+>/i.test(s)) {
node = parseElement(context, ancestors);
} else {
node = parseText(context);
}
nodes.push(node);
}
return nodes;
}
如何判断当前结点孩子结束呢,有两种可能:
- context.source为空,模板遍历结束了。
- 遇到了</标签>。
这里插入一嘴,大家应该注意到上面函数中都有ancestors参数,该参数是一个栈,它的原理就类似于力扣的“括号匹配”。因为用户可能没有把标签写完整,在遇到</标签>时,需要看它和此时位于栈顶的标签是否匹配,如果不匹配,就要报错。如果匹配,则出栈。
所以isEnd的实现是这样的:
function isEnd(context, ancestors) {
const s = context.source;
const parentTag = ancestors.at(-1);
// 结束情况二:遇到</标签>
if (s.startsWith(`</`)) {
if (parentTag) {
if (s.startsWith(`</${parentTag}>`)) {
ancestors.pop();
// 顺利结束
return true;
} else {
// <div><span></div>
throw new Error(`缺少结束标签:${parentTag}`);
// <div></span></div>
// 虽然本意是让span缺少开始标签,这种情况也可以认为是div缺少结束标签
}
} else {
// </div>
const endIndex = s.indexOf('>');
throw new Error(`缺少开始标签:${s.slice(2, endIndex)}`);
}
}
// 结束情况一:模板遍历结束,s === '',!s === true
return !s;
}
现在就能通过全部单元测试了:
import { NodeTypes } from '../src/ast';
import { baseParse } from '../src/parse';
describe('Parse', () => {
describe('interpolation', () => {
test('simple interpolation', () => {
const ast = baseParse('{{ message }}');
expect(ast.children[0]).toStrictEqual({
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'message'
}
});
});
});
describe('element', () => {
it('simple element div', () => {
const ast = baseParse('<div></div>');
expect(ast.children[0]).toStrictEqual({
type: NodeTypes.ELEMENT,
tag: 'div',
children: []
});
});
});
describe('text', () => {
it('simple text', () => {
const ast = baseParse('some text');
expect(ast.children[0]).toStrictEqual({
type: NodeTypes.TEXT,
content: 'some text'
});
});
});
test('hello world', () => {
const ast = baseParse('<div>hi,{{message}}</div>');
expect(ast.children[0]).toStrictEqual({
type: NodeTypes.ELEMENT,
tag: 'div',
children: [
{
type: NodeTypes.TEXT,
content: 'hi,'
},
{
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'message'
}
}
]
});
});
test('my test', () => {
const ast = baseParse('<div>some text</div>');
expect(ast.children[0]).toStrictEqual({
type: NodeTypes.ELEMENT,
tag: 'div',
children: [
{
type: NodeTypes.TEXT,
content: 'some text'
}
]
});
});
test('Nested element ', () => {
const ast = baseParse('<div><p>hi</p>{{message}}</div>');
expect(ast.children[0]).toStrictEqual({
type: NodeTypes.ELEMENT,
tag: 'div',
children: [
{
type: NodeTypes.ELEMENT,
tag: 'p',
children: [
{
type: NodeTypes.TEXT,
content: 'hi'
}
]
},
{
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'message'
}
}
]
});
});
test('should throw error when lack end tag', () => {
expect(() => {
baseParse('<div><span></div>');
}).toThrow(`缺少结束标签:span`);
});
test('should throw error when lack start tag', () => {
expect(() => {
baseParse('<div></span></div>');
}).toThrow(`缺少结束标签:div`);
});
test('should throw error when lack start tag', () => {
expect(() => {
baseParse('</span>');
}).toThrow(`缺少开始标签:span`);
});
});
parse的原理
parse本质是自动机,一图胜千言: