写了5年JS,才发现这10个方法能少写一半代码

先讲个我真遇到过的事。

那天下午,运营在群里发了张截图:一个商品的折扣设置成了 0(就是免费送,做活动用的),但前台显示原价。

我们查了两个小时。接口对的,数据库对的,Redux 里也对的。最后定位到组件里这一行:

ini 复制代码
const discount = config.discount || 1;

看出来了吗?用户填的 0,被 || 当成"没填",兜底成了 1

一个字符的问题,查了两小时。

后来我把项目里所有 || 兜底的地方搜了一遍------37 处,其中 9 处有同样的风险。


这件事之后我开始留意:JavaScript 这些年悄悄补了一堆东西,??Object.hasOwnstructuredCloneArray.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 || truetrue
  • 用户开着 → true || truetrue

这个开关永远是开的,而且不报任何错。

换成 ??

?? 只认两个东西:nullundefined。其他一律放行。

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

正常情况没问题。但如果这个卖家没填地址,addressnull,那么:

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 组合,等于给对象加上了 mapfilter

场景一:提交表单前清理空参数

前端表单经常有一堆没填的字段,直接提交会让后端收到一串 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(松散比较,同时排除 nullundefined),不是 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 === 10485760true)。

不止十进制。按语义分组比按三位分组更有用:

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 时不再被吞成 20
  • history 里如果有 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.hasOwnstructuredClone,都是 2022 年前后铺开的。现状:

  • 主流浏览器近三年的版本:全部支持,不用查
  • Node.js :16.9+ 支持 Object.hasOwn,17+ 支持 structuredClone
  • 微信小程序 / 老 webviewstructuredClone 要留个心眼,基础库版本低的可能没有。用之前判断一下:
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 倍,但会让下一个接手的人少骂两句------包括三个月后的你自己。

我这几年最深的一个体会:语言本身的更新,收益比追新框架高得多。 框架三年换一茬,?? 你会用一辈子。


最后聊聊 👇

这十个里,你入职到现在真正用过几个?

我猜 ?. 和解构默认值人人都会,?? 一半一半,structuredCloneObject.hasOwn 可能不少人今天第一次见。

评论区报个数就行,比如「用过 6 个,第 4 个第一次听说」。

另外那个问题我更好奇:

你踩过 || 吞掉 0 或 false 的坑吗?当时那个 bug 查了多久?

相关推荐
平凡的阿泽1 小时前
我用TRAE Work手搓了一个「谁是卧底」小游戏
前端·javascript
一心只读圣贤书1 小时前
AI 辅助前端视觉回归治理:从截图基线到变更解释
前端·人工智能
嘟嘟07171 小时前
用单例模式管理弹窗:从一段原生 JS 理解 Singlet
前端·javascript·代码规范
一心只读圣贤书1 小时前
AI 辅助前端图片资源治理:从懒加载到多端适配
前端·人工智能
一心只读圣贤书1 小时前
AI 辅助前端 Feature Flag 治理:从灰度开关到实验复盘
前端·人工智能
胡萝卜术1 小时前
编译期与运行期的双重防线:从 TypeScript 类型之争到 LLM 输出的自动化择优
前端·设计模式·面试
Goodbye1 小时前
前端路由深度解析:从原理到 React Router 实战
前端
何时梦醒1 小时前
🤖 Harness 工程:用 LLM as Judge + Best of N 打造自优化的 AI 代码生成流水线
前端·人工智能
AI_paid_community2 小时前
如何使用 Claude 在 AI 时代快速入局新的行业?(经验贴)
前端·javascript·后端