[译] React 是如何区分 Class 和 Function 的 ?

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

function Greeting() {
  return <p>Hello</p>;
}
复制代码

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

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

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

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

// 是类仍是函数 —— 无所谓
<Greeting />
复制代码

React 自己在乎其中的差异!ios

若是 Greeting 是一个函数,React 须要调用它。git

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

// React 内部
const result = Greeting(props); // <p>Hello</p>
复制代码

但若是 Greeting 是一个类,React 须要先用 new 操做符将其实例化,而后 调用刚才生成实例的 render 方法:github

// 你的代码
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 是怎么知道某样东西是 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");
  }
  // 咱们的代码:
  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 成功的话, 它的用户 永远没必要思考这一过程。他们只须要专心建立应用就行了。

但若是你同时也很好奇...知道它是怎么工做的也是极好的。

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索