本文讨论 AST 转换为 渲染字符串并最终调整为 render 渲染函数
的具体过程,这样的讨论有益于我们加深对常见的模板引擎
其工作机制以及Vue
等前端框架的理解 。
在上图中简单画出了这篇文章的代码要完成的主要工作,即把 html-parser 中模板编译得到的 AST 抽象语法树处理为 render字符串
继而包装为render渲染函数
。
假设我们要编译的模板字符串
为:
<div id="app" title="标题"><p>hello</p><span>{{msg}}</span></div>
那么编译为 AST 语法树
后大概应该长成下面这样。
{ tag: 'div',
attrs:
[ { name: 'id', value: 'app' }, { name: 'title', value: '标题' } ],
children:
[ { tag: 'p',
attrs: [],
children: [Array],
parent: [Circular],
nodeType: 1 },
{ tag: 'span',
attrs: [],
children: [Array],
parent: [Circular],
nodeType: 1 } ],
parent: null,
nodeType: 1
}
我们需要通过代码来得到的renderString
应该是下面这样的字符串结构(忽略换行)。
'_createElement("div",
{id:"app",title:"标题"},
_createElement("p",null,_v("hello")),
_createElement("span",null,_v(_s(msg))))'
在得到的整个字符串中,主要包含的要素有:_createElement
这是创建函数的名称,div
和p
等这是对应标签的名称,{id:"app",title:"标题"}
这部分是对应标签的属性节点,如果当前标签存在子标签,那么应该以递归的方式来进行处理。因为整个过程比较复杂,所有下面分成 属性节点处理 和 标签(子)节点处理两个部分。
属性节点的处理要求把attrs:[ { name: 'id', value: 'app' }, { name: 'title', value: '标题' } ]
这样的对象结构转换为{id:"app",title:"标题"}
字符串,难度不大。
function generateAttrs(attrs) {
/* 1.初始化空字符 */
let str = '';
/* 2.遍历属性节点数组,并按既定格式拼接 */
attrs.forEach((attr, idx) => {
str += `${attr.name}:${JSON.stringify(attr.value)},`
}); /* 循环后:str === id:"app",title:"标题", */
/* 3.拼接上外层的{},并去掉{}中最后一个逗号(,)*/
str = `{ ${str.slice(0, -1)} }`;
return str;
}
let attrs = [{ name: 'id', value: 'app' }, { name: 'title', value: '标题' }];
let attrsString = generateAttrs(attrs);
console.log(attrsString); /* { id:"app",title:"标题" } */
在上面代码中封装了的generateAttrs
函数,虽然能够解决标签中简单属性节点但还需要注意一种特殊的属性节点,那就是style
,我们在给标签设置行内样式的时候,是可以给 style
设置多个样式的,比如宽度和高度。
console.log( generateAttrs([name:"style",value:"color:red;background:#000"]));
/*执行上面的代码,得到打印结果为*/
'{ style:"color:red;background:#000" }'
/* 我想要的结果 */
'{ style:{"color":"red","background":"#000"} }`
调整generateAttrs
函数的实现。
function generateAttrs(attrs) {
/* 1.初始化空字符 */
let str = '';
/* 2.遍历属性节点数组,并按既定格式拼接 */
attrs.forEach((attr, idx) => {
/* 2.1 如果属性节点名称为 style那么则对 value进行中间处理 */
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)},`
}); /* 循环后:str === id:"app",title:"标题", */
/* 3.拼接上外层的{},并去掉{}中最后一个逗号(,)*/
str = `{ ${str.slice(0, -1)} }`;
return str;
}
标签(子)节点的处理因为涉及到标签嵌套(标签可能存在多个子标签)所以会稍显复杂。
这里我们暂且不考虑标签的属性节点,假设我们有模板字符串为<p>hello</p>
,它转换之后的结果应该为_createElement("p",null,_v("hello"))
,这里_createElement
为固定的函数名字,第一个参数p
表示标签的类型(名称),第二个参数用来放置属性节点( 如果没有属性节点那么显示为 null ),第三个参数_v("hello")
表示 p标签的文本内容hello
,此处如果标签中的内容为类似{{msg}}
的插值语法,那么还需要处理为_createElement("span",null,_v(_s(msg))))
结构,做额外的处理。
那么怎么转换呢?
function generateText(node) {
let text = JSON.stringify(node.text);
return `_v(${text})`;
}
console.log(generateText({ text: "hello" }));
console.log(generateText({ text: "My name is {{name}}" }));
/* 上述代码的执行结果 */
/* _v("hello") */
/* _v("My name is {{name}}") */
在上面的代码中,我封装了一个专门用来处理标签内容(字符串)的函数generateText
,内部的逻辑非常简单只是字符串的无脑拼接而已。但是_v("My name is {{name}}")
只能算是半成品,因为我们在真正渲染的时候,插值语法{{xx}}
中的变量是需要用真正的实例数据来进行替换的,因此我们需要进一步处理为_v("My name is "+_s(name))
这样的结构。那要怎么做呢?
要处理这个问题无疑是个挑战,因为当我们面对"My name is {{name}} "
这样内容的时候,首先应该先把普通字符串和插值语法的部分区分开来,然后对插值语法的部分单独处理成_s(name)
结构,最后再拼接。
无疑,字符串插值语法部分的匹配需要用到正则表达式,下面试着给出对应的代码。
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
function generateText(node) {
let tokens = [];
let match, index;
/* 获取文本内容 */
let text = node.text;
/*如果是全局匹配 那么每次匹配的时候都需要将 lastIndex 调整到0*/
let lastIndex = defaultTagRE.lastIndex = 0;
/* 正则匹配(匹配插值语法部分的内容) */
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)))
}
console.log("tokens", tokens); /* tokens [ '"My name is "', '_s(name)' ] */
return `_v(${tokens.join('+')})`;
}
console.log(generateText({ text: "hello" }));
console.log(generateText({ text: "My name is {{name}} biubiubiu @" }));
/* 打印结果 */
/* _v("hello") */
/* _v("My name is "+_s(name)+" biubiubiu @")*/
此外,我们还需要考虑到标签的嵌套,这个问题我们可以通过函数的递归调用来实现。
最后一步,我们还需要完成RenderString->RenderFunction
,即把拼接好的字符串转换为函数,这个过程需要用到两个小技巧。我们可以通过 new Function
来创建函数并将字符串转换为函数体内容,此外插值语法(如 {{name}}
)中的name
变量应该通过作用域绑定的方式来进行处理,因此这里还用到了with特性
。
下面给出整个过程的完整代码。
/* 形如:abc-123 */
const nc_name = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
/* 形如:<aaa:bbb> */
const q_nameCapture = `((?:${nc_name}\\:)?${nc_name})`;
/* 形如:<div 匹配开始标签的左半部分 */
const startTagOpen = new RegExp(`^<${q_nameCapture}`);
/* 匹配开始标签的右半部分(>) 形如`>`或者` >`前面允许存在 N(N>=0)个空格 */
const startTagClose = /^\s*(\/?)>/;
/* 匹配闭合标签:形如 </div> */
const endTag = new RegExp(`^<\\/${q_nameCapture}[^>]*>`);
/* 匹配属性节点:形如 id="app" 或者 id='app' 或者 id=app 等形式的字符串 */
const att =/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<div>`]+)))?/
/* 匹配插值语法:形如 {{msg}} */
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
/* 标记节点类型(文本节点) */
let NODE_TYPE_TEXT = 3;
/* 标记节点类型(元素节点) */
let NODE_TYPE_ELEMENT = 1;
let stack = []; /* 数组模拟栈结构 */
let root = null;
let currentParent;
function compiler(html) {
/* 推进函数:每处理完一部分模板就向前推进删除一段 */
function advance(n) {
html = html.substring(n);
}
/* 解析开始标签部分:主要提取标签名和属性节点 */
function parser_start_html() {
/* 00-正则匹配 <div id="app" title="标题">模板结构*/
let start = html.match(startTagOpen);
if (start) {
/* 01-提取标签名称 形如 div */
const tagInfo = {
tag: start[1],
attrs: []
};
/* 删除<div部分 */
advance(start[0].length);
/* 02-提取属性节点部分 形如:id="app" title="标题"*/
let attr, end;
while (!(end = html.match(startTagClose)) && (attr = html.match(att))) {
tagInfo.attrs.push({
name: attr[1],
value: attr[3] || attr[4] || attr[5]
});
advance(attr[0].length);
}
/* 03-处理开始标签 形如 >*/
if (end) {
advance(end[0].length);
return tagInfo;
}
}
}
while (html) {
let textTag = html.indexOf('<');
/* 如果以<开头 */
if (textTag == 0) {
/* (1) 可能是开始标签 形如:<div id="app"> */
let startTagMatch = parser_start_html();
if (startTagMatch) {
start(startTagMatch.tag, startTagMatch.attrs);
continue;
}
/* (2) 可能是结束标签 形如:</div>*/
let endTagMatch = html.match(endTag);
if (endTagMatch) {
advance(endTagMatch[0].length);
end(endTagMatch[1]);
continue;
}
}
/* 文本内容的处理 */
let text;
if (textTag >= 0) {
text = html.substring(0, textTag);
}
if (text) {
advance(text.length);
chars(text);
}
}
return root;
}
/* 文本处理函数:<span> hello <span> => text的值为 " hello "*/
function chars(text) {
/* 1.先处理文本字符串中所有的空格,全部替换为空 */
text = text.replace(/\s/g, '');
/* 2.把数据组织成{text:"hello",type:3}的形式保存为当前父节点的子元素 */
if (text) {
currentParent.children.push({
text,
nodeType: NODE_TYPE_TEXT
})
}
}
function start(tag, attrs) {
let element = createASTElement(tag, attrs);
if (!root) {
root = element;
}
currentParent = element;
stack.push(element);
}
function end(tagName) {
let element = stack.pop();
currentParent = stack[stack.length - 1];
if (currentParent) {
element.parent = currentParent;
currentParent.children.push(element);
}
}
function createASTElement(tag, attrs) {
return {
tag,
attrs,
children: [],
parent: null,
nodeType: NODE_TYPE_ELEMENT
}
}
/* ****************** */
function generateAttrs(attrs) {
/* 1.初始化空字符 */
let str = '';
/* 2.遍历属性节点数组,并按既定格式拼接 */
attrs.forEach((attr, idx) => {
/* 2.1 如果属性节点名称为 style那么则对 value进行中间处理 */
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)},`
}); /* 循环后:str === id:"app",title:"标题", */
/* 3.拼接上外层的{},并去掉{}中最后一个逗号(,)*/
str = `{ ${str.slice(0, -1)} }`;
return str;
}
function generateChildren(el) {
let children = el.children;
return (children && children.length > 0)
? `${children.map(c => generate(c)).join(',')}`
: false;
}
function generate(node) {
/* 如果是子标签那么就递归调用 */
return node.nodeType == 1 ? generateRenderString(node) : generateText(node);
}
function generateText(node) {
let tokens = [];
let match, index;
/* 获取文本内容 */
let text = node.text;
/*如果是全局匹配 那么每次匹配的时候都需要将 lastIndex 调整到0*/
let lastIndex = defaultTagRE.lastIndex = 0;
/* 正则匹配(匹配插值语法部分的内容) */
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('+')})`;
}
/* 核心函数:完成每个部分字符串(标签名 && 属性节点 && 子节点)的拼接 */
function generateRenderString(el) {
let children = generateChildren(el);
return `_createElement("${el.tag}",${el.attrs.length ? generateAttrs(el.attrs) : 'null'}${ children ? `,${children}` : ''})`;
}
function compilerToFunction(template) {
/* Html->AST */
let root = compiler(template);
/* AST->RenderString */
let renderString = generateRenderString(root);
/* RenderString->RenderFunction */
let renderFn = new Function(`with(this){ return ${renderString}}`);
console.log("renderString", renderString,'renderFn', renderFn);
}
// const template = `<div><span class="span-class">Hi 夏!</span></div>`;
// const template = `<div id="app" title="标题"><p>hello</p><span>vito</span></div>`
const template = `<a id="app" title="标题"><p>hello</p><span>My name is {{name}} dududu!!!</span></a>`;
compilerToFunction(template);
最后,给出上述代码的测试结果。
renderString
_createElement("a",
{ id:"app",title:"标题" },
_createElement("p",null,_v("hello")),
_createElement("span",null,_v("My name is"+_s(name)+"dududu!!!")))
renderFn
function anonymous() {
with(this){
return _createElement("a",
{ id:"app",title:"标题" },
_createElement("p",null,_v("hello")),
_createElement("span",null,_v("My name is"+_s(name)+"dududu!!!")))
}
}