如何解析template成VNODE

最近被问到,Vue2.0 中 template 是如何解析成 VNode
先变成 DOM 节点再通过深度优先遍历转成 VNode,
又瞎答说使用正则来解析,但是又给不出具体的思路。

恰逢周末,特地搜了一波,运气还不错,搜到了preact作者写的htm库,发现完全就是我想要的实现,而且简单,代码量少,不必直接阅读 Vue 或 React 源码。

代码量虽少,但对于我来说,还是不太好理解,所以打算记录学习该源码的过程,帮助和我一样读起来较困难的同学。😁

为了简单,所以我们将以普通的 html 为例子,像 vue 中的指令感兴趣的可以自我扩展。

1
2
3
4
5
6
7
8
const template = `
<div class="container">
<h1 class="h1">
Hello World
</h1>
<br />
</div>
`;

从上面的实例 template 中,我们可以大概知道其中包含的字符有:

1
2
3
4
5
6
7
8
9
10
<      # 标签开始
> # 标签结束
/ # 尾标签
\t # Tab键输入
\r # 换行
\n # 换行
" # 字符串 (属性值)
' # 字符串 (属性值)
= # 属性=属性值
a-zA-Z # 字符,你懂的

VNode 结构

首先让我们看下 VNode 的结构:

1
2
3
4
5
6
7
8
/**
* type: 元素type
* props: 元素的属性
* children: 元素的子节点
*/
function h(type, props, ...children) {
return { type, props, children };
}

htm库的方法是遍历 template 字符串,所以当我们处理到上面的字符,我们需要进行判断性操作。

解析 type

如果当遍历到<时, 表示我们解析到标签(元素节点), 但是我们如何判断出该元素的type(如: div,span,a)值呢?

想一想其实大概有三种情况:

1
2
3
4
5
6
<!-- 1. 解析到空格字符时候,表示获取到该元素的type值 -->
<div class="container"></div>
<!-- 2. 解析到>字符时 -->
<div></div>
<!-- 3. 解析到/字符时。 这里编辑器美化处理了,导致br和/中间隔开了 -->
<br />

解析 props

1
<div class="container"></div>

当我们解析到<div class的空格时,我们获取到了元素的 type 值,这个时候我们继续遍历(并进入空格模式),并进行一个+=char的过程,记录遍历的字符

这个时候我们遍历到了=,好的,表示我们解析到了属性,这个时候我们通过"来解析属性值,当遇到后面那个",我们判断为一个属性解析完毕。并添加到props中.

不知道,大家有没有考虑到一种场景,有些属性是不需要设置属性值的,比如<input hidden ><input hidden>, 这个时候我们所以需要在遍历到空格或者>的时候判断属性处理, 并添加到
props中,属性值设置为true.

解析 children

这个应该算是最难理解的部分了。htm默认的处理是初始一个数组[0], 通过pushslice来获取children。这里不明白不用急,让我们简单的实现一个htm

代码会比较多,可以泡杯咖啡慢慢阅读,最好可以自己手打一遍

简单实现

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
// 遍历处理字符所处于的模式
const MODE_SLASH = 0;
const MODE_TEXT = 1; // 进入普通模式
const MODE_TAGNAME = 2; // 进入元素模式
const MODE_ATTRIBUTE = 3; // 进入属性模式
const MODE_WHITESPACE = 4; // 进入空格模式

function compile(tmpl) {
let mode = MODE_TEXT; // 初始普通模式
let buffer = ""; // 进行记录之前遍历过的字符 += char
let propName = ""; // 属性名{}
let quote = "";
let current = [0]; // 保存VNode

function h(type, props, ...children) {
return { type, props, children };
}

// 根据不同的模式,进行不同的处理
function commit() {
// 库:表示current

if (
mode === MODE_TEXT &&
(buffer = buffer.replace(/^\s*\n\s*|\s*\n\s*$/g, ""))
) {
// htm对于只有\s和\n的文本进行去空处理
// 也就是<前面只有非空文本节点才会选择入库
current.push(buffer);
} else if (mode === MODE_TAGNAME && buffer) {
// 进入这里可能是遍历到>(<div>),或者是遍历到\s(<div >)
current[1] = buffer; // buffer 便是type
// 因为如果是<div>进入,设置成空格模式,则在遍历中会直接执行buffer += char;不会有什么问题
// 如果是<div >进入,需要设置空格模式
mode = MODE_WHITESPACE;
} else if (mode === MODE_WHITESPACE && buffer) {
// 可能是遍历到>(<div class="container">)或者\s(<div class="container" >)
(current[2] = current[2] || {})[buffer] = true;
} else if (mode === MODE_ATTRIBUTE && propName) {
// 有属性值的属性
(current[2] = current[2] || {})[propName] = buffer;
propName = "";
}

// 处理后,不再保存之前的遍历字符
buffer = "";
}

// 解构字符串,变成一个个字符
[...tmpl].forEach(char => {
// 如果当前处于普通模式
if (mode === MODE_TEXT) {
// 解析到<
if (char === "<") {
// <前面的文本节点遍历完毕,可以入current保存了
commit();
// 表示解析到元素,这里会把当前VNode节点,先保存起来,类似递归操作
// 因为还没解析出type和props,所以一个设置为"",另一个设置为null
current = [current, "", null];
// 进入元素模式
mode = MODE_TAGNAME;
}
// 文本节点内容, 可以理解为<前面的文本节点
else {
// 记录保存下来
buffer += char;
}
} else if (quote) {
// 表示已解析到"或'字符, 但还没有遇到终止,表示这个属性值还没结束

if (char === quote) {
// 遇到终止
quote = ""; // 重置
} else {
buffer += char;
}
} else if (char === '"' || char === "'") {
// 遍历到"或'字符
quote = char;
} else if (char === ">") {
// 上面提到当遍历到>,可能是属性解析完毕<input hidden>, 也可能是元素解析完毕<div>
// 所以我们需要提交一下
commit();
// 按理说>后面,基本上是换行或者空格,所以我们进入普通模式
mode = MODE_TEXT;
} else if (!mode) {
// 什么都不做,SLASH模式
} else if (char === "=") {
// 解析到属性
mode = MODE_ATTRIBUTE;
propName = buffer; // 属性名
buffer = "";
} else if (char === "/") {
// <input /> or <div></div>
// 可能表示属性解析结束, 所以提交一波
commit();
if (mode === MODE_TAGNAME) {
// </div>或者<input/>
// 取出节点们
// 因为</div> 遍历<时会添加一个元素进去, 但是我们是不需要的
current = current[0];
}
mode = current;
// 把当前VNode push到上一个节点的children中去
(current = current[0]).push(h.apply(null, mode.slice(1)));
mode = MODE_SLASH;
} else if (
char === " " ||
char === "\t" ||
char === "\n" ||
char === "\r"
) {
// 当遍历到<div class的空格时

// 这里可能是元素type解析好,也可能是属性间的空格
// 所以需要提交判断下
commit();
mode = MODE_WHITESPACE;
} else {
// 其他
buffer += char;
}
});

return current.length > 2 ? current.slice(1) : current[1];
}

example 使用

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
const template = `
<div class="container">
<h1 class="h1">
Hello World
</h1>
<br />
</div>
`;

compile(template);
/*
{
"type":"div",
"props":{"class":"container"},
"children":[
{
"type":"h1",
"props":{"class":"h1"},
"children":["Hello World"]
},
{
"type":"br",
"props":null,
"children":[]
}
]
}
*/

更新

  1. fix L205 MODE_SLASh -> MODE_SLASH
  2. 增加compilereturn

难免有错误之处,还请指出,谢谢。🤝

推荐文章