原创

JSON.stringify 把字段悄悄省略:undefined 不能代替清空指令

更新个人资料时,前端明明放了 bio: undefined,请求里却没有bio。后端按“缺失表示保持原值”处理,用户以为已经清空,重新打开页面旧内容还在。

JSON.stringify对undefined不是统一转成null。对象中的相应属性通常被省略,数组中的对应位置则变成null;根值本身是undefined时,返回值甚至不是一段JSON字符串。用同一个“空值”概念理解这三条路径,会把字段更新意图丢在序列化边界。

先打印真正准备发出去的文本

把下面程序保存为patch-contract.js,使用现代浏览器控制台或Node.js运行。它先展示序列化规则,再给一个范围很小的补丁接口定义明确合同:字段缺失表示不修改,null表示清空,字符串表示设为新内容,undefined直接拒绝。

'use strict';
const assert = (condition, message) => {
  if (!condition) throw new Error(message);
};
const objectText = JSON.stringify({ name: 'Lin', bio: undefined, empty: null });
const arrayText = JSON.stringify(['Lin', undefined, , null]);
console.log('object:', objectText);
console.log('array:', arrayText);
console.log('root undefined:', JSON.stringify(undefined));
assert(objectText === '{"name":"Lin","empty":null}', 'object omission');
assert(arrayText === '["Lin",null,null,null]', 'array placeholders');
assert(JSON.stringify(undefined) === undefined, 'root result is not a string');

function encodeProfilePatch(input) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
    throw new TypeError('profile patch must be an object');
  }
  const allowed = new Set(['displayName', 'bio']);
  for (const key of Object.keys(input)) {
    if (!allowed.has(key)) throw new TypeError(`unknown field: ${key}`);
  }
  const patch = {};
  for (const key of allowed) {
    if (!Object.hasOwn(input, key)) continue;
    const value = input[key];
    if (value !== null && typeof value !== 'string') {
      throw new TypeError(`${key}: string or null required`);
    }
    patch[key] = value;
  }
  return JSON.stringify(patch);
}
console.log('leave unchanged:', encodeProfilePatch({}));
console.log('clear bio:', encodeProfilePatch({ bio: null }));
console.log('set empty text:', encodeProfilePatch({ bio: '' }));
try {
  encodeProfilePatch({ bio: undefined });
  throw new Error('undefined should not silently disappear');
} catch (error) {
  if (!(error instanceof TypeError)) throw error;
  console.log('undefined patch: rejected');
}

const log = [];
const source = {
  toJSON(key) { log.push(`toJSON:${key}`); return { safe: 3 }; }
};
const transformed = JSON.stringify({ item: source }, (key, value) => {
  if (key === 'item') log.push(`replacer sees safe:${value.safe}`);
  return value;
});
console.log('transformed:', transformed);
console.log('order:', log.join(' | '));
assert(transformed === '{"item":{"safe":3}}', 'toJSON transformation');
assert(log[0] === 'toJSON:item' && log[1] === 'replacer sees safe:3', 'execution order');

对象里bio消失,数组里undefined和空槽都占一个null位置,根undefined仍然是JavaScript的undefined。三个结果都没有抛异常,不代表它们承载同一种业务意图。

补丁编码器则把清空、空字符串与不修改分别保留下来。这里null的含义是本接口规定的清空,不是JSON本身规定所有后端都应该如此处理。数据库是否允许NULL、清空后是否改成空字符串,也应由服务端合同决定。

用 hasOwn 区分没有字段与字段值为空

如果直接写 if (input.bio),空字符串和null都会被当成假,无法表达清空。若只检查 input.bio !== undefined,缺失与显式undefined又被合成同一种状态。需要区分字段存在性时,应先确认自有属性,再验证允许值。

示例不接受任意对象图,只处理两项标量字段。实际接口还应限定文本长度、字段格式与授权范围,不能仅靠JSON.stringify成功就把数据视为合规。字符串里包含多少字符、是否允许空白,仍然是输入验证的一部分。

输入对象若来源于应用内部模型,还可能带getter或自定义原型。编码器读取字段时可以执行这些行为,因此更稳妥的调用方式是传入明确的数据传输对象,或在接收JSON后验证其模型。它不是任意对象的安全净化器。

用 replacer 全局把 undefined 变 null,可能改错合同

写一个replacer把undefined返回为null,确实可以让对象字段不再消失。但它同时把所有嵌套位置都解释成显式空值;原本表示“不知道”“没有提供”的状态可能被改成清空请求。

在资料补丁里,全局替换尤其危险。某个表单控件没挂载就产生undefined,本应保持旧值,通用替换却可能清掉后端数据。正确做法是先把表单意图转换成接口允许的状态,再序列化,而不是让序列化回调猜用户想做什么。

数组也不是对象规则的简单延伸。位置常有含义,省掉某个元素会让后续下标移动,因此JSON表示选择保留null占位。但应用若要求数组每项必须是合法对象,应该在序列化前拒绝空槽或undefined,不能让null替你掩盖缺项。

toJSON 可以在 replacer 之前改变数据

最后一组输出显示,source先用toJSON变成safe对象,replacer在item位置看到的已经是转换结果。replacer不是保证能见到所有原始字段的一道审计关卡。

一些内置对象也有自己的JSON表示。序列化之后的结构未必还能恢复原来的类型,更不能指望JSON往返深拷贝保留方法、资源或对象身份。本文只围绕字段意图,不把它包装成通用克隆办法。

如果日志与实际请求不一致,应同时查看序列化前的数据模型和最终文本。浏览器控制台展示对象有时反映后续状态,而请求发送的是某一时刻生成的字节。把最终JSON字符串作为受控调试证据,比展开一个稍后才观察的对象更明确;其中敏感字段仍需按日志规则处理。

数值字段还有另一种静默变形:非有限数值可以在JSON表示里变成null。若把计算失败产生的NaN直接放进请求,后端看见的可能是一个合法空值,而不是前端算术错误。需要有限数值的字段应在编码前使用明确检查,不能等到JSON文本生成以后再猜null来自哪里。

这也说明序列化前的模型验证不是重复劳动。字段存在性、值域和更新意图在JavaScript里更丰富,进入JSON之后某些区别已经不可逆地消失。后端仍然要验证JSON本身,但它无法知道一个缺失字段最初是用户没改、表单没加载,还是被undefined静默省略。

循环引用与BigInt等还有各自失败路径,不能因为本例undefined静默处理,就认为所有不支持的值都会自动消失。调用方应让序列化异常进入明确失败分支,避免UI显示保存成功却没有形成请求。

toJSON和replacer本身也可能抛异常。它们不是只影响显示的格式选项,而是编码过程中实际执行的代码;错误处理应围绕完整编码操作建立,并且只有文本成功形成后才进入请求发送阶段。编码失败时也要保留尚未提交的用户输入。

重新验证清空资料时,至少核对三份证据:编码器返回含bio:null的文本,服务端按清空合同应用更新,重新读取结果符合预期。第一步若已经丢掉字段,继续排查数据库默认值通常只会把真正问题绕过去。

正文到此结束
评论插件初始化中...
Loading...