先讲个我真遇到过的事。
那天下午,运营在群里发了张截图:一个商品的折扣设置成了 0(就是免费送,做活动用的),但前台显示原价。
我们查了两个小时。接口对的,数据库对的,Redux 里也对的。最后定位到组件里这一行:
ini
const discount = config.discount || 1;
看出来了吗?用户填的 0,被 || 当成"没填",兜底成了 1。
一个字符的问题,查了两小时。
后来我把项目里所有 || 兜底的地方搜了一遍------37 处,其中 9 处有同样的风险。
这件事之后我开始留意:JavaScript 这些年悄悄补了一堆东西,??、Object.hasOwn、structuredClone、Array.at......都不是什么实验性语法,Chrome、Node 早就全支持了。但很多人的手速还停在 2018 年。
下面这 10 个,全是我在真实项目里用得最多的。每个都配一个具体场景------不是"这个 API 是干什么的",而是"你什么时候会需要它"。
1. ?? ------ 那个查了我两小时的坑
场景:用户把开关关了,但它自己又打开了
先说清楚 || 到底在判断什么。
它判断的不是"有没有值",而是"是不是假值"。JS 里的假值有一串:
javascript
0 '' false null undefined NaN
只要落进这个范围,|| 就认为你没给值。
问题就在这儿:0、''、false 在业务里经常是用户真实的输入。
来看三个我踩过的:
ini
const discount = form.discount || 1; // 用户填 0 → 变成 1(开头那个 bug)
const remark = form.remark || '暂无'; // 用户清空备注 → 又冒出「暂无」
const autoPlay = form.autoPlay || true; // 用户关掉自动播放 → 关不掉
第三行最阴险,展开看:
- 用户关掉 →
false || true→true - 用户开着 →
true || true→true
这个开关永远是开的,而且不报任何错。
换成 ??
?? 只认两个东西:null 和 undefined。其他一律放行。
ruby
const discount = form.discount ?? 1; // 0 就是 0 ✅
const remark = form.remark ?? '暂无'; // '' 就是 '' ✅
const autoPlay = form.autoPlay ?? true; // false 就是 false ✅
两者的差别画出来一目了然:
javascript
null undefined 0 '' false NaN
┌──────┬─────────┬─────┬─────┬──────┬─────┐
|| │ 兜底 │ 兜底 │兜底 │兜底 │ 兜底 │兜底 │
?? │ 兜底 │ 兜底 │放行 │放行 │ 放行 │放行 │
└──────┴─────────┴─────┴─────┴──────┴─────┘
↑ 差别全在这四个上
一句话记住
默认全写
??。 只有当你真的想让空字符串也走默认值时,才写||,并且加一行注释说明是故意的。
顺带一个会报错的细节:?? 不能跟 ||、&& 直接混着写,必须加括号。
ini
const v = a ?? b || c; // ❌ SyntaxError,直接跑不起来
const v = (a ?? b) || c; // ✅
这是 JS 标准故意这么设计的------因为混在一起谁都算不清优先级,与其让你写出 bug,不如直接不让你写。
2. ?. ------ 详情页最常见的那个白屏
场景:后端某个字段没返回,整个页面崩了
商品详情页要显示卖家所在城市,数据长这样:
kotlin
res.data.seller.address.city
正常情况没问题。但如果这个卖家没填地址,address 是 null,那么:
kotlin
const city = res.data.seller.address.city;
// ❌ TypeError: Cannot read properties of null (reading 'city')
React 里这一行会直接白屏。 不是"这个字段不显示",是整个页面没了。
所以以前我们得写成这样:
kotlin
const city = res && res.data && res.data.seller && res.data.seller.address
? res.data.seller.address.city
: '';
写到第三层就开始烦,而且一旦后端加了层级,这串还得改。
现在一行
ini
const city = res?.data?.seller?.address?.city ?? '';
?. 的意思是:"如果左边是 null 或 undefined,整条链路直接停下,返回 undefined,不报错。"
配上 ?? 兜个默认值,就完整了。
另外两种形态,用得比属性访问还多
可选调用------React 里的回调 prop:
scss
// 以前
if (props.onChange) {
props.onChange(newValue);
}
// 现在
props.onChange?.(newValue);
这一行在组件库里能省掉几十个 if。父组件没传 onChange,这里什么都不会发生,也不会报错。
动态取值:
ini
const val = data?.[currentKey]; // 中括号前也要加 ?.
const first = list?.[0]?.name; // 数组元素
但别拿它当遮羞布
我 review 时看到过这种:
ini
const n = a?.b?.c?.d?.e?.f ?? 0;
这行代码"不报错",但你已经完全不知道数据在哪一层断的了。出问题只能一层层 console.log。
判断标准 :?. 是用来处理"这个字段本来就可能没有 "的(比如可选的地址、可选的回调)。如果你是因为"不确定后端返回什么 "才加的 ?.,那问题不在这行,在数据入口------该在那儿做一次结构规整。
3. Object.hasOwn() ------ 判断"用户到底改没改这个字段"
场景:编辑表单,只提交改过的字段
后台的编辑页,产品要求"用户没动的字段不要提交",避免覆盖别人同时改的内容。
这就需要区分两种情况:
- 这个 key 压根不存在 → 用户没动过
- 这个 key 存在,值是空 → 用户主动清空了
用 if (form.remark) 判断不了------两种情况都是假值。得判断"key 在不在"。
老写法为什么难看
教科书写法:
less
if (user.hasOwnProperty('email')) { ... }
三个问题,我按遇到的概率排:
第一,ESLint 直接报警。 no-prototype-builtins 规则默认开启,于是大家被迫写成这种鬼东西:
javascript
if (Object.prototype.hasOwnProperty.call(user, 'email')) { ... }
一行代码,看三遍才知道在干嘛。
第二,字典对象会直接报错。 存 key-value 时为了安全,常这么创建对象:
ini
const dict = Object.create(null);
dict.a = 1;
dict.hasOwnProperty('a');
// ❌ TypeError: dict.hasOwnProperty is not a function
因为这个对象没有原型,也就没有 hasOwnProperty 这个方法。
第三,后端数据可能把它顶掉。 听着离谱,但接口返回用户自定义表单字段时真会碰上:
kotlin
const data = { hasOwnProperty: () => false, email: 'a@b.com' };
data.hasOwnProperty('email'); // false ← 骗你的
Object.hasOwn(data, 'email'); // true ← 对的
新写法
javascript
const user = { name: '张伟', age: 25 };
Object.hasOwn(user, 'name'); // true
Object.hasOwn(user, 'email'); // false
Object.hasOwn(user, 'toString'); // false ← 原型上的不算
上面三种坑一次全解决。
顺带分清 in
sql
'toString' in user // true ← in 会查原型链
Object.hasOwn(user, 'toString') // false ← 只看对象自己
记法 :问"这条数据自己有没有这个字段",用 Object.hasOwn;问"这个对象能不能访问到这个属性",用 in。业务代码里,99% 的场景你要的是前者。
4. structuredClone() ------ Date 变成字符串的那次事故
场景:拷贝一份订单数据,然后 .getTime() 炸了
深拷贝这事,国内项目十有八九是这么干的:
ini
const copy = JSON.parse(JSON.stringify(original));
能用。但它会悄悄改掉你的数据类型,而且不报错。
我实际跑了一遍给你看:
javascript
const order = {
id: 1001,
createdAt: new Date('2026-08-08'),
remark: undefined,
tags: new Set(['急单']),
total: NaN,
};
console.log(JSON.parse(JSON.stringify(order)));
真实输出:
csharp
{
id: 1001,
createdAt: '2026-08-08T00:00:00.000Z', // ← Date 变字符串了
tags: {}, // ← Set 变空对象了
total: null, // ← NaN 变 null 了
// remark 整个字段消失了
}
四个字段,三个变了,一个没了。
然后下游代码写 copy.createdAt.getTime()------报错。而报错的位置离拷贝那行可能隔着五个文件,这类 bug 特别难查,因为拷贝那一行看起来毫无问题。
structuredClone 直接原样保留
javascript
const copy = structuredClone(order);
copy.createdAt instanceof Date // true
copy.tags instanceof Set // true
Number.isNaN(copy.total) // true
'remark' in copy // true
四个全对。
它支持 Date、Map、Set、RegExp、ArrayBuffer、Blob、File、TypedArray,还能正确处理循环引用(对象套自己,JSON 大法这里会直接抛错)。
它不支持什么
函数、Symbol、DOM 节点,还有类实例的原型:
javascript
class Product { constructor(){ this.id = 1 } getName(){} }
const p = structuredClone(new Product());
p instanceof Product // false ← 变成普通对象了,方法丢了
函数的话直接抛错:
javascript
structuredClone({ fn(){} });
// ❌ DOMException [DataCloneError]
报错反而是好事------总比 JSON 那样默默吞掉、等你上线才发现强。
三种方案怎么选
javascript
Date/Set/Map 函数 循环引用 类实例 体积
────────────────────────────────────────────────────────────
JSON 大法 ✘ ✘ ✘ ✘ 0
structuredClone ✔ ✘ ✔ ✘ 0(原生)
lodash cloneDeep ✔ ✔ ✔ ✔ ~70KB
────────────────────────────────────────────────────────────
结论 :以前无脑上 lodash 的场景,现在大部分能换成 structuredClone,省一个依赖。只有需要拷贝函数、或者要保住类实例的时候,才值得引 lodash。
5. Array.at(-1) ------ 取最后一条消息
场景:聊天窗口显示"最新一条"
ini
// 以前
const lastMsg = state.conversation.messages[state.conversation.messages.length - 1];
同一个路径写了两遍,改一个变量名要改两处。
ini
// 现在
const lastMsg = state.conversation.messages.at(-1);
at(-1) 是倒数第一,at(-2) 是倒数第二,以此类推。
链式调用里差别更大
比如"拿到已付款订单里最新的一条":
ini
// 用 at,一行
const latest = orders.filter(o => o.status === 'paid').at(-1);
// 不用 at,必须先存个临时变量
const paid = orders.filter(o => o.status === 'paid');
const latest = paid[paid.length - 1];
因为 list.length 需要一个名字,而 .at() 不需要。链式写法里这个差别很明显。
字符串也有
arduino
'abc'.at(-1) // 'c'
判断结尾字符、取扩展名的时候顺手。
空数组调 at(-1) 返回 undefined,不会报错。
6. Object.entries() ------ 后端返回个 map,前端要渲染下拉框
场景:订单状态筛选器
后端返回的状态字典长这样:
arduino
const statusMap = {
pending: '待付款',
paid: '已付款',
shipped: '已发货',
};
要渲染成下拉选项。Object.entries() 把它变成 [key, value] 数组:
javascript
{Object.entries(statusMap).map(([code, label]) => (
<Option key={code} value={code}>{label}</Option>
))}
一行搞定。三兄弟按需选:只要 key 用 Object.keys(),只要值用 Object.values(),都要用 Object.entries()。
反向操作才是真香
Object.fromEntries() 能把数组变回对象。它和 entries 组合,等于给对象加上了 map 和 filter。
场景一:提交表单前清理空参数
前端表单经常有一堆没填的字段,直接提交会让后端收到一串 undefined。
javascript
const params = { name: '张伟', age: 0, remark: '', tag: null, city: undefined };
const clean = Object.fromEntries(
Object.entries(params).filter(([, v]) => v !== '' && v != null)
);
// { name: '张伟', age: 0 }
注意 age: 0 被保留了------因为条件写的是 v != null(松散比较,同时排除 null 和 undefined),不是 if (v)。又是那个 0 的坑。
场景二:解析 URL 查询参数
ini
const query = Object.fromEntries(new URLSearchParams(location.search));
// ?page=2&size=20 → { page: '2', size: '20' }
这行我几乎每个项目都会写一遍。以前得手写 split 循环,现在一行。
7. Promise.allSettled() ------ 一个接口挂了,首页整个白屏
场景:首页并发三个模块
scss
const [banner, goods, notice] = await Promise.all([
fetchBanner(),
fetchGoods(),
fetchNotice(),
]);
看起来挺合理。但 Promise.all 的语义是"全成功才算成功"。
公告接口 500 了,整个 await 抛异常。而 banner 和 goods 明明已经拿到数据了------它们就在内存里------页面照样白屏。
一个不重要的公告栏,搞挂了整个首页。
换成 allSettled
scss
const results = await Promise.allSettled([
fetchBanner(),
fetchGoods(),
fetchNotice(),
]);
它永远不会 reject。每一项都会告诉你成还是败:
css
[ { status: 'fulfilled', value: [...] },
{ status: 'fulfilled', value: [...] },
{ status: 'rejected', reason: Error('500') },
]
处理起来:
ini
const [banner, goods, notice] = results.map(r =>
r.status === 'fulfilled' ? r.value : null
);
// 失败的顺手报给监控,不影响页面
results
.filter(r => r.status === 'rejected')
.forEach(r => reportError(r.reason));
公告接口挂了,那块显示"暂无公告",其他照常。能显示多少显示多少,这才是首页该有的行为。
四个方法怎么选
python
什么时候结束 你拿到什么
──────────────────────────────────────────────────────
all 全成功 / 任一失败 全部结果 / 第一个错误
allSettled 全部尘埃落定 每一项的成败明细
race 第一个有结果的 它的结果(成败都算)
any 第一个成功的 它的值 / 全失败才报错
──────────────────────────────────────────────────────
下单 → 扣库存 → 发通知 任一失败必须中断 → all
首页三个模块并发 各显示各的 → allSettled
接口超时控制 谁先到用谁 → race
多 CDN 取同一份资源 谁先成功用谁 → any
Promise.any 顺带提一句:它会跳过失败的继续等 ,直到有一个成功。多节点容灾比 race 更符合直觉------race 是"第一个有结果的",那个结果可能是失败。
8. console.table() ------ 别再瞪着一堆折叠箭头了
场景:接口返回 50 条订单,要检查某个字段
console.log(list) 出来是一坨 ▶ {...},得一个个点开。
ini
console.table(orders);
直接渲染成表格,一行一条数据,一列一个字段,点表头还能排序。
字段太多看着乱?第二个参数指定只看哪几列:
css
console.table(orders, ['id', 'status', 'amount']);
要检查"是不是所有已付款订单的金额都大于 0",扫一眼表格就完了,比展开 50 个折叠快十倍。
顺手三个被埋没的 console
分组折叠,日志一多就救命:
javascript
console.group('订单校验');
console.log('库存', stock);
console.log('优惠券', coupon);
console.groupEnd();
断言 ------条件为真时完全没有输出,不污染控制台:
java
console.assert(total > 0, '总价异常', order);
我拿它当开发环境的轻量断言用,比 if (xxx) console.warn(...) 省事。
计时:
javascript
console.time('render');
// ...
console.timeEnd('render'); // render: 12.4ms
9. 解构默认值 ------ 把参数校验写进函数签名
场景:配置项越来越多,函数开头堆了十行 if
基础用法大家都会:
ini
const { name, role = '普通用户', pageSize = 20 } = user;
但下面这个坑,我见过太多人栽。
坑:后端返回 null,默认值不生效
ini
const { count = 10 } = { count: null }; // count 是 null,不是 10 ❗
const { count = 10 } = { count: undefined }; // count 是 10 ✅
const { count = 10 } = {}; // count 是 10 ✅
规则是:默认值只在值严格等于 undefined 时才生效。
而后端用 null 表示"无值"是非常常见的(Java 和数据库那边天然就是 null)。这意味着你写的默认值在真实接口面前经常不起作用。
两个解法:
kotlin
// 方法一:补一刀 ??
const { count } = data;
const finalCount = count ?? 10;
// 方法二(推荐):在接口层统一洗数据,把 null 转成 undefined
我倾向方法二------在 axios 拦截器里统一处理,业务代码就干净了。
嵌套解构:那两个 = {} 千万别漏
javascript
function initChart({
title = '未命名图表',
axis: { x = '时间', y = '数值' } = {}, // ← 这个 = {}
} = {}) { // ← 这个也是
// ...
}
initChart(); // 不传参也不会炸
漏掉的话,initChart() 会在解构 undefined 时直接报错------因为对 undefined 做解构是非法的。
记法 :每一层解构,都要给这一层准备一个空对象兜底。
重命名 + 默认值:对付下划线接口
后端字段是下划线,前端要驼峰,还要给默认值:
ini
const {
user_name: userName = '匿名',
create_time: createTime = Date.now(),
} = res.data;
一行同时做完改名和兜底。接老系统接口的时候特别顺手。
10. 数字分隔符 ------ 一秒读出这是多少
最后一个最简单的。
ini
const MAX_UPLOAD = 10485760;
const DAY_MS = 86400000;
数一数上面有几个零。现在:
ini
const MAX_UPLOAD = 10_485_760; // 一眼看出是 10MB
const DAY_MS = 86_400_000; // 一眼看出是一天
下划线纯粹给人看,编译后完全消失 ,值一模一样(10_485_760 === 10485760 是 true)。
不止十进制。按语义分组比按三位分组更有用:
ini
const FLAGS = 0b1010_0001; // 权限位,四个一组
const COLOR = 0xFF_6B_35; // RGB,两位正好一个通道
const BIG = 9_007_199_254_740_991n;
限制:不能放开头结尾、不能挨着小数点、不能连写两个。写错了直接语法报错,不会悄悄出问题。
全部串起来看,到底省多少
拿一个真实的"加载用户配置"函数对比。这是老写法:
ini
function loadConfig(res) {
var data = res && res.data ? res.data : {};
var conf = JSON.parse(JSON.stringify(data));
var theme = conf.theme;
if (theme === undefined || theme === null || theme === '') {
theme = 'light';
}
var pageSize = conf.pageSize || 20;
var list = conf.history;
var lastItem = list && list.length ? list[list.length - 1] : null;
var opts = [];
for (var key in conf.map) {
if (Object.prototype.hasOwnProperty.call(conf.map, key)) {
opts.push({ value: key, label: conf.map[key] });
}
}
return { theme: theme, pageSize: pageSize, lastItem: lastItem, opts: opts };
}
换成现在的写法:
javascript
function loadConfig(res) {
const { theme = 'light', pageSize = 20, history = [], map = {} } =
structuredClone(res?.data ?? {});
return {
theme,
pageSize,
lastItem: history.at(-1) ?? null,
opts: Object.entries(map).map(([value, label]) => ({ value, label })),
};
}
24 行 → 9 行。 而且顺手修掉了两个 bug:
pageSize填 0 时不再被吞成 20history里如果有 Date 字段,不会在拷贝时变成字符串
TypeScript 版本:
css
interface RawConfig {
theme?: 'light' | 'dark';
pageSize?: number;
history?: Array<{ id: string; at: Date }>;
map?: Record<string, string>;
}
interface LoadedConfig {
theme: 'light' | 'dark';
pageSize: number;
lastItem: { id: string; at: Date } | null;
opts: Array<{ value: string; label: string }>;
}
function loadConfig(res?: { data?: RawConfig }): LoadedConfig {
const { theme = 'light', pageSize = 20, history = [], map = {} } =
structuredClone(res?.data ?? {});
return {
theme,
pageSize,
lastItem: history.at(-1) ?? null,
opts: Object.entries(map).map(([value, label]) => ({ value, label })),
};
}
TS 这里有个隐藏收益:structuredClone 的类型是原样透传的,lastItem.at 在类型和运行时都是 Date。
换成 JSON.parse(JSON.stringify()),TS 依然会告诉你那是 Date(因为 JSON.parse 返回 any),但运行时是 string------类型骗了你。这种 bug 是最难查的一类,因为编译器站在错误的一边。
能不能直接上生产
这几个里最"新"的是 Object.hasOwn 和 structuredClone,都是 2022 年前后铺开的。现状:
- 主流浏览器近三年的版本:全部支持,不用查
- Node.js :16.9+ 支持
Object.hasOwn,17+ 支持structuredClone - 微信小程序 / 老 webview :
structuredClone要留个心眼,基础库版本低的可能没有。用之前判断一下:
javascript
const clone = typeof structuredClone === 'function'
? structuredClone
: (o) => JSON.parse(JSON.stringify(o));
- 构建工具:这里有个容易搞混的点------
javascript
语法层面(Vite/webpack 会自动降级,放心用)
?. ?? 数字分隔符 解构默认值
运行时 API(需要 core-js polyfill 才能降级)
Object.hasOwn structuredClone Array.at
Object.fromEntries Promise.allSettled
一句话:语法糖放心用;运行时 API 看目标环境。To B 后台项目基本无脑上,To C 且要兼容老安卓的,加个 polyfill 或做个降级判断。
写在最后
这十个没一个是"高级技巧",全是 JS 这些年为常见问题补上的官方答案。
它们不会让你的代码快 10 倍,但会让下一个接手的人少骂两句------包括三个月后的你自己。
我这几年最深的一个体会:语言本身的更新,收益比追新框架高得多。 框架三年换一茬,?? 你会用一辈子。
最后聊聊 👇
这十个里,你入职到现在真正用过几个?
我猜 ?. 和解构默认值人人都会,?? 一半一半,structuredClone 和 Object.hasOwn 可能不少人今天第一次见。
评论区报个数就行,比如「用过 6 个,第 4 个第一次听说」。
另外那个问题我更好奇:
你踩过 || 吞掉 0 或 false 的坑吗?当时那个 bug 查了多久?