« 回到博客列表

【译】React 如何区分 Class 和 Function?

Mar 6th, 2019阅读本文大约需要 17 分钟

原文地址:How Does React Tell a Class from a Function?

原文作者:Dan Abramov

让我们来看一下这个以函数形式定义的 Greeting 组件:

function Greeting() {
  return <p>Hello</p>;
}

React 也支持将他定义成一个类:

class Greeting extends React.Component {
  render() {
    return <p>Hello</p>;
  }
}

(直到 最近,这是使用 state 特性的唯一方式)

当你要渲染一个 <Greeting /> 组件时,你并不需要关心它是如何定义的:

// 是类还是函数 —— 无所谓
<Greeting />

React 本身在意其中的差别!

如果 Greeting 是一个函数,React 需要调用它。

// 你的代码
function Greeting() {
  return <p>Hello</p>;
}

// 在 React 内部
const result = Greeting(props); // <p>Hello</p>

但如果 Greeting 是一个类,React 需要先用 new 操作符将其实例化,然后 调用刚才生成实例的 render 方法:

// 你的代码
class Greeting extends React.Component {
  render() {
    return <p>Hello</p>;
  }
}

// 在 React 内部
const instance = new Greeting(props); // Greeting {}
const result = instance.render(); // <p>Hello</p>

无论哪种情况 React 的目标都是去获取渲染后的节点(在这个案例中,Hello</p)。但具体的步骤取决于 Greeting 是如何定义的。

所以 React 是怎么知道某样东西是 class 还是 function 的呢?

就像我 上一篇博客 中提到的,你并不需要知道这个才能高效使用 React。 我几年来都不知道这个。请不要把这变成一道面试题。事实上,这篇博客更多的是关于 JavaScript 而不是 React。

这篇博客是写给那些对 React 具体是 如何 工作的表示好奇的读者的。你是那样的人吗?那我们一起深入探讨一下吧。

这将是一段漫长的旅程,系好安全带。这篇文章并没有多少关于 React 本身的信息,但我们会涉及到 newthisclass、箭头函数、prototype__proto__instanceof 等方面,以及这些东西是如何在 JavaScript 中一起工作的。幸运的是,你并不需要在使用 React 时一直想着这些,除非你正在实现 React...

(如果你真的很想知道答案,直接翻到最下面。)


首先,我们需要理解为什么把函数和类分开处理很重要。注意看我们是怎么使用 new 操作符来调用一个类的:

// 如果 Greeting 是一个函数
const result = Greeting(props); // <p>Hello</p>

// 如果 Greeting 是一个类
const instance = new Greeting(props); // Greeting {}
const result = instance.render(); // <p>Hello</p>

我们来简单看一下 new 在 JavaScript 是干什么的。


在过去,JavaScript 还没有类。但是,你可以使用普通函数来模拟。具体来讲,只要在函数调用前加上 new 操作符,你就可以把任何函数当做一个类的构造函数来用:

// 只是一个函数
function Person(name) {
  this.name = name;
}

var fred = new Person('Fred'); // ✅ Person {name: 'Fred'}
var george = Person('George'); // 🔴 没用的

现在你依然可以这样写!在 DevTools 里试试吧。

如果你调用 Person('Fred')没有new,其中的 this 会指向某个全局且无用的东西(比如,window 或者 undefined),因此我们的代码会崩溃,或者做一些像设置 window.name 之类的傻事。

通过在调用前增加 new,我们说:“嘿 JavaScript,我知道 Person 只是个函数,但让我们假装它是个构造函数吧。创建一个 {} 对象并把 Person 中的 this 指向那个对象,以便我可以通过类似 this.name 的形式去设置一些东西,然后把这个对象返回给我。

这就是 new 操作符所做的事。

var fred = new Person('Fred'); // 和 `Person` 中的 `this` 等效的对象

new 操作符同时也把我们放在 Person.prototype 上的东西放到了 fred 对象上:

function Person(name) {
  this.name = name;
}
Person.prototype.sayHi = function() {  alert('Hi, I am ' + this.name);}
var fred = new Person('Fred');
fred.sayHi();

这就是在 JavaScript 直接支持类之前,人们模拟类的方式。


new 在 JavaScript 中已经存在了好久了,然而类还只是最近的事,它的出现让我们能够重构我们前面的代码以使它更符合我们的本意:

class Person {
  constructor(name) {
    this.name = name;
  }
  sayHi() {
    alert('Hi, I am ' + this.name);
  }
}

let fred = new Person('Fred');
fred.sayHi();

捕捉开发者的本意 是语言和 API 设计中非常重要的一点。

如果你写了一个函数,JavaScript 没办法判断它应该像 alert() 一样被调用,还是应该被视作像 new Person() 一样的构造函数。忘记给像 Person 这样的函数指定 new 会导致令人费解的行为。

类语法允许我们说:“这不仅仅是个函数 —— 这是个类并且它有构造函数”。 如果你在调用它时忘了加 new,JavaScript 会报错:

let fred = new Person('Fred');
// ✅  如果 Person 是个函数:有效
// ✅  如果 Person 是个类:依然有效

let george = Person('George'); // 我们忘记使用 `new`
// 😳 如果 Person 是个长得像构造函数的方法:令人困惑的行为
// 🔴 如果 Person 是个类:立即失败

这可以帮助我们在早期捕捉错误,而不会遇到类似 this.name 被当成 window.name 对待而不是 george.name 的隐晦错误。

然而,这意味着 React 需要在调用所有类之前加上 new,而不能把它直接当做一个常规的函数去调用,因为 JavaScript 会把它当做一个错误对待!

class Counter extends React.Component {
  render() {
    return <p>Hello</p>;
  }
}

// 🔴 React 不能简单这么做:
const instance = Counter(props);

这意味着麻烦。


在我们看到 React 如何处理这个问题之前,很重要的一点就是要记得大部分 React 的用户会使用 Babel 等编译器来编译类等现代化的特性以便能在老旧的浏览器上运行。因此我们需要在我们的设计中考虑编译器。

在 Babel 的早期版本中,类不加 new 也可以被调用。但这个问题已经被修复了 —— 通过生成额外的代码的方式。

function Person(name) {
  // 稍微简化了一下 Babel 的输出:
  if (!(this instanceof Person)) {
    throw new TypeError("Cannot call a class as a function");
  }
  // Our code:
  this.name = name;
}

new Person('Fred'); // ✅ OK
Person('George');   // 🔴 无法把类当做函数来调用

你或许已经在你构建出来的包中见过类似的代码,这就是那些 _classCallCheck 函数做的事。(你可以通过启用“loose mode”来关闭检查以减小构建包的尺寸,但这或许会使你最终转向真正的原生类时变得复杂)


至此,你应该已经大致理解了调用时加不加 new 的差别:

new Person() Person()
class this 是一个 Person 实例 🔴 TypeError
function this 是一个 Person 实例 😳 thiswindowundefined

这就是 React 正确调用你的组件很重要的原因。 如果你的组件被定义为一个类,React 需要使用 new 来调用它

所以 React 能检查出某样东西是否是类吗?

没那么容易!即便我们能够 在 JavaScript 中区分类和函数,面对被 Babel 等工具处理过的类这还是没用。对浏览器而言,它们只是不同的函数。这是 React 的不幸。


好,那 React 可以直接在每次调用时都加上 new 吗?很遗憾,这种方法并不总是有用。

对于常规函数,用 new 调用会给它们一个 this 作为对象实例。对于用作构造函数的函数(比如我们前面提到的 Person)是可取的,但对函数组件这或许就比较令人困惑了:

function Greeting() {
  // 我们并不期望 `this` 在这里表示任何类型的实例
  return <p>Hello</p>;
}

这暂且还能忍,还有两个 其他 理由会扼杀这个想法。


关于为什么总是使用 new 是没用的的第一个理由是,对于原生的箭头函数(不是那些被 Babel 编译过的),用 new 调用会抛出一个错误:

const Greeting = () => <p>Hello</p>;
new Greeting(); // 🔴 Greeting 不是一个构造函数

这个行为是遵循箭头函数的设计而刻意为之的。箭头函数的一个附带作用是它 没有 自己的 this 值 —— this 解析自离得最近的常规函数:

class Friends extends React.Component {
  render() {
    const friends = this.props.friends;
    return friends.map(friend =>
      <Friend
        // `this` 解析自 `render` 方法
        size={this.props.size}
        name={friend.name}
        key={friend.id}
      />
    );
  }
}

OK,所以 箭头函数没有自己的 this但这意味着它作为构造函数是完全无用的!

const Person = (name) => {
  // 🔴 这么写是没有意义的!
  this.name = name;
}

因此,JavaScript 不允许用 new 调用箭头函数。 如果你这么做,你或许已经犯了错,最好早点告诉你。这和 JavaScript 不让你 不加 new 去调用一个类是类似的。

这样很不错,但这也让我们的计划受阻。React 不能简单对所有东西都使用 new,因为会破坏箭头函数!我们可以利用箭头函数没有 prototype 的特点来检测箭头函数,不对它们使用 new

(() => {}).prototype // undefined
(function() {}).prototype // {constructor: f}

但这对于被 Babel 编译过的函数是 没用 的。这或许没什么大不了,但还有另一个原因使得这条路不会有结果。


另一个我们不能总是使用 new 的原因是它会妨碍 React 支持返回字符串或其它原始类型的组件。

function Greeting() {
  return 'Hello';
}

Greeting(); // ✅ 'Hello'
new Greeting(); // 😳 Greeting {}

这,再一次,和 new 操作符 的怪异设计有关。如我们之前所看到的,new 告诉 JavaScript 引擎去创建一个对象,让这个对象成为函数内部的 this,然后把这个对象作为 new 的结果给我们。

然而,JavaScript 也允许一个使用 new 调用的函数返回另一个对象以 覆盖 new 的返回值。或许,这在我们利用诸如“对象池模式”来对组件进行复用时是被认为有用的:

// 创建了一个懒变量 zeroVector = null;
function Vector(x, y) {
  if (x === 0 && y === 0) {
    if (zeroVector !== null) {
      // 复用同一个实例
      return zeroVector;
    }
    zeroVector = this;
  }
  this.x = x;
  this.y = y;
}

var a = new Vector(1, 1);
var b = new Vector(0, 0);
var c = new Vector(0, 0); // 😲 b === c

然而,如果一个函数的返回值 不是 一个对象,它会被 new 完全忽略。如果你返回了一个字符串或数字,就好像完全没有 return 一样。

function Answer() {
  return 42;
}

Answer(); // ✅ 42
new Answer(); // 😳 Answer {}

当使用 new 调用函数时,是没办法读取原始类型(例如一个数字或字符串)的返回值的。因此如果 React 总是使用 new,就没办法增加对返回字符串的组件的支持!

这是不可接受的,因此我们必须妥协。


至此我们学到了什么?React 在调用类(包括 Babel 输出的)时 需要用 new,但在调用常规函数或箭头函数时(包括 Babel 输出的)不需要用 new,并且没有可靠的方法来区分这些情况。

如果我们没法解决一个笼统的问题,我们能解决一个具体的吗?

当你把一个组件定义为类,你很可能会想要扩展 React.Component 以便获取内置的方法,比如 this.setState()与其试图检测所有的类,我们能否只检测 React.Component 的后代呢?

剧透:React 就是这么干的。


或许,检查 Greeting 是否是一个 React 组件类的最符合语言习惯的方式是测试 Greeting.prototype instanceof React.Component

class A {}
class B extends A {}

console.log(B.prototype instanceof A); // true

我知道你在想什么,刚才发生了什么?!为了回答这个问题,我们需要理解 JavaScript 原型。

你或许对“原型链”很熟悉。JavaScript 中的每一个对象都有一个“原型”。当我们写 fred.sayHi()fred 对象没有 sayHi 属性,我们尝试到 fred 的原型上去找 sayHi 属性。要是我们在这儿找不到,就去找原型链的下一个原型 —— fred 的原型的原型,以此类推。

费解的是,一个类或函数的 prototype 属性 并不 指向那个值的原型。 我没开玩笑。

function Person() {}

console.log(Person.prototype); // 🤪 不是 Person 的原型
console.log(Person.__proto__); // 😳 Person 的原型

因此“原型链”更像是 __proto__.__proto__.__proto__ 而不是 prototype.prototype.prototype,我花了好几年才搞懂这一点。

那么函数和类的 prototype 属性又是什么?是用 new 调用那个类或函数生成的所有对象的 __proto__

function Person(name) {
  this.name = name;
}
Person.prototype.sayHi = function() {
  alert('Hi, I am ' + this.name);
}

var fred = new Person('Fred'); // 设置 `fred.__proto__` 为 `Person.prototype`

那个 __proto__ 链才是 JavaScript 用来查找属性的:

fred.sayHi();
// 1. fred 有 sayHi 属性吗?不。
// 2. fred.__proto__ 有 sayHi 属性吗?是的,调用它!

fred.toString();
// 1. fred 有 toString 属性吗?不。
// 2. fred.__proto__ 有 toString 属性吗?不。
// 3. fred.__proto__.__proto__ 有 toString 属性吗?是的,调用它!

在实战中,你应该几乎永远不需要直接在代码里动到 __proto__ 除非你在调试和原型链相关的问题。如果你想让某样东西在 fred.__proto__ 上可用,你应该把它放在 Person.prototype,至少它最初是这么设计的。

__proto__ 属性甚至一开始就不应该被浏览器暴露出来,因为原型链应该被视为一个内部概念,然而某些浏览器增加了 __proto__ 并最终勉强被标准化(但已被废弃并推荐使用 Object.getPrototypeOf())。

然而一个名叫“原型”的属性却给不了我一个值的“原型”这一点还是很让我困惑(例如,fred.prototype 是未定义的,因为 fred 不是一个函数)。个人观点,我觉得这是即便有经验的开发者也容易误解 JavaScript 原型链的最大原因。


这篇博客很长,是吧?已经到 80% 了,坚持住。

我们知道当说 obj.foo 的时候,JavaScript 事实上会沿着 obj, obj.__proto__, obj.__proto__.__proto__ 等等一路寻找 foo

在使用类时,你并非直接面对这一机制,但 extends 的原理依然是基于这项老旧但有效的原型链机制。这也是的我们的 React 类实例能够访问如 setState 这样方法的原因:

class Greeting extends React.Component {
  render() {
    return <p>Hello</p>;
  }
}

let c = new Greeting();
console.log(c.__proto__); // Greeting.prototype
console.log(c.__proto__.__proto__); // React.Component.prototype
console.log(c.__proto__.__proto__.__proto__); // Object.prototype

c.render();      // 在 c.__proto__ (Greeting.prototype) 上找到
c.setState();    // 在 c.__proto__.__proto__ (React.Component.prototype) 上找到
c.toString();    // 在 c.__proto__.__proto__.__proto__ (Object.prototype) 上找到

换句话说,当你在使用类的时候,实例的 __proto__ 链“镜像”了类的层级结构:

// `extends` 链
Greeting
  → React.Component
    → Object (间接的)

// `__proto__` 链
new Greeting()Greeting.prototype
    → React.Component.prototype
      → Object.prototype

2 条链。


既然 __proto__ 链镜像了类的层级结构,我们可以检查一个 Greeting 是否扩展了 React.Component,我们从 Greeting.prototype 开始,一路沿着 __proto__ 链:

// `__proto__` chain
new Greeting()Greeting.prototype // 🕵️ 我们从这儿开始
    → React.Component.prototype // ✅ 找到了!Object.prototype

方便的是,x instanceof Y 做的就是这类搜索。它沿着 x.__proto__ 链寻找 Y.prototype 是否在那儿。

通常,这被用来判断某样东西是否是一个类的实例:

let greeting = new Greeting();

console.log(greeting instanceof Greeting); // true
// greeting (🕵️‍ 我们从这儿开始)
//   .__proto__ → Greeting.prototype (✅ 找到了!)
//     .__proto__ → React.Component.prototype
//       .__proto__ → Object.prototype

console.log(greeting instanceof React.Component); // true
// greeting (🕵️‍ 我们从这儿开始)
//   .__proto__ → Greeting.prototype
//     .__proto__ → React.Component.prototype (✅ 找到了!)
//       .__proto__ → Object.prototype

console.log(greeting instanceof Object); // true
// greeting (🕵️‍ 我们从这儿开始)
//   .__proto__ → Greeting.prototype
//     .__proto__ → React.Component.prototype
//       .__proto__ → Object.prototype (✅ 找到了!)

console.log(greeting instanceof Banana); // false
// greeting (🕵️‍ 我们从这儿开始)
//   .__proto__ → Greeting.prototype
//     .__proto__ → React.Component.prototype
//       .__proto__ → Object.prototype (🙅‍ 没找到!)

但这用来判断一个类是否扩展了另一个类还是有效的

console.log(Greeting.prototype instanceof React.Component);
// greeting
//   .__proto__ → Greeting.prototype (🕵️‍ 我们从这儿开始)
//     .__proto__ → React.Component.prototype (✅ 找到了!)
//       .__proto__ → Object.prototype

这种检查方式就是我们判断某样东西是一个 React 组件类还是一个常规函数的方式。


然而 React 并不是这么做的 😳

关于 instanceof 解决方案有一点附加说明,当页面上有多个 React 副本,并且我们要检查的组件继承自 另一个 React 副本的 React.Component 时,这种方法是无效的。在一个项目里混合多个 React 副本是不好的,原因有很多,但站在历史角度来看,我们试图尽可能避免问题。(有了 Hooks,我们 或许得 强制避免重复)

另一点启发可以是去检查原型链上的 render 方法。然而,当时还 不确定 组件的 API 会如何演化。每一次检查都有成本,所以我们不想再多加了。如果 render 被定义为一个实例方法,例如使用类属性语法,这个方法也会失效。

因此, React 为基类 增加了 一个特别的标记。React 检查是否有这个标记,以此知道某样东西是否是一个 React 组件类。

最初这个标记是在 React.Component 这个基类自己身上:

// 在 React 内部
class Component {}
Component.isReactClass = {};

// 我们可以像这样检查它
class Greeting extends Component {}
console.log(Greeting.isReactClass); // ✅ 是的

然而,有些我们希望作为目标的类实现 并没有 复制静态属性(或设置非标准的 __proto__),标记也因此丢失。

这也是为什么 React 把这个标记 移动到了 React.Component.prototype

// 在 React 内部
class Component {}
Component.prototype.isReactComponent = {};

// 我们可以像这样检查它
class Greeting extends Component {}
console.log(Greeting.prototype.isReactComponent); // ✅ 是的

说真的这就是全部了。

你或许奇怪为什么是一个对象而不是一个布尔值。实战中这并不重要,但早期版本的 Jest(在 Jest 商品化之前)是默认开始自动模拟功能的,生成的模拟数据省略掉了原始类型属性,破坏了检查。真是谢谢了,Jest。

一直到今天,React 都在用 isReactComponent 进行检查。

如果你不扩展 React.Component,React 不会在原型上找到 isReactComponent,因此就不会把组件当做类处理。现在你知道为什么解决 Cannot call a class as a function 错误的 得票数最高的答案 是增加 extends React.Component。最后,我们还 增加了一项警告,当 prototype.render 存在但 prototype.isReactComponent 不存在时会发出警告。


你或许会觉得这个故事有一点“标题党”。 实际的解决方案其实真的很简单,但我花了大量的篇幅在转折上来解释为什么 React 最终选择了这套方案,以及还有哪些候选方案。

以我的经验来看,设计一个库的 API 也经常会遇到这种情况。为了一个 API 能够简单易用,你经常需要考虑语义化(可能的话,为多种语言考虑,包括未来的发展方向)、运行时性能、有或没有编译时步骤的工程效能、生态的状态以及打包方案、早期的警告,以及很多其它问题。最终的结果未必总是最优雅的,但必须要是可用的。

如果最终的 API 成功的话, 它的用户 永远不必思考这一过程。他们只需要专心创建应用就好了。

但如果你同时也很好奇...知道它是怎么工作的也是极好的。

该系列的其他文章