[Part1]JavaScript生態加速攻略:一次一個庫

前端小智發表於2023-05-16
本文首發於微信公眾號:大遷世界, 我的微信:qq449245884,我會第一時間和你分享前端行業趨勢,學習途徑等等。
更多開源作品請看 GitHub https://github.com/qq449245884/xiaozhi ,包含一線大廠面試完整考點、資料以及我的系列文章。

快來免費體驗ChatGpt plus版本的,我們出的錢
體驗地址:https://chat.waixingyun.cn
可以加入網站底部技術群,一起找bug.

該系列是由@marvinhagemeist撰寫的,旨在透過一系列文章加速JavaScript生態系統。這些文章提供了有關如何加速JavaScript生態系統的有用資訊。文章涵蓋了各種主題,包括PostCSS、SVGO、模組解析、eslint和npm指令碼。

今天我們來看第一部分。在第一部分文章[1]中,作者分享了許多流行庫的加速技巧。作者建議避免不必要的型別轉換,避免在函式內部建立函式等。

儘管趨勢似乎是將每個JavaScript構建工具重寫為其他語言,如Rust或Go,但當前基於JavaScript的工具可以更快。典型前端專案中的構建流水線通常由許多不同的工具組成。但是,工具的多樣化使得工具維護者更難以發現效能問題,因為他們需要知道自己的工具通常與哪些工具一起使用。

從純語言角度來看,JavaScript肯定比Rust或Go慢,但當前的JavaScript工具可以得到相當大的改進。當然,JavaScript比較慢,但與今天相比,它不應該那麼慢。JIT引擎現在非常快!

在 PostCSS 中節省了 4.6 秒

有一個非常有用的外掛叫做 postcss-custom-properties,它在舊版瀏覽器中增加了對 CSS 自定義屬性的基本支援。不知何故,它在跟蹤中非常突出,被歸因於它內部使用的單個正規表示式,導致了高達 4.6 秒的成本。這看起來很奇怪。

image.png

正規表示式看起來很像搜尋特定註釋值以更改外掛行為的內容,類似於 eslint 中用於禁用特定 linting 規則的內容。雖然在 README 中沒有提到,但是檢視原始碼確認了這一假設。

建立正規表示式的位置是函式的一部分,該函式檢查CSS規則或宣告是否由該註釋前置。

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 。它從未是一個字串,因此我們知道我們總是要支付一定的序列化成本來測試正規表示式。從經驗上講,我知道將正規表示式與許多短字串匹配比將其與少量長字串匹配要慢得多。這是一個等待最佳化的主要候選項!

這段程式碼令人不安的一點是,每個輸入檔案都必須支付這個成本,無論它是否有 postcss 註釋。我們知道,在長字串上執行一個正規表示式比在短字串上重複執行正規表示式和序列化成本更便宜,因此,如果我們知道檔案不包含任何 postcss 註釋,我們可以保護此函式,避免甚至不必呼叫 isBlockIgnored

應用了修復後,構建時間驚人地減少了4.6秒!

最佳化SVG壓縮速度

接下來是 SVGO,一個用於壓縮 SVG 檔案的庫。它非常棒,是擁有大量 SVG 圖示專案的基石。CPU 分析顯示,花費了 3.1 秒來壓縮 SVG 檔案。我們能加快這個過程嗎?

在分析資料時,有一個函式引起了注意: strongRound 。更重要的是,該函式總是緊隨著一小段垃圾回收清理(請參見小紅框)。

image.png

檢視原始碼

/**
 * Decrease accuracy of floating-point numbers
 * in path data keeping a specified number of decimals.
 * Smart rounds values like 2.3491 to 2.35 instead of 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;
}

這是一個用於壓縮數字的函式,在任何典型的SVG檔案中都有很多數字。該函式接收一個 numbers 陣列,並期望改變其條目。讓我們看一下其實現中使用的變數型別。經過仔細檢查,我們注意到在字串和數字之間來回轉換了很多次。

function strongRound(data: number[]) {
    for (var i = data.length; i-- > 0; ) {
        // Comparison between string and number -> string is cast to number
        if (data[i].toFixed(precision) != data[i]) {
            // Creating a string from a number that's casted immediately
            // back to a number
            var rounded = +data[i].toFixed(precision - 1);
            data[i] =
                // Another number that is casted to a string and directly back
                // to a number again
                +Math.abs(rounded - data[i]).toFixed(precision + 1) >= error
                    ? // This is the same value as in the if-condition before,
                      // just casted to a number again
                      +data[i].toFixed(precision)
                    : rounded;
        }
    }
    return data;
}

四捨五入數字似乎是一件只需要進行一點點數學運算就能完成的事情,而不必將數字轉換為字串。通常情況下,最佳化的關鍵在於用數字表達事物,主要原因是CPU在處理數字方面非常出色。透過一些微小的改變,我們可以確保始終處於數字領域,從而完全避免字串轉換。

// Does the same as `Number.prototype.toFixed` but without casting
// the return value to a string.
function toFixed(num, precision) {
    const pow = 10 ** precision;
    return Math.round(num * pow) / pow;
}

// Rewritten to get rid of all the string casting and call our own
// toFixed() function instead.
function strongRound(data: number[]) {
    for (let i = data.length; i-- > 0; ) {
        const fixed = toFixed(data[i], precision);
        // Look ma, we can now use a strict equality comparison!
        if (fixed !== data[i]) {
            const rounded = toFixed(data[i], precision - 1);
            data[i] =
                toFixed(Math.abs(rounded - data[i]), precision + 1) >= error
                    ? fixed // We can now reuse the earlier value here
                    : rounded;
        }
    }
    return data;
}

再次執行分析,確認我們能夠將構建時間加速約1.4秒!

短字串上的正規表示式(第二部分)

strongRound 的緊密鄰近,另一個功能看起來很可疑,因為它需要近乎一秒鐘(0.9秒)才能完成。

image.png

類似於 stringRound ,此函式也可以壓縮數字,但有一個額外的技巧,即如果數字有小數並且小於1且大於-1,則可以刪除前導零。因此, 0.5 可以壓縮為 .5-0.2 分別可以壓縮為 -.2 。特別是最後一行看起來很有趣。

const stringifyNumber = (number: number, precision: number) => {
    // ...snip

    // remove zero whole from decimal number
    return number.toString().replace(/^0\./, ".").replace(/^-0\./, "-.");
};

在這裡,我們將一個數字轉換為字串並對其呼叫正規表示式。數字的字串版本很可能是一個短字串。我們知道一個數字不能同時是 n > 0 && n < 1n > -1 && n < 0 。甚至 NaN 也沒有這個能力!從中我們可以推斷出,只有一個正規表示式匹配或者兩個都不匹配,但永遠不會同時匹配。至少 .replace 中的一個呼叫總是浪費的。

我們可以透過手動區分這些情況來進行最佳化。只有當我們知道我們正在處理一個具有前導 0 的數字時,我們才應用我們的替換邏輯。這些數字檢查比進行正規表示式搜尋更快。

const stringifyNumber = (number: number, precision: number) => {
    // ...snip

    // remove zero whole from decimal number
    const strNum = number.toString();
    // Use simple number checks
    if (0 < num && num < 1) {
        return strNum.replace(/^0\./, ".");
    } else if (-1 < num && num < 0) {
        return strNum.replace(/^-0\./, "-.");
    }
    return strNum;
};

我們可以更進一步,完全擺脫正規表示式搜尋,因為我們可以百分之百確定字串中前導 0 的位置,因此可以直接操作字串。

const stringifyNumber = (number: number, precision: number) => {
    // ...snip

    // remove zero whole from decimal number
    const strNum = number.toString();
    if (0 < num && num < 1) {
        // Plain string processing is all we need
        return strNum.slice(1);
    } else if (-1 < num && num < 0) {
        // Plain string processing is all we need
        return "-" + strNum.slice(2);
    }
    return strNum;
};

由於 svgo 程式碼庫中已經有一個單獨的函式來修剪前導 0 ,我們可以利用它來實現。又節省了 0.9 秒!

行內函數、內聯快取和遞迴

一個名為 monkeys 的函式僅憑其名稱就引起了我的興趣。在跟蹤中,我可以看到它在自身內部被多次呼叫,這是某種遞迴發生的強烈指示。它經常用於遍歷類似樹形結構的資料。每當使用某種遍歷時,就有可能它在程式碼的“熱”路徑中。雖然這並非所有情況都成立,但在我的經驗中,這是一個不錯的經驗法則。

function perItem(data, info, plugin, params, reverse) {
    function monkeys(items) {
        items.children = items.children.filter(function (item) {
            // reverse pass
            if (reverse && item.children) {
                monkeys(item);
            }
            // main filter
            let kept = true;
            if (plugin.active) {
                kept = plugin.fn(item, params, info) !== false;
            }
            // direct pass
            if (!reverse && item.children) {
                monkeys(item);
            }
            return kept;
        });
        return items;
    }
    return monkeys(data);
}

這裡我們有一個函式,它在其主體內建立另一個函式,該函式再次呼叫內部函式。如果我必須猜測,我會認為這是為了節省一些按鍵次數而在此處完成的,而不必再次傳遞所有引數。問題是,當外部函式頻繁呼叫時,內部函式中建立的函式很難進行最佳化。

function perItem(items, info, plugin, params, reverse) {
    items.children = items.children.filter(function (item) {
        // reverse pass
        if (reverse && item.children) {
            perItem(item, info, plugin, params, reverse);
        }
        // main filter
        let kept = true;
        if (plugin.active) {
            kept = plugin.fn(item, params, info) !== false;
        }
        // direct pass
        if (!reverse && item.children) {
            perItem(item, info, plugin, params, reverse);
        }
        return kept;
    });
    return items;
}

我們可以透過始終明確傳遞所有引數而不是像以前那樣透過閉包捕獲它們來擺脫內部函式。這種變化的影響相當小,但總共節省了另外0.8秒。

幸運的是,這已經在新的主要 3.0.0 版本中得到解決,但需要一些時間才能使生態系統切換到新版本。

當心 for...of 轉譯

一個幾乎相同的問題發生在 @vanilla-extract/css 中。釋出的軟體包附帶以下程式碼片段:

class ConditionalRuleset {
    getSortedRuleset() {
        //...
        var _loop = function _loop(query, dependents) {
            doSomething();
        };

        for (var [query, dependents] of this.precedenceLookup.entries()) {
            _loop(query, dependents);
        }
        //...
    }
}

這個函式有趣的地方在於它在原始原始碼中並不存在。在原始原始碼中,它是一個標準的 for...of 迴圈。

class ConditionalRuleset {
    getSortedRuleset() {
        //...
        for (var [query, dependents] of this.precedenceLookup.entries()) {
            doSomething();
        }
        //...
    }
}

我無法在 Babel 或 TypeScript 的 REPL 中複製此問題,但我可以確認它是由它們的構建流程引入的。鑑於它似乎是構建工具上的共享抽象,我會假設還有其他幾個專案受到了影響。因此,現在我只是在 node_modules 中本地修補了該軟體包,並很高興看到這進一步提高了構建時間 0.9s

語義化版本號、案例

對於這個問題,我不確定是否配置有誤。基本上,該配置檔案顯示每當它轉換檔案時,整個 Babel 配置都會被重新讀取。

image.png

在截圖中有點難看清楚,但其中一個佔用大量時間的功能是來自 semver 包的程式碼,這個包也是 npm 的 cli 中使用的包。嗯?semver 與 babel 有什麼關係?直到一段時間後我才明白:它是用於解析 @babel/preset-env 的 browserlist 目標的。雖然 browserlist 設定可能看起來相當簡短,但最終它們被擴充套件為大約 290 個單獨的目標。

僅僅這些還不足以引起關注,但在使用驗證函式時很容易忽略分配成本。這在babel的程式碼庫中有點分散,但基本上瀏覽器目標的版本被轉換為semver字串 "10" -> "10.0.0" ,然後進行驗證。其中一些版本號已經匹配了semver格式。這些版本號和有時版本範圍會相互比較,直到找到我們需要轉碼的最低公共功能集。這種方法沒有任何問題。

效能問題在這裡出現,因為 semver 版本被儲存為 string 而不是解析後的 semver 資料型別。這意味著每次呼叫 semver.valid('1.2.3') 都會建立一個新的 semver 例項並立即銷燬它。當使用字串比較 semver 版本時,情況也是如此: semver.lt('1.2.3', '9.8.7') 。這就是為什麼我們在跟蹤中經常看到 semver 的原因。

透過在 node_modules 中再次進行本地修補,我能夠將構建時間再次縮短 4.7s

程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug

交流

有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

相關文章