在 Vue 框架里面内置了一个模板引擎,用于编译 Vue 专有语法,例如:
<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>
这里面有 {{ message }}
、v-if
、v-on:click
、v-for
等特殊的语法,Vue 需要把这些内容提取出来,转换成响应式的函数或者对应的 DOM 事件。
在 Vue 中同样是用正则来提取这些内容的,它的转化流程如下:
- 通过正则把模板转换成 AST 抽象语法树
- 用 AST 生成 JS 代码
- 用
new Function
配合with
来执行 JS 代码
AST 抽象语法树
AST 中的节点是具有特殊属性的 JS 对象,它的结构大致如下:
{
tag: tagName, // 标签名
type: 1, // 元素类型
children: [], // 孩子列表
attrs, // 属性集合
parent: null, // 父元素
text: null // 文本节点内容
...
}
在 AST 抽象语法树中,会按照节点类型的不同进行区分:
- 元素类型
- 文本类型
- 注释类型
- ...
正则分析
接下来开始用正则对节点进行提取,先看下 Vue 中定义的正则:
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
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
这个正则比较长,是用于匹配 HTML 标签属性的,可能的属性写法有:
- 双引号:
class="some-class"
- 单引号:
class='some-class'
- 不用引号:
class=some-class
- 单独的属性名:
disabled
这个表达式有五个捕获组,第一个捕获组用来匹配属性名,第二个捕获组用来匹配等于号,第三、第四、第五个捕获组都是用来匹配属性值的,同时 ? 表明第三、四、五个分组是可选的。
startTagClose
用于匹配结束标签,例如:br />
或 /div>
。
解析器
利用上面的正则,可以写出下面简化版 AST 解析器:
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 树:
{
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 Function
和 with
进行处理。所以接下来要写一个函数来处理上面的 AST 树:
function generate(node) {
return node.type === 1 ? genElement(node) : genText(node.text)
}
同样这里只考虑元素节点和文本节点两种情况。
生成元素节点代码
对于元素节点,要拼成 _c(tag, data, childNodes)
函数,
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})`
}
处理元素节点上的属性
下面的代码用于处理属性:
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)
}
生成文本节点代码
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 带入函数得到代码字符串为:
_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 用到的一些辅助函数:
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 用下面的变量带入:
{
message: '消息',
seen: false,
todos: [{ text: 'study' }, { text: 'reading' }],
}
会得到虚拟 DOM:
{
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 换成下面的环境:
{
message: '水果',
seen: true,
todos: [{ text: '香蕉' }, { text: '苹果' }, { text: '西瓜' }],
}
则可以生成另一种 DOM 结构:
{
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。