我在 JavaScript 里随手用一个普通对象当字典来存用户提交的键值数据、还用 key in obj 判断某个键存在不存在,结果有用户根本没提交过 toString 这个键、我的代码却判定它存在并走错了分支,更有个用户把键填成 __proto__ 直接让一批对象行为错乱,排查很久才搞懂用字面量对象当字典时它身上本就继承着一堆原型链上的属性的深度复盘
这次踩的坑,根源是我把 JavaScript 里的"普通对象"和"纯粹的字典/哈希表"划上了等号——可它们根本不是一回事。一个用 {} 创建的对象,从出生那一刻起,就不只装着我放进去的东西。
故障现场:没存过的键却"存在",还有用户把键填成 __proto__
我做了个功能,要把用户提交的一批键值对(键是用户填的字段名,值是内容)存起来、再做些判断。我图省事,直接用一个普通对象 {} 当字典来装:const dict = {}; dict[key] = value;,判断某个键在不在就用 if (key in dict)。开发时拿正常数据测都好好的,可上线后开始冒出各种邪门事:
- 没存过的键却判定"存在":有个分支是
if (key in dict)才怎么怎么样,结果发现用户从没提交过toString这个键,可'toString' in dict却返回true,代码就走错了分支。 - 遍历时多出莫名的键:有的地方用
for...in遍历这个字典,偶尔会处理到一些用户根本没填过的、像constructor之类的键。 - __proto__ 引发集体错乱:最吓人的是,有个用户把字段名(key)填成了
__proto__,dict['__proto__'] = somevalue执行后,一批本来好好的对象行为全乱了,有的属性凭空冒出来、有的判断逻辑全错。 - 值还可能"覆盖"内置方法:用户把 key 填成
hasOwnProperty,后面我想调dict.hasOwnProperty(...)时,它已经被用户的值覆盖、根本不是函数了,直接报错。
"没存过的 toString/constructor 却'存在'、__proto__ 引发错乱、内置方法被覆盖"——这几个现象合起来,矛头清清楚楚地指向一件事:我这个用 {} 创建的"字典",身上本来就带着一堆我没有放进去的东西(toString、constructor、hasOwnProperty、__proto__……),而且这些东西还能被用户的输入触碰甚至篡改。我得去搞清楚,JavaScript 里一个普通对象,到底"自带"了些什么。
第一件事:搞懂字面量对象自带一条原型链,继承着一堆属性
带着这个疑问去翻 JavaScript 的对象模型,我才算真正理解了一件天天用却没深想的事——在 JavaScript 里,用字面量 {}(或 new Object())创建的对象,并不是一张白纸,它继承自 Object.prototype,通过原型链带着 Object.prototype 上的一系列属性和方法。
也就是说,一个看起来"空"的 {},实际上能"访问到"一堆继承来的成员:toString、valueOf、hasOwnProperty、constructor、以及那个特殊的访问器 __proto__ 等等。关键在于,JavaScript 里很多"查找属性"的操作,会顺着原型链往上找,而不只看对象自身:
key in obj:它检查的是"obj 自身或其原型链上有没有这个属性"——所以'toString' in dict是true,因为 toString 在原型上;for...in:会遍历对象自身的以及继承的可枚举属性(虽然 Object.prototype 上的大多不可枚举,但自定义继承链上的会被遍历到);dict[key]读取:取不到自身的就往原型链上找;dict['__proto__'] = x:__proto__不是一个普通键,而是一个访问器,给它赋值会改变这个对象的原型——这就是"原型污染"的源头,会影响所有继承自同一原型的对象。
这下所有诡异现象全说通了:我把一个本就继承着 Object.prototype 一堆属性的字面量对象,当成了一张"只有我放进去的东西"的纯净白纸来用;于是 in 命中了继承来的 toString,for...in 遍历到了继承的键,而用户输入的 __proto__、hasOwnProperty 这些"魔法键名",更是直接触碰到了对象的原型机制本身,引发污染和覆盖。错不在 in 或 for...in,而在于我用错了容器——拿一个"自带继承"的对象,去干"纯字典"的活。我把这些行为验证清楚:
const dict = {}; // 看起来是空字典
console.log('toString' in dict); // true! —— 继承自 Object.prototype
console.log(dict.hasOwnProperty); // function —— 自带的, 没存过
console.log(Object.keys(dict)); // [] —— 自身属性确实是空的
dict['__proto__'] = { hacked: 1 }; // 这不是存了个键, 而是想改原型
console.log(dict.hacked); // 行为诡异(取决于赋值方式/环境)
// 对比:真正的字典容器, 干净
const map = new Map();
console.log(map.has('toString')); // false —— 只有我放进去的
map.set('__proto__', 1); // 就是个普通键, 不碰原型
console.log(map.get('__proto__')); // 1 —— 老老实实存取
真相大白:一个 {} 对象,从来不只装着"我放进去的",它还继承着原型链上一整套"我没放、但能访问到、还可能被篡改"的东西;我把"对象自身有什么"和"对象(含继承)能访问到什么"混为一谈,才会把没存过的键判成存在、让用户输入污染了原型。这正是为什么"用对象当字典"在有外部输入做键时如此危险。
第二件事:正解——当字典就用 Map,或用无原型对象、Object.hasOwn 判存在
根因是"拿自带继承的对象当纯字典",那正解的核心就一句话:要当字典/哈希表用、尤其键来自外部输入时,就用专为此设计、没有原型链包袱的容器——首选 Map。
// 正解 1(首选):用 Map —— 它本就是字典, 没有原型污染、键可任意类型
const dict = new Map();
dict.set(key, value);
dict.has('toString'); // false —— 只反映你真正存过的
dict.set('__proto__', 1); // 就是个普通键, 不会碰原型
dict.get('__proto__'); // 1
for (const [k, v] of dict) { /* 只遍历你存的, 顺序还是插入序 */ }
dict.size; // 直接拿到数量
// 正解 2:非要用对象, 就用无原型对象, 切断继承
const dict2 = Object.create(null); // 没有原型, 没有 toString 等继承属性
dict2[key] = value;
console.log('toString' in dict2); // false 了
// 正解 3:判断"自身"有没有某键, 用 Object.hasOwn(只看自身, 不看原型链)
if (Object.hasOwn(dict3, key)) { /* ... */ } // ES2022, 替代 hasOwnProperty
// 或老写法:Object.prototype.hasOwnProperty.call(dict3, key)
这里的关键判断是:这个对象,我是当"有固定已知字段的结构体/记录"用,还是当"键不确定、可能来自外部的字典"用?
- 当字典(键动态、可能来自用户输入、要频繁增删查):用
Map——它没有原型污染、键可以是任意类型(不止字符串)、有size、遍历顺序确定、性能也好。这是最干净的选择。 - 非要用对象当字典:用
Object.create(null)创建无原型对象,断掉继承;判断键存在一律用Object.hasOwn而非in;遍历用Object.keys(只看自身)而非for...in。 - 当结构体/记录(字段固定已知、就是
{name, age}这种):用普通对象没问题,因为键是你自己写死的,不会撞原型、也没有外部输入污染。
核心就一条:别拿一个"自带原型继承"的普通对象去当"纯字典"用,尤其键来自外部时;字典就用为字典而生的 Map。
第三件事:同一类"把'继承/自带的'误当成'自己的'"的坑,我后来又撞见好几个
这次踩坑让我看清一个更普遍的模式:很多东西除了"它自己显式拥有的",还隐含地携带着从某处继承/默认来的一堆东西;如果你只把它当成"只有我给的那些",忽略了那些"自带的",就会出错——要么误把自带的当成自己的,要么被自带的干扰。这种坑不止 JS 原型链:
- 类继承的方法/字段:子类对象不只有自己定义的,还继承了父类的;反射遍历"所有方法"时会带出一堆继承来的。
- 环境变量继承:子进程默认继承父进程的全部环境变量,你以为它只有你设的那几个,其实带着一大堆。
- CSS 继承属性:元素的 color、font 等会从父元素继承,你以为没设就是默认,其实继承了父级的值。
- 原型/默认配置合并:配置对象 merge 了一层默认配置,你以为读到的是用户配的,其实可能是默认值或继承值。
- 序列化带出继承属性:某些序列化/拷贝会把继承的属性也算进去,导致输出多了不该有的字段。
它们的内核是同一个:一个实体的"有效内容",往往是"它自身显式拥有的"加上"它从原型/父类/环境/默认值继承来的"两部分的叠加;我们很容易只看见前者、把它当成全部,而忽略后者的存在。一旦做"判断有没有、遍历有哪些、是不是干净的"这类操作时,没有区分"自身的"和"继承的",就会把继承来的误当成自己的、或被继承来的干扰。所以,面对任何"带继承/带默认"的东西,都要分清:我现在关心的,是"它自身"还是"它自身加继承"?用对应的、能区分二者的操作。我把这套判断画成了一张图(见后文)。
| 场景 | 把"继承/自带"误当"自己的"的后果 | 正确做法 |
|---|---|---|
| 对象当字典 | in 命中 toString、__proto__ 污染 | 用 Map / Object.create(null) |
| 判断键存在 | in 查到原型链上的 | Object.hasOwn 只看自身 |
| 遍历对象键 | for...in 带出继承键 | Object.keys 只看自身 |
| 反射取方法 | 带出父类继承的方法 | 只取声明的(declared) |
| 子进程环境变量 | 带着父进程一堆变量 | 显式指定/清空再设 |
第四件事:对象当字典 vs Map——一张对照表
这次事故逼我把"用对象当字典"和"用 Map"摆成一张表,以后存键值数据前先对照:
| 维度 | 普通对象 {} 当字典 | Map |
|---|---|---|
| 有没有原型继承包袱 | 有(toString/__proto__ 等) | 没有, 干净 |
| 'toString' in 它 | true(继承的) | false(没存就没有) |
| 键 __proto__ 安全吗 | 危险, 可能污染原型 | 安全, 就是个普通键 |
| 键的类型 | 只能字符串/Symbol | 任意类型(对象都行) |
| 取数量 | Object.keys().length | 直接 .size |
| 遍历 | for...in 带继承、要 hasOwn 过滤 | for...of 只你存的、插入序 |
| 适合 | 字段固定的结构体/记录 | 动态键、外部输入的字典 |
看清这张表,选择就有了准绳:键固定已知、当结构体用,普通对象没问题;键动态、可能来自外部、当字典用,就用 Map——它没有原型包袱、键类型不限、增删查遍历都更干净可靠。对象能当字典,不代表它适合当字典。
第五件事:我曾经对 JS 对象想当然的几个误区
这场"原型链作祟"的事故,把我对 JS 对象的一堆想当然照得清清楚楚:
| 我以为 | 实际上 |
|---|---|
| {} 就是一张空白的纯字典 | 它继承 Object.prototype、带一堆属性 |
| key in obj 检查我存没存过 | 查的是自身+原型链, 命中继承属性 |
| 对象当字典和 Map 没区别 | 对象有原型包袱、键只能字符串 |
| __proto__ 就是个普通字符串键 | 是访问器、赋值会改原型引发污染 |
| for...in 只遍历我加的键 | 会遍历继承的可枚举属性 |
| obj.hasOwnProperty 总能调 | 可能被同名键覆盖、该用 Object.hasOwn |
这些误区的根子是同一个:我把 JavaScript 的对象,简化理解成了一个"纯粹的、只装我放进去的东西的键值袋子",完全忽略了它本质上是一个挂在原型链上、继承着一整套成员的结构。正因为在我的心智模型里只有"我放进去的"、没有"它继承来的",我才会把继承属性当成不存在、把魔法键名当成普通字符串、把对象和 Map 当成可随意互换。把一个"带继承结构"的对象,简化成一个"扁平的纯容器",忽略它自带的那层继承,是这类原型相关问题的共同根源。
第六件事:存键值数据、判断键存在时,我现在的自检习惯
现在每当我要存键值数据、或排查"没存过的键却存在/__proto__ 作怪",我都会先分清"自身"和"继承"。先看清属性查找为什么会命中原型:
然后用这张自检图决定用什么容器、怎么判断:
配套地,我把"安全地把对象当字典"的几个动作固化成了对照写法:
// 反例:拿 {} 当字典 + in 判存在(会被原型坑)
const bad = {};
if ('toString' in bad) {} // true! 误判
for (const k in bad) {} // 可能带出继承键
// 正例 A:用 Map(首选, 键来自外部时尤其)
const m = new Map();
m.set(userKey, userVal);
if (m.has(userKey)) {} // 只反映你存过的
for (const [k, v] of m) {} // 只遍历你存的
// 正例 B:非要用对象 -> 无原型 + Object.hasOwn + Object.keys
const o = Object.create(null);
o[userKey] = userVal;
if (Object.hasOwn(o, userKey)) {} // 只看自身
for (const k of Object.keys(o)) {} // 只看自身
而排查一个"键值相关的诡异行为"时,我固定先确认是不是原型在作怪:
// 排查清单:键值容器行为诡异, 先怀疑原型链
// 1. 没存过的键却 in 返回 true? -> 是继承属性, 用 Object.hasOwn
console.log(Object.hasOwn(dict, key), key in dict); // 两者不一致即原型命中
// 2. 键里有没有 __proto__/constructor/prototype? -> 原型污染高危
// 3. 遍历多出莫名的键? -> for...in 带了继承, 改 Object.keys
// 4. 键来自外部输入? -> 直接换 Map, 从根上免疫
这套习惯的精髓,是"键固定当结构体用对象、键动态或来自外部用 Map、判存在用 Object.hasOwn 别用 in、遍历用 Object.keys 别用 for...in、诡异先怀疑原型"。它让我从"对象就是字典随便用",变成了"分清自身和继承、字典用 Map"——核心始终是:在 JavaScript 中用字面量 {} 或 new Object() 创建的对象并不是一张只装你放进去内容的白纸、而是继承自 Object.prototype 通过原型链带着 toString valueOf hasOwnProperty constructor 以及特殊访问器 __proto__ 等一整套成员的结构,而 JavaScript 里很多属性查找操作会顺着原型链往上找而不只看对象自身——key in obj 检查的是自身或原型链上有没有该属性(所以 'toString' in {} 是 true)、for...in 会遍历自身和继承的可枚举属性、obj[key] 取不到自身的会往原型找、给 obj['__proto__'] 赋值不是存了个普通键而是触发访问器改变对象的原型从而引发影响所有同原型对象的原型污染;所以拿一个自带原型继承的普通对象去当只应装自己放进去内容的纯字典/哈希表用是危险的、尤其当键来自外部用户输入时——会把继承来的 toString 误判为存在、for...in 带出继承键、用户输入的 __proto__ hasOwnProperty 等魔法键名会污染原型或覆盖内置方法;正解是要当字典哈希表用尤其键来自外部时就用专为此设计没有原型链包袱的 Map(它没有原型污染、键可以是任意类型不止字符串、有 size、遍历是插入序且只遍历你存的),非要用对象就用 Object.create(null) 创建无原型对象切断继承、判断键是否存在用只看自身不看原型链的 Object.hasOwn(或 Object.prototype.hasOwnProperty.call)而非 in、遍历用只看自身的 Object.keys 而非 for...in,而字段固定已知的结构体记录用普通对象没问题因为键是你写死的不会撞原型也无外部污染;更一般地一个实体的有效内容往往是它自身显式拥有的加上它从原型父类环境默认值等继承来的两部分的叠加、我们容易只看见前者把它当成全部而忽略后者,做判断有没有遍历有哪些是否干净这类操作时若不区分自身的和继承的就会把继承来的误当成自己的或被继承来的干扰,所以面对任何带继承带默认的东西都要分清此刻关心的是它自身还是它自身加继承并用对应的能区分二者的操作。
我立下的几条规矩
这场"原型链作祟"的事故,换来了我处理键值数据时,刻进骨子里的几条铁律:
- {} 不是白纸,它继承 Object.prototype 带一堆属性。
- key in obj 查的是自身+原型链,会命中 toString 等继承属性。
- 键动态或来自外部输入,一律用 Map,别用对象当字典。
- 非要用对象当字典,就 Object.create(null) 切断原型。
- 判键存在用 Object.hasOwn,遍历用 Object.keys。
- 警惕 __proto__/constructor 当键名,是原型污染高危。
- 字段固定的结构体用普通对象没问题,键是你写死的。
附:一段把"对象当字典"改成安全写法的对照清单
最后留一段我自己整改时照着改的对照清单,一眼看清危险写法和安全写法:
// ❌ 危险:{} 当字典 + in 判存在 + for...in 遍历(键来自外部时尤其)
const bad = {};
bad[userKey] = userVal;
if ('toString' in bad) {/*误判存在*/}
for (const k in bad) {/*带继承键*/}
bad['__proto__'] = x; // 可能原型污染
// ✅ 首选:Map —— 没有原型包袱, 外部输入做键也安全
const map = new Map();
map.set(userKey, userVal);
map.has(userKey); // 只反映存过的
map.get('__proto__'); // 普通键, 安全
map.size; // 直接拿数量
for (const [k, v] of map) {/* 只你存的, 插入序 */}
// ✅ 备选:非要用对象, 切断原型 + 只看自身的操作
const obj = Object.create(null); // 无原型, 'toString' in obj === false
obj[userKey] = userVal;
Object.hasOwn(obj, userKey); // 只看自身(ES2022)
for (const k of Object.keys(obj)) {/* 只看自身 */}
// ✅ 判存在通用安全写法(即使对普通对象)
Object.hasOwn(anyObj, key); // 替代 key in obj
Object.prototype.hasOwnProperty.call(anyObj, key); // 老环境兜底
这段清单的核心就一句:键来自外部/动态就用 Map 从根上免疫原型问题;非用对象就 Object.create(null) 切断原型,并一律用 Object.hasOwn 判存在、Object.keys 遍历(只看自身)。把"{} + in"换成"Map / hasOwn",那些"凭空存在的 toString"和"__proto__ 污染"就再也钻不进来了。
写在最后
回头看,这场由"对象自带原型链"引发的事故,真正教给我的,远不止"字典用 Map"这一个技巧。它让我对"我们手里的很多东西,看起来'空空如也、只装着我放进去的',实际上却从它诞生的那一刻起,就背着一整套从别处继承来的、看不见却真实存在的东西",有了一次刻骨的体会。我栽跟头,是因为我把一个 {} 对象,想象成了一个干干净净、只有我往里放东西它才有东西的纯粹容器;在我的脑海里,它就是一个空袋子,我没装的,它就没有;可它偏偏不是——它一出生就挂在 Object.prototype 这条原型链上,继承着 toString、hasOwnProperty、__proto__ 这一整套我没装、却真实存在、还能被外人触碰的东西;我对着这个"其实背着一大包继承"的对象,用着"它只有我放进去的"的假设去判断、去遍历、去接收外部输入,自然处处碰壁。这让我领悟到一个关于"自身与继承"的深刻认知:在很多有"层级、继承、默认"结构的系统里(对象的原型链、类的继承、进程的环境、元素的样式、配置的默认),一个实体所"拥有"的东西,从来不是它自己显式声明的那一部分,而是它自己的 + 它从上游继承/默认来的的总和;这两部分在"访问"的时候常常混在一起、不分彼此——你访问一个属性,系统会先找自己的、找不到就往上继承链找,于是"它自己的"和"它继承的",在使用者眼里浑然一体、难以分辨;而当我们需要做精确的判断时——"这到底是不是它自己的?它自己究竟有哪些?它是不是干净的、没被继承的东西干扰?"——这种"混在一起"就成了陷阱:我们以为在问"它自己",系统却把"它自己 + 继承"的答案给了我们;所以,凡是面对带继承结构的东西,我都要时刻分清"自身"和"继承"这两个层次,清楚自己此刻关心的是哪一个,并选用能精确对应那个层次的操作(hasOwn 只问自身、Object.keys 只列自身、Map 干脆没有继承)。这给了我一种面对"一切带继承、带默认、带上游的东西"时的清醒:每当我要判断一个带继承结构的东西"有没有、有哪些、干不干净",我都先问"我问的是它自身,还是它自身加继承?我用的这个操作,分得清这两者吗?会不会把继承来的算进来坑了我"——分清自身与继承、用对应层次的操作、外部输入当字典就用没有继承包袱的 Map;"分清自身与继承、别把继承来的当成自己的",是用对 JS 对象、也是驾驭一切带继承结构系统的关键。认清 {} 自带原型链、in 会命中继承属性、字典用 Map——这,是我用一次"没存过的键却存在、__proto__ 引发集体错乱"的事故,换来的、关于 JavaScript、也关于如何在继承的迷雾里分清自身的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想用 {} 存外部传来的键值时,先顿一下、改手敲 new Map(),那我对着那个"凭空存在的 toString"挠破头的那个下午,就值了。
—— 别看了 · 2026