React是怎么从函数中识别类的

原文信息: 查看原文查看原文

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 />时,你不会介意它是怎么被定义的,使用起来都是下面这样:

// 无论是 Class 或者 function
<Greeting />

但是React自身是介意这中间的区别的!

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

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

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

但是,如果Greeting是一个class,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是怎么知道这个东西是一个class还是一个function?

就像我之前的博文中提到的,你不需要知道这在React中是高效的。这几年我都不知道这个,请不要将这个当成一个面试题目。事实上,这篇文章讨论JavaScript更多于React。

这篇文章是为一个好奇的读者,他很想知道React具体的工作原理。你也是这样的吗?那就让我们一起深入探讨下吧。

这是一段很长的路,先洗好安全带。这篇文章没有很多关于React自身的信息,但是我们将通过newthisclassarrow functions(箭头函数)prototype__proto__instanceof的一些方面,和这些东西是怎样在JavaScript中一起合作的。幸运的是,当你在使用React的时候,你不必思考这么多。如果你在实现React……。

(如果你真的只是想知道答案,滑动到最后。)


首先,我们需要理解为什么分别对待functionsclasses很重要。注意一下我们在调用一个class时是怎么使用new运算符的:

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

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

让我们粗略地看一下new运算符在JavaScript中都做了什么。


在以前,JavaScript还没有classes。然而,你可以使用普通的function来表示一个相似的模式。具体地,你可以使用任何function扮演一个类似class的构造函数的角色,并在它调用之前添加new

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

var fred = new Person('Fred'); // ✅ Person {name: 'Fred'}
var george = Person('George'); // 🔴 不工作

今天你仍然可以写这种代码!在DevTools中试一下吧。

如果你调用Person('Fred')时没有用new,它里面的this会指向某个全局的东西且没有任何用处(比如,windowundefined)。所以我们的代码会崩溃掉,或者会做一些愚蠢的操作,比如设置window.name

在调用之前加一个new,相当于我们说:“Hey JavaScript,我知道Person只是一个function,但是让我们假装它像一个class的构造函数之类的东西。创建一个{}对象,并且将Personfunction内的this指向这个对象,所以我可以像this.name一样分配内容。然后将那个对象返回给我。

这就是new运算符做的事情。

var fred = new Person('Fred'); // Same object as `this` inside `Person`

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直接添加class之前,开发者是仿制class的。


JavaScript已经支持new有一段时间了。然而,class是最近才支持的。它们可以让我们重写上面的代码来更尽量地满足我们的要求:

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

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

在语言和API设计中,获取开发者的意图是很重要的。

如果你写一个function,JavaScript不知道这个function是否像alert()一样被调用,或者它是否像new Person()一样扮演一个构造函数。如果没有给像Person这样的function指定new的话,会引起异常的行为。

Class语法让我们说:“这不是一个function - 它是一个class且有一个构造函数”。如果你在调用的时候忘记new,JavaScript会提示一个异常:

let fred = new Person('Fred');
// ✅  如果 Person 是一个 function: 工作正常
// ✅  如果 Person 是一个 class: 也工作正常

let george = Person('George'); // 没有加 `new`
// 😳 如果 Person 是一个像function的构造函数: 混乱的行为
// 🔴 如果 Person 是一个 class: 立即失败

这帮助我们提前捕获错误,而不是等像this.name这种被当成window.name而不是george.name隐晦的bug出现。

然而,这意味着React需要在调用任何class之前放一个new。它不能像普通function一样被调用,因为JavaScript会将它看成一个异常。

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

// 🔴 React can't just do this:
const instance = Counter(props);

这就麻烦了。


在我们看React怎么解决这个之前,有一点很重要,就是大多数开发者在使用React的时候,也使用像Babel这样的编辑器将像class这样现代化的特性编译成老浏览器可以使用。所以我们需要在设计的时候考虑这些编译器。

在Babel早期的版本,class被调用的时候可以不用new。然而,这个问题是通过产生更多的其他代码来解决的:

function Person(name) {
  // Babel output 的一个小简化版本
  if (!(this instanceof Person)) {
    throw new TypeError("Cannot call a class as a function");
  }
  // 我们的代码
  this.name = name;
}

new Person('Fred'); // ✅ Okay
Person('George');   // 🔴 不能把class当成一个function调用

你可能已经在你的代码片段中看到这种代码。这就是那些_classCallCheck函数做的事情。(你可以通过配置“loose mode”不做检查来减少代码块的大小,但是这样可能会使你的代码转换成原生class复杂化。)


现在,你可以大致上理解一下调用时使用new还不使用new时的区别:

new Person()Person()
classthis是一个Person实例🔴 TypeError
functionthis是一个Person实例😳 thiswindowundefined

这也是为什么对React来说正确的调用你的组件很重要的原因。如果你的组件是用class定义的,React在调用的时候需要用new

所以,React只需要检查一下组件是class还是function就行了吗?

没有那么容易!即使我们从JavaScript中判断出是一个class,仍然不能跟像使用Babel处理过的class正常工作。对浏览器来说,它们都是普通的函数。真是React的不幸啊。


所以,React可以再每次调用时都使用new吗?不幸的是,这也不是每次都正常工作。

对于一般的function来说,用new调用它们时会给它们一个对象实例当作this。虽然将functions重写成构造函数(像上面讲的Person),但是它对function组件来说是令人疑惑的:

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

然而,这些都是可以忍受的,有两个其他的原因放弃这个想法。

使用new不能正常工作的第一个原因是,对于原生箭头函数(不是被Babel编译之后的),用new调用的时候会抛一个异常:

const Greeting = () => <p>Hello</p>;
new Greeting(); // 🔴 Greeting is not a constructor

这个行为是故意遵循箭头函数的设计,箭头函数主要的一点是箭头函数没有属于自己的this值,这个问题可以从一般函数来解决:

class Friends extends React.Component {
  render() {
    const friends = this.props.friends;
    return friends.map(friend =>
      <Friend
        // 从`render`函数中解决`this`
        size={this.props.size}
        name={friend.name}
        key={friend.id}
      />
    );
  }
}

OK,所以箭头函数没有他们自己的this,这意味着他们完全不能被用作构造函数。

const Person = (name) => {
  // 🔴 这没有作用
  this.name = name;
}

所以,JavaScript不允许用new调用一个箭头函数,如果你这样做了,你肯定已经犯错了,还是最好早点告诉你这个。这就像为什么JavaScript不让你在调用class的时候漏掉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引擎去创建一个对象,并在function里面将this指向那个对象,然后将new出来的对象作为结果返回给我们。

然而,JavaScript也允许一个函数被调用时使用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

然而,如果一个function的返回值不是一个对象,new也完全会忽略这个返回值。如果你返回的是一个字符串或数字,就相当于没有任何返回值。

function Answer() {
  return 42;
}

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

当调用一个function时使用了new,是没有办法从它的返回值里取到一个原生值(像数字或字符串)的。所以,如果React一直使用new,将不能支持那些返回字符串的组件。

这是无法接受的,所以我们做了妥协。


到现在为止,我们学到了什么呢?

React在调用class(包括Babel输出的)时,需要使用new;但是,在调用普通函数或箭头函数(包括Babel输出的)时,不需要使用new。并且,没有可靠的方法来区分它们。

如果我们不能解决一个普遍的问题,又怎么能解决一个更特殊的呢?

当你将一个组件定义成一个class时,你将要继承自React.Component,为了一些内置的函数,比如this.setState()与其尝试查明所有的class,还不如看看我们是否可以只查明React.Component的后代呢?

剧透:这就是React真正做的事情。


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

class A {}
class B extends A {}

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

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

你应该了解“原型链”。JavaScript中每一个对象都应该有一个原型(prototype)。当我们写fred.sayHi(),但是fred对象没有sayHi属性,我们会从fred的原型上去找sayHi属性。如果我们没有找到它,我们会从原型链上的下一个原型(fred原型的原型)上找,等等。

令人疑惑的是,一个class或function的prototype属性不指向它们原型的值,不是开玩笑的。

function Person() {}

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

所以,“原型链”更像是__proto__.__proto__.__proto__这样的,而不是prototype.prototype.prototype,这让我花了好几年才明白。

一个function或class的prototype属性是什么?它是所有用new创建的class或function都有的__proto__属性。

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

var fred = new Person('Fred'); // Sets `fred.__proto__` to `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.prototypeundefined,因为fred不是一个function)。个人觉得,这是即使有经验的开发者仍不理解JavaScript原型最大的原因。


这是一个很长的文章,嗯?我想说我们已经到了80%了,再坚持一下。

我们知道,当说obj.foo时,JavaScript实际上会从objobj.__proto__obj.__proto__.__proto__,等等上查找foo

在class中,不能直接暴露这个机制,但是继承也是工作在旧的原型链上面的。这就是我们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();      // Found on c.__proto__ (Greeting.prototype)
c.setState();    // Found on c.__proto__.__proto__ (React.Component.prototype)
c.toString();    // Found on c.__proto__.__proto__.__proto__ (Object.prototype)

换句话说,当你使用class时,一个实例的__proto__链“映射”class的层级:

// `extends` chain
Greeting
  → React.Component
    → Object (implicitly)

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

两个链式反应。


既然__proto__链映射class的层级,那么通过Greeting.prototype,我们可以检查一个Greeting是否继承自React.Component,然后顺着它的__proto__链向下:

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

很方便地就可以发现,x instanceof Y就确切是这一类的查找。它顺着x.__proto__链去寻找Y.prototype的地方。

通常,这就被用来确定一个东西是不是一个class的实例:

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 (🙅‍ 没找到它!)

但是,它只会在确定一个class是否继承自另一个class时才能工作正常。

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

并且,这种检查也是我们怎么确定一些东西是一个React组件或是一个有规律的function。


但是,React不是这样做的。 😳

使用instanceof的方案有一个警告,当一个页面有多个React的拷贝时不起作用,并且其他React拷贝的React.Component同样不起作用。在单个项目内将多个React的拷贝混合到一起是不好的,不好的原因有很多,但是我们已经尝试了尽可能避免这个问题。(对于Hooks,我们应该需要强制去重。)

另一个有可能启发的,能够检查原型上一个render函数是否存在。然而,现在还不能清晰地知道组件API可以怎么进化。每一个检查都有成本,所以我们不想添加多个。如果render被定义成一个实例函数,仍然不起作用,比如class属性语法。

所以,React给基础组件添加了一个特殊的标识,React检查这个标识是否存在,且这就是怎么知道一个东西是不是一个React组件。

起初,基础的React.Componentclass自身上的标识:

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

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

然而,一些我们想作为目标的class实现没有拷贝静态函数(或设置了非标准的__proto__),所以这个标识丢失了。

这就是为什么React将这个标识移到React.Component.prototype

// React里面
class Component {}
Component.prototype.isReactComponent = {};

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

这就是它的全部。

你可能想知道为什么这是一个对象而不是一个布尔值?在实践中没有多大关系,但是早期版本的JestJest之前叫Good™️)已经默认开启自动模拟。生成的模拟数据遗漏了原生属性,中断了检查。多谢Jest

这个isReactComponent检查在React中被使用至今。现在你知道

如果你没有继承React.Component,React不能从原型中发现isReactComponent,不能将组件当做一个class。现在你知道为什么“Cannot call a class as a function error(不能将一个class按照function调用)”最高赞的回答是“添加继承自React.Component”。最后,当存在prototype.render存在,而prototype.isReactComponent不存在的时候,会抛出一个警告。

你可能会说,这个故事有一点引诱骗人。实际的解决方案真的是很简单,但是我花了很大的篇幅解释“React为什么最终选择了这个方案,也选择了这个方案”。

从我的经验来看,库的API经常会遇到这种场景。想要一个API简单好用,你经常需要考虑语言的语义(可能对于一些语言,还包括未来的方向),运行时性能,包含和不包含编译时间步骤的工程学,生态系统和包解决方案的状态,早期预警,和很多其它的东西。最终的结果可能不总是最完美的,但确实实用的。

如果最终API是成功的,它的用户永远不用考虑这个过程。相反,他们可以专注于创建应用。

但是如果你还好奇,了解它是如何工作的还是很不错的。

分享于 2019-02-19

访问量 1223

预览图片