try catch引起的性能優化深度思考

image

關鍵代碼拆解成以下圖所示(無關部分已省略):node

demo

起初我認爲多是這個 getRowDataItemNumberFormat 函數裏面某些方法執行太慢,從 formatData.replaceunescape(已廢棄,官方建議使用 decodeURI 或者 decodeURIComponent 替代) 方法都懷疑了一遍,發現這些方法都不是該函數運行慢的緣由。爲了深究緣由,我給 style.formatData 傳入了不一樣的值,發現這個函數的運行效率出現不一樣的表現。開始有點疑惑爲何 style.formatData 的值致使這個函數的運行效率差異如此之大。git

進一步最終定位發現若是 style.formatData 爲 undefined 的時候,效率驟降,若是 style.formatData 爲合法的字符串的時候,效率是正常值。我開始意識到這個問題的緣由在那裏了,把目光轉向了 try catch 代碼塊,這是一個很可疑的地方,在很早以前曾經據說過不合理的 try catch 是會影響性能的,可是以前從沒遇到過,結合了一些資料,我發現比較少案例去探究這類代碼片斷的性能,我決定寫代碼去驗證下:github

window.a = 'a';
window.c = undefined;
function getRowDataItemNumberFormatTryCatch() {
    console.time('getRowDataItemNumberFormatTryCatch');
    for (let i = 0; i < 3000; i++) {
        try {
            a.replace(/%022/g, '"');
        }
        catch (error) {
        }
    }
    console.timeEnd('getRowDataItemNumberFormatTryCatch');
}

我嘗試把 try catch 放入一個 for 循環中,讓它運行 3000 次,看看它的耗時爲多少,個人電腦執行該代碼的時間大概是 0.2 ms 左右,這是一個比較快的值,可是這裏 a.replace 是正常運行的,也就是 a 是一個字符串能正常運行 replace 方法,因此這裏的耗時是正常的。我對他稍微作了一下改變,以下:瀏覽器

function getRowDataItemNumberFormatTryCatch2() {
    console.time('getRowDataItemNumberFormatTryCatch');
    for (let i = 0; i < 3000; i++) {
        try {
            c.replace(/%022/g, '"');
        }
        catch (error) {
        }
    }
    console.timeEnd('getRowDataItemNumberFormatTryCatch');
}

這段代碼跟上面代碼惟一的區別是,c.replace 此時應該是會報錯的,由於 cundefined,這個錯誤會被 try catch 捕捉到,而上面的代碼耗時出現了巨大的變化,上升到 40 ms,相差了將近 200 倍!而且上述代碼和首圖的 getRowDataItemNumberFormat 函數代碼均出現了 Minor GC,注意這個 Minor GC 也是會耗時的。異步

demo

這能夠解釋一部分緣由了,咱們上面運行的代碼是一個性能比較關鍵的部分,不該該使用 try catch 結構,由於該結構是至關獨特的。與其餘構造不一樣,它運行時會在當前做用域中建立一個新變量。每次 catch 執行該子句都會發生這種狀況,將捕獲的異常對象分配給一個變量。async

即便在同一做用域內,此變量也不存在於腳本的其餘部分中。它在 catch 子句的開頭建立,而後在子句末尾銷燬。由於此變量是在運行時建立和銷燬的(這些都須要額外的耗時!),而且這是 JavaScript 語言的一種特殊狀況,因此某些瀏覽器不能很是有效地處理它,而且在捕獲異常的狀況下,將捕獲處理程序放在性能關鍵的循環中可能會致使性能問題,這是咱們爲何上面會出現 Minor GC 而且會有嚴重耗時的緣由。函數

若是可能,應在代碼中的較高級別上進行異常處理,在這種狀況下,異常處理可能不會那麼頻繁發生,或者能夠經過首先檢查是否容許所需的操做來避免。上面的 getRowDataItemNumberFormatTryCatch2 函數示例顯示的循環,若是裏面所需的屬性不存在,則該循環可能引起多個異常,爲此性能更優的寫法應該以下:性能

function getRowDataItemNumberFormatIf() {
    console.time('getRowDataItemNumberFormatIf');
    for (let i = 0; i < 3000; i++) {
        if (c) {
            c.replace(/%022/g, '"');
        }
    }
    console.timeEnd('getRowDataItemNumberFormatIf')
}

上面的這段代碼語義上跟 try catch 實際上是類似的,但運行效率迅速降低至 0.04ms,因此 try catch 應該經過檢查屬性或使用其餘適當的單元測試來徹底避免使用此構造,由於這些構造會極大地影響性能,所以應儘可能減小使用它們。單元測試

若是一個函數被重複調用,或者一個循環被重複求值,那麼最好避免其中包含這些構造。它們最適合僅執行一次或僅執行幾回且不在性能關鍵代碼內執行的代碼。儘量將它們與其餘代碼隔離,以避免影響其性能。測試

例如,能夠將它們放在頂級函數中,或者運行它們一次並存儲結果,這樣你之後就能夠再次使用結果而沒必要從新運行代碼。

demo

getRowDataItemNumberFormat 在通過上述思路改造後,運行效率獲得了質的提高,在實測 300 屢次循環中減小的時間以下圖,足足優化了將近 2s 多的時間,若是是 3000 次的循環,那麼它的優化比例會更高:

demo
demo

因爲上面的代碼是從項目中改造出來演示的,可能並不夠直觀,因此我從新寫了另一個類似的例子,代碼以下,這裏面的邏輯和上面的 getRowDataItemNumberFormat 函數講道理是一致的,可是我讓其發生錯誤的時候進入 catch 邏輯執行任務。

事實上 plus1plus2 函數的代碼邏輯是一致的,只有代碼語義是不相同,一個是返回 1,另外一個是錯誤拋出1,一個求和方法在 try 片斷完成,另外一個求和方法再 catch 完成,咱們能夠粘貼這段代碼在瀏覽器分別去掉不一樣的註釋觀察結果。

咱們發現 try 片斷中的代碼運行大約使用了 0.1 ms,而 catch 完成同一個求和邏輯卻執行了大約 6 ms,這符合咱們上面代碼觀察的預期,若是把計算範圍繼續加大,那麼這個差距將會更加明顯,實測若是計算 300000 次,那麼將會由原來的 60 倍差距擴大到 500 倍,那就是說咱們執行的 catch 次數越少折損效率越少,而若是咱們執行的 catch 次數越多那麼折損的效率也會越多。

因此在不得已的狀況下使用 try catch 代碼塊,也要儘可能保證少進入到 catch 控制流分支中。

const plus1 = () => 1;
const plus2 = () => { throw 1 };
console.time('sum');
let sum = 0;
for (let i = 0; i < 3000; i++) {
    try {
        // sum += plus1(); // 正確時候 約 0.1ms
        sum += plus2(); // 錯誤時候 約 6ms
    } catch (error) {
        sum += error;
    }
}
console.timeEnd('sum');

上面的種種表現進一步引起了我對項目性能的一些思考,我搜了下咱們這個項目至少存在 800 多個 try catch,糟糕的是咱們沒法保證全部的 try catch 是不損害代碼性能而且有意義的,這裏面確定會隱藏着不少上述類的 try catch 代碼塊。

從性能的角度來看,目前 V8 引擎確實在積極的經過 try catch 來優化這類代碼片斷,在之前瀏覽器版本中上面整個循環即便發生在 try catch 代碼塊內,它的速度也會變慢,由於之前瀏覽器版本會默認禁用 try catch 內代碼的優化來方便咱們調試異常。

try catch 須要遍歷某種結構來查找 catch 處理代碼,而且一般以某種方式分配異常(例如:須要檢查堆棧,查看堆信息,執行分支和回收堆棧)。儘管如今大部分瀏覽器已經優化了,咱們也儘可能要避免去寫出上面類似的代碼,好比如下代碼:

try {
    container.innerHTML = "I'm alloyteam";
}
catch (error) {
    // todo
}

上面這類代碼我我的更建議寫成以下形式,若是你實際上拋出並捕獲了一個異常,它可能會變慢,可是因爲在大多數狀況下上面的代碼是沒有異常的,所以總體結果會比異常更快。

這是由於代碼控制流中沒有分支會下降運行速度,換句話說就是這個代碼執行沒錯誤的時候,沒有在 catch 中浪費你的代碼執行時間,咱們不該該編寫過多的 try catch 這會在咱們維護和檢查代碼的時候提高沒必要要的成本,有可能分散並浪費咱們的注意力。

當咱們預感代碼片斷有可能出錯,更應該是集中注意力去處理 successerror 的場景,而非使用 try catch 來保護咱們的代碼,更多時候 try catch 反而會讓咱們忽略了代碼存在的致命問題。

if (container) container.innerHTML = "I'm alloyteam";
else // todo

在簡單代碼中應當減小甚至不用 try catch ,咱們能夠優先考慮 if else 代替,在某些複雜不可測的代碼中也應該減小 try catch(好比異步代碼),咱們看過不少 asyncawait 的示例代碼都是結合 try catch 的,在不少性能場景下我認爲它並不合理,我的以爲下面的寫法應該是更乾淨,整潔和高效的。

由於 JavaScript 是事件驅動的,雖然一個錯誤不會中止整個腳本,但若是發生任何錯誤,它都會出錯,捕獲和處理該錯誤幾乎沒有任何好處,代碼主要部分中的 try catch 代碼塊是沒法捕獲事件回調中發生的錯誤。

一般更合理的作法是在回調方法經過第一個參數傳遞錯誤信息,或者考慮使用 Promisereject() 來進行處理,也能夠參考 node 中的常見寫法以下:

;(async () => {
    const [err, data] = await readFile();
    if (err) {
        // todo
    };
})()

fs.readFile('<directory>', (err, data) => {
    if (err) {
        // todo
    }
});

結合了上面的一些分析,我本身作出一些淺顯的總結:

    1. 若是咱們經過完善一些測試,儘可能確保不發生異常,則無需嘗試使用 try catch 來捕獲異常。
    1. 非異常路徑不須要額外的 try catch,確保異常路徑在須要考慮性能狀況下優先考慮 if else,不考慮性能狀況請君隨意,而異步能夠考慮回調函數返回 error 信息對其處理或者使用 Promse.reject()
    1. 應當適當減小 try catch 使用,也不要用它來保護咱們的代碼,其可讀性和可維護性都不高,當你指望代碼是異常時候,不知足上述1,2的情景時候可考慮使用。

最後,筆者但願這篇文章能給到你我一些方向和啓發吧,若有疏漏不妥之處,還請不吝賜教!

附筆記連接,閱讀往期更多優質文章可移步查看,但願對你有些許的幫助,你的點贊是對我最大的鼓勵:

相關文章
相關標籤/搜索