🧊 考古学,十五年前的模板解析器长什么样?

上回我借着解决 CSS 嵌套解析问题聊到 micro-app 中实现的一个非常精简的 CSS 解析器,还没过瘾。今天整理笔记的时候发现了角落里的这个模板解析器,那就赶紧擦擦灰尘拉出来聊一聊。这个模板解析器可是有年头了,它是 John Resig 佬 2008 年写的,叫 micro-templating,距今十五年哩。(John Resig 就是写《JavaScript 忍者秘籍》那位)

令人惊叹的地方是,micro-templating 的代码和它的名字一样 micro,它能用仅仅 30 行代码完成对模板的处理,并且还有读取 html 模板、缓存、柯里化等额外的高级功能!

可能有些佬没接触过模板解析器,这里给出一个简单的示例。

js 复制代码
parse(`
  <% if (shouldCount) { %>
    <% for (var i = 1; i <= 3; i++) { %>
      <div>hello - <%= i + count %></div>
    <% } %>
  <% } else { %>
    <div>noop</div>
  <% } %>
`, {
  shouldCount: true,
  count: 1
})

parse 函数传入模板字符串和数据后,自然也是输出字符串啦:

html 复制代码
<!-- 以下经过格式化 -->
<div>hello - 2</div>
<div>hello - 3</div>
<div>hello - 4</div>

相比生态里现在动则 30kb 甚至 300kb 的库,仅 30 行代码完成这个功能,简直不可思议。

完整实现

因为代码真的非常短,先直接看一遍,这是带注释版本的。如果你能在第一遍 review 这段代码后脑袋还保持清醒的话,留下评论,我拜你为大哥。

js 复制代码
// 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;
  };
})();

代码看完了,我知道你没看懂,因为曾经我也是。好了,留下三连,解析开始。

解析

首先,代码最外层是一个 IIFE,给 this 对象挂载了 tmpl 函数,直接执行的话就是 window。内部通过局部变量保存了模板解析的缓存:

js 复制代码
(function () {
  var cache = {};
  this.tmpl = function tmpl(str, data) {};
})();

str 参数有两种两种用途,/\W/.test(str) 用来判断字符串中包不包含模板分隔符,如果不含分隔符,即普通字符串,那就当作 ID,从页面对应 ID 的节点中获取内容作为模板。比如,如果 str 为 'user_tmpl',那么模板函数将会读取页面的以下脚本的内容作为输入:

html 复制代码
<!-- type="text/html",所以浏览器并不会执行这串脚本。它只能由 tmpl 来读取... -->
<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>
js 复制代码
(function () {
  var cache = {};
  this.tmpl = function tmpl(str, data) {
    return !/\W/.test(str)
      ? // 缓存会将读取脚本内容到模板解析这个过程的解析结果缓存下来
        (cache[str] =
          cache[str] ||
          // 读取脚本内容
          tmpl(document.getElementById(str).innerHTML))
      : new Function();
  };
})();

new Function() 即模板解析的核心内容,原理是用一个数组,保存所有解析内容,再拼接回字符串,作为 JS 执行。类似 eval 函数。

js 复制代码
new Function(
  "obj",
  "var p=[];" +
    // + '...'
    "return p.join('')"
);

要解析什么内容呢?无非是 <%=hello%> 这种模板字符串。执行过程中,通过 with 语句,将内部变量的求值(hello)转移到参数对象(data.hello)。这个玩意儿在 VueJS 的模板编译中也用到过:

js 复制代码
/* VueJS */
with (vm) {
  return createVNode("p", { attrs: { hi: hello /* vm.hello */ } }, [
    createTextNode("Hello World"),
  ]);
}

那如何解析呢?

我们知道 曾写过经典字符串模板的佬应该知道,模板字符串一般包含:字符串、变量及逻辑

  • 字符串 <html>Lionad</html>

  • 带变量 <html><%=name%></html>

  • 带逻辑 <% if(name){ %><html><%=name%></html><% } %>

所以解析模板,无非就是把这三种字符串替换成纯粹的 JS 逻辑,作为 new Function 的参数。其中所有中间变量,可以用一个数组来储存:

  • 将普通字符串直接推入作为结果的内容数组,比如 <html>Lionad</html> 应该被解析为 p.push('<html>Lionad</html>')

  • 如果碰到变量,则将变量拆分出来推入数组,如 <html><%=name%></html> 应该被解析为 p.push('<html>');p.push(name);p.push('</html>')

  • 带逻辑的字符串,由于逻辑本身就是 JS 代码,所以不需要解析为字符串,只需要把两侧的 <% %> 这种模板标志去掉就好了。如 <% if(name){ %><html><%=name%></html><% } %> 应该被解析为:if(name){ p.push('<html>');p.push(name);p.push('</html>') }

以下面这段模板为例,我们看看字符串具体的替换规则。

js 复制代码
str = `<% if (true) { %>
    <li><%=users[i]%></li>
<% } %>`;
js 复制代码
// with 的使用方法都知道了,with(obj = { a }),那么
// 就能在内部直接读取 a,不需要写 obj.a
"with(obj){p.push('" +
  str
    // 将换行等空白字符转换为空格,这样所有代码都在一行了
    // '<% if (true) { %>     <li><%=users[i]%></li> <% } %>'
    .replace(/[\r\t\n]/g, " ")
    // ['', 'if (true) { %><li>', '=users[i]%></li>', ' } %>']
    .split("<%")
    // 因为 \t 刚才已经被替换成空格了,所以这里可以使用 \t 用来将不同行的代码字符串"相加",
    // 一会儿再拆分时,不会和原先字符串里的字符混淆
    .join("\t")
    // 去掉模板标志两端的引号,在这个例子里不会用到
    .replace(/((^|%>)[^\t]*)'/g, "$1\r")
    // 以下三句最重要,将所有变量推入数组,生成结果形如 `push('xxx',x,'xxx')` 的字符串
    // 首先,刚才匹配完了 <%,所以 \t= 开头后边接的就是变量,这里让它转换成 ',x,' 的形式
    .replace(/\t=(.*?)%>/g, "',$1,'")
    .split("\t")
    // 在刚刚跟 split('<%') 的位置用括号还原,得到了 ',x,'xxx'); 的形式
    .join("');")
    // 在 \t=xxx 那次匹配,已经把变量模板对应的结束标志 %> 匹配完了,
    // 所以这是处理剩余语句的结束标志,用 split 拆分行后相当于直接把结束标记舍弃了
    .split("%>")
    // 最终在每行开头增加 push 方法,形成了 push('xxx',x,'xxx'); 的形式
    .join("p.push('")
    // 处理剩余的换行符
    .split("\r")
    .join("\\'") +
  "');}";

有一个小细节,字符串替换时,所有逻辑语句的模板标志开头和结尾(<%%>)分别被替换为 ');push(',但是开头和结尾的那个标志,并没有对应的匹配。所以在代码中,能看 with(obj){p.push(''); 这种手动补全。

最终生成的字符串如下(已格式化处理):

js 复制代码
`with(obj){
    p.push('');
    if (true) {
        p.push('     <li>',users[i],'</li>   ');
    }
    p.push('');
}`;

为什么 with 语句里有一行空的 push('') 呢?

还记得原始的模板吗?一开的模板是以"<%"开头的,而"<%"在替换过程被右括号取代了,此时正好和初始字符串结合形成了空 push。如果原始字符串开头是 "123<% if (true)",那这行空 push 会转换成"p.push('123');",也是正确的代码。

最后,micro-templating 需要返回这个新函数。如果不传参数 data 给模板函数,则会返回一个部分应用的函数等待用户输入 data 参数再执行模板解析;如果已经传了 data 那就会立即调用返回模板解析的结果。

js 复制代码
(function () {
  var cache = {};
  this.tmpl = function tmpl(str, data) {
    return data ? fn(data) : fn;
  };
})();

终于结束了!这个函数不长,但一口气看下来还是蛮费力气的。如果以便写测试用例以便看代码,很快能把这个函数跑通,如果你是纯靠脑力跑,还没被那个长长的字符串替换链给吓住,在此我赐你称号:"人肉编译器 - 2024 限定版"。

额外提一句,Function 构造器和 eval 函数比,除了能传参数意外,还有一些细节不同,比如说声明变量的作用域不同,这个需要注意。这个编译器没有错误处理等代码完善,因此不可以上生产。此外,由于 with 语句非常容易出错,且不利于静态优化,不应该在业务函数里使用[^with-mdn]。最后,阅读源码需要技巧,这几点希望能帮助到你:

  • 从注释、测试用例,先搞清楚要看的代码的作用

  • 使用编辑器的折叠代码,一次只看一部分源码

  • 在 Git 提交记录中搜索,从简单的版本看起

  • 复杂代码应该从高层结构看起,比如使用 IDE 各种插件或是 Madge 等代码分析工具

相关链接

相关推荐
Dontla几秒前
前端状态管理,为什么要状态管理?(React状态管理、zustand)
前端·react.js·前端框架
编程猪猪侠2 分钟前
前端根据文件后缀名智能识别文件类型的实用函数
前端
宋辰月3 分钟前
学习react第一天
javascript·学习·react.js
yinuo10 分钟前
基于 Git Submodule 的代码同步融合方案
前端
伶俜monster21 分钟前
大模型 “万能接口” MCP 横空出世!打破数据孤岛,重塑 AI 交互新规则
前端·mcp
你听得到1122 分钟前
肝了半个月,我用 Flutter 写了个功能强大的图片编辑器,告别image_cropper
android·前端·flutter
flashlight_hi23 分钟前
LeetCode 分类刷题:141. 环形链表
javascript·算法·leetcode
www_stdio25 分钟前
JavaScript 中的异步编程与 Promise
javascript
宇余1 小时前
前端新玩具Vike:摆脱框架绑架,实现真正的技术自由
vue.js
Macbethad1 小时前
Typora 精通指南:掌握高效 Markdown 写作的艺术
前端·macos·前端框架