「这是我参与2022首次更文挑战的第7天,活动详情查看:2022首次更文挑战」
前言
我们前面分析了vue的编译入口,算是个开端。从今天开始我们进入到编译的主流程中分析其实现逻辑,实际编译主要分为三步,其中第一步就是生成AST。在源码中,生成AST是从解析template模板开始的,所以我们今天从parseHTML
开始。
parse
我们还是从三部曲的入口开始讲起比较好,在compiler/parser/index
const ast = parse(template.trim(), options) 复制代码
在parse
中可以分为两个方面来分析
- 调用
parseHTML
解析template
parseHTML
在解析的过程中调用parse
中的钩子函数生成AST
parseHTML(template, { warn, expectHTML: options.expectHTML, // ... start (tag, attrs, unary, start, end) { // 解析开始标签调用的钩子 }, end (tag, start, end) { // 解析结束标签调用的钩子 }, chars (text: string, start: number, end: number) { // 解析文本节点调用的钩子 }, comment (text: string, start, end) { // 解析注释节点调用的钩子 } }) 复制代码
parseHTML
我们前面分析了parseHTML
的入口,和其配置options
来源,现在我们开始分析其实现,了解初步解析tempalte
的实现。
我们从实例出发,假设有以下代码片段
<div class="dd" @click="xxx" :class="1" style="color: red;"> hello <p>Dom</p> </div> 复制代码
进到html-parser.js
文件,我们可以先看看有以下正则定义
// 属性 const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ // 动态属性 例如指令/bind/事件 const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ // 标签名 const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*` const qnameCapture = `((?:${ncname}\\:)?${ncname})` // 开始标签 const startTagOpen = new RegExp(`^<${qnameCapture}`) // 开始标签结束 const startTagClose = /^\s*(\/?)>/ // 结束标签 const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) // 文档节点 const doctype = /^<!DOCTYPE [^>]+>/i // 注释节点 // #7298: escape - to avoid being passed as HTML comment when inlined in page const comment = /^<!\--/ // 条件节点 const conditionalComment = /^<!\[/ 复制代码
其实我们本篇文章的目的就是在于弄清解析的原理,在这里其实已经可以看到个大概了,在compile中即是借助这些强大的正则表达式去匹配字符串来完成解析的,我们下面再继续看看到底是如何做的。
export function parseHTML (html, options) { // 1 const stack = [] // 2 let index = 0 let last, lastTag // 3 while (html) { last = html // Make sure we're not in a plaintext content element like script/style if (!lastTag || !isPlainTextElement(lastTag)) { let textEnd = html.indexOf('<') if (textEnd === 0) { // Comment: // ... // 4 // End tag: const endTagMatch = html.match(endTag) if (endTagMatch) { const curIndex = index advance(endTagMatch[0].length) parseEndTag(endTagMatch[1], curIndex, index) continue } // 5 // Start tag: const startTagMatch = parseStartTag() if (startTagMatch) { handleStartTag(startTagMatch) if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) { advance(1) } continue } } // 6 let text, rest, next if (textEnd >= 0) { // ... text = html.substring(0, textEnd) } if (textEnd < 0) { text = html } if (text) { advance(text.length) } // 7 if (options.chars && text) { options.chars(text, index - text.length, index) } } else { // ... } // ... } // Clean up any remaining tags parseEndTag() } 复制代码
// 用于不断移除已匹配数据 // 类似前进 function advance (n) { index += n html = html.substring(n) } 复制代码
我将代码简化剩下以上的关键部分,去掉了一些标签的解析,例如注释标签,条件显示标签及有内容的script
和style
等。接下来通过剩余的代码,我们来理解解析template的实现逻辑即达到目的。
- 定义了stack数组,用于存放我们解析到的标签数据。实际是作为个节点栈来使用。
- 变量index用于存放当前匹配位置,last存放剩余字符串,lastTag存放上次匹配标签
- while循环,在这我们可以发现,template的解析实际是通过不断地匹配当前字符串
html
头部得到的。在匹配头部后,截取头部匹配数据进行相关处理。然后再不断地裁剪html
得到未匹配的字符串。 - 匹配结束标签如
div>
进行处理。 - 匹配为开始标签如
<div class="dd" @click="xxx" :class="1" style="color: red;"
进行处理。 - 匹配文本如
hello
进行处理 - 调用
options.chars
也就是在parse
函数中传入的钩子对文本内容进一步解析。
通过我们前面的分析,其实可以明白
parseHTML
中的工作是对html
进行初步解析,其实现是通过不断地使用正则匹配html
的头部字符串并对其分类处理,然后再移除匹配数据继续后面的匹配。
开始标签
我们上面主要分析了如何匹配,但是没有进行匹配后的分析,我们先看看对于匹配开始标签后的处理。
function parseStartTag () { const start = html.match(startTagOpen) if (start) { // 1 const match = { tagName: start[1], attrs: [], start: index } // 2 advance(start[0].length) // 3 let end, attr while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) { attr.start = index advance(attr[0].length) attr.end = index match.attrs.push(attr) } // .. } } 复制代码
parseStartTag
的逻辑比较简单,主要是以下几步
- 匹配到开始标签,为其创建标签对象用于存放数据
- 匹配后通过
advance
将起点前进 - 将匹配标签对应的节点属性,不断提取键值信息放在
match.attrs
中
在调用parseStartTag
得到基本的节点数据后,还会调用handleStartTag
对其进行进一步处理
function handleStartTag (match) { const tagName = match.tagName const unarySlash = match.unarySlash const unary = isUnaryTag(tagName) || !!unarySlash // 1 const l = match.attrs.length const attrs = new Array(l) for (let i = 0; i < l; i++) { const args = match.attrs[i] const value = args[3] || args[4] || args[5] || '' attrs[i] = { name: args[1], value: decodeAttr(value, shouldDecodeNewlines) } } // 2 if (!unary) { stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end }) lastTag = tagName } // 3 if (options.start) { options.start(tagName, attrs, unary, match.start, match.end) } } 复制代码
handleStartTag
主要逻辑可以分为以下几步
- 处理匹配的
attrs
,提取键值数据 - 往我们开始定义的stack栈中存入节点数据
- 调用
parse
中的钩子对数据进行进一步的解析生成AST
处理结束标签
function parseEndTag (tagName, start, end) { let pos, lowerCasedTagName if (start == null) start = index if (end == null) end = index if (tagName) { // 1 lowerCasedTagName = tagName.toLowerCase() for (pos = stack.length - 1; pos >= 0; pos--) { if (stack[pos].lowerCasedTag === lowerCasedTagName) { break } } } else { pos = 0 } if (pos >= 0) { for (let i = stack.length - 1; i >= pos; i--) { if (options.end) { // 2 options.end(stack[i].tag, start, end) } } stack.length = pos lastTag = pos && stack[pos - 1].tag } } 复制代码
处理结束标签相对而言会比较简单
- 通过tagName在stack寻找对应的开始标签
- 在stack中寻找到开始标签后,获取其tag并调用结束标签匹配钩子
处理文本节点
对文本的解析就更加简单了,因为我们在这不会去处理文本插值的处理只是单纯获取开发者的文本例如{{text}}
。所以就是简单的匹配,匹配前进,再调用对应的钩子函数进一步解析。
let text, rest, next if (textEnd >= 0) { text = html.substring(0, textEnd) } if (text) { advance(text.length) } if (options.chars && text) { options.chars(text, index - text.length, index) } 复制代码
结语
相信通过本篇文章的学习,我们可以弄清楚模板的处理解析是如何完成的,也就是parseHTML
函数的主要实现逻辑。后面我们继续分析parse
的另一部分,也就是我们在初步解析中调用的钩子实现。了解是如何通过那些钩子函数去最终生成AST的。