React是如何区分class和function的?

10,171 阅读14分钟

看看这个由function定义的Greeting组件:

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

React也支持由class来定义:

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

(一直到 最近Hooks出现之前,这是唯一可以使用有(如state)功能的方法。)

当你打算渲染一个<Greeting />时,你不会在意它是如何定义的:

// Class or function — whatever.
<Greeting />

但是React本身是要考虑两者之间的区别的。

如果Greeting是一个function,React需要这样调用它:

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

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

但如果Greeting是一个class,React需要先用new操作实例一个对象,然后调用实例对象的render方法。

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

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

两种类别React的目的都是获得渲染后的节点(这里为,<p>Hello</p>)。但确切的步骤取决于如何定义Greeting

所以React是如何识别组件是class还是function的呢?

就像上一篇文章你不需要知道React中的具体实现。 多年来我也不知道。请不要把它做为一个面试问题。事实上,这篇文章相对于React,更多的是关于JavaScript的。

这篇文章是给好奇知道为什么React是以某种方式运行的同学的。你是吗?让我们一起挖掘吧。

这是一段漫长的旅行,系好安全带。这篇文章没有太多关于React本身的内容,但我们会经历另一些方面的东西:newthisclassarrow functionprototype__proto__instanceof,及在JavaScript中它们的相关性。幸运的是,在使用React的时候你不用考虑太多。

(如果你真的只是想知道答案,滚动到最底部吧。)


首先,我们需要明白不同处理functions和classes为什么重要。注意当调用class时,我们是如何使用new的。

// If Greeting is a function
const result = Greeting(props); // <p>Hello</p>

// If Greeting is a class
const instance = new Greeting(props); // Greeting {}
const result = instance.render(); // <p>Hello</p>

让我们粗略地了解下new在JavaScript中作用吧。


过去,JavaScript没有class。但是,你可以用plain function近似的表示它。具体来说,你可以像构建class一样在function前面加new来创建函数:

// Just a function
function Person(name) {
  this.name = name;
}

var fred = new Person('Fred'); // ✅ Person {name: 'Fred'}
var george = Person('George'); // 🔴 Won’t work

如今你仍然可以这么编写!用开发工具试试吧。

如果你调用Person('Fred') 没有 new,方法里的this会指向global或者空(例如,windowundefined)。所以我们的代码会发生错误或者在不知情的情况下给window.name赋值。

在调用方法前加new,我们说:“Hey JavaScript,我知道Person只是一个function,但让我们假装它是一个class构造函数吧。添加一个{}对象,将Person里的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的方法。


所以new已经存在于JavaScript里有些时日了。然而,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无法猜到它是要像alert()一样被调用,还是说像new Person()一样做为构造函数。忘记在function前面加new会导致不可预测的事发生。

Class语法使我们可以说:“这不止是个function - 它还是个class且有一个构造函数”。如果你忘记在调用时加new,JavaScript会抛出错误:

let fred = new Person('Fred');
// ✅  If Person is a function: works fine
// ✅  If Person is a class: works fine too

let george = Person('George'); // We forgot `new`
// 😳 If Person is a constructor-like function: confusing behavior
// 🔴 If Person is a class: fails immediately

这有助我们尽早发现错误,而不是之后遇到一些难以琢磨的bug,例如this.name要为george.name的却变成了window.name

但是,这也意味着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这样的编译器将新的特性进行编译,而兼容较老的浏览器。所以我们要在设计中考虑编译器。

老版本的Babel中,调用class可以没有new。不过这被修复了 —— 通过一些额外代码:

function Person(name) {
  // A bit simplified from Babel output:
  if (!(this instanceof Person)) {
    throw new TypeError("Cannot call a class as a function");
  }
  // Our code:
  this.name = name;
}

new Person('Fred'); // ✅ Okay
Person('George');   // 🔴 Cannot call a class as a function

你可能会在bundle中看到这些代码,这就是所有_classCallCheck函数的功能。(你可以通过选择"loose mode"而不进行检查来减小bundle大小,但你最终转换成的原生class在实际开发中会带来麻烦。)


现在,你应该大致明白了调用时有new和没有new的区别了:

new Person() Person()
class this is a Person instance 🔴 TypeError
function this is a Person instance 😳 this is window or undefined

这就是为什么正确调用你的组件对React来说很重要了。如果你的组件用class声明,React需要用new来调用它。

所以React可以只判断是否是class吗?

没这么简单!即使我们可以区分class和function,这仍然不适用像Babel这样的工具处理后的class。对于浏览器,它们只是单纯的函数。对React来说真是倒霉。


好的,所以也许React可以每个调用都用上new?不幸的是,这也不见得总是奏效。

一般的function,带上new调用它们可以得到等同于this的实例对象。对于做为构造函数编写的function(像前面的Person),是可行的。但对于function组件会出现问题:

function Greeting() {
  // We wouldn’t expect `this` to be any kind of instance here
  return <p>Hello</p>;
}

这中情况还算是可以忍受的。但有两个原因可以扼杀这个想法。


第一个原因是因为,new无法适用于原生箭头函数(非Babel编译的),调用时带new会抛出一个错误:

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

这种情况是有意的,遵从了箭头函数的设计。箭头函数的主要特点是它们没有自己的this值,而this是最临近自身的一般function决定的。

class Friends extends React.Component {
  render() {
    const friends = this.props.friends;
    return friends.map(friend =>
      <Friend
        // `this` is resolved from the `render` method
        size={this.props.size}
        name={friend.name}
        key={friend.id}
      />
    );
  }
}

好的,所以箭头函数没有自己的this。但也意味着它们不可能是构造函数。

const Person = (name) => {
  // 🔴 This wouldn’t make sense!
  this.name = name;
}

因此,JavaScript不允许调用箭头函数时加new。如果你这样做了,一定是会发生错误的,趁早告诉你下。这类似于JavaScript不允许在没有new时调用class。

这很不错,但它也影响了我们的计划。由于箭头函数,React不可以用new来调用所有组件。我们可以用缺失prototype来检验箭头函数的可行性,而不单单用new

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

但这不适用于使用Babel编译的function。这可能不是什么大问题,但还有另外一个原因使这种方法走向灭亡。


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

function Greeting() {
  return 'Hello';
}

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

这再次与new操作符的怪异设计有关。正如之前我们看到的,new告诉JavaScript引擎创建一个对象,将对象等同function内的this,之后对象做为new的结果返回。

但是,JavaScript也允许使用new的function通过返回一些对象来覆盖new的返回值。据推测,这被认为对于如果我们想要池化来重用实例,这样的模式会很有用。

// Created lazily
var zeroVector = null;

function Vector(x, y) {
  if (x === 0 && y === 0) {
    if (zeroVector !== null) {
      // Reuse the same instance
      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又会完全无视此返回值。如果你返回的是一个string或者number,那完全和不返回值一样。

function Answer() {
  return 42;
}

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

使用new调用function时,无法读取到原始数据返回值(像number或者string),它无法支持返回字符串的组件。

这是无法接受的,所以我们势必要妥协。


到目前为止我们学到了什么?React必须用new调用class(包含 Babel 的输出),但必须不用new调用一般的function(包含 Babel 的输出)或是箭头函数,而且并没有可靠的方法区别它们。

如果我们解决不了一般性问题,那我们能否解决比较特定的问题呢

当你用class声明一个组件时,你可能会想扩展React.Component的内置方法,如this.setState()相比于检测所有class,我们可以只检测React.Component的后代组件吗?

剧透:这正是React所做的。


也许,如果Greeting是一个class组件,可以用一个常用手段去检测,通过测试Greeting.prototype instanceof React.Component:

class A {}
class B extends A {}

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

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

你可能常听到“原型链”,在JavaScript中,所有对象都应该有一个“prototype”。当我们写fred.sayHi()fred没有sayHi属性时,我们会从fred的原型中寻找sayHi。如果我们找不到它,我们会看看链中下一个prototype —— fred原型的原型,以此类推。

令人费解的是,一个class或者function的prototype属性 并不会 指向该值的原型。我没有在开玩笑。

function Person() {}

console.log(Person.prototype); // 🤪 Not Person's prototype
console.log(Person.__proto__); // 😳 Person's prototype

所以__proto__.__proto__.__proto__prototype.prototype.prototype更像"原型链"。这我花了好多年才理解。

那么function或是class的prototype属性是什么?它是提供给所有被class或function new过的对象 __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. Does fred have a sayHi property? No.
// 2. Does fred.__proto__ have a sayHi property? Yes. Call it!

fred.toString();
// 1. Does fred have a toString property? No.
// 2. Does fred.__proto__ have a toString property? No.
// 3. Does fred.__proto__.__proto__ have a toString property? Yes. Call it!

在编码时,除非你在调试原型链相关的错误,否则你几乎不需要在代码中触碰__proto__。如果你想在fred.__proto__添加东西的话,你应该把它加到Person.prototype上。至少它原先是这么被设计的。

起初,__proto__属性甚至不应该被浏览器暴露的,因为原型链被视为内部的概念。但有些浏览器加上了__proto__,最终它勉为其难地被标准化了(但已经被弃用了,取而代之的是Object.getPrototypeOf())。

然而,我仍然觉得一个被称为prototype的属性并没有提供给你该值的原型而感到非常困惑(举例来说,由于fred不是一个function致使fred.prototype变成undefined)。对我而言,我觉得这个是经验丰富的开发者也会误解JavaScript原型最大的原因。


这是一篇很长的文章,对吧?我想说我们到了80%了,继续吧。

当我们编写obj.foo时,JavaScript实际上会在obj, obj.__proto__, obj.__proto__.__proto__上寻找foo,以此类推。

在class中,你不会直接看到这种机制,不过extends也是在这个经典的原型链基础上实现的。这就是我们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开始,随着__proto__链往下检查,是否一个Greeting扩展了React.Component

// `__proto__` chain
new Greeting()
  → Greeting.prototype // 🕵️ We start here
    → React.Component.prototype // ✅ Found it!
      → Object.prototype

简单来说,X instanceof Y正好做了这种搜索。它随着x.__proto__链寻找其中的Y.prototype

通常,这被拿来判断一个东西是不是一个class的实例:

let greeting = new Greeting();

console.log(greeting instanceof Greeting); // true
// greeting (🕵️‍ We start here)
//   .__proto__ → Greeting.prototype (✅ Found it!)
//     .__proto__ → React.Component.prototype 
//       .__proto__ → Object.prototype

console.log(greeting instanceof React.Component); // true
// greeting (🕵️‍ We start here)
//   .__proto__ → Greeting.prototype
//     .__proto__ → React.Component.prototype (✅ Found it!)
//       .__proto__ → Object.prototype

console.log(greeting instanceof Object); // true
// greeting (🕵️‍ We start here)
//   .__proto__ → Greeting.prototype
//     .__proto__ → React.Component.prototype
//       .__proto__ → Object.prototype (✅ Found it!)

console.log(greeting instanceof Banana); // false
// greeting (🕵️‍ We start here)
//   .__proto__ → Greeting.prototype
//     .__proto__ → React.Component.prototype 
//       .__proto__ → Object.prototype (🙅‍ Did not find it!)

而它也可以用来判断一个class是否扩展了另一个class:

console.log(Greeting.prototype instanceof React.Component);
// greeting
//   .__proto__ → Greeting.prototype (🕵️‍ We start here)
//     .__proto__ → React.Component.prototype (✅ Found it!)
//       .__proto__ → Object.prototype

如果某个东西是一个class或者普通function的React组件,就可以用这个来判断我们的想法了。


然而这并不是React的作法。😳

其中有个问题,在React中,我们检查的组件可能是继承至别的React组件的React.Component副本,instanceof解决方案对页面上这种多次复制的React组件是无效的。从经验上看,有好几个原因可证实,在一个项目中,多次重复混合使用React组件是不好的选择,我们要尽量避免这种操作。(在Hooks中,我们可能需要)强制执行删除重复的想法。

还有一种启发方法是检测原型上是否存在render方法。但是,当时还不清楚组件API将如何发展。每次检测要增加一次检测时间,我们不想花费两次以上的时间在这。并且当render是实例上定义的方法时(例如class属性语法糖定义的),这种方法就无计可施了。

因此,React添加了一个特殊标志到基类组件上。React通过检查是否存在该标志,来知道React组件是否是一个class。

最初此标志位于React.Component这个基类上:

// Inside React
class Component {}
Component.isReactClass = {};

// We can check it like this
class Greeting extends Component {}
console.log(Greeting.isReactClass); // ✅ Yes

但是,有些我们需要判断的继承类是不会复制静态属性(或者设置不正规的__proto__)的,所以这种标志将失去作用。

这就是为什么React将这个标志转移React.Component.prototype上:

// Inside React
class Component {}
Component.prototype.isReactComponent = {};

// We can check it like this
class Greeting extends Component {}
console.log(Greeting.prototype.isReactComponent); // ✅ Yes

全部内容就到这里了

你可能会想,为什么它是一个对象而不是boolean。这问题在实际过程中没什么好纠结的。在早期的Jest版本中(在Jest还优秀的时候)默认启动了自动锁定功能,Jest生成的mock数据会忽略原始数据类型,致使React检查失效。多谢您勒。。Jest。

isReactComponent检测在今天的React中还在使用

如果你没有扩展React.Component,React不会去原型中寻找isReactComponent,也就不会把它当作class组件来处理。现在你知道为什么Cannot call a class as a function错误的最佳答案是使用了extends React.Component了吧。最后,我们还添加了一个警告,当prototype.render存在但prototype.isReactComponent不存在时会发出警告。


你可能会说这个故事有点诱导推销的意思。实际的解决方案其实非常简单,但我却用大量离题的事来解释为什么React最后会用到这个解法,以及替代的方案有哪些

以我的经验来看,类库的API通常就是这样,为了使API易于使用,你常常需要去考虑语言的语义(可能对于许多语言来说,还需要考虑到未来的走向),运行时的性能、人体工程学和编译时间流程、生态、打包方案、预先的警告、和许多其他的东西。最终结果可能并不总是最优雅,但它必须是实用的。

如果最终API是可行的,它的使用者 就永远不需要去考虑其中的衍生过程。反而他们能更专注于创造应用程序。

但如果你对此依然充满好奇。。。 知道它如何工作也是不错的。

翻译原文How Does React Tell a Class from a Function?