Published on

前端模板引擎(template engine)发展史及实现

Authors

前端模板引擎(template engine)概述

template-engine

上图是大部分老的 Web 程序员一下子能反应过来的模板引擎的全部。“数据 + 模板,经过模板引擎的渲染,得到结果文档内容”。如果是前端程序员,最后的“结果文档内容”,就是一段 HTML;如果是一个 Web 后端,这里的结果文档内容也许是 Controller 的 HTML 输出,也许是 i18n 消息体。

为什么我们需要模板引擎呢?显而易见的理由是我们借由它可以免去手写大量文本的功夫,只需要描述页面或者模块的结构就可以根据数据生成动态的 HTML,提升编码效率。另外只描述结构,也让视图层的逻辑更清晰,和数据解耦之后,也更易于独立维护。视图层独立维护的意义也给多端支持提供了可能性。最后,模板引擎的抽象,也可以有效隔离数据中的 XSS 攻击,让应用更安全。

模板引擎的输入是模板和数据,输出是结果文本(前端的场景大部分是输出 HTML)。从这个“黑盒”来看,模板引擎承担的职责和最基础组成部分包括:

  • 一套描述模板的语法支持(DSL)
  • 数据填充(必须,譬如格式化、字符转码)和加工逻辑(可选,譬如条件分支、循环的支持等)
  • 解析处理数据和模板,输出结果(Parsing, etc.)

前端引擎的发展史

对构建大型的前端应用来说,模板引擎的作用是不言而喻的。不过前端模板引擎技术发展到现在的阶段,也经历了非常长的历史。我们可以回顾一下这项技术大概经历的阶段。从整体来看,这项技术经历的阶段有几个趋势:从后端转向前端;从无逻辑到图灵完备逻辑;从三方方案到语言内置支持。

空白阶段

在 JavaScript 还没有大行其道的年代,没有所谓“前端模板”的说法。HTML 模板技术由后端语言包揽,其中最复杂精巧的方案由当时几个主流的 Web 后端语言社区提供:

  • ASP:微软推出的技术,可以在 HTML 里嵌入 ASP 脚本,经由服务器处理输出动态页面。ASP 的能力已经比较完善,可以直接和数据库数据交互,还提供条件判断等能力
  • PHP:PHP 的方案在“前 JavaScript”年代是最流行的方案,提供的模板引擎能力非常全面,譬如支持基本的条件判断、循环等语法等,并且社区庞大,模板语法简单易用
  • JSP:这相当于 Java 世界里的 ASP

示例

<%
Dim name 
name = "John"
%>
<html>
<body>
<%
response.write("Hello " & name)
If name = "John" Then
response.write("<p>John is here!</p>")
Else
response.write("<p>I don't know you.</p>")
End If
%>
</body>
</html>
<?php
$name = "John";
?>
<html> 
<body>
<?php
echo "Hello $name"; 
if ($name == "John") {
  echo "<p>John is here!</p>";
} else {
  echo "<p>I don't know you.</p>"; 
}
?>
</body>
</html>
<%
String name = "John";
%>
<html>
<body>
<% 
out.println("Hello " + name);
if (name.equals("John")) {
  out.println("<p>John is here!</p>"); 
} else {
  out.println("<p>I don't know you.</p>");
}
%>
</body>
</html>

这个阶段后端的模板引擎方案解决了基本的动态页面生成问题。但交互响应的速度很慢,因为依赖服务端的解析渲染和频繁的网络交互。此后 Ajax 的出现,基本终结了这类模板技术。

Mustache 阶段

Gmail 带火了 Ajax 技术之后,前端慢慢进入了单页面富交互应用(Single Page Application,SPA)时代。刚进入这个阶段,大家对于兼容性、安全性的关注远比灵活性要大。所以早期大家倾向于选用语言无关的、或者前后端一致的模板引擎方案。这个阶段前端模板引擎的代表有这几种:

  • Mustache:这个引擎有两个最大的特点,第一个它是“无逻辑”的,也就是连比较基础的 if、loop 这些逻辑都没有,只做视图与数据的绑定;第二个特点是它是三方模板引擎里支持语言最广泛的模板引擎,基本上所有流行的 Web 开发语言都支持
  • Handlebars:Handlebars 就是 Mustache 的扩展方案,基础语法一致,但增加了 if else、loop 等逻辑处理能力,也支持子模板
  • Jade(Pug):这个引擎是由 Node.js 社区发展起来的,用缩进表示结构层级的模板引擎。语法简洁,但其实对于前端使用不够友好

示例

<html>
<body>
  Hello {{name}}
  {{#name}}
    <p>{{name}} is here!</p>
  {{/name}}
</body>  
</html>
<html>
<body>
  Hello {{name}}
  {{#if name}}
    <p>{{name}} is here!</p>
  {{else}}
    <p>I don't know you.</p>  
  {{/if}}
</body>
</html>
html
  body 
    p Hello #{name}
    if name
      p #{name} is here!
    else  
      p I don't know you.

这个阶段,前端要么采用传统的 Web 后端模板引擎方案,要么也是沿着这样的思路迭代的方案,相对比较保守,模板内部可以处理的逻辑能力非常有限。这也和单页面富交互应用所处的发展阶段还相对初级有关。在复杂度尚未膨胀的时候,更关注可靠性和安全性,这样的想法是主流。

Lodash.template 阶段

随着 MVC、MVVM 这样的框架思路的普及推广,Backbone.js、Angular、Dojo 这样能开发处理大型前端应用的框架相继出现,传统的模板引擎方案面对复杂场景开始力不从心。这个阶段是三方前端模板引擎百花齐放的时代。我们当时的大型项目,很有可能同时混杂有好几种模板引擎的方案,每种模板引擎很有可能大到语法、性能,小到转义函数的实现,都各不相同。这是混乱而又自由的年代,有很多问题,但也有很多可以选择的方案。

这个阶段有代表性的模板方案很多,最流行的莫过于 Lodash.template 函数。它的前身是 underscore.template。国内团队开发的都有很多,animajs 里的模板、各个稍微流行的前端 UI 框架里都基本有一个模板引擎。社区还有主攻运行时性能的 doT.js 等等。这些模板引擎有一个特点,就是都基本可以使用到 JavaScript 的全量语法能力。

<h1><%= title %></h1>
<ul>
  <% _.forEach(items, function(item) { %>
    <li><%= item %></li>
  <% }) %>
</ul>

这个阶段的模板引擎,有点像是传统模板引擎和 template string 的结合体。一方面有内置的 DSL 处理基础的模板渲染能力,譬如字符串转义会有特殊的符号标记等;另一方面,在语法里可以直接内置 JavaScript 语句的逻辑。但没有 template string 灵活的地方在于,它并不能感知当前运行时上下文的信息,所以需要像上面 demo 一样,传入一个类似_的 helper 变量,这样就可以调用更多的辅助函数能力了。

为什么是这样的形态,归根到底,这个阶段的 Web 开发,还没有完全做到前后端分离。然后在前端侧的技术发展又对模板这一层在灵活性上有更高的要求。

JSX 和 template string 阶段

这就是现行的状态了。在前端,HTML 被进一步抽象,大家不直接操作和处理 DOM,而是各种框架针对 DOM 抽象出来的 Virtual Node。并且 Virtual Node 本身还耦合了各种渲染生命周期处理、交互事件处理等逻辑,所以产生了类似 JSX 这样的重型模板方案。这样的方案一般和具体的 Web 研发框架深度绑定,并且也不仅仅承担页面结构组织的任务,它的职责几乎可以是 Web 前端开发本身。

而 JavaScript 也有了语言层面的模板方案:template string。在这个方案里,结构可以自由组织,上下文变量在模板内部也全部可见,灵活性得到了前所未有的扩展。基本上 template string 出来之后,纯 JavaScript 语境下的 i18n 的消息组织的模板方案、单纯描述 HTML 结构的模板方案,都不需要了。

示例

const name = 'John';

const jsxTemplate = (
  <div>
    <h1>Hello {name}!</h1>
    {name === 'John' ? (
      <p>{name} is here!</p>  
    ) : (
      <p>I don't know you.</p>
    )}
  </div>
);
const name = 'John';

const templateString = `
  <div>
    <h1>Hello ${name}!</h1>
    ${name === 'John' ? 
      `<p>${name} is here!</p>` :
      `<p>I don't know you.</p>`}
  </div>
`;

现行流行的这些模板方案,得益于 JavaScript 语言本身,以及其社区编译工具链(尤其 Babel 等)的长足发展。上述两个例子,JSX 编译后的代码其实是几个嵌套的函数,而 Template String 的示例编译到 ES5 语法,则是简单的字符串拼接。

如何实现一个最小化模板引擎

怎样实现一个最小可用的模板引擎?这里讲的,是 Lodash.template 阶段的前端模板引擎。有一个很经典的实现:

三方应用:https://johnresig.com/blog/javascript-micro-templating/

// Simple JavaScript Templating
// John Resig - https://johnresig.com/ - MIT Licensed
(function(){
  var cache = {};
  this.tmpl = function tmpl(str, data){
    // Figure out if we're getting a template, or if we need to
    // load the template - and be sure to cache the result.
    var fn = !/\W/.test(str) ?
      cache[str] = cache[str] ||
        tmpl(document.getElementById(str).innerHTML) :
      // Generate a reusable function that will serve as a template
      // generator (and which will be cached).
      new Function("obj",
        "var p=[],print=function(){p.push.apply(p,arguments);};" +
        // Introduce the data as local variables using with(){}
        "with(obj){p.push('" +
        // Convert the template into pure JavaScript
        str
          .replace(/[\r\t\n]/g, " ")
          .split("<%").join("\t")
          .replace(/((^|%>)[^\t]*)'/g, "$1\r")
          .replace(/\t=(.*?)%>/g, "',$1,'")
          .split("\t").join("');")
          .split("%>").join("p.push('")
          .split("\r").join("\'")
      + "');}return p.join('');");
    // Provide some basic currying to the user
    return data ? fn( data ) : fn;
  };
})();

大神寥寥 30 行代码,几乎就总结了那个年代在各个 UI 框架里野蛮生长的模板引擎方案的内核。包括 underscore,当时的方案也是使用new Function或者with去给模板代码传入上下文变量。传入上下文变量之后,模板内部就可以使用数据、使用传入的 helper 函数了。再稍稍补充一下字符串转义等,一个模板引擎的必备要素就已齐备。

示例

<script type="text/html" id="item_tmpl">
  <div id="<%=id%>" class="<%=(i % 2 == 1 ? " even" : "")%>">
    <div class="grid_1 alpha right">
      <img class="righted" src="<%=profile_image_url%>"/>
    </div>
    <div class="grid_6 omega contents">
      <p><b><a href="/<%=from_user%>"><%=from_user%></a>:</b> <%=text%></p>
    </div>
  </div>
</script>

更通用的、类似 Lodash 的方案会一定程度上避免使用 with(会有更大的上下文切换还原的成本,以及安全问题)。相应地,在字符转义等方面会提供更好的灵活性和 DSL 支持。下面是一个模仿 Lodash template 的模板引擎实现:

var html = require('./html');
var lang = require('zero-lang');
var cache = {};
var helper = {};
// add helpers to pastry to pass to compiled functions, can be extended
lang.extend(helper, html, lang);
var RE_parser = /([\s'\])(?!(?:[^{]|\{(?!%))*%\})|(?:\{%(=|#)([\s\S]+?)%\})|(?:(\{%)([\s\S]+?)(%\}))/g;
// defaultOpitons = {}; // TODO add grammar aliases, etc.
function replacer(s, p1, p2, p3, p4, p5, p6) {
    if (p1) {
        // whitespace, quote and backspace in HTML context
        return ({
            "\n": "\n",
            "\r": "\r",
            "\t": "\t",
            " ": " "
        })[p1] || "\" + p1;
    }
    if (p2) {
        // interpolation: {%=prop%}, or unescaped: {%#prop%}
        p3 = lang.trim(p3);
        if (p2 === "=") return "'+_e(" + p3 + ")+'";
        return "'+_p(" + p3 + ")+'";
    }
    if (p4 && p5 && p6) {
        // evaluation two matched tags: {% * %}
        // COMMENT: this is for fixing bug mentioned in test/jasmine/text/template.spec.js
        return "';" + lang.trim(p5) + " _s+='";
    }
}
function parse(str) {
    var option = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
    var result = str.replace(RE_parser, replacer);
    if (!option.newline) return result.replace(/\n\s*/g, '');
    return result;
}
var template = {
    helper: helper,
    parse: parse,
    compile: function compile(str) {
        var option = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
        if (!lang.isString(str)) return str;
        // new Function()
        return cache[str] || (cache[str] = new Function('data', 'helper', "data=data||{};" + // 当 obj 传空的时候
        "helper=helper||{};" + // 当 obj 传空的时候
        "var _p=helper.print?helper.print:function(s){return s === null ? '' : s;};" + "var _e=helper.escape?helper.escape:function(s){return _p(s);};" +
        // "with(data){" +
        // include helper {
        // "include = function (s, d) {" +
        //     "_s += tmpl(s, d);}" + "," +
        // }
        "var _s='" + parse(str, option) + "';" +
        // "}" +
        "return _s;"));
    },
    render: function render(str, data) {
        var option = arguments.length <= 2 || arguments[2] === undefined ? {} : arguments[2];
        return template.compile(str, option)(data, option.helper || helper);
    }
};
module.exports = template;

早期模板引擎代码的预编译加速

上述第二阶段、第三阶段的前端模板引擎都存在一个问题,就是几乎都只能在运行时去实时解析一段模板字符串,生成函数(一般都有缓存)之后,再结合数据和 helper 函数渲染得到最终的运行时结果。并且几乎都有类似new Function或者with的语句,性能会很差。为了得到更好的运行时性能,可以在类似技术的基础上加上预编译,把每一段模板都变成一个 js module,并且替换其中的new Function语句,从而实现极致的性能表现。当然,这个肯定是赶不上 template string 这样语言内置的方案的,不过在当时,这已经算是最先进的模板引擎使用技巧。

三方应用:https://github.com/leungwensen/template2module

性能表现:同样的 underscore 模板,template2module 预编译后的函数,比 underscore 解析生成的函数快 10 倍

template2module-vs-underscore

大致原理:模板使用原来的引擎 parse 生成 js 函数之后,使用一个 js parser(homunculus)去分析生成的代码,把 new Function 之类的代码转换成两层函数(外部函数传入和处理变量,内部函数是原来模板消费变量的逻辑),最终生成标准 js 模块

parse()
analyse()
wrap()
modularize()
Template
JS Code
Inner Function
Outer Function
Module

把这个处理流程泛化,设计一个通用的插件基础类,不同模板引擎的支持就只是简单替换函数封装的逻辑了。当时项目里使用到的 doT、underscore、animajs、zero 等等,基本都使用这套逻辑,实现了性能和可维护性的跃迁。