iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Vue.js

從零到一打造 Vue3 響應式系統系列 第 29

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

  • 分享至 

  • xImage
  •  

banner

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

Proxy

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

攔截與代理

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

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

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

handler 中,最關鍵的陷阱 (trap) 之一就是 getget(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>

day29-01

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

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

day29-02

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

day29-03

查看這個 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 透過一個巧妙的兩階段過程來處理模組,從而解決了循環引用的問題:

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

關鍵是執行時機

最關鍵的一點是:

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

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


同步更新《嘿,日安!》技術部落格


上一篇
Day 28 - shallowRef、shallowReactive
下一篇
Day 30 - 完賽心得
系列文
從零到一打造 Vue3 響應式系統30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言