1. 前言
近期工作中需要用到模板渲染,没想到实现方案,也对此有了解兴趣。本文是 lodash template 方法的简化版本,在此做一个学习记录。
2. 功能
- 支持渲染值:
<%= 变量名 %> - 支持渲染转义后的html:
<%- 变量名 %> - 支持运行js代码:
<% 代码块 %>
3. 思路
-
lodashtemplate执行结果观察const content = '<code> <%- code %> </code>'; const render = template(content); console.log(render({code: '<script>alert("hello")</script>'})); console.log(render);输出
<code> <script>alert("hello")</script> </code> [Function] { source: 'function(obj) {\n' + 'obj || (obj = {});\n' + "var __t, __p = '', __e = _.escape;\n" + 'with (obj) {\n' + "__p += '<code> ' +\n" + '__e( code ) +\n' + "' </code>';\n" + '\n' + '}\n' + 'return __p\n' + '}' -
如何实现字符串渲染、代码执行?
将字符串转为可执行的函数。渲染一次,即是执行一次render函数并得到返回值。
4. 实现
4.1 字符串匹配的正则表达式
function template(string) {
const options = {
// 原值
interpolate: /<%=([\s\S]+?)%>/g,
// 转义值
escape: /<%-([\s\S]+?)%>/g,
// 执行代码
evaluate: /<%([\s\S]+?)%>/g,
};
// 使用此正则来匹配字符串,evaluate放在后面,末尾的$是为了匹配到最后
const reDelimiters = RegExp(
options.escape.source + '|' +
options.interpolate.source + '|' +
options.evaluate.source + '|$'
, 'g');
// return render function
}
4.2 原值渲染
4.2.1 简单版
function template(string) {
// 之前的省略
// render function content
const startPart = 'obj || (obj = {});\n' + "let __p='';\n";
const endPart = 'return __p;';
// 主体代码 需要组装
let bodyPart = "__p +='";
let index = 0;
string.replace(reDelimiters, function(match, escVal, inteVal, evaVal, offset) {
console.log(`match = ${match}, index=${index}, offset=${offset}`);
// 未匹配上的就是普通文本,直接拼接
const textStr = string.slice(index, offset);
bodyPart += textStr;
if(inteVal) {
bodyPart += "' +\n" + inteVal + "+\n'";
}
index = offset + match.length;
});
// 字符串拼接结尾
bodyPart += "';\n";
// 代码放到with中执行
bodyPart = 'with(obj) {\n' + bodyPart + '}\n';
const code = startPart + bodyPart + endPart;
return new Function('obj', code);
}
使用
const content = 'hello <%= user%>, welcome to <%= city %> !';
const render = template(content);
console.log(render.toString());
console.log(render({user: 'elly', city: 'chongqing'}));
输出
match = <%= user%>, index=0, offset=6
match = <%= city %>, index=16, offset=29
match = , index=40, offset=42
function anonymous(obj
) {
obj || (obj = {});
let __p='';
with(obj) {
__p +='hello ' +
user +
', welcome to ' +
city +
'';
}
return __p;
}
hello elly, welcome to chongqing !
4.2.2 优化版
-
null、undefined值处理console.log(render({user: null, city: 'chongqing'})); // 输出 // hello null, welcome to chongqing !如果值为
null或者undefined,期望渲染空字符串if(inteVal) { const calculateStr = '((' + inteVal + ' === null | '+ inteVal + ' === undefined) ? "" : '+ inteVal + ')'; bodyPart += "' +\n" + calculateStr + " +\n'"; }输出
function anonymous(obj ) { obj || (obj = {}); let __p=''; with(obj) { __p +='hello ' + (( user === null | user === undefined) ? "" : user) + ', welcome to ' + (( city === null | city === undefined) ? "" : city ) + ' !'; } return __p; } hello , welcome to chongqing !为什么lodash中没看到判断处理
undefined,但和null一样会输出空字符?
因为lodash中用的是宽松相等(两个等号),此时undefined == null为truewith (obj) { __p += 'hello ' + ((__t = ( user)) == null ? '' : __t) + ', welcome to ' + ((__t = ( city )) == null ? '' : __t) + ' !'; } -
特殊字符转义
const content = 'hello <%= user%>, welcome to <%= city %> ! \n --from amy'; const render = template(content); console.log(render.toString()); console.log(render({user: 'elly', city: 'chongqing'}));输出
match = <%= user%>, index=0, offset=6 match = <%= city %>, index=16, offset=29 match = , index=40, offset=55 undefined:10 ' ! ^^^^ SyntaxError: Invalid or unexpected token at new Function (<anonymous>)打印
bodyPart错误原因:原字符串
\n的换行在拼接函数体的时候就生效了,但实际是期望在拼接字符串的时候生效。
解决:我们需要对这类字符串进行转义/** * 转义 模板字符串中的特殊字符 * @param {string} chr The matched character to escape. * @returns {string} Returns the escaped character. */ function escapeStringChar(chr) { const stringEscapes = { '\\': '\\', "'": "'", '\n': 'n', '\r': 'r', '\u2028': 'u2028', '\u2029': 'u2029' }; return '\\' + stringEscapes[chr]; } function template(string) { // 不变的内容省略 string.replace(reDelimiters, function(match, escVal, inteVal, evaVal, offset) { // 未匹配上的就是普通文本,直接拼接 let textStr = string.slice(index, offset); // 新增 textStr = textStr.replace(/['\n\r\u2028\u2029\\]/g, escapeStringChar); bodyPart += textStr; } }输出,此时的
\n就是生效于字符串function anonymous(obj ) { obj || (obj = {}); let __p=''; with(obj) { __p +='hello ' + (( user === null) ? "" : user) + ', welcome to ' + (( city === null) ? "" : city ) + ' ! \n --from amy'; } return __p; } hello elly, welcome to chongqing ! --from amy
4.3 html转义渲染
function escapeHtml(string) {
const htmlEscapes = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return string.replace(/[&<>"']/g, function(char) {
return htmlEscapes[char];
});
}
function template(string) {
string.replace(reDelimiters, function(match, escVal, inteVal, evaVal, offset) {
// 不变的省略
if(escVal) {
bodyPart += "'+\nescapeHtml(" + escVal + ") +\n'";
}
});
}
测试
const content = '<code> <%- code %> </code>';
const render = template(content);
console.log(render.toString());
console.log(render({code: '<script>alert("hello")</script>'}));
输出
function anonymous(obj
) {
obj || (obj = {});
let __p='';
with(obj) {
__p +='<code> '+
escapeHtml( code ) +
' </code>';
}
return __p;
}
undefined:4
__p +='<code> '+
^
ReferenceError: escapeHtml is not defined
模块中声明的escapeHtml和render中要使用的不在一个作用域中(Function 创建的只能访问全局作用域),不能使用。如何定义才能在render中使用escapeHtml?
用闭包,函数中声明escapeHtml,返回值是render。
function () {
// 只要能访问就行,实际源码中不用包含escapeHtml定义
function escapeHtml(){}
return render function
}
修改
// 1. 增加function包裹
const startPart = 'function(obj){\n' +
'obj || (obj = {});\n' + "let __p='';\n";
const endPart = 'return __p;\n}';
// 2. 将直接返回function 改为闭包函数
const code = startPart + bodyPart + endPart;
const parentFunc = Function('escapeHtml', 'return ' + code);
console.log(parentFunc.toString());
return parentFunc.apply(undefined, [ escapeHtml ]);
再次执行
// parentFunc
function anonymous(escapeHtml
) {
return function(obj){
obj || (obj = {});
let __p='';
with(obj) {
__p +='<code> '+
escapeHtml( code ) +
' </code>';
}
return __p;
}
// 模板渲染结果
<code> <script>alert("hello")</script> </code>
4.4 执行JS代码
function template(string) {
string.replace(reDelimiters, function(match, escVal, inteVal, evaVal, offset) {
// 不变的省略
if(evaVal) {
bodyPart += "';\n" + evaVal + "\n__p+='";
}
});
}
测试
const content = '<ul> <% for(let i = 0; i < data.length; i++) { %> <li> <%= data[i] %> </li> <% } %> </ul>';
const render = template(content);
console.log(render.toString());
console.log(render({data: ['苹果', '香蕉', '橘子']}));
输出
function anonymous(obj
) {
obj || (obj = {});
let __p='';
with(obj) {
__p +='<ul> ';
for(let i = 0; i < data.length; i++) {
__p+=' <li> ' +
(( data[i] === null) ? "" : data[i] ) +
' </li> ';
}
__p+=' </ul>';
}
return __p;
}
<ul> <li> 苹果 </li> <li> 香蕉 </li> <li> 橘子 </li> </ul>