飯糰
ONIGIRI常駐監工

PERF LAB

可以自己操作的前端效能實作。數字是當場跑出來的,不是截圖——調參數就會變。之後會陸續增加。

01useMemo

快取昂貴的運算

同一份運算(計算範圍內的質數個數),用開關切換走不走 useMemo 快取,再量同一次重新渲染的耗時。

先在 ON 的狀態按幾次,再切到 OFF 按幾次,兩條長條就會拉開。計時用 flushSync 逼 React 同步跑完渲染後在事件處理函式裡量——React 的 Profiler 在正式建置會被移除,量不到東西。

看這篇的來由與原理

ms本次重新渲染

useMemo ON
useMemo OFF
質數個數
4,203
渲染次數
#0
useMemo
02React.lazy

捲動時才載入

列表一次只載一批,捲到底才取下一批。列渲染元件也不在主 bundle 裡,Vite 把它切成獨立 chunk,第一次捲到才透過網路取得。

在框內往下捲,新資料會一批一批逐筆淡入。打開開發者工具的 Network 分頁再按重置重看一次,會看到那個 js 檔在第一次捲到底的瞬間才被請求,首頁載入時完全沒抓它。

看這篇的來由與原理
    在框內往下捲載入更多
    0 / 60 筆
    03Suspense

    等待資料抵達

    資料未就緒時元件會 throw 一個 promise,React 接住後顯示骨架畫面,等 promise 完成再重試渲染。這是 Suspense 的底層契約。

    拉動延遲再重播,骨架畫面停留的時間就會跟著變。這一頁本身也用了預先渲染,首屏 HTML 在建置時就產生好了。

    看這篇的來由與原理

    尚未開始請求

    04虛擬捲動

    只畫看得到的那幾筆

    兩側資料完全一樣,都是 10,000 筆在記憶體裡。左邊全部畫進 DOM,右邊只畫可視範圍的約 20 筆,靠位移把它們對到正確位置。

    跟 02 是相反的兩件事:02 是少載資料,這裡是少畫 DOM。按下左邊的按鈕會明顯卡一下,那就是瀏覽器在算 10,000 個節點的版面。

    看這篇的來由與原理
    全部渲染
    尚未渲染(按下方按鈕,畫面會卡一下)
    DOM 節點數
    首次渲染
    虛擬捲動
    #1row-00001
    #2row-00002
    #3row-00003
    #4row-00004
    #5row-00005
    #6row-00006
    #7row-00007
    #8row-00008
    #9row-00009
    #10row-00010
    #11row-00011
    #12row-00012
    DOM 節點數
    12
    首次渲染
    0 ms

    節點數差 500 倍。真實專案裡這個差距會直接反映在捲動流暢度與記憶體用量上。

    05Web Worker

    把重活搬離主執行緒

    同一段質數運算,一邊跑在主執行緒、一邊跑在 Web Worker。運算量與結果完全相同,差別只在「跑在哪條執行緒上」。

    盯著上面那個轉圈。按「主執行緒」它會整個凍住,按「Worker」它一路轉不停——那個停頓就是使用者感受到的當機。

    看這篇的來由與原理
    盯著這個轉圈,然後按下方按鈕
    上次執行於
    耗時
    質數個數
    06Map vs find

    在迴圈裡查表的代價

    把每筆訂單對應到使用者名稱。差別在於「怎麼找人」:一種每次都從頭掃陣列,一種先建一張 id 對照表再直接取。兩種做法的結果會互相比對驗證,確保比的是同一件事。

    在 map 裡呼叫 find 是列表渲染最常見的效能地雷——它看起來只是一行,實際是 n × m 次比對。資料量翻倍時 find 那側的成本是四倍成長,Map 那側只有兩倍。把資料量拉到 5,000 差距最明顯。

    看這篇的來由與原理
    map + find
    orders.map(o =>
      users.find(
        u => u.id === o.userId
      )
    )
    users 是陣列,只能從頭一個一個比對到找著為止。每筆訂單都重來一次。
    先建 Map 索引
    const index = new Map(
      users.map(u => [u.id, u])
    )
    orders.map(o =>
      index.get(o.userId)
    )
    先花一次把陣列整理成「id → 使用者」的對照表,之後給 id 就直接取出。像電話簿先按姓氏排好,查的時候直接翻到。

    示意:這筆訂單要找 user-9

    map + find
    從頭一格一格比對,已比 1 次
    Map index
    索引直接命中,1 次

    2,000 筆訂單 × 2,000 位使用者按下方按鈕開始
    map + find
    先建 Map 索引
    資料量
    07React.memo

    只重畫真的變了的那一筆

    一份任務列表,點任一列就重跑該任務、更新它的耗時。右側數字是每一列被渲染過幾次,由 effect 實際計數寫入。

    兩個開關都開時點幾列:只有你點的那列會閃、數字會加,其餘全停在 1。把任一個開關關掉再點,整份列表每一列都跟著重畫——明明只有一筆資料變了。useCallback 關掉時尤其容易忽略:函式每次都是新參考,memo 比對後判定 props 變了,就整個失效。

    看這篇的來由與原理
    點任一列即可重跑該任務只有 1 列重畫
    任務渲染次數
    React.memo
    useCallback
    08debounce / throttle

    把事件次數降下來

    輸入框每打一個字就算一次「要送出的請求」。三種策略處理同一串輸入,看實際會送出幾次。

    快速連打一段字再停手。原始那條會跟你的按鍵數一樣多;防抖只在停手後送一次;節流則在打字期間穩定地送。搜尋建議適合防抖,捲動與拖曳適合節流。

    看這篇的來由與原理
    原始0
    防抖0
    節流0
    • · 每次輸入都送,最浪費
    • · 停手 350ms 後才送一次
    • · 每 200ms 最多送一次
    09強制同步版面

    讀寫順序決定成本

    兩種模式做的事完全一樣:把每個方塊的 margin 改掉,也都讀了版面。差別只在讀寫的順序——一種寫完立刻讀,一種全部寫完才讀一次。

    寫完就讀(如 offsetHeight、getBoundingClientRect)會逼瀏覽器當場把版面算完才能回答,在迴圈裡做就是每一輪都同步重排一次。一幀的預算約 16ms,超過就撐不住流暢。量測期間畫面會明顯凍住——那正是使用者感受到的當機。這個坑常出現在「遍歷元素、邊量邊改」的程式裡,改法是把讀和寫分開成兩批。

    看這篇的來由與原理
    ms/幀(版面計算)1400 個方塊同時更新

    切到「讀寫交錯」後整頁會明顯卡頓——那正是這個 demo 要讓你感受的東西,不是頁面壞了。停止鍵仍然有效,只是回應會慢半拍,多按一下就會停。想少卡一點可以先按停止再切換模式。

    10AVIF / srcset

    同一張圖能差多少

    這是 /surf 實際使用的素材,不是另外做的教學範例。同一張圖輸出成 AVIF 與 WebP、各兩種寬度,位元組是當場抓下來量的。

    AVIF 通常比 WebP 再小三到四成;而手機只需要 768 寬那檔,硬塞 1920 給它就是白花好幾倍流量。srcset 的作用就是讓瀏覽器自己挑對的那一檔。

    看這篇的來由與原理
    衝浪者破水而出的照片,用於比較圖片格式

    點左側名稱可切換預覽。實務上不必手動選——sizes 與 srcset 寫對,瀏覽器會依螢幕寬度與像素密度自己決定。