929 字
5 分鐘

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 實作。

參考資料:

MDN:Object.freeze()

ECMAScript:SetIntegrityLevel

MDN:const

MDN:structuredClone()

Object.freeze 後巢狀物件還會變?設定凍結與資料所有權檢查
https://laplusda.com/posts/javascript-object-freeze-nested-config/
作者
Zero
發佈於
2026-11-11
許可協議
CC BY-NC-SA 4.0