
代码今天能跑,不代表明天还敢信。
先讲个我遇到的真事。
同事提交了个用户信息卡片,功能全对——头像有了,昵称有了,测试也过了。但我在评论区打了个问号,指着这一行:
const name = user.name || user.full_name || user.profile?.name || '';
我说:你为啥要写四个兜底?
他说:因为有的接口返回 name,有的返回 full_name,还有个老接口塞在 profile 里,不写全会白屏。
那一刻我就知道,这个项目迟早要出事。不是这一行会崩,而是这行代码正在替整个后端擦屁股,而且它绝对不是唯一一个这么干的地方。
这就是老手和新手看代码的区别。新人问"这能跑吗",老手问"这段代码偷偷做了哪些假设,这些假设靠谱吗"。今天咱们就把 10 种"当时看着没毛病、后来越来越坑"的写法,一个一个掰开揉碎讲。每个我都配个生活里的比方,代码也都用真实业务场景,保证你看完能对上号。
📌 小声明:文中代码都是教学示意,为了讲清思路做了精简(省了
AbortController、防抖、错误类定义这些)。重点是理解"为什么这么写",别直接抄进生产项目——真上手时记得补齐这些细节。
打个比方:这就像一个前台,来一个访客登记表格式不一样,就临时想一套办法应付。张三的表格写"姓名",李四的写"名字",王五的干脆写在备注里。前台每次都得猜"这次名字在哪一栏"。表格一多,前台就疯了。
前面那个四重兜底就是这么回事。问题不在 ||,而在于**整个项目没有一道"数据进门时的安检口"**。每个页面都在各自防守,出了 bug 谁都说不清数据本该在哪儿被理顺。
老手的做法是:在数据刚进前端那一刻,就把它整理成一个固定、可信的样子,之后所有页面都不用再猜。
// 安检口:脏数据进来 → 干净数据出去,只在这儿理一次
interface User {
id: string;
name: string;
avatar: string | null;
}
function normalizeUser(raw: any): User {
return {
id: String(raw.id),
name: raw.name ?? raw.full_name ?? raw.profile?.name ?? '匿名用户',
avatar: raw.avatar ?? null,
};
}
// JS 版,一模一样的逻辑
function normalizeUser(raw) {
return {
id: String(raw.id),
name: raw.name ?? raw.full_name ?? raw.profile?.name ?? '匿名用户',
avatar: raw.avatar ?? null,
};
}
不用上什么重型校验库,就记住一句话:接口返回的原始数据 ≠ 你应用里用的数据。跳过这道安检,整个项目就变成一支到处打扫卫生的清洁队。
接口返回(脏、乱、天天变)
│
▼
┌──────────────┐
│ normalize │ ← 唯一的安检口
└──────┬───────┘
│ 干净、可信、格式统一
▼
页面A 页面B 页面C (谁都不用再兜底了)
打个比方:你让会计算一下这单多少钱,结果他不光算了账,顺手还把仓库发了货、给你手机发了条短信、又把订单原件涂改了。下次你只是想问个数,他又把这一整套流程走一遍。你敢再找他算账吗?
代码里就是这样的函数:
function prepareOrder(order) {
order.total = calculateTotal(order.items); // 算账
updateCartState(order); // 顺手改了购物车界面
trackEvent('order_prepared'); // 顺手埋了个点
return order; // 还把订单丢回去给别人用
}
名字叫"准备订单",其实它算账、改原件、刷界面、发埋点一条龙。谁调用它,谁就被迫背上这一堆看不见的动作。
拆开就清爽了:算账的只管算账(给数据、还数据,不碰外面);干活的只管干活(改状态、发请求、埋点)。两件事都要做,但别搅在一个含糊的名字底下。
// 纯算账:给它订单,还你一个待提交的数据,绝不动外面
function buildOrderPayload(order: Order): OrderPayload {
return {
...order,
total: calculateTotal(order.items),
};
}
现在这个函数的承诺很清楚:我只负责把订单整理成能提交的样子。我不刷界面、不发埋点、不改原单。好代码不是函数写得短,而是函数不会背着你偷偷改变世界。
打个比方:一份合同,你怕丢,复印了好几份,放在抽屉、放在包里、放在公司、还发了份到邮箱。刚开始挺安心。直到有一天要改一个条款——你得把四份全找出来改对,漏一份就出纠纷。
state 就是这种合同。组件要个值就存一份,表单要副本就拷一份,抽屉要选中项又留一份。同一个东西在好几个地方各活各的,用户一改动,你就得同步四个地方,还得同步对。
举个真实场景:一个订单列表页,筛选条件(时间、状态)到底存哪儿?
// ❌ 合计金额也存一份 state,跟明细各活各的,迟早对不上
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
// ✅ 能算出来的就别存,让它永远跟着明细走
const [items, setItems] = useState([]);
const total = useMemo(
() => items.reduce((sum, i) => sum + i.price * i.count, 0),
[items]
);
别再问"这个值放哪儿方便",要问:这个值到底归谁管。该刷新还能恢复的(比如筛选条件),放 URL;本来就归后端的,别在前端存副本;能从别的数据算出来的(比如合计、勾选数量),压根就不该存。归属清楚了,应用就"安静"了。
打个比方:你在餐厅同时点了三家外卖,谁先到不一定。结果你家门口只认"最后按门铃的那个",先到的那份直接被拒收。你的界面也一样——用户手快点了好几次,或者切来切去,最后是哪个请求的结果把屏幕刷了,全看运气。
真实场景:搜索框,用户一边打字一边触发请求。
const result = await searchApi(keyword);
setList(result); // 看着没问题
它从没问过一句:结果回来的时候,用户还在搜这个词吗?很可能用户已经改了关键词,但一个"慢半拍"的旧请求后回来,把新结果盖掉了——于是你搜"手机",界面却显示"手表"的结果。
await 只保证"办完了",它**不保证"现在还需要"**。老手看异步,不只问"它成功了吗",而是问"成功之后,界面凭什么就该信它"。
// 加个"当前有效"的判断:旧请求回来直接作废
useEffect(() => {
let alive = true;
searchApi(keyword).then((result) => {
if (alive) setList(result); // 用户已经改词了就别刷
});
return () => { alive = false; };
}, [keyword]);
用户连续打字(触发好几次)
│
┌───────┼────────┐
▼ ▼ ▼
搜"手" 搜"手机" 搜"手机壳" (谁先回来不一定)
│ │ │
└───────┴────────┘
▼
只有"最新那次"
才允许刷新界面
保存按钮被点两次会不会生成两笔订单?token 跑着跑着过期了怎么办?这些都是同一个道理:时间也是逻辑的一部分,看着干净的异步代码照样能出乱子。
打个比方:同事递给你一个黑色塑料袋说"帮我把这个交上去"。里面装的啥?要不要拆掉某些东西?有没有过期的?你全不知道,只能整袋交上去,出了事还得背锅。
代码里到处是这种黑袋子:data、options、config、payload——没人说得清里面哪些字段必填、哪些是给前端用的、哪些是半年前某个页面临时塞的。
function submitForm(data) {
return api.post('/submit', data); // data 里到底有啥?天知道
}
小项目还能扛,项目一大就成了 bug 窝。更好的做法是把袋子打开,明明白白拼出来要交什么:
// 提交发票:清清楚楚,只放该发给后端的字段
function buildInvoicePayload(form: InvoiceForm): InvoicePayload {
return {
customerId: form.customerId,
dueDate: formatDateForApi(form.dueDate), // 日期格式转成后端要的
items: form.items.map(buildInvoiceItemPayload),
// 那些只给前端用的字段(比如临时选中态),到这儿就被挡掉了
};
}
代码是长了点,但它诚实——它告诉下一个人:到底要交什么、前端的样子怎么变成后端的样子。表单数据、接口参数、权限对象、缓存 key 这些跨门交接的东西,别用黑袋子装。
打个比方:公司规定"只有主管能审批 5 万以上的报销"。这条规定要是没写进制度手册,而是靠每个前台各自记——A 前台记成 5 万,B 前台记成 3 万,C 前台干脆忘了。同一条规矩,执行起来五花八门。
代码里最常见:按钮该不该禁用、能不能导出、这个字段必不必填,规则被随手塞进 JSX 或者某个点击事件里。能用——直到别的地方也要用这条规则,于是复制、微调、遗漏,同一条规矩在好几处各写一遍还不一样。
// ❌ 规则散在按钮里,换个页面又得重写一遍,改一处漏三处
<button disabled={user.role !== 'owner' || invoice.status === 'paid'}>
审批
</button>
// ✅ 把规则单独拎出来,给它一个"家"
function canApproveInvoice(user: User, invoice: Invoice): boolean {
return user.role === 'owner' && invoice.status !== 'paid';
}
// 按钮只负责"长什么样",不负责"定规矩"
<button disabled={!canApproveInvoice(user, invoice)}>审批</button>
按钮可以决定禁用时灰不灰,但它不该是唯一知道"这事到底准不准干"的地方。还有一条铁律:前端只负责引导用户,真正的权限必须后端再拦一道——前端的判断随时能被绕过。
打个比方:家里都有个抽屉,一开始放剪刀、胶带、充电线。慢慢地,钥匙、发票、药、螺丝、不知道哪来的遥控器全往里塞。最后你要找东西,得把整个抽屉翻个底朝天。
utils 文件夹就是这个抽屉。一开始很干净——格式化日期、拼 query、安全 parse JSON。后来开始塞业务:权限判断、计费规则、报表专属逻辑……突然它就不是工具库了,成了"没人愿意好好安置的逻辑"的垃圾场。
判断很简单:真正的工具函数应该是"无聊"的,它解决一个通用的技术小问题,不需要懂业务。formatDate(格式化日期)是工具;canApproveInvoice(能不能审批发票)就不是——那是业务规则,该待在业务代码旁边。危险的不是"有 helper",而是用 helper 来逃避"这段逻辑到底该放哪"这个问题。
打个比方:同样是指路,有人说"往前走两个红绿灯,右拐看到超市就到了",有人说"沿最优路径穿过三个拓扑节点抵达终点"。后者听着高级,但你根本不敢照着走。
炫技的代码就是第二种。一个漂亮的一行流、一条优雅到看不懂的数组链——问题不在于它高级,而在于它省了几行代码、却让读的人多花十倍脑子。而代码往往是在"最要命的时候"被读的:线上出 bug 时、新人接手时、你自己半年后回来改时。
下面两段做的是同一件事(筛掉未激活用户 → 算个 score → 按 score 建索引),但读起来天差地别:
// 秀操作版:三步挤成一坨,key 是啥、每步干了啥全得脑补
const byScore = users
.filter(u => u.active)
.map(u => ({ ...u, score: u.value * 2 }))
.reduce((acc, u) => (acc[u.score] = u, acc), {});
// 读的时候:🤯 得在脑子里跑一遍
// 无聊但看得懂版:每一步都有名字,一眼就知道在干嘛
const active = users.filter(u => u.active);
const scored = active.map(u => ({ ...u, score: u.value * 2 }));
const byScore = keyBy(scored, 'score'); // keyBy 来自 lodash
// 读的时候:😌 每步都有名字
简单代码不等于新手代码。所谓简单,是这段代码里已经没有东西需要藏着掖着了。好代码读完你会想"这不挺显然吗"——这不是没水平,这恰恰是目的。
打个比方:家里电器坏了,无论是没插电、跳闸、还是真烧了,维修工都只回你一句"反正是坏了"。你既不知道自己能不能顺手解决,也不知道该不该叫人来。
代码里最偷懒的写法就是这样:
try {
await saveRecord(values);
} catch {
showError('出错了'); // 啥错?不知道
}
可"手机号没填对"和"你没权限保存"和"数据被别人改了",是完全不同的三件事,就该给用户完全不同的提示:
// ValidationError / PermissionError / ConflictError 是你自己定义的错误类型,
// 通常在接口层根据后端返回的状态码(如 422 / 403 / 409)分别抛出
try {
await saveRecord(values);
} catch (err) {
if (err instanceof ValidationError) return showFieldErrors(err.fields); // 标红哪个框填错了
if (err instanceof PermissionError) return toast('你没有权限保存这条数据');
if (err instanceof ConflictError) return toast('数据已被他人修改,请刷新后重试');
logError(err); // 意料之外的错,留足线索好排查
toast('系统开小差了,请稍后再试');
}
好的错误处理不只是"别崩",而是帮用户搞清楚下一步该干嘛。catch 不是垃圾桶,它是一个要做判断的地方。
打个比方:你把新房验收了,水电通、门能开、灯能亮,就签字入住了。可你没试过:同时开空调和微波炉会不会跳闸?下暴雨会不会漏水?这些不是"故意找茬",而是迟早会发生的日常。
代码也一样。真实用户会双击、会刷新、会断网、会开好几个 tab、会拿着过期数据提交表单——他们不像你本地测试时那么乖。如果你的代码只在用户"完美配合"时才能跑,那它还没准备好上线。
不用给每个功能上企业级防御,但那些一眼就能想到的失败情况,应该是提前设计好的,而不是等用户投诉才发现的:
接口很慢、转圈半天时,界面卡死还是有提示?列表返回为空,是白屏还是"暂无数据"?用户没权限,是报错还是友好引导?保存按钮点两下,会不会生成两笔订单?
这些不是新手才操心的事,这些就是线上每天在发生的事。能跑的代码和靠谱的代码,差别往往就在于——它有多认真地对待那些"不顺利"的时刻。
它未必看着更高级,反而往往看着更省心、更安静:
数据进门先过安检;算账的和干活的分开;每个 state 都知道归谁管;异步结果会认"现在还需不需要";对象不再是黑袋子;业务规则有自己的家;工具函数保持无聊;错误保留了"到底啥错";代码宁可无聊也要看得懂;顺利的路能走,不顺利的路也没被无视。
这样的代码,别人 review 更省心,出 bug 更好查,加功能不用东改西改,最重要的是——它不押注"一切都完美"这件根本不可能的事。
这就是经验最终教会人的那个转变:
新手写 JS,心里问: "这能跑吗?"
老手写 JS,心里问: "这段代码做了哪些假设,这些假设靠谱吗?"
就这一个问题,会悄悄改变你写代码的每一个选择。它让你的代码少一点魔法——而少一点魔法的代码,才更好维护。
JavaScript 灵活到几乎能让你为所欲为,这是它的强大,也是它的陷阱。你可以用松散的数据、随手的 state、看不透的黑袋子一路狂奔,爽一阵子。但未来总会到来:有人改需求、有人复用你的函数、有人开两个 tab、有人传来奇葩数据、有人在没有你记忆的情况下 debug 你的代码。
到那时候,当初图省事写的代码,就会一口一口咬回来。
所以别再写只能证明"一切顺利"的代码了。目标从来不是完美,而是——项目长大之后,它依然让人敢信。
这篇聊了 10 种"当时没毛病、后来变坑"的写法。我最想问你:
你维护过真实项目之后,戒掉了哪个 JavaScript 习惯?
是那个四重兜底的 ||,是 catch {} 里那句万能的"出错了",还是某个你后来死活看不懂的"一行流"?评论区聊聊你的翻车现场,最有共鸣的几个,我下一篇引用 👀