字串模板淺析

滴滴WebApp架構組發表於2019-03-03

作者:崔靜

前言

雖然現在有各種前端框架來提高開發效率,但是在某些情況下,原生 JavaScript 實現的元件也是不可或缺的。例如在我們的專案中,需要給業務方提供一個通用的支付元件,但是業務方使用的技術棧可能是 VueReact 等,甚至是原生的 JavaScript。那麼為了實現通用性,同時保證元件的可維護性,實現一個原生 JavaScript 的元件也就顯得很有必要了。

下面左圖為我們的 Panel 元件的大概樣子,右圖則為我們專案的大概目錄結構:

字串模板淺析字串模板淺析

我們將一個元件拆分為 .html.js.css 三種檔案,例如 Panel 元件,包含 panel.html、panel.js、panel.css 三個檔案,這樣可以將檢視、邏輯和樣式拆解開來便於維護。為了提升元件靈活性,我們 Panel 中的標題,button 的文案,以及中間 item 的個數、內容等均由配置資料來控制,這樣,我們就可以根據配置資料動態渲染元件。這個過程中,為了使資料、事件流向更為清晰,參考 Vue 的設計,我們引入了資料處理中心 data center 的概念,元件需要的資料統一存放在 data center 中。data center 資料改變會觸發元件的更新,而這個更新的過程,就是根據不同的資料對檢視進行重新渲染。

字串模板淺析

panel.html 就是我們常說的“字串模板”,而對其進行解析變成可執行的 JavaScript 程式碼的過程則是“模板引擎”所做的事情。目前有很多的模板引擎供選擇,且一般都提供了豐富的功能。但是在很多情況下,我們可能只是處理一個簡單的模板,沒有太複雜的邏輯,那麼簡單的字串模板已足夠我們使用。

幾種字串模板方式和簡單原理

主要分為以下幾類:

  1. 簡單粗暴——正則替換

    最簡單粗暴的方式,直接使用字串進行正則替換。但是無法處理迴圈語句和 if / else 判斷這些。

    a. 定義一個字串變數的寫法,比如用 <%%> 包裹

    const template = (
      '<div class="toast_wrap">' +
        '<div class="msg"><%text%></div>' +
        '<div class="tips_icon <%iconClass%>"></div>' +
      '</div>'
    )
    複製程式碼

    b. 然後通過正則匹配,找出所有的 <%%>, 對裡面的變數進行替換

    function templateEngine(source, data) {
      if (!data) {
        return source
      }
      return source.replace(/<%([^%>]+)?%>/g, function (match, key) {  
        return data[key] ? data[key] : ''
      })
    }
    templateEngine(template, {
      text: 'hello',
      iconClass: 'warn'
    })
    複製程式碼
  2. 簡單優雅——ES6 的模板語法

    使用 ES6 語法中的模板字串,上面的通過正規表示式實現的全域性替換,我們可以簡單的寫成

    const data = {
      text: 'hello',
      iconClass: 'warn'
    }
    const template = `
      <div class="toast_wrap">
        <div class="msg">${data.text}</div>
        <div class="tips_icon ${data.iconClass}"></div>
      </div>
    `
    複製程式碼

    在模板字串的 ${} 中可以寫任意表示式,但是同樣的,對 if / else 判斷、迴圈語句無法處理。

  3. 簡易模板引擎

    很多情況下,我們渲染 HTML 模板時,尤其是渲染 ul 元素時, 一個 for 迴圈顯得尤為必要。那麼就需要在上面簡單邏輯的基礎上加入邏輯處理語句。

    例如我們有如下一個模板:

    var template = (
      'I hava some menu lists:' +
      '<% if (lists) { %>' +
        '<ul>' +
          '<% for (var index in lists) { %>' +
            '<li><% lists[i].text %></li>' +
          '<% } %>' +
        '</ul>' +
      '<% } else { %>' +
        '<p>list is empty</p>' +
      '<% } %>'
    )
    複製程式碼

    直觀的想,我們希望模板能轉化成下面的樣子:

    'I hava some menu lists:'
    if (lists) {
      '<ul>'
      for (var index in lists) {
        '<li>'
        lists[i].text
        '</li>'
      }
      '</ul>'
    } else {
     '<p>list is empty</p>'
    }
    複製程式碼

    為了得到最後的模板,我們將散在各處的 HTML 片段 push 到一個陣列 html 中,最後通過 html.join('') 拼接成最終的模板。

    const html = []
    html.push('I hava some menu lists:')
    if (lists) {
      html.push('<ul>')
      for (var index in lists) {
        html.push('<li>')
        html.push(lists[i].text)
        html.push('</li>')
      }
      html.push('</ul>')
    } else {
     html.push('<p>list is empty</p>')
    }
    return html.join('')
    複製程式碼

    如此,我們就得到了可以執行的 JavaScript 程式碼。對比一下,容易看出從模板到 JavaScript 程式碼,經歷了幾個轉換:

    1. <%%> 中如果是邏輯語句(if/else/for/switch/case/break),那麼中間的內容直接轉成 JavaScript 程式碼。通過正規表示式 /(^( )?(var|if|for|else|switch|case|break|;))(.*)?/g 將要處理的邏輯表示式過濾出來。
    2. <% xxx %> 中如果是非邏輯語句,那麼我們替換成 html.push(xxx) 的語句
    3. <%%> 之外的內容,我們替換成 html.push(字串)
    const re = /<%(.+?)%>/g
    const reExp = /(^( )?(var|if|for|else|switch|case|break|;))(.*)?/g
    let code = 'var r=[];\n'
    let cursor = 0
    let result
    let match
    const add = (line, js) => {
      if (js) { // 處理 `<%%>` 中的內容,
        code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n'
      } else { // 處理 `<%%>` 外的內容
        code += line !== '' ? 'r.push("' + line.replace(/"/g, '\\"') + '");\n' : ''
      }
      return add
    }
    
    while (match = re.exec(template)) { // 迴圈找出所有的 <%%> 
      add(template.slice(cursor, match.index))(match[1], true)
      cursor = match.index + match[0].length
    }
    // 處理最後一個<%%>之後的內容
    add(template.substr(cursor, template.length - cursor))
    // 最後返回
    code = (code + 'return r.join(""); }').replace(/[\r\t\n]/g, ' ')
    複製程式碼

    到此我們得到了“文字”版本的 JavaScript 程式碼,利用 new Function 可以將“文字”程式碼轉化為真正的可執行程式碼。

    最後還剩一件事——傳入引數,執行該函式。

    方式一:可以把模板中所有的引數統一封裝在一個物件 (data) 中,然後利用 apply 繫結函式的 this 到這個物件。這樣在模板中,我們便可通過 this.xx 獲取到資料。

    new Function(code).apply(data)
    複製程式碼

    方式二:總是寫 this. 會感覺略麻煩。可以把函式包裹在 with(obj) 中來執行,然後把模板用到的資料當做 obj 引數傳入函式。這樣一來,可以像前文例子中的模板寫法一樣,直接在模板中使用變數。

    let code = 'with (obj) { ...'
    ...
    new Function('obj', code).apply(data, [data])
    複製程式碼

    但是需要注意,with 語法本身是存在一些弊端的。

    到此我們就得到了一個簡單的模板引擎。

    在此基礎上,可以進行一些包裝,擴充一下功能。比如可以增加一個 i18n 多語言處理方法。這樣可以把語言的文案從模板中單獨抽離出來,在全域性進行一次語言設定之後,在後期的渲染中,直接使用即可。

    基本思路:對傳入模板的資料進行包裝,在其中增加一個 $i18n 函式。然後當我們在模板中寫 <p><%$i18n("something")%></p> 時,將會被解析為 push($i18n("something"))

    具體程式碼如下:

    // template-engine.js
    import parse from './parse' // 前面實現的簡單的模板引擎
    class TemplateEngine {
      constructor() {
        this.localeContent = {}
      }
    
      // 引數 parentEl, tpl, data = {} 或者 tpl, data = {}
      renderI18nTpl(tpl, data) {
        const html = this.render(tpl, data)
        const el = createDom(`<div>${html}</div>`)
        const childrenNode = children(el)
        // 多個元素則用<div></div>包裹起來,單個元素則直接返回
        const dom = childrenNode.length > 1 ? el : childrenNode[0]
        return dom
      }
      setGlobalContent(content) {
        this.localeContent = content
      }
      // 在傳入模板的資料中多增加一個$i18n的函式。
      render(tpl, data = {}) {
        return parse(tpl, {
          ...data,
          $i18n: (key) => {
            return this.i18n(key)
          }
        })
      }
      i18n(key) {
        if (!this.localeContent) {
          return ''
        }
        return this.localeContent[key]
      }
    }
    export default new TemplateEngine()
    複製程式碼

    通過 setGlobalContent 方法,設定全域性的文案。然後在模板中可以通過<%$i18n("contentKey")%>來直接使用

    import TemplateEngine from './template-engine'
    const content = {
      something: 'zh-CN'
    }
    TemplateEngine.setGlobalContent(content)
    const template = '<p><%$i18n("something")%></p>'
    const divDom = TemplateEngine.renderI18nTpl(template)
    複製程式碼

    在我們介紹的方法中使用 '<%%>' 的來包裹邏輯語塊和變數,此外還有一種更為常見的方式——使用雙大括號 {{}},也叫 mustache 標記。在 Vue, Angular 以及微信小程式的模板語法中都使用了這種標記,一般也叫做插值表示式。下面我們來看一個簡單的 mustache 語法模板引擎的實現。

  4. 模板引擎 mustache.js 的原理

    有了方法3的基礎,我們理解其他的模板引擎原理就稍微容易點了。我們來看一個使用廣泛的輕量級模板 mustache 的原理。

    簡單的例子如下:

     var source = `
      <div class="entry">
        {{#author}}
          <h1>{{name.first}}</h1>
        {{/author}}
      </div>
    `
    var rendered = Mustache.render(source, {
      author: true,
      name: {
        first: 'ana'
      }
    })
    複製程式碼
    • 模板解析

      模板引擎首先要對模板進行解析。mustache 的模板解析大概流程如下:

      字串模板淺析
      1. 正則匹配部分,虛擬碼如下:
      tokens = []
      while (!剩餘要處理的模板字串是否為空) {
        value = scanner.scanUntil(openingTagRe);
        value = 模板字串中第一個 {{ 之前所有的內容
        if (value) {
          處理value,按字元拆分,存入tokens中。例如 <div class="entry">
          tokens = [
            {'text', "<", 0, 1},
            {'text', "d"< 1, 2},
            ...
          ]
        }
        if (!匹配{{) break;
        type = 匹配開始符 {{ 之後的第一個字元,得到型別,如{{#tag}},{{/tag}}, {{tag}}, {{>tag}}等
        value = 匹配結束符之前的內容 }},value中的內容則是 tag
        匹配結束符 }}
        token = [ type, value, start, end ]
        tokens.push(token)
      }
      複製程式碼
      1. 然後通過遍歷 tokens,將連續的 text 型別的陣列合並。

      2. 遍歷 tokens,處理 section 型別(即模板中的 {{#tag}}{{/tag}}{{^tag}}{{/tag}})。section 在模板中是成對兒出現的,需要根據 section 進行巢狀,最後和我們的模板巢狀型別達到一致。

      字串模板淺析
    • 渲染

      解析完模板之後,就是進行渲染了:根據傳入的資料,得到最終的 HTML 字串。渲染的大致過程如下:

      字串模板淺析

      首先將渲染模板的資料存入一個變數 context 中。由於在模板中,變數是字串形式表示的,如 'name.first'。在獲取的時候首先通過 . 來分割得到 'name''first' 然後通過 trueValue = context['name']['first'] 設值。為了提高效能,可以增加一個 cache 將該次獲取到的結果儲存起來,cache['name.first'] = trueValue 以便於下次使用。

      渲染的核心過程就是遍歷 tokens,獲取到型別,和變數 (value) 的正真的值,然後根據型別、值進行渲染,最後將得到的結果拼接起來,即得到了最終的結果。

找到適合的模板引擎

眾多模板引擎中,如何鎖定哪個是我們所需的呢?下面提供幾個可以考慮的方向,希望可以幫助大家來選擇:

  • 功能

    選擇一個工具,最主要的是看它能否滿足我們所需。比如,是否支援變數、邏輯表示式,是否支援子模板,是否會對 HTML 標籤進行轉義等。下面表格僅僅做幾個模板引擎的簡單對比。 字串模板淺析 不同模板引擎除了基本功能外,還提供了自己的特有的功能,比如 artTemplate 支援在模板檔案上打斷點,使用時方便除錯,還有一些輔助方法;handlesbars 還提供一個 runtime 的版本,可以對模板進行預編譯;ejs 邏輯表示式寫法和 JavaScript 相同;等等在此就不一一例舉了。

  • 大小

    對於一個輕量級元件來說,我們會格外在意元件最終的大小。功能豐富的模板引擎便會意味著體積較大,所以在功能和大小上我們需要進行一定的衡量。artTemplate 和 doT 較小,壓縮後僅幾 KB,而 handlebars 就較大,4.0.11 版本壓縮後依然有 70+KB。 字串模板淺析 (注:上圖部分資料來源於 https://cdnjs.com/ 上 min.js 的大小,部分來源於 git 上大小。大小為非 gzip 的大小)

  • 效能

    如果有非常多的頻繁 DOM 更新或者需要渲染的 DOM 數量很多,渲染時,我們就需要關注一下模板引擎的效能了。

最後,以我們的專案為例子,我們要實現的元件是一個輕量級的元件(主要為一個浮層介面,兩個頁面級的全覆蓋介面)同時使用者的互動也很簡單,元件不會進行頻繁重新渲染。但是對元件的整體大小會很在意,而且還有一點特殊的是,在元件的文案我們需要支援多語言。所以最終我們選定了上文介紹的第三種方案。

參考文件