在開始 readonly 之前,我們先講一下 Proxy 的補充知識:
Proxy
Proxy
是實現 reactive
、readonly
等功能的核心。它會在目標物件前架設一個「代理」或「攔截層」,讓我們有機會對外界的存取操作進行自訂處理。
攔截與代理
Proxy
的工作模式可以想像成一個保全:
- 目標物件 (
target
):是公司內部的辦公室。 - 代理物件 (
proxy
):保全本人。 - 處理器 (
handler
):是保全應對手冊,裡面寫了存取物件時的該如何處理的邏輯。
任何外部程式碼(訪客)要存取物件屬性(進辦公室)都需要經過 Proxy
(保全),Proxy
可以知道 handler
(保全手冊)來決定如何回應。
在handler
中,最關鍵的陷阱 (trap) 之一就是 get
。get(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:111 }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>
如果你設定一個readonly
物件,修改傳入的物件,readonly 仍然會接受到響應式的觸發更新。
1setTimeout(() => {2 readonlyState.a++3}, 1000)
但如果你修改的是 readonly 物件,那就會跳出警告。
查看這個 readonly 物件,可以發現它就是 reactive 物件,是由 _isReadonly
旗標來判斷,這跟我們上一個章節在寫 shallow
的時候特別像。
首先,我們先在 ref.ts
增加附註的旗標,分別是 IS_REACTIVE
以及 IS_READONLY
:
1export enum ReactiveFlags {2 IS_REF = '__v_isRef',3 IS_REACTIVE = '__v_isReactive',4 IS_READONLY = '__v_isReadonly'5}
接著調整一下 reactive,我們移除原有的 Set
檢查,改為透過旗標來判斷是否需要重複代理。
1import { ReactiveFlags } from './ref'2...3...4function createReactiveObject(target, handlers, proxyMap) {5 // reactive 只處理物件6 if (!isObject(target)) return target7
8 // 統一處理「防止重複代理」的情況,這個檢查取代了 reactiveSet9 if (target[ReactiveFlags.IS_REACTIVE]) {10 return target11 }12
13 // 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy14 const existingProxy = proxyMap.get(target)28 collapsed lines
15 if (existingProxy) {16 return existingProxy17 }18
19 // 建立 target 的代理物件20 const proxy = new Proxy(target, handlers)21
22 // 儲存使用 reactive 建立的響應式物件23 proxyMap.set(target, proxy)24
25 return proxy26}27...28...29// 調整 reactive 判斷30export function isReactive(target) {31 return !!(target && target[ReactiveFlags.IS_REACTIVE])32}33
34// 先新增一個空物件,等一下再來補充35export function readonly(target) {36 return {}37}38
39// 新增 readonly 判斷40export function isReadonly(value) {41 return !!(value && value[ReactiveFlags.IS_READONLY])42}
接著回到baseHandlers.ts
,新增一個 readonlyHandler
。
1// 導入旗標2import { isRef, ReactiveFlags } from './ref'3// 引入 readonly 函式,4import { reactive, readonly } from './reactive'5
6// 擴充 createGetter,它接受一個 isReadonly 參數,並且檢查7function createGetter(isShallow = false, isReadonly = false) {8 return function get(target, key, receiver) {9 //讓 isReactive 以及 isReadonly 可以進行判斷10 if (key === ReactiveFlags.IS_REACTIVE) {11 return !isReadonly12 } else if (key === ReactiveFlags.IS_READONLY) {13 return isReadonly14 }15
30 collapsed lines
16 track(target, key)17 const res = Reflect.get(target, key, receiver)18 if (isRef(res)) {19 return res.value20 }21
22 if (isObject(res)) {23 // 如果屬於唯讀,那返回一個24 return isReadonly ? readonly(res) : isShallow ? res : reactive(res)25 }26 return res27 }28}29
30...31...32// 建立唯讀的 getter33const readonlyGet = createGetter(false, true)34// 建立唯讀的 handler,並且阻止 setter 修改跟刪除35export 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
的實作:
1import { mutableHandlers, shallowReactiveHandlers, readonlyHandlers } from './baseHandlers'2
3// 建立一個 readonly 快取map4const readonlyMap = new WeakMap()5...6...7function createReactiveObject(target, handlers, proxyMap) {8 // reactive 只處理物件9 if (!isObject(target)) return target10
11 // 如果遇到重複代理,或是唯讀物件,無需處理,並且返回本身物件12 if (target[ReactiveFlags.IS_REACTIVE] || target[ReactiveFlags.IS_READONLY]) {13 return target21 collapsed lines
14 }15
16 // 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy17 const existingProxy = proxyMap.get(target)18 if (existingProxy) {19 return existingProxy20 }21
22 // 建立 target 的代理物件23 const proxy = new Proxy(target, handlers)24
25 // 儲存使用 reactive 建立的響應式物件26 proxyMap.set(target, proxy)27
28 return proxy29}30...31...32export function readonly(target) {33 return createReactiveObject(target, readonlyHandlers, readonlyMap)34}
這樣我們就完成了 readonly
的實作。
循環引用
有些人可能會發現我們遇到循環引用的狀態
1ref.ts -> reactive.ts -> baseHandlers.ts -> ref.ts
這個問題在 CommonJS 是需要特別注意跟避免,但在現代的 ESM 中可以正常運作。
什麼是循環引用?
在過往 CommonJS 中,require()
是同步執行的,當模組 A 依賴模組 B,而模組 B 同時也依賴模組 A 時,這會導致其中一個模組在被引入時沒有初始化完全,引發執行時的錯誤。
即時綁定
ESM 的 import
/export
機制與 CommonJS 完全不同。它導出的不是一個值的拷貝,而是一個即時綁定,可以把它想像成一個指向原始變數記憶體位置的指標。
ESM 透過一個巧妙的兩階段過程來處理模組,從而解決了循環引用的問題:
- 第一階段:解析與綁定
- JavaScript 引擎首先會掃描所有相關的模組檔案,解析
import
和export
語句,建立一個完整的「依賴圖」。 - 在這個階段,引擎會為所有
export
的變數、函式、類別在記憶體中建立綁定並分配空間,但不會執行任何程式碼。
- JavaScript 引擎首先會掃描所有相關的模組檔案,解析
- 第二階段:執行與賦值
- 在所有綁定都建立好之後,引擎才開始執行每個模組的主體程式碼,將實際的函式或值放到之前預留的記憶體位置中。
- 以我們這次來說:當
baseHandlers.ts
需要import { readonly } from './reactive'
時,它得到的是readonly
這個函式的「即時綁定」。 baseHandlers.ts
模組(像是createGetter
函式的定義)可以順利執行完畢。- 之後,
reactive.ts
模組也會執行,將readonly
函式的定義填充到它的綁定中。
關鍵是執行時機
最關鍵的一點是:
baseHandlers.ts
裡的 createGetter
的get
只是定義了readonly
,它並沒有被立即呼叫。
它要等到未來某個代理物件的屬性被存取時,才會被真正執行,而到那個時候,所有模組早就完成了第二階段的執行。因此,呼叫 readonly(res)
不會有任何問題。