js模板引擎3--编译模板
编译模板的时候,我们可以使用Function
构造函数构建出可执行的js代码,但关键点是如何把模板数据和构建的js代码关联起来。
比如前面的模板字符串例子:
<script id="test" type="text/template">
<% for(var i = 0; i < list.length; i++) { %>
<li><%=list[i].title %></li>
<% } %>
</script>
这个例子中,我们关心的是变量list
的值如何确定。
有两种思路可以实现,先介绍第一种,比较复杂的做法。
解析js语句
用到的工具
要确定list
的值,首先要从模板语句中识别出list
。
模板语句中我们直接是使用了js的语法,要识别出js语句中的变量,就要对js语句进行解析。
简单起见,这里使用js-tokens
包(v3.0.2)解析js语句。
js-tokens使用正则表达式解析js语句,全部代码只有20多行,牛!当然也可以使用babel等解析。
把js-tokens
的代码稍微改造一下:
var jsTokens = {
default: /((['"])(?:(?!\2|\\).|\\(?:\r\n|[\s\S]))*(\2)?|`(?:[^`\\$]|\\[\s\S]|\$(?!\{)|\$\{(?:[^{}]|\{[^}]*\}?)*\}?)*(`)?)|(\/\/.*)|(\/\*(?:[^*]|\*(?!\/))*(\*\/)?)|(\/(?!\*)(?:\[(?:(?![\]\\]).|\\.)*\]|(?![\/\]\\]).|\\.)+\/(?:(?!\s*(?:\b|[\u0080-\uFFFF$\\'"~({]|[+\-!](?!=)|\.?\d))|[gmiyu]{1,5}\b(?![\u0080-\uFFFF$\\]|\s*(?:[+\-*%&|^<>!=?({]|\/(?![\/*])))))|(0[xX][\da-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?)|((?!\d)(?:(?!\s)[$\w\u0080-\uFFFF]|\\u[\da-fA-F]{4}|\\u\{[\da-fA-F]+\})+)|(--|\+\+|&&|\|\||=>|\.{3}|(?:[+\-\/%&|^]|\*{1,2}|<{1,2}|>{1,3}|!=?|={1,2})=?|[?~.,:;[\](){}])|(\s+)|(^$|[\s\S])/g,
matchToToken: function(match) {
var token = {type: "invalid", value: match[0]}
if (match[ 1]) token.type = "string" , token.closed = !!(match[3] || match[4])
else if (match[ 5]) token.type = "comment"
else if (match[ 6]) token.type = "comment", token.closed = !!match[7]
else if (match[ 8]) token.type = "regex"
else if (match[ 9]) token.type = "number"
else if (match[10]) token.type = "name"
else if (match[11]) token.type = "punctuator"
else if (match[12]) token.type = "whitespace"
return token
}
};
测试一下上面的代码:
var test = 'for(var i = 0; i < list.length; i++) { console.log(list[i]); }';
var res = test.match(jsTokens.default).map(function(item, index) {
jsTokens.default.lastIndex = 0;
return jsTokens.matchToToken(jsTokens.default.exec(item));
});
console.log(res);
输出结果:
[
{ type: 'name', value: 'for' },
{ type: 'punctuator', value: '(' },
{ type: 'name', value: 'var' },
{ type: 'whitespace', value: ' ' },
{ type: 'name', value: 'i' },
{ type: 'whitespace', value: ' ' },
{ type: 'punctuator', value: '=' },
{ type: 'whitespace', value: ' ' },
{ type: 'number', value: '0' },
{ type: 'punctuator', value: ';' },
{ type: 'whitespace', value: ' ' },
{ type: 'name', value: 'i' },
{ type: 'whitespace', value: ' ' },
{ type: 'punctuator', value: '<' },
{ type: 'whitespace', value: ' ' },
{ type: 'name', value: 'list' },
{ type: 'punctuator', value: '.' },
{ type: 'name', value: 'length' },
{ type: 'punctuator', value: ';' },
{ type: 'whitespace', value: ' ' },
{ type: 'name', value: 'i' },
{ type: 'punctuator', value: '++' },
{ type: 'punctuator', value: ')' },
{ type: 'whitespace', value: ' ' },
{ type: 'punctuator', value: '{' },
{ type: 'whitespace', value: ' ' },
{ type: 'name', value: 'console' },
{ type: 'punctuator', value: '.' },
{ type: 'name', value: 'log' },
{ type: 'punctuator', value: '(' },
{ type: 'name', value: 'list' },
{ type: 'punctuator', value: '[' },
{ type: 'name', value: 'i' },
{ type: 'punctuator', value: ']' },
{ type: 'punctuator', value: ')' },
{ type: 'punctuator', value: ';' },
{ type: 'whitespace', value: ' ' },
{ type: 'punctuator', value: '}' }
]
从上面的结果来看,变量名和关键字的type
属性均是name
还是没法获取到变量名,那么就要用到is-keyword-js
包来区分。
is-keyword-js 是将mdn网站上的js的关键字作为对象的key,使用 hasOwnProperty 判断是否是关键字,具体可以看源码,很简单。
下面是稍加改造的is-keyword-js
包的源码:
var isJsKeywords = {
reservedKeywords: {
'abstract': true,
'await': true,
'boolean': true,
'break': true,
'byte': true,
'case': true,
'catch': true,
'char': true,
'class': true,
'const': true,
'continue': true,
'debugger': true,
'default': true,
'delete': true,
'do': true,
'double': true,
'else': true,
'enum': true,
'export': true,
'extends': true,
'false': true,
'final': true,
'finally': true,
'float': true,
'for': true,
'function': true,
'goto': true,
'if': true,
'implements': true,
'import': true,
'in': true,
'instanceof': true,
'int': true,
'interface': true,
'let': true,
'long': true,
'native': true,
'new': true,
'null': true,
'package': true,
'private': true,
'protected': true,
'public': true,
'return': true,
'short': true,
'static': true,
'super': true,
'switch': true,
'synchronized': true,
'this': true,
'throw': true,
'transient': true,
'true': true,
'try': true,
'typeof': true,
'var': true,
'void': true,
'volatile': true,
'while': true,
'with': true,
'yield': true
},
test: function(str) {
return this.reservedKeywords.hasOwnProperty(str);
}
};
介绍完工具,下面开始完善代码。
解析流程
接着上一篇中模板的解析结果开始。
模板的解析结果有两中类型: string
和express
。
最终模板填充完数据之后本质是一个字符串,对于string
类型无需过多处理,只要拼接起来就行;express
类型本质是完成数据填充,然后将填充完数据的字符串拼接,所以需要一个变量保存拼接字符串的结果:
// 预设变量,保存模板最终的处理结果
var OUT_CODE = '$RES_OUT';
function getScriptTokens(tplTokens) {
var scripts = []; // 保存js代码语句,最终用于构造渲染函数
for(var i = 0; i < tplTokens.length; i++) {
var source = tplTokens[i].value.replace(/\n/g,'');
var code = '';
if(tplTokens[i].type === 'string') {
code = OUT_CODE + ' += "' + source + '"';
} else if(tplTokens[i].type === 'express') {
if(tplTokens[i].script.output) { // 直接输出变量值
code = OUT_CODE + ' += ' + tplTokens[i].script.code;
} else {
// js逻辑语句
code = tplTokens[i].script.code;
}
// 解析js语句 -- 提取变量,确定变量的值
getVariableContext(code);
}
scripts.push({
source: source,
tplToken: tplTokens[i],
code: code
});
}
return scripts;
}
暂停一下,上面代码中用到了常量string
和express
和上一篇中tplToToken
函数中的一样,提取出这两个常量:
var TPL_TOKEN_TYPE_STRING = 'string';
var TPL_TOKEN_TYPE_EXPRESSION = 'express';
接着实现getVariableContext
函数的逻辑。
首先解析js语句,标记出关键字
function getVariableContext(code) {
var jsExpressTokens = code.match(jsTokens.default).map(function(item) {
jsTokens.default.lastIndex = 0;
return jsTokens.matchToToken(jsTokens.default.exec(item));
}).map(function(item) {
if(item.type === 'name' && isJsKeywords.test(item.value)) {
item.type = 'keywords';
}
return item;
});
}
然后提取出变量名,变量名的type
为name
。注意,还要跳过对象的属性名:
function getVariableContext(code) {
var jsExpressTokens = code.match(jsTokens.default).map(function(item) {
jsTokens.default.lastIndex = 0;
return jsTokens.matchToToken(jsTokens.default.exec(item));
}).map(function(item) {
if(item.type === 'name' && isJsKeywords.test(item.value)) {
item.type = 'keywords';
}
return item;
});
var ignore = false; // 跳过对象的属性
var variableTokens = jsExpressTokens.filter(function(item) {
if(item.type === 'name' && !ignore) {
return true;
}
// 如果ignore是true,说明下一个是对象的属性
ignore = item.type === 'punctuator' && item.value === '.';
return false;
});
console.log(variableTokens);
}
用一个例子测试一下:
var tplStr = `
<% for (var i = 0; i < list.length; i ++) { %>
<p>索引 <%= i + 1 %> :<%= list[i] %></p>
<% } %>
<% var a = list.name; console.log(a); %>
`;
var tplTokens = tplToToken(tplStr,reg);
getScriptTokens(tplTokens);
输出结果:
[
{ type: 'name', value: 'i' },
{ type: 'name', value: 'i' },
{ type: 'name', value: 'list' },
{ type: 'name', value: 'i' }
]
[
{ type: 'name', value: '$RES_OUT' },
{ type: 'name', value: 'i' } ]
[
{ type: 'name', value: '$RES_OUT' },
{ type: 'name', value: 'list' },
{ type: 'name', value: 'i' }
]
[]
[
{ type: 'name', value: 'a' },
{ type: 'name', value: 'list' },
{ type: 'name', value: 'console' },
{ type: 'name', value: 'a' }
]
观察上面的测试结果,注意到几个问题:
- 同一个变量会多次提取;
- 提取到的变量包含了预设变量
$RES_OUT
,顶级变量下的console
,模板变量list
,模板内声明的变量i
和a
,这些变量的值要怎么确定;
在变量赋值逻辑中会解决上述两个问题。
确定好变量的值后,需要保存变量名和对应的值,以便在构造渲染函数时使用,所以声明一个变量variableContext
保存。
对于变量名重复问题,利用对象的键的唯一性解决,需要另外声明一个变量variableContextMap
。
对于顶级变量下的变量名,需要通过顶级变量globalThis
赋值,注入到渲染函数中的顶级变量保存在$GLOBAL_DATA
中。
对于注入到模板中的数据保存在$DATA
中,模板变量做为$DATA
的一个属性赋值。
预设变量$RES_OUT
的初始值为空字符''
。
确定提取出变量的值:
var DATA = '$DATA'; // 模板数据变量名
var GLOBAL_DATA = '$GLOBAL_DATA'; // 全局变量名
var globalThis = typeof self !== 'undefined' ? self
: typeof window !== 'undefined' ? window
: typeof global !== 'undefined' ? global : {};
var variableContext = [];
var variableContextMap = {};
function getVariableContext(code) {
var jsExpressTokens = code.match(jsTokens.default).map(function(item) {
jsTokens.default.lastIndex = 0;
return jsTokens.matchToToken(jsTokens.default.exec(item));
}).map(function(item) {
if(item.type === 'name' && isJsKeywords.test(item.value)) {
item.type = 'keywords';
}
return item;
});
var ignore = false; // 跳过对象的属性
var variableTokens = jsExpressTokens.filter(function(item) {
if(item.type === 'name' && !ignore) {
return true;
}
// 如果ignore是true,说明下一个是对象的属性
ignore = item.type === 'punctuator' && item.value === '.';
return false;
});
variableTokens.forEach(function(item) {
var val;
if(!Object.prototype.hasOwnProperty.call(variableContextMap, item.value)) {
if(item.value === OUT_CODE) { // 预设变量
val = '""';
} else if(Object.prototype.hasOwnProperty.call(globalThis, item.value)) {
val = GLOBAL_DATA + '.' + item.value;
} else { // 模板变量
// 这里有个问题:模板内声明的变量也会被作为$DATA的一个属性,先记下这个问题。
val = DATA + '.' + item.value;
}
variableContextMap[item.value] = val;
variableContext.push({
name: item.value,
value: val
});
}
});
}
完整的代码附在最后,下面测试一下:
var tplStr = `
<% if (isAdmin) { %>
<!--fhj-->
<h1><%=title%></h1>
<ul>
<% for (var i = 0; i < list.length; i ++) { %>
<li>索引 <%= i + 1 %> :<%= list[i] %></li>
<% } %>
</ul>
<% var a = list.name; console.log(a); %>
<% } %>
`;
var tplTokens = tplToToken(tplStr,reg);
var scripts = getScriptTokens(tplTokens);
console.log(variableContext);
console.log(scripts);
提取的变量及其值:
[
{ name: 'isAdmin', value: '$DATA.isAdmin' },
{ name: '$RES_OUT', value: '' },
{ name: 'title', value: '$DATA.title' },
{ name: 'i', value: '$DATA.i' },
{ name: 'list', value: '$DATA.list' },
{ name: 'a', value: '$DATA.a' },
{ name: 'console', value: '$GLOBAL_DATA.console' }
]
js代码片段列表:
[
{
source: '\n',
tplToken: Token {
type: 'string',
value: '\n',
script: null,
start: 0,
line: 0,
end: 1
},
code: '$RES_OUT += "\n"'
},
{
source: '<% if (isAdmin) { %>',
tplToken: Token {
type: 'expression',
value: '<% if (isAdmin) { %>',
script: [Object],
line: 1,
start: 1,
end: 21
},
code: 'if (isAdmin) {'
},
{
source: '\n <!--fhj-->\n <h1>',
tplToken: Token {
type: 'string',
value: '\n <!--fhj-->\n <h1>',
script: null,
line: 1,
start: 21,
end: 45
},
code: '$RES_OUT += "\n <!--fhj-->\n <h1>"'
},
{
source: '<%=title%>',
tplToken: Token {
type: 'expression',
value: '<%=title%>',
script: [Object],
line: 3,
start: 45,
end: 55
},
code: '$RES_OUT += title'
},
{
source: '</h1>\n <ul>\n ',
tplToken: Token {
type: 'string',
value: '</h1>\n <ul>\n ',
script: null,
line: 3,
start: 55,
end: 78
},
code: '$RES_OUT += "</h1>\n <ul>\n "'
},
{
source: '<% for (var i = 0; i < list.length; i ++) { %>',
tplToken: Token {
type: 'expression',
value: '<% for (var i = 0; i < list.length; i ++) { %>',
script: [Object],
line: 5,
start: 78,
end: 124
},
code: 'for (var i = 0; i < list.length; i ++) {'
},
{
source: '\n <li>索引 ',
tplToken: Token {
type: 'string',
value: '\n <li>索引 ',
script: null,
line: 5,
start: 124,
end: 144
},
code: '$RES_OUT += "\n <li>索引 "'
},
{
source: '<%= i + 1 %>',
tplToken: Token {
type: 'expression',
value: '<%= i + 1 %>',
script: [Object],
line: 6,
start: 144,
end: 156
},
code: '$RES_OUT += i + 1'
},
{
source: ' :',
tplToken: Token {
type: 'string',
value: ' :',
script: null,
line: 6,
start: 156,
end: 158
},
code: '$RES_OUT += " :"'
},
{
source: '<%= list[i] %>',
tplToken: Token {
type: 'expression',
value: '<%= list[i] %>',
script: [Object],
line: 6,
start: 158,
end: 172
},
code: '$RES_OUT += list[i]'
},
{
source: '</li>\n ',
tplToken: Token {
type: 'string',
value: '</li>\n ',
script: null,
line: 6,
start: 172,
end: 186
},
code: '$RES_OUT += "</li>\n "'
},
{
source: '<% } %>',
tplToken: Token {
type: 'expression',
value: '<% } %>',
script: [Object],
line: 7,
start: 186,
end: 193
},
code: '}'
},
{
source: '\n </ul>\n ',
tplToken: Token {
type: 'string',
value: '\n </ul>\n ',
script: null,
line: 7,
start: 193,
end: 208
},
code: '$RES_OUT += "\n </ul>\n "'
},
{
source: '<% var a = list.name; console.log(a); %>',
tplToken: Token {
type: 'expression',
value: '<% var a = list.name; console.log(a); %>',
script: [Object],
line: 9,
start: 208,
end: 248
},
code: 'var a = list.name; console.log(a);'
},
{
source: '\n',
tplToken: Token {
type: 'string',
value: '\n',
script: null,
line: 9,
start: 248,
end: 249
},
code: '$RES_OUT += "\n"'
},
{
source: '<% } %>',
tplToken: Token {
type: 'expression',
value: '<% } %>',
script: [Object],
line: 10,
start: 249,
end: 256
},
code: '}'
}
]
下一篇将根据上述结果构造渲染函数。
附本篇完整代码:
var TPL_TOKEN_TYPE_STRING = 'string';
var TPL_TOKEN_TYPE_EXPRESSION = 'expression';
// 预置变量
var OUT_CODE = '$RES_OUT';
var DATA = '$DATA'; // 模板数据变量名
var GLOBAL_DATA = '$GLOBAL_DATA'; // 全局变量名
const globalThis = typeof self !== 'undefined' ? self
: typeof window !== 'undefined' ? window
: typeof global !== 'undefined' ? global : {};
var jsTokens = {
default: /((['"])(?:(?!\2|\\).|\\(?:\r\n|[\s\S]))*(\2)?|`(?:[^`\\$]|\\[\s\S]|\$(?!\{)|\$\{(?:[^{}]|\{[^}]*\}?)*\}?)*(`)?)|(\/\/.*)|(\/\*(?:[^*]|\*(?!\/))*(\*\/)?)|(\/(?!\*)(?:\[(?:(?![\]\\]).|\\.)*\]|(?![\/\]\\]).|\\.)+\/(?:(?!\s*(?:\b|[\u0080-\uFFFF$\\'"~({]|[+\-!](?!=)|\.?\d))|[gmiyu]{1,5}\b(?![\u0080-\uFFFF$\\]|\s*(?:[+\-*%&|^<>!=?({]|\/(?![\/*])))))|(0[xX][\da-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?)|((?!\d)(?:(?!\s)[$\w\u0080-\uFFFF]|\\u[\da-fA-F]{4}|\\u\{[\da-fA-F]+\})+)|(--|\+\+|&&|\|\||=>|\.{3}|(?:[+\-\/%&|^]|\*{1,2}|<{1,2}|>{1,3}|!=?|={1,2})=?|[?~.,:;[\](){}])|(\s+)|(^$|[\s\S])/g,
matchToToken: function(match) {
var token = {type: "invalid", value: match[0]}
if (match[ 1]) token.type = "string" , token.closed = !!(match[3] || match[4])
else if (match[ 5]) token.type = "comment"
else if (match[ 6]) token.type = "comment", token.closed = !!match[7]
else if (match[ 8]) token.type = "regex"
else if (match[ 9]) token.type = "number"
else if (match[10]) token.type = "name"
else if (match[11]) token.type = "punctuator"
else if (match[12]) token.type = "whitespace"
return token
}
};
var isJsKeywords = {
reservedKeywords: {
'abstract': true,
'await': true,
'boolean': true,
'break': true,
'byte': true,
'case': true,
'catch': true,
'char': true,
'class': true,
'const': true,
'continue': true,
'debugger': true,
'default': true,
'delete': true,
'do': true,
'double': true,
'else': true,
'enum': true,
'export': true,
'extends': true,
'false': true,
'final': true,
'finally': true,
'float': true,
'for': true,
'function': true,
'goto': true,
'if': true,
'implements': true,
'import': true,
'in': true,
'instanceof': true,
'int': true,
'interface': true,
'let': true,
'long': true,
'native': true,
'new': true,
'null': true,
'package': true,
'private': true,
'protected': true,
'public': true,
'return': true,
'short': true,
'static': true,
'super': true,
'switch': true,
'synchronized': true,
'this': true,
'throw': true,
'transient': true,
'true': true,
'try': true,
'typeof': true,
'var': true,
'void': true,
'volatile': true,
'while': true,
'with': true,
'yield': true
},
test: function(str) {
return this.reservedKeywords.hasOwnProperty(str);
}
};
var reg = {
test: /<%(#?)(=)?[ \t]*([\w\W]*?)[ \t]*%>/g,
// 对识别出的模板语法语句做进一步处理变量
use: function (match, comment, output, code) {
if(output === '=') { // 变量 / 表达式
output = true;
} else {
output = false;
}
if(comment) { // 注释
code = '/*' + code + '*/';
output = false;
}
return {
code: code,
output: output
}
}
};
var variableContext = [];
var variableContextMap = {}; // 确保上下文中变量名的唯一性
function Token(type, value, preToken) {
this.type = type;
this.value = value;
this.script = null;
if(preToken) {
this.line = preToken.value.split('\n').length - 1 + preToken.line;
this.start = preToken.end;
} else {
this.start = 0;
this.line = 0;
}
this.end = this.start + value.length; // 包含起点start,不包含终点end
}
function tplToToken(str,reg) {
var tokens = [],
match,
preToken,
index = 0;
while((match = reg.test.exec(str)) !== null) {
preToken = tokens[tokens.length - 1];
// 如果匹配结果的开始下标比上一次匹配结果的结束下标大,说明两个模板语法之间有字符串
if(match.index > index) {
preToken = new Token(TPL_TOKEN_TYPE_STRING, str.slice(index,match.index), preToken);
tokens.push(preToken);
}
preToken = new Token(TPL_TOKEN_TYPE_EXPRESSION, match[0], preToken);
preToken.script = reg.use.apply(reg,match);
tokens.push(preToken);
// 保存本次匹配结果的结束下标
index = match.index + match[0].length;
}
return tokens;
}
// 从js表达式中提取出变量,用来构建执行上下文
function getVariableContext(code) {
var jsExpressTokens = code.match(jsTokens.default).map(function(item) {
jsTokens.default.lastIndex = 0;
return jsTokens.matchToToken(jsTokens.default.exec(item));
}).map(function(item) {
if(item.type === 'name' && isJsKeywords.test(item.value)) {
item.type = 'keywords';
}
return item;
});
var ignore = false; // 跳过变量的属性
var variableTokens = jsExpressTokens.filter(function(item) {
if(item.type === 'name' && !ignore) {
return true;
}
ignore = item.type === 'punctuator' && item.value === '.';
return false;
});
// console.log(variableTokens);
// return;
variableTokens.forEach(function(item) {
var val;
if(!Object.prototype.hasOwnProperty.call(variableContextMap, item.value)) {
if(item.value === OUT_CODE) {
val = '';
} else if(Object.prototype.hasOwnProperty.call(globalThis, item.value)) {
val = GLOBAL_DATA + '.' + item.value;
} else {
val = DATA + '.' + item.value;
}
variableContextMap[item.value] = val;
variableContext.push({
name: item.value,
value: val
});
}
});
}
function getScriptTokens(tplTokens) {
var scripts = [];
for(var i = 0; i < tplTokens.length; i++) {
// 去掉换行符,js中引号包裹的字符串不能换行
var source = tplTokens[i].value.replace(/\n/g,'');
var code = '';
if(tplTokens[i].type === TPL_TOKEN_TYPE_STRING) {
code = OUT_CODE + ' += "' + source + '"';
} else if(tplTokens[i].type === TPL_TOKEN_TYPE_EXPRESSION) {
if(tplTokens[i].script.output) {
code = OUT_CODE + ' += ' + tplTokens[i].script.code;
} else {
// js表达式
code = tplTokens[i].script.code;
}
getVariableContext(code);
}
scripts.push({
source: source,
tplToken: tplTokens[i],
code: code
});
}
return scripts;
}