数组本身没有 map,为什么还能直接调用:原型链怎样查找属性

本文是「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 仍不容易形成完整关系。下面把数组读取 maphasOwnProperty 时经过的路径放到同一张图中。

数组是对象的一种,因此它的原型链不会停在 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,其中包含 mapfilterpush 等数组方法。

继续向上是 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

mapArray.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 原生数组方法

在业务代码里,如果接口数据、表单字段或动态配置使用了 toStringconstructorhasOwnProperty 等名称,也可能遮蔽原型上的同名属性。

例如:

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

这种对象适合把任意字符串当作键,因为 constructortoString 等名称不会自动对应原型属性。代价是很多普通对象方法不能直接调用,调试输出和部分工具的表现也可能不同。

若需求只是保存键值映射,还可以考虑 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 为什么指向这个新对象。

相关推荐
未秃头的程序猿1 小时前
JDK 26的Value Class,我做了个性能测试,结果出乎意料
java·后端·面试
bonechips1 小时前
RAG实战(一):EPUB 加载、文本切割与向量入库
前端·数据库
swipe1 小时前
10|(前端转全栈)库存扣减为什么最容易出事故?SKU、并发与原子更新
前端·后端·全栈
cdcdhj1 小时前
vue3中的watchEffect()监听,什么时候监听,什么时候清理,什么时候停止
前端·javascript·vue.js
huabuyu1 小时前
几百 MB 的文件为什么等几分钟才能打开?文件分片下载与渐进预览原理
前端·javascript
虚惊一场1 小时前
麻将桌上的并发控制:扫码进房、乐观版本与零和结算
前端·javascript
巴勒个啦1 小时前
从需求到上线:记录一次完全由 AI 辅助完成的小产品全流程
java·前端
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 6 篇:状态管理 useState 与 useReducer
前端·vue.js·react.js
hunterandroid1 小时前
Room 并发写入与事务一致性:从数据竞争到可靠落地
前端