模板引擎实现原理(Vue篇)

在 Vue 框架里面内置了一个模板引擎,用于编译 Vue 专有语法,例如:

1
2
3
4
5
6
7
8
9
10
<div id="app">
你好,{{ message }}!
<p v-if="seen" styles="color: red; fontSize: 16px">条件渲染</p>
<button v-on:click="reverseMessage">反转消息</button>
<ol>
<li v-for="todo in todos" class="color-gray ml-2">
{{ todo.text }}
</li>
</ol>
</div>

这里面有 v-ifv-on:clickv-for 等特殊的语法,Vue 需要把这些内容提取出来,转换成响应式的函数或者对应的 DOM 事件。

在 Vue 中同样是用正则来提取这些内容的,它的转化流程如下:

  • 通过正则把模板转换成 AST 抽象语法树
  • 用 AST 生成 JS 代码
  • new Function 配合 with 来执行 JS 代码

AST 抽象语法树

AST 中的节点是具有特殊属性的 JS 对象,它的结构大致如下:

1
2
3
4
5
6
7
8
9
{
tag: tagName, // 标签名
type: 1, // 元素类型
children: [], // 孩子列表
attrs, // 属性集合
parent: null, // 父元素
text: null // 文本节点内容
...
}

在 AST 抽象语法树中,会按照节点类型的不同进行区分:

  • 元素类型
  • 文本类型
  • 注释类型

正则分析

接下来开始用正则对节点进行提取,先看下 Vue 中定义的正则:

1
2
3
4
5
6
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; // 标签名
const qnameCapture = `((?:${ncname}\\:)?${ncname})`; // ?: 表示匹配不捕获
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 标签开头的正则 捕获的内容是标签名
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾的 </div>
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性
const startTagClose = /^\s*(\/?)>/; // 匹配标签结束的 >

ncname

ncname 就是不包含前缀的XML标签名称,规则如下:

字母(a-zA-Z)或下划线(_)开头,后面可以跟任意数量的:

  • 中横线(-)
  • 点(.)
  • 数字(0-9)
  • 下划线(_)
  • 字母(a-zA-Z)

qname 和 qnameCapture

qname 是合法的 XML 标签,它的组成规则是 <前缀:标签名称>,例如:<abc:span></abc:span>,其中前缀可以省略,也就是说,可能是一个 ncname,或者两个 ncname 中间通过冒号拼接起来。

这个正则中冒号和冒号前面的部分是一个非捕获分组,后面的标签名是捕获分组,即可以取到标签名称。

startTagOpen

匹配开始标签,例如 <div<abc:span

endTag

来匹配结束标签

attribute

1
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

这个正则比较长,是用于匹配 HTML 标签属性的,可能的属性写法有:

  • 双引号:class="some-class"
  • 单引号:class='some-class'
  • 不用引号:class=some-class
  • 单独的属性名:disabled

这个表达式有五个捕获组,第一个捕获组用来匹配属性名,第二个捕获组用来匹配等于号,第三、第四、第五个捕获组都是用来匹配属性值的,同时 ? 表明第三、四、五个分组是可选的。

startTagClose

用于匹配结束标签,例如:br />/div>

解析器

利用上面的正则,可以写出下面简化版 AST 解析器:

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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
function parseHTML(html) {
let root, parent, stack = []
// 只要剩余的 html 不为空就一直解析
while (html) {
let textEnd = html.indexOf('<')
if (textEnd == 0) {
const { tag, attrs } = parseStartTag() || {}
if (tag) {
start(tag, attrs)
continue
}
const endTagMatch = html.match(endTag)
if (endTag) {
advance(endTagMatch[0].length)
end(endTagMatch[1])
continue
}
} else {
const text = textEnd > 0 ? html.substring(0, textEnd) : html
advance(text.length)
chars(text)
}
}
// 获取截取后剩余的html
function advance(n) {
html = html.substring(n)
}
// 解析开始标签
function parseStartTag() {
const start = html.match(startTagOpen)
if (start) {
const match = { tag: start[1], attrs: [] }
advance(start[0].length)
let end, attr
while (
!(end = html.match(startTagClose)) &&
(attr = html.match(attribute))
) {
match.attrs.push({
name: attr[1],
value: attr[3] || attr[4] || attr[5],
})
advance(attr[0].length)
}
if (end) {
advance(end[0].length)
return match
}
}
}
// 解析到开始标签时触发
function start(tag, attrs) {
const el = createASTElement(tag, attrs)
if (!root) root = el
stack.push((parent = el))
processFor(el) // 处理 v-for
processIf(el) // 处理 v-if
processAttrs(el) // 处理 v-on、v-show、v-bind 等
}
// 解析到结束标签时触发
function end(tag) {
const el = stack.pop()
parent = stack[stack.length - 1]
if (parent) {
el.parent = parent
parent.children.push(el)
}
}
// 解析到文本时触发
function chars(text) {
text = text.trim()
if (!text) return
const el = { type: 3, text }
if (parent) {
parent.children.push(el)
el.parent = parent
}
}
return root
}
// 创建元素节点
function createASTElement(tag, attrs, parent) {
return {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
parent,
children: [],
}
}
// 把数组类型的属性转换为对象
function makeAttrsMap(attrs) {
const map = {}
attrs.forEach((it) => (map[it.name] = it.value))
return map
}
// 获取并删除数组中的某个属性
function getAndRemoveAttr(el, name) {
let val
if ((val = el.attrsMap[name]) != null) {
const list = el.attrsList
for (let i = list.length - 1; i >= 0; i--) {
if (list[i].name === name) {
list.splice(i, 1)
break
}
}
}
return val
}
// 处理v-for
function processFor(el) {
let exp
if ((exp = getAndRemoveAttr(el, 'v-for'))) {
const inMatch = exp.match(/([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/)
if (!inMatch) return
Object.assign(el, {
alias: inMatch[1].trim(),
for: inMatch[2].trim(),
})
}
}
// 处理v-if
function processIf(el) {
const exp = getAndRemoveAttr(el, 'v-if')
if (exp) el.if = exp
}
// 处理各种属性,这里以v-on为例
function processAttrs(el) {
const list = el.attrsList,
onRE = /^@|^v-on:/
let i, l, name, rawName, value
for (i = 0, l = list.length; i < l; i++) {
name = rawName = list[i].name
value = list[i].value
if (onRE.test(name)) {
name = name.replace(onRE, '')
el.events = { [name]: value }
list.splice(i, 1)
break
}
}
}

对文章开头的示例模板运行这段代码,可以得到如下的 AST 树:

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
{
type: 1,
tag: 'div',
attrsList: [ { name: 'id', value: 'app' } ],
attrsMap: { id: 'app' },
children: [
{ type: 3, text: '你好,{{ message }}!' },
{
type: 1,
tag: 'p',
attrsList: [ { name: 'style', value: 'color: red; fontSize: 16px' } ],
attrsMap: { 'v-if': 'seen', style: 'color: red; fontSize: 16px' },
children: [ { type: 3, text: '条件渲染' } ],
if: 'seen'
},
{
type: 1,
tag: 'button',
attrsList: [],
attrsMap: { 'v-on:click': 'reverseMessage' },
children: [ { type: 3, text: '反转消息' } ],
events: { click: 'reverseMessage' }
},
{
type: 1,
tag: 'ol',
attrsList: [],
attrsMap: {},
children: [
{
type: 1,
tag: 'li',
attrsList: [ { name: 'class', value: 'color-gray ml-2' } ],
attrsMap: { 'v-for': 'todo in todos', class: 'color-gray ml-2' },
children: [ { type: 3, text: '{{ todo.text }}' } ],
alias: 'todo',
for: 'todos'
}
]
}
]
}

注意这里并没有对注释节点等进行解析,只处理了元素节点和文本节点。

生成器

有了 AST 之后,就需要将其组装成代码了,本质上就是拼接代码字符串,用 new Functionwith 进行处理。所以接下来要写一个函数来处理上面的 AST 树:

1
2
3
function generate(node) {
return node.type === 1 ? genElement(node) : genText(node.text)
}

同样这里只考虑元素节点和文本节点两种情况。

生成元素节点代码

对于元素节点,要拼成 _c(tag, data, childNodes) 函数,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function genElement(el) {
const { tag, attrsList, children } = el
const childNodes = children.map((child) => generate(child))
if (el.for && !el.forProcessed) {
el.forProcessed = true
return (
`_l((${el.for}),` +
`function(${el.alias}){` +
`return ${genElement(el)}` +
'})'
)
} else if (el.if && !el.ifProcessed) {
el.ifProcessed = true
return `${el.if} ? ${genElement(el)}: _e('')`
}
return `_c('${tag}',${genAttrs(attrsList)},${childNodes})`
}

处理元素节点上的属性

下面的代码用于处理属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function genAttrs(attrs) {
const obj = {}
for (let i = 0; i < attrs.length; i++) {
let attr = attrs[i]
if (attr.name === 'style') {
const kv = {} // 对样式进行特殊的处理
attr.value.split(';').forEach((item) => {
let [key, value] = item.split(':')
kv[key.trim()] = value.trim()
})
attr.value = kv
}
obj[attr.name] = attr.value
}
return JSON.stringify(obj)
}

生成文本节点代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function genText(text) {
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
if (!defaultTagRE.test(text)) {
return `_v(${JSON.stringify(text)})`
}
let tokens = []
let lastIndex = (defaultTagRE.lastIndex = 0)
let match, index
while ((match = defaultTagRE.exec(text))) {
index = match.index
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}
tokens.push(`_s(${match[1].trim()})`)
lastIndex = index + match[0].length
}
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return `_v(${tokens.join('+')})`
}

代码生成结果

将 ast 带入函数得到代码字符串为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_c(
'div',
{ id: 'app' },
_v('你好,' + _s(message) + '!'),
seen
? _c('p', { style: { color: 'red', fontSize: '16px' } }, _v('条件渲染'))
: _e(''),
_c('button', {}, _v('反转消息')),
_c(
'ol',
{},
_l(todos, function (todo) {
return _c('li', { class: 'color-gray ml-2' }, _v(_s(todo.text)))
})
)
)

虚拟 DOM

有了代码字符串之后,就可以带入环境变量来生成虚拟 DOM 了,下面是生成虚拟 DOM 用到的一些辅助函数:

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
function _c(tag, data, ...children) {
return { tag, data, children: children.flat() }
}
function _v(text) {
return { text }
}

function _s(val) {
if (val == null) return ''
if (typeof val == 'object') return JSON.stringify(val, null, 2)
return String(val)
}

function _l(val, render) {
const ret = new Array(val.length)
for (i = 0, l = val.length; i < l; i++) {
ret[i] = render(val[i], i)
}
return ret
}

function _e(text) {
return { text, isComment: true }
}

function createVdom(vm, code) {
const f = new Function('vm', `with(vm){return ${code}}`)
return f({ ...vm, _c, _s, _v, _l, _e })
}

如果 vm 用下面的变量带入:

1
2
3
4
5
{
message: '消息',
seen: false,
todos: [{ text: 'study' }, { text: 'reading' }],
}

会得到虚拟 DOM:

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
{
tag: 'div',
data: { id: 'app' },
children: [
{ text: '你好,消息!' },
{ text: '', isComment: true },
{ tag: 'button', data: {}, children: [ { text: '反转消息' } ] },
{
tag: 'ol',
data: {},
children: [
{
tag: 'li',
data: { class: 'color-gray ml-2' },
children: [ { text: 'study' } ]
},
{
tag: 'li',
data: { class: 'color-gray ml-2' },
children: [ { text: 'reading' } ]
}
]
}
]
}

如果 vm 换成下面的环境:

1
2
3
4
5
{
message: '水果',
seen: true,
todos: [{ text: '香蕉' }, { text: '苹果' }, { text: '西瓜' }],
}

则可以生成另一种 DOM 结构:

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
{
tag: 'div',
data: { id: 'app' },
children: [
{ text: '你好,水果!' },
{
tag: 'p',
data: { style: { color: 'red', fontSize: '16px' } },
children: [ { text: '条件渲染' } ]
},
{ tag: 'button', data: {}, children: [ { text: '反转消息' } ] },
{
tag: 'ol',
data: {},
children: [
{
tag: 'li',
data: { class: 'color-gray ml-2' },
children: [ { text: '香蕉' } ]
},
{
tag: 'li',
data: { class: 'color-gray ml-2' },
children: [ { text: '苹果' } ]
},
{
tag: 'li',
data: { class: 'color-gray ml-2' },
children: [ { text: '西瓜' } ]
}
]
}
]
}

由于 vue 的数据是响应式的,数据改变会触发页面渲染,而页面渲染的逻辑就是新旧虚拟 DOM 利用 patch 算法进行比较得到差异,最终更新真实 DOM。