幾十行代碼實現一個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
}))
複製代碼
相關文章
相關標籤/搜索