Vue3.0 源码系列(二)编译器核心 - Compiler core 1: parse.ts
文章目录
该系列文章,均以测试用例通过为基准一步步实现一个 vue3 源码副本(学习)。
文字比较长,如果不想看文字可直接转到这里看脑图
可能感兴趣列表:源码相关的疑问/问题列表及其解答 🛳 🛳 🛳 🛳 🛳
阶段性的代码备份(比如能 pass 某个用例) 🚘 🚘 🚘 🚘 🚘
小结
小结之所以放在最前面,主要原因有二:
文章都是根据测试用例逐步由少到多,简到全的进度去实现和测试的。
文字内容太多,小结放前面能提前大概有个全局观,全局的概念。
上图:几个重要函数和几个简单的用例
每个函数的重要实现解说:
parseChildren 所有模板解析的入口,重点是 while 循环检测规则进入对应的 parse* 函数解析,合并相邻文本节点,过滤空行节点,返回 root.children。
parseComment 这里的注释是指
<!--xx-->
html 注释,区分几种非法情况,可通过用 例来熟悉(a. 正常注释,b. 非法注释)parseElememt 解析标签,得到整个标签的 ast 结构,包含:标签名 tag,属性列表 props,孩子节点 children,等等。
检测自闭合(
<div/>
)和空标签(<img>
)检测,它们没有孩子节点。关键的 ancestors 数组,在递归解析孩子节点的时候通过出入栈操作保存当前解析的 节点对象(如:疑问3)。
parseText 文本解析,非标签,非插值类型的节点会被当做文本类型去解析。文本结束 根据是 (
<, {{, ]]>
)。parseTextData 解析文本,替换 html 标记(匹配:
/&(gt|lt|amp|apos|quot);/g
)parseTag 解析元素标签,属性 props,v-pre 等指令都是在这里面发起解析的,注意自闭 合标签的处理 isSelfClosing 标志结束 parseElement 中解析进程。
parseAttributes whle 循环调用 parseAttribute 解析属性存到 props 中。
parseAttribute 解析单个属性,集合保存属性名防止重复,先解析属性值,然后解析属 性名,指令,修饰符,参数等。
parseAttributeValue 解析属性值,区分有引号或没引号(即属性值可以没引号哦😯)。
parseInterpolation 插值解析,取 {{ 和 }} 之间的文本作为表达式。
parseCDATA 解析 xml 注释,当做文本处理
parseBogusComment 解析 <? 的注释
parse.spec.ts
测试用例结构:compiler: parse 截止:2020-09-02 22:53:14
所有用例全部通过:parse.ts 的解析功能几乎全部实现(可能会有遗漏)
ErrorCodes 各种错误情况用例
不通过用例:
<textarea></div></textarea>
<template><svg><![CDATA[cdata]]></svg></template>
用例代码:
|
|
大部分都能通过,只有少部分不能通过的分为几种:
CDATA 类型处理,需要实现 parseBogusComment 和 parseCDATA 两个函数
RCDATA 类型几个用例不能通过,原因在于在 isEnd 函数中没有实现除 DATA 类型外的 情况,实现之后就能正常检测 RCDATA 的结束标签。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
function isEnd( context /*ParserContext*/, mode /*TextModes*/, ancestors /*ElementNode[]*/ ) /*boolean*/ { const s = context.source; // mode 为 TextModes 各种情况 // ...省略 switch (mode) { case TextModes.DATA: if (s.startsWith("</")) { // 标签 for (let i = ancestors.length - 1; i >= 0; --i) { if (startsWithEndTagOpen(s, ancestors[i].tag)) { return true; } } } // 新增 - start case TextModes.RCDATA: case TextModes.RAWTEXT: { const parent = last(ancestors); if (parent && startsWithEndTagOpen(s, parent.tag)) { return true; } break; } case TextModes.CDATA: if (s.startsWith("]]>")) { return true; } break; // 新增 - end } // 是 TextModes.TEXT 直接返回 source 的内容是否为空了 return !s; }
注释反例(嵌套注释):
<template><!--a<!--b--></template>
<template><!--a<!--b<!--c--></template>
<template><!--a<!--></template>
<template><!--a<!--
其他用例
02-valid/invalid html
|
|
这里要分析的是 invalid html, 这个用例拿出来说主要原因是它能帮助我们更好的理解标 签嵌套时候的解析过程。
<div>\n<span>\n</div>\n</span>
大致解析流程是: parseChildren -> parseElement -> parseTag -> parseChildren -> parseElement -> parseTag -> 报错
debugger local 数据(解析完 <span> 之后):
|
|
解析出 div 标签,所以
ancestors.length === 1
解析出 span 标签,ancestors.length 应该是 2,但是上面我们只保留了 span 解析之 后的数据,所以 ancestors.span 被
pop()
掉了,因为它不是重点解析完 span 之后会去解析 \n ,但是会被 removedWhitespace 那段逻辑过滤掉(满足 在 pre 和 next 之间条件)
那么重点在这,到这一步也是上面代码
source = `</div>\n</span>`
的时候检测到 </ 开始结束标签解析,注意看 parseElement 中有这么一段
1 2 3
if (startsWithEndTagOpen(context.source, element.tag)) { parseTag(context, TagType.End, parent); }
经过 4 之后的 source 刚好能满足这个 if ,因此携带 TagType.End 进入 parseTag, 此时有个变量 parent 保存了
pop()
之前的那个ancestors[1]
即 span 那个标签 ,但是这里的结束标签是 div 最后会匹配失败,抛出异常。
01-self closing single/multiple tag
|
|
Element 元素标签解析
13-结束标签忽略大小写
<div>hello</DIV>after
因为解析到结束标签的时候匹配结束标签名称的时候会调用 startsWithEndTagOpen 检测, 且里面是忽略大小写的,统一转成小写去比较。
|
|
12-v-pre 用例测试
`<div v-pre :id="foo"><Comp/>{{ bar }}</div>\n` + `<div :id="foo"><Comp/>{{ bar }}</div>`
现阶段代码暂时是不支持的 v-pre 的。所以解析之后会出现下面的结果:
root.children[3]
有三个孩子节点
first: div v-pre(还没实现所以当做普通标签处理),first.children[2] 有两个孩子
component 类型的
<Comp/>
因为首字母大写所以当做组件类型处理bar 插值节点
second: \n 文本节点
third: div :id,third.children[2] 也有两个孩子和 first 一样
|
|
实现之后:
0: {type: 1, ns: 0, tag: "div", tagType: 0, props: Array(1), …} 1: null 2: {type: 1, ns: 0, tag: "div", tagType: 0, props: Array(1), …} length: 3 __proto__: Array(0)
要通过该用例需要修改的点:
parseChildren 里要添加删除空字符换行符操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
function parseChildren( context /* ParserContext*/, mode /*TextModes*/, ancestors /*ElementNode[]*/ ) { // ... const parent = last(ancestors); const ns = parent ? parent.ns : Namespaces.HTML; const nodes /*TemplateChildNode[]*/ = []; // ... 省略 while // 新增-start let removedWhitespace = false; // TODO 空格管理,为了更高效的输出 // `\n<div>...` 删除开头的空格字符,之前解析 v-pre 用例是卡在这里了 // 这里忘记实现了,所以用例 http://www.cheng92.com/vue/vue3-source-code-compiler-core-parse_ts/#headline-3 // 得到了三个 child,第二个是 \n,就是因为这里没实现过滤 if (mode !== TextModes.RAWTEXT) { if (!context.inPre) { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node.type === NodeTypes.TEXT) { if (!/[^\t\r\n\f ]/.test(node.content)) { const prev = nodes[i - 1]; const next = nodes[i + 1]; // 1. 空格是第一个或者最后一个节点,或者 // 2. 空格与注释节点相邻 // 3. 空格在两个元素之间,就我们遇到的 <div></div>\n<div>... // 上面三种情况的空格会被忽略 if ( !prev || !next || prev.type === NodeTypes.COMMENT || next.type === NodeTypes.COMMENT || (prev.type === NodeTypes.ELEMENT && next.type === NodeTypes.ELEMENT && /[\r\n]/.test(node.content)) ) { removedWhitespace = true; nodes[i] = null; } else { // 否则替换成空格 node.content = " "; } } else { // 替换成空格 node.content = node.content.replace(/[\t\r\n\f ]+/g, " "); } } } } else if (parent && context.options.isPreTag(parent.tag)) { //如果是 <pre> 删掉第一行的空行 const first = nodes[0]; if (first && first.type === NodeTypes.TEXT) { first.content = first.content.replace(/^\r?\n/, ""); } } } // <<<<<< 新增-end return removedWhitespace ? nodes.filter(Boolean) : nodes; }
修改 parseTag 增加 v-pre, <pre> 代码处理
这里会有个值得注意的地方就是它检测到是 pre 会回头重新解析属性,然后过滤掉 v-pre 指令,并且在 parseAttribute 里面会检测到 inVPre 从来不会进 行指令解析,只会解析普通的 props。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
function parseTag(context, type, parent) { // ... // 新增-start if (context.options.isPreTag(tag)) { context.inPre = true; } // 1. inVPre = false 因为初始化默认不会是 v-pre 的 // 2. 只要属性列表中有一个满足:v-pre 指令类型 if ( !context.inVPre && props.some((p) => p.type === NodeTypes.DIRECTIVE && p.name === "pre") ) { context.inVPre = true; // 这里恢复之前的解析,因为 <div v-pre>...</div> 走到这里的时候已经解析完了 // 所以要恢复属性字符串? extend(context, cursor); context.source = currentSource; // 为什么要重新解析,直接过滤不好吗? // 因为 parseAttribute 中在 inVPre = true 情况下是不会去解析其他指令属性的 // 其他指令照样会解析,直接过滤掉所有指令属性不就好了? props = parseAttributes(context, type).filter((p) => p.name !== "v-pre"); } // 新增-end // ... const val = { type: NodeTypes.ELEMENT, ns, tag, tagType, props, isSelfClosing, children: [], loc: getSelection(context, start), codegenNode: undefined, }; return val; }
11-<div> id=a/></div>
属性值中没有引号时
没有引号的时候有一些非法字符: const unexpectedChars = /["'<=`]/g;
,遇到这些
值的时候会报错。
在这之前有一个匹配使用来匹配出值的:
const match = /^[^\t\r\n\f >]+/.exec(context.source);
这个会将 > 之前的 = 之后的属性值匹配出来,然后交给 parseTextData 进行解析。
10-<div> id=">\'"></div>
属性值中有引号时
这种情况是合法的,属性值里面的内容会被当做纯文本处理。
props: Array(1) 0: name: "id" type: 6 value: content: ">'" // 属性值 type: 2
这个处理跟 用例09 是一样的逻辑
多个属性的情况,在 parseAttributes 中有个 while 循环处理。
|
|
09-<div id=""></div>
属性值为空的情况
|
|
解析: parseTag -> parseAttributes -> parseAttribute -> parseAttributeValue
-> parseTextData 直接返回空字符串,组织: { type, content: '', ... }
返回
|
|
08-<div id></div>
无属性值的属性
|
|
解析: parseTag -> parseAttributes -> parseAttribute 里面有一段针对属性值处理
|
|
07-isCustomElement 自定义元素
|
|
自定义类型判断:
|
|
06-isNativeTag 原生标签类型
这个用例(<div></div><comp></comp><Comp></Comp>
)里面有三个标签:
div
comp
Comp
同时传递一个 options: { isNativeTag: tag => tag === 'div' }
意思告诉编译器这里面只有 div 属于原生标签,其他的都属于组件类型,这个在 parseTag 实现中体现出来。
|
|
通过该用例的代码实现片段(在用例 05 中就已经实现过了,因此该用例顺利通过):
|
|
而在没有提供 isNativeTag()
的情况下,三种标签的解析结果中的 tagType
又是不一
样的,延续上面的带继续分析:
|
|
那么接下来的用例也不是什么问题了:
|
|
自定义组件:
05-template element with directives
这个用例开始模板的解析。
|
|
baseParse('<template v-if="ok"></template>')
解析之后的结构:
|
|
为了能解析出 v-if="ok"
我们需要去实现 parseAttributes(context, type) ->
parseAttribute -> parseAttributeValue
该用例考察的其实并不是 <template>
模板标签解析,而是标签上的属性解析,对普通的
<div>
标签依然可以解析出属性 props[]。
针对模板
<template>
标签的处理详情可以查看此处(含脑图),更直观。
04-void element
空标签解析,如:~<img>~
前提是提供了 isVoidTag()
选项。
|
|
该用例和自闭标签类似都是在 parseTag 解析完之后在 parseElement 中结束解析,不同点
在于调用 baseParse 的时候需要传递一个包含 isVoidTag()
的选项 {isVoidTag: tag
=> tag === 'img'}
用来告诉解析器什么样的标签属于空标签,即不是 <img/>
也不是
<div></div>
类型。
parseElement 中解析条件:
|
|
03-self closing
|
|
02-empty div
和 01-simple div 一样,无非就是没有 children[]
子节点了。在 parseElement -> parseTag 解析就结束了。
|
|
01-simple div
流程图:
因为 parseElement 已经实现,因此这个顺利通过,~parseElement~ 解析先检测 </div>
结束标签位置,如果没有则为非法无结束标签触发 ErrorCodes.EOF_IN_TAG
异常。
|
|
标签的解析在 parseTag 中完成, 如果是自闭合标签,会置标志位 isSelfClosing =
true
。
并且解析标签只会解析到 <div>
中的 <div
部分就结束,是因为需要检测后面是 >
还是 />
如果是 />
则为自闭合标签需要区分处理,因此这里会有个判断来决定
advanceBy
1 或 2 个指针位置。
|
|
Comment 注释解析
注释风格: <!-- ... -->
,阶段 5 及之前还不支持注释解析,因为还没实现 parseComment。
注释测试用例不存在阶段性的实现,只要实现了 parseComment 就饿都可以通过了,因此这里放在一起通过记录。
empty comment 空注释节点
simple comment 正常注释节点
two comments 多个注释节点
|
|
这里总共有三个用例,一开始测试并不能通过,是因为实现 pushNode 的时候忘记加上
__DEV__
环境检测了,因为生产环境是不需要保存注释节点的,开发环境为了测试需要有
这个信息。
|
|
Interpolation 插值解析
05-custom delimiters
自定义插值分隔符,其实处理流程和插值处理一样,所以没啥好讲的,阶段代码 4 就支持该用例通过。
|
|
04-it can have tag-like notation (3)
前面的两个用例已经解释过了,插值里面的内容会在 parseInterpolation 中直接处理成插 值的模板(source),不会进入到 while 循环触发异常。
|
|
03-it can have tag-like notation(2)
这个用例其实和 用例 2 是一样的,只不过是解析了两个插值而已,先解析 {{ a<b }}
,最后剩下的 {{ c>d }}
会在退出 parseInterpolation 之后剩余的 context.source
为 {{ c>d }}
在 parseChildren 里面继续进行 while 循环处
理,随又检测到是插值再次调用 parseInterpolation
进行处理得到第二个插值节点。
|
|
02-it can have tag-like notation(1)
该用例里面虽然有 <
符号,但是由于是在插值内部,会进入 parseInterpolation 之后
就被解析成插值的 source,并不会进入 while 里面的作为标签的开始 <
来解析。
|
|
01- simple interpolation
|
|
Text 文本解析
07-only "{{" don\'t separate nodes
这个用例是用来检测插值不完整的情况,正常会爆出 X_MISSING_INTERPOLATION_END
异
常,在该用例中重写了该异常处理,因此不会报错,用例会很顺利通过,因为没有异常,
parseInterpolation 会退出,最后 {{
会被当做普通文本内容处理。
|
|
parseInterpolation 该用例处理代码:
|
|
test:
➜ packages git:(master) ✗ jest compiler-core PASS compiler-core/__tests__/parse.spec.js (19.233 s) compiler: parse Text ✓ simple text (5 ms) ✓ simple text with invalid end tag (2 ms) ✓ text with interpolation (1 ms) ✓ text with interpolation which has `<` (1 ms) ✓ text with mix of tags and interpolations (1 ms) ✓ lonly "<" don't separate nodes (7 ms) ✓ lonly "{{" don't separate nodes Test Suites: 1 passed, 1 total Tests: 7 passed, 7 total Snapshots: 0 total Time: 23.277 s Ran all test suites matching /compiler-core/i
06-only "<" don\'t separate nodes
|
|
这个用例在实现的 test-05 之后就可以通过,因为 a < b
并不是插值一部分,会被当做
纯文本处理,而为了避免报错用例中重写了 onError=,因为 while 循环里在检测到 =<
开头的 if 条件分支中,第二个字符为空格的情况会进入最后的 else 分支处理,即触发
INVALID_FIRST_CHARACTER_OF_TAG_NAME
异常。
|
|
05-text with mix of tags and interpolations
|
|
这是个标签+插值混合模板,现阶段的代码是通不过该测试的,因为它会进入到下面这个分支:
|
|
如控制台输出:
错误上面的输出其实是 }} 和 {{ 的解析位置信息,并且 <div>
并没有解析是因为我们
还没实现 parseElement 分支逻辑,所以直接过滤掉当成文本处理了。
右边: offset=14 刚好是 `some <span>{{ ` 字符串长度 + 1 即插值内第一个空格的位置
左边:offset=29 刚好是 14 + `foo < bar + foo` 长度位置(slice 不包含 endIdx), 即插值内最后一个空格的位置
接下来我们得看下怎么不报错能解析 </div>
。
大概的猜想是在解析 <div>
的时候发现是标签,可能会重写
onError
,避免在解析 </div>
触发异常,而是进入 parseTag
解析结束标签。但很可惜不是这样,而是在 parseElement 中递归
调用 parseChildren 解析标签内部的模板,解析完成之后检测
结束标签,无结束标签,非法异常,具体实现请看 parseElement 源码实
现。
在实现了 parseElement 和部分 parseTag 之后用例通过:
➜ packages git:(master) ✗ jest compiler-core PASS compiler-core/__tests__/parse.spec.js (14.492 s) compiler: parse Text ✓ simple text (5 ms) ✓ simple text with invalid end tag (2 ms) ✓ text with interpolation (2 ms) ✓ text with interpolation which has `<` (1 ms) ✓ text with mix of tags and interpolations (2 ms) Test Suites: 1 passed, 1 total Tests: 5 passed, 5 total Snapshots: 0 total Time: 15.743 s Ran all test suites matching /compiler-core/i.
期间碰到个问题:
> Cannot find module 'core-js/modules/es6.string.iterator' from 'packages/compiler-core/parse.js'
解决方案:是 core-js 降级到 2
04-text with interpolation which has `<`
|
|
这个用例其实和 03-text with interpolation 用例原理一样,虽然插值里面有特殊字符
<
,但是由于在 parseInterpolation 函数解析过程中是通过截取 {{ 到 }} 直接的全部
字符串去解析的。
|
|
所以这个用例会很顺利的通过(在 03 用例通过的前提下)。
PASS packages/compiler-core/__tests__/parse.spec.js (5.375 s) compiler: parse Text ✓ simple text (5 ms) ✓ simple text with invalid end tag (3 ms) ✓ text with interpolation (41 ms) ✓ text with interpolation which has `<` (3 ms)
03-text with interpolation
该用例检验的差值的处理。
|
|
差值的处理分支在 parseChildren 的
|
|
完成,因为需要 parseInterpolation() 的支持。
用例结果(OK ):
➜ vue-next-code-read git:(master) ✗ jest parse.spec PASS packages/compiler-core/__tests__/parse.spec.js compiler: parse Text ✓ simple text (4 ms) ✓ simple text with invalid end tag (2 ms) ✓ text with interpolation (47 ms) console.log { column: 18, line: 1, offset: 17 } { column: 9, line: 1, offset: 8 } 1 at parseInterpolation (packages/compiler-core/parse.js:262:11) Test Suites: 1 passed, 1 total Tests: 3 passed, 3 total Snapshots: 0 total Time: 8.776 s Ran all test suites matching /parse.spec/i. ➜ vue-next-code-read git:(master) ✗
02-simple text\<div>
在跑这个用例的时候出现内存溢出了,查了下原因是因为只是增加了 while 里面的各种 if 分支,但是实际并没有实现,这个用例会走到
|
|
因此要通过这个用例,就必须得实现 parseTag(context, TagType.End, parent)
函数解析标签。
|
|
因为 baseparse 调用的时候有传递 onError 覆盖报错代码,会进入到 parseTag 进行解析 标签,如果不实现会导致死循环。因此这里要通过这个用例就必须实现 parseTag():
|
|
parseTag 实现到这里就可以满足通过测试用例的条件了,这里面会去匹配 </div
然后将
其过滤掉(通过 advanceBy 和 advanceSpaces 来改变 context 里面的 offset 和 line 值),
输出结果(log1 和 log2 位置 context 的输出):
01-simple text
这里用到的就一个 baseParse 函数,需要我们来实现其基本的功能以通过该用例。
用例源码:
|
|
用例的基本功能,验证 baseParse 解析出来的文本节点对象是否满足基本要求。
支持该用例的重要部分代码:
createParseContext 构建被解析的内容的对象结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
function createParserContext(context, options) /*ParserContext*/ { return { options: { ...defaultParserOptions, ...options, }, // 初始化以下内容 column: 1, line: 1, offset: 0, originalSource: context, source: context, inPref: false, inVPref: false, }; }
parseChildren
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
function parseChildren( context /* ParserContext*/, mode /*TextModes*/, ancesotrs /*ElementNode[]*/ ) { // ... const nodes /*TemplateChildNode[]*/ = []; while (!isEnd(context, mode, ancesotrs)) { // do sth const s = context.source; let node = undefined; // 由于 baseparse 里面传过来的是个 DATA 类型,因此会走到这个 if 里 // 面去解析 if (mode === TextModes.DATA || mode === TextModes.RCDATA) { // 过略掉非文本的 if (!context.inVPre && s.startsWith(context.options.delimiters[0])) { // ... 插值处理{{}} } else if (mode === TextModes.DATA && s[0] === "<") { // ... 标签开头 <... } // ... 到这里也就是说文本节点不会被这个 if 处理,而是直接到 // !node 给 parseText 解析 } if (!node) { // 纯文本重点在这里面处理,截取字符直到遇到 <, {{, ]]> 标志结束 // 然后传入到 parseTextData() 判断是否是数据绑定的变量,在 // context.options.decodeEntities() 中处理 node = parseText(context, mode); } if (Array.isArray(node)) { for (let i = 0; i < node.length; i++) { pushNode(nodes, node[i]); } } else { pushNode(nodes, node); } } let removedWhitespace = false; return removedWhitespace ? nodes.filter(Boolean) : nodes; }
parseText
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
function parseText(context, mode) { // 字符串解析直到遇到 <, {{, ]]> 为止 const endTokens = ["<", context.options.delimiters[0]]; if (mode === TextModes.CDATA) { endTokens.push("]]>"); } let endIndex = context.source.length; for (let i = 0; i < endTokens.length; i++) { const index = context.source.indexOf(endTokens[i], 1); if (index !== -1 && endIndex > index) { endIndex = index; } } const start = getCursor(context); // 解析 & 开头的 html 语义的符号(>,<,&,',") const content = parseTextData(context, endIndex, mode); return { type: NodeTypes.TEXT, content, // loc:{ start, end, source} // start,end: { line, column, offset } loc: getSelection(context, start), }; }
parseTextData
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// 解析文本数据,纯文本内容 function parseTextData(context, length, mode) { const rawText = context.source.slice(0, length); // 解析换行,更新 line, column, offset,返回换行之后的的 source advanceBy(context, length); if ( mode === TextModes.RAWTEXT || mode === TextModes.CDATA || rawText.indexOf("&") === -1 ) { return rawText; } return context.options.decodeEntities( rawText, mode === TextModes.ATTRIBUTE_VALUE ); }
advancedBy 解析多个字符之后更新
start,end(line,column,offset)
,尤其是换行符的特殊处理。1 2 3 4 5
function advanceBy(context, numberOfCharacters) { const { source } = context; advancePositionWithMutation(context, source, numberOfCharacters); context.source = source.slice(numberOfCharacters); }
advancePositionWithMutation
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
export function advancePositionWithMutation( pos, source, numberOfCharacters = source.length ) { let linesCount = 0; let lastNewLinePos = -1; for (let i = 0; i < numberOfCharacters; i++) { if (source.charCodeAt(i) === 10 /* newline char code */) { linesCount++; lastNewLinePos = i; } } pos.offset += numberOfCharacters; pos.line += linesCount; pos.column = lastNewLinePos === -1 ? pos.column + numberOfCharacters : numberOfCharacters - lastNewLinePos; return pos; }
函数列表
baseParse(context, options)
|
|
baseParse 内部实现基本就是调用其他方法,所以接下来我们得针对它使用的几个方法去逐一实现:
createParserContext,创建节点解析对象,包含解析过程中需要或需要保存的数据
getCursor,获取 context 中的 offset, line, column, start, end 等信息
createRoot,创建根节点
parseChildren,解析子节点
getSelection,获取选中的未解析的内容
baseParse 函数大体结构和代码调用图示:
createParseContext(context, options)
函数作用:*创建解析器上下文对象(包含解析过程中的一些记录信息)*
函数声明:
function createParserContext(context, options) /*ParserContext*/ {}
参数没什么好讲的了,从 baseParse 继承而来,返回的是一个 ParserContext 类型。具体 实现其实就是返回一个 ParserContext 类型的对象,里面包含了源码字符串被解析是的一 些信息存储,比如:解析时指针的位置 offset,当前行列(line, column),及其他信息。
|
|
parseChildren(context, mode, ancestors)
|
|
参数列表:
context,待解析的模板对象(ParserContext)
mode,文本模式(TextModes)
ancestors,祖先元素(ElementNode[])
返回结果: TemplateChildNode[]
阶段一(test01 some text)
实现 parseText() 之后的 parseChildren() 代码:
|
|
最后处理完之后文本节点对象内容如下:
|
|
baseParse 之后的 ast 结构:
|
|
图示:文本解析
阶段二(<div …></div>\n<div …></div>)
增加空行节点过滤。
|
|
parseComment(context)
注释处理函数,解析原则是匹配 <!--
开头和 -->
结尾,中间部分统统视为注释,中
间需要考虑嵌套注释问题。
|
|
parseElement(context, mode)
这个解析函数,用来解析 <div>
标签。
阶段一(test-05)
some \<span>{{ foo < bar + foo }} text\</span>
此阶段只实现对 <div>...</div>
的解析,不包含属性等等其他复杂情况,因为只需要能
通过用例 5 就行。
|
|
实现到这里是为了想看下经过 parseTag 之后的 element 是什么?parseTag 里面有个正则
是用来匹配开始或结束标签的,即: /^<\/?([a-z][^\t\r\n\f />]*)/i
这个既可以匹配
开始标签,也可以匹配结束标签,并且考虑了 <div >
有空格的情况,忽略大小写。
正则匹配测试结果:
/^<\/?([a-z][^\t\r\n\f />]*)/i.exec('<span>') (2) ["<span", "span", index: 0, input: "<span>", groups: undefined]
所以这里首先匹配解析的是开始标签 <div>
。
|
|
解析之后 context 内容变化:
|
|
到此我们已经解析除了 <span>
开始标签,这个时候的 =node.childrens = []=,下一步
解析标签里面的内容。
在实现完整的 parseElement 之后发现执行会报错,因为这个用例并不是 <span></span>
标签内没东西,所以会进入 else 触发 emitError()
,那不是没法往下走了???
|
|
那是因为前面漏了一段代码。
代码加上之后最后代码 P1 出的输出 ancestors 里面会有一个子节点(element):
|
|
这里也没什么好解释的,插值在 parseInterpolation 处分析过了,文本解析在 parseText 处分析了。
parseInterpolation(context, mode)
函数声明:
|
|
context: 将被解析的上下文,此时这里的 source 应该是以差值 ({{
)开始的字符串。
mode: 文本模式。
|
|
图中我们看到在经过解析之后 innerStart 和 innerEnd 都数据都正确定位到了相应位置,
innerStart 是解析后插值字符串的开始位置(第一个 {
offset = 8(or='red'>度</font>)),innerEnd 是解析后插值字符串的结束位置
(最后一个 }
offset = 17(<font color="purple">'some {{ foo + bar '的长
度))。
解析之后得到的 ast.children
将会有三个节点:
|
|
解析回顾(分别解析出了三个节点对象):
0: {type: 2, content: "some ", loc: {…}}
详细结构:
1 2 3 4 5 6 7 8 9
0: content: "some " // 解析出的文本内容 loc: // 位置信息 end: {column: 6, line: 1, offset: 5} // 该节点在模板中的位置信息 source: "some " // 文本源内容 start: {column: 1, line: 1, offset: 0} // 该节点在模板中的结束信息 __proto__: Object type: 2 // 节点类型 __proto__: Object
那么是如何得到上面的结果的呢???那得从 parseChildren 说起了,模板:
—>> "some {{ foo + bar }} text"
(!context.inVPre && s.startsWith(context.options.delimiters[0]))
检测失败mode === TextModes.DATA && s[0] === "<"
检测失败即一开始并不会进入插值和标签解析代码,而是直接进入 parseText(context, mode) 中解析文本,解析时候直到遇到
{{
之前都一直会当做文本解析,而之前的文本中又 不包含decodeMap
中的字符,因此知道遇到{
之前会一直执行 while 里面的:1 2 3 4 5 6 7 8 9 10 11
if (!node) { node = parseText(context, mode); } if (Array.isArray(node)) { for (let i = 0; i < node.length; i++) { pushNode(nodes, node[i]); } } else { pushNode(nodes, node); }
这段代码,而由于 "some " 都是普通字符,每个字符串会对应一个 node ,然后又都是 普通文本节点,会经过 pushNode(nodes, node[i]) 处理掉,进行合并最后成为上面的 一个完整的 "some " 对应文本节点结构。
1: {type: 5, content: {…}, loc: {…}}
节点结构:
1: content: // 这里的数据是经过插值解析之后的模板对象 content: "foo + bar" // trim 之后的插值字符串,没有 }} ??? isConstant: false // 非常量类型 isStatic: false // 非静态节点 loc: // 解析之后的该节点在整个模板中的位置信息 // 17 -> r 所在的位置 end: {column: 18, line: 1, offset: 17} source: "foo + bar" // 8 -> f 所在的位置,即 start -> end => 'f <-> r' start: {column: 9, line: 1, offset: 8} __proto__: Object type: 4 // 插值表达式类型 __proto__: Object loc: // 这里是没经过去尾部空格的位置信息 // 20 -> 'some {{ foo + bar ' 最后一个空格位置 end: {column: 21, line: 1, offset: 20} source: "{{ foo + bar }}" // 5 -> 'some ' 第一个 { 位置 start: {column: 6, line: 1, offset: 5} __proto__: Object type: 5 // 插值类型 __proto__: Object
如上所注释的,第一级的 loc 是通过解析 "{{ foo + bar}}" 在整个模板中的位置 信息,content 里面包含的是插值内部的信息,即真正的表达式结构信息。
{type: 2, content: " text", loc: {…}}
和第一步中一样,只会经过
parseText(context, mode)
解析出纯文本内容:" text",最后的结构:1 2 3 4 5 6 7 8 9 10
{ type: 2, content: " text", loc: { // 从 text 前面的空格开始记录,"some {{ foo + bar }}" 长度为 20 start: { column: 21, line: 1, offset: 20 }, source: " text", end: { column: 26, line: 1, offset: 25} } }
三步分析完之后,到现在我们应该具备脱离代码就可以直接根据模板得到解析后对应的
children 结构。分析的重点是要得到一个 { type, content, loc: { start, source, end }}
结构的对象。
|
|
parseTag(context, type, parent)
阶段一(simple text<\/div>)
为什么只匹配
</div
而忽略掉最后一个>
??? 参数:1 2 3 4 5
function parseTag( context: ParserContext, // 要继续解析的模板对象 simple text</div> 里面的 </div> type: TagType, // Start(<div>), End(</div>)开始结束标签 parent: ElementNode | undefined // 该标签的父级 ): ElementNode
具体实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
function parseTag(context, type, parent) { // 获取当前解析的起始位置,此时值应该是 simple text 的长度 const start = getCursor(context); // 匹配 </div 过滤掉空格字符,但是为什么要把 > 给忽略掉??? const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source); const tag = match[1]; const ns = context.options.getNamespace(tag, parent); // 改变位移,将 offset 定位到 </div> 的最有一个 > 上 advanceBy(context, match[0].length); // 过滤掉空格 advanceSpaces(context); const cursor = getCursor(context); const currSource = context.source; }
阶段二(test-text-05)
满足用例 5(some <span>{{ foo < bar + foo }} text</span>
) 的代码实现,这里只需
要能解析 <span> ... </span>
标签就可以,没有 pre
, v-pre
, <span/>自闭合标
签
,因此下面省略这几部分检测代码。
|
|
要能通过用例5必须搭配 parseElement(context, ancestors) 才行,并且重点在 parseElement 中,因为有了开始标签才会有结束标签的解析,不然会触发结束标签解析分 支里面的 error:
|
|
因此如果这里不会触发 X_INVALID_END_TAG 那必定是 parseElement 里面做了什么处理, 这个实现了 parseElement 才得以知晓(目前只是猜测~~~),传送门🚪>>>
阶段三(test-element-03)
支持自闭标签解析,实现了阶段二之后,这里其实很简单,在上一阶段中的实现在
parseTag 中返回的时候 isSelfClosing
写死成了 false
,要支持这个用例,只要将
它的值赋值为实际的 isSelfClosing
就可以了。
|
|
阶段四(支持 template + v-if)
|
|
这里的实现涉及到几个新的函数:
options.isCustomElement(tag)
默认在 options 里面是NO
options.isNativeTag(tag)
作为可选OptionalOptions
选项类型,并没默认值isCoreComponent(tag)
vue 内部作为核心组件的标签1 2 3 4 5 6
{ // 主要就这四个 Teleport: TELEPORT, Suspense: SUSPENSE, KeepAlive: KEEP_ALIVE, BaseTransition: BASE_TRANSITION }
options.isBuiltInComponent?.(tag)
和isNativeTag
一样作为可选选项,无默认值isSpecialTemplateDirective(p.name)
特殊的模板指令1 2 3
const isSpecialTemplateDirective = /*#__PURE__*/ makeMap( `if,else,else-if,for,slot` )
从上面的代码可以看出,如果要被定义为是
<template>
类型必须包含if,else,else-if,for,slot
这其中的任一个指令属性,判断条件:1 2 3 4 5 6 7 8 9 10 11 12
if ( tag === 'template' && props.some( (p) => // isSpecialTemplateDirective 是使用 makeMap 创建的函数 // 即 key => true/false 的一些函数 p.type === NodeTypes.DIRECTIVE && isSpecialTemplateDirective(p.name) ) ) { // 是模板的前提是有指令,并且是特殊的模板指令(if, else, else-if, slot, for) tagType = ElementTypes.TEMPLATE }
parseText(context, mode)
解析文本节点,直到遇到结束标记(<
, {{
, ]]>
)。
|
|
导图:
parseTextData(context, length, mode)
文本节点可能包含数据,通过 context.options.decodeEntities(???) 来解析。
一些字符的 html 书写格式,有 /&(gt|lt|amp|apos|quot);/
,最终会被对应的字符替换掉。
decodeEntities: (rawText: string): string => rawText.replace(decodeRE, (_, p1) => decodeMap[p1])
字符集:
|
|
代码:
|
|
导图:
parseAttributes(context, type)
这里定义 props[]
数组,真正解析单个属性的在 parseAttribute 中,解析之后的单个
属性解构保存到数组中,返回给当前组件作为 props
属性字段存在:
|
|
parseAttribute(context, nameSet)
解析标签属性或指令:
|
|
该函数实现主要有几部分(以 <div v-bind:keyup.enter.prevent="ok"></div>
为例):
匹配属性名,关键正则:
/^[^\t\r\n\f />][^\t\r\n\f />=]*/
会将v-if="varname"
中等号前面的v-bind:keyup.enter.prevent
都匹配出来。将匹配到的属性名收集到
nameSet[]
中,检测重复性。这里需要注意的是,属性名匹配的结果会将变量名, 修饰符都匹配到,如:
<div v-bind:keyup.enter.prevent="ok">
,最后 add 到 nameSet 中的完整属性名为:v-bind:keyup.enter.prevent
。非法属性名检测(如:
=name=value
,或属性名中包含["'<]
字符),异常移动指针
advanceBy(context, name.length)
定位到属性名后的位置,目的是为了取 属性值,剩下:="ok"
。正则:
/^[\t\r\n\f ]*=/
,解析属性值,调用 parseAttributeValue 解析出属性值来指针归位至开始位置,如:
v-bind:keyup.enter.prevent="ok"
的开始位置为v
位置,解析修饰符,得到modifiers: []
,这里的关键在于正 则:/(?:^v-([a-z0-9]+))?(?:(?::|^@|^#)([^\.]+))?(.+)?$/i
,会匹配v-if, :, @, #...
指令和指令缩写以及修饰符。解析指令后面的变量名称,如:
keyup
,有可能是动态值v-bind:[varname]
。检测属性值有没被引号包起来,如果有,要更新
value.loc
,只取引号内的内容content.source = valueLoc.source.slice(1, -1)
返回指令节点类型对象
否则返回普通属性类型节点
parseAttributeValue(context)
解析属性值。
|
|
DONE parseCDATA(context, ancestors)
CLOSED: [2020-09-02 Wed 23:14]
State "DONE" from "TODO" [2020-09-02 Wed 23:14]
解析 <![CDATA[....]]
xml 类型注释。
|
|
DONE parseBogusComment(context)
CLOSED: [2020-09-02 Wed 23:11]
State "DONE" from "TODO" [2020-09-02 Wed 23:11]
解析一些注释性的结构,如: <!DOCTYPE
。
|
|
pushNode(nodes, node)
注释节点不处理
合并文本节点(前提是 prev, node 两个节点是紧挨着的,由
loc.end.offset
和loc.start.offset
判断)返回新增 node 的 nodes 节点数组
|
|
isEnd(context, mode, ancestors)
|
|
getCursor(context)
|
|
getSelection(context, start, end?: Postion)
取实时解析后的 source,start,end 的值。
|
|
重要类型声明
该模块所有类型声明统一归类到此,顺序按照用例解析遇到的顺序为主。
defaultParserOptions
|
|
TextModes
|
|
ParserOptions
定义位置:
src/options.ts接口内容:
|
|
字段说明:
isNativeTag?: (tag: string) => boolean
一个函数,判断标签是否是原生标签(如:li, div)isVoidTag?: (tag: string) => boolean
,自关闭标签,如:img, br, hrisPreTag?: (tag: string) => boolean
,代码标签,需要空格缩进的,如:preisBuiltInComponent?: (tag: string) => symbol | void
,平台相关的内置组件,如:TransitionisCoustomElement?: (tag: string) => boolean
,用户自定的标签getNamespace?: (tag: string, parent: ElementNode | undefined) => N⁄amespace
,获取标签命名空间getTextMode?: (node: ElementNode, parent: ElementNode|undefined) => TextModes
获取文本解析模式delimiters?: [string, string]
,插值分隔符,默认:['{{', '}}']
decodeEntities?: (rawText: string, asAttr: boolean) => string
,仅用于 DOM compilersonError?: (error: CompilerError) => void
ParserContext
定义位置:
src/parse.ts接口内容:
|
|
utils.ts
advancePositionWithMutation(pos,source, numberOfCharacters)
更新 context 的 line,column,offset 的值
|
|
阶段代码记录
test-element-v-pre 代码备份, 支持 v-pre 和
<pre>
标签,以及换行
所有用例全部通过:
|
|
问题/疑问列表
如何区分内置标签|内置组件|核心组件|自定义组件?🛫
为什么 parseTag 解析
<div>
之后只会得 到<div
而不会将>
解析进去?🛫答:是因为漏掉实现了一部分代码,自闭合标签的检测,移动指针(2/1位)
如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
function parseTag(context, type) { // .... 省略 // TODO-3 <div/> 自闭标签 // 这里要实现,不然最后解析完成之后 source 会是:>...</span> // 需要检测下是不是自闭合标签来移动指针位置 let isSelfClosing = false if (context.source.length === 0) { emitError(context, ErrorCodes.EOF_IN_TAG) } else { // some <div> ... </div> 到这里的 source = > ... </div> // 所以可以检测是不是以 /> 开头的 isSelfClosing = context.source.startsWith('/>') if (type === TagType.End && isSelfClosing) { emitError(context, ErrorCodes.END_TAG_WITH_TRAILING_SOLIDUS) } // 如果是自闭合指针移动两位(/>),否则只移动一位(>) // 到这里 source = ... </div> advanceBy(context, isSelfClosing ? 2 : 1) } // ... 省略 }
为什么 parseElement 解析 children 的时候先 ancestors.push(element) 解析之后又 pop() 掉?
答:要回到这个问题要从 parseChildren 和 parseElement 两个函数结合来看,如下代码分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// 解析流程(用例5): // 1. 先 parseChildren(context, mode, ancestors) // 解析 `some <span>{{ foo < bar + foo }} text</span>` // 1) 首先得到的是 `some ` 文本节点 // 2) 检测到 <span> 进入标签解析 parseElement(context, ancestors) 注意这里的 // ancestors,是由 parseChildren 继承过来的 // 2. 进入 parseElement 解析进程 // 1) 遇到 <span> 解析出标签节点 span // 2) 在自身函数内检测到标签内还有内容,重新调用 parseChildren(..., ancestors) // 3) 所以重点来了 // ... // ... // ancestors 是 parseChildren 传递过来的,parseElement 里面将 // push 的目的:让子节点有所依赖,知道自己的父级是谁,但好像 parseChildren 里面用到 // parent 也是为了获取命名空间去用了 // pop 的目的:难道是为了不污染 ancestors ???
好像还不是很明确为何要 push->pop(DONE)。
更新:2020-09-02 16:57:35
在测试用例 parse-test-other-01 时,嵌套标签解析的时候 ancestors 中保存着多级 嵌套标签的父级(当前被解析的节点的父级)。
比如:
<div><span>\n</div></span>
这个是反例哈,这里只是举例。ancestors: Array(2) 0: {type: 1, ns: 0, tag: "div", tagType: 0, props: Array(0), …} 1: {type: 1, ns: 0, tag: "span", tagType: 0, props: Array(0), …} length: 2
解析顺序: div ->
push:ancestors[0]
-> span ->push:ancestors[1]
->\n
解 析完成之后,发现 parent 有内容,那么这个节点解析完之后会被 push 到span.children~里面去,到这里 span 解析完了,所以要退出当前递归回到 div 的解 析,因此需要将~ancestors.pop()
掉最后一个,这样才能保证 div 的 child 能正确 push 到~div.ancestors~ 中去。