几十行代码实现一个HTML模板引擎

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!javascript

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

<span>Message: {{ msg }}</span>
复制代码

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

虽然咱们此次实现的是 ERB 风格,可是这也只是一个标记,若是您读懂了本文的内容,您能够换成任意喜欢的标记方法,好比,若是想使用 {{ 的方式,也彻底没问题。java

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

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

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

假设咱们写好了模板函数,就叫 templategithub

咱们的使用方法会是:web

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 风格的模板是什么了吧?markdown

除了上面提到的两种标签语法,还有其余的标签,好比 <%- ... %>,其实它的转换原理和 <%= ... %>是同样的,只不过额外转义了内部的 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 的实现,拆解开来应该更容易理解吧 :)

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

我有一个当心愿,那就是总点赞数达到 100,如今已经有 91 个了,看到这里的同窗,若是本篇文章对你有帮助,能够帮我点个赞吗,不点也不要紧,阅读到这里,已是对我最大的支持了,感恩。

谢谢各位的阅读,撒花 ~

参考连接

  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
}))
复制代码
相关文章
相关标签/搜索