作者:崔靜
前言
雖然現在有各種前端框架來提高開發效率,但是在某些情況下,原生 JavaScript 實現的元件也是不可或缺的。例如在我們的專案中,需要給業務方提供一個通用的支付元件,但是業務方使用的技術棧可能是 Vue、React 等,甚至是原生的 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 程式碼的過程則是“模板引擎”所做的事情。目前有很多的模板引擎供選擇,且一般都提供了豐富的功能。但是在很多情況下,我們可能只是處理一個簡單的模板,沒有太複雜的邏輯,那麼簡單的字串模板已足夠我們使用。
幾種字串模板方式和簡單原理
主要分為以下幾類:
-
簡單粗暴——正則替換
最簡單粗暴的方式,直接使用字串進行正則替換。但是無法處理迴圈語句和 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' }) 複製程式碼
-
簡單優雅——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 判斷、迴圈語句無法處理。 -
簡易模板引擎
很多情況下,我們渲染 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 程式碼,經歷了幾個轉換:
<%%>
中如果是邏輯語句(if/else/for/switch/case/break),那麼中間的內容直接轉成 JavaScript 程式碼。通過正規表示式/(^( )?(var|if|for|else|switch|case|break|;))(.*)?/g
將要處理的邏輯表示式過濾出來。<% xxx %>
中如果是非邏輯語句,那麼我們替換成html.push(xxx)
的語句<%%>
之外的內容,我們替換成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 語法模板引擎的實現。 -
模板引擎 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 的模板解析大概流程如下:
- 正則匹配部分,虛擬碼如下:
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) } 複製程式碼
-
然後通過遍歷
tokens
,將連續的text
型別的陣列合並。 -
遍歷
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 數量很多,渲染時,我們就需要關注一下模板引擎的效能了。
最後,以我們的專案為例子,我們要實現的元件是一個輕量級的元件(主要為一個浮層介面,兩個頁面級的全覆蓋介面)同時使用者的互動也很簡單,元件不會進行頻繁重新渲染。但是對元件的整體大小會很在意,而且還有一點特殊的是,在元件的文案我們需要支援多語言。所以最終我們選定了上文介紹的第三種方案。