首页 / 文章

JavaScript的真正设计蓝图:原型、委托机制与函数

为什么类在本质上只是原型的语法糖,委托机制与传统继承有何不同,这种模式会在何处失效,以及函数式风格在JavaScript中能带来什么优势。

发布者:REACTAPP.TOP阅读约 11 分钟2593 词

来源: The True Blueprints to JavaScript

忘掉从其他语言引入的僵化面向对象设计吧。JavaScript 的真正架构长期以来一直建立在函数式编程和原型委托之上。违背这一设计理念会让该语言显得功能残缺,而顺应它则能让其具备强大的灵活性。

大约十年来,JavaScript 看起来像是一种普通的面向对象语言。你输入class、extends、constructor和super(),仔细看的话它甚至与 Java 或 C# 有些相似。这种相似性其实是一种刻意设计的伪装。实际上,其运行时机制完全不同。一旦有人忘记了这种伪装——尤其是那些最初学习传统面向对象编程的人——“不可能出现”的错误就会随之而来。JavaScript 从来就不是传统的面向对象语言。只有看清语法背后的机制,才能真正相信其表面所展现的功能。

class 关键字只是一种伪装

在ECMAScript 2015(ES6)之前,JavaScript 并没有 class 关键字。开发者们仍然使用构造函数和手动设置原型的方式来编写类似面向对象编程的代码。当 class 出现时,它被定义为语法糖:即同一机制的更友好写法,并非新增的继承模型。

标准委员会TC39明确指出,JavaScript 的 class 并不像 Java 类那样是直接嵌入实例的蓝图,而只是披着不同外衣的函数而已。

几行代码就能体现这一点:

class Dog {
  constructor(name) {
    this.name = name;
  }
  bark() {
    return `${this.name} says woof`;
  }
};
// Then just ask JS what Dog actually is.
console.log(typeof Dog);    // "function"

bark() 方法并不存在于抽象类表中,而是位于 Dog.prototype 上,这与 2015 年之前的方法所在位置完全相同。从概念上讲,每个类最终都会被编译成一个函数以及一个附有方法的原型对象。只是关键字变了,但底层机制并未改变。

这一点很重要,因为传统的预期暗含了 JavaScript 从未提供过的保证:编译时即可确定的固定层级结构、默认的私有字段,以及无论以何种方式调用方法都能保持一致的行为。而在 class 语法下,JavaScript 提供的是一种动态的、可变的、运行时确定的查找链,它在大多数情况下类似那些保证,但总会有例外情况出现,使这种相似性不再成立。

那并非需要道歉的设计缺陷,而只是角色装在履行其功能:让另一种生物能够在为其他事物设计的空间中活动。问题在于当这种角色装被当作真实结构来对待时才会出现。真正值得学习的模式是委托,而非传统的继承。

委托的真正含义

原型委托与传统继承听起来相似,但运作方式不同。在传统编程语言中,子类结构在定义时或编译时就已固定——Dog的实例在结构上就是Animal所定义内容的复制版本。

在 JavaScript 中,对象并不会获得一个副本,而是获得对另一个对象(即其原型)的实时引用。当某个属性不存在时,引擎不会立即报错;它会沿着该引用查找,到那里去查询。这种查找方式就叫做委托:

  • 并非:“包含父对象行为的副本”
  • 而是:“当本地找不到答案时,去询问父对象”

查找算法非常精确:首先检查对象自身的属性;如果不存在,则沿着内部的 [[Prototype]] 链进行查找(历史上以 __proto__ 的形式暴露,现在应通过 Object.getPrototypeOf() 正确获取);如此重复,直到找到该属性或链终止于原型为 null 的 Object.prototype。这是一种运行时的“询问下一个对象”的链式结构,而非一旦形成就固定不变的静态层次结构。

由于原型链接是动态且可变的——并非预先生成的副本——因此即使在已有上千个对象存在之后,你仍可以向共享原型添加方法,所有对象会立即获得这一新方法。它们持有的是指针而非副本。你甚至可以在运行时使用 Object.setPrototypeOf() 重新指定对象的原型,之后的查找将遵循新的链结构。

古典语言若不重新编译则无法实现这一点。JavaScript的模型并非较弱的古典继承机制,而是一种不同的机制,其灵活性足以在需要时模拟古典继承。

Object.create()展示了无需额外封装的委托机制:

const animal = {
  speak() {
    return '...';
  },
};
const dog = Object.create(animal);
// creates dog w/animal set directly as its pro

没有构造函数,没有new操作,也没有任何隐藏关键字代劳处理。之所以dog.speak()能够正常工作,是因为当dog自身没有speak方法时,查找会委托给animal。这正是class和extends用更友好的语法所封装的机制。

幻想破灭之处

那些从 C# 或 Java 转过来的团队往往是在处理事件绑定或定时器回调时发现这个问题,而非在正常流程的方法调用中。相关教学强调“方法属于实例”,因此丢失 this 会被视为语言缺陷,而非调用位置相关的规则。尽早明确这些调用规则,就能避免后续出现大量盲目使用的 .bind(this) 代码。

私有状态的问题也体现在那些虽提供“安全”对象,却仍暴露可变数据的库中。如果使用者能够直接修改某个字段,原本看似仅限内部使用的不变量其实只是常规约定而已。当必须保持这些不变量时,建议使用工厂闭包或 # 字段;而当数据本就需被共享时,则应使用普通对象。

简单的教程能保持代码结构的完整。只有当 this 面对比直接在可见对象上调用方法更复杂的场景时,才会出现拼接问题。在 JavaScript 中,this 从未以传统方式绑定到“实例”上。决定接收者的其实是调用位置的语境,而非函数的词法作用域或定义方法的类块。

几乎每个人都会遇到的问题:将方法作为回调函数传递:

setTimeout(instance.greet, 1000);
// or, wiring up an event listener directly
const el = document.getElementById('btn');
el.addEventListener('click', instance.handleClick);
// NOTE: The `this` inside that function is no longer the instance
// you extracted it from.

在 C# 这类经典语言中,绑定方法会携带其所属的实例。而在 JavaScript 中,方法只是普通的函数,一旦脱离了提供 this 值的调用位置,就会失去该实例。setTimeout 和 DOM 监听器都是常见的导致这种情况的场景。解决方法包括使用 .bind、箭头函数,或是重新设计代码以确保调用位置仍能提供正确的接收者——而这些在处理普通方法时,经典语言并不要求开发者去考虑。

更为隐蔽的封装形式是真正的私有状态。下划线约定的字段(如this._internal)属于社会共识,并非强制要求。现代的#private字段虽能缩小这一差距,但出现得较晚,且仍与公开可修改属性的文化共存。若默认期望类似Java的封装机制,当外部代码修改了那些看似“内部”的数据时就会感到意外。

手动根据原型重建类

将模型内部化的最快方法是在不使用class的情况下,通过普通的构造函数加上原型对象来重现相同的行为——这是2015年之前引擎仍在使用的实现方式。在实践这些模式时,MDN上关于核心脚本函数及高级对象原型的文档仍是很有用的参考资料。

// function (using the `this` keyword)
function Dog(name) {
  this.name = name;
};

// prototype (doing the same thing)
Dog.prototype.bark = function() {
  return `${this.name} says woof`;
};

// assigning a variable to a new instance
const rex = new Dog('Rex');
console.log(rex.bark());
// Output: "Rex says woof"

通过new Dog('Rex')的示例,可以揭示一个许多人多年来一直在使用却从未了解其实现步骤的关键字:创建一个新的空对象;将该对象的[[Prototype]]设置为Dog.prototype;以新对象作为this的值来调用Dog函数;除非构造函数明确返回其他对象,否则返回该对象。这里不存在隐藏的类注册表或编译后的蓝图——只有对象的创建、原型链接,以及带有特定this值的函数调用。

不使用extends的继承方式如下——这与extends编译后的最终形式完全一致:

// an extension of the Dog
function Puppy(name) {
  Dog.call(this, name);
};

Puppy.prototype = Object.create(Dog.prototype);
Puppy.prototype.constructor = Puppy;

// This line here doesn't work, because arrow functions can't use `this`.
// Puppy.prototype.bark = () => { return `${this.name} whines instead of barks`; };

// Use a standard function like this instead:
Puppy.prototype.bark = function() {
  return `${this.name} whines instead of barks`;
};

const buddy = new Puppy('Buddy');
console.log(buddy.bark());
// Output: "Buddy whines instead of barks"

Object.create(Dog.prototype) 所做的正是 extends Dog 自动完成的工作:将 Puppy.prototype 的 [[Prototype]] 连接到 Dog.prototype,从而使缺失的属性向上委托查找。Dog.call(this, name) 执行的功能与 super(name) 相同:在同一个实例上运行父类的初始化代码。一旦这种连接通过手动方式建立过几次,class 就不再具有神奇色彩,而只是已知机制的一种简写形式。

函数一直都是原生语言

以函数思维进行设计也能让模块结构更加清晰。从一个文件中导出少量专注的功能,通常比导出一个其方法大多忽略共享实例字段的类更为明了。当一个“类”没有有意义的实例状态时,它其实只是披着面向对象外衣的命名空间而已。而由函数构成的模块则能以更简洁的方式表达相同概念,同时也能减少与this相关的意外问题。

闭包与这种风格相得益彰:工厂函数可以利用配置信息创建一个小型API对象,其方法无需遵循传统的层次结构。在调用处(如api.save()),代码依然呈现出面向对象的特征,而实际实现则可根据需要采用词法作用域而非原型机制。

当传统的面向对象形式显得可有可无之后,一个更关键的问题出现了:

JavaScript底层的真正原生结构是怎样的?

答案是函数作为一等值——可以传递、存储和返回——而非类层次结构。从1995年JavaScript首次发布时起就存在这种意义上的函数,而类则是在大约二十年后才出现。该语言的大部分发展历史都与面向对象编程关系不大。如果要说JavaScript是以什么特性为基础构建的,那绝不是对象层次结构,而是函数。

常被提及的对这一特性的历史影响包括:

  • Scheme——一种强调一等函数的Lisp家族语言
  • Self——一种基于原型的语言,影响了委托模型
  • Java——其表层语法后来让class这一概念在商业上更具吸引力

一等函数自然而然地带来了闭包机制:函数能够保留对其创建时的词法环境的访问权。正是这种保留机制使得“工厂模式”无需传统私有字段即可隐藏状态,也让钩子函数和中间件能够在没有子类的情况下保留每个请求的上下文。

函数式编程真正带来的好处

在实践中,团队会逐步养成相关习惯。将输入和输出处理放在网络的边缘、磁盘的边缘以及DOM的边缘,而将程序的核心部分视为对原始数据的转换操作。仅这一分离就能避免许多因UI事件被多个处理程序同时修改同一个对象图而产生的竞态条件。

不可变性辅助工具(展开操作、structuredClone 或相关库)只是具体手段,真正的策略在于让那些非法的中间状态更难出现。区分联合类型与纯更新函数与上述原型设计原则相得益彰:对象依然具有实用性,但更新路径会变成明确的值,而非隐式的字段修改。

调试方式也会随之改变。使用纯更新函数时,可以记录转换过程中的输入与输出,并在离线环境下重现故障。而面对复杂的实例变异情况时,往往需要完整的应用会话才能复现导致错误的那些混乱的字段状态。

抛开学术层面的框架与函数式编程概念,从实际应用来看,这意味着主要使用纯函数来构建程序:相同的输入会产生相同的输出,且不会在返回值之外产生任何副作用。程序通过将这些函数组合成一系列转换流程,而非随着时间推移通过方法修改实例字段。

首要好处就是可测试性。纯函数无需复杂的设置或清理流程,也不需要模拟的this对象或精心构造的实例状态。只需输入数据,再验证输出结果即可。面向对象编程测试之所以常常不稳定,很大程度上是因为在测试关键行为之前必须先让对象处于特定的内部状态。而纯函数无需经历这些繁琐步骤,因为不存在需要预先设置的隐藏实例状态。

第二个好处直接回应了this部分的内容:共享的可变状态是大多数经典绑定问题的根源。那些能够读写共享实例字段的方法会受到任何其他修改过该实例的操作的影响,而调用顺序并不会明确指示这些影响的先后关系。纯函数则没有这样的共享可变状态,因此调用顺序的调整不会悄无声息地改变其含义。

第三个好处是可组合性。小巧的纯函数辅助工具很容易串联起来:每个返回值都会作为输入传递给下一步,从而形成自下而上的处理流程,并且允许对每一步进行独立测试。而复杂的类层次结构则难以实现这种组合,因为其行为分散在继承链和可变字段中,无法形成可以独立测试的单元。

这些做法并不会禁止使用任何对象或方法。它们只是重新定义了这些元素的作用:将其视为边界和命名空间,而非所有行为都必须存在的默认位置。

最后一点

向新手传授这种思维模式,能够缩短他们从复制类教程到理解为何点击处理函数会丢失接收对象这一过程所需的时间。同时,它还能缓解“理念之争”:当经典模式有助于明确领域边界时,它们仍可作为语法糖使用;而日常中使用的数组处理流程、还原函数和钩子则能让语言本身的特性保持清晰可见。

在审查代码时,要判断一个新的类是否真正代表了具有生命周期和不变性的实体,还是仅仅用于函数分组。如果是后者,使用函数模块或单个工厂函数通常会更高效。若是前者,则可保留该类,但需记住它仍然依赖于原型对象和调用处的上下文,并且应当编写测试,在实际使用环境中这些调用点存在时能够触发独立的回调函数。

在日常的 JavaScript 开发中,这些都不是理论上的内容。诸如 map、filter 和 reduce 这样的数组方法属于函数式工具,也是该语言中最常被使用的 API 之一。从 React 16.8 开始,React 逐步将组件从类组件转向函数组件及 Hooks,这一变革旨在让组件被视为仅由属性和状态构成的纯函数,相比那些需要处理生命周期方法及可变字段的类组件,这种方式更易于理解。

Redux reducer 遵循官方关于状态、动作和 reducer 的基础教程中规定的纯度准则:相同的状态和动作输入必须产生相同的下一状态,且不能直接修改原有的状态——这正是时间旅行调试得以实现的原因。具体标准表述可参见 Redux 基础指南。许多团队更倾向于使用 Zustand 等轻量级状态管理库来处理应用状态;在任何需要保持更新可预测性的场景中,同样的纯度要求依然适用。

流处理和事件处理管道——这类库通过一系列小型转换函数来处理连续发生的事件序列——是将函数式编程应用于前端最棘手的难题之一:异步且持续不断的用户输入。

核心结论是:JavaScript从来就不是传统意义上的面向对象语言。面向对象模式在某些场景下仍能发挥作用,但这种语言的底层设计始终以函数式为主。二十年来,它一直披着一种看似面向对象的伪装——这种伪装极具迷惑性,以至于整整一代人都是在这种表象下学习编程,甚至还会遇到各种错误。实际上,它依然是一种基于函数与委托的语言——更像是套着Java外衣的Scheme,而非真正的Java——这意味着函数式风格在JavaScript中从来就不是外来元素,而一直是它与生俱来的特性。

换种说法:掌握相关规范才能让协作更顺畅,了解内部机制才能理解开发过程中出现的各种问题。那些只记住class语法的团队会不断遇到回调函数错位以及原型对象意外变化的情况。而那些同时掌握Object.create、构造函数以及纯数据更新方法的团队,则会减少与运行时的冲突,将更多时间用于处理业务数据。

有了这样的基础,class就变成了可选的实现方式而非语言的核心定义——调试时也会从调用位置和原型链接入手,而不是依赖传统运行时带来的假设。

那些尽早要求明确这种区别的审查者,能够避免出现伪装成业务模型的“神对象”。