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

Vue.js 3.0 的编译场景分服务端 SSR 编译web 编译,本文我们只分析 web 的编译。

我们先来看 web 编译的入口 compile 函数,分析它的实现原理:

  1. function compile(template, options = {}) {
  2. return baseCompile(template, extend({}, parserOptions, options, {
  3. nodeTransforms: [...DOMNodeTransforms, ...(options.nodeTransforms || [])],
  4. directiveTransforms: extend({}, DOMDirectiveTransforms, options.directiveTransforms || {}),
  5. transformHoist: null
  6. }))
  7. }

compile 函数支持两个参数,第一个参数 template 是待编译的模板字符串,第二个参数 options 是编译的一些配置信息。

compile 内部通过执行 baseCompile 方法完成编译工作,可以看到 baseCompile 在参数 options 的基础上又扩展了一些配置。对于这些编译相关的配置,我们后面会在具体的场景具体分析。

接下来,我们来看一下 baseCompile 的实现:

  1. function baseCompile(template, options = {}) {
  2. const prefixIdentifiers = false
  3. const ast = isString(template) ? baseParse(template, options) : template
  4. const [nodeTransforms, directiveTransforms] = getBaseTransformPreset()
  5. transform(ast, extend({}, options, {
  6. prefixIdentifiers,
  7. nodeTransforms: [
  8. ...nodeTransforms,
  9. ...(options.nodeTransforms || [])
  10. ],
  11. directiveTransforms: extend({}, directiveTransforms, options.directiveTransforms || {}
  12. )
  13. }))
  14. return generate(ast, extend({}, options, {
  15. prefixIdentifiers
  16. }))
  17. }

可以看到,baseCompile 函数主要做三件事情:解析 template 生成 ASTAST 转换生成代码

这一节课我们的目标就是解析 template 生成 AST 背后的实现原理

生成 AST 抽象语法树

你可以在百度百科中看到 AST 的定义,这里我就不赘述啦,对应到我们的 template,也可以用 AST 去描述它,比如我们有如下 template:

  1. <div class="app">
  2. <!-- 这是一段注释 -->
  3. <hello>
  4. <p>{{ msg }}</p>
  5. </hello>
  6. <p>This is an app</p>
  7. </div>

它经过第一步解析后,生成相应的 AST 对象:

  1. {
  2. "type": 0,
  3. "children": [
  4. {
  5. "type": 1,
  6. "ns": 0,
  7. "tag": "div",
  8. "tagType": 0,
  9. "props": [
  10. {
  11. "type": 6,
  12. "name": "class",
  13. "value": {
  14. "type": 2,
  15. "content": "app",
  16. "loc": {
  17. "start": {
  18. "column": 12,
  19. "line": 1,
  20. "offset": 11
  21. },
  22. "end": {
  23. "column": 17,
  24. "line": 1,
  25. "offset": 16
  26. },
  27. "source": "\"app\""
  28. }
  29. },
  30. "loc": {
  31. "start": {
  32. "column": 6,
  33. "line": 1,
  34. "offset": 5
  35. },
  36. "end": {
  37. "column": 17,
  38. "line": 1,
  39. "offset": 16
  40. },
  41. "source": "class=\"app\""
  42. }
  43. }
  44. ],
  45. "isSelfClosing": false,
  46. "children": [
  47. {
  48. "type": 3,
  49. "content": " 这是一段注释 ",
  50. "loc": {
  51. "start": {
  52. "column": 3,
  53. "line": 2,
  54. "offset": 20
  55. },
  56. "end": {
  57. "column": 18,
  58. "line": 2,
  59. "offset": 35
  60. },
  61. "source": "<!-- 这是一段注释 -->"
  62. }
  63. },
  64. {
  65. "type": 1,
  66. "ns": 0,
  67. "tag": "hello",
  68. "tagType": 1,
  69. "props": [],
  70. "isSelfClosing": false,
  71. "children": [
  72. {
  73. "type": 1,
  74. "ns": 0,
  75. "tag": "p",
  76. "tagType": 0,
  77. "props": [],
  78. "isSelfClosing": false,
  79. "children": [
  80. {
  81. "type": 5,
  82. "content": {
  83. "type": 4,
  84. "isStatic": false,
  85. "isConstant": false,
  86. "content": "msg",
  87. "loc": {
  88. "start": {
  89. "column": 11,
  90. "line": 4,
  91. "offset": 56
  92. },
  93. "end": {
  94. "column": 14,
  95. "line": 4,
  96. "offset": 59
  97. },
  98. "source": "msg"
  99. }
  100. },
  101. "loc": {
  102. "start": {
  103. "column": 8,
  104. "line": 4,
  105. "offset": 53
  106. },
  107. "end": {
  108. "column": 17,
  109. "line": 4,
  110. "offset": 62
  111. },
  112. "source": "{{ msg }}"
  113. }
  114. }
  115. ],
  116. "loc": {
  117. "start": {
  118. "column": 5,
  119. "line": 4,
  120. "offset": 50
  121. },
  122. "end": {
  123. "column": 21,
  124. "line": 4,
  125. "offset": 66
  126. },
  127. "source": "<p>{{ msg }}</p>"
  128. }
  129. }
  130. ],
  131. "loc": {
  132. "start": {
  133. "column": 3,
  134. "line": 3,
  135. "offset": 38
  136. },
  137. "end": {
  138. "column": 11,
  139. "line": 5,
  140. "offset": 77
  141. },
  142. "source": "<hello>\n <p>{{ msg }}</p>\n </hello>"
  143. }
  144. },
  145. {
  146. "type": 1,
  147. "ns": 0,
  148. "tag": "p",
  149. "tagType": 0,
  150. "props": [],
  151. "isSelfClosing": false,
  152. "children": [
  153. {
  154. "type": 2,
  155. "content": "This is an app",
  156. "loc": {
  157. "start": {
  158. "column": 6,
  159. "line": 6,
  160. "offset": 83
  161. },
  162. "end": {
  163. "column": 20,
  164. "line": 6,
  165. "offset": 97
  166. },
  167. "source": "This is an app"
  168. }
  169. }
  170. ],
  171. "loc": {
  172. "start": {
  173. "column": 3,
  174. "line": 6,
  175. "offset": 80
  176. },
  177. "end": {
  178. "column": 24,
  179. "line": 6,
  180. "offset": 101
  181. },
  182. "source": "<p>This is an app</p>"
  183. }
  184. }
  185. ],
  186. "loc": {
  187. "start": {
  188. "column": 1,
  189. "line": 1,
  190. "offset": 0
  191. },
  192. "end": {
  193. "column": 7,
  194. "line": 7,
  195. "offset": 108
  196. },
  197. "source": "<div class=\"app\">\n <!-- 这是一段注释 -->\n <hello>\n <p>{{ msg }}</p>\n </hello>\n <p>This is an app</p>\n</div>"
  198. }
  199. }
  200. ],
  201. "helpers": [],
  202. "components": [],
  203. "directives": [],
  204. "hoists": [],
  205. "imports": [],
  206. "cached": 0,
  207. "temps": 0,
  208. "loc": {
  209. "start": {
  210. "column": 1,
  211. "line": 1,
  212. "offset": 0
  213. },
  214. "end": {
  215. "column": 7,
  216. "line": 7,
  217. "offset": 108
  218. },
  219. "source": "<div class=\"app\">\n <!-- 这是一段注释 -->\n <hello>\n <p>{{ msg }}</p>\n </hello>\n <p>This is an app</p>\n</div>"
  220. }
  221. }

可以看到,AST 是树状结构,对于树中的每个节点,会有 type 字段描述节点的类型,tag 字段描述节点的标签,props 描述节点的属性,loc 描述节点对应代码相关信息,children 指向它的子节点对象数组。

当然 AST 中的节点还包含其他的一些属性,我在这里就不一一介绍了,你现在要理解的是 AST 中的节点是可以完整地描述它在模板中映射的节点信息

注意,AST 对象根节点其实是一个虚拟节点它并不会映射到一个具体节点,另外它还包含了其他的一些属性,这些属性在后续的 AST 转换的过程中会赋值,并在生成代码阶段用到。

那么,为什么要设计一个虚拟节点呢?

因为 Vue.js 3.0 和 Vue.js 2.x 有一个很大的不同——Vue.js 3.0 支持了 Fragment 的语法,即组件可以有多个根节点,比如:

  1. <img src="./logo.jpg">
  2. <hello :msg="msg"></hello>

这种写法在 Vue.js 2.x 中会报错,提示模板只能有一个根节点,而 Vue.js 3.0 允许了这种写法。但是对于一棵树而言,必须有一个根节点,所以虚拟节点在这种场景下就非常有用了,它可以作为 AST 的根节点,然后其 children 包含了 img 和 hello 的节点。

好了,到这里你已经大致了解了 AST,那么接下来我们看一下如何根据模板字符串来构建这个 AST 对象吧。

先来看一下 baseParse 的实现:

  1. function baseParse(content, options = {}) {
  2. const context = createPa rserContext(content, options)
  3. const start = getCursor(context)
  4. return createRoot(parseChildren(context, 0 , []), getSelection(context, start))
  5. }

baseParse 主要就做三件事情:创建解析上下文解析子节点创建 AST 根节点

创建解析上下文

首先,我们来分析创建解析上下文的过程,先来看 createParserContext 的实现:

  1. const defaultParserOptions = {
  2. delimiters: [`{{`, `}}`],
  3. getNamespace: () => 0 ,
  4. getTextMode: () => 0 ,
  5. isVoidTag: NO,
  6. isPreTag: NO,
  7. isCustomElement: NO,
  8. decodeEntities: (rawText) => rawText.replace(decodeRE, (_, p1) => decodeMap[p1]),
  9. onError: defaultOnError
  10. }
  11. function createParserContext(content, options) {
  12. return {
  13. options: extend({}, defaultParserOptions, options),
  14. column: 1,
  15. line: 1,
  16. offset: 0,
  17. originalSource: content,
  18. source: content,
  19. inPre: false,
  20. inVPre: false
  21. }
  22. }

解析上下文实际上就是一个 JavaScript 对象,它维护着解析过程中的上下文,其中 options 表示解析相关配置 ,column 表示当前代码的列号,line 表示当前代码的行号,originalSource 表示最初的原始代码,source 表示当前代码,offset 表示当前代码相对于原始代码的偏移量,inPre 表示当前代码是否在 pre 标签内,inVPre 表示当前代码是否在 v-pre 指令的环境下。

在后续解析的过程中,会始终维护和更新这个解析上下文,它能够表示当前解析的状态。

创建完解析上下文,接下来就开始解析子节点了。

解析子节点

我们先来看一下 parseChildren 函数的实现:

  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. return removedWhitespace ? nodes.filter(Boolean) : nodes
  7. }

parseChildren 的目的就是解析并创建 AST 节点数组。它有两个主要流程,第一个是自顶向下分析代码,生成 AST 节点数组 nodes;第二个是空白字符管理,用于提高编译的效率。

首先,我们来看生成 AST 节点数组的流程:

  1. function parseChildren(context, mode, ancestors) {
  2. const parent = last(ancestors)
  3. const ns = parent ? parent.ns : 0
  4. const nodes = []
  5. while (!isEnd(context, mode, ancestors)) {
  6. const s = context.source
  7. let node = undefined
  8. if (mode === 0 || mode === 1 ) {
  9. if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
  10. node = parseInterpolation(context, mode)
  11. }
  12. else if (mode === 0 && s[0] === '<') {
  13. if (s.length === 1) {
  14. emitError(context, 5 , 1)
  15. }
  16. else if (s[1] === '!') {
  17. if (startsWith(s, '<!--')) {
  18. node = parseComment(context)
  19. }
  20. else if (startsWith(s, '<!DOCTYPE')) {
  21. node = parseBogusComment(context)
  22. }
  23. else if (startsWith(s, '<![CDATA[')) {
  24. if (ns !== 0 ) {
  25. node = parseCDATA(context, ancestors)
  26. }
  27. else {
  28. emitError(context, 1 )
  29. node = parseBogusComment(context)
  30. }
  31. }
  32. else {
  33. emitError(context, 11 )
  34. node = parseBogusComment(context)
  35. }
  36. }
  37. else if (s[1] === '/') {
  38. if (s.length === 2) {
  39. emitError(context, 5 , 2)
  40. }
  41. else if (s[2] === '>') {
  42. emitError(context, 14 , 2)
  43. advanceBy(context, 3)
  44. continue
  45. }
  46. else if (/[a-z]/i.test(s[2])) {
  47. emitError(context, 23 )
  48. parseTag(context, 1 , parent)
  49. continue
  50. }
  51. else {
  52. emitError(context, 12 , 2)
  53. node = parseBogusComment(context)
  54. }
  55. }
  56. else if (/[a-z]/i.test(s[1])) {
  57. node = parseElement(context, ancestors)
  58. }
  59. else if (s[1] === '?') {
  60. emitError(context, 21 , 1)
  61. node = parseBogusComment(context)
  62. }
  63. else {
  64. emitError(context, 12 , 1)
  65. }
  66. }
  67. }
  68. if (!node) {
  69. node = parseText(context, mode)
  70. }
  71. if (isArray(node)) {
  72. for (let i = 0; i < node.length; i++) {
  73. pushNode(nodes, node[i])
  74. }
  75. }
  76. else {
  77. pushNode(nodes, node)
  78. }
  79. }
  80. }

这些代码看起来很复杂,但它的思路就是自顶向下地去遍历代码,然后根据不同的情况尝试去解析代码,然后把生成的 node 添加到 AST nodes 数组中。在解析的过程中,解析上下文 context 的状态也是在不断发生变化的,我们可以通过 context.source 拿到当前解析剩余的代码 s,然后根据 s 不同的情况走不同的分支处理逻辑。在解析的过程中,可能会遇到各种错误,都会通过 emitError 方法报错。

我们没有必要去了解所有代码的分支细节,只需要知道大致的解析思路即可,因此我们这里只分析四种情况:注释节点的解析、插值的解析、普通文本的解析,以及元素节点的解析。

  • 注释节点的解析

首先,我们来看注释节点的解析过程,它会解析模板中的注释节点,比如 <!-- 这是一段注释 -->, 即当前代码 s 是以 <!-- 开头的字符串,则走到注释节点的解析处理逻辑。

我们来看 parseComment 的实现:

  1. function parseComment(context) {
  2. const start = getCursor(context)
  3. let content
  4. const match = /--(\!)?>/.exec(context.source)
  5. if (!match) {
  6. content = context.source.slice(4)
  7. advanceBy(context, context.source.length)
  8. emitError(context, 7 )
  9. }
  10. else {
  11. if (match.index <= 3) {
  12. emitError(context, 0 )
  13. }
  14. if (match[1]) {
  15. emitError(context, 10 )
  16. }
  17. content = context.source.slice(4, match.index)
  18. const s = context.source.slice(0, match.index)
  19. let prevIndex = 1, nestedIndex = 0
  20. while ((nestedIndex = s.indexOf('<!--', prevIndex)) !== -1) {
  21. advanceBy(context, nestedIndex - prevIndex + 1)
  22. if (nestedIndex + 4 < s.length) {
  23. emitError(context, 16 )
  24. }
  25. prevIndex = nestedIndex + 1
  26. }
  27. advanceBy(context, match.index + match[0].length - prevIndex + 1)
  28. }
  29. return {
  30. type: 3 ,
  31. content,
  32. loc: getSelection(context, start)
  33. }
  34. }

其实,parseComment 的实现很简单,首先它会利用注释结束符的正则表达式去匹配代码,找出注释结束符。如果没有匹配到或者注释结束符不合法,都会报错。
如果找到合法的注释结束符,则获取它中间的注释内容 content,然后截取注释开头到结尾之间的代码,并判断是否有嵌套注释,如果有嵌套注释也会报错。

接着就是通过调用 advanceBy 前进代码到注释结束符后,这个函数在整个模板解析过程中经常被调用,它的目的是用来前进代码,更新 context 解析上下文,我们来看一下它的实现:

  1. function advanceBy(context, numberOfCharacters) {
  2. const { source } = context
  3. advancePositionWithMutation(context, source, numberOfCharacters)
  4. context.source = source.slice(numberOfCharacters)
  5. }
  6. function advancePositionWithMutation(pos, source, numberOfCharacters = source.length) {
  7. let linesCount = 0
  8. let lastNewLinePos = -1
  9. for (let i = 0; i < numberOfCharacters; i++) {
  10. if (source.charCodeAt(i) === 10 ) {
  11. linesCount++
  12. lastNewLinePos = i
  13. }
  14. }
  15. pos.offset += numberOfCharacters
  16. pos.line += linesCount
  17. pos.column =
  18. lastNewLinePos === -1
  19. ? pos.column + numberOfCharacters
  20. : numberOfCharacters - lastNewLinePos
  21. return pos
  22. }

advanceBy 的实现很简单,主要就是更新解析上下文 context 中的 source 来前进代码,同时更新 offset、line、column 等和代码位置相关的属性。

为了更直观地说明 advanceBy 的作用,前面的示例可以通过下图表示:

12 | 模板解析:构造 AST 的完整流程是怎样的?(上) - 图1

经过 advanceBy 前进代码到注释结束符后,表示注释部分代码处理完毕,可以继续解析后续代码了。

parseComment 最终返回的值就是一个描述注释节点的对象,其中 type 表示它是一个注释节点,content 表示注释的内容,loc 表示注释的代码开头和结束的位置信息。

  • 插值的解析

接下来,我们来看插值的解析过程,它会解析模板中的插值,比如 {{msg}} ,即当前代码 s 是以 {{ 开头的字符串,且不在 v-pre 指令的环境下(v-pre 会跳过插值的解析),则会走到插值的解析处理逻辑 parseInterpolation 函数,我们来看它的实现:

  1. function parseInterpolation(context, mode) {
  2. const [open, close] = context.options.delimiters
  3. const closeIndex = context.source.indexOf(close, open.length)
  4. if (closeIndex === -1) {
  5. emitError(context, 25 )
  6. return undefined
  7. }
  8. const start = getCursor(context)
  9. advanceBy(context, open.length)
  10. const innerStart = getCursor(context)
  11. const innerEnd = getCursor(context)
  12. const rawContentLength = closeIndex - open.length
  13. const rawContent = context.source.slice(0, rawContentLength)
  14. const preTrimContent = parseTextData(context, rawContentLength, mode)
  15. const content = preTrimContent.trim()
  16. const startOffset = preTrimContent.indexOf(content)
  17. if (startOffset > 0) {
  18. advancePositionWithMutation(innerStart, rawContent, startOffset)
  19. }
  20. const endOffset = rawContentLength - (preTrimContent.length - content.length - startOffset)
  21. advancePositionWithMutation(innerEnd, rawContent, endOffset);
  22. advanceBy(context, close.length)
  23. return {
  24. type: 5 ,
  25. content: {
  26. type: 4 ,
  27. isStatic: false,
  28. isConstant: false,
  29. content,
  30. loc: getSelection(context, innerStart, innerEnd)
  31. },
  32. loc: getSelection(context, start)
  33. }
  34. }

parseInterpolation 的实现也很简单,首先它会尝试找插值的结束分隔符,如果找不到则报错。

如果找到,先前进代码到插值开始分隔符后,然后通过 parseTextData 获取插值中间的内容并前进代码到插值内容后,除了普通字符串,parseTextData 内部会处理一些 HTML 实体符号比如 &nbsp 。由于插值的内容可能是前后有空白字符的,所以最终返回的 content 需要执行一下 trim 函数。

为了准确地反馈插值内容的代码位置信息,我们使用了 innerStart 和 innerEnd 去记录插值内容(不包含空白字符)的代码开头和结束位置。

接着就是前进代码到插值结束分隔符后,表示插值部分代码处理完毕,可以继续解析后续代码了。

parseInterpolation 最终返回的值就是一个描述插值节点的对象,其中 type 表示它是一个插值节点,loc 表示插值的代码开头和结束的位置信息,而 content 又是一个描述表达式节点的对象,其中 type 表示它是一个表达式节点,loc 表示内容的代码开头和结束的位置信息,content 表示插值的内容。

  • 普通文本的解析

接下来,我们来看普通文本的解析过程,它会解析模板中的普通文本,比如 This is an app ,即当前代码 s 既不是以 {{ 插值分隔符开头的字符串,也不是以 < 开头的字符串,则走到普通文本的解析处理逻辑,我们来看 parseText 的实现:

  1. function parseText(context, mode) {
  2. const endTokens = ['<', context.options.delimiters[0]]
  3. if (mode === 3 ) {
  4. endTokens.push(']]>')
  5. }
  6. let endIndex = context.source.length
  7. for (let i = 0; i < endTokens.length; i++) {
  8. const index = context.source.indexOf(endTokens[i], 1)
  9. if (index !== -1 && endIndex > index) {
  10. endIndex = index
  11. }
  12. }
  13. const start = getCursor(context)
  14. const content = parseTextData(context, endIndex, mode)
  15. return {
  16. type: 2 ,
  17. content,
  18. loc: getSelection(context, start)
  19. }
  20. }

同样,parseText 的实现很简单。对于一段文本来说,都是在遇到 < 或者插值分隔符 {{ 结束,所以会遍历这些结束符,匹配并找到文本结束的位置,然后执行 parseTextData 获取文本的内容,并前进代码到文本的内容后。

parseText 最终返回的值就是一个描述文本节点的对象,其中 type 表示它是一个文本节点,content 表示文本的内容,loc 表示文本的代码开头和结束的位置信息。

这部分内容比较多,所以本课时的内容就先到这。下节课中,我们接着分析元素节点,继续解析 template 生成 AST 的背后实现原理。

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