如果让我给前端运行时错误排个出场率,"Cannot read properties of undefined (reading ...)"绝对稳居前三。做联调这几年,我盯着浏览器控制台里这行红字不知道看了多少回,也见过新手在接口明明有返回数据、页面却一刷新就报错的时候对着屏幕发呆。今天干脆把 undefined 整个来龙去脉摊开:它到底是什么、会从哪些缝里冒出来、热搜里最常见的 reading 'starttime' 这类报错怎么一步步定位,以及最后怎么靠代码习惯把它挡在门外。刚入门的建议从头顺序读,已经在踩坑的可以拿中间两章当排查手册抄作业。
1. undefined 到底是值还是类型:先把这个基本盘聊透
1.1 Undefined 是一个类型,里面只住着一个值
JavaScript 里有七种原始类型:string、number、boolean、null、undefined、symbol、bigint。其中 Undefined 类型有个特别之处——它内部只有一个值,名字就叫 undefined。你可以把类型理解成一间房间,Undefined 这间房只摆了一把椅子,椅子上永远只坐着同一个客人。所以当我们说 "a 的值是 undefined",其实包含两层意思:a 当前没有任何有意义的数据,同时它连语言层面的兜底值都还没被主动设置,只是默认状态。
typeof undefined返回字符串 "undefined",这是类型名和值名重合造成的现象。初学者容易把这件事理解反,以为 typeof 专门用来检测某个东西是不是 undefined。其实 typeof 只是返回了运行时类型的名称,恰好 Undefined 类型只有一个值,所以"判断类型"和"判断值"在这里等价了。这个细节后面判断undefined与null时非常关键。
1.2 全局属性、历史遗留的遮蔽问题和 void 0
在浏览器里,window.undefined就是全局的 undefined;在 Node 和 Worker 里,用globalThis.undefined访问同样东西。ES5 之前 undefined 并不是保留字,任何人都能window.undefined = 'xxx'把全局值改掉,或者在局部作用域里声明一个同名变量把它遮蔽。ES5 之后它被设为只读、不可枚举、不可配置,但这种保护只到"全局属性"这个层面,局部作用域仍然可以写:
function f(undefined) { return undefined; } f('我不是 undefined'); // 返回 "我不是 undefined"所以老代码里经常见void 0这种写法,保证拿到的是真正且不可被篡改的 undefined。void是一个单目运算符,不管后面接什么表达式,它都返回 undefined:void 0、void('hello')、void anyObject结果一样。你在老项目里看到的javascript:void(0)就是利用这一点:把它作为链接的 href 值执行后,JavaScript 表达式返回 undefined,浏览器不会拿它触发页面跳转。现在写<a href="javascript:void(0)">已经不推荐了,用<button type="button">更语义化,但理解void能帮你搞清楚"表达式没有返回值"和"undefined"之间的关系。
1.3 typeof 的安全检测与裸变量检测的坑
判断一个东西是不是 undefined,有两种写法,路线完全不一样:
if (sdk === undefined) { /* 直接比较 */ } if (typeof sdk === 'undefined') { /* 通过类型名判断 */ }前者要求sdk这个名字已经被声明过,否则会抛 ReferenceError;后者因为typeof对未声明的变量也返回 "undefined",永远不会抛错。所以判断"某个全局方法、某个 SDK 存不存在"只能用 typeof。反过来说,如果目标变量已经声明,=== undefined更直接;真正想区分"变量未声明"和"声明了但值是 undefined",typeof 反而帮不上忙,只能靠in操作符或全局对象的属性探测。这在写 SDK 兼容代码时是一个经典考点。
再补两个基础转换值:Number(undefined)是 NaN,String(undefined)是字符串 "undefined",Boolean(undefined)是 false。最后一个需要时刻警惕,因为它意味着if (!obj.flag)在 flag 为 false、0 或 '' 时也会进入分支,很多时候这并非本意。至于怎么精确区分"假值"和"缺值",后面防御式写法那节会细说。
2. 生产环境里最容易撞出 undefined 的九个场景
2.1 声明了但没有赋值
let a;之后 a 就是 undefined,var a;同样。const则必须声明时赋值,否则直接语法错误。这个场景最简单,但大量 bug 的源头其实是"以为赋了,实际没赋"——比如在分支里赋值,另一个分支忘了写。
2.2 函数没有 return
function getUser() { if (condition) { return { name: '张三' }; } } const user = getUser().name; // 当 condition 为 false 时,getUser() 返回 undefined漏写 return 或者只覆盖部分分支,返回值就是 undefined,然后把 undefined 一路传给下层函数。团队里我一般建议开 ESLint 的consistent-return规则,强制所有分支要么都有返回值、要么都没有,让这类问题在提交前暴露。
2.3 参数没传满
function add(a, b) { return a + b; } add(1); // NaN,因为 b 是 undefined函数声明的形参数量和被传入的参数数量不一致时,缺的形参就是 undefined。要想知道实际传了几个参数,可以看arguments.length或使用 rest 参数。这也是为什么现代写法里默认参数如此重要:function add(a, b = 0)能直接把"缺省"兜成可预期值。
2.4 读取不存在的对象属性
const obj = {}; obj.name; // undefined对象上没有的属性,读出来是 undefined。这里有个需要区分清楚的坑:属性"不存在"和属性"存在但值是 undefined"是两回事。obj.name === undefined只能说明它"没值",不能说明它"没有这个键"。想知道键到底存不存在,用'name' in obj或Object.prototype.hasOwnProperty.call(obj, 'name')。联调时经常遇到前端写if (res.data.msg !== undefined)判断字段在不在,结果字段名拼错,等于是拿一个永远 undefined 的表达式做了判断,问题被静默吞掉。
2.5 数组越界和稀疏数组
const arr = [1, 2, 3]; arr[5]; // undefined const sparse = [1, , 3]; // 索引 1 是空位 sparse[1]; // undefined数组越界读出来是 undefined,这个好理解。稀疏数组则更隐蔽:[1, , 3]的索引 1 读出来是 undefined,但forEach、map会直接跳过空位,跳过之后 map 出来的新数组仍然保留空位,再次读取还是 undefined。而Array.from(sparse)会把空位还原成真正的 undefined 元素再参与遍历。同样是"读出一个 undefined",处理机制完全不同,排查时别只盯着返回值。
2.6 解构的来源是 undefined 或 null
const { a } = obj; // obj 是 undefined 时直接抛 TypeError解构一个 undefined 或 null 对象会直接报错,报错文案是 "Cannot destructure property 'a' of 'obj' as it is undefined"。默认值只在被解构的字段值是 undefined 时生效:
const obj = { a: null }; const { a = 5 } = obj; // a 是 null,不是 5这是 null 和 undefined 的区别在解构语法里最直观的体现。
2.7 回调里的 this 丢失
严格模式下,直接调用一个普通函数,函数内的 this 是 undefined;非严格模式下才是 window。最常见的翻车现场是:
const obj = { name: '项目A', print() { console.log(this.name); } }; setTimeout(obj.print, 1000); // this 丢成 window(非严格)或 undefined(严格)修法有三种:包一层箭头函数、用bind固定 this、或者在 setTimeout 回调里通过对象引用调用。this 一旦变成 undefined,再去读this.name就会出现经典的 Cannot read properties of undefined。
2.8 new 还没执行完,为什么对象已经能调 prototype 方法
这篇标题下面有一条经典热搜:"未 new 完的对象为何能使用 prototype"。这得从 new 的执行顺序说起。用new Person()时,引擎做了四件事:
- 创建一个空对象;
- 把这个空对象的内部原型
[[Prototype]]指向Person.prototype; - 执行 Person 构造函数体,此时 this 指向这个空对象;
- 如果构造函数返回值是一个对象,就返回那个对象,否则返回第 1 步创建的对象。
注意第 2 步发生在第 3 步之前。也就是说,在构造函数体执行到一半时,这个对象已经"继承"了原型链上的方法。所以如果你在构造函数里安排了一个 setTimeout 或微任务,回调里拿到的引用虽然构造函数还没跑完,但已经能调用Person.prototype上的方法。反过来,实例自身属性是在第 3 步逐步赋值的,赋值之前读出来就是 undefined。实例属性和原型方法有完全不同的"存在时间表",这正是很多半初始化对象 bug 的根源。
2.9 JSON.stringify 会悄悄丢掉 undefined 字段
JSON.stringify({ a: undefined, b: null }); // '{"b":null}' JSON.stringify([undefined]); // '[null]'对象里的 undefined 字段在序列化时会被直接删除,数组里的 undefined 会被转成 null。这个静默行为是很多接口联调 bug 的根源:前端明明设置了字段,传出去一看,字段消失了。排查这类问题不要靠肉眼,直接把 JSON.stringify 的结果打印出来对照。
3. Cannot read properties of undefined 排错实战:starttime 读不到的全过程
3.1 先学会读报错文案
Uncaught TypeError: Cannot read properties of undefined (reading 'start_time')翻译成人话:你写了xxx.start_time,而 xxx 此刻是 undefined。括号里的 reading 'start_time' 指出错的那次属性读取名称。浏览器还给了文件、行号、调用栈,但真正要回答的问题是:xxx 为什么是 undefined。
很多人第一反应是"把这个 xxx 补个默认值"就完了,但根因没找到,换个接口、换个字段名还会再犯。排查思路应该像侦探破案:先确认数据来源,再确认取值路径,最后确认取值时机。
3.2 三个最常见的病根,对应三类完全不同的修法
第一个病根是接口数据结构对不上。后端返回的字段叫start_time,你代码里写的是starttime;或者data.list是个空数组,你直接取data.list[0].starttime,list[0] 就是 undefined。写法不对和字段名拼错,报错长一个样,但修法完全不同。
第二个病根是异步时序。组件刚挂载就去读this.list[0].starttime,请求还在路上,this.list 还是 undefined;或者某个 Promise 在中间被拒绝,外层没捕获,最后在浏览器报 uncaught (in promise)。这种报错往往不是每次复现,和接口耗时、用户操作速度都有关。
第三个病根是 this/回调上下文丢失,或者外部注入的 JS 环境还没就绪。表单提交、事件监听里经常出现:想用document.querySelector('input[name="xxx"]').value,页面结构变了或者选择器写错,拿到 null,再读 .value 就是 Cannot read properties of null。注意这里是 null,不是 undefined,但排查思路一致。
3.3 一条可复制的排查链路:从报错行倒推到数据源头
拿 starttime 这个例子完整走一遍。假设代码长这样:
fetch('/api/launch') .then(r => r.json()) .then(data => { const time = data.data.list[0].starttime; // 报错行 render(time); });我建议按下面顺序动手,而不是直接改代码:
- 打开浏览器 Sources,在报错行打上断点。断点命中后,右侧 Scope 面板会显示当前作用域里所有变量,第一时间确认
data到底是不是期望的结构。 - 在断点前一句插入
console.log(JSON.stringify(data, null, 2)),把全量响应打出来。不要只打印data.data,因为你尚不确定层级,全量打印才能看到真实结构。 - 对照打印结果,确认两件事:
data.data.list是不是数组、长度是多少;数组元素的字段到底是starttime、startTime还是start_time。 - 如果
list是空数组,根因是"没有数据"。此时应该走空态渲染,而不是取值。改成const first = data.data.list[0] || {};只能临时止血,正确做法是让接口或后端约定返回结构统一,前端把所有集合字段默认成[]。 - 如果
list有数据但字段名对不上,根因是"契约不一致"。应该找后端对字段名,或者前端写映射层统一转换,而不是在业务代码里到处补兼容。 - 如果打印出来的结构和预期完全一致,但运行还是报错,那多半是时序问题。给整条 Promise 链补
.catch(err => console.error(err)),把请求失败、解析失败的真实原因打出来;再用dayjs(starttime)之类的输出点验证 starttime 本身是否为 null。
这套链路走完,80% 的 undefined 报错都能定位到根因。剩下一部分其实不是 undefined,而是 null,需要放到下一节的对比里判断。
3.4 高频报错文案速查表
实际开发里,"undefined 相关的报错"其实有一整套容易混淆的措辞,一起列在这里,省得到时候一个个查。
| 报错文案 | 真实含义 | 常见位置 |
|---|---|---|
| Cannot read properties of undefined (reading 'x') | xxx 是 undefined,你在读它的 x 属性 | API 数据、组件状态、this 丢失 |
| Cannot read properties of null (reading 'x') | xxx 是 null,不是 undefined | DOM 没找到、JSON.parse 得到 null |
| undefined is not a function | 你调用了一个值为 undefined 的东西 | 动态取方法、window str 拼错 |
| x is not defined | 变量名根本没有被声明 | 手滑拼错变量名、没引入模块 |
| Cannot destructure property 'x' of 'a' as it is undefined | 解构的对象本身是 undefined | 解构未初始化的状态、未解析的响应 |
最后补一句:动态调用里最容易踩的是第二种。比如window['submitOrder'](),方法名拼错或还没注入,取出来就是 undefined,然后再加一对括号调用,就成了 undefined is not a function。调用前先typeof window[action] === 'function'再执行,能把这个报错挡掉。
4. undefined、null、未声明:三兄弟一张表理清
4.1 显著性差异对照
面试里问"undefined 和 null 的区别",如果只答"undefined 是未定义,null 是空对象"就太浅了。真正见功力的是它们在各种操作里的行为差:
| 对比维度 | undefined | null | 未声明变量 |
|---|---|---|---|
| typeof 结果 | "undefined" | "object"(历史遗留) | "undefined"(安全) |
| === undefined | true | false | 直接读会抛 ReferenceError |
| == null | true | true | / |
| JSON.stringify 对象属性 | 被删除 | 保留为 null | / |
| 默认参数/解构默认值 | 触发默认值 | 不触发默认值 | / |
| 语义倾向 | 尚未给值 | 有意图地置空 | 名字不存在 |
typeof null === "object"是语言设计时的历史包袱,不用深究为什么,记住结论就行。真正的考点是:undefined 会触发默认值,null 不会。因为默认值机制只有在"当前值是 undefined"时才生效,null 在语义上是一个"已经填好的空值",语言不会帮它兜底。
4.2 语义上谁负责什么
我的团队里有一条约定:接口返回的数据,"空"一律用 null;程序内部还没准备好的中间变量,一律保持 undefined。null 表示"这个值是我们主动留空的,不是程序没有赋值";undefined 表示"这个东西的赋值流程还没发生"。数据库里的空值、配置文件里的缺省项这种"业务空"用 null;组件挂载前的状态、函数还没 return 的默认返回这种"程序空"用 undefined。两者混用会让日志完全没法看,因为同一个字段,一会儿是 null 一会儿是 undefined,排查的人只能两头猜。
4.3 判断手段分场景选择
- 判断一个已声明变量有没有值:
v === undefined最快。 - 判断全局环境里有没有某个 API:必须用
typeof window.xxx === 'undefined',直接比较会因为未声明而抛错。 - 判断对象里是否存在某个键,且不想被"值为 undefined"干扰:用
'key' in obj或Object.prototype.hasOwnProperty.call(obj, 'key')。 - 想同时匹配 null 和 undefined,且不介意旧写法:可以用
v == null。这里的宽松相等是刻意设计的——null 和 undefined 互相等于,并且只等于对方。===下它们不相等,这是最基础的考点。
5. 把 undefined 挡在业务代码外面:防御式写法清单
5.1 空值兜底优先用 ??,别随手写 ||
const time = res.data.starttime || '--';这段代码在 starttime 是 0、''、false 时也会把默认值 '--' 命中。前端很多"时间为 0 却显示成 --"的 bug 就是从这里来的。业务上想表达的语义通常是"只有 undefined 或 null 才用默认值",这时候应该写:
const time = res.data.starttime ?? '--';记住一句话:要拦截 undefined/null 用??,要拦截所有假值才用||。
5.2 可选链打在数据出入口,别滥用
res?.data?.list?.[0]?.starttime ?? '--'可选链能极大减少 Cannot read properties 报错,但它不是银弹。滥用会让接口结构变更被静默吞掉——字段层级变了,代码不报错,只显示默认值,反而更难发现契约已经失效。我的经验是:在 IO 边界(接口返回、事件传值)和模板渲染入口使用可选链,中间的纯业务逻辑尽量保证数据已经规范化。如果项目里有 ESLint,可以结合@typescript-eslint/no-unnecessary-condition之类的规则控制滥用。
5.3 函数默认参数和解构兜底双管齐下
function formatTime(time = '--') { ... } function parseList({ list = [] } = {}) { ... }这里有两层兜底:外层= {}挡"整个参数没传"或"传了 undefined",内层list = []挡"参数对象存在但没有 list 字段"。注意,如果来源可能给出 null,第一层兜底不会生效,因为在解构语境里 null 不触发默认值。所以源头给 null 的地方,得先?? {}转换一次,或者干脆让后端统一返回对象而不是 null。
5.4 合并对象时把 undefined 字段洗掉
Object.assign({}, defaults, payload)会把 payload 里的 undefined 值原样覆盖 defaults,导致默认配置失效。这一点在"接口返回覆盖本地默认配置"的场景里非常坑。想保留默认值,可以写一个简单的过滤工具:
function pickDefined(obj) { return Object.fromEntries( Object.entries(obj).filter(([, v]) => v !== undefined) ); } const config = { ...defaults, ...pickDefined(payload) };其实核心思想只有一句:undefined 字段不该参与"覆盖"操作。
5.5 约定状态初始值:集合用 []、对象用 {}
状态管理里的初始值也是一个重灾区。定义一个全局列表,初始状态是 undefined,页面上直接list.map(...)必然报错。建议约定:凡是会给业务使用的集合,初始化为[];详情对象初始化为{};标量初始化为可展示的默认值。list为[]时list.map不会报错,list.length也能正常工作,大部分 Cannot read properties 都能在源头消失。
5.6 严格模式和 TypeScript 是最后一道防线
给脚本加上'use strict',函数内 this 丢失时不会偷偷变成 window 继续运行,而是直接变成 undefined 并抛错,让问题尽早暴露。上 TypeScript 并开启strictNullChecks之后,undefined 和 null 会变成类型层面的强制检查,绝大多数这类运行时错误可以提前到编译期。即便没有条件上 TS,也建议把 ESLint 的no-undefined打开,至少能杜绝window.undefined = ...这类危险写法。
6. 我自己的几个排查检查小习惯
6.1 打断点不猜,断言比 console.log 更靠谱
我见过太多人排查 undefined 报错就是到处加 console.log,打印一堆看得眼花。更推荐用console.assert加条件断言:
console.assert(Array.isArray(data.data.list), 'list 必须是数组', data.data);条件满足时控制台安静如山,不满足时连数据一起打出来。日志噪音小,定位速度快。另外,开发环境可以在全局挂一个window.addEventListener('unhandledrejection', e => console.error(e.reason)),把所有没被捕获的 Promise 失败统一收集起来,避免 uncaught (in promise) 刷屏时找不到源头。
6.2 写一个安全取值函数,替代层层判断
项目初期如果不想在每个页面都写&&链路,可以沉淀一个工具函数:
function getByPath(obj, path, fallback = undefined) { const keys = path.split('.'); let current = obj; for (const key of keys) { if (current == null) return fallback; current = current[key]; } return current === undefined ? fallback : current; } getByPath(res, 'data.list.0.starttime', '--');它能覆盖"中间某层为空、最后一层是 undefined"两种情况,代码可读性也更好。等团队规范成型后再逐步替换成可选链,或者让 TS 严格模式接管。
6.3 动态调用先验类型,字符串调函数不再翻车
const action = 'submitOrder'; if (typeof window[action] === 'function') { window[action](payload); } else { console.error('动态方法不存在:', action); }这个习惯尤其适合带插件机制或原生桥接的项目。外部容器注入的 JS 方法若没有按时就绪,这个判断会直接把问题从"运行时崩溃"变成"可控日志"。
我在实际项目里还有一个"笨"方法:遇到迟迟查不出原因的 undefined,就把相关数据JSON.stringify之后复制到本地文件里,用结构对比工具慢慢看。字符串化会丢函数、丢 undefined 字段,但数据结构一目了然,层级和命名对不对立刻见分晓。等你的代码里到处是?? []、?.和明确的初始值之后,再回头看这些排查技巧,大部分都用不上了——这才是好事。