前言
在上一篇文章中講了怎麼來實現一個模板引擎,而寫完上一篇文章的時候,我的模板引擎也確實是造出來了,不過起初的實現還是比較簡陋,就想著做一下效能優化,讓自己的輪子,真正能成為可用的元件,而不僅僅是一個 demo。於是就有了這篇文章。
工具
在做元件優化的時候,總不可能自己覺得那樣寫會提升效能就那樣寫了,很多時候,瞎嘗試可能會帶來反效果,所以我們需要一個工具來驗證自己的優化是否有效,業界最常用的就是 benchmark.js 了,因此,我也是用 benchmark 來做驗證。
除了 benchmark 之外,我們最好還需要一個用來對比的東西,才知道要優化到什麼程度才可以。而我的元件的語法是參考 nunjucks 做的,因此我就理所當然的選擇了 nunjucks 來做對比了。
優化之前
在做優化之前,我先寫了幾個 benchmark 來測一下。
1 2 3 4 5 6 7 8 9 |
Mus#renderExtend x 10,239 ops/sec ±0.93% (88 runs sampled) Nunjucks#renderExtend x 16,468 ops/sec ±2.13% (82 runs sampled) Fastest is Nunjucks#renderExtend Mus#renderNormal x 16,388 ops/sec ±0.98% (86 runs sampled) Nunjucks#renderNormal x 44,464 ops/sec ±1.16% (88 runs sampled) Fastest is Nunjucks#renderNormal Mus#renderSimple x 53,138 ops/sec ±1.07% (89 runs sampled) Nunjucks#renderSimple x 275,825 ops/sec ±1.63% (86 runs sampled) Fastest is Nunjucks#renderSimple |
簡直全方面被吊打。簡單說一下這幾個 benchmark 的測試例子是怎樣的:renderExtend
是測試有 extend 其他模板檔案的測試例子,renderNormal
是渲染一段比較多巢狀的模板,renderSimple
是渲染一段非常簡單,只有變數的模板。
具體可以看 https://github.com/whxaxes/mus/tree/master/benchmark
優化實現
1. 能在 ast 階段做的事,儘量在 ast 階段做好
做模板渲染之前,都會先生成 ast 並且快取起來,從而將一切準備工作準備好,儘量減少渲染時候的計算量,從而提升效能。
在此前的實現中。如果有看過上一篇文章的人應該有印象,在進行變數渲染的時候,會把表示式用方法字串包裝起來,並且建立一個方法例項,但是這個行為是在渲染階段做的。也就是以下這段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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; } } } |
但是其實建立方法例項,是完全可以在構建 ast 階段就準備好的,而渲染階段,就只需要執行已經準備好的 render function 即可。
上面貼的 benchmark 結果其實是已經做了這個優化的了,在做這個優化之前,renderNormal
只有 7000ops/sec 而已。
2. path.resolve
剛開始,上面的 benchmark 中有一點讓我特別疑惑,就是 renderSimple
的差距,只是一個變數渲染而已,怎麼會差那麼多,經過排查,發現程式碼中在讀取模板檔案的時候,每次都會進行 path.resolve
來獲取檔案的絕對路徑。於是立馬對該操作進行了快取。跑分立馬就上去了。
3. for 迴圈的優化。
在此前的實現中是這樣的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
utils.forEach(result, (value, key, index, len) => { const o = { [el.value]: value, loop: { index: index + 1, index0: index, length: len, } }; if (el.index) { o[el.index] = key; } html += this.processAst(el.children, Object.assign({}, scope, o)); }); |
注意到每個 for 迴圈中都會重新做一次物件的淺拷貝,而其實完全沒必要,因為在每個 for 迴圈中需要的物件都是類似的,因此只需要做一個淺拷貝即可。就改成了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
utils.forEach(result, (value, key, index, len) => { loopScope = loopScope || Object.assign({}, scope); loopScope[el.value] = value; loopScope.loop = { index: index + 1, index0: index, length: len, }; if (el.index) { loopScope[el.index] = key; } html += this.processAst(el.children, loopScope); }); |
4. 對錶達式進行預處理
此前的實現中,無論什麼樣的表示式,都一股腦,直接拼成方法來處理,而且此前的都是用 with 來包裹的,而被 with 包裹的程式碼,在 js 引擎解析的時候是沒法做優化的,執行效率特別慢。
因此可以在構建 AST 階段,對錶達式做預處理:
- 如果是簡單的字串,或者數字,就完全都不需要建立 function 了,直接返回即可。
- 如果是簡單的變數輸出,比如
{{ test }}
或者{{ test.value }}
之類的,就不需要用 with 包裹。直接拼成{{ _$o.test }}
,然後再建立 function。 - 剩下的就是有運算子之類的,這種不太好解析,就直接用 with 包裹了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
if (stringRE.test(expr) || numberRE.test(expr)) { el.expression = RegExp.$1 || el.expression; } else if (objectRE.test(expr)) { // simple render, like {{ test }} computedString = `_$o.${utils.nlEscape(expr)}`; } else { // computed render, like {{ test > 1 ? 1 : 2 }} computedString = `(${utils.nlEscape(expr)})`; useWith = true; } // create render function if (computedString) { let funcStr = ` var result = ${computedString}; return (result === undefined || result === null) ? '' : result; `; if (useWith) { funcStr = `with(_$o){ ${funcStr} }`; } el.render = new Function('_$o', '_$f', funcStr); } |
5. filter 的優化
在第4點中,我會對錶達式做一個型別判斷,但是還不夠,按照此前實現的 filter 的邏輯,有 filter 的表示式,會被組裝成_$f('nl2br')(test)
的格式,一旦被組裝後,到第四點中的表示式判斷的時候,就會被認為是比較複雜的型別從而選擇使用 with 來組合渲染方法。所以這個也是可以優化的點。然後就把 filter 的處理部分改成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
let flStr = ''; // _$f('json')(_$f('nl2br')( let frStr = ''; // )) if (matches) { expr = expr.substring(0, matches.index); const filterString = matches[0]; // collect filter string while (filterRE.test(filterString)) { const name = RegExp.$1; const args = RegExp.$2; if (name === 'safe') { el.safe = true; } else { flStr = `_$f('${name}')(${flStr}`; if (args) { frStr = `${frStr}, ${args.substring(1)}`; } else { frStr = `${frStr})`; } } } } |
把 filter 的 function string 分為左半邊以及右半邊來進行收集,在做完型別檢查之後,再把 filter 組合起來。這樣的話,filter 就不影響型別檢查了。
1 2 3 4 5 6 7 8 9 10 |
// create render function if (computedString) { computedString = utils.nlEscape(`${flStr}${computedString}${frStr}`); let funcStr = ` var result = ${computedString}; return (result === undefined || result === null) ? '' : result; `; ... } |
除了以上幾個,還有將所有的 for 迴圈改成了 while 迴圈,經過一系列優化後再次跑 benchmark:
1 2 3 4 5 6 7 8 9 |
Mus#renderExtend x 48,836 ops/sec ±1.04% (88 runs sampled) Nunjucks#renderExtend x 17,738 ops/sec ±2.35% (76 runs sampled) Fastest is Mus#renderExtend Mus#renderNormal x 62,793 ops/sec ±0.93% (91 runs sampled) Nunjucks#renderNormal x 56,013 ops/sec ±1.00% (90 runs sampled) Fastest is Mus#renderNormal Mus#renderSimple x 594,982 ops/sec ±1.38% (89 runs sampled) Nunjucks#renderSimple x 295,682 ops/sec ±1.45% (82 runs sampled) Fastest is Mus#renderSimple |
在已有的測試例子中,分數都超過 nunjucks 。也算是優化成功了。
寫本文更多是記錄一下自己的優化過程。可能沒啥乾貨,有興趣的看看,沒興趣的也請勿噴。
最後再貼上專案地址:https://github.com/whxaxes/mus