overreactedby Dan Abramov

React 如何区分类和函数?

December 2, 2018

来看这个用函数定义的 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 的目标都是拿到渲染出来的节点(本例中是 <p>Hello</p>)。但具体的步骤取决于 Greeting 是如何定义的。

那么 React 是如何判断某个东西是类还是函数的呢?

就像我在上一篇文章里说的,你并不需要知道这些细节才能高效使用 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>

我们先来大致了解下 JavaScript 里的 new 操作符到底做了什么。


在早期,JavaScript 并没有类。不过,你可以用普通函数实现类似类的模式。具体来说,给任何函数前面加上 new,它就能像类构造函数一样工作:

// 只是个函数
function Person(name) {
  this.name = name;
}
 
var fred = new Person('Fred'); // ✅ Person {name: 'Fred'}
var george = Person('George'); // 🔴 不会按预期工作

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

如果你不加 new 调用 Person('Fred'),函数内部的 this 会指向全局对象(比如 windowundefined),这会导致代码崩溃,或者做出一些奇怪的事情,比如设置了 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 这样奇怪的 bug 出现。

但这也意味着 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");
  }
  // 我们自己的代码:
  this.name = name;
}
 
new Person('Fred'); // ✅ 正常
Person('George');   // 🔴 不能把类当函数调用

你可能在打包后的代码里见过类似的代码。这就是那些 _classCallCheck 函数的作用。(你可以通过开启 “loose mode” 来减少包体积,但这样可能会让你以后迁移到原生类时更麻烦。)


现在你应该大致明白了用 new 和不用 new 的区别:

new Person()Person()
classthisPerson 实例🔴 TypeError
functionthisPerson 实例😳 thiswindowundefined

这就是为什么 React 必须正确地调用你的组件。如果你的组件是用类定义的,React 必须用 new 调用它。

那 React 能不能直接判断某个东西是不是类呢?

没那么简单!即使我们能在 JavaScript 里区分类和函数,对于编译器处理过的类来说,它们在浏览器里其实就是普通函数。React 也没办法。


那 React 能不能每次都用 new 调用?很遗憾,这也行不通。

对于普通函数来说,用 new 调用会让它们的 this 变成一个对象实例。对于像 Person 这样的构造函数来说这是期望的,但对于函数组件来说就很奇怪:

function Greeting() {
  // 我们并不希望这里的 `this` 是某种实例
  return <p>Hello</p>;
}

虽然这还可以忍受,但还有两个更致命的原因让这种做法行不通。


第一个原因是,对于原生箭头函数(不是 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}
      />
    );
  }
}

所以,箭头函数没有自己的 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 的返回值。这在比如对象池等场景下很有用:

// 懒加载
var 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 的原型的原型,如此类推。

令人困惑的是,类或函数的 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__ 链:

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() 代替)。

但我还是觉得 prototype 这个属性名很容易让人误解(比如 fred.prototype 是 undefined,因为 fred 不是函数)。我觉得这也是为什么即使是有经验的开发者也经常搞不懂 JavaScript 的原型机制。


这篇文章真长啊?我觉得我们已经走了 80% 了,坚持一下。

我们知道,当你写 obj.foo 时,JavaScript 实际上会在 objobj.__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 Chainz。


既然 __proto__ 链映射了类的继承关系,我们就可以从 Greeting.prototype 开始,沿着 __proto__ 链查找是否继承自 React.Component

// `__proto__` 链
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 变好用™️之前)默认开启了自动 mock。生成的 mock 会省略原始类型的属性,导致检查失效。感谢 Jest。

isReactComponent 检查至今仍在 React 里使用

如果你没有继承 React.Component,React 在原型上找不到 isReactComponent,就不会把组件当成类处理。现在你知道为什么这个高赞回答会建议你加上 extends React.Component 了。最后,React 还加了个警告,如果原型上有 render 但没有 isReactComponent,就会提示你。


你可能会觉得这故事有点“钓鱼执法”的味道。其实最终的解决方案很简单,但我花了很大篇幅解释 React 为什么会采用这个方案,以及其他可能的替代方案。

我的经验是,库的 API 设计往往如此。为了让 API 简单易用,你得考虑语言语义(甚至要考虑未来的方向)、运行时性能、是否有编译步骤、生态和打包方案的现状、如何尽早报错等等。最终的方案未必最优雅,但一定要实用。

如果最终的 API 成功了,它的用户 根本不用关心这些过程。 他们只需要专注于写应用。

但如果你也好奇……了解下它的原理其实很有趣。

Pay what you like

Edit on GitHub