- Published on
前端模板引擎(template engine)发展史及实现
- Authors
- Name
- 文森
- Github
- @leungwensen
前端模板引擎(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 倍
大致原理:模板使用原来的引擎 parse 生成 js 函数之后,使用一个 js parser(homunculus)去分析生成的代码,把 new Function 之类的代码转换成两层函数(外部函数传入和处理变量,内部函数是原来模板消费变量的逻辑),最终生成标准 js 模块
把这个处理流程泛化,设计一个通用的插件基础类,不同模板引擎的支持就只是简单替换函数封装的逻辑了。当时项目里使用到的 doT、underscore、animajs、zero 等等,基本都使用这套逻辑,实现了性能和可维护性的跃迁。