嘿,日安!

Day 29 - readonly: 資料唯讀保護實作

8th October 2025
Front-end
Vue3
IT鐵人賽2025
從零到一打造 Vue3 響應式系統
Last updated:9th October 2025
9 Minutes
1715 Words

在開始 readonly 之前,我們先講一下 Proxy 的補充知識:

Proxy

Proxy 是實現 reactivereadonly 等功能的核心。它會在目標物件前架設一個「代理」或「攔截層」,讓我們有機會對外界的存取操作進行自訂處理。

攔截與代理

Proxy 的工作模式可以想像成一個保全:

  • 目標物件 (target):是公司內部的辦公室。
  • 代理物件 (proxy):保全本人。
  • 處理器 (handler):是保全應對手冊,裡面寫了存取物件時的該如何處理的邏輯。

任何外部程式碼(訪客)要存取物件屬性(進辦公室)都需要經過 Proxy(保全),Proxy 可以知道 handler(保全手冊)來決定如何回應。

handler 中,最關鍵的陷阱 (trap) 之一就是 getget(target, key, receiver):這個陷阱的觸發時機是當程式碼試圖讀取代理物件屬性時,縱使原始物件沒有這個屬性,它也可以透過 handler 的規則下去處理。

了解這些之後,可以開始實作了!

readonly 只接受物件參數,在前面的文章有寫到 ref 如果傳入是物件的話,那就會回傳一個 reactive,因此在 readonly 實作,我們只要針對 reactive 完成就可以。

1
<body>
2
<div id="app"></div>
3
<script type="module">
4
import { readonly, reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
5
// import { readonly, effect, reactive } from '../dist/reactivity.esm.js'
6
7
const state = reactive({
8
a:1,
9
b:{
10
c:1
11
}
12
})
13
14
const readonlyState = readonly(state)
15
9 collapsed lines
16
effect(() => {
17
console.log(readonlyState.a)
18
})
19
20
setTimeout(() => {
21
state.a++
22
}, 1000)
23
</script>
24
</body>

default

如果你設定一個readonly物件,修改傳入的物件,readonly 仍然會接受到響應式的觸發更新。

1
setTimeout(() => {
2
readonlyState.a++
3
}, 1000)

default

但如果你修改的是 readonly 物件,那就會跳出警告。

default

查看這個 readonly 物件,可以發現它就是 reactive 物件,是由 _isReadonly 旗標來判斷,這跟我們上一個章節在寫 shallow 的時候特別像。

首先,我們先在 ref.ts 增加附註的旗標,分別是 IS_REACTIVE 以及 IS_READONLY

ref.ts
1
export enum ReactiveFlags {
2
IS_REF = '__v_isRef',
3
IS_REACTIVE = '__v_isReactive',
4
IS_READONLY = '__v_isReadonly'
5
}

接著調整一下 reactive,我們移除原有的 Set 檢查,改為透過旗標來判斷是否需要重複代理。

reactive.ts
1
import { ReactiveFlags } from './ref'
2
...
3
...
4
function createReactiveObject(target, handlers, proxyMap) {
5
// reactive 只處理物件
6
if (!isObject(target)) return target
7
8
// 統一處理「防止重複代理」的情況,這個檢查取代了 reactiveSet
9
if (target[ReactiveFlags.IS_REACTIVE]) {
10
return target
11
}
12
13
// 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy
14
const existingProxy = proxyMap.get(target)
28 collapsed lines
15
if (existingProxy) {
16
return existingProxy
17
}
18
19
// 建立 target 的代理物件
20
const proxy = new Proxy(target, handlers)
21
22
// 儲存使用 reactive 建立的響應式物件
23
proxyMap.set(target, proxy)
24
25
return proxy
26
}
27
...
28
...
29
// 調整 reactive 判斷
30
export function isReactive(target) {
31
return !!(target && target[ReactiveFlags.IS_REACTIVE])
32
}
33
34
// 先新增一個空物件,等一下再來補充
35
export function readonly(target) {
36
return {}
37
}
38
39
// 新增 readonly 判斷
40
export function isReadonly(value) {
41
return !!(value && value[ReactiveFlags.IS_READONLY])
42
}

接著回到baseHandlers.ts,新增一個 readonlyHandler

1
// 導入旗標
2
import { isRef, ReactiveFlags } from './ref'
3
// 引入 readonly 函式,
4
import { reactive, readonly } from './reactive'
5
6
// 擴充 createGetter,它接受一個 isReadonly 參數,並且檢查
7
function createGetter(isShallow = false, isReadonly = false) {
8
return function get(target, key, receiver) {
9
//讓 isReactive 以及 isReadonly 可以進行判斷
10
if (key === ReactiveFlags.IS_REACTIVE) {
11
return !isReadonly
12
} else if (key === ReactiveFlags.IS_READONLY) {
13
return isReadonly
14
}
15
30 collapsed lines
16
track(target, key)
17
const res = Reflect.get(target, key, receiver)
18
if (isRef(res)) {
19
return res.value
20
}
21
22
if (isObject(res)) {
23
// 如果屬於唯讀,那返回一個
24
return isReadonly ? readonly(res) : isShallow ? res : reactive(res)
25
}
26
return res
27
}
28
}
29
30
...
31
...
32
// 建立唯讀的 getter
33
const readonlyGet = createGetter(false, true)
34
// 建立唯讀的 handler,並且阻止 setter 修改跟刪除
35
export const readonlyHandlers = {
36
get: readonlyGet,
37
set(target, key) {
38
console.warn(`Set operation on key "${String(key)}" failed: target is readonly.`)
39
return true // 阻止修改
40
},
41
deleteProperty(target, key) {
42
console.warn(`Delete operation on key "${String(key)}" failed: target is readonly.`)
43
return true // 阻止刪除
44
}
45
}

createGetter 的旗標邏輯是:縱使旗標是原始物件上一個不存在的屬性,但當外部程式碼(如 isReadonly)訪問它時,代理物件的 getter 會被觸發。 JavaScript 引擎會發現它是一個代理物件,因此 getter 會根據傳入的 isReadonly 參數回傳對應的布林值。

我們回到 reactive.ts,完成 readonly 的實作:

reative.ts
1
import { mutableHandlers, shallowReactiveHandlers, readonlyHandlers } from './baseHandlers'
2
3
// 建立一個 readonly 快取map
4
const readonlyMap = new WeakMap()
5
...
6
...
7
function createReactiveObject(target, handlers, proxyMap) {
8
// reactive 只處理物件
9
if (!isObject(target)) return target
10
11
// 如果遇到重複代理,或是唯讀物件,無需處理,並且返回本身物件
12
if (target[ReactiveFlags.IS_REACTIVE] || target[ReactiveFlags.IS_READONLY]) {
13
return target
21 collapsed lines
14
}
15
16
// 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy
17
const existingProxy = proxyMap.get(target)
18
if (existingProxy) {
19
return existingProxy
20
}
21
22
// 建立 target 的代理物件
23
const proxy = new Proxy(target, handlers)
24
25
// 儲存使用 reactive 建立的響應式物件
26
proxyMap.set(target, proxy)
27
28
return proxy
29
}
30
...
31
...
32
export function readonly(target) {
33
return createReactiveObject(target, readonlyHandlers, readonlyMap)
34
}

這樣我們就完成了 readonly的實作。

循環引用

有些人可能會發現我們遇到循環引用的狀態

1
ref.ts -> reactive.ts -> baseHandlers.ts -> ref.ts

這個問題在 CommonJS 是需要特別注意跟避免,但在現代的 ESM 中可以正常運作。

什麼是循環引用?

在過往 CommonJS 中,require() 是同步執行的,當模組 A 依賴模組 B,而模組 B 同時也依賴模組 A 時,這會導致其中一個模組在被引入時沒有初始化完全,引發執行時的錯誤。

即時綁定

ESM 的 import/export 機制與 CommonJS 完全不同。它導出的不是一個值的拷貝,而是一個即時綁定,可以把它想像成一個指向原始變數記憶體位置的指標

ESM 透過一個巧妙的兩階段過程來處理模組,從而解決了循環引用的問題:

  • 第一階段:解析與綁定
    • JavaScript 引擎首先會掃描所有相關的模組檔案,解析 importexport 語句,建立一個完整的「依賴圖」。
    • 在這個階段,引擎會為所有 export 的變數、函式、類別在記憶體中建立綁定並分配空間,但不會執行任何程式碼
  • 第二階段:執行與賦值
    • 在所有綁定都建立好之後,引擎才開始執行每個模組的主體程式碼,將實際的函式或值放到之前預留的記憶體位置中。
    • 以我們這次來說:當 baseHandlers.ts 需要 import { readonly } from './reactive' 時,它得到的是 readonly 這個函式的「即時綁定」。
    • baseHandlers.ts 模組(像是 createGetter 函式的定義)可以順利執行完畢。
    • 之後,reactive.ts 模組也會執行,將 readonly 函式的定義填充到它的綁定中。

關鍵是執行時機

最關鍵的一點是:

baseHandlers.ts 裡的 createGetterget 只是定義了readonly,它並沒有被立即呼叫

它要等到未來某個代理物件的屬性被存取時,才會被真正執行,而到那個時候,所有模組早就完成了第二階段的執行。因此,呼叫 readonly(res) 不會有任何問題。

Article title:Day 29 - readonly: 資料唯讀保護實作
Article author:日安
Release time:8th October 2025