JSON.stringify 欄位不見了?undefined、null 與更新契約排錯
表單狀態是 { nickname: undefined },送出的 JSON 卻成了 {}。若後端把省略欄位理解成「保持原值」,使用者按下清空後,資料就沒有改變。undefined 在物件屬性中會被省略,在陣列位置中會變成 null;要清空欄位,必須先和 API 約定明確表示法。
這裡討論的是序列化前後的欄位意義;JSON 語法本身可能完全合法,不能只看 HTTP 成功或 JSON.parse() 沒拋錯就判定保存成功。
三個位置,三種結果
MDN JSON.stringify 文件記載了 undefined 的省略與替代規則。把下面存成獨立 json-fields.mjs,執行 node json-fields.mjs:
import assert from 'node:assert/strict';
const input = { nickname: undefined, note: null };assert.equal(Object.hasOwn(input, 'nickname'), true);const raw = JSON.stringify(input);assert.equal(raw, '{"note":null}');const output = JSON.parse(raw);assert.equal(Object.hasOwn(output, 'nickname'), false);assert.equal(output.note, null);assert.equal(Object.hasOwn(input, 'nickname'), true); // 原物件沒有被刪欄位
assert.equal(JSON.stringify([undefined, null]), '[null,null]');assert.equal(JSON.stringify(new Array(2)), '[null,null]');assert.equal(JSON.stringify(undefined), undefined); // 不是字串 "undefined"assert.equal(JSON.stringify({ score: NaN }), '{"score":null}');assert.equal(JSON.stringify({ score: Infinity }), '{"score":null}');console.log('欄位省略、陣列替代與根值檢查通過');這個對照能定位損失發生在哪裡:原物件還有自有屬性,輸出的 JSON 卻沒有。如果 DevTools 顯示的是稍後才展開的物件,應另外保留序列化當下的字串;不要把畫面中的物件外觀當成送出的本文。
陣列中的空洞和 undefined 在這份 JSON 裡都變成 null,回讀後無法知道原本是哪一種。若問題起於陣列初始化,先看 new Array 的 empty slots 與 map;不能期待 JSON 往返保留所有 JavaScript 資料差異。
把「不修改」和「清空」寫成可驗收的契約
以下是假設後端已同意的單欄 API:缺少 nickname 代表不修改,字串代表設定,null 代表清空。這是範例協定,不是所有 PATCH endpoint 的共同規則;接入前應核對後端 schema、Content-Type 與實際處理方式。
在這個協定下,自己的前端函式拒絕明確傳入 undefined,避免呼叫端誤把未決定的狀態當成「不修改」。它只接受一般資料物件與自有資料屬性,拒絕額外欄位、accessor 和 Symbol key;不打算處理任意 Proxy。
import assert from 'node:assert/strict';
function nicknameUpdate(input) { if (input === null || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype) { throw new TypeError('Expected a plain update object'); } const keys = Reflect.ownKeys(input); if (keys.some(key => key !== 'nickname')) { throw new TypeError('Unexpected field'); } if (keys.length === 0) return '{}'; const descriptor = Object.getOwnPropertyDescriptor(input, 'nickname'); if (!Object.hasOwn(descriptor, 'value')) { throw new TypeError('Expected a data property'); } const value = descriptor.value; if (value !== null && typeof value !== 'string') { throw new TypeError('nickname must be a string or null'); } return JSON.stringify({ nickname: value });}
assert.equal(nicknameUpdate({}), '{}');assert.equal(nicknameUpdate({ nickname: '小夏' }), '{"nickname":"小夏"}');assert.equal(nicknameUpdate({ nickname: null }), '{"nickname":null}');assert.equal(nicknameUpdate({ nickname: '' }), '{"nickname":""}');for (const input of [ { nickname: undefined }, { nickname: 0 }, { nickname: false }, { nickname: NaN }, { nickname: 1n }, { extra: true }, [], null, { [Symbol('hidden')]: 'x' },]) { assert.throws(() => nicknameUpdate(input), TypeError);}let reads = 0;assert.throws(() => nicknameUpdate({ get nickname() { reads += 1; return 'x'; },}), TypeError);assert.equal(reads, 0);console.log('保留、設定、清空與拒絕邊界通過');範例沒有把空字串轉成 null:兩者是否等價要由業務決定。也沒有替任何字串做長度、格式或名稱可用性驗證;那些應依真實 schema 補上。它的新增價值是讓「呼叫端尚未決定」在送出前明確失敗,而非靠序列化默默省略。
為什麼不直接把所有 undefined 都轉成 null
JSON.stringify(value, (_key, item) => item === undefined ? null : item) 會把範圍擴大到整棵資料,連巢狀物件和根值也一起改變。若某些欄位的 null 代表刪除,這個通用替換就改掉了原本「沒有提供」的意思。只有整個 API 契約都同意這種轉換時才適合採用;單欄更新優先明確建立 payload。
ECMAScript JSON 序列化規則也包含 toJSON() 與 replacer 步驟。若一般欄位仍不見,檢查是否存在自訂 toJSON()、replacer 函式或欄位白名單;先用最小普通資料物件比較,別直接修改全域原型。BigInt 與循環參照還可能讓序列化拋錯,這和「成功但省略欄位」不同;大整數 ID 的表示方式可接著看 JSON 精度與字串契約。
本文兩個完整程式區塊由維護流程於 2026-10-28 在 macOS、Node.js v24.13.0 執行通過,未呼叫真實 API,也未測試後端 PATCH 或框架表單。正式驗收應比較送出前的 payload、Network 原始本文與保存後重新讀取的值;本機序列化 assertion 無法證明後端接受清空語意。
參考資料: