基于 Babel 来实现一个前端模板
发布于 7 年前 作者 helloyou2012 3810 次浏览 来自 分享

背景

之前公司有些项目前端模板用的是 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 只需要改几个地方,修改后代码如下:

preParse.js

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;
}

因此我们需要做下面几个处理:

  1. 函数需要加一个 data 参数作为入参。
  2. 未定义变量需要转换为 data 对象的属性。
  3. ENCODE_FUNCTION 需要转换为对应的 encode 函数。
  4. windowconsole 等浏览器内置全局对象不作处理。

下面我们就需要来写一个 Babel 插件来处理上面流程,在写插件前我们先用 AST Explorer 来查看一下前面预处理结果的 AST 结构,如下图:

ast.png

根据上图 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, '&amp;')
 .replace(/"/g, '&quot;')
 .replace(/'/g, '&#39;')
 .replace(/</g, '&lt;')
 .replace(/>/g, '&gt;')
 .replace(/\//g, '&#47;');
};

最后

最后我们将上面的内容封装成了一个 Webpack 的 loader 库:etpl-loader

本文一些参考链接:

回到顶部

AltStyle によって変換されたページ (->オリジナル) /