基于 Babel 来实现一个前端模板

1,716 阅读3分钟
原文链接: zhuanlan.zhihu.com

背景

之前公司有些项目前端模板用的是 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 结构我们来实现这个简单的插件,代码如下:

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

本文一些参考链接: