字符串模板淺析

做者:崔靜javascript

前言

雖然如今有各類前端框架來提升開發效率,可是在某些狀況下,原生 JavaScript 實現的組件也是不可或缺的。例如在咱們的項目中,須要給業務方提供一個通用的支付組件,可是業務方使用的技術棧多是 VueReact 等,甚至是原生的 JavaScript。那麼爲了實現通用性,同時保證組件的可維護性,實現一個原生 JavaScript 的組件也就顯得頗有必要了。css

下面左圖爲咱們的 Panel 組件的大概樣子,右圖則爲咱們項目的大概目錄結構:html

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

panel.html 就是咱們常說的「字符串模板」,而對其進行解析變成可執行的 JavaScript 代碼的過程則是「模板引擎」所作的事情。目前有不少的模板引擎供選擇,且通常都提供了豐富的功能。可是在不少狀況下,咱們可能只是處理一個簡單的模板,沒有太複雜的邏輯,那麼簡單的字符串模板已足夠咱們使用。vue

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

主要分爲如下幾類:java

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

    最簡單粗暴的方式,直接使用字符串進行正則替換。可是沒法處理循環語句和 if / else 判斷這些。git

    a. 定義一個字符串變量的寫法,好比用 <%%> 包裹github

    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 數量不少,渲染時,咱們就須要關注一下模板引擎的性能了。

最後,以咱們的項目爲例子,咱們要實現的組件是一個輕量級的組件(主要爲一個浮層界面,兩個頁面級的全覆蓋界面)同時用戶的交互也很簡單,組件不會進行頻繁從新渲染。可是對組件的總體大小會很在乎,並且還有一點特殊的是,在組件的文案咱們須要支持多語言。因此最終咱們選定了上文介紹的第三種方案。

參考文檔
相關文章
相關標籤/搜索