深入理解 JavaScript undefined:从原理到 Cannot read properties 排错实战
如果让我给前端运行时错误排个出场率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是一个单目运算符不管后面接什么表达式它都返回 undefinedvoid 0、void(hello)、void anyObject结果一样。你在老项目里看到的javascript:void(0)就是利用这一点把它作为链接的 href 值执行后JavaScript 表达式返回 undefined浏览器不会拿它触发页面跳转。现在写a hrefjavascript:void(0)已经不推荐了用button typebutton更语义化但理解void能帮你搞清楚表达式没有返回值和undefined之间的关系。1.3 typeof 的安全检测与裸变量检测的坑判断一个东西是不是 undefined有两种写法路线完全不一样if (sdk undefined) { /* 直接比较 */ } if (typeof sdk undefined) { /* 通过类型名判断 */ }前者要求sdk这个名字已经被声明过否则会抛 ReferenceError后者因为typeof对未声明的变量也返回 undefined永远不会抛错。所以判断某个全局方法、某个 SDK 存不存在只能用 typeof。反过来说如果目标变量已经声明 undefined更直接真正想区分变量未声明和声明了但值是 undefinedtypeof 反而帮不上忙只能靠in操作符或全局对象的属性探测。这在写 SDK 兼容代码时是一个经典考点。再补两个基础转换值Number(undefined)是 NaNString(undefined)是字符串 undefinedBoolean(undefined)是 false。最后一个需要时刻警惕因为它意味着if (!obj.flag)在 flag 为 false、0 或 时也会进入分支很多时候这并非本意。至于怎么精确区分假值和缺值后面防御式写法那节会细说。2. 生产环境里最容易撞出 undefined 的九个场景2.1 声明了但没有赋值let a;之后 a 就是 undefinedvar a;同样。const则必须声明时赋值否则直接语法错误。这个场景最简单但大量 bug 的源头其实是以为赋了实际没赋——比如在分支里赋值另一个分支忘了写。2.2 函数没有 returnfunction 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 或 nullconst { 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].starttimelist[0] 就是 undefined。写法不对和字段名拼错报错长一个样但修法完全不同。第二个病根是异步时序。组件刚挂载就去读this.list[0].starttime请求还在路上this.list 还是 undefined或者某个 Promise 在中间被拒绝外层没捕获最后在浏览器报 uncaught (in promise)。这种报错往往不是每次复现和接口耗时、用户操作速度都有关。第三个病根是 this/回调上下文丢失或者外部注入的 JS 环境还没就绪。表单提交、事件监听里经常出现想用document.querySelector(input[namexxx]).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不是 undefinedDOM 没找到、JSON.parse 得到 nullundefined 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 是空对象就太浅了。真正见功力的是它们在各种操作里的行为差对比维度undefinednull未声明变量typeof 结果undefinedobject历史遗留undefined安全 undefinedtruefalse直接读会抛 ReferenceError nulltruetrue/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 字段但数据结构一目了然层级和命名对不对立刻见分晓。等你的代码里到处是?? []、?.和明确的初始值之后再回头看这些排查技巧大部分都用不上了——这才是好事。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →