Object.freeze 後巢狀物件還會變?設定凍結與資料所有權檢查
本文目錄
Object.isFrozen(config) 回傳 true,config.api.timeout 卻仍能改成另一個值,通常是只凍結了外層。Object.freeze 作用在傳入的那個物件,巢狀物件要各自處理。
這個問題適合先查資料所有權:你要保護的是自己建立的固定設定,還是別人也持有的共用資料?freeze() 會原地修改物件的屬性限制並回傳同一物件。對共用參照凍結,其他使用者也會遇到寫入限制。
用斷言看出哪一層還能改
依 MDN Object.freeze與 ECMAScript SetIntegrityLevel的定義,freeze 會限制自有屬性及物件擴充性。它沒有遞迴走訪整張物件圖。
存成 freeze.mjs,以 node freeze.mjs 執行。ES module 為嚴格模式,範例把寫入凍結屬性造成的 TypeError 也列入檢查:
import assert from 'node:assert/strict';
const config = { api: { timeout: 1000 }, tags: ['stable'] };assert.equal(Object.freeze(config), config);assert.equal(Object.isFrozen(config), true);assert.equal(Object.isFrozen(config.api), false);assert.throws(() => { config.api = {}; }, TypeError);config.api.timeout = 2000;config.tags.push('beta');assert.equal(config.api.timeout, 2000);assert.deepEqual(config.tags, ['stable', 'beta']);
// 已知且由自己建立的設定結構:逐層凍結。const fixed = Object.freeze({ api: Object.freeze({ timeout: 1000 }), servers: Object.freeze([ Object.freeze({ url: 'https://api.example.test' }), ]),});assert.throws(() => { fixed.api.timeout = 2000; }, TypeError);assert.throws(() => fixed.servers.push({ url: 'x' }), TypeError);assert.throws(() => { fixed.servers[0].url = 'x'; }, TypeError);
// 凍結 Map 物件,不會封住其內部的資料操作。const lookup = Object.freeze(new Map([['mode', 'stable']]));lookup.set('mode', 'beta');assert.equal(Object.isFrozen(lookup), true);assert.equal(lookup.get('mode'), 'beta');console.log('凍結層級檢查通過');const 管變數綁定,freeze 管該物件的屬性限制;兩者解決不同層次的修改。Map 的例子則提醒:即使所有可見自有屬性都凍結,內部資料結構仍可能有自己的修改 API。
共用輸入先決定要不要複製
假設外部程式傳入 source.api,你的設定也直接引用它。若接著凍結 config.api,就凍結了同一份來源。這可能符合共享唯讀設定的約定,也可能突然破壞呼叫端。
對下面這種已知欄位的簡單設定,重建內層物件能讓所有權清楚,並在邊界驗證需要的值:
import assert from 'node:assert/strict';
function makeConfig(source) { const timeout = source?.api?.timeout; if (!Number.isSafeInteger(timeout) || timeout <= 0) { throw new TypeError('timeout 必須是正安全整數'); } return Object.freeze({ api: Object.freeze({ timeout }) });}
const source = { api: { timeout: 1000 }, extra: '不帶入' };const isolated = makeConfig(source);assert.notEqual(isolated.api, source.api);source.api.timeout = 2000;assert.equal(isolated.api.timeout, 1000);assert.equal(Object.isFrozen(source.api), false);assert.equal(Object.hasOwn(isolated, 'extra'), false);assert.throws(() => makeConfig({ api: { timeout: -1 } }), TypeError);assert.throws(() => makeConfig({}), TypeError);console.log('設定所有權檢查通過');這段刻意只支援一個正整數欄位,沒有宣稱是任意深拷貝。需要保存更多資料時逐欄定義契約;若資料確實能被 structured clone,先複製再凍結可分開處理參照與寫入限制,細節見 structuredClone 的型別與 transfer 邊界。clone 本身不會讓副本自動唯讀。
要遞迴 deepFreeze,先縮小資料種類
已知小型設定用上面逐層建立的方式較容易審查。若要封裝通用函式,需先決定支援哪些型別:循環參照需要 visited 集合、Symbol key 要納入走訪、getter 可能執行額外邏輯,Map/Set 等內部狀態不能只靠一般屬性遞迴封住。任意遞迴也有深度與效能成本。
因此驗收至少包含「巢狀欄位確實不能寫」、「來源沒有被順帶凍結」與「不支援的型別會明確拒絕」。這比只檢查最外層 Object.isFrozen() 更能反映設定契約。freeze 是避免意外修改的工具;物件中的函式、存取器與外部資源行為仍由各自實作決定。
2026-11-11 在 macOS、Node.js 24.13.0 執行本文兩段程式通過。尚未測試框架 reactive proxy、DOM 物件或大型物件圖,本文也未提供通用 deepFreeze 實作。
參考資料: