『手写Vue3』parse

133 阅读6分钟

现在,进入编译模块compiler-core,Vue3的编译分为以下三个阶段:

  • parse:由template生成AST。
  • transform:修改AST的结点内容,使其满足我们的需求。
  • codegen:由AST生成render函数。

后两个阶段的耦合较强,而parse和它们是独立的。

AST的结构

在parse中,我们需要处理三种类型,分别是:

  • 插值。例如{{ message }}
  • 标签。例如<div></div>
  • 文本。例如some text

以及它们的组合:<div>some text {{ message }}</div>

image.png 该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}}:

  1. 计算中间“message”的长度,先计算当前 }} 开始的下标,再减2({{ 的长度)即可。
  2. 移除 {{,使用advancedBy
  3. 提取出“message”并赋值给content。
  4. 移除“message”。
  5. 移除 }}。

把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本质是自动机,一图胜千言:

image.png