手把手实现一个HTML模板引擎

1,331 阅读7分钟

想必前端的同学都接触过 HTML 模板语法,大多数可能都是以 {{ 的形式(Mustache 风格)去表示的,比如 Vue 的模板语法,Vue 通过对模板字符串的遍历解析,最终生成了 HTML:

<span>Message: {{ msg }}</span>

除了上面这种类型,还有一个叫做 ERB-style 的模板标记语法,也非常的常见,它就是我们接下来要实现的这一种。

虽然我们这次实现的是 ERB 风格,但是这也只是一个标记,如果您读懂了本文的内容,您可以换成任意喜欢的标记方法,比如,如果想使用 {{ 的方式,也完全没问题。

不过,本文还是以 ERB 风格为例。

它的语法也比较简单,主要有两种表示:

  1. <% ... %> 可以包裹一个 JavaScript 语句:
<%for ( let i = 0; i < 10; i++ ) { %>
  <% console.log(i) %>
<% } %>
  1. <%= ... %> 可以获取当前执行环境下的变量:

假设我们写好了模板函数,就叫 template

我们的使用方法会是:

const render = template('<div><%= data.name %></div>');
console.log(render({name: 'hi'})) // <div>hi</div>

我们再举一个使用 <%= ... %> 的例子,那就是在 webpack 中的一个使用场景:

// @filename: webpack.config.js
plugins: [
  new HtmlWebpackPlugin({
    title: 'Custom template',
    // Load a custom template (lodash by default)
    template: 'index.html'
  })
]

// @filename: index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
  </body>
</html>

经过上面的举例,想必大家都很清楚 ERB 风格的模板是什么了吧?

除了上面提到的两种标签语法,还有其他的标签,比如 <%- ... %>,其实它的转换原理和 <%= ... %>是一样的,只不过额外转义了内部的 HTML 字符串的,但是本文不会讲解如何转义 HTML 字符串,所以那种记法就略过了。想了解原理的同学推荐阅读 这篇文章

接下来我们就来实现 ERB 风格的模板引擎。

ps: 下面讲解的代码其实就是 underscorejs 的 _.template 的思路,只不过略过了对一些边界情况的兼容。

我们有一个 index.html 文件:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title></title>
</head>

<body>
  <script id="templ" type="text/html">
    <%for ( var i = 0; i < data.list.length; i++ ) { %>
      <li>
          <a data-id="<%= data.list[i].id %>">
              <%=data.list[i].name %>
          </a>
      </li>
    <% } %>
  </script>
  <script src="./template.js"></script>
</body>

</html>

首先,我们先获取这段模板:

let content = document.querySelector('#templ').innerHTML

我们的模板引擎最核心的原理是什么呢?是对 new Funtion 的使用。事实上,我们可以通过如下方法构造一个函数:

const print = new Function('str', 'console.log(str)')
print('hello world') // hello world

它就相当于:

const print = function (str) {console.log(str)}

有了这个神奇的特性,我们就在想,如果我们把上述模板转化为合法的 JavaScript 代码的字符串,记作字符串 x 。

那我们是不是就可以做一个模板引擎了呢?

new Funtion('data', x);

答案是:是的,我们就是要这么去做。

现在问题的关键就是我们怎么把 content 的值转换为 JavaScript 代码的字符串。

<%for ( var i = 0; i < data.list.length; i++ ) { %>
  <li>
      <a data-id="<%= data.list[i].id %>">
          <%=data.list[i].name %>
      </a>
  </li>
<% } %>

我们可以:

  1. 使用正则 /<%=([\s\S]+?)%>/g 匹配到 <%= ... %> 格式的字符串
  2. 使用正则 /<%([\s\S]+?)%>/g 匹配到 <% ... %> 格式的字符串

注意,第二个正则是包含第一个的,所以,我们在正则替换的时候一定要先替换第一个。

如果我们匹配到了 <%= ... %>,我们会把它变为:'+\n ... +\n'

content = content.replace(/<%=([\s\S]+?)%>/g, function(_, evaluate) {
 return "'+\n" + evaluate + "+\n'"
})

image.png

嗯... 结果有点奇怪?没关系,先看下去。

接下来,我们匹配 <% ... %>

把它变为:';\n ... \n_p +='

content = content.replace(/<%([\s\S]+?)%>/g, function(match,interpolate) {
  return "';\n" + interpolate + "\n_p +='";
})

image.png

现在是不是有点像样了呢?不过这个还不是合法的 JavaScript 代码。

我们还需要在它的头尾加点东西。

在头部加上 let _p = '';\nwith (data){\n_p+=',在尾部加上 '}return _p,再来看一下效果:

image.png

这样才是差不多像样了,但是还是有个问题,请看上图的第五行,因为行的最后有个 \n 字符,所以在 ' 之后换行了。

但是在 JavaScript 中 ' 是不允许换行的,如果我们把这段代码拷贝到控制台执行,还是会报错。

我们可以考虑把 ' 换成 ES6 的模板字符串语法,也可以考虑对此类特殊字符进行处理,我们选择特殊处理一下。

如果我们用编辑器在某个 JS 文件中写两行代码:

const a = 1;
const b = 2;

它其实是真正存储在文件是更像这样子的:const a = 1;\nconst b = 2;。而我们要在字符串里保留 \n的原始模样,就要它做一层转义,当我们在字符串写 'const a = 1;\\nconst b = 2;' 才真正表示了上面真正的存储结果。

\n 一样的还有下面几个,列一个统一的表:

转义字符要转化为
'\`
\\\
\r\\r
\n\\n
\u2028\\u2028
\u2029\\u2029

到代码层面的话,就会是下面这样子:

var escapes = {
  "'": "'",
  '\\': '\\',
  '\r': 'r',
  '\n': 'n',
  '\u2028': 'u2028',
  '\u2029': 'u2029'
};

var escapeRegExp = /\\|'|\r|\n|\u2028|\u2029/g;

function escapeChar(match) {
  if (match === "'") {
    return "\'" 
  }
  return '\\' + escapes[match];
}

注意看 escapeChar 函数,我们特别兼容了一下单引号,因为它和其他的不同,对比我们列的表,它的转化的结果前面只有一个 \ ,但是我们也可以去掉这个,那就是用单引号表示。因为 "\'" 等于 '\\'',所以代码就可以去掉那个 if 语句,写成:

function escapeChar(match) {
  return '\\' + escapes[match];
}

鉴于 \ 的作为转译序列的特殊性,我们的 escapes 对象的第二项其实代表的是一个\ ,而转换后的结果其实代表的两个 \:

byte[] byteArray1 = "\\".getBytes();
byte[] byteArray2 = "\\\\".getBytes();
System.out.println(byteArray1) // [92]
System.out.println(byteArray2) // [92, 92]

我们在最开始获取到 content 后,加上这段处理转译序列的逻辑后,再看一下最后的结果:

content = content.replace(escapeRegExp, function(match) {
  return escapeChar(match);
}

image.png

这样就没什么问题了,我们就可以放心的把它传给 new Function 的第二个参数了。

const render = new Function('data', content);

后面调用我们的 render 函数就可以这样:

render({
    list: {name: 'Bob', id: 1}
})

我们可以得到下面这样的结果:

image.png

完美,逻辑我们终于讲完了。

underscore 的思路也是这样子,只不过,它做的更简洁。

我们的思路是先把 content 的特殊字符处理掉,再把 <%= ... %> 处理掉,再把 <% ... %> 处理掉,然后再把代码的头部尾部完善一下。

而它呢,它使用的正则和我们不一样,它是 /<%=([\s\S]+?)%>|<%([\s\S]+?)%>|$/g ,关键点就在这里。

underscore 只需要遍历一遍,碰到 <%= ... %>或者 <% ... %> 后,先把上一次匹配结果的结束到这次匹配结果之前的特殊字符处理掉,然后再判断当前匹配到的模板语法怎么处理,依次迭代,直到匹配到字符串结尾。

有些同学可能会好奇,这样能匹配到最后嘛?如果我们的模板最后面是一些纯字符串,而不是 <%= ... %>或者 <% ... %>,正则岂不是匹配不到最后了?这也就是 underscore 为了把正则最后加了 |$ 的原因,保证可以匹配到最后,这样就能把这一段的特殊字符也处理掉。

另外,underscore 在处理模板语法 <%= ... %> 的时候加了对 nullundefined判断,如果是这两者,我们最开始的写法会直接输出字符串 'undefined' 或者 'null'。但是 underscore 则让这些情况输出空字符串。

var interpolate = '123'; 
var __t;
(__t= (interpolate)) == null ? '' : __t

写得人性化一点,就等同于:

interpolate == null ? interpolate : ''

明白了上面这些点之后,再去看 _.template 的源码应该会轻松一些了。

但是,思路都是一样的,相信明白了最开始我们分析过程的同学,一定也能明白 underscore 的 _.template 函数的原理。比起直接讲解 _.template 的实现,拆解开来应该更容易理解吧 :)

为了方便各位调试,我把可执行代码都放在下面,需要的同学自取~

谢谢各位的阅读,撒花 ~

参考链接

  1. 实现一个模板引擎
  2. new Functon 的使用

完整代码

<!-- @filename: index.html -->
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title></title>
</head>

<body>
  <script id="templ" type="text/html">
    <%for ( var i = 0; i < data.list.length; i++ ) { %>
      <li>
          <a data-id="<%= data.list[i].id %>">
              <%=data.list[i].name %>
          </a>
      </li>
    <% } %>
  </script>
  <script src="./index.js"></script>
</body>

</html>
// @filename: main.js
let content = document.querySelector('#templ').innerHTML

var settings = {
  evaluate: /<%([\s\S]+?)%>/g,
  interpolate: /<%=([\s\S]+?)%>/g,
};

var escapes = {
  "'": "'",
  '\\': '\\',
  '\r': 'r',
  '\n': 'n',
  '\u2028': 'u2028',
  '\u2029': 'u2029'
};

var escapeRegExp = /\\|'|\r|\n|\u2028|\u2029/g;

function escapeChar(match) {
  if (match === "'") {
    return '\\`' 
  }
  return '\\' + escapes[match];
}

function template(text) {
  var matcher = RegExp([
    (settings.interpolate || noMatch).source,
    (settings.evaluate || noMatch).source
  ].join('|') + '|$', 'g');

  var index = 0;
  var source = "__p+='";
  text.replace(matcher, function (match, interpolate, evaluate, offset) {
    source += text.slice(index, offset).replace(escapeRegExp, escapeChar);
    index = offset + match.length;

   if (interpolate) {
      source += "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'";
    } else if (evaluate) {
      source += "';\n" + evaluate + "\n__p+='";
    }

    return match;
  });
  source += "';\n";

  var argument = 'data';
  source = 'with('+ argument + '||{}){\n' + source + '}\n';

  source = "var __t,__p='';" +
    source + 'return __p;\n';

  var render;
  try {
    render = new Function(argument, source);
  } catch (e) {
    e.source = source;
    throw e;
  }

  var template = function (data) {
    return render.call(this, data);
  };

  return template;
}

const render = template(content);

var list = [
  {name: 'Bob', id: 1},
  {name: 'Jack', id: 2},
]

console.log(render({
  list
}))