文章来源:手写Vue2.0源码(二)-模板编译原理|技术点评 - 掘金 (juejin.cn) 本文无商业价值,如侵联删。
Vue进行模板渲染的步骤是:
1)首先拿到template中的html,利用正则匹配将html转换成ast语法树,这棵树描述了我们当前的DOM结构;
2)然后再将AST语法树转行成虚拟DOM节点,最后再将虚拟DOM节点转换成真实DOM节点。
那么什么是ast语法树呢?
废话不多说,我们用一个小案例来搞明白
如果此时你的Vue中的el模板是:
<div id="app">
<p>hello{{name}}</p>
</div>
那么最后所变成的ast语法树应该大致为这样
let root = {
tag: "div", //标签名称
attrs: [{ name: "id", value: "app" }], //标签属性
parent: null, //该标签的父亲
type: 1, //比如元素的类型是1
children: [
{
tag: "p",
attrs: [],
parent: root,
type: 1, //比如元素的类型是1
children: [
{
text: "hello{{name}}",
type: 3, //比如文本的类型是3
},
],
},
],
};
1)首先将template转换成AST语法树
import { initState } from "./initState";
import compileToFunctions from './compiler/index.js'
function initMixin(Vue) {
Vue.prototype._init = function (options) {
const vm = this; //声明一个vm=this,这样方便后续拿值,且由于this是实例对象,根据地址引用,操作vm就相当于操作this
vm.$options = options; //把new Vue传进来的options配置对象挂载在vue的实例身上
initState(vm); //初始化状态,这里进行data初始化等
if (vm.$options.el) {
//开始进行模板渲染
vm.$mount(vm.$options.el);
}
};
Vue.prototype.$mount = function (el) {
const vm = this;
const options = vm.$options;
el = document.querySelector(el);
// 进行el挂载时的顺序为:
//如果options中有render函数,则使用render;没有则采用template;否则就使用el中的内容
if (!options.render) {
let template = options.template;
if (!template && el) {
template = el.outerHTML; //outerHTML内容包含描述元素及其后代的序列化 HTML 片段,就是字符串
}
// 拿到template之后,我们就要把template先变成AST语法树,再变成虚拟DOM节点
if (template) {
const render = compileToFunctions(template);
options.render = render;
}
}
};
}
export { initMixin }
src/compiler/index.js
import parse from "./parse.js";
import generate from "./generate.js";
export default function compileToFunctions(template) {
// 1)首先将template生成ast语法树
let ast = parse(template);
// 2)需要把ast转化成类似_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))这样的字符串
// 3)然后把上面的字符串生成一个render函数
let code = generate(ast);
//with语法可以改变作用域
let renderFn = new Function(`with(this){return ${code}}`);
return renderFn;
}
src/compiler/parse.js
export default function prase(html) {
//用来捕获template的正则表达式,?:是匹配但不捕获的意思
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; //匹配标签名 形如 abc-123
const qnameCapture = `((?:${ncname}\\:)?${ncname})`; //匹配特殊标签 形如 abc:234 前面的abc:可有可无
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 匹配标签开始 形如 <abc-123 捕获里面的标签名
const startTagClose = /^\s*(\/?)>/; // 匹配标签结束 >
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾 如 </abc-123> 捕获里面的标签名
const attribute =
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性 形如 id="app"
let root, currentParent; //首先定义一个根节点和当前父节点
let stack = []; //栈结构,把匹配到的标签都放入栈中,当匹配到相同的标签,证明就是闭合的标签,需要从栈的最后边拿到这个标签
const ELEMENT_TYPE = 1; //元素类型为1
const TEXT_TYPE = 3; //文本的类型为3
//首先定义一个生成AST语法树结构的方法
function createASTElement(tagName, attrs) {
return {
tag: tagName,
type: ELEMENT_TYPE,
children: [],
attrs,
parent: null,
};
}
// 对开始标签进行处理
function handleStartTag({ tagName, attrs }) {
let element = createASTElement(tagName, attrs);
if (!root) {
root = element;
}
currentParent = element;
stack.push(element);
}
// 对结束标签进行处理
function handleEndTag(tagName) {
// 栈结构 []
// 比如 <div><span></span></div> 当遇到第一个结束标签</span>时 会匹配到栈顶<span>元素对应的ast 并取出来
let element = stack.pop();
// 当前父元素就是栈顶的上一个元素 在这里就类似div
currentParent = stack[stack.length - 1];
// 建立parent和children关系
if (currentParent) {
element.parent = currentParent;
currentParent.children.push(element);
}
}
// 对文本进行处理
function handleChars(text) {
// 去掉空格
text = text.replace(/\s/g, "");
if (text) {
currentParent.children.push({
type: TEXT_TYPE,
text,
});
}
}
// 解析标签生成ast核心
while (html) {
// 查找<
let textEnd = html.indexOf("<");
// 如果<在第一个 那么证明接下来就是一个标签 不管是开始还是结束标签
if (textEnd === 0) {
// 如果开始标签解析有结果
const startTagMatch = parseStartTag();
if (startTagMatch) {
// 把解析好的标签名和属性解析生成ast
handleStartTag(startTagMatch);
continue;
}
// 匹配结束标签</
const endTagMatch = html.match(endTag);
if (endTagMatch) {
advance(endTagMatch[0].length);
handleEndTag(endTagMatch[1]);
continue;
}
}
let text;
// 形如 hello<div></div>
if (textEnd >= 0) {
// 获取文本
text = html.substring(0, textEnd);
}
if (text) {
advance(text.length);
handleChars(text);
}
}
// 匹配开始标签
function parseStartTag() {
const start = html.match(startTagOpen);
if (start) {
const match = {
tagName: start[1],
attrs: [],
};
//匹配到了开始标签 就截取掉
advance(start[0].length);
// 开始匹配属性
// end代表结束符号> 如果不是匹配到了结束标签
// attr 表示匹配的属性
let end, attr;
while (
!(end = html.match(startTagClose)) &&
(attr = html.match(attribute))
) {
advance(attr[0].length);
attr = {
name: attr[1],
value: attr[3] || attr[4] || attr[5], //这里是因为正则捕获支持双引号 单引号 和无引号的属性值
};
match.attrs.push(attr);
}
if (end) {
// 代表一个标签匹配到结束的>了 代表开始标签解析完毕
advance(1);
return match;
}
}
}
//截取html字符串 每次匹配到了就把已经匹配的删除掉,往后吧继续匹配
function advance(n) {
html = html.substring(n);
}
// 返回生成的ast
return root;
}
//比如这个案例经过AST语法树转换后是变成如下的样子
<div id="app">
<h1>hello{{name}}</h1>
</div>
2)把AST语法树变成JS语法形式的字符串
我们要把AST语法树转换成形如_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))的函数式字符串
src/compiler/generate.js
export default function generate(el) {
let children = getChildren(el);
let code = `_c('${el.tag}',${
el.attrs.length ? `${genProps(el.attrs)}` : "undefined"
}${children ? `,${children}` : ""})`;
return code;
}
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; //匹配花括号 {{ }} 捕获花括号里面的内容
function gen(node) {
// 判断节点类型
// 主要包含处理文本核心
// 源码这块包含了复杂的处理 比如 v-once v-for v-if 自定义指令 slot等等 咱们这里只考虑普通文本和变量表达式{{}}的处理
// 如果是元素类型
if (node.type == 1) {
// 递归创建
return generate(node);
} else {
// 如果是文本节点
let text = node.text;
// 不存在花括号变量表达式
if (!defaultTagRE.test(text)) {
return `_v(${JSON.stringify(text)})`;
}
// 正则是全局模式 每次需要重置正则的lastIndex属性 不然会引发匹配bug
let lastIndex = (defaultTagRE.lastIndex = 0);
let tokens = [];
let match, index;
while ((match = defaultTagRE.exec(text))) {
// index代表匹配到的位置
index = match.index;
if (index > lastIndex) {
// 匹配到的{{位置 在tokens里面放入普通文本
tokens.push(JSON.stringify(text.slice(lastIndex, index)));
}
// 放入捕获到的变量内容
tokens.push(`_s(${match[1].trim()})`);
// 匹配指针后移
lastIndex = index + match[0].length;
}
// 如果匹配完了花括号 text里面还有剩余的普通文本 那么继续push
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)));
}
// _v表示创建文本
return `_v(${tokens.join("+")})`;
}
}
// 处理attrs属性
function genProps(attrs) {
let str = "";
for (let i = 0; i < attrs.length; i++) {
let attr = attrs[i];
// 对attrs属性里面的style做特殊处理
if (attr.name === "style") {
let obj = {};
attr.value.split(";").forEach((item) => {
let [key, value] = item.split(":");
obj[key] = value;
});
attr.value = obj;
}
str += `${attr.name}:${JSON.stringify(attr.value)},`;
}
return `{${str.slice(0, -1)}}`;
}
// 生成子节点 调用gen函数进行递归创建
function getChildren(el) {
const children = el.children;
if (children) {
return `${children.map((c) => gen(c)).join(",")}`;
}
}
3)把JS语法形式的字符串变成render渲染函数
src/compiler/index.js
import parse from "./parse.js";
import generate from "./generate.js";
export default function compileToFunctions(template) {
// 1)首先将template生成ast语法树
let ast = parse(template);
// 2)需要把ast转化成类似_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))这样的字符串
// 3)然后把上面的字符串生成一个render函数
let code = generate(ast);
//with语法可以改变作用域
let renderFn = new Function(`with(this){return ${code}}`);
return renderFn;
}
经过上面的一系列转化,这就是我们最终所要的渲染函数了,其中_c代表的create,_v代表的是文本,接下来我们就要实现如何把这个渲染函数渲染成虚拟节点。