我给函数参数和解构都设了默认值,以为这下不管传什么进来都有兜底了,结果一个从接口来的 null 直接穿透了默认值、拿到手还是 null、访问属性当场崩溃,排查半天才发现默认值只认 undefined、根本不认 null 的深度复盘

我写了个 TypeScript 函数接收配置对象,为了健壮给参数和解构都加了默认值,心想无论传不传、传什么都有兜底绝不会出问题。本地用 undefined、不传参数测了一圈默认值都生效,我便放心了。可上线后偏偏崩在我自以为最稳的地方:一个从后端接口返回的字段是 null 被当参数传进来,我设的默认值完全没生效,参数拿到的就是那个 null,紧接着属性访问当场崩溃 Cannot read properties of null。我盯着代码百思不解:明明给了默认值,不传时都好好的,怎么传个 null 默认值就形同虚设了?翻了触发规则才恍然:JS/TS 的默认值(参数默认值和解构默认值)只在对应值严格等于 undefined 时才生效,而 null 被视为一个货真价实的值,会正大光明地穿透默认值原样传进来。复盘才懂:我把 null 和 undefined 当成一回事都是空,可它们是两种不同的空——undefined 是压根没赋值/缺失,null 是明确赋了一个空这个值;默认值只为缺失兜底,既然你明确给了 null,凭什么替换它。接口 JSON、DOM querySelector、正则 match 等外部来源经常给 null,正好踩这个区别。正解是分清要兜哪种空:只兜缺失用默认值、要把 null 也兜住用 ??(对 null 和 undefined 都生效且不误伤 0/空串/false)、或在边界把 null 归一成 undefined,别用 || 兜(会误伤所有 falsy)。这篇复盘从故障现场讲到 null 与 undefined 的区别、默认值为何只认 undefined、怎么诊断,再到 ?? 空值合并、边界归一的完整正解,以及 || 误伤、catch 漏异常、switch 漏分支、校验漏类型等同类坑,和笼统类别内部分多种子情况、手段常只覆盖一个子集、要拆解类别让覆盖对齐现实的认知。

我给函数参数和解构都设了默认值,以为这下不管传什么进来都有兜底了,结果一个从接口来的 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 大摇大摆地穿过我精心设置的默认值,我才彻底明白:我把 nullundefined 当成了一回事——都是"没有值/空",以为默认值会把它们俩都兜住。可在 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 也当空兜住,用 ??(空值合并,对 nullundefined 都生效);或者在数据进入的边界上,把 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;??nullundefined 这两种""、且不误伤 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 时,刻进骨子里的几条铁律:

  1. undefined 和 null 是两种不同的"空":undefined 是"没给",null 是"明确给了个空"。
  2. 默认值(参数/解构)只在值 === undefined 时生效;null 会被当成有效值,穿透默认值。
  3. 接口 JSON、DOM querySelector、正则 match 等外部来源经常给 null,默认值拦不住。
  4. 要把 null 和 undefined 都兜住,用 ??(空值合并);它还不会误伤 0/""/false。
  5. 别用 || 兜默认值——它把所有 falsy(0/""/false/NaN)都当空,会误伤合法值。
  6. 也可在数据进入的边界把 null 归一成 undefined,让内部"空"的种类单一、默认值正常生效。
  7. 开 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我用 C# 的 DateTime 存取时间,本地开发一切正常,可一部署到时区不同的服务器上,显示的时间就整整差了几个小时,排查半天才发现 DateTime 这个值压根没带它到底是哪个时区的这个身份信息的深度复盘

2026-6-3 4:48:45

技术教程

我给 AI Agent 加了长期记忆,想让它把每次交互都记下来、越用越聪明,结果它什么都往里塞、记忆越堆越多,反而被一堆无关的陈年旧事淹没、判断越来越差的深度复盘

2026-6-3 5:11:01

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索