issue-blog icon indicating copy to clipboard operation
issue-blog copied to clipboard

[译]How Does React Tell a Class from a Function ?

Open bigbigbo opened this issue 6 years ago • 1 comments

[译]How Does React Tell a Class from a Function ? React 是如何分辨一个函数是不是类的?

备注:本人的第一篇翻译文章,文中如果有不正确的地方,欢迎指出~ 原文:https://overreacted.io/how-does-react-tell-a-class-from-a-function/

现在我们有一个通过函数定义的Greeting组件:

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

同样我们也可以通过类来声明这样一个组件:

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

一直到最近react-hooks的出现,否则在此之前,如果你的组件需要state,你就必须使用类来创建组件。

当然当你渲染一个<Greeting />组件时,你不需要关心它到底是一个函数式组件还是一个类组件:

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

但是React需要关心这两者之间的区别!

如果Greeting是一个函数式组件,React将这样调用它:

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

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

但如果Greeting是一个类组件,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>

这两种方法最后都是为了获得<p>Hello</p>这个节点,但是获取的过程就要看Greeting是如何被定义的了。

所以,React是如何知道类和函数的区别的?

就像上一篇文章介绍的那样,你不需要知道这一点就可以使用React。多年来我也一直都不知道这一点。请不要把这些当做面试题,事实上,这篇文章更多的是在讨论Javascript而不是React

这个博客是写给那些好奇React究竟是如何运作的人们。你是这其中的一员吗?让我们一起探索吧。

我们准备发车了(这不是开往幼儿园的车),路途遥远,请系好安全带。这篇文章不会过多的谈论React本身,但是我们会涉及到很多的概念,像newthisclass箭头函数prototype__proto__instance,并且我们会去探究这些东西是如何在JavaScript中一起工作的。幸运的是,如果你只是为了使用React。你并不需要知道这些细节,是的,不需要掌握这些你也可以使用React...

(如果你已经知道答案,出门右拐吧...不,请到最底下)


在早些时候,JavaScript中并没有class(类)这个玩意,但是你却可以使用函数来模拟的行为。准确地说,你可以像调用类的构造函数一样通过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

在今天,你仍然可以这样书写代码,你可以自己动手试试!

如果你没有通过new操作符来调用Person('Fred'),这将导致this的指向不明确(例如,this可能代表着window或者是undefined),这可能会导致你的代码不能正确运行。

通过new来调用函数,就像我们对JavaScript说:“我知道Person只是一个函数而已,但是请你假装把它当做一个类的构造函数吧!然后请你创建一个空对象,并且将this指向Person吧,这样我就可以为Person添加属性了,像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();

这就是在es6的class未出来之前我们是如何模拟类(class)的

在《你不知道的JavaScript》中,是这样介绍的:

当你使用new来调用函数,会执行的一些操作:

  • 创建一个全新的对象
  • 这个新对象会执行原型的链接,将对象的原型链接到构造函数的原型上
  • 将this绑定到这个对象上
  • 如果没有显式的返回其他对象,则默认返回这个对象

new已经在JavaScript存在了一段时间了,但是class却出现没多长时间。让我们使用class重写上面的代码,你会发现它能更好的表达我们的意图:

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操作符,这可能导致你的代码不能正确运行。

类(class)的语法语义更加清晰,“这不仅仅是一个函数—它是一个class并且它拥有一个构造器”。如果你忘记使用new调用class,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

这能够帮助你尽早的捕获错误,比如this.name引用的是window.name而不是george.name

但是,这意味着React在调用class的时候必须加上new而不能直接调用它,因为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操作符(这和刚才提到calss的特性相驳)。但是后面Babel修改了对class的编译实现,参考下面这段代码:

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');   // 🔴 Can’t call class as a function

你可能在Babel编译过后的代码包里看到过一个名为_classCallCheck的函数,它的作用就是用来检测class有没有被new调用而不是直接调用。(你可以通过babel中的一个配置项:loose mode来减少包体积的大小,而减少的代码中就包括移除了_classCallCheck,但是这可能在class被原生支持前带来更多的麻烦。)


现在,你可以大致地了解了通过和不通过new来调用函数的区别了:

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

这对React能否正确调用类组件来说非常重要。如果你声明了一个类组件则React在调用它的时候必须使用new来调用。

那么React是如何判断一个函数它到底是不是类(函数模拟出来的)呢?

这似乎没那么容易!尽管我们可以在ES6中分辨出一个function是不是类,但是当我们借助Babel来编译class的时候,得到的是像上面示例代码那样的函数,对于浏览器来说,它们和普通的函数没有任何区别。所以React就不能借此来作判断了~


那如果React全部通过new来调用函数呢?不幸的是,这也行不通...

当我们通过new来调用函数的时候,它会返回一个新对象并将this指向这个对象。如果你是声明一个类这正好满足我们的需求,但是对于函数式来说这就有点问题了:

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

不过这个看起来还可以接受,因为我们不会在函数式组件里面使用this。还有另外两个原因让我们放弃了这个想法。


第一个原因就是当你使用箭头函数来声明一个函数的时候(如果你让Babel不去编译箭头函数的话),通过new来调用箭头函数将抛出一个错误:

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

其实之所以会报错是因为箭头函数的设计就是如此。箭头函数没有自己的this而是会就近解析this:

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来调用箭头函数。如果你通过new来调用箭头函数,那么你会得到一个错误,JavaScript希望尽早告诉你这个错误从而避免掉上面提到的class没有通过new来调用有可能导致的错误的错误。

箭头函数这个特性很好,但是打乱了我们的计划 —“全部通过new来调用函数”。我们又尝试去判断一个函数是否有prototype这个属性来判断一个函数是不是箭头函数,而不是通过能否new调用来判断:

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

悲剧,这同样行不通,因为Babel还是会将箭头函数编译成普通的函数。但是乐观点看这好像没什么大不了(反正我们也不会在函数式组件中使用this),但是还有最后一个原因彻底kill掉了我们这个想法。


另外一个重要的原因是:如果你在函数中返回的并非是一个对象,并且你通过new来调用这个函数,神奇的是你拿不到你返回的值如一个字符串或者一个数值:

function Greeting() {
  return 'Hello';
}

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

这与new操作符的设计有关,就像我们上面说到的,new会执行以下的操作:

  • 创建一个全新的对象
  • 这个新对象会执行原型的链接,将对象的原型链接到构造函数的原型上
  • 将this绑定到这个对象上
  • 如果没有显式的返回其他对象,则默认返回这个对象

但是,JavaScript允许你返回一个对象来覆盖掉new创建的那个对象。据我推测,这对一些设计模式像XX池(比如对象池),比如当你想复用一个实例的时候是非常有用的:

// 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 Answer() {
  return 42;
}

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

所以显而易见的,如果React总是通过new来调用函数就会出现问题,因为这意味着React必须放弃支持只返回字符串的组件。

这是不可接受的,我们必须妥协。


好了,咱们先缕缕刚才都扯了哪些东西。React想实现:

  • 通过new来调用class(包括babel编译过后的)
  • 调用普通函数和箭头函数(包括babel编译过后的)的时候又不需要通过new调用

目前为止,我们没有可靠的方法来区分他们。

如果我们不能解决一般性问题,那么我们可以解决更具体的问题吗?

当你使用类组件的时候你肯定是会需要用到React.Component内建的一些方法,比如this.setState。So,与其判断一个函数究竟是类还是普通函数或者箭头函数,为什么我们不直接判断这个类是不是React.Component的派生类呢?

剧透:React就是这么干滴。


也许,检测Greeting组件是不是一个React类组件可以通过Greeting.prototype instanceof React.Component

class A {}
class B extends A {}

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

我知道你想问为什么代码输出了true!为了回答这个问题,我们必须先搞懂JavaScript原型这个东西。

你可能对原型链已经很熟悉了。在JavaScript中,每个对象可能都有一个prototype属性。当我们希望调用fred.sayHi()的时候但fred这个对象又没有sayHi这个属性的时候,JavaScript会在fred的prototype上寻找sayHi这个属性。如果没有找到,那么JavaScript会沿着原型链查找下一个原型即fred原型的原型。

但是令人困惑的是,类或者函数的prototype属性并不是指向它真正的原型。我一脸认真脸:

function Person() {}

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

所以原型链更准确的说应该是__proto__.__proto__.__proto__而不是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'); // Sets `fred.__proto__` to `Person.prototype`

JavaScript就是依靠__proto__链来查找属性的:

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不是一个函数的话,访问fred.prototype得到的是undefined)。 就我个人而言的话,我认为这是即使是经营丰富的开发人员也容易对JavaScript原型产生误解的重要原因。


这文章也太长了吧...还剩20%...Orz

现在我们知道,当我们访问obj.foo的时候,JavaScript实际会先在obj上寻找foo属性,接着是obj.__proto__, obj.__proto__.__proto__...

如果你直接使用ES6中的类,你可能都不需要理解__proto__链(原型链)这个东西,但是extends就是__proto__链(原型链)的语法糖。这也解释了为什么类组件可以访问到React.Component上的属性比如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__链(原型链)正一一对应着类的层级结构:

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

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

2 Chainz. (不知如何翻译)


既然__proto__链(原型链)映射出类的层级结构,那我们如果想判断Greeting是否extendsReact.Component,则我们就从Greeting.prototype顺着__proto__链(原型链)来做一一判断:

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

实际上,X instanceof Y 就是做的这种查询。它会顺着X.__proto__.__proto__...查找Y.prototype

通常,它用于确定某样东西是否是类的实例:

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!)

并且,这同样适用于一个类是否extends另外一个类:

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

所以我们可以借助这个来检测一个组件究竟是函数式组件或者类组件。


不过,这并不是React所做的。😳

instanceof有个问题是在某个场景下会导致得不到正确结果,即当一个页面存在多个不同的React副本。在一个项目中混合使用不同版本的React是不好的,原因有很多,但从长远地看,我们应该尽可能避免出现这种问题。 (With Hooks, we might need to force deduplication though.)PS: 这段不知道该如何翻译比较好...

另一种可行的方案是去判断其原型上是否存在render方法。但是当时尚不清楚组件的API会如何发展。而且每次检查都会带来相应的成本,我们不想这样做。而且当render被定义实例方法,例如使用类属性语法,这同样也会导致判断不准确。

所以我们选择了另外一个方案,我们通过添加一个特殊的标志来判断是否是类组件。React通过检查这个特殊标志是否存在来判断当前这个组件是不是类组件。

一开始我们直接将这个特殊标志添加到React本身:

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

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

但是一些对class的编译会忽略掉对class静态属性的复制,所以你有可能获取不到这个特殊标志。

这也是为什么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

这就是在这个问题上React做的全部事情。

你可能会疑惑这个特殊标志为什么是一个对象而不是一个布尔值。恩~自己看这个issue,我也不知道怎么翻译,词不达意最要命...

现在React就是使用isReactComponent来做是否是类组件的检查。

如果你不是声明一个类组件,那么React就不会去查找isReactComponent这个属性即我们也不会把它当做一个类组件来处理。现在你可能知道关于“Cannot call a class as a function ”这个问题在stackoverflow中得票最高的答案为什么是add extends React.Component了吧。当存在render方法却又找不到isReactComponent属性时会触发警告!


你可能会说这看起来像是Bait-and-switch实际的解决方案非常简单,那为什么我最终会使用这个解决方案当还有其他解决方案可以考虑的时候?

以我的经验来看,这个问题(API的设计及实现,即实现可能不是最优雅的)经常出现在一些第三方库的API设计上。为了考虑如何设计一个易于使用的API,你需要考虑到其语言语义的正确性(可能还涉及到多种语言)、运行时的性能、考虑在有无编译环境下的使用、生态的建设及打包的解决方案、早期的警告提示以及其他需要考虑的东西。最终的结果可能不是最优雅的,但一定是最实用的。

如果一个API的设计是成功的,那么使用它的人就永远不会去考虑这个API是如何实现的。相反,他们会更专注于他们的工作。

但是如果好奇它是怎么工作的,能了解它的实现那是最好不过的事了。

bigbigbo avatar Dec 04 '18 13:12 bigbigbo

With Hooks, we might need to force deduplication though.

由于Hooks的出现,我们可能必需要去消除这个重复

ChangeHow avatar Sep 23 '20 08:09 ChangeHow