本文是「JavaScript 运行现场」系列的第 6 篇。
在一个列表页面里,开发者准备对接口返回的数据进行转换:
js
const users = [
{ name: 'Alice' },
{ name: 'Bob' }
]
console.log(Object.hasOwn(users, 'map'))
const names = users.map(user => user.name)
console.log(names)
输出是:
text
false
[ 'Alice', 'Bob' ]
Object.hasOwn(users, 'map') 返回 false,说明数组对象自身没有名为 map 的属性。下一行却可以正常执行 users.map(...)。
这段代码值得继续拆开,因为它说明对象读取属性时,不只会检查对象自身。如果当前对象没有找到目标属性,JavaScript 还会沿着一条既定关系继续查找。
本文只讨论这条属性查找关系:数组为什么能使用 map,普通对象为什么能使用 toString,同名属性又会怎样改变查找结果。
一、数组自身没有 map,方法来自哪里
先确认 map 实际放在哪里。下面这段代码只观察数组对象和数组原型上的属性:
js
const users = ['Alice', 'Bob']
console.log(Object.hasOwn(users, 'map'))
console.log(Object.hasOwn(Array.prototype, 'map'))
console.log(users.map === Array.prototype.map)
输出是:
text
false
true
true
第一行说明数组实例自身没有 map。
第二行说明 Array.prototype 自身拥有 map。
第三行说明通过 users.map 读取到的函数,就是 Array.prototype.map 中保存的函数。
当代码执行:
js
users.map
JavaScript 会先检查 users 自身是否有 map。没有找到后,再去它关联的原型对象中查找,最终在 Array.prototype 上找到对应函数。
对象自身找不到属性时,会继续沿着原型对象向上查找。这条连续的查找关系叫作原型链。
当前例子中的关系可以简化为:
text
users
→ Array.prototype
→ Object.prototype
→ null
数组实例能调用 map,并不是因为创建数组时把所有数组方法复制了一遍,而是属性读取时可以沿着原型链找到共享的方法。
二、属性查找按照什么顺序进行
下面创建一个普通对象,并把另一个对象作为它的原型。运行后观察属性分别来自哪一层:
js
const baseUser = {
role: 'member'
}
const user = Object.create(baseUser)
user.name = 'Alice'
console.log(user.name)
console.log(user.role)
console.log(user.missing)
输出是:
text
Alice
member
undefined
读取 user.name 时,JavaScript 在当前对象自身找到了属性,所以直接返回 Alice。
读取 user.role 时,当前对象没有这个属性,于是继续检查它的原型 baseUser,最终得到 member。
读取 user.missing 时,当前对象和后续原型都没有找到该属性,查找走到末尾后返回 undefined。
整个过程可以整理成下面的表格:
| 读取的属性 | 当前对象是否拥有 | 是否继续查找原型 | 最终结果 |
|---|---|---|---|
name |
有 | 否 | Alice |
role |
没有 | 是 | member |
missing |
没有 | 是,直到链尾 | undefined |
这里最容易产生的误解是:原型属性并没有被复制到当前对象中。
可以继续验证:
js
const baseUser = {
role: 'member'
}
const user = Object.create(baseUser)
user.name = 'Alice'
console.log(Object.keys(user))
console.log(Object.hasOwn(user, 'role'))
console.log('role' in user)
输出是:
text
[ 'name' ]
false
true
Object.keys(user) 只列出当前对象自身可枚举的属性,所以结果中没有 role。
Object.hasOwn(user, 'role') 只检查对象自身,因此返回 false。
'role' in user 会检查当前对象及其原型链,所以返回 true。
这三个结果分别回答了不同问题:
| 写法 | 检查范围 |
|---|---|
Object.keys(object) |
对象自身的可枚举属性 |
Object.hasOwn(object, key) |
对象自身是否拥有该属性 |
key in object |
对象自身和原型链中是否存在该属性 |
业务代码需要判断接口数据是否明确返回了某个字段时,通常更适合检查自身属性。若只想判断当前读取是否能得到这个属性,则需要考虑原型链。
三、数组的原型链为什么还能继续到 Object.prototype
代码已经验证了每一层对象,但只看多次 Object.getPrototypeOf 仍不容易形成完整关系。下面把数组读取 map 和 hasOwnProperty 时经过的路径放到同一张图中。

数组是对象的一种,因此它的原型链不会停在 Array.prototype。
下面通过标准方法查看每一层关系:
js
const list = []
const arrayPrototype = Object.getPrototypeOf(list)
const objectPrototype = Object.getPrototypeOf(arrayPrototype)
const chainEnd = Object.getPrototypeOf(objectPrototype)
console.log(arrayPrototype === Array.prototype)
console.log(objectPrototype === Object.prototype)
console.log(chainEnd)
输出是:
text
true
true
null
第一层是 Array.prototype,其中包含 map、filter、push 等数组方法。
继续向上是 Object.prototype,其中包含 hasOwnProperty 等普通对象共享的方法。
最后一层是 null,表示原型链已经结束。
因此,数组不仅能使用数组方法,也能读取 Object.prototype 上的方法:
js
const list = [1, 2, 3]
console.log(typeof list.map)
console.log(typeof list.hasOwnProperty)
console.log(list.hasOwnProperty('length'))
输出是:
text
function
function
true
map 在 Array.prototype 上找到,hasOwnProperty 则继续向上在 Object.prototype 上找到。
下面这张表展示常见属性的查找位置:
| 读取内容 | 首次找到的位置 |
|---|---|
list.length |
数组实例自身 |
list.map |
Array.prototype |
list.push |
Array.prototype |
list.hasOwnProperty |
Object.prototype |
| 不存在的属性 | 查找到 null 后得到 undefined |
代码输出已经能说明结果,但多层对象关系只靠文字不容易形成完整结构。后文的配图建议会把数组实例、两个原型对象和查找终点放在同一条链路中。
四、对象自身出现同名属性后,为什么会覆盖原型方法
属性查找总是先检查当前对象。只要自身已经存在同名属性,就不会继续向上寻找。
下面给单个数组添加一个同名的 map 属性:
js
const numbers = [1, 2, 3]
numbers.map = function () {
return 'custom map'
}
console.log(numbers.map())
console.log(numbers.map === Array.prototype.map)
输出是:
text
custom map
false
此时读取 numbers.map,第一步就在数组自身找到了函数,所以不会继续使用 Array.prototype.map。
这种现象可以称为属性遮蔽:当前对象上的同名属性挡住了原型链上更高位置的属性。
删除自身属性后,原型方法会重新参与查找:
js
const numbers = [1, 2, 3]
numbers.map = function () {
return 'custom map'
}
delete numbers.map
console.log(numbers.map(number => number * 2))
console.log(numbers.map === Array.prototype.map)
输出是:
text
[ 2, 4, 6 ]
true
删除的是数组自身的 map,并没有修改 Array.prototype.map。下一次读取属性时,当前对象找不到同名属性,于是继续向原型查找。
查找过程如下:
| 当前状态 | 首先命中的位置 | 实际调用 |
|---|---|---|
数组自身有 map |
当前数组 | 自定义函数 |
删除自身 map 后 |
Array.prototype |
原生数组方法 |
在业务代码里,如果接口数据、表单字段或动态配置使用了 toString、constructor、hasOwnProperty 等名称,也可能遮蔽原型上的同名属性。
例如:
js
const formData = {
hasOwnProperty: false,
username: 'Alice'
}
console.log(formData.hasOwnProperty)
console.log(Object.hasOwn(formData, 'username'))
输出是:
text
false
true
此时不能安全调用:
js
formData.hasOwnProperty('username')
因为当前对象自身的布尔值遮蔽了原型方法。Object.hasOwn(formData, 'username') 不依赖对象上的同名方法,更适合处理来源不确定的数据。
五、直接修改 Array.prototype 会影响哪些代码
既然数组方法来自 Array.prototype,项目也可以在这个对象上添加方法:
js
Array.prototype.first = function () {
return this[0]
}
const numbers = [10, 20, 30]
const names = ['Alice', 'Bob']
console.log(numbers.first())
console.log(names.first())
delete Array.prototype.first
输出是:
text
10
Alice
这段代码给所有沿着 Array.prototype 查找属性的数组增加了 first 方法。
它解决的是多个数组共享同一个方法,但影响范围也很大:
- 当前页面中的数组都能读取这个属性;
- 第三方代码也可能观察到它;
- 未来标准若增加同名方法,可能发生冲突;
- 类型系统需要额外声明,否则 TypeScript 不认识该属性;
- 测试之间若没有清理修改,结果可能互相影响。
因此,业务项目通常不会为了一个简单工具直接修改内置原型。更稳妥的写法是普通函数:
js
function first(items) {
return items[0]
}
const numbers = [10, 20, 30]
const names = ['Alice', 'Bob']
console.log(first(numbers))
console.log(first(names))
输出是:
text
10
Alice
普通函数需要显式传入数组,调用形式没有原型方法简短,但它的依赖和影响范围更容易定位。
修改内置原型会影响所有使用该原型的实例,采用前应确认调用范围和冲突风险。
六、Object.create(null) 为什么不能使用普通对象方法
有些代码会创建一个没有原型的对象,用来保存键值映射:
js
const dictionary = Object.create(null)
dictionary.apple = 3
dictionary.toString = 5
console.log(dictionary.apple)
console.log(dictionary.toString)
console.log(Object.getPrototypeOf(dictionary))
输出是:
text
3
5
null
这个对象的原型直接是 null,所以它不会继续查找 Object.prototype。
因此,下面的方法不存在:
js
const dictionary = Object.create(null)
dictionary.apple = 3
console.log(typeof dictionary.hasOwnProperty)
console.log(Object.hasOwn(dictionary, 'apple'))
输出是:
text
undefined
true
普通对象通常能读取 hasOwnProperty,因为该方法位于 Object.prototype。无原型对象没有这条查找关系,所以读取结果是 undefined。
这种对象适合把任意字符串当作键,因为 constructor、toString 等名称不会自动对应原型属性。代价是很多普通对象方法不能直接调用,调试输出和部分工具的表现也可能不同。
若需求只是保存键值映射,还可以考虑 Map。选择哪一种取决于键类型、遍历方式和现有接口,不能因为无原型对象避免了名称冲突,就直接替换所有普通对象。
七、业务代码中怎样排查属性来自哪一层
当代码读取到一个没有在当前对象中声明的属性时,可以按以下顺序检查。
第一步,确认当前对象是否拥有该属性:
js
const users = []
console.log(Object.hasOwn(users, 'map'))
输出是:
text
false
第二步,查看当前对象的直接原型:
js
const users = []
console.log(
Object.getPrototypeOf(users) === Array.prototype
)
输出是:
text
true
第三步,确认属性首次出现在哪一层:
js
const users = []
const directPrototype = Object.getPrototypeOf(users)
console.log(Object.hasOwn(directPrototype, 'map'))
console.log(Object.hasOwn(Object.prototype, 'map'))
输出是:
text
true
false
如果当前对象出现同名属性,还要检查它是否遮蔽了原型属性:
js
const users = ['Alice']
users.map = 'disabled'
console.log(Object.hasOwn(users, 'map'))
console.log(users.map)
console.log(Array.prototype.map.call(users, name => name))
输出是:
text
true
disabled
[ 'Alice' ]
这里数组原型上的方法仍然存在,只是普通的 users.map 属性读取先命中了数组自身的字符串。
可以把排查顺序整理为:
| 检查动作 | 解决的问题 |
|---|---|
Object.hasOwn(object, key) |
属性是否来自当前对象 |
Object.getPrototypeOf(object) |
下一层查找对象是谁 |
| 检查原型自身属性 | 属性最先出现在哪一层 |
| 检查同名自身属性 | 是否发生属性遮蔽 |
| 检查是否修改内置原型 | 是否影响了所有同类实例 |
调试工具中也能查看对象的原型关系,但代码层面的检查更适合写成最小示例,避免把框架代理或第三方扩展误认为语言默认行为。
八、当前规则还没有覆盖哪些内容
本文只解释对象读取属性时如何沿原型链查找,没有继续展开对象是怎样获得这条原型关系的。
构造函数、new、class 和 extends 都会参与创建对象和连接原型,但它们属于下一层问题。即使暂时不理解这些语法,仍然可以先根据当前对象、直接原型和链尾解释属性读取结果。
本文也没有讨论属性描述符。一个属性是否可写、可枚举、可配置,会影响赋值、遍历和删除,但不会改变"先查自身,再沿原型链向上"的基本顺序。
Proxy 可以拦截属性读取,框架的响应式对象也可能基于代理实现额外行为。遇到代理对象时,读取过程还要结合对应代码或框架实现,不能只套用普通对象示例。
原型链只负责属性查找关系。它不会自动复制数据,也不会让两个对象的自身属性同步变化。
九、小结
开头的数组自身没有 map,却能正常调用该方法,是因为属性读取先检查数组本身,找不到后继续到 Array.prototype 查找。数组原型再连接到 Object.prototype,最终以 null 作为查找终点,这条关系就是原型链。
当前对象拥有同名属性时,查找会在这一层停止,所以自身属性可能遮蔽原型方法。修改内置原型则会影响所有沿该原型查找的实例,能够共享方法,同时也会增加冲突、类型声明和测试隔离成本。
这套规则不能直接解释 Proxy、框架响应式对象、构造函数和 class 的全部行为。下次看到某个对象使用了源码中没有声明的方法,可以先用 Object.hasOwn 检查自身属性,再通过 Object.getPrototypeOf 向上确认方法来自哪一层。
下一篇会继续讨论 new User() 背后发生了什么,重点分析新对象怎样创建、怎样连接原型,以及构造函数中的 this 为什么指向这个新对象。