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

2018-11-07 admin

背景

之前公司有些项目前端模板用的是 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

本文一些参考链接:

原文链接:https://zhuanlan.zhihu.com/p/48798765

本站文章除注明转载外,均为本站原创或编译。欢迎任何形式的转载,但请务必注明出处。

转载请注明:文章转载自 JavaScript中文网 [https://www.javascriptcn.com]

本文地址:https://www.javascriptcn.com/read-44769.html

文章标题:基于 Babel 来实现一个前端模板

相关文章
前端交流QQ群
我们建立了一个前端交流QQ群供大家交流,有什么问题都可以在群里提问,欢迎你的加入,也希望我们大家能够在群里互帮互助,同时也能学到东西。 我们相信,前端有你更精彩! 为了让更多的小伙伴加入我们,欢迎大家转发扩散! 长按以上二维码加入我们 ...
2016-04-01
2014年最流行前端开发框架对比评测
如今,各种开发框架层出不穷,各有千秋。哪些是去年较受开发者关注的呢?前不久,云适配根据Github上的流行程度整理了2014年最受欢迎的6个前端开发框架,并进行对比说明,希望帮助有需要的朋友选择合适自己的前端框架。 1. Bootstrap...
2015-11-12
javaScript+turn.js实现图书翻页效果实例代码
为了实现图书翻页的效果我们在网上可以看到很多教程 在这里推荐turn.js 网上的turn.js 有api 不过是英文的  很多人看起来不方便 .关于代码也是奇形怪状在这里我将详细讲解如何使用turn.js实现翻页效果 ,本篇文章只是讲解 ...
2017-03-16
Web前端开发与iOS终端开发的异同
毕业之前一直在做前端开发,毕业后就转成做iOS开发,这两者有很多挺有意思的对比,尝试写下我能想到的它们的一些相同点和不同点。 语言 前端和终端作为面向用户端的程序,有个共同特点:需要依赖用户机器的运行环境,所以开发语言基本上是没有选择的,...
2016-01-13
js实现手机拍照上传功能
在前段时间的项目开发中,用到了拍照上传的地方,后来发现了最为简单的一种方法,现总结如下: &lt;form id=&quot;form&quot; method=&quot;post&quot; action=&quot;http:&#x2...
2017-03-06
纯JS实现旋转图片3D展示效果
CSS: &lt;style type=&quot;text&#x2F;css&quot;&gt; #show{position:relative;margin:20px auto;width:800px;} .item{position:...
2017-03-22
vue.js实现请求数据的方法示例
vue2.0示例代码如下: var vm = new Vue({ el:&quot;#list&quot;, data:{ gridData: &quot;&quot;, }, ...
2017-03-20
v-charts | 饿了么团队开源的基于 Vue 和 ECharts 的图表工具
在使用echarts生成图表时,经常需要做繁琐的数据类型转化、修改复杂的配置项,v-charts的出现正是为了解决这个 痛点。基于Vue2.0和echarts封装的v-charts图表组件,只需要统一提供一种对前后端都友好的数据格式 设置简...
2018-05-24
前端MV*框架的意义
经常有人质疑,在前端搞MV有什么意义?也有人提出这样的疑问:以AngularJS,Knockout,BackBone为代表的MV框架,它跟jQuery这样的框架有什么区别?我jQuery用得好好的,有什么必要再引入这种框架? 回答这些问题之...
2016-03-11
前端问答社区成立了
由雷锋网友提供的给大家相互交流的前端问答社区正式上线了,欢迎大家来此相互交流相互学习 ...
2016-03-30
回到顶部