前几天改一个老项目里的表格排序功能,遇到一个特别典型的报错:TypeError: Cannot read properties of undefined (reading 'values')。翻到代码才发现,同事用点号写法硬编码了一个由后端动态返回的字段名。这个场景我见过太多次了——对象属性的“动态访问”看起来只是个语法细节,但实际开发中,十个报错里至少有八个跟它有关。
对象属性的动态访问,简单说就是通过方括号语法obj[key]来读取属性,其中key可以是变量、表达式甚至函数返回值,而不是写死的字符串。它要解决的核心问题只有一个:当属性名在运行时才能确定时,代码该怎么写。怎么拿用户输入的字段名去取数?怎么遍历一个对象的所有键值对?怎么把后端返回的一堆字段映射成前端要的结构?这些全部依赖动态访问。
这篇文章我会从语法本质、应用场景、常见坑位、进阶组合四个方面把它讲透,最后再带大家一起封装一个安全好用的动态取值工具函数。无论你是刚接触 JS 的新手,还是写了几年业务代码的老手,里面都有能直接抄作业的东西。
1. 动态访问是什么:从一次线上 bug 说起
先还原一下那个 bug 的场景。
后端返回的数据结构是这样的:
const res = { data: { orderInfo: { totalAmount: 99.5, status: 'paid' }, userInfo: { name: '张三', level: 'vip' } } }表格组件需要的列配置是后端动态下发的,字段名可能是data.orderInfo.totalAmount,也可能是data.orderInfo.status。同事当时图省事,直接用字符串拼接加三层点号去取:
const field = 'data.orderInfo.totalAmount' const value = res.data.orderInfo.totalAmount // 写死了写死的结果就是:后端一旦把字段从totalAmount改成payAmount,前端直接白屏。这还只是问题的一半。真正的麻烦在于,有些字段需要根据当前操作动态决定,比如点击了“金额”列就取totalAmount,点击了“状态”列就取status,这种运行时才能确定的取值需求,点号写法完全无能为力。
1.1 点号和方括号的本质区别
JS 里访问对象属性有两种语法:
const obj = { name: '张三', 'user-age': 18 } // 点号访问 obj.name // '张三' // 方括号访问 obj['name'] // '张三' obj['user-age'] // 18看到区别了吗?第一种是点号obj.name,第二种是方括号obj['name']。这两者在绝大多数情况下等价,但有一个根本性的区别:方括号里面可以放任意表达式。
const key = 'user-age' obj[key] // 18,注意这里没有引号 const prefix = 'user' obj[prefix + '-age'] // 18,先拼接再访问点号后面的内容必须是一个合法的标识符,比如字母、数字、下划线、$,而且不能以数字开头。方括号则没有这个限制,放什么都行——带连字符的'user-age'、纯数字的'0'、甚至 Symbol 类型的 key 都可以。
从 JS 引擎的角度看,obj.name等价于obj['name'],引擎内部会把点号后面的标识符自动转换成一个字符串 key 去查找。所以点号是一种“静态”写法的简写,方括号才暴露了属性访问的完整能力。
1.2 为什么说“动态”:变量求值的过程
理解动态访问的关键,在于理解方括号里的表达式是怎么被求值的。
const obj = { a: 1, b: 2 } let key = 'a' obj[key] // 1 key = 'b' obj[key] // 2这里obj[key]中的key是变量。JS 引擎在执行obj[key]时,会先把key的值取出来,得到字符串'a',然后再去对象里查找名为'a'的属性。这个查表动作发生在运行时,而不是写代码的时候。
正因为是运行时求值,所以方括号里可以放任何能得出字符串或 Symbol 的表达式:
const id = 1001 const user = { 'user_1001': { name: '李四' } } // 模板字符串 user[`user_${id}`] // { name: '李四' } // 三元表达式 const type = condition ? 'admin' : 'guest' user[type] // 函数返回值 const pickedKey = getSortField() // 运行时才知道返回什么 obj[pickedKey]这就是动态访问的核心:用一份代码,处理无数种可能的属性名。点号写法是“我在编译时就确定了访问谁”,方括号是“我运行时才决定访问谁”。理解了这一点,后面所有场景都能顺下来。
2. 动态访问的典型应用场景:数据映射与配置驱动
动态访问不是面试题里用来炫技的,它几乎是所有前端业务代码的地基。我挑三个出现频率最高的场景展开说,每个都是我在真实项目里反复写过的。
2.1 遍历对象:for...in 与 Object.keys 的组合拳
拿到一个对象,想把它里面的所有键值对都过一遍,最常见的诉求有两个:要么是找某个值,要么是改造整个结构。这两种需求都离不开动态访问。
先看一个最简单的场景,统计一个对象里所有数字字段的总和:
const order = { apple: 3, banana: 5, coupon: 'SAVE10', shipping: 2 } let total = 0 for (const key in order) { if (typeof order[key] === 'number') { total += order[key] } }for...in循环遍历的是对象的所有可枚举属性名,拿到key之后,再用order[key]去取对应的值。如果你用order.key,取到的永远是一个叫'key'的属性——它不存在。这个错我在新手代码里看到不下几十次。
再复杂一点,过滤出对象里指定的几个字段:
function pick(obj, fields) { const result = {} fields.forEach((field) => { result[field] = obj[field] }) return result } const user = { name: '王五', age: 25, password: '123456' } pick(user, ['name', 'age']) // { name: '王五', age: 25 }这里result[field] = obj[field]是典型的双向动态访问:左边在动态地创建属性,右边在动态地读取属性。如果只懂点号写法,这个函数根本写不出来。
Object.keys()和Object.entries()也是遍历时的好搭档。尤其是Object.entries(),它把每个键值对直接打包成[key, value]数组,配合解构非常爽:
Object.entries(user).forEach(([key, value]) => { console.log(key, value) })动态访问在这里依然是核心——没有它,Object.entries()返回的 value 就没人去消费。
2.2 后端字段映射:配置驱动的前端表格
这是我个人觉得动态访问最有价值的场景。后端返回的数据字段往往长得跟 UI 需要的结构不完全一致,或者前端需要根据配置文件来决定展示哪一列、显示什么内容。
最常见的做法是列配置数组:
const columns = [ { title: '订单号', dataIndex: 'orderNo' }, { title: '用户', dataIndex: 'userId' }, { title: '金额', dataIndex: 'amount' } ] function buildRows(list, columns) { return list.map((item) => { const row = {} columns.forEach((col) => { // 动态访问:item[col.dataIndex] row[col.title] = item[col.dataIndex] }) return row }) }这个函数的核心就一行:item[col.dataIndex]。列配置里写的是字符串'orderNo',真正取数的时候,这一行代码会根据配置动态地从每条数据里取对应字段的值。如果你用点号硬编码,每个列都得写一行row['订单号'] = item.orderNo,字段一多代码就疯了。
更复杂的场景是跨层取值。后端返回的是嵌套结构:
const data = { orderInfo: { id: 'A001', amount: 199 }, buyer: { nickname: '小明', mobile: '138xxxx' } }列配置里对应的字段名可能是'orderInfo.id'或'buyer.nickname'。这时候单纯的item[col.dataIndex]就不够了,需要写一个支持路径解析的动态取值函数。这个函数我在第 5 节会完整实现,这里先不展开。
2.3 状态映射与动态表单:用对象替代 switch
业务代码里最常见的一类重复劳动,是根据某个值返回对应的文案、颜色、样式。很多人会写一长串switch,其实用动态访问配合映射对象,两三行就能搞定。
比如订单状态:
const statusMap = { WAIT_PAY: '待支付', PAID: '已支付', SHIPPED: '已发货', DONE: '已完成' } function getStatusText(status) { // 如果在映射表里找不到,就原样返回 return statusMap[status] || status }这里statusMap[status]就是动态访问。status是运行时才确定的变量,可能是PAID,可能是DONE,也可能是一个前端没见过的未知状态。用映射表的好处是:加状态只需要往对象里加一个键,不用改函数逻辑。
动态表单也同理。假设表单的校验规则是按字段名组织的:
const rules = { username: [requiredRule, minLengthRule(3)], email: [requiredRule, emailRule] } // 动态读取某个字段对应的校验规则 const fieldName = 'email' validate(fieldName, values[fieldName], rules[fieldName])values[fieldName]取出用户填的值,rules[fieldName]取出对应的校验规则。你要写一个通用校验器,就必须用这种动态写法。
3. 动态访问的安全边界:新手最容易踩的 3 个坑
动态访问好用,但坑也多。我总结了三个在实战里出现频率最高的,每一个都是我亲眼见过线上出问题的。
3.1 多层动态访问的 undefined 地狱
obj[key]访问一个不存在的属性,不会报错,只会返回undefined。但如果你在undefined上继续访问,那就炸了:
const data = { user: { name: '张三' } } const key = 'user' data[key]['name'] // 没问题,'张三' const badKey = 'address' data[badKey]['city'] // TypeError: Cannot read properties of undefined报错的原因很简单:data[badKey]返回了undefined,然后 JS 试图在undefined上读取city属性,于是抛错。
这种问题在动态访问里特别隐蔽,因为key是变量,你很难一眼看出它到底能不能取到值。比起处理报错,更好的做法是访问前先确认结构存在:
// 传统写法:一级一级判断 if (data && data[badKey] && data[badKey]['city']) { console.log(data[badKey]['city']) } // 更推荐:可选链,直接从根上防住 const city = data?.[badKey]?.city注意data?.[badKey]?.city是动态访问和可选链的组合写法,?.[中间不需要加空格。这行代码在链上任何一环为null或undefined时,整个表达式都会安全返回undefined,不会抛错。
我个人的经验是:凡是访问嵌套对象且 key 来自外部输入(后端返回、用户输入、配置文件),一律用可选链,不要赌数据结构一定完整。后端加个字段、少个字段,前端不应该跟着崩。
3.2 原型链上的坑:in 和 hasOwnProperty 的区别
动态访问有个容易被忽视的问题:obj[key]不只是查对象自己的属性,还会沿着原型链往上找。
const obj = { name: '张三' } const key = 'toString' obj[key] // ƒ toString() { [native code] }这个例子可能会让不少人意外:obj这个对象明明没有自定义toString,但obj['toString']还是能取到一个函数,因为它是从Object.prototype上继承下来的。
在普通业务场景下,从原型链上取到方法最多是行为诡异。但如果你的key来自用户输入,就可能引发更严重的问题。比如用动态 key 去查对象,如果用户输入了constructor或__proto__,顺着原型链可能取到意想不到的东西,甚至造成原型污染。
所以动态访问时,判断一个属性是否真的属于对象自身,要用hasOwnProperty而不是in:
const obj = { name: '张三' } 'name' in obj // true,自有属性 'toString' in obj // true,原型链上的也算 Object.prototype.hasOwnProperty.call(obj, 'name') // true Object.prototype.hasOwnProperty.call(obj, 'toString') // falsein操作符会检查整个原型链,hasOwnProperty只检查对象自身。做动态访问的安全过滤时,后者的语义才是我们通常想要的。
3.3 被篡改的 hasOwnProperty:必须绕开的坑
更隐蔽的坑是:如果你在对象上动态创建属性时,不小心用了一个叫hasOwnProperty的 key,那原来的判断方法就失效了。
const obj = { hasOwnProperty: '我是自定义属性' } obj.hasOwnProperty('name') // TypeError: obj.hasOwnProperty is not a function因为obj.hasOwnProperty被自定义属性覆盖了,它不再指向原型上的方法,直接调用就会炸。动态访问的代码里,这种情况并不少见——用户数据里什么字段名都可能出现。
稳妥的写法有两种:
// 老派做法:借用 Object.prototype 上的方法 Object.prototype.hasOwnProperty.call(obj, 'name') // 新派做法:直接用 ES2022 的 Object.hasOwn Object.hasOwn(obj, 'name')Object.hasOwn是 2022 年加入的标准 API,语义跟Object.prototype.hasOwnProperty.call(obj, key)完全一致,写法更简洁。它把判断逻辑从对象自身“抽离”出来,所以无论对象的hasOwnProperty属性怎么被覆盖,都不影响判断结果。
4. 动态访问的进阶玩法:和 ES6+ 特性打组合拳
懂了基础语法,还得能在现代语法里灵活运用。这一节我把动态访问和几个高频 ES6+ 特性的组合方式过一遍,每一组都是我在代码里实际写过的。
4.1 解构赋值里的动态 key
解构赋值通常要求属性名写死,但你也可以在解构时使用动态 key:
const key = 'name' const { [key]: userName } = { name: '赵六', age: 20 } userName // '赵六'这里的[key]算是一个“计算属性名”,它会先计算变量key的值('name'),再从右侧对象里解构出对应属性,赋值给userName。注意解构出来的变量名是冒号后面那个,不是key本身。
这个特性的实用场景是:从一个对象里动态提取字段,而不是先赋值给临时变量再二次访问。
const user = { id: 1, profile: { nickname: '阿强' } } const field = 'profile' // 不用写两行,一步搞定 const { [field]: profileData } = user profileData // { nickname: '阿强' }配合 rest 剩余参数,还能把动态字段“摘”出来,留下其余部分:
const { [field]: picked, ...rest } = user // picked = { nickname: '阿强' } // rest = { id: 1 }这种写法在拆分配置项、剥离敏感字段时非常顺手。
4.2 可选链与空值合并:告别层层 if
动态访问最怕取不到值,而可选链?.和空值合并??就是专门治这个的。
你肯定写过这样的代码:
const status = obj && obj.data && obj.data[activeKey] ? obj.data[activeKey].status : 'unknown'用可选链之后可以缩成这样:
const status = obj?.data?.[activeKey]?.status ?? 'unknown'obj?.data?.[activeKey]?.status会依次检查每一层,任何一环是null或undefined就停止,整个表达式返回undefined。后面接??空值合并,表示当结果严格是null或undefined时,用默认值'unknown'兜底。
注意??和||的区别:||会把0、''、false这些假值也当成“没有”,而??只认null和undefined。如果字段值本身可能是0或者空字符串,用??才是正确的。
我把可选链动态访问当成标配来写:
const value = config?.[env]?.features?.export ?? false这一行同时处理了“配置没有加载”“环境类型不存在”“功能列表是空的”三种情况,用if写至少四五行。
4.3 计算属性名:对象字面量里的动态访问
前面讲的是“读”对象属性,反过来,对象也有很多场景需要在“创建”对象时就用上动态 key。ES6 的计算属性名语法可以直接在字面量里写:
const type = 'SAVE' const action = { type: type, [`${type}_FORM`]: { loading: false } } // 等价于 const action = { type: type, SAVE_FORM: { loading: false } }这种写法在写状态管理、生成配置项时特别有用。比如要在一次循环里批量生成不同 key 的配置对象:
const envs = ['dev', 'test', 'prod'] const config = {} envs.forEach((env) => { config[`api_${env}`] = `https://api.${env}.example.com` }) // config = { // api_dev: 'https://api.dev.example.com', // api_test: 'https://api.test.example.com', // api_prod: 'https://api.prod.example.com' // }如果你是等着循环结束后再给config加属性,那需要动态访问;但如果你能一步到位地构建对象,计算属性名更直观。两者本质是同一件事:用变量值作为属性名。
5. 实战:封装一个安全好用的动态取值工具
前面讲了这么多,最后落到一个能直接抄进项目的实战工具。我给大家实现一个get函数,支持通过'a.b.c'这样的点路径(或数组路径)安全取值,这也是很多工具库的核心实现思路。
5.1 需求分析与 API 设计
先明确要解决的问题:动态访问的常见痛点是多层嵌套时的未知路径。比如后端返回了data.orderInfo.detail.list,但中间任何一层缺失都会报错,我们希望有一个函数能安全地拿值。
设计思路很简单:
get(对象, 'a.b.c', '默认值') // 整个路径都能取到 -> 返回结果 // 路径中任何一环缺失 -> 返回默认值 // 对象本身是 null/undefined -> 返回默认值路径支持两种写法:字符串'a.b.c'或者数组['a', 'b', 'c']。数组形式的好处是,后端字段名本身可能包含点号,比如'user.name'作为一个整体 key,这种情况用字符串路径就解析错了。
5.2 实现与边界处理
先写一个最直观的版本:
function get(obj, path, defaultValue) { const keys = Array.isArray(path) ? path : path.replace(/\[(\d+)\]/g, '.$1') // 把 arr[0] 转成 arr.0 .split('.') .filter(Boolean) let result = obj for (const key of keys) { if (result == null) { return defaultValue // 中间环节缺失,提前退出 } result = result[key] } return result === undefined ? defaultValue : result }逐行解释几个关键点:
Array.isArray(path) ? path : ...统一把路径转成数组,方便循环。数组直接可用,字符串需要先拆分。path.replace(/\[(\d+)\]/g, '.$1')是处理数组索引的语法糖,比如list[0].name会被转成list.0.name,统一用.分隔。if (result == null)这里用的是==而不是===,目的是同时捕获null和undefined两种情况。- 最后一步
result === undefined ? defaultValue : result表示:路径走完了,但目标属性不存在(值为undefined),就返回默认值。注意null不会被替换成默认值,因为null是明确取到的一个值,语义上不同于“没取到”。
测试一下:
const data = { user: { name: '小明', contact: { phone: '138xxxx' } }, list: [{ id: 1 }, { id: 2 }] } get(data, 'user.name', 'unknown') // '小明' get(data, ['user', 'contact', 'phone']) // '138xxxx' get(data, 'user.address.city', 'unknown') // 'unknown' get(null, 'a.b', 'fallback') // 'fallback' get(data, 'list[0].id', 0) // 1 get(data, 'user.age', 18) // 18,默认值兜底支持数组索引这个能力,是直接在路径字符串里写list[0].id而不是list.0.id,对使用者更友好。这也是很多现成工具库(比如 lodash 的_.get)会做的兼容处理。
5.3 扩展:支持动态 key 的深度操作
get函数解决了“读”的问题,但动态访问往往也涉及“写”。我顺手扩展一个set方法,能够按路径动态地往深处写入:
function set(obj, path, value) { const keys = Array.isArray(path) ? path : path.split('.').filter(Boolean) let cursor = obj for (let i = 0; i < keys.length - 1; i++) { const key = keys[i] if (cursor[key] == null || typeof cursor[key] !== 'object') { cursor[key] = /^\d+$/.test(keys[i + 1]) ? [] : {} } cursor = cursor[key] } cursor[keys[keys.length - 1]] = value return obj }set的核心是:逐层下行,如果中间路径不存在,就根据下一个 key 是数字还是字符串,自动创建数组或对象。这在初始化树形数据结构、表单嵌套值回填时非常省事。
注意if (cursor[key] == null || typeof cursor[key] !== 'object')这里处理了一种特殊情况:路径穿越时,如果中间某个值已经存在但类型不对(比如是字符串),会被强制替换成新对象。这是“写”语义下的合理行为——既然要往下写,原来的值就得让路。
结合get和set可以做一些很有意思的事,比如把扁平表单数据重新组装成嵌套结构:
const flatValues = { 'user.name': '张三', 'user.contact.phone': '138xxxx', 'list[0].id': 1 } const result = {} Object.entries(flatValues).forEach(([path, value]) => { set(result, path, value) })这种“路径即 key”的思想,在很多表单库的表单值转换、配置合并工具里都有应用。动态访问的价值不只是读数据,更是用一套统一的路径语法去读写任意深度的数据结构。
结尾的个人体会
写到最后,想聊一点题外话。我见过很多同学觉得自己“会用obj[key]”就够了,直到在真实项目里被undefined is not an object这类报错折磨到深夜,才开始认真研究动态访问的边界问题。根据我个人经验,这个知识点值得花一整块时间系统过一遍,因为它是前端处理数据时绕不开的基石。而且你会发现,把它吃透之后,再去看 lodash 的get、可选链的底层逻辑、甚至 JSONPath 这类表达式的设计,都会顺畅很多。
最后再分享一个小技巧:在团队里写动态访问代码时,尽量把路径字符串抽成常量,而不是到处手写魔法字符串。比如const PATH = { USER_INFO: 'user.info' },这样即使后端字段改名,你也只需要改一处。这个小习惯,能在半年后维护自己代码时替你省下大量排查时间。