
事情是从一个排序功能开始的。当时我维护一个表格组件产品经理要求在表头点一下就能按某个字段排序。我第一版老老实实写了一串switch-case每支持一个字段就加一个case后来字段越来越多代码膨胀得不成样子。某个版本又新增了三个排序字段我看着那个越来越长的函数意识到这种写法压根走不通。然后我换成用方括号动态访问对象属性——把字段名当作变量传进去30多行分支代码变成了3行通用逻辑。这就是JS对象属性动态访问的典型价值当属性名在运行时才知道、或者需要由数据驱动时方括号语法就是你的主力工具。这篇文章我把自己踩过的坑、用过的场景、以及背后的机制一次性说清楚适合正在写业务逻辑、做组件库、或者想彻底搞懂对象属性访问机制的前端开发者。1. 点号与方括号动态访问背后的属性名机制1.1 属性名在引擎眼里都是字符串或Symbol很多初学者对动态访问的第一印象是obj.name和obj[name]等价仅此而已。这句话没错但它掩盖了一个关键事实——对象属性名在JavaScript引擎内部压根不区分变量名还是字符串字面量统一都会被转换成字符串或者是Symbol来存储。const user { name: 张三, age: 30 }; console.log(user[name]); // 张三 console.log(user.name); // 张三 // 甚至这样也能访问到 const key na me; console.log(user[key]); // 张三这里背后的机制叫 ToPropertyKey。不管你是用点号、方括号还是在对象字面量里写键名最终引擎都会把键转成属性键。数字会被转成字符串true会转成truenull和undefined也都有对应的字符串形态。这个知识点不是用来背的它直接解释了后面一大票诡异行为——为什么obj[1]和obj[1]会是同一个属性为什么obj[null]也能取值。1.2 点号只是方括号的语法糖但有限制点号访问本质上是一种受限制的快捷写法它要求点号后面的部分必须是一个合法的标识符也就是说它得满足变量命名的规则不能是数字开头不能包含连字符、空格也不能是保留字不过在ES5之后保留字作为属性名其实放开了但习惯上还是避免。而方括号里可以是任意表达式——字符串拼接、函数返回值、变量值、甚至模板字符串的结果都可以。const data { user-name: 张三, 2023:total: 100, is admin: true }; // 点号直接报错 // data.user-name // 语法错误 // 方括号全部搞定 console.log(data[user-name]); console.log(data[2023:total]); console.log(data[is admin]); // 模板字符串作为key const prefix user; console.log(data[${prefix}-name]);这里的本质区别在于点号后面的 name 是被当作一个字面量属性名user-name来处理的而方括号里面的 user-name 如果写成标识符就会变成变量读取。所以当你看到object.expression这种写法时引擎读取的是object[expression]你没有任何机会让 expression 变成动态值。这就是为什么动态访问必须使用方括号语法。1.3 常见误区变量名与属性名混用我在评论区见过很多次类似的提问let obj { a: 1, b: 2 }; let a b; console.log(obj.a);为什么输出的是1而不是2这个问题的根子就在于没分清变量读取和属性读取。obj.a里的 a 是属性名引擎不会去外面找一个叫 a 的变量它只会找 obj 这个对象里名为 a 的属性而obj[a]里的 a 是变量表达式引擎先求值变量 a 得到字符串 b然后再去 obj 里取键为 b 的属性。这两步的先后顺序完全不同。把这一点想透你就能理解所有动态访问的核心逻辑方括号里的内容先求值成字符串字符串再作为键去对象里查找。中间隔了一层表达式求值这就是动态二字的来源。2. 真正能用上动态访问的常见业务场景2.1 列表排序与筛选最典型的动态取值回到文章开头的表格排序场景。动态访问让按哪个字段排序变成纯数据驱动表头配置里写清楚每列对应对象的哪个字段排序函数只需要读row[field]即可。// 表头配置 const columns [ { label: 姓名, field: name }, { label: 年龄, field: age }, { label: 销售额, field: sales.total } ]; // 排序函数这里用到了嵌套属性的动态取值 function sortByField(list, field, direction asc) { return [...list].sort((a, b) { const valA field.split(.).reduce((obj, key) obj?.[key], a); const valB field.split(.).reduce((obj, key) obj?.[key], b); if (valA valB) return 0; const result valA valB ? 1 : -1; return direction asc ? result : -result; }); }这个例子里值得注意的点有两个。第一通过分隔符拆解属性路径再用reduce逐层动态访问可以支持sales.total这种嵌套字段这一步在很多通用表格组件里就是这么实现的。第二这里用了可选链obj?.[key]防止中间某一层不存在时报错。我实测过这种写法处理几百条数据的表格没有任何问题而且新增加排序字段只需要在 columns 配置里加一行排序函数一行都不用改。2.2 表单字段映射与API数据格式化做过后端联调的人都有过这种经历后端返回的字段命名风格和前端展示不一致比如后端给created_at前端组件要的是createTime。字段少的时候可以手写映射字段一多就得靠动态访问批量处理。// 字段映射表 const fieldMap { created_at: createTime, updated_at: updateTime, user_name: userName, is_active: isActive }; // 批量格式化成前端需要的结构 function formatData(raw) { const result {}; Object.entries(fieldMap).forEach(([sourceKey, targetKey]) { if (raw[sourceKey] ! undefined) { result[targetKey] raw[sourceKey]; } }); return result; }这种做法的好处是映射关系集中放在一张表里字段改名、删减都只动配置不用在业务代码里到处找raw.created_at。我做这类处理时还会顺手处理空值——很多后端会把空字符串、null、undefined混着返回统一在映射阶段做清洗后面组件层就会干净很多。另一个相关的场景是表单校验。用户提交的表单是一个对象校验规则可以动态读取字段const validators { username: (val) val.length 3, password: (val) val.length 6, email: (val) //.test(val) }; function validate(formData) { const errors {}; Object.entries(validators).forEach(([field, validator]) { errors[field] validator(formData[field]) ? : 校验不通过; }); return errors; }这里 if 分支完全消失了校验规则和数据字段通过 key 动态对齐。以后新增校验字段只要往 validators 里加一条validate 函数一行不改。这就是动态访问在逻辑根据数据结构自动适配上的威力。2.3 配置驱动逻辑用映射表替代if-else判断字符串是否包含某个值、做状态映射这类业务逻辑如果全写if-else代码会变得又臭又长。用对象做映射表key来自动态访问可读性和扩展性都会好很多。// 状态映射避免一堆 if-else const statusTextMap { pending: 待审核, approved: 已通过, rejected: 已驳回, cancelled: 已取消 }; function getStatusText(status) { // 动态访问映射表找不到就返回原值兜底 return statusTextMap[status] || status; }这种写法的隐含前提是status 必须是受控的枚举值不能是用户随便传的字符串。如果 key 完全不受控那就要考虑是不是该用 Map 而不是对象。这个取舍我放到第5章详细讲这里先记住一点动态访问很适合已知key集合、需要批量处理的场景而在key完全不可预测的场景下对象并不是最优数据结构。3. 动态访问的暗坑原型链污染与类型转换3.1 key不存在与值为undefined两件不同的事用动态访问读取属性时最容易踩的第一个坑是把属性不存在和属性值为undefined混为一谈。const obj { a: undefined, b: 1 }; console.log(obj[a]); // undefined console.log(obj[c]); // undefined // 看着都是undefined但语义完全不同 a in obj; // true属性存在只是值为undefined c in obj; // false压根没有这个属性如果你用obj[key] undefined来判断某个字段是否存在那么当这个字段确实存在但值就是undefined时判断会误伤。我见过线上bug就是这样造成的——后端返回的某个字段在特定条件下是undefined前端用它做存在性判断结果整条数据被错误地丢弃了。更可靠的判断方式是结合key in obj或者Object.prototype.hasOwnProperty.call(obj, key)来使用。注意这里我特意写了Object.prototype.hasOwnProperty.call因为在很多实际项目中对象可能是Object.create(null)创建的没有原型方法也可能某个对象自己定义了同名方法覆盖了原型上的hasOwnProperty直接调用obj.hasOwnProperty(key)会踩到这些坑。3.2 原型链污染__proto__与constructor的陷阱这一节是我个人认为动态访问最容易引发安全风险的场景。当你用外部传进来的字符串作为key去访问或设置对象属性时有几类特殊key必须警惕__proto__、constructor、prototype。const userInput __proto__; const obj {}; // 这行代码如果不加防护会改变obj的原型链 // obj[userInput] { polluted: true }; // 实际上 console.log(obj.polluted); // true —— 原型被污染了问题的根源在于对象的属性查找会顺着原型链往上走。当你给obj[__proto__]赋值时改的不只是 obj 自己而是它的原型对象。如果外部输入可控攻击者甚至可以通过构造{__proto__: {isAdmin: true}}之类的JSON数据来污染Object.prototype从而影响所有对象。这就是历史上lodash原型链污染漏洞的核心原理。动态访问业务字段时我建议做两件事。第一优先使用Object.create(null)创建这些映射表这样的对象没有原型链__proto__就是普通属性不会触发污染。第二如果必须用普通对象在写入之前过滤掉__proto__、constructor、prototype这几个key。// 安全取值判断是否是对象自身的属性顺带排除危险key const UNSAFE_KEYS [__proto__, constructor, prototype]; function safeGet(obj, key) { if (UNSAFE_KEYS.includes(key)) return undefined; return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined; } function safeSet(obj, key, value) { if (UNSAFE_KEYS.includes(key)) return; Object.defineProperty(obj, key, { value, writable: true, enumerable: true, configurable: true }); }这里我故意在 safeSet 里用了Object.defineProperty而不是直接赋值赋值是因为 defineProperty 在 key 为__proto__时也不会触发原型链的setter行为相当于多了一层防御。判断对象是否是空对象也一样不要用Object.keys(obj).length 0就完事还得确认它是不是Object.create(null)创建的以及有没有原型属性这些细节在具体排查时都跑不掉。3.3 隐式类型转换数字键、null键、布尔键动态访问时方括号里的表达式会被求值然后通过 ToPropertyKey 转成字符串。这个转换过程隐藏着一系列让人挠头的等价关系。const obj {}; obj[1] one; obj[1] one; // 同一个属性数字变成了字符串1 obj[true] yes; obj[true] yes; // 同一个属性 obj[null] null; obj[null] null; // 同一个属性 obj[undefined] undefined; obj[undefined] undefined; // 甚至对象作为key时也会被转成字符串 obj[{}] empty obj; obj[[object Object]] empty obj; // 同一个属性日常开发中最容易遇到的坑是数字型key。后端返回的字段名可能是字符串形式的数字比如2024前端如果处理不当obj[2024]和obj[2024]会踩到同一个属性上这在做年份、分类ID这类特殊字段时要注意。另外使用对象做key时必须知道它默认会调用 toString多个不同对象转出来的可能都是[object Object]互相覆盖。如果真要用对象做键正确选择是 Map 而不是普通对象。这也解释了为什么很多动态访问相关的工具函数里判断key的类型要先做一次typeof和String()转换预处理。4. 继续挖一层Reflect.get、Proxy 与动态取值4.1 Reflect.get 与 Proxy.get从读取到拦截动态访问不只是简单的方括号语法ES6 之后 Reflect 和 Proxy 给了我们更强的手段。Reflect.get(target, propertyKey, receiver)可以显式指定 receiver这在处理继承属性时会非常有用。const parent { get greeting() { return 你好${this.name}; } }; const child Object.create(parent); child.name 张三; // 普通访问 console.log(child.greeting); // 你好张三 // 动态访问 手动指定receiver Reflect.get(parent, greeting, child); // 你好张三这里 receiver 决定了 getter 内部的 this 指向谁。有些场景下你想借用父对象上的 getter 方法但又希望 this 指向子对象就可以用 Reflect.get 的第三个参数来精确控制。Proxy 的 get 拦截则是动态访问的进阶应用。它是响应式框架数据绑定、依赖跟踪的基础设施。一个最小的例子const handler { get(target, key, receiver) { // 动态访问时统一做埋点或日志 if (key.startsWith(_)) { throw new Error(私有属性 ${key} 不允许访问); } return Reflect.get(target, key, receiver); } }; const proxied new Proxy({ _secret: 内部数据, name: 张三 }, handler); console.log(proxied[name]); // 张三 // proxied[_secret] // 抛错你看get 拦截函数接收的第二个参数就是动态访问的 key。所有proxied[xxx]、proxied.xxx调用都会先经过这里。在拦截函数里你可以做权限校验、埋点统计、值转换、懒加载而业务代码的访问方式完全不用变。这也是我理解动态访问最有价值的一面——既然属性的读取时机是动态的你就有机会在这个环节插入横切逻辑。4.2 计算属性名与动态解构动态访问的反方向是动态定义属性——用变量作为对象字面量的键名这被称为计算属性名computed property names。这个语法经常和解构配合使用实现按变量名从对象中提取字段。// 计算属性名key来自变量 const dynamicKey score; const record { [dynamicKey]: 98, name: 张三 }; // 动态解构从对象里按变量值提取 function pick(obj, key) { const { [key]: result } obj; return result; } console.log(pick(record, score)); // 98动态解构的应用场景通常是函数参数是一个对象你需要按调用方传入的key名提取某个字段。用解构语法可以用一行代替方括号加变量赋值的两行操作而且配合默认值写法还能顺便处理属性不存在的情况。说到动态访问和格式化很多工具库里的从对象中挑选若干字段、排除若干字段功能本质就是遍历字段名数组逐一动态取值、动态赋值。自己也完全可以实现function pick(obj, keys) { return keys.reduce((acc, key) { if (key in obj) { acc[key] obj[key]; } return acc; }, {}); } function omit(obj, keys) { const keySet new Set(keys); return Object.fromEntries( Object.entries(obj).filter(([key]) !keySet.has(key)) ); }这里写key in obj而不写obj[key] ! undefined是为了规避3.1节说的那个坑当属性值本身就为 undefined 时后者会错误地把字段丢掉。4.3 可选链与动态访问组合ES2020之后可选链运算符也可以用在方括号访问上obj?.[key]。这个组合在动态访问里非常好用尤其是处理深层嵌套的数据时。// 多层动态访问中间任何一层不存在都不会报错 function deepGet(obj, path) { return path.split(.).reduce((current, key) current?.[key], obj); } console.log(deepGet({ a: { b: { c: 1 } } }, a.b.c)); // 1 console.log(deepGet({ a: {} }, a.b.c)); // undefined而不是报错如果没有可选链reduce 里就要多写一长串判空逻辑。现在这种写法足够简洁而且我实测过可读性对团队成员来说几乎零成本——只要会普通动态访问这个语法一眼就能明白。需要提醒的是obj?.[key]和obj[key]在对象为 null/undefined 时的行为是不同的前者安全返回 undefined后者直接抛 TypeError这一点在写通用工具函数时尤其关键。5. 性能与规范动态访问在工程中的合理姿势5.1 隐藏类与动态访问的实际性能表现关于动态访问性能的问题社区里有不少以讹传讹的说法什么点号快方括号慢动态访问会导致V8优化失败之类。我来拆一下背后的原理。V8 引擎对对象的优化依赖隐藏类Hidden Class / Maps。当对象属性的形状稳定时引擎可以精准预测属性在内存里的偏移量点号访问被直接编译成固定偏移的内存读取这是最快的路径。而方括号访问里如果是一个动态key引擎无法提前知道偏移量可能要进入字典模式的查找流程理论上确实比点号慢——但这个前提是同一段代码在热路径里高频执行而且key在每次迭代中变化。实际上V8 对方括号访问也做了很多优化。连续使用相同的key访问对象引擎会记录这个key对应的隐藏类并缓存查找结果性能并不会崩。我拿一万条数据做过排序测试动态key做表格排序没有感知层面的性能差异。真正会触发性能问题的是这两类写法第一对象的属性形状极不稳定每创建一个对象就加一个新key且key是动态的引擎会放弃隐藏类优化转成字典模式第二在循环体内频繁拼接字符串作为key访问导致每次的key字符串都是新对象缓存完全失效。// 不推荐循环里反复计算key字符串 for (const item of list) { const val obj[data_${item.type}_${item.id}]; // key每次重新创建 } // 推荐先把key算好局部变量复用 for (const item of list) { const key data_${item.type}_${item.id}; const val obj[key]; // key字符串被复用 }我可以负责任地说对99%的业务代码这种性能差异可以忽略。它不像算法复杂度那样直接影响体量更多是个健康习惯。真有极致性能需求时优先考虑数据结构是否合理而不是揪着点号还是方括号。5.2 工程规范对象动态访问 vs Map在读代码评审时我经常看到两种极端一种极端是所有的映射关系统统用对象动态访问包括key完全不可控的用户输入另一种极端是谈动态访问色变遇到一点映射需求就上Map。这两种都不算最优解。我的判断标准很简单key 集合可控比如枚举状态、固定的字段映射表用对象配合动态访问代码直观且适合序列化。key 完全不可控比如用户自定义字段、运行时动态增长的键集合用 Map。Map 的 key 可以是任意类型不会触发原型链污染且天生适合频繁增删。需要遍历但不在意顺序对象键有特定的遍历顺序规则整数键会排前面如果你对这个有要求考虑用 Map。// 场景一受控枚举 —— 用对象动态访问 const LEVEL_LABEL { 1: 初级, 2: 中级, 3: 高级 }; console.log(LEVEL_LABEL[level] || 未知); // 场景二不可控key —— 用Map const cache new Map(); cache.set(userInputKey, value); console.log(cache.get(userInputKey));在实际项目里这两种结构经常混用。比如表单校验规则用对象key是固定字段名而校验结果里的动态错误信息可能挂到一个 Map 上key是报错字段某个随机值。理解它们的适用边界比记住哪种快更有工程意义。5.3 我的习惯安全取值函数与团队规范写到这里我把自己的几个实践习惯分享给你们也算是对整篇文章的一个可执行总结。第一个习惯项目中封装一个通用的安全取值/赋值工具名字可以叫getByPath、setByPath之类的。它内部处理了原型链污染、路径解析、空值兜底这些细节业务代码里用到动态访问的地方尽量走这个工具而不是到处裸写方括号。尤其要处理字符串路径与数组路径两种传入形式这样各种场景都能覆盖。// 通用取值工具简版 function getByPath(obj, path, defaultValue) { if (obj null) return defaultValue; const keys Array.isArray(path) ? path : path.split(.); let result obj; for (const key of keys) { if (result null) return defaultValue; if (UNSAFE_KEYS.includes(key)) return defaultValue; if (!Object.prototype.hasOwnProperty.call(result, key)) return defaultValue; result result[key]; } return result undefined ? defaultValue : result; } // 通用赋值工具简版 function setByPath(obj, path, value) { const keys Array.isArray(path) ? path : path.split(.); let current obj; for (let i 0; i keys.length - 1; i) { const key keys[i]; if (UNSAFE_KEYS.includes(key)) return false; if (current[key] null || typeof current[key] ! object) { current[key] {}; } current current[key]; } const lastKey keys[keys.length - 1]; if (UNSAFE_KEYS.includes(lastKey)) return false; current[lastKey] value; return true; }第二个习惯字段名统一用常量管理。在项目里专门建一个field-names.js把所有会用到的动态key都定义成常量。这样动态访问的时候代码不再散落着一堆魔法字符串团队其他人也能一眼看出这个key从哪来到哪去。如果字段名变动只需要改常量定义处grep起来也方便。这个习惯在多人协作时价值极大我至少见过三次因为魔法字符串拼写错误导致的线上数据读取异常全部是字段名写错一个字母这种低级问题。第三个习惯写单元测试时专门针对动态访问的特殊key做用例。__proto__、空字符串、数字0、null、undefined都测一遍。这些用例平时看着无聊一旦上线前触发原型链污染或者空值处理bug你就会庆幸这些测试的存在。最后再分享一个小技巧当你调试一个对象却不知道有哪些可用key时直接Object.keys(obj)或者Reflect.ownKeys(obj)把键拉出来经常比盯着代码猜半天要快得多。动态访问和反射遍历本来就是一对搭档——一个负责精确取一个键一个负责把所有键摊开给你看。把这两招配合好处理对象属性这块基本就不会再被绊住了。