2291 字
11 分鐘

Vue 3 外部插件資料變了,畫面沒更新?用 toRaw 釐清原因

我在開發一個外部插件時,遇到一個狀況:插件拿到主站傳來的清單,再補上 API 回傳的詳細資訊。資料已經寫進物件,畫面上的對應欄位卻一直空著。

把交接資料的地方改成 toRaw(items.value) 後,插件畫面就能更新。這讓問題指向一個容易忽略的邊界:主站與插件各自打包了一份 Vue,資料卻沿用主站建立的 reactive proxy。

以下把原本的業務情境改成「主站清單與外部資訊面板」,欄位與內容也換成通用範例。重點是資料如何跨越兩份 Vue runtime,以及 toRaw 在其中做了什麼。

資料已經變了,為什麼 Vue 沒有更新畫面?#

先把情境拆開:

  • 主站用 Vue 3 管理一份 items 清單。
  • 外部插件有自己的 Vue 3,負責渲染獨立資訊面板。
  • 主站把 items.value 傳給插件。
  • 插件取得詳細資訊後,寫入某筆資料的 detail 欄位。

下面是簡化後的整合示意。兩段程式碼分別由主站與插件獨立打包,openPanel 是插件對外提供的入口。

主站:

import { ref } from 'vue'
import { openPanel } from './external-panel.js'
const items = ref([{ id: 1, title: '項目 A', detail: null }])
openPanel(items.value)

插件內部:

import { ref } from 'vue'
// 此處的 vue 來自插件自己的 bundle。
export function openPanel(input) {
const panelItems = ref(input)
// 模擬非同步取得詳細資訊。
setTimeout(() => {
panelItems.value[0].detail = { label: '補充資訊' }
}, 100)
// 實際插件把 panelItems 交給自己的 Vue 元件渲染。
return panelItems
}

直覺上,插件已經用 ref 包過資料,接著修改 panelItems.value[0].detail,畫面應該更新。但這裡必須區分兩件事:建立了插件自己的 ref,不代表裡面的物件也改由插件自己的響應式系統管理。

Vue 的 ref 會處理傳入的物件,但對已經是 Vue proxy 的物件,底層 reactive 轉換會沿用既有 proxy。相關行為可以對照 Vue 3.5.22 的 ref 實作與 reactive 實作。

reactive proxy 幫 Vue 記錄了什麼#

Vue 3 對一般物件的響應式處理,會用 JavaScript Proxy 攔截屬性讀寫。ref 本身的 .value 則透過 getter/setter 追蹤;兩者不要混為一談。

import { reactive } from 'vue'
const raw = { title: '項目 A' }
const state = reactive(raw)
console.log(state === raw) // false
state.title = '項目 B'
console.log(raw.title) // '項目 B'

state 是代理物件,資料仍存在 raw 裡。當元件渲染時讀取 state.title,Vue 會記錄這個依賴;後續透過 state.title 寫入新值,才能通知有訂閱的元件更新。官方的響應式原理說明有對應的讀寫追蹤示意。

因此,能從物件讀到新值,只能證明資料變了。畫面能不能跟著更新,還要看渲染時有沒有建立依賴,以及寫入時是否通知到那份依賴。

兩份 Vue 認得 proxy,卻沒有共用依賴追蹤#

把主站的 Vue 稱為 A,插件的 Vue 稱為 B。

Vue A 建立 proxy 後,讀寫攔截會使用 A 的依賴追蹤機制。Vue B 的元件渲染時,執行中的渲染 effect 則由 B 管理。兩份 runtime 各自保存追蹤狀態,沒有自動接在一起。

但 B 收到 A 的 proxy 時,仍然可以透過 Vue 內部使用的識別旗標,判斷它已經是 proxy,並沿用它。這些旗標由 proxy 的讀取攔截器回應,不是原始資料上真的多了一組業務欄位,實作可見 baseHandlers.ts。

於是這次的更新路徑變成:

插件的 Vue B 元件讀取 detail
↓
經過 Vue A 建立的 proxy
↓
A 的追蹤機制沒有取得 B 正在執行的渲染 effect
↓
插件寫入 detail 時,仍由 A 的 proxy 處理
↓
B 的元件沒有收到這次欄位變更通知

主站若有訂閱該欄位,主站的訂閱者可以收到通知;不能直接說「主站整個畫面一定會重新渲染」。插件也可能因為其他狀態觸發重繪,才順便顯示最新資料,讓問題看起來時好時壞。

Vue 官方在 @vue/reactivity 的使用說明也提醒:獨立的 reactivity build 與內含另一份 reactivity 的 renderer build,會有不同的響應式連結儲存空間。這正是判讀多份 runtime 時要注意的限制。

toRaw 是什麼,為什麼交接前使用它有效?#

toRaw 會取回 Vue proxy 對應的原始物件:

import { reactive, toRaw } from 'vue'
const raw = { title: '項目 A' }
const state = reactive(raw)
console.log(toRaw(state) === raw) // true

在這個案例中,主站可以在呼叫插件入口時改成:

import { ref, toRaw } from 'vue'
import { openPanel } from './external-panel.js'
const items = ref([{ id: 1, title: '項目 A', detail: null }])
openPanel(toRaw(items.value))

插件的 ref(input) 現在收到原始陣列。以這裡由普通物件組成的資料為例,插件就能建立 B 自己的 proxy,後續元件讀取與欄位寫入都經過 B 的響應式系統。

這次的交接條件是:主站交出這批資料後,就不再負責更新它;後續補資料與顯示都由插件處理。因此這個修正符合當時的使用方式。

toRaw 不會幫你複製資料#

toRaw 回傳的仍然是同一份原始物件。插件透過自己的 proxy 寫入後,主站直接讀取同一份底層資料,也能讀到新值;但這次寫入沒有經過主站的 proxy,不能期待主站收到更新通知。

官方把 toRaw 定位為暫時繞過代理的出口,並提醒避免長期持有原始物件參照。它不是一般元件更新失敗時的通用解法,也不能因為用途是「交給外部程式」就直接視為安全。使用前仍要確認誰負責後續更新。參見 toRaw API 文件。

toRaw 也不會遞迴清理整棵資料#

如果原始陣列裡本來就放了一個另外建立的 proxy,只對外層呼叫 toRaw,裡面那個 proxy 仍然存在:

import { isReactive, reactive, ref, toRaw } from 'vue'
const entry = reactive({ id: 1 })
const items = ref([entry])
console.log(isReactive(toRaw(items.value)[0])) // true

所以「傳入 toRaw(items.value) 後,所有巢狀資料都一定變成普通物件」並不成立。插件若要完全持有自己的狀態,可以在邊界明確建立只含所需欄位的普通資料,再由插件轉成 reactive;巢狀物件也要按資料結構重新建立。

怎麼確認遇到的是同一種問題#

先確認主站與插件的建置產物是否真的各自包含 Vue。兩個 createApp() 共用同一份 Vue,和兩份獨立 Vue runtime,是不同情況。

isReactive(input) 只能告訴你它是不是 reactive,無法告訴你是哪一份 runtime 建立的。在插件內檢查 reactive(input) === input,可以觀察 proxy 是否被沿用,但同一份 Vue 本來也會沿用自己的 proxy,這個結果不能單獨證明有重複 runtime。

這次針對核心機制的驗證,是在 Node.js 的兩個獨立 vm context 載入同一版本的 @vue/reactivity 3.5.22,以 A 建立資料、B 建立讀取欄位的 effect。結果如下:

交給 B 的輸入修改欄位後的結果
A 的 proxy資料改變,B 的 effect 沒有再次執行
A 的 toRaw 結果,內容是普通物件B 建立自己的 proxy,effect 隨修改再次執行

這個驗證重現的是響應式追蹤邊界,沒有重建原本的完整插件與瀏覽器畫面。套回專案時,還要確認非同步補值是否真的寫入元件正在讀取的那筆資料。

若主站與插件本來就應共用狀態,可以從建置層檢查 Vue 是否應由主站提供;共用套件的依賴管理可參考用 npm Workspaces 整理 Vue 3 共用程式碼。如果兩邊必須獨立,則要定義普通資料與事件的交接方式,由各自的 runtime 更新各自狀態。

為什麼主站使用 Vue 2 時沒有遇到?#

Vue 2 透過 Object.defineProperty 改寫物件屬性的 getter/setter;Vue 3 對一般物件改用 Proxy。Vue 2 觀察過的物件不會因為這件事就成為 Vue 3 的 reactive proxy,因此不會走到這次「辨識為既有 Vue 3 proxy,直接沿用」的相同路徑。Vue 2 的機制可參考官方響應式原理。

這能解釋為什麼原本運作的整合,在主站升級後出現不同結果。但它不代表 Vue 2 與 Vue 3 混用就保證正確:Vue 2 本身還有新增物件屬性等偵測限制。其他升級差異可參考 Vue 2 升級到 Vue 3 的語法與套件調整。

回到這個問題,toRaw 有效的原因,是讓插件拿到原始資料後,由自己的 Vue 建立代理。真正要確認的是:元件讀取資料與程式寫入資料,是否經過能追蹤同一份渲染依賴的響應式系統。

參考資料:

Vue:Reactivity in Depth

Vue:toRaw API

Vue 3.5.22:reactivity 使用限制

Vue 2:Reactivity in Depth

Vue 3 外部插件資料變了,畫面沒更新?用 toRaw 釐清原因
https://laplusda.com/posts/vue3-plugin-reactivity-toraw/
作者
Zero
發佈於
2026-10-21
許可協議
CC BY-NC-SA 4.0