說說如何用 JavaScript 實現一個模板引擎
前言
不知不覺就很長時間沒造過什麼輪子了,以前一直想自己實現一個模板引擎,只是沒付諸於行動,最近終於在業餘時間裡抽了點時間寫了一下。因為我們的專案大部分用的是 swig 或者 nunjucks ,於是就想實現一個類似的模板引擎。
至於為什麼要做這麼一個東西?基本上每一個做前端的人都會有自己的一個框架夢,而一個成熟的前端框架,模板編譯能力就是其中很重要的一環,雖然目前市面上的大部分框架 vue、angular 這些都是屬於 dom base 的,而 swig nunjucks ejs這些都是屬於 string base 的,但是其實實現起來都是差不多的。不外乎都是 Template
=parse=> Ast
=render=>String
。
再者,做一個模板引擎,個人感覺還是對自身的編碼能力的提升還是很有幫助的,在效能優化、正則、字元解析上尤為明顯。在日後的業務需求中,如果有一些需要解析字串相關的需求,也會更得心應手。
功能分析
一個模板引擎,在我看來,就是由兩塊核心功能組成,一個是用來將模板語言解析為 ast(抽象語法樹)。還有一個就是將 ast 再編譯成 html。
先說明一下 ast 是什麼,已知的可以忽略。
抽象語法樹(abstract syntax tree或者縮寫為AST),或者語法樹(syntax tree),是原始碼的抽象語法結構的樹狀表現形式,這裡特指程式語言的原始碼。樹上的每個節點都表示原始碼中的一種結構。之所以說語法是“抽象”的,是因為這裡的語法並不會表示出真實語法中出現的每個細節。比如,巢狀括號被隱含在樹的結構中,並沒有以節點的形式呈現;而類似於if-condition-then這樣的條件跳轉語句,可以使用帶有兩個分支的節點來表示。
在實現具體邏輯之前,先決定要實現哪幾種 tag 的功能,在我看來,for
,if else
,set
,raw
還有就是基本的變數輸出,有了這幾種,模板引擎基本上也就夠用了。除了 tag,還有就是 filter 功能也是必須的。
構建 AST
我們需要把模板語言解析成一個又一個的語法節點,比如下面這段模板語言:
<div> {% if test > 1 %} {{ test }} {% endif %} </div>
很明顯,div 將會被解析為一個文字節點,然後接著是一個塊級節點 if ,然後 if 節點下又有一個變數子節點,再之後有是一個 的文字節點,用 json 來表示這個模板解析成的 ast 就可以表示為:
[ { type: 1, text: '<div>' }, { type: 2, tag: 'if', item: 'test > 1', children: [{ type: 3, item: 'test' }] }, { type: 1, text: '</div>' } ]
基本上就分成三種型別了,一種是普通文字節點,一種是塊級節點,一種是變數節點。那麼實現的話,就只需要找到各個節點的文字,並且抽象成物件即可。一般來說找節點都是根據模板語法來找,比如上面的塊級節點以及變數節點的開始肯定是{%
或者{{
,那麼就可以從這兩個關鍵字元下手:
... const matches = str.match(/{{|{%/); const isBlock = matches[0] === '{%'; const endIndex = matches.index; ...
通過上面一段程式碼,就可以獲取到處於文字最前面的{{
或者{%
位置了。
既然獲取到了第一個非文字類節點的位置,那麼該節點位置以前的,就都是文字節點了,因此就已經可以得到第一個節點,也就是上面的<div>
了。
獲取到 div 文字節點後,我們也可以知道獲取到的第一個關鍵字元是{%
,也就是上面的endIndex
是我們要的索引,記得要更新剩餘的字元,直接通過 slice 更新即可:
// 2 是 {% 的長度 str = str.slice(endIndex + 2);
而此時我們就可以知道匹配到的當前關鍵字元是{%
,那麼他的閉合處就肯定是%}
,因此就可以再通過
const expression = str.slice(0, str.indexOf('%}'))
獲取到 if test > 1
這個字串了。然後我們再通過正則/^if\s+([\s\S]+)$/
匹配,就可以知道這個字串是 if 的標籤,同時可以獲得test > 1
這一個捕獲組,然後就可以建立我們的第二個節點,if 的塊級節點了。
因為 if 是個塊級節點,那麼繼續往下匹配的時候,在遇到 {% endif %}
之前的所有節點,都是屬於 if 節點的子節點,所以我們在建立節點時要給它一個children
陣列屬性,用來儲存子節點。
緊接著再重複上面的操作,獲取下一個{%
以及{{
的位置,跟上面的邏輯差不多,獲取到{{
的位置後再判斷}}
的位置,就可以建立第三個節點,test 的變數節點,並且 push 到 if 節點的子節點列表中。
建立完變數節點後繼續重複上述操作,就能夠獲取到{% endif %}
這個閉合節點,當遇到該節點之後的節點,就不能儲存到 if 節點的子節點列表中了。緊接著就又是一個文字節點。
相對比較完整的實現如下:
const root = []; let parent; function parse(str){ const matches = str.match(/{{|{%/); const isBlock = matches[0] === '{%'; const endIndex = matches.index; const chars = str.slice(0, matches ? endIndex : str.length); if(chars.length) { ...建立文字節點 } if(!matches) return; str = str.slice(endIndex + 2); const leftStart = matches[0]; const rightEnd = isBlock ? '%}' : '}}'; const rightEndIndex = str.indexOf(rightEnd); const expression = str.slice(0, rightEndIndex) if(isBlock) { ...建立塊級節點 el parent = el; } else { ...建立變數節點 el } (parent ? parent.children : root).push(el); parse(str.slice(rightEndIndex + 2)); }
當然,具體實現起來還是有其他東西要考慮的,比如一個文字是{% {{ test }}
,就要考慮到{%的干擾等。還有比如 else 還有 elseif 節點的處理,這兩個是需要關聯到 if 標籤上的,這個也是需要特殊處理的。不過大概邏輯基本上就是以上。
組合 html
建立好 ast 後,要渲染 html 的時候,就只需要遍歷語法樹,根據節點型別做出不同的處理即可。
比如,如果是文字節點,就直接html += el.text
即可。如果是if
節點,則判斷表示式,比如上面的test > 1
,有兩種辦法可以實現表示式的計算,一種就是eval
,還有一種就是new Function
了,eval 會有安全性問題,因此就不考慮了,而是使用new Function
的方式來實現。變數節點的計算也一樣,用new Function
來實現。
封裝後具體實現如下:
function computedExpression(obj, expression) { const methodBody = `return (${expression})`; const funcString = obj ? `with(__obj__){ ${methodBody} }` : methodBody; const func = new Function('__obj__', funcString); try { let result = func(obj); return (result === undefined || result === null) ? '' : result; } catch (e) { return ''; } }
使用 with ,可以讓在 function 中執行的語句關聯物件,比如
with({ a: '123' }) { console.log(a); // 123 }
雖然 with 不推薦在編寫程式碼的時候使用,因為會讓 js 引擎無法對程式碼進行優化,但是卻很適合用來做這種模板編譯,會方便很多。包括 vue 中的 render function 也是用 with 包裹起來的。不過 nunjucks 是沒有用 with 的,它是自己來解析表示式的,因此在 nunjucks 的模板語法中,需要遵循它的規範,比如最簡單的條件表示式,如果用 with 的話,直接寫{{ test ? 'good' : 'bad' }}
,但是在 nunjucks 中卻要寫成�{{ 'good' if test else 'bad' }}
。
anyway,各有各的好吧。
實現多級作用域
在將 ast 轉換成 html 的時候,有一個很常見的場景就是多級作用域,比如在一個 for 迴圈中再巢狀一個 for 迴圈。而如何在做這個作用域分割,其實也是很簡單,就是通過遞迴。
比如我的對一個 ast 樹的處理方法命名為:processAst(ast, scope)
,再比如最初的 scope 是
{ list: [ { subs: [1, 2, 3] }, { subs: [4, 5, 6] } ] }
那麼 processAst 就可以這麼實現:
function processAst(ast, scope) { ... if(ast.for) { const list = scope[ast.item]; // ast.item 自然就是列表的 key ,比如上面的 list list.forEach(item => { processAst(ast.children, Object.assign({}, scope, { [ast.key]: item, // ast.key 則是 for key in list 中的 key })) }) } ... }
就簡單通過一個遞迴,就可以把作用域一直傳遞下去了。
Filter 功能實現
實現上面功能後,元件就已經具備基本的模板渲染能力,不過在用模板引擎的時候,還有一個很常用的功能就是 filter 。一般來說 filter 的使用方式都是這這樣 {{ test | filter1 | filter2 }}
,這個的實現也說一下,這一塊的實現我參考了 vue 的解析的方式,還是蠻有意思的。
還是舉個例子:
{{ test | filter1 | filter2 }}
在構建 AST 的時候,就可以獲取到其中的test | filter1 | filter2
,然後我們可以很簡單的就獲取到 filter1 和 filter2 這兩個字串。起初我的實現方式,是把這些 filter 字串扔進 ast 節點的 filters 陣列中,在渲染的時候再一個一個拿出來處理。
不過後來又覺得為了效能考慮,能夠在 AST 階段就能做完的工作就不要放到渲染階段了。因此就改成 vue 的方法組合方式。也就是把上面字串變成:
_$f('filter2', _$f('filter1', test))
預先用個方法包裹起來,在渲染的時候,就不需要再通過迴圈去獲取 filter 並且執行了。具體實現如下:
const filterRE = /(?:\|\s*\w+\s*)+$/; const filterSplitRE = /\s*\|\s*/; function processFilter(expr, escape) { let result = expr; const matches = expr.match(filterRE); if (matches) { const arr = matches[0].trim().split(filterSplitRE); result = expr.slice(0, matches.index); // add filter method wrapping utils.forEach(arr, name => { if (!name) { return; } // do not escape if has safe filter if (name === 'safe') { escape = false; return; } result = `_$f('${name}', ${result})`; }); } return escape ? `_$f('escape', ${result})` : result; }
上面還有一個就是對 safe 的處理,如果有 safe 這個 filter ,就不做 escape 了。完成這個之後,有 filter 的 variable 都會變成_$f('filter2', _$f('filter1', test))
這種形式了。因此,此前的 computedExpression 方法也要做一些改造了。
function processFilter(filterName, str) { const filter = filters[filterName] || globalFilters[filterName]; if (!filter) { throw new Error(`unknown filter ${filterName}`); } return filter(str); } function computedExpression(obj, expression) { const methodBody = `return (${expression})`; const funcString = obj ? `with(_$o){ ${methodBody} }` : methodBody; const func = new Function('_$o', '_$f', funcString); try { const result = func(obj, processFilter); return (result === undefined || result === null) ? '' : result; } catch (e) { // only catch the not defined error if (e.message.indexOf('is not defined') >= 0) { return ''; } else { throw e; } } }
其實也是很簡單,就是在 new Function 的時候,多傳入一個獲取 filter 的方法即可,然後有 filter 的 variable 就能被正常識別解析了。
至此,AST 構建、AST 到 html 的轉換、多級作用域以及 Filter 的實現,都已經基本講解完成。
貼一下自己實現的一個模板引擎輪子:https://github.com/whxaxes/mus
算是實現了大部分模板引擎該有的功能,歡迎各路豪傑 star 。
相關文章
- 說說如何實現一個模板引擎
- 實現一個簡單的模板引擎
- underscore 系列之實現一個模板引擎(上)
- underscore 系列之實現一個模板引擎(下)
- 如何實現一個模板引擎二:優化優化
- 20行程式碼實現JavaScript模板引擎行程JavaScript
- 如何實現一個基於 DOM 的模板引擎
- 如何用JavaScript手動實現一個棧JavaScript
- 編寫一個簡單的JavaScript模板引擎JavaScript
- 實現一個程式碼自動生成(一):模板引擎Freemarker
- 如何用不到 30 行程式碼寫一個模板引擎?行程
- 如何用 JavaScript 實現一個陣列惰性求值庫JavaScript陣列
- 面試官:說說執行 JavaScript 的 V8 引擎做了什麼?面試JavaScript
- JavaScript模板引擎的應用場景及實現原理JavaScript
- JavaScript 模板引擎概述JavaScript
- 說一說javascript的非同步程式設計JavaScript非同步程式設計
- 如何理解CDN?說說實現原理?
- 手擼 JavaScript 模板引擎JavaScript
- JavaScript模板引擎綜述JavaScript
- 不用正規表示式,用javascript從零寫一個模板引擎(一)JavaScript
- 美團一面:說說synchronized的實現原理?問麻了。。。。synchronized
- 閱讀優秀的JAVA模板引擎Beetl的使用說明有感Java
- 說說JavaScript中的事件模型JavaScript事件模型
- 說說VNode節點(Vue.js實現)Vue.js
- 如何用Go快速實現規則引擎Go
- 前端模板引擎的實現總結前端
- Vue原始碼閱讀一:說說vue.nextTick實現Vue原始碼
- 說一下HashSet的實現原理?
- 說說JavaScript的型別轉換JavaScript型別
- 面試官:說說反射的底層實現原理?面試反射
- KOA的簡易模板引擎實現方式
- javascript簡單模板引擎介紹JavaScript
- JavaScript 模板引擎 Velocity.jsJavaScriptJS
- NodeJS 說說“重寫” 自定義stream 的實現NodeJS
- 說說如何基於 Vue.js 實現表格元件Vue.js元件
- 說說Android動態換膚實現原理吧Android
- 說說你對單例模式的理解?如何實現?單例模式
- 論如何用Vue實現一個彈窗-一個簡單的元件實現Vue元件