藥不藥來當工程師一個不務正業的藥師,誤打誤撞來當了前端工程師。斜槓工程師的人生!

為什麼 .reverse() 讓瀏覽器凍結?Vue Reactivity 與 Array In-Place Mutation 的陷阱

發佈於 最後更新於
7 min read

在一個 Vue 3 專案中,我把一行 .sort() 改成 .reverse(),結果瀏覽器直接凍結。這篇文章記錄這個問題的根本原因:在 reactive context 中對陣列做 in-place mutation 可能觸發無限迴圈,以及為什麼 ES2023 的 toReversed() / toSorted() 是更安全的選擇。

問題現場

一個 Chart.js 圖表模組,buildChartConfig 負責把 API 回傳的資料轉成 chart 設定。原本用 .sort()id 降序排列,運作正常:

ts
// 正常運作
const items = chartData.category_analysis.sort((a, b) => b.id - a.id)

因為資料本身已經按 id 由小到大排好,所以改成語意更清楚的 .reverse()

ts
// 瀏覽器凍結
const items = chartData.category_analysis.reverse()

改完之後,打開頁面瀏覽器直接凍結,看起來像是 OOM 或 memory leak。

追蹤問題根源

buildChartConfig 本身只是一個普通 function,不在 computed 裡。但往上追 call chain:

ts
// composable
function useChart(data: { name: string, visual_data: VisualData }) {
  function drawCharts() {
    for (const section of allSections) {
      const config = section.buildChartConfig({
        visualData: data.visual_data, // ← reactive 物件的 reference
        // ...
      })
      new Chart(ctx, config)
    }
  }
  return { drawCharts }
}

在使用這個 composable 的元件裡:

ts
const { drawCharts } = useChart(props.data)

watch(props.data, () => {
  nextTick(() => {
    drawCharts()
  })
}, { deep: true })

問題的完整路徑:

  1. watch(props.data, { deep: true }) 監聽 props 變化
  2. 觸發 drawCharts()buildChartConfig()
  3. chartData.category_analysis.reverse() 直接 mutate 了 reactive 陣列
  4. Vue 的 Proxy 攔截到 mutation → 通知 watcher
  5. watcher 偵測到 props.data 內部變化 → 又觸發 drawCharts()
  6. reverse() → 又 mutate → 又觸發 watch → 無限迴圈

為什麼 .sort() 沒事?

.sort().reverse() 都是 in-place mutation method1,都會直接修改原陣列。差別在於操作結果是否「收斂」:

.sort((a, b) => b.id - a.id) — 第一次排完是 [3, 2, 1],watch 觸發後再 sort 一次,結果還是 [3, 2, 1]。Vue 比對後發現陣列沒變,不再觸發 update。迴圈在第二次就停了。

.reverse() — 第一次翻轉成 [3, 2, 1],watch 觸發後再 reverse 變成 [1, 2, 3],下一次又變 [3, 2, 1]… 永遠在兩個狀態之間來回切換,Vue 每次都偵測到變化,永遠不會停。

所以 .sort() 不是「沒有 bug」,它只是剛好收斂了。本質上一樣是在 reactive context 裡 mutate source data,只是運氣好沒爆。

Vue 3 Reactivity 與 Array Mutation

Vue 3 使用 Proxy 實作 reactivity system2。當你對一個 reactive 物件(包括陣列)進行操作時,Proxy 會攔截 getset 等操作,自動追蹤依賴和觸發更新。

對陣列來說,Vue 會攔截所有的 mutation method(pushpopsplicesortreverse 等)。當這些方法被呼叫時,Vue 會通知所有依賴這個陣列的 watcher 和 computed。

搭配 watch(source, callback, { deep: true }),Vue 會遞迴追蹤 source 內部所有屬性的變化3。這意味著即使你 mutate 的是 props.data.visual_data.category_analysis(一個深層巢狀的陣列),最外層的 watch(props.data) 也會被觸發。

這就是為什麼一個看似無害的 .reverse() 能造成無限迴圈 — 它 mutate 了 reactive source 的深層屬性,而 deep watcher 會捕捉到這個變化。

解法:使用 Non-Mutating 方法

ES2023 引入了四個 non-mutating 的陣列方法4

Mutating(原地修改)Non-Mutating(回傳新陣列)
sort()toSorted()
reverse()toReversed()
splice()toSpliced()
arr[i] = valuewith(i, value)

修正方式:

ts
// ✅ 不 mutate 原陣列,回傳新陣列
const items = chartData.category_analysis.toReversed()

toReversed() 回傳一個全新的陣列,完全不碰原本的 reactive proxy。原陣列沒被 mutate,watch 就不會被觸發,迴圈根本不會開始。

如果環境不支援 ES2023,用 spread 展開再操作也可以:

ts
// ES2023 之前的寫法
const items = [...chartData.category_analysis].reverse()

[...arr] 產生新陣列(shallow copy),再對新陣列 .reverse(),原陣列不受影響。

Shallow Copy 夠用嗎?

toReversed()[...arr].reverse() 都是 shallow copy — 陣列容器是新的,但裡面的元素還是同一批 reference:

ts
const original = [objA, objB, objC]
const reversed = original.toReversed()

reversed === original // false(新陣列)
reversed[0] === original[2] // true(同一個物件 reference)

在這個場景下 shallow copy 就夠了。問題出在「陣列本身的順序被改了」觸發 watch,不是裡面的物件內容被改。而且 buildChartConfig 只是讀取資料來建 chart config,沒有寫入元素屬性,所以不存在穿透回 reactive source 的風險。

不需要 structuredCloneJSON.parse(JSON.stringify()),那是 deep clone,對一個只需要反轉順序的 read-only 場景來說完全沒必要。

判斷原則:什麼時候該用 Non-Mutating 方法?

一個簡單的判斷:如果你操作的陣列來源是 reactive 的(refreactivepropscomputed 的回傳值),就應該用 non-mutating 方法。

安全的情境(不需要改):

ts
// .filter() 已經產生新陣列,後面的 .sort() 不會 mutate 原始資料
const result = props.options.filter(o => o.active).sort((a, b) => a.name.localeCompare(b.name))

// .map() 已經產生新陣列
const labels = data.map(d => d.label).sort()

// Object.entries() 產生新陣列
const sorted = Object.entries(grouped).sort(([a], [b]) => b.localeCompare(a))

需要改的情境:

ts
// ❌ 直接 mutate reactive 陣列
const items = reactiveArray.sort((a, b) => a.id - b.id)
const items = reactiveArray.reverse()

// ✅ 改用 non-mutating
const items = reactiveArray.toSorted((a, b) => a.id - b.id)
const items = reactiveArray.toReversed()

關鍵是看 .sort() / .reverse() 前面有沒有一個會產生新陣列的操作(.filter().map()[...arr]Object.entries() 等)。如果有,就是安全的;如果沒有,就是直接 mutate reactive source。

環境資訊

  • Vue: 3.x
  • Nuxt: 3.x(SPA mode, ssr: false
  • Chart.js: 4.x
  • ES2023 toReversed() / toSorted() — 所有主流瀏覽器自 2023 年起支援5

本文撰寫時間:2026 年 3 月,技術版本可能隨時間更新,請以官方文件為準。

Reference

  1. Array.prototype.reverse() - JavaScript | MDN
  2. Reactivity in Depth | Vue.js
  3. Watchers - Deep Watchers | Vue.js
  4. Array.prototype.toReversed() - JavaScript | MDN
  5. Array.prototype.toSorted() - JavaScript | MDN
Copyright 2020-2026 - AzureBlue ALL RIGHTS RESERVED.