代码复用模式
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.getPrototypeOf 与 Object.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
这种模式有历史教学价值,但不应作为现代继承工具,原因包括:
- 定义继承关系时就执行了一次
Parent,可能产生副作用。 Parent的实例属性被放进Child.prototype,所有子实例共享同一个可变引用。- 无法自然地把子实例参数传给父构造函数。
Child.prototype.constructor会继承或指向错误的构造函数。- 原型链比必要情况更长,且语义不清晰。
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.prototype 与 Parent.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
这个实现只适用于普通函数形式的旧式构造器;如果 Parent 是 class,不能用 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 的构造行为、length、name、instanceof 和 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 extends 或 Object.create 都可以使用;关键是不要仅为了复用几行代码建立巨大的层次结构。
十七、如何选择复用方式
| 需求 | 合适方式 | 需要注意 |
|---|---|---|
| 多个实例共享方法 | 自定义原型或 class 方法 | 避免把可变实例状态放到原型 |
| 明确的父子类型关系 | class extends 或 Object.create | 控制继承层次,正确初始化父状态 |
| 只想复制一层数据 | 对象展开或 Object.assign | 浅拷贝、getter、setter 和 descriptor 差异 |
| 深层数据克隆 | structuredClone 或领域专用方案 | 函数、DOM、原型和特殊对象限制 |
| 拼装多项独立能力 | 组合或 mixin | 处理命名冲突和隐式依赖 |
| 临时调用一个方法 | call、apply、Reflect.apply | this 由显式接收者决定 |
| 固定 this 和部分参数 | 原生 bind | 构造调用和绑定函数属性有额外语义 |
十八、总结
原文展示的“默认继承、借用构造函数、组合继承、共享原型、临时构造函数、原型继承、属性复制、混入、借用方法和绑定”都是 JavaScript 历史上重要的模式。它们值得理解,因为旧项目和面试题中仍然会出现;但新代码不应机械照搬:
- 用
Object.getPrototypeOf、Object.create和class extends表达原型关系,不把__proto__当作规范属性。 - 不用
Child.prototype = new Parent()作为默认继承方案,避免执行父构造函数和共享可变状态。 - 赋值、展开、
Object.assign和描述符复制的语义不同,先明确是复用行为还是复制数据。 - 简化的递归深拷贝、bind polyfill 和 klass 模拟器只能作为教学代码。
- 把复用目标放在第一位:能组合就不建立不必要的继承关系,能委托就不复制完整对象。
参考资料: