K 的一隅

JavaScript JavaScript 核心机制

对象上没有这个方法,为什么还能调用:原型链与 new

从公司制度手册与岗位说明说起,弄清 [[Prototype]]、prototype、new、class 与 instanceof 究竟在查什么。

11 分钟阅读 更新于 2026-07-28
本文目录

总公司有一份制度手册,各门店柜台上只贴当日通知。客人问“能不能开发票”,店员先翻柜台上的通知;没有,再去翻总公司手册。两份文件不是同一本,但查找顺序固定:先近后远。

上一章把流式响应里多种异步机制拧在一起;本章回到对象模型:实例上的方法往往不在实例“身上”,而在原型链上。

JavaScript 对象查属性也类似。实例上找不到某个名字时,会沿 [[Prototype]] 继续向上找。第二章问“变量名在哪个词法环境”;第三章问“这次调用的 this 是谁”;这一章问“这个属性究竟挂在哪一层对象上”。

第三章留下的问题:方法存在哪里

js
function Branch(name) {
  this.name = name
}

Branch.prototype.invoice = function invoice() {
  console.log(`${this.name} 按总公司手册开发票`)
}

const a = new Branch('城东店')
const b = new Branch('城西店')

a.invoice()
b.invoice()

ab 各自有 name,但 invoice 通常只有一份,挂在 Branch.prototype 上。两次调用里 this 分别是 ab(第三章的隐式绑定),函数体却是共享的。

先猜输出:属性查找走原型,变量查找走词法

js
const parent = { sauce: '酱油' }
const child = Object.create(parent)
child.spice = '辣椒'

console.log(child.spice)  // 辣椒
console.log(child.sauce)  // 酱油
console.log(child.vinegar) // undefined

function inner() {
  console.log(sauce) // ReferenceError
}

inner()

child.sauce 能读到,因为查属性会沿原型链向上;inner 里的 sauce 报错,因为词法环境里没有这个绑定。这是两套机制,别混成“原型链也能补变量”。

child 自己再写 child.sauce = '蚝油',会在实例上遮蔽原型上的同名属性,原型那份仍在,只是被盖住。

属性查找路径可以画成一条链(实例 → 原型 → 再上一级,直到 null):

[[Prototype]]、prototype 与 new 各管什么

几乎每个对象都有内部槽 [[Prototype]](可用 Object.getPrototypeOf 读取)。它指向另一个对象或 null,形成链的下一环。

函数对象另有 prototype 属性,仅在作为构造函数通过 new 调用时参与关联:

js
function Dish(name) {
  this.name = name // 在即将返回的新对象上创建自有属性
}

Dish.prototype.describe = function () {
  return `菜品:${this.name}`
}

const d = new Dish('拌面')
console.log(d.describe()) // 菜品:拌面
console.log(Object.getPrototypeOf(d) === Dish.prototype) // true

new Dish() 大致做四件事(概念层面):

  1. 创建一个新对象,并将其 [[Prototype]] 设为 Dish.prototype
  2. 以该对象为 this 执行 Dish 函数体。
  3. 若函数体没有返回对象,则返回这个新对象。
  4. 若构造函数返回了普通对象,则那个对象可能成为结果(边界规则,了解即可)。

因此“实例的方法”常常不在实例自身上,而在原型对象上;实例只持有自己的数据字段。

class 语法:更易读的构造函数 + 原型

js
class Menu {
  constructor(title) {
    this.title = title
  }

  render() {
    return `今日:${this.title}`
  }
}

const m = new Menu('盖饭')
console.log(m.render())
console.log(typeof Menu.prototype.render) // function

class 不会发明另一套对象模型。render 仍挂在 Menu.prototype 上;new Menu() 仍走构造流程。extends 则是设置子类 prototype[[Prototype]] 指向父类 prototype,并在 super() 里完成父构造逻辑。

静态方法挂在构造函数本身,不在实例原型上:

js
class Utils {
  static parse(input) {
    return input.trim()
  }
}

Utils.parse('  hi  ')
// 实例上没有 parse

读 Vue、React 类组件或手写库时,分清“实例方法”“原型方法”“静态方法”各挂在哪一层,排查会快很多。

instanceof 在问:原型链上有没有这个构造函数的 prototype

js
console.log(d instanceof Dish) // true
console.log(d instanceof Object) // true

instanceof 检查的是:对象的原型链上,是否出现过 Constructor.prototype。不是问“是不是这个类 new 出来的”那么简单——原型可以被手动改过,结果也会变。

更可靠地判断类型时,在现代代码里常配合 Symbol.toStringTagObject.prototype.toString.call,或 TypeScript 类型层工具;instanceof 适合理解机制,不适合作为唯一契约。

属性描述符与自有属性 vs 继承属性

js
const proto = {
  get count() {
    return this._count ?? 0
  },
}

const shop = Object.create(proto)
shop._count = 5
console.log(shop.count) // 5

访问器属性也在原型链上;get 里的 this 指向触发访问的对象(第三章),因此 shop.count 读到的是 shop._count

Object.hasOwn(shop, 'count')falsecount 在原型上;hasOwn(shop, '_count')true。遍历对象时,for...in 会枚举继承的可枚举属性,Object.keys 只列自有键——调试“多出来的字段”时要分清。

组合优于深继承:用组合挂载行为

深长的原型链会让 instanceofsuper 调用难以推理。许多现代代码更倾向“对象持有别的对象”,而不是层层 extends

js
const logger = {
  log(msg) {
    console.log(msg)
  },
}

const service = {
  logger,
  run() {
    this.logger.log('开始')
  },
}

service.run()

这不是否定原型——service 仍可能通过 Object.create 共享某些方法——而是提醒:看到 class 嵌套三层以上,先问能否用组合或纯函数拆分。React 早期 createClass、部分工具库 mixin,都曾在原型链上叠出过难维护的图。

Object.create 与无原型字典

js
const dict = Object.create(null)
dict.count = 1
console.log(dict.toString) // undefined,没有 Object.prototype 那套

Object.create(null) 常用于纯键值映射,避免 toString 等继承键干扰。与普通 {} 相比,它更像“空白抄本”,不自动继承 Object.prototype

手动设置原型链(了解即可,少在生产环境动态改):

js
const animal = { eat() { console.log('吃') } }
const dog = { bark() { console.log('汪') } }
Object.setPrototypeOf(dog, animal)

dog.eat()

规范允许,但引擎优化更喜欢创建时定型。读别人代码见到 setPrototypeOf,要警惕性能与心智负担。

in 运算符与 hasOwn:两层“有没有”

js
const box = Object.create({ color: '红' })
box.size = '大'

console.log('color' in box) // true,原型链上有
console.log(Object.hasOwn(box, 'color')) // false,不是自有属性

in 走原型链;hasOwn 只看实例自身。写防御性代码、序列化 JSON、或做浅拷贝时,这个差别会直接决定“多复制了哪些键”。

容易踩的坑:别把原型链当成作用域链

第一种误解是“子对象能读父对象的属性,所以内层函数也能读外层变量”。前者是原型查找,后者是词法环境,第二章已拆开。

第二种是“每个对象都复制了一份方法”。多数情况下方法在 prototype 上共享;只有箭头函数实例字段、或 this.method = fn 这类写法才会在实例上挂函数。

第三种认为 __proto__ 是标准推荐 API。它是历史遗留访问方式,规范更推荐 Object.getPrototypeOf / Object.setPrototypeOf

第四种把 constructor 属性当成绝对可靠。它通常指向构造函数,但可以被改写;不要仅凭 obj.constructor === Array 做安全判断。

第五种在调试时只看 console.log(obj) 的浅层输出,以为对象“没有某个方法”。展开 [[Prototype]] 或改用 console.dir,往往能看到方法挂在原型上。读库与读框架组件实例时,这个习惯能省很多时间。

第六种把 class 当成与原型无关的新对象系统。编译后仍是构造函数与 prototype;只是在源码层提供了更接近其他语言的写法。读 Babel 或 TypeScript 编译结果,会看到 extends 被翻译成一系列原型操作。理解本章后,再读那些编译输出会轻松很多,也能解释为何子类实例仍能调用父类方法。建议对照运行时代码与源码各读一遍,印象会更牢固。动手画过链图后再翻编译产物,效果最好。

工厂函数、构造函数与 class 怎么选

三者都能生成对象,语义侧重点不同:

  • 字面量 + 函数:简单数据容器,无共享方法时足够。
  • 工厂函数:不依赖 newthis 问题少,返回普通对象即可。
  • 构造函数 / class:需要共享原型方法、需要 instanceof 语义时更常见。
js
function createUser(name) {
  return {
    name,
    greet() {
      return `你好,${this.name}`
    },
  }
}

每次调用 createUser 都会得到带自有 greet 的新对象。若用户量很大且方法完全相同,更省内存的写法是把 greet 放到 User.prototype 上,让实例共享一份函数对象——代价是要理解 new 与原型链。

读团队代码时,不要争论“class 比工厂高级”。先问:是否需要共享原型、是否需要继承、是否要与现有 new API 兼容。

修改原型的代价:为什么少改已经发布的 prototype

在原型上给内置或共享构造函数追加方法,会影响所有实例,甚至与其它库冲突:

js
Array.prototype.last = function () {
  return this[this.length - 1]
}

短期方便,长期可能污染全局预期。更稳妥的是工具函数 last(arr),或局部包装。若必须扩展,优先在自己的子类 prototype 上操作,而不是直接改 Array.prototype

这也解释为什么现代风格更谨慎地对待 Object.setPrototypeOf 与随意 mixin:你在改整条链上的查找结果,而不只是某一个对象。

数组与内置类型的原型链

js
const list = [1, 2, 3]
console.log(Object.getPrototypeOf(list) === Array.prototype) // true
console.log(Object.getPrototypeOf(Array.prototype) === Object.prototype) // true

list.push 来自 Array.prototypelist.toString 可能来自更上层的 Object.prototype。内置类型只是这条教学线上的标准样本,规则与普通对象一致。

当你看到“数组上怎么多了个奇怪方法”,先沿 [[Prototype]] 向上查,而不是假设数据被篡改。

super 与原型链:子类方法为何能调用父类实现

js
class Base {
  init() {
    return '基础就绪'
  }
}

class Derived extends Base {
  init() {
    const base = super.init()
    return `${base},扩展完成`
  }
}

console.log(new Derived().init())

super.init() 并不是“再 new 一个 Base”,而是沿原型链找到父类 prototype 上的 init,并以当前实例为 this 调用。super 关键字把第三章的 this 与本章的链式查找绑在一起。

若手动模拟,可以理解为对 Base.prototype.init.call(this) 的语法糖(省略细节)。读继承报错时,除了看 extends 有没有写,还要看父类方法是否挂在 prototype 上。

用 Object.getOwnPropertyDescriptor 看清属性在哪一层

js
const proto = { shared: 1 }
const obj = Object.create(proto)
Object.defineProperty(obj, 'own', { value: 2, enumerable: true })

console.log(Object.getOwnPropertyDescriptor(obj, 'shared')) // undefined
console.log(Object.getOwnPropertyDescriptor(obj, 'own')) // 有描述符

描述符查询只看自有属性。要判断某键是否可枚举、是否只读,需要分清它在实例上还是原型上。Object.definePropertyobj 上定义的属性,不会自动同步到 proto

这与第二章“绑定在环境里”不同:原型链解决的是对象属性的来源,不是变量名解析。两套图可以画在同一张纸上,但边和节点含义不同。

试一试:手动串一条原型链

不运行代码,先回答:d 上有没有自有属性 describeBranch.prototype 上有没有?Object.prototype 上有没有 toString

js
function Branch(name) {
  this.name = name
}
Branch.prototype.describe = function () {
  return this.name
}
const d = new Branch('测试店')

运行后分别执行:

js
Object.hasOwn(d, 'describe')
Object.hasOwn(d, 'name')
'describe' in d

把四个布尔结果记进表格。以后看到 inhasOwn 混用,就不会在序列化或拷贝时漏字段。

与第三章并排:同一次调用,两套查找

调用 d.describe() 时:

  • this 绑定到 d(第三章)
  • describe 函数体来自 Branch.prototype(本章)
  • 函数体内的 this.name 先读 this 绑定,再读实例自有属性 name

任何一步搞混,都会得到“方法存在却 undefined”或“this 不是我想的那个对象”。调试面向对象代码,建议在纸上画两个小图:左边原型链找属性,右边标出当前 this

JSON.stringify 与原型:为什么“转 JSON 后方法没了”

js
const user = new Branch('城南')
console.log(JSON.stringify(user)) // 往往只有 {"name":"城南"}

JSON.stringify 只序列化自有可枚举属性,不会沿着原型链把 describe 等方法带出去。API 设计时若要把行为与数据一起发给前端,需要显式挑选字段,而不是假设“对象上能点到的方法都会进 JSON”。

若你在 TypeScript 里为接口只声明了数据字段,而运行时用 class 挂方法,类型检查与运行时形状也会分裂。序列化、日志打印、状态持久化,都应默认“只有自有字段可靠”,方法行为留在原型或模块里。

这也再次说明:原型上的方法是共享行为,不是实例背包里的一张张纸条。

小结:查属性与查变量分开想

问题机制
userName 变量从哪来词法环境(第二章)
user.name 属性从哪来自有属性或原型链(本章)
this 在方法里是谁调用绑定(第三章)

三张表不要合成一张。下一章讨论:当计算太重、主线程仍卡住时,能否把活挪到 Worker。建议先完成本章原型图练习,再进入多线程章节。你可以用 DevTools 的 console.dir(obj) 展开 [[Prototype]] 链,对照纸上画的图逐项核对。

下一步:另一个线程也能帮忙算吗

第十一章流式读取省下了等待,却没省掉计算。若解析、解压或统计仍占满主线程,页面照样卡顿。原型链解释“方法从哪继承”;闭包解释“绑定为何仍可达”;下一章从银行柜台与后台清算说起,拆开 Worker 与消息传递的边界。