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

