coverPiccoverPic

Parse:Template 模板 → AST(v2)

前言

原文再续,书接上一回,上次讲到baseCompile经过parseoptimizegenerate 3 个阶段,把 template 模板转化为虚拟 DOM,这里主要讨论parse阶段发生的事情。

graph LR
A[template 模板]-->|parse|B[AST 抽象语法树]-->|optimize|C[优化后的 AST]-->|generate|D[render]-->|return|E[虚拟 DOM]

parse把模板转化为 AST 节点,举个例子:

html
  1. <div>
  2. <div v-for="(item, index) in arr" @click="clickHandler" class="item">
  3. Current Value is {{ item }}
  4. </div>
  5. </div>

<div v-for="(item, index) in arr" @click="clickHandler" class="item">解析后生成如下 AST 节点:

json
  1. {
  2. "type": 1,
  3. "tag": "div",
  4. "attrsList": [
  5. {
  6. "name": "@click",
  7. "value": "clickHandler",
  8. "start": 44,
  9. "end": 65
  10. },
  11. {
  12. "name": "class",
  13. "value": "item",
  14. "start": 66,
  15. "end": 78
  16. }
  17. ],
  18. "attrsMap": {
  19. "v-for": "(item, index) in arr",
  20. "@click": "clickHandler",
  21. "class": "item"
  22. },
  23. "rawAttrsMap": {
  24. "v-for": {
  25. "name": "v-for",
  26. "value": "(item, index) in arr",
  27. "start": 15,
  28. "end": 43
  29. },
  30. "@click": {
  31. "name": "@click",
  32. "value": "clickHandler",
  33. "start": 44,
  34. "end": 65
  35. },
  36. "class": {
  37. "name": "class",
  38. "value": "item",
  39. "start": 66,
  40. "end": 78
  41. }
  42. },
  43. "parent": {
  44. "type": 1,
  45. "tag": "div",
  46. "attrsList": [],
  47. "attrsMap": {},
  48. "rawAttrsMap": {},
  49. "children": [],
  50. "start": 0,
  51. "end": 5
  52. },
  53. "children": [],
  54. "start": 10,
  55. "end": 79,
  56. "for": "arr",
  57. "alias": "item",
  58. "iterator1": "index"
  59. }

入口文件

来看 src/compiler/parser/index.ts,模板到 AST 的转换规则比较复杂,而且有不少平时不会关注的部分,这里主要看看开始标签、结束标签和文本的处理。

graph LR
baseCompile-->A["parse(template.trim())"]-->parseHTML

parse函数组装 AST 抽象语法树,需要注意这几个变量:

ts
  1. // 记录了 AST 的未闭合节点,确保 AST 和模板层次结构一致
  2. const stack: any[] = []
  3. // AST 的根节点,也作为 parse 函数的结果返回。
  4. let root

parseparseHTML传入了一系列用来生成 AST 的钩子函数,现在先不用管:

ts
  1. parseHTML(template, {
  2. // ...
  3. start(tag, attrs, unary, start, end) {
  4. // 处理开始标签
  5. // ...
  6. },
  7. end(tag, start, end) {
  8. // 处理结束标签
  9. // ...
  10. },
  11. chars(text: string, start?: number, end?: number) {
  12. // 处理文本标签
  13. // ...
  14. },
  15. // ...
  16. })

下面来看parseHTML,开始解析模板之前,先注意到这几个变量:

ts
  1. export function parseHTML(html, options: HTMLParserOptions) {
  2. // 记录了之前匹配到的未闭合开始标签
  3. const stack: any[] = []
  4. // 记录了当前的模板字符串下标偏移
  5. let index = 0
  6. // last 记录了未匹配的剩下的模板字符串,lastTag 则记录了上一个未闭合的开始标签
  7. let last, lastTag
  8. // ...
  9. }

index记录了相对于模板html开头的当前操作下标的偏移,每成功匹配一段模板,index会发生偏移,已经匹配的部分也会从html中扔掉,大部分截取和位移操作都是由这个函数完成的:

ts
  1. function advance(n) {
  2. index += n
  3. html = html.substring(n)
  4. }

标签匹配

先不管那些配置,parse最终调用parseHTML解析模板,代码如下所示:

ts
  1. export function parseHTML(html, options: HTMLParserOptions) {
  2. const stack: any[] = []
  3. let index = 0
  4. let last, lastTag
  5. while (html) {
  6. last = html
  7. // Make sure we're not in a plaintext content element like script/style
  8. if (!lastTag || !isPlainTextElement(lastTag)) {
  9. let textEnd = html.indexOf('<')
  10. if (textEnd === 0) {
  11. // Comment:
  12. if (comment.test(html)) {
  13. // ...
  14. continue
  15. }
  16. // Conditional comment:
  17. if (conditionalComment.test(html)) {
  18. // ...
  19. continue
  20. }
  21. // Doctype:
  22. const doctypeMatch = html.match(doctype)
  23. if (doctypeMatch) {
  24. // ...
  25. continue
  26. }
  27. // End tag:
  28. const endTagMatch = html.match(endTag)
  29. if (endTagMatch) {
  30. // ...
  31. continue
  32. }
  33. // Start tag:
  34. const startTagMatch = parseStartTag()
  35. if (startTagMatch) {
  36. // ...
  37. continue
  38. }
  39. }
  40. // Text:
  41. // ...
  42. } else {
  43. // Style, Script & Textarea:
  44. // ...
  45. }
  46. if (html === last) {
  47. // Last Text:
  48. // ...
  49. }
  50. }
  51. // 处理未闭合的标签
  52. parseEndTag()
  53. //...
  54. }

parseHTML这个函数修改自 JQuery 创建者 John Resig 所写的 HTML Parser,把字符串的模板转为 AST。

函数基于正则表达式来匹配标签类型:

  1. 注释:/^<!\--/,匹配<!-- 注释 -->的开头。
  2. 条件注释:/^<!\[/,这是一种 IE 5 ~ 9 版本的语法,向浏览器按条件执行的 HTML 代码,Vue 会把它忽略掉。
  3. Doctype:/^<!DOCTYPE [^>]+>/i,顾名思义,就是 HTML 第一行的<!DOCTYPE>
  4. 结束标签:new RegExp(`^<\\/${qnameCapture}[^>]*>\`),其中qnameCapture是各种 HTML 中合法的字符;例如可以匹配</div>
  5. 开始标签:先使用使用const startTagOpen = new RegExp(`^<${qnameCapture}`)匹配标签开头,然后循环匹配动态属性和静态属性,结尾使用const startTagClose = /^\s*(\/?)>/进行匹配。动态属性(例如v-bindv-if@input等)使用以下正则表达式:
ts
  1. const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
  • 使用以下正则表达式匹配静态属性,也就是除了动态属性外的所有属性:
ts
  1. const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
  • 也就是说,开始标签可以匹配这样子的内容:<div v-for="(item, index) in arr" @click="clickHandler" class="item">
  1. 文本:当要匹配的内容不是<开头的就是文本内容,直到下一个标签开始为止全部视为文本内容。

开始标签

下面来看不同内容被匹配了之后如何转为 AST的。

解析开始标签

下面的代码转换开始标签:

ts
  1. const startTagMatch = parseStartTag()
  2. if (startTagMatch) {
  3. handleStartTag(startTagMatch)
  4. continue
  5. }

如果是开始标签,parseStartTag提取它的标签名和各种属性值:

ts
  1. function parseStartTag() {
  2. const start = html.match(startTagOpen)
  3. if (start) {
  4. const match: any = {
  5. tagName: start[1],
  6. attrs: [],
  7. start: index
  8. }
  9. advance(start[0].length)
  10. let end, attr
  11. while (
  12. // startTagClose 匹配开始标签的结束部分 /^\s*(\/?)>/,
  13. !(end = html.match(startTagClose)) &&
  14. // 上面说过的,对动态属性和静态属性的匹配
  15. (attr = html.match(dynamicArgAttribute) || html.match(attribute))
  16. ) {
  17. attr.start = index
  18. advance(attr[0].length)
  19. attr.end = index
  20. match.attrs.push(attr)
  21. }
  22. if (end) {
  23. // end[1] 有值就是 /^\s*(\/?)>/ 括号里面被匹配上了,例如:<img/>,表示这是单标签
  24. // 后续只有非单标签才会被入栈,单标签直接进行闭合的操作
  25. match.unarySlash = end[1]
  26. advance(end[0].length)
  27. match.end = index
  28. return match
  29. }
  30. }
  31. }

接下来开始标签会被handleStartTag处理:

ts
  1. function handleStartTag(match) {
  2. const tagName = match.tagName
  3. // 在 web 上运行为 true(似乎没看见有别的值,可能留给二次开发实现吧)
  4. if (expectHTML) {
  5. // lastTag 是栈中最后的一个标签
  6. // isNonPhrasingTag 中的标签不能嵌套在 p 标签里面,例如 div、p 等等,p 标签遇到这种情况直接闭合
  7. if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
  8. // 闭合标签的操作,见下文
  9. parseEndTag(lastTag)
  10. }
  11. // canBeLeftOpenTag 是可以写成开始标签结束标签的单标签,
  12. // 例如模板里面可以写<img></img>,里面不能嵌套别的东西
  13. if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
  14. parseEndTag(tagName)
  15. }
  16. }
  17. // ...
  18. }

下面看正式的处理流程,也很好懂,把handleStartTag的结果拼成一个对象。

ts
  1. function handleStartTag(match) {
  2. // ...
  3. const l = match.attrs.length
  4. const attrs: ASTAttr[] = new Array(l)
  5. for (let i = 0; i < l; i++) {
  6. // 其实是把属性的 = 左右拆开,class="item" → { name: "class", value: "item" }
  7. const args = match.attrs[i]
  8. const value = args[3] || args[4] || args[5] || ''
  9. const shouldDecodeNewlines =
  10. tagName === 'a' && args[1] === 'href'
  11. ? options.shouldDecodeNewlinesForHref
  12. : options.shouldDecodeNewlines
  13. attrs[i] = {
  14. name: args[1],
  15. value: decodeAttr(value, shouldDecodeNewlines)
  16. }
  17. }
  18. // ...
  19. }

下面的代码,会把非单标签的开始标签入栈,用于后续结束标签匹配

ts
  1. function handleStartTag(match) {
  2. // unarySlash 和 unary 表示这是单标签,为什么要两个变量?感觉是为了允许一些不那么严谨的写法吧
  3. const unarySlash = match.unarySlash
  4. // ...
  5. const unary = isUnaryTag(tagName) || !!unarySlash
  6. // ...
  7. if (!unary) {
  8. stack.push({
  9. tag: tagName,
  10. lowerCasedTag: tagName.toLowerCase(),
  11. attrs: attrs,
  12. start: match.start,
  13. end: match.end
  14. })
  15. lastTag = tagName
  16. }
  17. if (options.start) {
  18. options.start(tagName, attrs, unary, match.start, match.end)
  19. }
  20. }

options.start函数也就开始组装 AST。

开始标签的 AST 节点

start函数如下所示:

ts
  1. export function createASTElement(
  2. tag: string,
  3. attrs: Array<ASTAttr>,
  4. parent: ASTElement | void
  5. ): ASTElement {
  6. return {
  7. type: 1,
  8. tag,
  9. attrsList: attrs,
  10. attrsMap: makeAttrsMap(attrs),
  11. rawAttrsMap: {},
  12. parent,
  13. children: []
  14. }
  15. }
  16. start(tag, attrs, unary, start, end) {
  17. let element: ASTElement = createASTElement(tag, attrs, currentParent)
  18. if (!root) {
  19. root = element
  20. }
  21. // 省略了处理各种特殊属性的内容,例如 v-for、v-if
  22. // 省略了对 v-pre、svg 标签的处理
  23. // 省略了对静态属性、静态样式、input 标签的优化
  24. // 以后再说...
  25. if (!unary) {
  26. currentParent = element
  27. stack.push(element)
  28. } else {
  29. // 单标签则结束之,见结束标签部分
  30. closeElement(element)
  31. }
  32. }

主体代码就是把开始标签的 AST 节点入栈,如果是单标签则闭合之。

结束标签

解析结束标签

如果是结束标签,调用parseEndTag函数进行处理:

ts
  1. // End tag:
  2. const endTagMatch = html.match(endTag)
  3. if (endTagMatch) {
  4. const curIndex = index
  5. advance(endTagMatch[0].length)
  6. parseEndTag(endTagMatch[1], curIndex, index)
  7. continue
  8. }

parseEndTag在栈中寻找未闭合的相同标签名:

ts
  1. function parseEndTag(tagName?: any, start?: any, end?: any) {
  2. let pos, lowerCasedTagName
  3. if (start == null) start = index
  4. if (end == null) end = index
  5. // Find the closest opened tag of the same type
  6. if (tagName) {
  7. lowerCasedTagName = tagName.toLowerCase()
  8. for (pos = stack.length - 1; pos >= 0; pos--) {
  9. if (stack[pos].lowerCasedTag === lowerCasedTagName) {
  10. break
  11. }
  12. }
  13. } else {
  14. // If no tag name is provided, clean shop
  15. pos = 0
  16. }
  17. // ...
  18. }

直到找到标签的地方,倒序把栈中标签都结束掉:

ts
  1. function parseEndTag(tagName?: any, start?: any, end?: any) {
  2. let pos, lowerCasedTagName
  3. // ...
  4. if (pos >= 0) {
  5. // Close all the open elements, up the stack
  6. for (let i = stack.length - 1; i >= pos; i--) {
  7. if (options.end) {
  8. options.end(stack[i].tag, start, end)
  9. }
  10. }
  11. // Remove the open elements from the stack
  12. stack.length = pos
  13. lastTag = pos && stack[pos - 1].tag
  14. } else if (lowerCasedTagName === 'br') {
  15. // 允许单个 br 结束标签</br>变为<br>,和浏览器行为一致
  16. if (options.start) {
  17. options.start(tagName, [], true, start, end)
  18. }
  19. } else if (lowerCasedTagName === 'p') {
  20. // 允许单个 p 结束标签补全为<p></p>,和浏览器行为一致
  21. if (options.start) {
  22. options.start(tagName, [], false, start, end)
  23. }
  24. if (options.end) {
  25. options.end(tagName, start, end)
  26. }
  27. }
  28. }

结束标签的 AST 节点

来看options.end如何生成 AST 节点:

ts
  1. end(tag, start, end) {
  2. const element = stack[stack.length - 1]
  3. // pop stack
  4. stack.length -= 1
  5. currentParent = stack[stack.length - 1]
  6. closeElement(element)
  7. }

这段代码主要功能功能就只是给 AST 栈出栈。closeElement函数负责处理 AST 节点上各种属性:

ts
  1. function closeElement(element) {
  2. trimEndingWhitespace(element)
  3. if (!inVPre && !element.processed) {
  4. element = processElement(element, options)
  5. }
  6. // v-if 的逻辑...
  7. if (currentParent && !element.forbidden) {
  8. if (element.elseif || element.else) {
  9. // v-if 的逻辑...
  10. } else {
  11. // slot 的逻辑...
  12. // 把当前节点记录到亲节点(最近未闭合的双标签)上,记录当前节点的亲节点
  13. currentParent.children.push(element)
  14. element.parent = currentParent
  15. }
  16. }
  17. for (let i = 0; i < postTransforms.length; i++) {
  18. // 处理静态 style、静态 class、单选框和复选框的逻辑
  19. postTransforms[i](element, options)
  20. }
  21. }
  22. export function processElement(element: ASTElement, options: CompilerOptions) {
  23. // 很明显,这里给 AST 节点处理 key、ref、slot 等情况
  24. processKey(element)
  25. element.plain =
  26. !element.key && !element.scopedSlots && !element.attrsList.length
  27. processRef(element)
  28. processSlotContent(element)
  29. processSlotOutlet(element)
  30. processComponent(element)
  31. for (let i = 0; i < transforms.length; i++) {
  32. element = transforms[i](element, options) || element
  33. }
  34. processAttrs(element)
  35. return element
  36. }

processAttrs方法是处理 AST 节点的其他属性的,开发中最常见的数据绑定也在这里挂到节点上,例如:

html
  1. <div :data="test" @click="clickHandler"></div>

在 AST 上可以得到:

js
  1. {
  2. // ...
  3. attrs: [
  4. { name: 'data', value: 'test', dynamic: false }
  5. ],
  6. events: {
  7. click: { value: 'clickHandler', dynamic: false }
  8. }
  9. }

文本

解析文本

下面是关于解析文本的:

ts
  1. let text, rest, next
  2. if (textEnd >= 0) {
  3. rest = html.slice(textEnd)
  4. while (
  5. !endTag.test(rest) &&
  6. !startTagOpen.test(rest) &&
  7. !comment.test(rest) &&
  8. !conditionalComment.test(rest)
  9. ) {
  10. // < in plain text, be forgiving and treat it as text
  11. next = rest.indexOf('<', 1)
  12. if (next < 0) break
  13. textEnd += next
  14. rest = html.slice(textEnd)
  15. }
  16. text = html.substring(0, textEnd)
  17. }
  18. if (textEnd < 0) {
  19. text = html
  20. }
  21. if (text) {
  22. advance(text.length)
  23. }
  24. if (options.chars && text) {
  25. options.chars(text, index - text.length, index)
  26. }

截取直到<为止的字符串作为文本内容,直接来看options.chars

文本的 AST 节点

options.chars把文本转为 AST 节点,普通文本和包含变量的文本都由她处理:

ts
  1. chars(text: string, start?: number, end?: number) {
  2. if (!currentParent) {
  3. return
  4. }
  5. const children = currentParent.children
  6. if (inPre || text.trim()) {
  7. // v-pre 中展示源码
  8. text = isTextTag(currentParent)
  9. ? text
  10. : (decodeHTMLCached(text) as string)
  11. } else if (!children.length) {
  12. // remove the whitespace-only node right after an opening tag
  13. text = ''
  14. } else if (whitespaceOption) {
  15. // whitespaceOption 是一个控制压缩模板的空格和换行的配置
  16. if (whitespaceOption === 'condense') {
  17. // in condense mode, remove the whitespace node if it contains
  18. // line break, otherwise condense to a single space
  19. text = lineBreakRE.test(text) ? '' : ' '
  20. } else {
  21. text = ' '
  22. }
  23. } else {
  24. text = preserveWhitespace ? ' ' : ''
  25. }
  26. // ...
  27. }

刚才提到的 whitespace 是一个 Vue 构建的配置,有'condense' | 'preserve'两个值,前者会压缩空格和换行,后者不会。

ts
  1. chars(text: string, start?: number, end?: number) {
  2. // ...
  3. if (text) {
  4. if (!inPre && whitespaceOption === 'condense') {
  5. // condense consecutive whitespaces into single space
  6. text = text.replace(whitespaceRE, ' ')
  7. }
  8. let res
  9. let child: ASTNode | undefined
  10. // 解析 delimiters 语法 {{ ... }}
  11. if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
  12. child = {
  13. type: 2,
  14. expression: res.expression,
  15. tokens: res.tokens,
  16. text
  17. }
  18. } else if (
  19. text !== ' ' ||
  20. !children.length ||
  21. children[children.length - 1].text !== ' '
  22. ) {
  23. child = {
  24. type: 3,
  25. text
  26. }
  27. }
  28. if (child) {
  29. children.push(child)
  30. }
  31. }
  32. }

根据文本是否包含变量,生成type为 2 或者 3 的 AST 节点。好了,文本解析到此结束。

纯文本标签

纯文本标签也就是 style、script、textarea三个标签,里面的东西都当成纯文本,前两者 style、script 在 web 平台上面默认禁用,也就是说,只有 textarea 里面带有填充文本归入此类。

html
  1. <textarea id="story" name="story" rows="5" cols="33">
  2. It was a dark and stormy night...
  3. </textarea>

parseHTML栈中有未闭合的纯文本标签时,也就是说 textarea 开始标签被解析了后,进入这个分支,这里的标签不会被解析,直接当成纯文本。

ts
  1. let endTagLength = 0
  2. const stackedTag = lastTag.toLowerCase()
  3. const reStackedTag =
  4. reCache[stackedTag] ||
  5. (reCache[stackedTag] = new RegExp(
  6. '([\\s\\S]*?)(</' + stackedTag + '[^>]*>)',
  7. 'i'
  8. ))
  9. // 匹配内容和结束标签
  10. // 例如 <textarea>114514</textarea>,会被正则表达式 ([\\s\\S]*?)(</textarea[^>]*>) 匹配,
  11. // 下面 text = "114514",endTag = "</textarea>"
  12. const rest = html.replace(reStackedTag, function (all, text, endTag) {
  13. endTagLength = endTag.length
  14. if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
  15. // 如果有注释,则留下其中文本
  16. text = text
  17. .replace(/<!\--([\s\S]*?)-->/g, '$1')
  18. .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1')
  19. }
  20. if (shouldIgnoreFirstNewline(stackedTag, text)) {
  21. text = text.slice(1)
  22. }
  23. // 直接把所有内容当成纯文本
  24. if (options.chars) {
  25. // 处理文本的钩子,见上文
  26. options.chars(text)
  27. }
  28. // 把匹配了的纯文本标签内容扔掉
  29. return ''
  30. })
  31. index += html.length - rest.length
  32. html = rest
  33. // 结束标签的操作,见上文
  34. parseEndTag(stackedTag, index - endTagLength, index)

结束匹配

退出的条件是要么传入的模板html解析完了,要么经过以上各种解析流程,html没有改变,也就是剩下的是纯文本了,添加了一个文本节点结束解析:

ts
  1. export function parseHTML(html, options: HTMLParserOptions) {
  2. let index = 0
  3. let last, lastTag
  4. while (html) {
  5. last = html
  6. if (!lastTag || !isPlainTextElement(lastTag)) {
  7. // 注释、条件注释、Doctype、开始/结束标签、文本...
  8. } else {
  9. // 纯文本标签...
  10. }
  11. // html 没有改变,剩下全是文本
  12. if (html === last) {
  13. options.chars && options.chars(html)
  14. break
  15. }
  16. }
  17. // 结束掉栈中剩下的标签
  18. parseEndTag()
  19. // ...
  20. }

其他

AST 节点的 type 是什么?

1 是 HTML 标签(自定义组件的节点也包含在内),2 是包含变量的文本,3 是普通文本。

总结

parse通过调用parseHTML进行模板解析。parseHTML通过正则表达式匹配模板,通过栈结构匹配未闭合标签,返回为 AST 节点的雏形。parse中再生成 AST 节点,最终生成模板对于的 AST 结构。

0 条评论未登录用户
Ctrl or + Enter 评论
© 2023-2025 LittleRangiferTarandus, All rights reserved.
🌸 Run