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

显示模式

登录
ARCHIVE DOCUMENTJS

代码复用模式

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/80-代码复用模式
本文目录19 个章节
  1. 一、代码复用的目标不是继承本身
  2. 二、构造函数与原型的基础
  3. 三、类式继承 1:Child.prototype = new Parent()
  4. 四、类式继承 2:借用构造函数
  5. 五、类式继承 3:借用构造函数并设置原型
  6. 六、类式继承 4:共享原型
  7. 七、类式继承 5:临时构造函数(Holy Grail 模式)
  8. 八、寄生式与寄生组合式继承
  9. 九、现代首选:class extends
  10. 九、旧式 klass 语法糖
  11. 十、原型继承:对象继承对象
  12. 十一、属性复制与浅拷贝
  13. 十二、深拷贝:不要把递归示例当成通用方案
  14. 十三、混入(mix-in)
  15. 十四、借用方法
  16. 十五、绑定方法与部分应用
  17. 十六、组合优先于深层继承
  18. 十七、如何选择复用方式
  19. 十八、总结

代码复用模式

Category(分类): JavaScript Status: 已整理

原文参考:javascript-patterns/chapter6

本文保留原文关于构造函数继承、原型继承、属性复制、混入、借用方法和绑定的完整学习路线,并把早期 JavaScript 的术语和代码更新为当前写法。原文的 10 张模式示意图已下载到同目录 images 文件夹;图中的 Firebug、__proto__ 和旧式“类”概念属于历史背景,具体语义以本文文字和规范资料为准。

一、代码复用的目标不是继承本身

代码复用的目标是少写重复代码,同时保持行为可测试、可维护、可扩展。继承只是实现复用的一种方式,并不是唯一方式。

原文引用 GoF 的建议“优先使用对象组合而不是类继承”。这句话放到 JavaScript 中仍然有价值,但需要用当前术语理解:

  • 继承:通过 [[Prototype]]class extends 复用原型上的行为,建立“is-a”关系。
  • 组合:把若干独立能力组装到一个对象中,建立“has-a”关系。
  • 委托:让对象把某些操作交给另一个对象处理,通常通过原型链或显式引用完成。
  • 混入(mixin):把一组方法或数据复制到目标对象/原型中,不自动建立父子原型关系。
  • 借用方法:只调用另一个对象的方法,不复制完整继承关系。

JavaScript 现在有 class 语法,但 class 仍然建立在原型和内部方法之上;它不是 Java 等语言中完全相同的运行时模型。对象可以在没有“类”的情况下直接创建,也可以在运行时添加属性。

二、构造函数与原型的基础

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

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

const adam = new Person('Adam')

console.log(adam.say()) // Adam
console.log(Object.getPrototypeOf(adam) === Person.prototype) // true
console.log(Object.hasOwn(adam, 'name')) // true
console.log(Object.hasOwn(adam, 'say')) // false

name 是每个实例的自有属性,say 是原型上的共享方法。调用 adam.say() 时,方法虽然从原型链中找到,但调用形式仍然使 this 指向 adam

原文使用 __proto__ 描述隐藏链接。规范术语应写作对象内部的 [[Prototype]];观察和修改它时优先使用 Object.getPrototypeOfObject.setPrototypeOf__proto__ 访问器因 Web 兼容性仍存在,但属于遗留 API,不适合新代码。

原文配图:构造函数与原型链

三、类式继承 1:Child.prototype = new Parent()

原文最早的 inherit 写法是:

function inheritByInstance(Child, Parent) {
  Child.prototype = new Parent()
}

它能让 new Child() 通过原型链访问 Parent.prototype 上的方法:

function Parent(name) {
  this.name = name ?? 'Adam'
}

Parent.prototype.say = function () {
  return this.name
}

function Child() {}
inheritByInstance(Child, Parent)

const kid = new Child()
console.log(kid.say()) // Adam

这种模式有历史教学价值,但不应作为现代继承工具,原因包括:

  1. 定义继承关系时就执行了一次 Parent,可能产生副作用。
  2. Parent 的实例属性被放进 Child.prototype,所有子实例共享同一个可变引用。
  3. 无法自然地把子实例参数传给父构造函数。
  4. Child.prototype.constructor 会继承或指向错误的构造函数。
  5. 原型链比必要情况更长,且语义不清晰。
function Article() {
  this.tags = ['js', 'css']
}

const article = new Article()

function BlogPost() {}
BlogPost.prototype = article

const blog = new BlogPost()
blog.tags.push('html')

console.log(blog.hasOwnProperty('tags')) // false
console.log(article.tags) // ['js', 'css', 'html']:共享同一个数组

如果子对象后来执行 kid.name = 'Patrick',它会创建一个自有属性遮蔽原型上的 name;删除这个自有属性后,原型上的旧值又会显现。这是原型查找和属性遮蔽,不是复制。

原文配图:默认继承模式的原型链

原文配图:子对象遮蔽原型属性

四、类式继承 2:借用构造函数

借用构造函数是在子构造函数中以子对象作为 this 调用父构造函数:

function Parent(name) {
  this.name = name ?? 'Adam'
  this.tags = ['js', 'css']
}

Parent.prototype.say = function () {
  return this.name
}

function Child(name) {
  Parent.call(this, name)
}

const kid = new Child('Patrick')
console.log(kid.name) // Patrick
console.log(kid.tags) // ['js', 'css']
console.log(typeof kid.say) // undefined

这种方式会为每个子实例创建父构造函数写入的自有属性,因此可变数组不会和另一个实例共享:

const first = new Child('First')
const second = new Child('Second')
first.tags.push('html')

console.log(first.tags) // ['js', 'css', 'html']
console.log(second.tags) // ['js', 'css']

但它不会自动继承 Parent.prototype 上的方法。原文称“复制属性”,更准确的说法是:父构造函数在新的 this 上重新执行,创建新的自有属性;对象引用值是否独立取决于父构造函数如何创建它。

借用多个构造函数可以组合自有属性,但方法也会在每个实例上重复创建:

function Cat() {
  this.legs = 4
  this.speak = () => 'meow'
}

function Bird() {
  this.wings = 2
  this.canFly = true
}

function CatBird() {
  Cat.call(this)
  Bird.call(this)
}

const animal = new CatBird()
console.log(animal.legs) // 4
console.log(animal.wings) // 2
console.log(animal.speak()) // meow

原文配图:借用多个构造函数

五、类式继承 3:借用构造函数并设置原型

把前两种方式组合起来,早期代码通常这样写:

function Child(name) {
  Parent.call(this, name)
}

Child.prototype = new Parent()

它确实同时获得了父构造函数创建的自有属性和父原型方法,但 Parent 被执行了两次:一次设置原型时执行,一次创建子实例时执行。这会造成重复初始化,而且原型上还会多一份通常不需要的自有属性。

如果必须维护这种旧代码,至少要显式恢复 constructor,并确认父构造函数没有副作用:

Child.prototype = new Parent()
Object.defineProperty(Child.prototype, 'constructor', {
  configurable: true,
  value: Child,
  writable: true,
})

原文配图:组合继承

六、类式继承 4:共享原型

最短的写法是让父子构造函数直接共享同一个原型:

function inheritSharedPrototype(Child, Parent) {
  Child.prototype = Parent.prototype
}

它避免了调用父构造函数,但父、子会真正修改同一个对象:

function Parent() {}
Parent.prototype.say = function () {
  return 'hello'
}

function Child() {}
inheritSharedPrototype(Child, Parent)

Child.prototype.onlyForChild = true
console.log(Parent.prototype.onlyForChild) // true

因此不要使用这种方式表达独立的父子继承关系。若确实有意共享一组方法,应直接把它命名为共享能力或 mixin,并接受“修改会影响所有使用者”的事实。

原文配图:父子共享同一个原型

七、类式继承 5:临时构造函数(Holy Grail 模式)

原文使用一个空的临时构造函数隔开 Child.prototypeParent.prototype,避免执行父构造函数:

function inheritWithSurrogate(Child, Parent) {
  function Surrogate() {}
  Surrogate.prototype = Parent.prototype
  Child.prototype = new Surrogate()
  Child.prototype.constructor = Child
  Child.superclass = Parent.prototype
}

现在可以用 Object.create 更直接地表达相同的原型关系:

function inheritWithObjectCreate(Child, Parent) {
  Child.prototype = Object.create(Parent.prototype, {
    constructor: {
      configurable: true,
      value: Child,
      writable: true,
    },
  })
  Object.setPrototypeOf(Child, Parent)
}

Object.setPrototypeOf(Child, Parent) 是为了让子构造函数本身也能继承静态属性;如果不需要静态继承,可以省略。修改已经创建对象的原型可能影响性能,因此更推荐在对象创建前建立关系。

function Parent(name) {
  this.name = name ?? 'Adam'
}

Parent.prototype.say = function () {
  return this.name
}

function Child(name) {
  Parent.call(this, name)
}

inheritWithObjectCreate(Child, Parent)

const kid = new Child('Patrick')
console.log(kid.say()) // Patrick
console.log(kid.constructor === Child) // true
console.log(kid instanceof Parent) // true
console.log(kid instanceof Child) // true

这种模式只继承父原型上的成员,不会把父构造函数的自有属性自动放进子原型;子构造函数是否调用父构造函数、如何初始化状态,需要显式决定。

原文配图:临时构造函数隔离父子原型

八、寄生式与寄生组合式继承

原文的术语体系没有单独列出这两种常见模式,可以这样区分:

  • 寄生式继承:先用 Object.create(parent) 得到一个委托对象,再在这个对象上增加或覆盖能力并返回;它复用原型关系,但不会复制父对象的自有状态。
  • 寄生组合式继承:用 Parent.call(this, ...) 初始化子实例的自有状态,再用 Object.create(Parent.prototype) 建立原型关系,避免 Child.prototype = new Parent() 时额外执行一次父构造函数。
function Parent(name) {
  this.name = name
}

Parent.prototype.say = function () {
  return this.name
}

function Child(name) {
  Parent.call(this, name)
}

Child.prototype = Object.create(Parent.prototype, {
  constructor: {
    configurable: true,
    value: Child,
    writable: true,
  },
})

const child = new Child('Ada')
console.log(child.say()) // Ada
console.log(child instanceof Parent) // true

这仍然是旧式函数构造器模式,现代项目可用下一节的 class extends 表达同一意图。术语“寄生”描述的是创建/增强对象的步骤,不等于深拷贝,也不表示对象之间没有共享原型方法。

九、现代首选:class extends

现代代码通常直接使用 class 表达构造和原型方法:

class ParentModel {
  constructor(name) {
    this.name = name
  }

  say() {
    return this.name
  }
}

class ChildModel extends ParentModel {
  constructor(name, role) {
    super(name)
    this.role = role
  }

  describe() {
    return `${this.say()} (${this.role})`
  }
}

const child = new ChildModel('Ada', 'admin')
console.log(child.describe()) // Ada (admin)
console.log(Object.getPrototypeOf(ChildModel.prototype) === ParentModel.prototype) // true
console.log(Object.getPrototypeOf(ChildModel) === ParentModel) // true:静态继承

class 语法并没有消除原型:实例方法仍在原型上,extends 仍然建立原型链。它提供了更明确的构造器、super、私有字段和静态成员语法,但复杂的继承层次仍可能难以维护。

class Counter {
  #value = 0

  increment() {
    this.#value += 1
    return this.#value
  }
}

const counter = new Counter()
console.log(counter.increment()) // 1

如果只是复用一个能力,不需要建立父子类型关系,组合或 mixin 往往更简单。

九、旧式 klass 语法糖

原文还实现了一个名为 klass 的类模拟器。它对理解早期库如何包装构造函数有历史价值,但不建议在新项目中引入另一套类规则。下面是保留原意并修正 hasOwnProperty、描述符和原型设置后的教学实现:

function klass(Parent, props = {}) {
  const Base = Parent || Object

  function Child(...args) {
    if (Base !== Object) {
      Reflect.apply(Base, this, args)
    }

    if (Object.hasOwn(props, '__construct')) {
      Reflect.apply(props.__construct, this, args)
    }
  }

  Child.prototype = Object.create(Base.prototype, {
    constructor: {
      configurable: true,
      value: Child,
      writable: true,
    },
  })
  Child.uber = Base.prototype

  for (const key of Reflect.ownKeys(props)) {
    Object.defineProperty(
      Child.prototype,
      key,
      Object.getOwnPropertyDescriptor(props, key)
    )
  }

  if (Parent) {
    Object.setPrototypeOf(Child, Base)
  }
  return Child
}

const Man = klass(null, {
  __construct(name) {
    this.name = name
  },
  getName() {
    return this.name
  },
})

const SuperMan = klass(Man, {
  __construct() {
    this.hero = true
  },
  getName() {
    return `I am ${SuperMan.uber.getName.call(this)}`
  },
})

const clark = new SuperMan('Clark Kent')
console.log(clark.getName()) // I am Clark Kent
console.log(clark instanceof Man) // true
console.log(clark instanceof SuperMan) // true

这个实现只适用于普通函数形式的旧式构造器;如果 Parentclass,不能用 Reflect.apply 把它当普通函数调用。真实项目应使用 class extends 或组合,不要把该 klass 当作通用 class polyfill。

十、原型继承:对象继承对象

JavaScript 可以直接从一个对象创建另一个对象,不需要先定义构造函数。原文的临时函数 object(parent) 在现代代码中直接对应 Object.create(parent)

const parent = {
  name: 'Papa',
  getName() {
    return this.name
  },
}

const child = Object.create(parent)
child.name = 'Child'

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

Object.create 只建立原型关系,不复制父对象的自有属性。若父对象包含数组、Map 或普通对象,子对象通过原型读取到的仍是同一个引用:

const parentState = {
  items: [],
}
const childState = Object.create(parentState)

childState.items.push('shared')
console.log(parentState.items) // ['shared']

如果只想复用父对象的形状而不希望共享可变状态,应显式创建自己的状态;如果想复制数据,则选择浅拷贝、structuredClone 或领域专用序列化,而不是误把原型继承当作深拷贝。

原文配图:对象原型继承

Object.create(null) 可以创建没有 Object.prototype 的字典对象:

const dictionary = Object.create(null)
dictionary.key = 'value'

console.log(Object.getPrototypeOf(dictionary)) // null
console.log(dictionary.toString) // undefined

十一、属性复制与浅拷贝

属性复制不建立原型链,只读取一个对象的属性并写入另一个对象。现代代码可以使用对象展开或 Object.assign

const parent = {
  name: 'Adam',
  role: 'author',
}

const child = { ...parent, name: 'Patrick' }
const another = Object.assign({}, parent, { name: 'Grace' })

console.log(child) // { name: 'Patrick', role: 'author' }
console.log(another) // { name: 'Grace', role: 'author' }
console.log(Object.getPrototypeOf(child) === Object.prototype) // true

二者都是浅拷贝。展开创建新对象,Object.assign 修改目标对象;二者都会复制可枚举的自有字符串和 symbol 键,但不会复制非枚举属性、原型和完整描述符。源 getter 可能被执行,Object.assign 写入目标时还可能触发 setter。

原文的 extend 可以改写为:

function extend(parent, child = {}) {
  for (const key of Reflect.ownKeys(parent)) {
    const descriptor = Object.getOwnPropertyDescriptor(parent, key)
    if (descriptor.enumerable) {
      child[key] = parent[key]
    }
  }
  return child
}

const dad = { name: 'Adam' }
const kid = extend(dad)
console.log(kid.name) // Adam
console.log(kid === dad) // false

如果需要复制描述符而不是读取值,可以写:

function cloneDescriptors(source) {
  return Object.defineProperties(
    Object.create(Object.getPrototypeOf(source)),
    Object.getOwnPropertyDescriptors(source)
  )
}

但描述符复制仍然是浅层的;嵌套对象引用不会自动复制。

十二、深拷贝:不要把递归示例当成通用方案

原文的 extendDeep 通过递归复制数组和普通对象,可以演示嵌套对象引用的差异:

const dad = {
  counts: [1, 2, 3],
  reads: { paper: true },
}

const kid = structuredClone(dad)
kid.counts.push(4)
kid.reads.paper = false

console.log(dad.counts) // [1, 2, 3]
console.log(dad.reads.paper) // true
console.log(kid.reads === dad.reads) // false

structuredClone 能处理循环引用、很多内置类型和 transferable 对象,但也有限制:函数、DOM 节点、WeakMap、WeakSet 等不能按普通数据克隆;原型、访问器和某些自定义实例语义也不会按“复制一切”处理。使用前要确认数据类型和性能需求。

const cyclic = { name: 'cycle' }
cyclic.self = cyclic

const copy = structuredClone(cyclic)
console.log(copy !== cyclic) // true
console.log(copy.self === copy) // true

JSON stringify/parse 不是通用深拷贝,会丢失 undefined、Symbol、函数、非有限数字、日期类型和循环引用,并可能改变数值语义。手写递归实现还必须处理循环引用、Date、RegExp、Map、Set、数组稀疏性、symbol 键、属性描述符和自定义原型;除非有明确领域边界,不要把简化版命名为“深拷贝”。

十三、混入(mix-in)

混入是从多个来源复制能力,通常不建立父子原型关系。原文的 mix 可以用 Object.assign 表达:

const cake = Object.assign(
  {},
  { eggs: 2, large: true },
  { butter: 1, salted: true },
  { flour: '3 cups' },
  { sugar: 'sure!' }
)

console.log(cake.eggs) // 2
console.log(cake.flour) // 3 cups

后面的来源会覆盖前面的同名键。对于库代码,最好明确冲突策略,而不是静默覆盖:

function mixStrict(...sources) {
  const result = {}

  for (const source of sources) {
    for (const key of Reflect.ownKeys(source)) {
      const descriptor = Object.getOwnPropertyDescriptor(source, key)
      if (!descriptor.enumerable) continue
      if (Object.hasOwn(result, key)) {
        throw new Error(`mixin conflict: ${String(key)}`)
      }
      result[key] = source[key]
    }
  }

  return result
}

也可以把 mixin 方法放到类原型上,但要确认方法不依赖未声明的内部状态:

const Timestamped = {
  setTimestamp() {
    this.createdAt = Date.now()
  },
}

class Record {}
Object.assign(Record.prototype, Timestamped)

const record = new Record()
record.setTimestamp()
console.log(typeof record.createdAt) // number

这种做法不会像 extends 那样建立 Timestamped 的类型关系。混入方法之间可能互相覆盖,也可能依赖相同字段;在大型项目中应记录能力的前置条件和命名约定。

原文配图:属性混入

原文配图:混入结果示意

十四、借用方法

如果只想复用一个方法,可以通过显式绑定调用,而不建立继承关系:

const one = {
  name: 'object',
  say(greeting) {
    return `${greeting}, ${this.name}`
  },
}

const two = { name: 'another object' }

console.log(one.say.call(two, 'Hello')) // Hello, another object
console.log(one.say.apply(two, ['Hi'])) // Hi, another object

call 接收逐个参数,apply 接收一个参数数组;现代代码还可以使用 Reflect.apply

console.log(Reflect.apply(one.say, two, ['Welcome'])) // Welcome, another object

从数组借用方法是旧代码中常见的用法。现在可以优先使用 Array.from、展开语法或明确的数组 API:

function firstTwo(...values) {
  return values.slice(0, 2)
}

console.log(firstTwo(1, 2, 3, 4)) // [1, 2]

如果处理的是旧式 array-like 对象,Array.prototype.slice.call(arguments) 仍然有历史价值,但应理解它会读取 length 和整数键,不是把对象真正变成数组的唯一方法:

function collectArguments() {
  return Array.from(arguments)
}

console.log(collectArguments('a', 'b')) // ['a', 'b']

十五、绑定方法与部分应用

直接把方法赋值给变量会丢失调用者;bind 或箭头包装可以固定调用对象:

const say = one.say.bind(two)
console.log(say('Hello')) // Hello, another object

const sayWithGreeting = one.say.bind(two, 'Bonjour')
console.log(sayWithGreeting()) // Bonjour, another object

原文的手写绑定函数可以用现代语法表达为:

function bindMethod(object, method, ...boundArgs) {
  if (typeof method !== 'function') {
    throw new TypeError('method must be callable')
  }

  return (...callArgs) => Reflect.apply(
    method,
    object,
    boundArgs.concat(callArgs)
  )
}

const boundSay = bindMethod(two, one.say, 'Yo')
console.log(boundSay()) // Yo, another object

这个函数只实现“固定对象并拼接参数”,不实现原生 bind 的构造行为、lengthnameinstanceof 和 bound function 内部方法。需要完整语义时直接使用 Function.prototype.bind

原文还给出了 ES5 时代常见的简化 polyfill:

function legacyBind(context) {
  var target = this
  var boundArgs = Array.prototype.slice.call(arguments, 1)

  return function () {
    var callArgs = Array.prototype.slice.call(arguments)
    return target.apply(context, boundArgs.concat(callArgs))
  }
}

它只能演示普通函数调用和参数部分应用,不能正确支持 new、严格模式的 this、箭头函数、类、内置构造器、length/name 和原生异常边界。不要在现代环境中覆盖 Function.prototype.bind,也不要把这个片段当作完整 polyfill。

十六、组合优先于深层继承

当对象只是需要几项独立能力时,可以显式组合依赖:

function createLogger(prefix) {
  return {
    log(message) {
      console.log(`[${prefix}] ${message}`)
    },
  }
}

function createCache() {
  const values = new Map()
  return {
    get(key) {
      return values.get(key)
    },
    set(key, value) {
      values.set(key, value)
    },
  }
}

function createService() {
  return {
    ...createLogger('service'),
    cache: createCache(),
  }
}

const service = createService()
service.cache.set('answer', 42)
service.log(service.cache.get('answer')) // [service] 42

组合的优点是依赖明确、能力可以替换、测试时容易注入假的实现,也不会因为继承链改变而影响 instanceof 和原型查找。它并不意味着继承永远错误:如果确实存在稳定的父子替换关系,class extendsObject.create 都可以使用;关键是不要仅为了复用几行代码建立巨大的层次结构。

十七、如何选择复用方式

需求合适方式需要注意
多个实例共享方法自定义原型或 class 方法避免把可变实例状态放到原型
明确的父子类型关系class extendsObject.create控制继承层次,正确初始化父状态
只想复制一层数据对象展开或 Object.assign浅拷贝、getter、setter 和 descriptor 差异
深层数据克隆structuredClone 或领域专用方案函数、DOM、原型和特殊对象限制
拼装多项独立能力组合或 mixin处理命名冲突和隐式依赖
临时调用一个方法callapplyReflect.applythis 由显式接收者决定
固定 this 和部分参数原生 bind构造调用和绑定函数属性有额外语义

十八、总结

原文展示的“默认继承、借用构造函数、组合继承、共享原型、临时构造函数、原型继承、属性复制、混入、借用方法和绑定”都是 JavaScript 历史上重要的模式。它们值得理解,因为旧项目和面试题中仍然会出现;但新代码不应机械照搬:

  1. Object.getPrototypeOfObject.createclass extends 表达原型关系,不把 __proto__ 当作规范属性。
  2. 不用 Child.prototype = new Parent() 作为默认继承方案,避免执行父构造函数和共享可变状态。
  3. 赋值、展开、Object.assign 和描述符复制的语义不同,先明确是复用行为还是复制数据。
  4. 简化的递归深拷贝、bind polyfill 和 klass 模拟器只能作为教学代码。
  5. 把复用目标放在第一位:能组合就不建立不必要的继承关系,能委托就不复制完整对象。

参考资料:

原文图源:jayli/javascript-patterns Figure/chapter6

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS