我给函数参数和解构都设了默认值,以为这下不管传什么进来都有兜底了,结果一个从接口来的 null 直接穿透了默认值、拿到手还是 null、访问属性当场崩溃,排查半天才发现默认值只认 undefined、根本不认 null 的深度复盘
这是一次让我对"'没有值'其实有好几种,它们并不通用"有了刻骨认知的事故。我写了个 TypeScript 函数,接收一个配置对象。为了健壮,我给参数和解构都加上了默认值——心想这样一来,无论调用方传不传、传什么,我都有一个兜底的默认值,绝不会出问题。本地用 undefined、不传参数测了一圈,默认值都乖乖生效,我便放心了。
可上线后,偏偏崩在了我自以为最稳妥的地方:一个从后端接口返回的字段是 null,被当作参数传了进来,结果我设的默认值完全没生效——参数拿到的就是那个 null,紧接着对它做属性访问,Cannot read properties of null,当场崩溃。我盯着代码百思不得其解:我明明给了默认值啊!不传的时候默认值都好好的,怎么传个 null 进来,默认值就形同虚设了?直到我去翻了默认值的触发规则,才恍然大悟:JavaScript/TypeScript 的默认值(无论是函数参数默认值还是解构默认值),只在对应的值严格等于 undefined 时才会生效;而 null 被视为"一个货真价实的值",它会正大光明地穿透默认值,原样传进来。后端给的那个 null,根本没被我的默认值拦住。
故障现场:null 穿透了默认值,原样落到了变量里
我把这个"默认值拦不住 null"的现象还原出来,问题一目了然:
function setup(config: { timeout?: number } = { timeout: 1000 }) {
console.log(config.timeout);
}
setup(); // 不传 → config = {timeout:1000}, 默认值生效 ✓
setup(undefined); // 传 undefined → 同样用默认值 ✓
setup(null as any); // 传 null → config = null! 默认值【不生效】 → 下一行崩 ✗
// config.timeout → Cannot read properties of null
// 解构默认值同理, 只认 undefined:
function greet({ name = "匿名" }: { name?: string | null } = {}) {
return `你好, ${name}`;
}
greet({}); // name → "匿名"(undefined 触发默认值)✓
greet({ name: undefined }); // name → "匿名" ✓
greet({ name: null }); // name → null! 默认值不生效 → "你好, null" ✗
// null 是"一个值", 它穿透了默认值
// 我之前的代码: 接口返回的 user.nickname 是 null
const { nickname = "游客" } = user; // user.nickname === null
// nickname 拿到的是 null, 不是 "游客" → 后续 nickname.trim() 崩 ✗
看着 null 大摇大摆地穿过我精心设置的默认值,我才彻底明白:我把 null 和 undefined 当成了一回事——都是"没有值/空",以为默认值会把它们俩都兜住。可在 JS/TS 里,它们是两个不同的东西:undefined 表示"压根没赋值/缺失",而 null 表示"明确赋了一个'空'这个值";默认值机制只认前者(缺失才补),不认后者(null 是个有效的值,凭什么替换它)。后端接口返回 null(它表达"这个字段有,但值为空"),正好踩在这个区别上:它是个值,所以穿透了默认值。我以为我兜住了"所有的空",其实我只兜住了"undefined 这一种空",而真正打过来的是 null 那一种。
第一件事:搞懂 null 与 undefined 的区别,以及默认值为何只认 undefined
冷静下来,我去把"null 与 undefined、默认值的触发规则"这一课认真补了,才明白这个"默认值漏 null"的根源:
【null 与 undefined 是两种不同的"空", 默认值只认 undefined】
undefined: "压根没有这个值"——变量没赋值、属性不存在、参数没传
null: "明确地赋了一个'空'值"——是个有意为之的、表示"空"的【值】
默认值(参数默认值 / 解构默认值)的触发规则:
- 【只有】当对应的值 === undefined 时, 才用默认值
- null 被当作"一个正常的值", 【不触发】默认值, 原样保留
- 道理: 默认值是为"缺失(没给)"兜底的; 而 null 是"给了, 且给的是 null",
既然你明确给了一个值, 凭什么用默认值替换它?
所以这些只认 undefined、放过 null:
function f(x = 1) {} // f(null) → x 是 null
const { a = 1 } = obj // obj.a 为 null → a 是 null
function f(x?: number) // 可选参数缺失是 undefined, 但 null 是显式传入
常见 null 来源(防不胜防):
- 后端接口 JSON 里的 null(表示"字段存在但值为空")
- DOM API(querySelector 找不到返回 null)
- 正则 match 失败返回 null、某些库的"无结果"约定
关键区分:
- 要兜底"缺失" → 默认值即可(只管 undefined)
- 要兜底"null 和 undefined 都算空" → 用 ?? (空值合并, 两者都兜)
或先把 null 归一成 undefined, 再让默认值生效
这一下点醒了我:我脑子里"空就是空"的笼统印象,在 JS/TS 里是不成立的——undefined("没给")和 null("给了个空")是两种语义不同的"空",而默认值这个机制,被设计成只为"没给"兜底。它的逻辑很自洽:你都明确传了个 null 进来了,说明你"给了值",默认值当然不该越俎代庖去替换。可我没分清这两种空,把默认值当成了"万能的空值兜底",于是从接口来的 null(一种我没设防的空)就直接穿透了。不是默认值失灵,是我要兜的"空"和它能兜的"空",根本不是同一种空。
第二件事:正解——要把 null 也兜住,用 ?? 空值合并,或在边界把 null 归一
找到根因,正解就清晰了:分清你要兜的是哪种"空"——只兜"缺失(undefined)"用默认值就够;要把 null 也当空兜住,用 ??(空值合并,对 null 和 undefined 都生效);或者在数据进入的边界上,把 null 统一归一成 undefined,再让默认值正常工作。别让默认值去兜它根本不管的 null。
// 错误: 指望默认值兜住 null —— 兜不住
function f(x = 1) { return x; }
f(null as any); // x 是 null, 默认值没生效 ✗
// 正解1: 用 ?? 空值合并 —— null 和 undefined 都会被兜底
function f2(x?: number | null) {
const v = x ?? 1; // x 是 null 或 undefined 时, v 都 = 1 ✓
return v;
}
// 正解2: 解构后用 ?? 收口(默认值只管 undefined, ?? 补上 null)
const { nickname } = user;
const name = nickname ?? "游客"; // nickname 为 null/undefined 都兜 ✓
// 正解3: 在边界把 null 归一成 undefined, 再让默认值/可选链正常工作
const config = rawConfig ?? undefined; // null → undefined
setup(config); // 现在默认值能生效
// ⚠ 别用 || 兜默认值: 它会把 0/""/false 也当"空"替换掉(那是另一个坑)
const port = inputPort || 8080; // inputPort=0 会被错误替换成 8080 ✗
const port2 = inputPort ?? 8080; // 只在 null/undefined 时兜, 0 保留 ✓
这套做法的精髓,是先想清楚"我要把哪些情况当成'空'来兜底",再选对应的工具:默认值只兜 undefined;?? 兜 null 和 undefined 这两种"空"、且不误伤 0/""/false;|| 则把所有 falsy 都当空(范围太大,常误伤)。不同的工具,兜的"空"的范围不同;把它们当成等价的随手乱用,就会在"它不兜的那种空"上漏掉。而在边界把外部的 null 归一,则是从源头让"空"的种类变得可控。不是默认值不好,是我没用对兜底工具——要兜 null,就得用认 null 的那个工具。
【兜底"空值", 几条原则】
1. 先分清三种"空": undefined(没给)、null(给了个空)、falsy(0/""/false/NaN)
2. 选对工具(它们兜的范围不同):
- 默认值 (x = d / {a = d}): 只兜 undefined
- ?? (空值合并): 兜 null 和 undefined, 不误伤 0/""/false ← 多数兜底首选
- || : 兜所有 falsy(含 0/""/false), 范围太大, 易误伤, 慎用
3. 外部数据(接口/DOM/正则)常给 null: 别指望默认值拦住它
4. 边界归一: 入口处把 null 统一成 undefined(或反之), 让内部"空"的种类单一
5. 开 strictNullChecks: 让编译器逼你显式处理 null 和 undefined
第三件事:其他"以为兜住了全部、其实漏了一类"的同类坑
顺着"'空/异常'有好几种,兜底要覆盖对的那种"这条线,我把同类的坑都梳理了一遍,它们都源于"把一个笼统的类别,当成了单一的东西":
第一个,|| 兜底误伤 0/""/false。想兜"空",用了 ||,结果把合法的 0、空字符串、false 也当空替换了——它兜的"空"范围太大。该用 ??。
第二个,catch 只想了一种异常。try/catch 里只处理了自己预想的那类错误,别的异常类型(网络、解析、权限)没覆盖,漏网后行为失控。
第三个,switch 漏了分支 / 没有 default。以为枚举了所有情况,实际漏了一个值,又没写 default 兜底,漏的那个就静默走空。
第四个,校验只防了一种非法输入。校验只挡住了自己想到的那种坏数据(比如空字符串),没挡住别的(超长、特殊字符、类型不符),没设防的那种就溜进来了。
第四件事:三种"空"与三种兜底工具,一张表对照
我把 undefined/null/falsy 三种"空",和默认值/??/|| 三种兜底工具的覆盖范围整理成一张表,这是我现在选兜底方式的依据:
| 这个值 | 默认值 (x=d) | ?? (空值合并) | || (逻辑或) |
|---|---|---|---|
| undefined(没给) | ✓ 兜 | ✓ 兜 | ✓ 兜 |
| null(给了个空) | ✗ 不兜(穿透!) | ✓ 兜 | ✓ 兜 |
| 0 / "" / false | ✗ 不兜(原样) | ✗ 不兜(原样) | ✗ 误伤!被替换 |
| 正常值 | ✗ 不兜 | ✗ 不兜 | ✗ 不兜 |
| 适合 | 只兜"缺失" | 兜 null+undefined(首选) | 少用,易误伤 |
这张表把真相摊开了:三种兜底工具兜的"空"范围各不相同——默认值最窄(只 undefined)、?? 适中(null+undefined)、|| 最宽(连 0/""/false 都当空)。我要兜的是"null 和 undefined",却用了只兜 undefined 的默认值,自然漏掉 null;而很多人改用 || 又会误伤 0。多数"兜空值"场景的正确答案,是 ??。
第五件事:我对"设了默认值"的几个想当然
这次事故,本质是我把"设了默认值"当成了"所有'空'都被兜住了"。把这些想当然列出来,每一条都值得警惕:
| 我曾经的想当然 | 事故教我的真相 |
|---|---|
| "设了默认值,传啥进来都有兜底" | 默认值只在值为 undefined 时生效,null 会穿透 |
| "null 和 undefined 都是空,一样处理" | 它们是两种不同的空;undefined=没给,null=给了个空 |
| "本地不传参默认值都对,就没问题" | 不传是 undefined;真实数据常是 null,行为不同 |
| "接口字段空,传进来就是 undefined" | JSON 的空常是 null,它会穿透默认值 |
| "兜空值用 || 就行" | || 会误伤 0/空串/false;兜 null+undefined 该用 ?? |
| "解构默认值能兜住所有缺失/空" | 同样只认 undefined,属性是 null 时不生效 |
第六件事:给值做兜底时,我现在的自检习惯
现在每当我给一个值做兜底、或排查"明明设了默认值还是拿到了空/崩了",我都会先按这张图问自己:
这张图的精髓,是"先想清这个值实际会出现哪几种空(尤其接口来的常是 null),再选兜底范围匹配的工具——多数用 ??"。写时就兜 null+undefined 用 ??、只兜缺失用默认值、外部 null 在边界归一、排查就看默认值失效是不是因为打进来的是 null 而非 undefined。这套习惯,让我从"设了默认值就万事大吉"变成了"分清空的种类、用范围匹配的工具兜对的那种空"——核心始终是:undefined("没给")和 null("给了个空")是两种语义不同的空,而默认值(参数默认值/解构默认值)只在值严格等于 undefined 时才生效、null 被当作一个有效的值会穿透默认值原样保留;接口/DOM/正则等外部来源常给 null,正好踩这个区别;正解是分清要兜哪种空——只兜缺失用默认值、要把 null 也兜住用 ??(对 null 和 undefined 都生效且不误伤 0/""/false)、或在边界把 null 归一成 undefined,别用 || 兜(会误伤所有 falsy)。
我立下的几条规矩
这场"null 穿透默认值导致崩溃"的事故,换来了我写 JS/TS 时,刻进骨子里的几条铁律:
- undefined 和 null 是两种不同的"空":undefined 是"没给",null 是"明确给了个空"。
- 默认值(参数/解构)只在值 === undefined 时生效;null 会被当成有效值,穿透默认值。
- 接口 JSON、DOM querySelector、正则 match 等外部来源经常给 null,默认值拦不住。
- 要把 null 和 undefined 都兜住,用 ??(空值合并);它还不会误伤 0/""/false。
- 别用 || 兜默认值——它把所有 falsy(0/""/false/NaN)都当空,会误伤合法值。
- 也可在数据进入的边界把 null 归一成 undefined,让内部"空"的种类单一、默认值正常生效。
- 开 strictNullChecks,让编译器逼我显式区分和处理 null 与 undefined。
附:一段把"三种空 × 三种兜底"行为摆清楚的小实验
这是我后来写的一段小实验,把 undefined/null/falsy 分别喂给默认值、??、||,把它们各自兜与不兜的行为并排打出来——它帮我把这个抽象的区别变成了眼见为实的对比,现在我也常拿它给同事讲清这个坑:
function withDefault(x = "默认") { return x; } // 默认值: 只认 undefined
const cases: [string, any][] = [
["undefined", undefined],
["null", null],
["0", 0],
["空字符串", ""],
["false", false],
["正常值'abc'", "abc"],
];
for (const [label, v] of cases) {
console.log(
label.padEnd(12),
"默认值:", JSON.stringify(withDefault(v)),
" ??:", JSON.stringify(v ?? "兜底"),
" ||:", JSON.stringify(v || "兜底"),
);
}
// 输出(对齐后一目了然):
// undefined 默认值:"默认" ??:"兜底" ||:"兜底"
// null 默认值:null ??:"兜底" ||:"兜底" ← 默认值放过了 null!
// 0 默认值:0 ??:0 ||:"兜底" ← || 误伤了 0!
// 空字符串 默认值:"" ??:"" ||:"兜底" ← || 误伤了 ""!
// false 默认值:false ??:false ||:"兜底" ← || 误伤了 false!
// 正常值'abc' 默认值:"abc" ??:"abc" ||:"abc"
这段实验把这次的教训摆得明明白白:同一列纵向看,默认值那列在 null 这行赫然放过了 null(输出还是 null),这正是我崩溃的根源;|| 那列则在 0、空字符串、false 三行都误伤了合法值;只有 ?? 那列,恰好兜住了 null 和 undefined、又放过了 0/""/false,行为最符合"兜空值"的直觉。跑完这段我才真正在脑子里刻下:"兜底"从来不是一个动作,而是三个覆盖范围不同的动作;选哪个,取决于我到底想把哪几种值当成"空"——把这三列摆在一起看一眼,比记任何口诀都管用。
这件事过后,我把项目里所有用默认值兜底的地方都过了一遍,重点找那些参数或字段会从接口、从 DOM、从用户输入流进来的。结果又揪出好几处隐患:有的字段后端在数据为空时返回 null、被我用默认值想当然地以为兜住了,有的是从 querySelector 拿到 null 没判直接用。我把它们逐一改成了 ?? 兜底,或在数据入口统一做了 null 归一。改完心里那种从凭感觉到摸清规则的踏实,是这次崩溃给我最实在的回报——我终于不再把那个会穿透一切默认值的 null,当成和 undefined 一样无害的东西了。
更深一层,我开始警惕自己脑子里那些被抹平了的笼统概念。空、错误、失败、超时——这些词我天天挂在嘴边,可每一个背后都藏着好几种行为迥异的子情况,而我处理它们时,往往只想着其中最常见的那一种。这次 null 给我上的一课,本质上是逼我把空这个词重新拆开来看。从此我对任何一个我自以为很熟、张口就来的概念,都会多留一分警觉:它真的是一个东西吗,还是我把好几种东西偷懒地叫成了同一个名字?
我也借这次机会,把 ?? 优先于 || 兜空值、外部数据入口统一处理 null 写进了团队的代码约定里,还在 eslint 里加了规则提醒那些容易误伤的 || 兜底。一行小小的约定,挡掉的可能是日后某个因为 0 被当空替换、或 null 穿透默认值而引发的线上 bug。把自己踩过的坑,变成别人不必再踩的规则,大概是复盘最值得做的那部分。
说到底,这次的 bug 改起来不过是把一个等号换成两个问号,可它撬动的认知却远不止于此:它让我看清,语言里那些看起来近义、可以混用的东西(null 和 undefined、|| 和 ??、== 和 ===),往往藏着设计者精心区分过的不同语义,而我们图省事的混用,正是在悄悄丢弃这些区分、给自己埋雷。尊重这些细微的区别,不是吹毛求疵,而是在替未来的自己排雷。
如今再看到一个默认值,我不会再下意识地以为它兜住了一切,而是会顺手问一句:这个位置真正可能来的空,是哪一种?这一句多余的追问,往往就是稳健代码和定时炸弹之间的全部距离。
写在最后
回头看,这场由"null 穿透默认值"引发的"设了兜底却还是崩"事故,真正教给我的,远不止"用 ?? 代替默认值"这一个技巧。它让我对"我们常常把一个'笼统的类别'(比如'空'、'错误'、'非法')当成一个'单一、均质的东西'来处理, 用一套'以为能覆盖全部'的手段去应对它; 可这个类别内部, 其实分着好几种语义不同、行为各异的子情况, 而我们的手段往往只覆盖了其中一种——于是那些没被覆盖的子情况, 就成了我们以为已经设防、实则门户大开的缺口",有了一次刻骨的体会。我栽跟头,是因为我把'空'当成了一个笼统的整体, 以为'设个默认值'就把'所有的空'都兜住了——我没意识到, "空"在 JS/TS 里至少分着 undefined("没给")和 null("给了个空")两种不同的语义; 而默认值这个工具, 被精确地设计成只兜其中一种(没给);我用一个"只兜一种空"的工具, 去应对一个"有好几种空"的现实, 还自以为天衣无缝; 于是那种我没设防的空(从接口来的 null), 就堂而皇之地穿了过去。这让我领悟到一个关于"类别、子情况与覆盖"的深刻认知:许多我们习惯性当成"一个东西"的概念(空、错误、失败、异常、非法输入……), 内部其实是一个"由多种不同子情况组成的集合"; 而我们用来应对它的每一种手段, 往往只覆盖了这个集合的"一个子集";危险就藏在"我以为覆盖了整个类别、实则只覆盖了一个子集"的认知差里——那些落在我手段覆盖范围之外的子情况, 平时不出现, 一出现就直击我最没设防的地方, 而我还纳闷"我明明处理过'空'了啊";所以处理任何一个"笼统类别"时, 都要先把它拆开: 它具体分哪几种?我现在用的手段, 到底覆盖了哪几种、漏了哪几种?漏掉的那几种, 现实中真的不会发生吗?。这给了我一种看待"一切'处理某个笼统类别(空/错误/异常/边界)'之事"时的清醒:每当我用某个手段去"处理/兜底/防范"一个笼统的情况时, 要追问"这个'情况'具体分成哪几种子情况?我这个手段精确覆盖的是哪几种?有没有哪种子情况, 它其实管不到、而现实中又确实会发生?"——把笼统的类别拆解成具体的子情况, 确认手段的覆盖范围恰好匹配现实会出现的全部子情况, 而不是用"差不多能兜"的笼统印象蒙混过去;"拆解笼统类别为具体子情况、让应对手段的覆盖恰好对齐现实", 是写对兜底、也是做对一切'防范与处理'之事的关键。认清 null 与 undefined 是两种不同的空、默认值只兜 undefined、兜 null 要用 ??——这,是我用一次 null 穿透默认值的崩溃,换来的、关于 TypeScript、也关于如何拆解笼统类别的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给一个值设默认值、以为万无一失时,先想想"打进来的会不会是 null?默认值可拦不住它",并在该兜 null 时果断换上 ??,那我对着那个"设了默认值却还是拿到 null"的崩溃折腾的大半天,就值了。
—— 别看了 · 2026