JS生態(tài)系統(tǒng)加速一次一庫PostCSS SVGO的重構源碼和性能優(yōu)化探索
引言
長話短說:大多數(shù)流行庫可以通過避免不必要的類型轉換,或避免在函數(shù)內創(chuàng)建函數(shù)來優(yōu)化。
本期《前端翻譯計劃》共享的是“加速 JS 生態(tài)系統(tǒng)系列博客”,包括但不限于:
- PostCSS,SVGO 等等
- 模塊解析
- 使用 eslint
- npm 腳本
- draft-js emoji 插件
- polyfill 暴走
- 桶裝文件崩潰
- Tailwind CSS
本期共享的是第一篇博客 —— 一次一庫的重構源碼和性能優(yōu)化。
雖然前端趨勢似乎是用 Rust 或 Go 等其他語言重寫 JS 構建工具,但目前 JS 筑基的工具可能足夠快。典型前端項目中的構建管道通常由一大坨協(xié)同工作的不同工具組成。但工具的多樣化使得工具維護者難以發(fā)現(xiàn)性能瓶頸,因為它們需要知道自己的工具使用了哪些其他工具。
盡管從純語言的角度來看,JS 肯定比 Rust 或 Go 慢,但目前 JS 筑基的工具還有優(yōu)化空間。當然,JS 速度較慢,但與現(xiàn)在相比,它不至于太慢。JIT 引擎現(xiàn)在就快得要命!
好奇心引導我費時分析常見的 JS 筑基的工具,了解其性能開銷之所在。讓我們從 PostCSS 開始,它是一個人氣爆棚的 CSS 解析器和轉譯器。
在 PostCSS 中優(yōu)化 4.6 秒
有一個神通廣大的插件,名為 postcss-custom-properties
,它在舊版瀏覽器中添加了 CSS 自定義屬性的基本支持。不知為何,它在調試中非常扎眼,昂貴的 4.6 秒歸因于其內部使用的簡單正則。這有點奇葩。
正則表達式目測是疑似搜索特定注釋值,更改插件行為的東東,類似于 eslint
中用于禁用特定 linting 規(guī)則的東東。它們的 README 中沒有提及這一點,但偷瞄源碼證實了此猜想。
創(chuàng)建正則表達式的位置是檢查 CSS 規(guī)則或聲明前面是否有所述注釋的函數(shù)的一部分。
function isBlockIgnored(ruleOrDeclaration) { const rule = ruleOrDeclaration.selector ? ruleOrDeclaration : ruleOrDeclaration.parent return /(!\s*)?postcss-custom-properties:\s*off\b/i.test(rule.toString()) }
rule.toString()
調用瞬間引起了我的注意。如果您要解決性能問題,那么將一種類型轉換為另一種類型的地方通常值得三思而行,因為轉換總會有時間成本。此場景中有趣的是,rule
變量始終持有一個攜帶自定義 toString
方法的 object
。該變量一開始就不是一個字符串,所以我們在這里付出一些序列化開銷,才能測試正則表達式。根據(jù)個人經驗,我知道將正則表達式與一大坨短字符串匹配,比與一小坨長字符串匹配要慢得多。此處有待優(yōu)化!
這段代碼相當麻煩的一點是,無論文件是否有 postcss 注釋,每個輸入文件都有此開銷。知道在長字符串上運行一個正則表達式比在短字符串上運行重復的正則表達式和序列化成本更低,我們可以驗證此函數(shù),避免在知道文件不包含任何 postcss 注釋時,也調用 isBlockIgnored
。
應用修復后,構建時間縮短了 4.6 秒!
優(yōu)化 SVG 壓縮速度
接下來是 SVGO,一個用于壓縮 SVG 文件的庫。它功能強大,并且是具有大量 SVG 圖標的項目的主要工具。CPU 配置文件顯示,壓縮 SVG 花費了 3.1 秒。我們能提速嗎?
在分析數(shù)據(jù)中搜索一下,有一個函數(shù)很突兀:strongRound
。更重要的是,該函數(shù)之后總是會進行一些 GC 清理。
讓我們在 GitHub 上獲取源碼:
/** * 降低路徑數(shù)據(jù)中浮點數(shù)的精度 * 保持指定數(shù)量的小數(shù)。 * 智能四舍五入:比如 2.3491 取舍為 2.35 而不是 2.349 */ function strongRound(data: number[]) { for (var i = data.length; i-- > 0; ) { if (data[i].toFixed(precision) != data[i]) { var rounded = +data[i].toFixed(precision - 1); data[i] = +Math.abs(rounded - data[i]).toFixed(precision + 1) >= error ? +data[i].toFixed(precision) : rounded; } } return data; }
所以這是一個用于壓縮數(shù)字的函數(shù),在任何典型的 SVG 文件中都有一大坨類似的函數(shù)。該函數(shù)接收 numbers
數(shù)組,并預計會改變其元素。讓我們瞄一下其實現(xiàn)中使用的變量類型。通過仔細檢查,我們發(fā)現(xiàn)字符串和數(shù)字之間存在一大坨來回轉換。
function strongRound(data: number[]) { for (var i = data.length; i-- > 0; ) { // string 和 number 比較 -> string 轉換為 number if (data[i].toFixed(precision) != data[i]) { // 基于 number 創(chuàng)建 string,然后立刻轉換為 number var rounded = +data[i].toFixed(precision - 1); data[i] = // number 轉 string,然后直接轉換為 number +Math.abs(rounded - data[i]).toFixed(precision + 1) >= error ? // 這和之前的 if 條件的值相同 // 只是再次轉換為 number +data[i].toFixed(precision) : rounded; } } return data; }
對數(shù)字進行四舍五入的操作似乎只需一點點數(shù)學就歐了,而無需將數(shù)字轉換為字符串。作為一般經驗法則,大部分優(yōu)化都是用數(shù)字來表達事物,主要原因是 CPU 非常擅長處理數(shù)字。通過若干更改,我們可以確保我們始終保持在數(shù)字范圍內,完全避免字符串轉換。
// 類似 Number.prototype.toFixed 的功能 // 但返回值不轉換為 string function toFixed(num, precision) { const pow = 10 ** precision; return Math.round(num * pow) / pow; } // 重寫避免 string 轉換 // 調用我們自己的 toFixed() 函數(shù) function strongRound(data: number[]) { for (let i = data.length; i-- > 0; ) { const fixed = toFixed(data[i], precision); // 我們可以使用嚴格相等比較 if (fixed !== data[i]) { const rounded = toFixed(data[i], precision - 1); data[i] = toFixed(Math.abs(rounded - data[i]), precision + 1) >= error ? fixed // 現(xiàn)在這里我們可以復用之前的值 : rounded; } } return data; }
再次運行分析證實,我們能夠將構建時間加快約 1.4 秒!
短字符串上的正則
在 strongRound
附近的另一個函數(shù)看起來很可疑,因為它花費了約 1 秒(0.9 秒)才完成。
與 stringRound
類似,此函數(shù)用于壓縮數(shù)字,但增加了一個技巧,如果數(shù)字有小數(shù)且小于 1 和大于 -1,我們可以刪除前導零。所以 0.5
可以壓縮為 .5
,-0.2
則是 -.2
。特別是最后一行看起來很有趣。
const stringifyNumber = (number: number, precision: number) => { // 從十進制數(shù)中刪除零整數(shù) return number.toString().replace(/^0\./, ".").replace(/^-0\./, "-."); };
在這里,我們將數(shù)字轉換為字符串,并對其調用正則表達式。該數(shù)字的字符串版本很可能是一個短字符串。而且我們知道一個數(shù)字不能同時是 n > 0 && n < 1
和 n > -1 && n < 0
。連 NaN
無法如此逆天!由此我們可以推斷,要么有且僅有一個正則表達式匹配,要么沒有正則表達式匹配,但絕不會兩者都匹配。至少有一個 .replace
調用總被浪費。
我們可以通過手動區(qū)分這些情況來優(yōu)化它。當且僅當我們正在處理一個具有前導 0
的數(shù)字時,我們才應用替換邏輯。這些數(shù)字檢查比執(zhí)行正則表達式搜索更快。
const stringifyNumber = (number: number, precision: number) => { // 從十進制數(shù)中刪除零整數(shù) const strNum = number.toString(); // 使用簡單數(shù)字檢驗 if (0 < num && num < 1) { return strNum.replace(/^0\./, "."); } else if (-1 < num && num < 0) { return strNum.replace(/^-0\./, "-."); } return strNum; };
我們可以更進一步,完全擺脫正則表達式搜索,因為我們 100% 確定前導 0
位于字符串中,因此可以直接操作字符串。
const stringifyNumber = (number: number, precision: number) => { // 從十進制數(shù)中刪除零整數(shù) const strNum = number.toString(); if (0 < num && num < 1) { // 我們只需要簡單的字符串處理 return strNum.slice(1); } else if (-1 < num && num < 0) { // 我們只需要簡單的字符串處理 return "-" + strNum.slice(2); } return strNum; };
由于 svgo 的代碼庫中已經有一個單獨的函數(shù)可以移除前導 0
,因此我們可以利用它。又節(jié)省了 0.9 秒!
內聯(lián)函數(shù)、內聯(lián)緩存和遞歸
一個名為 monkeys
的函數(shù)只因其名就引起了我的興趣。在調試中,我可以看到它在其內部被多次調用,這是一個有力證據(jù),表明這里正在發(fā)生某種遞歸。它通常用于遍歷樹狀結構。每當使用某種遍歷時,它就有可能在代碼的“熱路徑”中。并非所有情況都如此,但根據(jù)個人經驗,這是一個很好的經驗法則。
function perItem(data, info, plugin, params, reverse) { function monkeys(items) { items.children = items.children.filter(function (item) { // 反向通過 if (reverse && item.children) { monkeys(item) } // 主要過濾 let kept = true if (plugin.active) { kept = plugin.fn(item, params, info) !== false } // 直接通過 if (!reverse && item.children) { monkeys(item) } return kept }) return items } return monkeys(data) }
這里我們有一個函數(shù),它在其體內創(chuàng)建另一個函數(shù),該函數(shù)再次調用內部函數(shù)。如果我不得不盲猜一手,我會假設這樣做是為了節(jié)省一些敲鍵次數(shù),而不必再次傳遞所有參數(shù)。問題是,當外部函數(shù)被頻繁調用時,在其他函數(shù)內部創(chuàng)建的函數(shù)很難優(yōu)化。
function perItem(items, info, plugin, params, reverse) { items.children = items.children.filter(function (item) { // 反向通過 if (reverse && item.children) { perItem(item, info, plugin, params, reverse) } // 主要過濾 let kept = true if (plugin.active) { kept = plugin.fn(item, params, info) !== false } // 直接通過 if (!reverse && item.children) { perItem(item, info, plugin, params, reverse) } return kept }) return items }
我們可以通過始終顯式傳遞所有參數(shù),而不是像以前一樣通過閉包捕獲它們,從而擺脫內部函數(shù)。此變更的影響相當小,但總共又節(jié)省了 0.8 秒。
小心 for..of 的轉譯
@vanilla-extract/css
中出現(xiàn)了幾乎相同的問題。已發(fā)布的軟件包附帶以下代碼:
class ConditionalRuleset { getSortedRuleset() { //... var _loop = function _loop(query, dependents) { doSomething() } for (var [query, dependents] of this.precedenceLookup.entries()) { _loop(query, dependents) } //... } }
這個函數(shù)的有趣之處在于,它沒有出現(xiàn)在原始源碼中。在原始源碼中,它是一個標準的 for...of
循環(huán)。
class ConditionalRuleset { getSortedRuleset() { //... for (var [query, dependents] of this.precedenceLookup.entries()) { doSomething() } //... } }
我無法在 babel
或 typescript
的 repl 中復現(xiàn)此問題,但我可以確定它是由它們的構建管道引入的。鑒于這似乎是構建工具的共享抽象,我假設還有更多項目受到此影響。因此,現(xiàn)在我只是在 node_modules
內部本地修補了該包,并且很高興看到,這縮短了 0.9s 的構建時間。
semver 的奇怪案例
對于此例子,我不確定我是否配置錯誤。本質上,該配置文件表明,每當轉譯文件時,整個 babel 配置總是會被重新讀取。
在屏幕截圖中很難看出,但占用大量時間的函數(shù)之一是 semver
包中的代碼,該包與 npm 的 cli 中使用的包相同。我花了一段時間才明白:它是用于解析 @babel/preset-env
的 browserlist
目標。盡管瀏覽器列表設置可能看起來很短,但最終它們擴展到大約 290 個單獨的目標。
僅這一點還不足以引起關注,但在使用驗證函數(shù)時很容易忽略分配成本。它在 babel 的代碼庫中有點分散,但本質上瀏覽器目標的版本被轉換為 semver
字符串 "10" -> "10.0.0"
,然后進行驗證。其中一些版本號已經與 semver
格式匹配。這些版本(有時是版本范圍)會相互比較,直到找到需要轉譯的最低通用功能集。此方案問題不大。
這里出現(xiàn)了性能問題,因為 semver
版本存儲為 string
,而不是解析的 semver
數(shù)據(jù)類型。這意味著,每次調用 semver.valid('1.2.3')
都會創(chuàng)建一個新的 semver
實例,并立即銷毀它。使用字符串 semver.lt('1.2.3', '9.8.7')
比較 semver
版本時也是如此。這就是為什么我們在調試中看到如此明顯的 semver
。
完美謝幕
我假設您會在流行庫中發(fā)現(xiàn)更多這些性能細節(jié)瓶頸。今天我們主要關注若干構建工具,但 UI 組件或其他庫通常也有同樣容易出現(xiàn)的性能問題。
免責聲明
本文屬于是語冰的直男翻譯了屬于是,略有刪改,僅供粉絲參考,英文原味版請傳送 Speeding up the JavaScript ecosystem - one library at a time
以上就是JS生態(tài)系統(tǒng)加速一次一庫PostCSS SVGO的重構源碼和性能優(yōu)化探索的詳細內容,更多關于JS PostCSS SVGO的資料請關注腳本之家其它相關文章!
相關文章
thinkphp標簽實現(xiàn)bootsrtap輪播carousel實例代碼
這篇文章給大家介紹thinkphp標簽實現(xiàn)bootsrtap輪播carousel實例代碼,非常不錯,具有參考借鑒價值,需要的的朋友參考下2017-02-02JavaScript函數(shù)式編程(Functional Programming)箭頭函數(shù)(Arrow functions)
這篇文章主要介紹了JavaScript函數(shù)式編程(Functional Programming)箭頭函數(shù)(Arrow functions)用法,結合實例形式分析了javascript函數(shù)式編程中箭頭函數(shù)相關概念、原理、用法及操作注意事項,需要的朋友可以參考下2019-05-05JavaScript判斷瀏覽器對CSS3屬性是否支持的多種方法
其實在使用css3的一些屬性時,為了兼顧低端瀏覽器對CSS3的不友好性,往往需要知道某些瀏覽器是否支持要使用的CSS3屬性,以此來做向下適配。比如常見的CSS3動畫就很有必要檢測瀏覽器是否支持。下面本文就分享了幾種方法,有需要的朋友們可以參考借鑒。2016-11-11