在開始 readonly 之前,我們先講一下 Proxy 的補充知識:
Proxy
是實現 reactive
、readonly
等功能的核心。它會在目標物件前架設一個「代理」或「攔截層」,讓我們有機會對外界的存取操作進行自訂處理。
Proxy
的工作模式可以想像成一個保全:
target
):是公司內部的辦公室。proxy
):保全本人。handler
):是保全應對手冊,裡面寫了存取物件時的該如何處理的邏輯。任何外部程式碼(訪客)要存取物件屬性(進辦公室)都需要經過 Proxy
(保全),Proxy
可以知道 handler
(保全手冊)來決定如何回應。
在handler
中,最關鍵的陷阱 (trap) 之一就是 get
。get(target, key, receiver)
:這個陷阱的觸發時機是當程式碼試圖讀取代理物件屬性時,縱使原始物件沒有這個屬性,它也可以透過 handler 的規則下去處理。
了解這些之後,可以開始實作了!
readonly 只接受物件參數,在前面的文章有寫到 ref 如果傳入是物件的話,那就會回傳一個 reactive,因此在 readonly 實作,我們只要針對 reactive 完成就可以。
<body>
<div id="app"></div>
<script type="module">
import { readonly, reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
// import { readonly, effect, reactive } from '../dist/reactivity.esm.js'
const state = reactive({
a:1,
b:{
c:1
}
})
const readonlyState = readonly(state)
effect(() => {
console.log(readonlyState.a)
})
setTimeout(() => {
state.a++
}, 1000)
</script>
</body>
如果你設定一個readonly
物件,修改傳入的物件,readonly 仍然會接受到響應式的觸發更新。
setTimeout(() => {
readonlyState.a++
}, 1000)
但如果你修改的是 readonly 物件,那就會跳出警告。
查看這個 readonly 物件,可以發現它就是 reactive 物件,是由 _isReadonly
旗標來判斷,這跟我們上一個章節在寫 shallow
的時候特別像。
首先,我們先在 ref.ts
增加附註的旗標,分別是 IS_REACTIVE
以及 IS_READONLY
:
//ref.ts
export enum ReactiveFlags {
IS_REF = '__v_isRef',
IS_REACTIVE = '__v_isReactive',
IS_READONLY = '__v_isReadonly'
}
接著調整一下 reactive,我們移除原有的 Set
檢查,改為透過旗標來判斷是否需要重複代理。
//reactive.ts
import { ReactiveFlags } from './ref'
...
...
function createReactiveObject(target, handlers, proxyMap) {
// reactive 只處理物件
if (!isObject(target)) return target
// 統一處理「防止重複代理」的情況,這個檢查取代了 reactiveSet
if (target[ReactiveFlags.IS_REACTIVE]) {
return target
}
// 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy
const existingProxy = proxyMap.get(target)
if (existingProxy) {
return existingProxy
}
// 建立 target 的代理物件
const proxy = new Proxy(target, handlers)
// 儲存使用 reactive 建立的響應式物件
proxyMap.set(target, proxy)
return proxy
}
...
...
// 調整 reactive 判斷
export function isReactive(target) {
return !!(target && target[ReactiveFlags.IS_REACTIVE])
}
// 先新增一個空物件,等一下再來補充
export function readonly(target) {
return {}
}
// 新增 readonly 判斷
export function isReadonly(value) {
return !!(value && value[ReactiveFlags.IS_READONLY])
}
接著回到baseHandlers.ts
,新增一個 readonlyHandler
。
// 導入旗標
import { isRef, ReactiveFlags } from './ref'
// 引入 readonly 函式,
import { reactive, readonly } from './reactive'
// 擴充 createGetter,它接受一個 isReadonly 參數,並且檢查
function createGetter(isShallow = false, isReadonly = false) {
return function get(target, key, receiver) {
//讓 isReactive 以及 isReadonly 可以進行判斷
if (key === ReactiveFlags.IS_REACTIVE) {
return !isReadonly
} else if (key === ReactiveFlags.IS_READONLY) {
return isReadonly
}
track(target, key)
const res = Reflect.get(target, key, receiver)
if (isRef(res)) {
return res.value
}
if (isObject(res)) {
// 如果屬於唯讀,那返回一個
return isReadonly ? readonly(res) : isShallow ? res : reactive(res)
}
return res
}
}
...
...
// 建立唯讀的 getter
const readonlyGet = createGetter(false, true)
// 建立唯讀的 handler,並且阻止 setter 修改跟刪除
export const readonlyHandlers = {
get: readonlyGet,
set(target, key) {
console.warn(`Set operation on key "${String(key)}" failed: target is readonly.`)
return true // 阻止修改
},
deleteProperty(target, key) {
console.warn(`Delete operation on key "${String(key)}" failed: target is readonly.`)
return true // 阻止刪除
}
}
createGetter
的旗標邏輯是:縱使旗標是原始物件上一個不存在的屬性,但當外部程式碼(如 isReadonly
)訪問它時,代理物件的 getter
會被觸發。 JavaScript 引擎會發現它是一個代理物件,因此 getter
會根據傳入的 isReadonly
參數回傳對應的布林值。
我們回到 reactive.ts
,完成 readonly
的實作:
//reative.ts
import { mutableHandlers, shallowReactiveHandlers, readonlyHandlers } from './baseHandlers'
// 建立一個 readonly 快取map
const readonlyMap = new WeakMap()
...
...
function createReactiveObject(target, handlers, proxyMap) {
// reactive 只處理物件
if (!isObject(target)) return target
// 如果遇到重複代理,或是唯讀物件,無需處理,並且返回本身物件
if (target[ReactiveFlags.IS_REACTIVE] || target[ReactiveFlags.IS_READONLY]) {
return target
}
// 如果這個 target 已經被 reactive 過了,直接返回已經建立好的 proxy
const existingProxy = proxyMap.get(target)
if (existingProxy) {
return existingProxy
}
// 建立 target 的代理物件
const proxy = new Proxy(target, handlers)
// 儲存使用 reactive 建立的響應式物件
proxyMap.set(target, proxy)
return proxy
}
...
...
export function readonly(target) {
return createReactiveObject(target, readonlyHandlers, readonlyMap)
}
這樣我們就完成了 readonly
的實作。
有些人可能會發現我們遇到循環引用的狀態
ref.ts -> reactive.ts -> baseHandlers.ts -> ref.ts
這個問題在 CommonJS 是需要特別注意跟避免,但在現代的 ESM 中可以正常運作。
在過往 CommonJS 中,require()
是同步執行的,當模組 A 依賴模組 B,而模組 B 同時也依賴模組 A 時,這會導致其中一個模組在被引入時沒有初始化完全,引發執行時的錯誤。
ESM 的 import
/export
機制與 CommonJS 完全不同。它導出的不是一個值的拷貝,而是一個即時綁定,可以把它想像成一個指向原始變數記憶體位置的指標。
ESM 透過一個巧妙的兩階段過程來處理模組,從而解決了循環引用的問題:
import
和 export
語句,建立一個完整的「依賴圖」。export
的變數、函式、類別在記憶體中建立綁定並分配空間,但不會執行任何程式碼。baseHandlers.ts
需要 import { readonly } from './reactive'
時,它得到的是 readonly
這個函式的「即時綁定」。baseHandlers.ts
模組(像是 createGetter
函式的定義)可以順利執行完畢。reactive.ts
模組也會執行,將 readonly
函式的定義填充到它的綁定中。最關鍵的一點是:
baseHandlers.ts
裡的 createGetter
的get
只是定義了readonly
,它並沒有被立即呼叫。
它要等到未來某個代理物件的屬性被存取時,才會被真正執行,而到那個時候,所有模組早就完成了第二階段的執行。因此,呼叫 readonly(res)
不會有任何問題。
同步更新《嘿,日安!》技術部落格