背景
之前公司有些项目前端模板用的是 primer-template,这是一个语法和 EJS 类似的轻量级的 JS 模板。因为是轻量级的模板,所以有一些不足的地方:
- 不支持全局变量(如
window
) - 不支持嵌套函数
- 不支持 HTML Encode
前两个不足是因为这个模板使用的 JS 编译器是 homunculus,homunculus 比较小众且文档较少;最后一个不支持 HTML Encode 会有 XSS 的风险。综合考虑了下决定还是基于 Babel 自己重新来撸一个吧。
语法规则
<%=
: Escaped output (转义输出)<%-
: Unescaped output (非转义输出)<%
: Scriptlet (JS 脚本)include()
: Including other files (模板引入)%>
: Ending tab (结束标签)
预解析
首先进行预解析,将模板转换为 JS 字符串拼接,这里参考 primer-template 只需要改几个地方,修改后代码如下:
import fs from 'fs';
import path from 'path';
function unescape (code) {
return code.replace(/\\('|\\)/g, '$1').replace(/[\r\t\n]/g, ' ');
}
function format (str, filePath) {
return str
.replace(/(^|\r|\n)\t* +| +\t*(\r|\n|$)/g, ' ')
.replace(/\r|\n|\t|\/\*[\s\S]*?\*\//g, '')
.replace(/<%(.+?)%>/g, (m, p) => {
const code = p.trim();
const first = code.slice(0, 1);
if (first === '-') {
// 处理非转义输出
return `';out+=(${unescape(code.slice(1))});out+='`;
} else if (first === '=') {
// 处理转义输出
return `';out+=ENCODE_FUNCTION(${unescape(code.slice(1))});out+='`;
} else {
const match = code.match(/^include\((.+)?\)$/);
// 处理模板引入
if (match) {
if (!match[1]) {
throw new Error('Include path is empty');
}
const base = path.dirname(filePath);
const tplPath = unescape(match[1]).replace(/['"]/gim, '');
const targetPath = path.resolve(base, tplPath);
if (fs.statSync(targetPath).isFile()) {
const content = fs.readFileSync(targetPath, 'utf-8');
return format(content, targetPath);
} else {
throw new Error('Include path is not file');
}
} else {
return `';${unescape(code)}\n out+='`;
}
}
});
}
export default function preParse (source, filePath) {
const result = `var out='${format(source, filePath)}';return out;`;
return { source, result };
}
首先来测试下预处理:
const data = preParse(`
<p><%=name%></p>
<p><%=email%></p>
<ul>
<%for (var i=0; i<skills.length; i++) {var skill = skills[i];%>
<li><%-skill%></li>
<%}%>
</ul>
<div>
<%projects.forEach((project) => {%>
<div>
<h3><%-project.name%></h3>
<p><%=project.description%></p>
</div>
<%});%>
</div>
`);
console.log(data.result);
输出结果为:
var out = '<p>';
out += ENCODE_FUNCTION(name);
out += '</p><p>';
out += ENCODE_FUNCTION(email);
out += '</p><ul> ';
for (var i = 0; i < skills.length; i++) {
var skill = skills[i];
out += ' <li>';
out += (skill);
out += '</li> ';
}
out += '</ul><div> ';
projects.forEach((project) => {
out += ' <div> <h3>';
out += (project.name);
out += '</h3> <p>';
out += ENCODE_FUNCTION(project.description);
out += '</p> </div> ';
});
out += '</div>';
return out;
我们把结果用函数包起来并将其导出,这样就生成了一个 CommonJS 模块。
const code = `module.exports = function(){${data.result}}`;
至此预处理就结束了,我们直接运行预处理结果的函数会报引用错误(ReferenceError),因为里面有些变量未定义。因此我们需要将代码转换(transform)一下,这时我们就可以用 Babel 来转换了。
Babel 转换
我们期望是将类似于下面的预处理结果:
module.exports = function() {
var out = '<p>';
out += ENCODE_FUNCTION(name);
out += '</p><p>';
out += (email);
out += '</p>';
return out;
}
转换为这样:
module.exports = function(data) {
var out = '<p>';
out += ENCODE(data.name);
out += '</p><p>';
out += (data.email);
out += '</p>';
return out;
}
因此我们需要做下面几个处理:
- 函数需要加一个
data
参数作为入参。 - 未定义变量需要转换为
data
对象的属性。 ENCODE_FUNCTION
需要转换为对应的 encode 函数。window
和console
等浏览器内置全局对象不作处理。
下面我们就需要来写一个 Babel 插件来处理上面流程,在写插件前我们先用 AST Explorer 来查看一下前面预处理结果的 AST 结构,如下图:
根据上图 AST 结构我们来实现这个简单的插件,代码如下:
function ejsPlugin (babel, options) {
// 获取 types 对象
const { types: t } = babel;
// 一些不作处理的全局对象
const globals = options.globals || ['window', 'console'];
// Encode 函数名称(默认为 ENCODE)
const encodeFn = options.encode || 'ENCODE';
return {
visitor: {
// 访问赋值表达式
AssignmentExpression (path) {
const left = path.get('left');
const right = path.get('right');
// 判断赋值表达式是否为 CommonJS 模块导出
if (t.isMemberExpression(left) &&
t.isFunctionExpression(right) &&
left.node.object.name === 'module' &&
left.node.property.name === 'exports') {
// 给函数添加 data 参数
right.node.params.push(t.identifier('data'));
// 未定义变量的 scope 是在 global 上面
// 判断是否是 global
const isGlobal = (v) => path.scope.globals[v];
// 遍历函数体
right.traverse({
// 访问引用标识符
ReferencedIdentifier (p) {
const v = p.node.name;
// 如果是全局变量且不在白名单里的变量需要替换
if (isGlobal(v) && globals.indexOf(v) < 0) {
if (v === 'ENCODE_FUNCTION') {
// 替换 Encode 函数名称
p.node.name = encodeFn;
} else {
// 替换未定义变量为 data 的属性
p.node.name = `data.${v}`;
}
}
}
});
}
}
}
};
}
最后用 Babel 进行转换:
import { transform } from '@babel/core';
import preParse from './preParse';
const data = preParse(`
<p><%=name%></p>
<p><%-email%></p>
`);
const options = {
encode: 'window.encode'
};
transform(`module.exports = function(){${data.result}}`, {
plugins: [[ejsPlugin, options]]
}, (err, result) => {
console.log(result.code);
});
输出为:
module.exports = function(data) {
var out = '<p>';
out += window.encode(data.name);
out += '</p><p>';
out += (data.email);
out += '</p>';
return out;
}
我们这里没有内置 encode 函数,这个需要自己实现,根据 XSS 预防手册 我们可以简单实现一下 window.encode
:
window.ENCODE = (str) => {
return String(str)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\//g, '/');
};
最后
最后我们将上面的内容封装成了一个 Webpack 的 loader 库:etpl-loader。
本文一些参考链接: