# Vue 模板编译
# 总体概述
- 获取 template:
- 首先要拿到 Vue 构造函数中 $options 参数中的 el,判断有没有 el,如果有再进行下一步操作
- 然后在 Vue 的原型上添加 $mount 方法用于渲染,在这个方法中,需要做如下操作:
- 获取 $options,并在 Vue 上挂载 $el
- 判断 $options 中有无 render 函数(目前不考虑有 render 函数的情况),有的话就使用 render 函数;然后判断有无 template,有的话就使用 template;最后判断在 body 标签中有无 el 所对应的元素节点,如果有就把该元素的 outerHTML 赋值给 template
- template 转 AST(Abstract Syntax Tree,抽象语法树) 树:
- 一边匹配一边删除,全部处理完就成为空字符串
- 用 parseStartTag 处理开始标签获取属性,处理完后用 start 函数
- 遇到文本节点用 chars 函数处理
- 遇到结束标签用 end 函数处理
- AST 树转 render 函数(_c、_v、_s):
- 获取元素的子元素并将其全部转化为字符串【递归处理】
- 将属性转为字符串
- 最后将元素节点进行字符串拼接
- render 函数转虚拟节点:
- 使用with函数将this指向vm,然后调用函数产生虚拟节点(vnode)
- 其中需要在原型上添加上 _c、_v、_s 方法
- 设置 PATCH,打补丁到真实 DOM
- 利用递归将虚拟DOM转化为真实DOM,然后替换 el
- 转化的时候需要将各自的属性添加至节点上
# 1. 模板转 AST 树
函数名:parseHTMLToAST(html)
# 1.1 实现思路
- 正则匹配 template 模板中的标签、属性和文本节点,正则匹配的所有功能如下,详情可查看 Vue 源码 (opens new window):
// 匹配id="app"/id='app'/id=app
const attribute =
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
// 匹配开始标签的起始部分,如:<div
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 匹配开始标签的结束部分,如:>、/>
const startTagClose = /^\s*(\/?)>/
// 匹配结束标签,如:</div>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
将 template 中匹配一部分删除一部分(advance 函数),匹配开始标签的流程如下:
- 函数名:parseStartTag()
- 首先 template 字符串中判断
<
的下标是为为 0,如果为 0 则代表即将匹配标签的开始标签(大于 0 的情况下面会说)或者为结束标签,则匹配结束标签(endTag),放入 end 函数中处理 - 然后匹配开始标签中的标签名(startTagOpen)、属性名和属性值(attribute),在匹配属性的时候要一直判读是否匹配到开始标签的结束部分,如果没有匹配到那就一直匹配属性
- 最后如果匹配到开始标签的结束项
>
或者/>
(startTagClose),这样匹配结束,返回 match 对象,结构如下:
match = { tagName: 'xxx', attrs: [ { name: 'aaa', value: '......' } ] // attrs 中的 value 是字符串类型,不存在对象类型,如果匹配到像 style 属性,最后转成 AST 树后 attrs 中的 value 是对象类型,处理在 render 函数中将字符串转为对象形式 }
- 将返回的对象放入 start 函数中处理
如果大于 0,则代表匹配文本内容,那么从开始到
<
前到内容都为文本,将其截取后放入 chars 函数中处理最后生成 AST 树,大致结构如下:
{
tag: 'div',
type: 1, // 代表元素节点
attrs: [
{
name: 'id',
value: 'app'
},
{
name: 'style',
value: {
color: 'red',
fontSize: '20px'
}
}
],
children: [
// 子元素
......
]
}
# 1.2 start 函数
- 利用 createASTElement 函数创建 AST 元素,即返回如下对象:
{
tag: tagName,
type: 1,
children: [],
attrs
}
- 利用栈存储节点,每次匹配到开始标签的标签名,就将标签名入栈
- 然后当前的父节点就是此元素
function start(tagName, attrs) {
console.log('---------开始---------');
console.log(tagName, attrs);
const element = createElement(tagName, 1, attrs);
!root && (root = element);
currentParent = element;
stack.push(element);
}
# 1.3 end 函数
- 遇到结束标签,将栈顶元素弹出,设为 A,并且此时栈顶的元素 B (如果有)就是元素 A 的父元素,元素 A 就是元素 B 的子元素
function end(tagName) {
console.log('---------结束---------');
console.log(tagName);
const element = stack.pop(),
parent = stack[stack.length - 1];
// 遇到结束标签那么需要找该元素的父元素,如果stack没有说明其为根元素
if (parent) {
parent.children.push(element);
}
}
# 1.4 chars 函数
- 将获取的文本进行去除前后空格,如果去除后长度不为 0,则代表当前父元素下有文本节点,那么将父元素中的 children 数组中加入文本节点对象
function chars(text) {
text = text.trim();
// 如果文本节点不为空则加入,因为有时候为换行符等空白字符
if (text) {
console.log('---------文本---------');
console.log(text);
currentParent.children.push({
type: 3,
value: text.trim()
})
}
}
# 1.5 代码实现(部分)
function parseHTMLToAST(html) {
while (html) {
let startTagIndex = html.indexOf('<');
if (startTagIndex === 0) {
let match;
// 如果为开始标签
if (match = html.match(startTagOpen)) {
advance(match[0]);
let attrs = parseStartTag(); // 获取属性值
start(match[1], attrs);
}
// 如果为结束标签
if (match = html.match(endTag)) {
end(match[1]);
advance(match[0]);
}
}
else {
// 为文本
let text = html.substring(0, startTagIndex);
chars(text);
advance(text);
}
}
return root;
}
提示
目前不能匹配单标签,也不支持属性里绑定变量
# 2. AST 树转 render 函数
函数名:generate(ast)
_c() => createElement()
_v() => createTextNode()
_s() => {{ name }} => _s(name)
render 函数是将 AST 对象转换后的一个字符串形式,举个例子:
- HTML 代码:
<div id="app" style="color: red; font-size: 20px;">
hello {{ name }}
<span style="color: green;">{{ age }}</span>
</div>
- 用 render 函数将 HTML 转换后的字符串:
`
_c(
"div",
{
"id": "app",
"style": {
"color": "red",
"font-size": "20px"
}
},
_v("hello" + _s(name)),
_c(
"span",
{
"class": "text",
"style": {
"color": "green"
}
},
_v(_s(age))
)
)
`
# 2.1实现思路
【递归】首先判断当前元素中是否含有 children 属性,如果没有则进行下一步,如果有那么需要产生 children 字符串,因为 children 存放的是一个数组,数组中含有元素节点对象信息,那么需要遍历每一个节点,使用 genChildren 函数对其进行字符串化,过程如下:
遍历的时候该节点有可能是元素节点或者文本节点
如果是元素节点,那么用 generate 函数进行处理
如果为文本节点那么又分为两种可能:
如果为纯文本,则直接返回即可
如果为混合文本(变量+文本),则需要对大括号进行正则匹配,然后进行截取和拼接操作,将纯文本直接放入临时数组中,如果匹配到则将变量拼接后添加进临时数组,直到匹配结束,结束后如果此时的 lastIndex 小于文本的长度则表示还有文本未被截取,则需将最后一部分文本也添加进临时数组,最后用
+
字符连接即可(正则匹配可查看 Vue 源码 (opens new window))const defautTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
在 generate 函数中拼接元素节点的标签名
元素节点中如果有属性,则传入 genAtrrs 中进行处理
- 如果属性值不为 style 那么可以直接拼接为 key: value 形式
- 否则将 style 中的每一项元素转化为 key: value 形式存储在对象中,并重新赋值给 value
- 最后获取到字符串,但是要返回到对象,所以要在字符串外加上
{}
,并且删除最后一个,
,这样属性就处理完了
然后添加 children 字符串,如果没有则为
''
# 2.2 代码实现(部分)
- 产生文本节点
function genTextNode(text) {
console.log("text: ", text);
// 如果为纯文本则直接返回
if (!defautTagRE.test(text)) return `_v(${JSON.stringify(text)})`;
let match, index = 0, lastIndex = defautTagRE.lastIndex = 0;
const tokens = [];
// 如果为混合文本,先将每一部分存入tokens然后用+连接
while (match = defautTagRE.exec(text)) {
index = match.index;
if (index > lastIndex) {
tokens.push(JSON.stringify(text.substring(lastIndex, index)));
}
tokens.push(`_s(${match[1].trim()})`);
lastIndex = index + match[0].length;
}
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.substring(lastIndex)));
}
return `_v(${tokens.join("+")})`;
}
- 子元素字符串化
function genChildren(children) {
// 防止产生多余的逗号,那么需要把子元素都收集起来再用逗号连接即可
let str = [];
children.forEach(item => {
if (item.type === 1) {
// 如果为元素节点就用generate函数产生一次
str.push(generate(item));
} else if (item.type === 3) {
// 如果为文本节点
str.push(genTextNode(item.value));
}
});
return str.join(',');
}
- 主函数
function generate(obj) {
const children = genChildren(obj.children);
return `_c(${obj.tag},${obj.attrs.length ? genAtrrs(obj.attrs) : 'undefined'}${children ? `,${children}` : ''})`;
}
# 3. 将 render 函数转化为虚拟 DOM
函数名:_render
# 3.1 实现思路
实现比较简单,首先需要在原型上添加 _c、_v、_s 三种方法
- _c 代码如下:
// 元素节点 Vue.prototype._c = function() { return createElement(...arguments); }
- _v 代码如下:
// 文本节点 Vue.prototype._v = function(text) { return createTextVode(text); }
- 添加变量:
// 变量 Vue.prototype._s = function (value) { if (value === null) return; return typeof value === 'object' ? JSON.stringify(value) : value; }
最后需要获取实例上的 render 函数,然后调用即可
# 3.2 代码实现
Vue.prototype._render = function () {
const vm = this,
render = vm.$options.render,
vnode = render.call(vm);
console.log("vnode: ", vnode, "\n--------------------");
return vnode;
}
# 4.虚拟 DOM 转化为真实 DOM
函数名:patch
# 4.1 实现思路
- 到目前为止我们已经获得了 render 函数运行后产生的虚拟 DOM,那么需要在 _update 函数里把传入的虚拟 DOM 转化为真实 DOM,然后替换 options 中的 el 元素
- 虚拟 DOM 的结构如下:
{
"tag": "div",
"attrs": {
"id": "app",
"class": "box",
"style": {
"color": "red",
"font-size": "20px"
}
},
"children": [
{
"text": "hello xxx, welcome to Karl's mini Vue.",
"el": null
},
{
"tag": "span",
"attrs": {
"style": {
"color": "green"
}
},
"children": [
{
"tag": "span",
"children": [
{
"text": "Hello World!",
"el": null
}
],
"el": null
},
{
"tag": "strong",
"attrs": {
"class": "test"
},
"children": [
{
"text": "Test",
"el": null
}
],
"el": null
}
],
"el": null
},
{
"tag": "p",
"children": [
{
"text": "Today is a nice day.",
"el": null
}
],
"el": null
}
],
"el": null
}
- 要产生真实的 DOM 需要递归处理,处理过程如下:
- 函数名:createElement
- 首先判断是否有 tag 的值,因为如果有就是元素节点,否则就是文本节点
- 然后分别用 createElement 方法和 createTextNode 方法产生相应的节点
- 其次如果有 children,那么就遍历 children,把每一项继续用 createElement 产生子元素节点,并将子元素节点添加到父节点下即可
- 最后返回该节点,这样就完成了 DOM 的产生,但是还需要将各自的属性添加到 DOM 节点上
- 就需要遍历 attrs 数组:
- 属性如果为 class,那么设置节点的 className 值
- 属性如果为 style,那么先将属性值拼接为字符串后,设置节点的 style.cssText 值
- 属性如果为其他值,那么直接 setAttribute 即可
- 产生真实的 DOM 后获取 oldNode 的父元素,然后将新产生的 DOM 添加在父节点的最后,并删除 oldNode 节点
# 4.2 代码实现
function createElement(vnode) {
const { tag, attrs, children, text } = vnode;
let dom = vnode.el = null;
if (tag) {
// 元素节点
dom = document.createElement(tag);
addAttrs(dom, attrs);
children.forEach(item =>
dom.appendChild(createElement(item)));
} else {
// 文本节点
dom = document.createTextNode(text)
}
return dom;
}
function patch(oldNode, vnode) {
const el = createElement(vnode),
parent = oldNode.parentNode;
console.log('真实DOM: ', el, '\n--------------------');
parent.insertAdjacentElement('beforeend', el);
parent.removeChild(oldNode);
}
# 5. 写在最后
- 完整代码:每个步骤都有相应的结果打印,可清楚查看每个过程,可见我的 GitHub 仓库 📦 (opens new window),如果喜欢可以给一颗 ⭐️ 支持一下
- 参考资料: