当代码有好几种写法时,到底应该怎么写
有段很常见的前端代码:页面上有一组 selectedIds,需要从全量列表里取出对应的实体,再展示出来。
这段逻辑有好几种写法。可以从全量列表出发 filter,可以先建 Map 再从 selectedIds 出发 map,也可以用 flatMap 把「没找到」的项直接展开成空数组。它们大多都能跑,类型也能写对。
真正拉开差异的是哪一版更诚实地暴露了业务主语:结果顺序到底听谁的,缺失项怎么处理,读者看到这个 API 时会自然期待什么。代码短一点当然好,但短不能把这些信息盖住。
我现在更愿意把这句话当成默认判断:
多种写法都正确时,优先选择「代码结构暗示的语义」和「业务真实语义」最一致的写法。
这个判断比「少一行」「更函数式」「看起来高级」稳定得多。它也能避开一种常见 review 僵局:大家都在争某个写法漂不漂亮,却没人先确认代码正在表达哪件业务事实。
结果顺序先听谁的
如果业务要求按用户选择顺序展示礼物,主语其实是 selectedGiftIds。它决定结果顺序,全量 gifts 只是一个查表来源。
这时可以先建索引,再按选择顺序查:
const isDefined = <T>(value: T | null | undefined): value is T => value != null;
const giftById = new Map(gifts.value.map((gift) => [gift.id, gift]));
const selectedGifts = selectedGiftIds.value
.map((id) => giftById.get(id))
.filter(isDefined);map 表达「每个 id 查一次」,filter(isDefined) 表达「查不到就丢掉」。这两个动作刚好贴着业务语义走。
flatMap 也能写:
const selectedGifts = selectedGiftIds.value.flatMap((id) => {
const gift = giftById.get(id);
return gift ? [gift] : [];
});它的结果没有错,但结构暗示变了。flatMap 更像在说「一个输入会展开成零个、一个或多个输出」,读者会自然去找这个一对多关系。这里的业务不是展开,只是查表和过滤缺失项,所以这点技巧反而让意图绕了一下。
这不是说 flatMap 不该用。业务本来就有一对多展开时,例如一个分组展开成多条子项,flatMap 很合适。问题只在于:为了少写一个 filter,把查表写成展开,读者要多绕一步才能回到真实语义。
从全量列表出发也要看业务是不是允许:
const selectedGifts = gifts.value.filter((gift) => selectedGiftIds.value.includes(gift.id));如果页面就应该跟着 gifts 的原始顺序走,这样写可以。可如果顺序来自用户选择,这段代码就把 owner 换掉了:结果顺序不再听 selectedGiftIds,而是听全量列表。代码仍然短,语义已经偏了。
建索引不一定要用 reduce
reduce 可以写几乎所有数组逻辑。它能转换、筛选、分组、求和、建索引,也能顺手塞进一些副作用。问题也在这里:读者看到 reduce 时,只知道「这里有个累计过程」,还不知道累计出来的是索引、总数、分组,还是一个带副作用的临时对象。
只是把数组变成 id 索引,不一定要写成这样:
const userById = users.reduce<Record<string, User>>((result, user) => {
result[user.id] = user;
return result;
}, {});如果 key 是字符串,Object.fromEntries 更像「把一组键值对变成对象」:
const userById = Object.fromEntries(
users.map((user) => [user.id, user]),
);如果后续要频繁查找实体,或者 id 不是天然适合对象 key 的字符串,Map 会更直接:
const userById = new Map(users.map((user) => [user.id, user]));reduce 当然还有自己的位置。真正的归约、分组、跨项累计、多状态同时推进,或者下一项处理依赖上一轮 accumulator 时,reduce 的结构就是问题本身。把它用在所有数组处理上,才会把「查表」「存在性」「取第一个」这些更具体的语义抹平。
问的是存在性,还是第一项
判断是否存在一项过期数据时,filter().length > 0 能跑:
const hasExpired = items.filter((item) => item.expired).length > 0;这类问题问的是「有没有」,some 更贴近这个动作:
const hasExpired = items.some((item) => item.expired);取第一项也是一样:
const firstExpired = items.filter((item) => item.expired)[0];这段代码把读者先带进「筛选全部」的动作,最后才通过 [0] 暗示只关心第一项。find 直接说清楚目标:
const firstExpired = items.find((item) => item.expired);这里也有性能上的短路差异,但性能不是最主要的理由。更主要的是,some 和 find 的名字已经替读者回答了问题类型:存在性,第一项。
请求是并发还是串行
forEach(async () => {}) 是一个特别容易混过去的写法:
ids.forEach(async (id) => {
await submit(id);
});
await refresh();它看起来像「遍历并等待每次提交」,实际 forEach 不会等待回调里的 Promise。refresh() 会在提交还没完成时继续执行,除非外面还有别的同步机制兜住。
如果这些提交可以并发,代码应该把并发写出来:
await Promise.all(ids.map((id) => submit(id)));
await refresh();如果提交必须串行,或者后一个请求依赖前一个请求结果,就用 for...of:
for (const id of ids) {
await submit(id);
}
await refresh();这两个写法写出的业务承诺不同:请求是一起发,还是一个一个发;失败时是整体失败,还是可以单独收集结果。需要保留部分成功结果时,还可以把选择写成 Promise.allSettled。异步代码最怕把这些承诺藏在一个看起来普通的数组遍历里。
循环由数据、下标还是条件控制
普通循环也一样,不该先问哪个写法高级,而是先问这段代码到底由什么控制。
如果只是把一组数据变成另一组同长度数据,map 就很自然。它表达一对一转换,返回值就是这次循环的目的:
const optionLabels = options.map((option) => option.label);如果只是对每一项做同步副作用,且一定会遍历完整个列表,forEach 可以接受:
items.forEach((item) => {
trackExposure(item.id);
});但 forEach 的边界也很硬:它不能 break 提前结束,也不能用 continue 表达跳过当前轮,更不会等待 async callback。只要循环里出现提前退出、错误短路、串行等待、向外层 return 这类控制流,for...of 通常更诚实。
for (const item of items) {
if (item.disabled) {
continue;
}
if (item.id === targetId) {
selectedItem = item;
break;
}
}for...of 的主语是「值」。读者看到它,会预期代码按迭代顺序处理每个值,并且可以在现场控制继续、跳过和退出。数组、Set、Map、生成器都能用它;如果遍历 Map,写成解构也能直接暴露 key 和 value:
for (const [userId, user] of userById) {
syncUser(userId, user);
}普通 for 的主语是「下标」。当下标本身参与业务,或者循环不是简单地从头到尾取值时,它比 for...of 更清楚:
for (let index = items.length - 1; index >= 0; index -= 1) {
if (items[index].expired) {
items.splice(index, 1);
}
}反向删除、相邻项比较、分页窗口、步长不是 1、需要同时访问 items[index - 1] 和 items[index],这些都是 for 的自然场景。这里如果硬写 for...of,反而要额外维护一个 index 变量,控制信息会散掉。
while 适合条件驱动的循环。它表达的重点是:只要条件还成立,就继续推进。
while (queue.length > 0) {
const task = queue.shift();
if (!task) {
continue;
}
runTask(task);
}队列消费、游标分页、重试直到成功、手写 parser 逐步移动 cursor,都更接近 while。如果循环次数不是由一个现成数组决定,先写数组方法往往会把状态推进藏起来。
do...while 更少见,但它有一个很明确的承诺:循环体至少执行一次。比如先请求第一页,再根据返回的 nextCursor 判断是否继续:
let cursor: string | undefined;
do {
const page = await fetchPage(cursor);
appendItems(page.items);
cursor = page.nextCursor;
} while (cursor);如果没有「至少执行一次」这个语义,do...while 往往会让读者多停一下,因为它把条件放到了末尾。多数业务代码里,普通 while 会更直观。
for...in 最容易被误用。它遍历的是对象可枚举属性名,不是数组元素值;属性名还是字符串,并且会走到原型链上可枚举的属性。遍历数组时默认不要用它:
for (const index in items) {
// index 是字符串属性名,不是 item 本身。
}对象枚举也通常优先用 Object.keys() / Object.values() / Object.entries(),因为它们默认只处理对象自己的属性,语义更收敛:
for (const [key, value] of Object.entries(payload)) {
normalizeField(key, value);
}需要遍历继承属性时再考虑 for...in,并且最好把这个需求写得很显眼。日常业务代码里,for...in 更像一个对象属性枚举工具,不是通用循环入口。
循环选型可以压成几句:map 用来产出新数组,forEach 用来做完整的同步副作用,for...of 用来表达可控制的顺序遍历,for 用来表达下标驱动,while 用来表达条件驱动,do...while 用来表达至少执行一次,for...in 留给对象属性名枚举。写法本身没有高低,关键是它有没有把控制流说出来。
集合先分清「有没有」和「查谁」
很多人会把 Set / Map 当成性能工具:数组 includes 多次调用会变慢,所以先转 Set。这个理由没错,但还不够。
Set 更重要的语义是 membership:只关心某个值在不在集合里,不关心它在集合里的第几个位置。
如果业务要按列表原始顺序展示已选项,写成这样就很自然:
const selectedIdSet = new Set(selectedGiftIds.value);
const visibleGifts = gifts.value.filter((gift) => selectedIdSet.has(gift.id));这里从 gifts 出发没有问题,因为结果顺序应该跟着 gifts。selectedIdSet 只负责回答「这个 id 有没有被选中」。
同样,Map 的语义是 lookup:拿一个 key 找对应实体。
const giftById = new Map(gifts.value.map((gift) => [gift.id, gift]));
const selectedGifts = selectedGiftIds.value
.map((id) => giftById.get(id))
.filter(isDefined);这两段代码看起来相似,业务 owner 正好相反。第一段的 owner 是全量列表顺序,第二段的 owner 是用户选择顺序。Set 和 Map 帮忙表达的不只是「查得更快」,还有「谁决定结果结构」。
这也是我现在看集合类型时最先问的问题:它是在回答「有没有」,还是在回答「用这个 key 找谁」。先问这个,比先估算数组长度更接近日常业务代码的风险。
派生值不要写成镜像状态
Vue 里也有同类问题。一个值完全由其他响应式值推出来时,它更像派生状态,而不是自由状态。
用 ref + watch 维护镜像值,会多出一条同步链路:
const fullName = ref('');
watch(
[firstName, lastName],
() => {
fullName.value = [firstName.value, lastName.value].filter(Boolean).join(' ');
},
{ immediate: true },
);这段代码的风险不在于它写不对,而是它制造了一个需要持续维护的副本。初始化、依赖遗漏、watch 触发时机和后续手动赋值,都可能让这个副本和来源分叉。代码读起来也会多一个疑问:fullName 是不是可以被别的地方改?
如果 fullName 只是派生值,computed 会直接回答这个疑问:它只能由 firstName 和 lastName 算出来。
const fullName = computed(() =>
[firstName.value, lastName.value].filter(Boolean).join(' '),
);需要 ref 的场景也很明确:用户可以临时编辑它,它要承接接口回填后的自由修改,它需要被手动重置,或者它的变化不完全由当前依赖决定。只要它还只是派生结果,就不要先创建一个镜像状态,再想办法证明这份镜像一直没偏。
稳定映射放进表
同一个判别值映射到不同文案、颜色或组件时,三元链很容易越写越长:
const statusText =
status === 'pending'
? '待审核'
: status === 'approved'
? '已通过'
: status === 'rejected'
? '已拒绝'
: '未知';这段代码要表达的是离散映射,不是复杂分支。映射表会更贴近这个形状:
const statusTextMap: Record<OrderStatus, string> = {
approved: '已通过',
pending: '待审核',
rejected: '已拒绝',
};
const statusText = statusTextMap[status] ?? '未知';如果每个分支里有副作用、提前返回、错误处理、日志记录,或者分支之间有条件优先级,if / switch 会更合适。映射表适合稳定的一对一关系,它不适合把一段流程伪装成数据。
helper 要命名稳定概念
重复代码当然会让人想抽 helper,但 helper 的名字必须真的减少理解成本。
比如只在一个地方比较几个字段时,直接把字段留在现场,读者反而更容易判断:
const profileChanged = !isEqual(
pick(currentProfile, ['nickname', 'avatar']),
pick(formValue, ['nickname', 'avatar']),
);如果抽成这样:
const profileChanged = hasProfileChanged(currentProfile, formValue);调用点短了,但比较哪些字段、为什么只比这些字段,都被藏到远处。这个 helper 如果只服务一次调用,名字没有比字段列表提供更多信息。
更值得抽的是稳定概念:
const toProfileIdentitySnapshot = (profile: ProfileForm) =>
pick(profile, ['nickname', 'avatar']);
const profileChanged = !isEqual(
toProfileIdentitySnapshot(currentProfile),
toProfileIdentitySnapshot(formValue),
);这里 helper 命名的是「身份信息快照」,不是「比较两个对象」。它把字段选择背后的业务概念立起来了,后续多个地方需要同一套比较边界时,也有一个明确入口可以复用。
抽象要让稳定概念有名字,让不稳定细节继续留在该被看见的位置。调用点变短只是副作用,不该是主要目标。
先问业务动作,再选写法
几种写法都能跑时,我会先把业务动作和读者预期问清楚:
- 这段业务的主语是谁:全量列表、用户选择、接口返回、当前表单,还是某个状态源。
- 这段代码要表达的动作是什么:转换、筛选、查找、存在性判断、分组、查表、并发、串行、按下标推进、按条件推进、派生状态,还是稳定映射。
- 当前 API 的惯用语义是否刚好对应这个动作:
map是一对一转换,filter是保留子集,some是存在性,find是第一项,flatMap是展开,computed是派生。 - 循环类写法有没有暴露控制流:
forEach是完整同步副作用,for...of是可控制的顺序遍历,for是下标驱动,while是条件驱动,do...while是至少执行一次,for...in是对象属性名枚举。 - 更短的写法有没有改变读者预期:如果读者需要先懂技巧,再反推业务,短就不一定是优势。
- 抽象有没有命名稳定概念:如果 helper 只是把字段、条件和副作用搬远,调用点变短了,系统反而更难读。
好写法通常不靠炫技。它应该让读者第一眼就猜到业务在做什么,再进细节确认边界。能做到这一点,哪怕多两行,也比把语义压进一个聪明表达式里更值得保留。