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 本身的信息并不多,但我们会一起了解 new
、this
、class
、箭头函数、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
会指向全局对象(比如 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
这样奇怪的 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() | |
---|---|---|
class | ✅ this 是 Person 实例 | 🔴 TypeError |
function | ✅ this 是 Person 实例 | 😳 this 是 window 或 undefined |
这就是为什么 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 实际上会在 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 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