技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

重新认识构造函数、原型和原型链

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/130-重新认识构造函数、原型和原型链
本文目录11 个章节
  1. 引言
  2. 构造函数
  3. constructor 值可以修改吗
  4. 模拟实现 new
  5. 原型 prototype
  6. [[Prototype]] 与 __proto__
  7. Object.create()
  8. 优化实现 new
  9. 原型链
  10. 现代继承写法
  11. 小结

重新认识构造函数、原型和原型链

Category(分类): JavaScript Status: 未知

配图来自原文页面,已下载并转换为本地 WebP;原始授权未核实,发布前请人工确认。

引言

前端进阶系列已经到第 5 期。本篇重点介绍构造函数、原型和原型链,并回答几个常见问题:Symbol 是否可以作为构造函数、constructor 如何产生以及是否可修改、prototype[[Prototype]]__proto__ 有什么区别,以及属性如何沿着原型链查找。

原文中的思维导图和配图保留为本地 WebP;文字和代码按现代 ECMAScript 语义重新整理。

原文配图 130-01

构造函数

什么是构造函数

constructor 是一个属性名,通常引用创建对象的构造函数;它的值是函数本身,而不是函数名称字符串。但它不是对象身份的可靠标签,因为它通常来自原型链,而且可以被覆盖。

构造函数本身就是函数。函数是否在某次调用中作为构造函数使用,取决于调用方式:使用 newReflect.construct 是构造调用,直接调用则是普通调用。首字母大写只是社区约定,不是语言强制规则。

function Parent(age) {
  this.age = age
}

const parent = new Parent(50)
console.log(parent.constructor === Parent) // true
console.log(parent.constructor === Object) // false
console.log(Object.hasOwn(parent, 'constructor')) // false

这里的 constructor 通常来自 Parent.prototype,而不是实例自己的属性:

console.log(Object.hasOwn(Parent.prototype, 'constructor')) // true
console.log(Parent.prototype.constructor === Parent) // true

普通函数直接调用不一定会创建实例:

function parent2(age) {
  this.age = age
}

function parent3(age) {
  return { age }
}

// 在 classic script 的非严格模式中,parent2() 的 this 可能是 globalThis;
// 在 ES module 或严格模式中,直接调用会因 this 为 undefined 而抛错。
const p2 = parent2(50)
console.log(p2) // undefined(非严格 classic script)

const p3 = parent3(50)
console.log(p3.constructor === Object) // true

新代码应避免依赖普通函数直接调用时的隐式 this。如果函数既支持工厂调用又支持构造调用,建议显式检查 new.target,或直接拆成工厂函数和 class。

Symbol 是构造函数吗

Symbol 是一个函数,但不能使用 new Symbol() 构造:

try {
  new Symbol(123)
} catch (error) {
  console.log(error instanceof TypeError) // true
}

const sym = Symbol(123)
console.log(typeof sym) // symbol
console.log(sym.constructor === Symbol) // true
console.log(Symbol.prototype.constructor === Symbol) // true

Symbol(123) 返回的是原始值。访问 sym.constructor 时,语言会临时对原始值进行包装,并沿着 Symbol.prototype 找到 constructor;这不意味着 sym 是一个普通对象实例,也不意味着 constructor 可以作为可靠的类型判断依据。

类似地,BigInt() 可以调用但不能使用 new BigInt()MapSetProxy 等则需要构造调用。一个函数能否构造,应以实际语言语义和 Reflect.construct 行为为准,而不是只看 typeof value === 'function'

constructor 值可以修改吗

对于普通对象,constructor 通常是原型上的可写属性,可以被覆盖;对于原始值,属性访问会临时装箱,不能把属性持久写回原始值。nullundefined 不能进行属性访问。

function Foo() {}

Foo.prototype = {
  method() {}
}

console.log(Foo.prototype.constructor === Object) // true

// 重新赋值 prototype 后,默认的 constructor 属性来自 Object.prototype。
// 如果希望保留约定,需要显式修正,并按需设置属性描述符。
Foo.prototype.constructor = Foo
console.log(Foo.prototype.constructor === Foo) // true

prototype 直接替换成对象时,最好明确恢复 constructor

function Bar() {}

Bar.prototype = Object.assign(Object.create(Object.prototype), {
  constructor: {
    value: Bar,
    writable: true,
    configurable: true,
    enumerable: false
  },
  method() {
    return 'bar'
  }
})

console.log(new Bar().constructor === Bar) // true

下面的例子说明原始值的 constructor 不是一个可以写入的实例属性:

function Type() {}
const values = [1, 'muyiy', true, Symbol(123)]

for (const value of values) {
  console.log(value.constructor.name, value instanceof Type)
}
// Number false
// String false
// Boolean false
// Symbol false

在严格模式下,对原始值属性执行赋值可能抛出 TypeError;在非严格模式下通常会被忽略。不要用这种行为修改所谓的“基本类型 constructor”。

原文配图 130-02

模拟实现 new

对于普通函数构造器,教学版实现可以写成:

function create(Constructor, ...args) {
  if (typeof Constructor !== 'function') {
    throw new TypeError('Constructor must be a function')
  }

  const prototype = Constructor.prototype
  const object = Object.create(
    prototype !== null && (typeof prototype === 'object' || typeof prototype === 'function')
      ? prototype
      : Object.prototype
  )
  const result = Reflect.apply(Constructor, object, args)
  const isObject = result !== null && (typeof result === 'object' || typeof result === 'function')

  return isObject ? result : object
}

这个实现表达了创建对象、连接原型、绑定 this 和处理返回值四个步骤,但它不是原生 new 的完全替代品:class 不能被 Reflect.apply 调用,Proxy 的构造捕获、跨 realm 默认原型和 new.target 也需要使用 Reflect.construct 等机制处理。

function Person(name, age) {
  this.name = name
  this.age = age
}

Person.prototype.sayName = function () {
  return this.name
}

const person = create(Person, 'Ada', 26)
console.log(person.name) // Ada
console.log(person.age) // 26
console.log(person.sayName()) // Ada
console.log(person instanceof Person) // true

如果目标是执行真正的构造调用,生产代码应直接使用 new,或在元编程场景使用标准 API:

function User(name) {
  this.name = name
}

const user = Reflect.construct(User, ['Ada'])
console.log(user.name) // Ada

原型 prototype

JavaScript 是基于原型的语言。函数对象可以有一个名为 prototype 的自有属性,它通常是一个对象;当这个函数作为构造函数被 new 调用时,新实例的 [[Prototype]] 会指向该对象。

需要区分三件事:

  • Parent.prototype:构造函数 Parent 的一个普通对象属性,供构造实例使用。
  • Object.getPrototypeOf(parent):读取实例 parent 的内部 [[Prototype]],通常等于 Parent.prototype
  • Object.getPrototypeOf(Parent):读取构造函数本身的 [[Prototype]],通常等于 Function.prototype,与 Parent.prototype 不是一回事。
function Parent() {}

const parent = new Parent()
console.log(Object.getPrototypeOf(parent) === Parent.prototype) // true
console.log(Object.getPrototypeOf(Parent) === Function.prototype) // true
console.log(Parent.prototype.constructor === Parent) // true

并不是所有函数都有可用的 prototype 属性。箭头函数、对象方法和绑定函数通常不可构造或没有自己的默认 prototype

const arrow = () => {}
const object = {
  method() {}
}
const bound = Parent.bind(null)

console.log(arrow.prototype) // undefined
console.log(object.method.prototype) // undefined
console.log(bound.prototype) // undefined

原文配图 130-03

[[Prototype]]__proto__

[[Prototype]] 是对象的内部插槽,值可以是对象或 null。它不是一个普通的字符串属性,外部代码应使用以下标准 API 读取或设置:

const parent = { kind: 'parent' }
const child = Object.create(parent)

console.log(Object.getPrototypeOf(child) === parent) // true
console.log(Reflect.getPrototypeOf(child) === parent) // true

const another = { kind: 'another' }
Object.setPrototypeOf(child, another)
console.log(child.kind) // another
console.log(Reflect.setPrototypeOf(child, parent)) // true

Object.prototype.__proto__ 是为了 Web 兼容而保留的访问器属性,不是每个实例自有的普通属性;不建议在新代码中使用 obj.__proto__ = value。对象字面量中的 { __proto__: value } 是另一种标准语法,作用是在创建对象时设置其原型,不能和访问器混为一谈:

function Parent() {}
const parent = new Parent()

console.log(parent.__proto__ === Parent.prototype) // true(访问器读取)
console.log(Object.hasOwn(parent, '__proto__')) // false

const child = { __proto__: parent, name: 'child' }
console.log(Object.getPrototypeOf(child) === parent) // true

动态修改已经存在对象的原型可能使 JavaScript 引擎放弃原有优化;如果原型关系是已知的,优先在对象创建时使用 class extends、构造函数、对象字面量或 Object.create 设置。

原文配图 130-04

Object.create()

Object.create(proto) 创建一个新对象,并把它的 [[Prototype]] 设置为 proto。传入 null 可以创建没有 Object.prototype 的字典对象:

const parent = { age: 50 }
const child = Object.create(parent)

console.log(child.age) // 50,继承属性
console.log(Object.hasOwn(child, 'age')) // false
console.log(Object.getPrototypeOf(child) === parent) // true

const dictionary = Object.create(null)
console.log(Object.getPrototypeOf(dictionary)) // null
console.log(dictionary.hasOwnProperty) // undefined

原文中 function Parent() { age: 50 } 里的 age: 50 只是一个标签语句,并不会给对象添加 age 属性;如果希望构造函数实例拥有属性,应写成 this.age = 50

优化实现 new

使用 Object.create 比手动给 __proto__ 赋值更清晰:

function createWithObjectCreate(Constructor, ...args) {
  const object = Object.create(Constructor.prototype)
  const result = Reflect.apply(Constructor, object, args)
  const isObject = result !== null && (typeof result === 'object' || typeof result === 'function')

  return isObject ? result : object
}

这是面试题中的教学实现,不建议在业务代码中用它替换原生 new。原生操作还会处理可构造性、new.target、Proxy 和内置对象的特殊内部槽。

原文配图 130-05

原型链

每个普通对象都可以通过 [[Prototype]] 指向另一个对象,另一个对象也可以有自己的原型,最终通常到达 Object.prototype,再到 null。访问属性时,先查找对象自身;找不到再沿原型链向上查找,直到找到属性或遇到 null

function Parent(age) {
  this.age = age
}

const parent = new Parent(50)

console.log(Object.hasOwn(parent, 'constructor')) // false
console.log(parent.constructor === Parent) // true
console.log(Object.getPrototypeOf(parent) === Parent.prototype) // true
console.log(Object.getPrototypeOf(Parent.prototype) === Object.prototype) // true
console.log(Object.getPrototypeOf(Object.prototype)) // null

实例 parent 本身没有 constructor,但可以从 Parent.prototype 继承到它。in 会检查整条原型链,Object.hasOwn 只检查对象自身:

console.log('constructor' in parent) // true
console.log(Object.hasOwn(parent, 'constructor')) // false
console.log(Object.hasOwn(Parent.prototype, 'constructor')) // true

原文配图 130-06

现代继承写法

class 语法仍然建立在原型机制之上,但能更清晰地表达构造、继承、静态成员和私有字段:

class ParentClass {
  constructor(age) {
    this.age = age
  }

  sayAge() {
    return this.age
  }
}

class ChildClass extends ParentClass {
  #kind = 'child'

  constructor(age, name) {
    super(age)
    this.name = name
  }

  describe() {
    return `${this.name}:${this.sayAge()}:${this.#kind}`
  }
}

const child = new ChildClass(18, 'Ada')
console.log(child.describe()) // Ada:18:child
console.log(child instanceof ChildClass) // true
console.log(child instanceof ParentClass) // true

extends 会建立 ChildClass.prototypeParentClass.prototype 的原型链,并同时建立构造函数本身的继承关系。class 方法默认不可枚举,class 仍然不能脱离 new 直接调用。

也可以使用 Object.setPrototypeOf 构建继承关系,但应尽量在定义阶段完成,不要在高频路径中反复修改对象原型:

function Base() {}
function Derived() {}

Object.setPrototypeOf(Derived.prototype, Base.prototype)
const value = new Derived()

console.log(value instanceof Derived) // true
console.log(value instanceof Base) // true

小结

  • Symbol 可以调用但不能使用 newSymbol.prototype.constructor 仍然引用 Symbol
  • constructor 通常是原型上的可写属性,不是可靠的类型标签;替换 prototype 后要留意它是否仍指向原构造函数。
  • prototype 是构造函数的属性,实例的 [[Prototype]] 是对象内部插槽,二者相关但不相同。
  • __proto__ 访问器不是每个实例的自有属性;读取或设置原型优先使用 Object.getPrototypeOfObject.setPrototypeOfReflect 对应 API。
  • 原型链以对象为节点,属性查找会从实例逐级向上直到 null
  • 现代代码优先使用 class、Object.create 和明确的组合方式;只有在确实需要元编程时才动态修改原型。

原文配图 130-07

原文配图 130-08

参考资料

  1. MDN:继承与原型链
  2. MDN:new 运算符
  3. MDN:Object.getPrototypeOf()
  4. MDN:Object.hasOwn()
  5. MDN:Symbol
  6. 原文作者文章仓库
  7. 原文作者相关文章

作者:木易杨

来源:原文整理自稀土掘金文章,著作权归原作者所有。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS