Vue.js 3.0 核心源码解析 - 前百度、滴滴资深技术专家 - 拉勾教育

这一节课我们依然要解析 template 生成 AST 背后的实现原理,上节课,我们知道了 baseParse 主要就做三件事情:创建解析上下文解析子节点创建 AST 根节点

我们讲到了解析子节点,主要有四种情况,分别是注释节点的解析、插值的解析、普通文本的解析,以及元素节点的解析,这节课我们就到了最后的元素节点。

解析子节点

  • 元素节点的解析

最后,我们来看元素节点的解析过程,它会解析模板中的标签节点,举个例子:

  1. <div class="app">
  2. <hello :msg="msg"></hello>
  3. </div>

相对于前面三种类型的解析过程,元素节点的解析过程应该是最复杂的了,即当前代码 s 是以 < 开头,并且后面跟着字母,说明它是一个标签的开头,则走到元素节点的解析处理逻辑,我们来看 parseElement 的实现:

  1. function parseElement(context, ancestors) {
  2. const wasInPre = context.inPre
  3. const wasInVPre = context.inVPre
  4. const parent = last(ancestors)
  5. const element = parseTag(context, 0 , parent)
  6. const isPreBoundary = context.inPre && !wasInPre
  7. const isVPreBoundary = context.inVPre && !wasInVPre
  8. if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
  9. return element
  10. }
  11. ancestors.push(element)
  12. const mode = context.options.getTextMode(element, parent)
  13. const children = parseChildren(context, mode, ancestors)
  14. ancestors.pop()
  15. element.children = children
  16. if (startsWithEndTagOpen(context.source, element.tag)) {
  17. parseTag(context, 1 , parent)
  18. }
  19. else {
  20. emitError(context, 24 , 0, element.loc.start);
  21. if (context.source.length === 0 && element.tag.toLowerCase() === 'script') {
  22. const first = children[0];
  23. if (first && startsWith(first.loc.source, '<!--')) {
  24. emitError(context, 8 )
  25. }
  26. }
  27. }
  28. element.loc = getSelection(context, element.loc.start)
  29. if (isPreBoundary) {
  30. context.inPre = false
  31. }
  32. if (isVPreBoundary) {
  33. context.inVPre = false
  34. }
  35. return element
  36. }

可以看到,这个过程中 parseElement 主要做了三件事情:解析开始标签,解析子节点,解析闭合标签。

首先,我们来看解析开始标签的过程。主要通过 parseTag 方法来解析并创建一个标签节点,来看它的实现原理:

  1. function parseTag(context, type, parent) {
  2. const start = getCursor(context)
  3. const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source);
  4. const tag = match[1];
  5. const ns = context.options.getNamespace(tag, parent);
  6. advanceBy(context, match[0].length);
  7. advanceSpaces(context);
  8. const cursor = getCursor(context);
  9. const currentSource = context.source;
  10. let props = parseAttributes(context, type);
  11. if (context.options.isPreTag(tag)) {
  12. context.inPre = true;
  13. }
  14. if (!context.inVPre &&
  15. props.some(p => p.type === 7 && p.name === 'pre')) {
  16. context.inVPre = true;
  17. extend(context, cursor);
  18. context.source = currentSource;
  19. props = parseAttributes(context, type).filter(p => p.name !== 'v-pre');
  20. }
  21. let isSelfClosing = false;
  22. if (context.source.length === 0) {
  23. emitError(context, 9 );
  24. }
  25. else {
  26. isSelfClosing = startsWith(context.source, '/>');
  27. if (type === 1 && isSelfClosing) {
  28. emitError(context, 4 );
  29. }
  30. advanceBy(context, isSelfClosing ? 2 : 1);
  31. }
  32. let tagType = 0 ;
  33. const options = context.options;
  34. if (!context.inVPre && !options.isCustomElement(tag)) {
  35. const hasVIs = props.some(p => p.type === 7 && p.name === 'is');
  36. if (options.isNativeTag && !hasVIs) {
  37. if (!options.isNativeTag(tag))
  38. tagType = 1 ;
  39. }
  40. else if (hasVIs ||
  41. isCoreComponent(tag) ||
  42. (options.isBuiltInComponent && options.isBuiltInComponent(tag)) ||
  43. /^[A-Z]/.test(tag) ||
  44. tag === 'component') {
  45. tagType = 1 ;
  46. }
  47. if (tag === 'slot') {
  48. tagType = 2 ;
  49. }
  50. else if (tag === 'template' &&
  51. props.some(p => {
  52. return (p.type === 7 && isSpecialTemplateDirective(p.name));
  53. })) {
  54. tagType = 3 ;
  55. }
  56. }
  57. return {
  58. type: 1 ,
  59. ns,
  60. tag,
  61. tagType,
  62. props,
  63. isSelfClosing,
  64. children: [],
  65. loc: getSelection(context, start),
  66. codegenNode: undefined
  67. };
  68. }

parseTag 首先匹配标签文本结束的位置,并前进代码到标签文本后面的空白字符后,然后解析标签中的属性,比如 class、style 和指令等,parseAttributes 函数的实现我就不多说了,感兴趣的同学可以自己去看,它最终会解析生成一个 props 的数组,并前进代码到属性后。

接着去检查是不是一个 pre 标签,如果是则设置 context.inPre 为 true;再去检查属性中有没有 v-pre 指令,如果有则设置 context.inVPre 为 true,并重置上下文 context 和重新解析属性;接下来再去判断是不是一个自闭和标签,并前进代码到闭合标签后;最后判断标签类型,是组件、插槽还是模板。

parseTag 最终返回的值就是一个描述标签节点的对象,其中 type 表示它是一个标签节点,tag 表示标签名,tagType 表示标签的类型,content 表示文本的内容,isSelfClosing 表示是否是一个闭合标签,loc 表示文本的代码开头和结束的位置信息,children 是标签的子节点数组,会先初始化为空。

解析完开始标签后,再回到 parseElement,接下来第二步就是解析子节点,它把解析好的 element 节点添加到 ancestors 数组中,然后执行 parseChildren 去解析子节点,并传入 ancestors。

如果有嵌套的标签,那么就会递归执行 parseElement,可以看到,在 parseElement 的一开始,我们能获取 ancestors 数组的最后一个值拿到父元素的标签节点,这个就是我们在执行 parseChildren 前添加到数组尾部的。

解析完子节点后,我们再把 element 从 ancestors 中弹出,然后把 children 数组添加到 element.children 中,同时也把代码前进到子节点的末尾。

最后,就是解析结束标签,并前进代码到结束标签后,然后更新标签节点的代码位置。parseElement 最终返回的值就是这样一个标签节点 element。

其实 HTML 的嵌套结构的解析过程,就是一个递归解析元素节点的过程,为了维护父子关系,当需要解析子节点时,我们就把当前节点入栈,子节点解析完毕后,我们就把当前节点出栈,因此 ancestors 的设计就是一个栈的数据结构,整个过程是一个不断入栈和出栈的过程。

通过不断地递归解析,我们就可以完整地解析整个模板,并且标签类型的 AST 节点会保持对子节点数组的引用,这样就构成了一个树形的数据结构,所以整个解析过程构造出的 AST 节点数组就能很好地映射整个模板的 DOM 结构。

空白字符管理

在前面的解析过程中,有些时候我们会遇到空白字符的情况,比如前面的例子:

  1. <div class="app">
  2. <hello :msg="msg"></hello>
  3. </div>

div 标签到下一行会有一个换行符,hello 标签前面也有空白字符,这些空白字符在解析的过程中会被当作文本节点解析处理。但这些空白节点显然是没有什么意义的,所以我们需要移除这些节点,减少后续对这些没用意义的节点的处理,以提高编译效率。

我们先来看一下空白字符管理相关逻辑代码:

  1. function parseChildren(context, mode, ancestors) {
  2. const parent = last(ancestors)
  3. const ns = parent ? parent.ns : 0
  4. const nodes = []
  5. let removedWhitespace = false
  6. if (mode !== 2 ) {
  7. if (!context.inPre) {
  8. for (let i = 0; i < nodes.length; i++) {
  9. const node = nodes[i]
  10. if (node.type === 2 ) {
  11. if (!/[^\t\r\n\f ]/.test(node.content)) {
  12. const prev = nodes[i - 1]
  13. const next = nodes[i + 1]
  14. if (!prev ||
  15. !next ||
  16. prev.type === 3 ||
  17. next.type === 3 ||
  18. (prev.type === 1 &&
  19. next.type === 1 &&
  20. /[\r\n]/.test(node.content))) {
  21. removedWhitespace = true
  22. nodes[i] = null
  23. }
  24. else {
  25. node.content = ' '
  26. }
  27. }
  28. else {
  29. node.content = node.content.replace(/[\t\r\n\f ]+/g, ' ')
  30. }
  31. }
  32. else if (!(process.env.NODE_ENV !== 'production') && node.type === 3 ) {
  33. removedWhitespace = true
  34. nodes[i] = null
  35. }
  36. }
  37. }
  38. else if (parent && context.options.isPreTag(parent.tag)) {
  39. const first = nodes[0]
  40. if (first && first.type === 2 ) {
  41. first.content = first.content.replace(/^\r?\n/, '')
  42. }
  43. }
  44. }
  45. return removedWhitespace ? nodes.filter(Boolean) : nodes
  46. }

这段代码逻辑很简单,主要就是遍历 nodes,拿到每一个 AST 节点,判断是否为一个文本节点,如果是则判断它是不是空白字符;如果是则进一步判断空白字符是开头或还是结尾节点,或者空白字符与注释节点相连,或者空白字符在两个元素之间并包含换行符,如果满足上述这些情况,这些空白字符节点都应该被移除。

此外,不满足这三种情况的空白字符都会被压缩成一个空格,非空文本中间的空白字符也会被压缩成一个空格,在生产环境下注释节点也会被移除。

在 parseChildren 函数的最后,会过滤掉这些被标记清除的节点并返回过滤后的 AST 节点数组。

创建 AST 根节点

子节点解析完毕,baseParse 过程就剩最后一步创建 AST 根节点了,我们来看一下 createRoot 的实现:

  1. function createRoot(children, loc = locStub) {
  2. return {
  3. type: 0 ,
  4. children,
  5. helpers: [],
  6. components: [],
  7. directives: [],
  8. hoists: [],
  9. imports: [],
  10. cached: 0,
  11. temps: 0,
  12. codegenNode: undefined,
  13. loc
  14. }
  15. }

createRoot 的实现非常简单,它就是返回一个 JavaScript 对象,作为 AST 根节点。其中 type 表示它是一个根节点类型,children 是我们前面解析的子节点数组。除此之外,这个根节点还添加了其它的属性,当前我们并不需要搞清楚每一个属性代表的含义,这些属性我们在分析后续的处理流程中会介绍。

总结

好的,到这里我们这一节的学习也要结束啦,通过这节课的学习,你应该掌握 Vue.js 编译过程的第一步,即把 template 解析生成 AST 对象,整个解析过程是一个自顶向下的分析过程,也就是从代码开始,通过语法分析,找到对应的解析处理逻辑,创建 AST 节点,处理的过程中也在不断前进代码,更新解析上下文,最终根据生成的 AST 节点数组创建 AST 根节点。

最后,给你留一道思考题目,在 parseTag 的过程中,如果解析的属性有 v-pre 标签,为什么要回到之前的 context,重新解析一次?欢迎你在留言区与我分享。

本节课的相关代码在源代码中的位置如下:
packages/compiler-core/src/parse.ts
packages/compiler-core/src/ast.ts