Articles

🧊 模板解析器轻考古

忍者秘籍中提到的模板解析器,很短但支持变量和 if 等语句,还是 15 年前的代码!

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

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

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

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

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

<!-- 以下经过格式化 -->
<div>hello - 2</div>
<div>hello - 3</div>
<div>hello - 4</div>

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

完整实现

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

// 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。内部通过局部变量保存了模板解析的缓存:

(function () {
  var cache = {};
  this.tmpl = function tmpl(str, data) {};
})();

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

<!-- 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>
(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 函数。

new Function(
  "obj",
  "var p=[];" +
    // + '...'
    "return p.join('')"
);

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

/* 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>') } 。

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

str = `<% if (true) { %>
    <li><%=users[i]%></li>
<% } %>`;
// 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(' 和 '); 这种手动补全。

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

`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 那就会立即调用返回模板解析的结果。

(function () {
  var cache = {};
  this.tmpl = function tmpl(str, data) {
    return data ? fn(data) : fn;
  };
})();

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

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

  • 从注释、测试用例,先搞清楚要看的代码的作用
  • 使用编辑器的折叠代码,一次只看一部分源码
  • 在 Git 提交记录中搜索,从简单的版本看起
  • 复杂代码应该从高层结构看起,比如使用 IDE 各种插件或是 Madge 等代码分析工具

相关链接


Copyright © 2024 Lionad - CC-BY-NC-CD-4.0