React 是如何分辨函式式元件和類元件的?

笑在秋風中發表於2018-12-11

前言

原文連結:How Does React Tell a Class from a Function?

本文中通過探討這個問題,涉及到了JavaScript中大量的重要概念像原型、原型鏈、this、類、繼承等,通過思考這個問題對這些知識進行一個回顧,不失為一個好的學習方法,但如果你只是想知道這個問題的答案,就像作者說的那樣,直接滾動到底部吧。

限於本人水平有限,翻譯不到位的地方,敬請諒解。

正文

在React中我們可以用函式定義一個元件:

function Greeting() {
  return <p>Hello</p>;
}
複製程式碼

同樣可以使用Class定義一個元件:

class Greeting extends React.Component {
  render() {
    return <p>Hello</p>;
  }
}
複製程式碼

在React推出Hooks之前,Class定義的元件是使用像state這樣的功能的唯一方式。

當你想渲染的時候,你不需要關心它是怎樣定義的:

// 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>
複製程式碼

那麼React是怎麼分辨class或者function的呢?


這會是一個比較長的探索之旅,這篇文章不會過多的討論React,我們將探索new,this,class,箭頭函式,prototype,__proto__,instanceof的某些方面以及它們是怎麼在JavaScript中一起工作的。

首先,我們需要理解為什麼區分functions和class之間不同是如此重要,注意怎樣使用new命令去呼叫一個class:

// 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,但是你能用一個正常的函式去模擬Class。具體地說,你可以使用任何通過new呼叫的函式去模擬class的建構函式

// 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會指向window或者undefined,這樣我們的程式碼將會炸掉或者出現怪異的行為像設定了window.name

通過使用new命令呼叫函式,相當於我們說:“JavaScript,你好,我知道Person僅僅只是一個普通函式但是讓我們假設它就是類的一個建構函式。建立一個{}物件然後傳入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(類)的。

如果你定義了一個函式,JavaScript是不能確定你會像alert()一樣直接呼叫或者作為一個建構函式像new Person()。忘了使用new命令去呼叫像Person這樣的函式將會導致一些令人困惑的行為。

Class(類)的語法相當於告訴我們:“這不僅僅是一個函式,它是一個有建構函式的類”。如果你在呼叫Class(類)的時候,忘了加new命令,JavaScript將會丟擲一個錯誤:

et 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變成了window.name而不是george.name

不管怎樣,這意味著React需要使用new命令去呼叫所有的類,它不能像呼叫正常函式一樣去呼叫類,如果這樣做了,JavaScript會報錯的!

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

// ? React can't just do this:
const instance = Counter(props);
複製程式碼

上面是錯誤的寫法。


在我們講React是怎麼解決的之前,我們要知道大多數人會使用Babel去編譯React專案,目的是為了讓專案中使用的最新特性像class(類)能夠相容低端的瀏覽器,這樣我們就需要了解的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');   // ? Can’t call class as a function
複製程式碼

你可能有在打包出來的檔案中看到過上面的程式碼,這就是_classCallCheck所做的事情。

到目前為止,你應該已經大概掌握了使用new命令和不使用new命令之間的差別:

React  是如何分辨函式式元件和類元件的?

這就是為什麼React需要正確呼叫元件是如此重要的原因。如果你使用class(類)定義一個元件,React需要使用new命令去呼叫。

那麼React能判斷出一個元件是否是由class(類)定義的呢?

沒那麼容易,即使我們能分辨出函式和class(類):

function isClass(func) {
  return typeof func === 'function' 
    && /^class\s/.test(Function.prototype.toString.call(func));
}
複製程式碼

但如果我們使用了像Babel這樣的編譯工具,上面的方法是不會起作用的,Babel會將class(類)編譯為:

// 類
class Person {
}
// Babel編譯後
"use strict";

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

var Person = function Person() {
  _classCallCheck(this, Person);
};
複製程式碼

對於瀏覽器來說,它們都是普通的函式。


ok,React裡面的函式能不能都使用new命令呼叫呢?答案是不能。

用new命令呼叫普通函式的時候,會傳入一個物件例項作為this,像上面的Person那樣將函式作為建構函式來使用是可以的,但是對於函式式的元件卻會讓人懵逼的:

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

即使你能這樣寫,下面的兩個原因會杜絕你的這種想法。

第一個原因:使用new命令呼叫箭頭函式(未經Babel編譯過)會報錯

const Greeting = () => <p>Hello</p>;
new Greeting(); // ? Greeting is not a constructor
複製程式碼

這樣的報錯是故意的並且遵從箭頭函式的設計。箭頭函式的一大特點是它沒有自己的thisthis繫結的是定義的時候繫結的,指向父執行上下文:

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}
      />
    );
  }
}

複製程式碼
Tips:

如果不太理解的童鞋,可以參考下面的文章

阮一峰ES6教程--箭頭函式

全方位解讀this-這波能反殺

ok,箭頭函式沒有自己的this,這就意味著它不能作為建構函式:

const Person = (name) => {
  // ? This wouldn’t make sense!
  this.name = name;
}
複製程式碼

因此,JavaScript不能使用new命令呼叫箭頭函式,如果你這樣做了,程式就會報錯,和你不用new命令去呼叫class(類)一樣。

這是非常好的,但是不利於我們的計劃,因為箭頭函式的存在,React不能只用new命令去呼叫,當然我們也能試著去通過箭頭函式沒有prototype去區分它們,然後不用new命令呼叫:

(() => {}).prototype // undefined
(function() {}).prototype // {constructor: f}
複製程式碼

但是如果你的專案中使用了Babel,這也不是個好主意,還有另一個原因使這條路徹底走不通。

這個原因是使用new命令呼叫React中的函式式元件,會獲取不到這些函式式元件返回的字串或者其他基本資料型別。

function Greeting() {
  return 'Hello';
}

Greeting(); // ✅ 'Hello'
new Greeting(); // ? Greeting {}
複製程式碼

關於這點,我們需要知道new命令到底幹了什麼?

通過new操作符呼叫建構函式,會經歷以下4個階段

  • 建立一個新的物件;
  • 將建構函式的this指向這個新物件;
  • 指向建構函式的程式碼,為這個物件新增屬性,方法等;
  • 返回新物件。

關於這些內容在全方位解讀this-這波能反殺有更為詳細的解釋。

如果React只使用new命令呼叫函式或者類,那麼就無法支援返回字串或者其他原始資料型別的元件,這肯定是不能接受的。


到目前為止,我們知道了,React需要去使用new命令呼叫class(包括經過Babel編譯的),不使用new命令呼叫正常函式和箭頭函式,這仍沒有一個可行的方法去區分它們。

當你使用class(類)宣告一個元件,你肯定想繼承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中每一個物件都有一個“prototype(原型)”。

下面的示例和圖來源於前端基礎進階(九):詳解物件導向、建構函式、原型與原型鏈,個人覺得比原文示例更能說明問題

// 宣告建構函式
function Person(name, age) {
    this.name = name;
    this.age = age;
}

// 通過prototye屬性,將方法掛載到原型物件上
Person.prototype.getName = function() {
    return this.name;
}

var p1 = new Person('tim', 10);
var p2 = new Person('jak', 22);
console.log(p1.getName === p2.getName); // true

複製程式碼

image

當我們想要呼叫p1上的getName方法時,但是p1自身並沒有這個方法,它會在p1的原型上尋找,如果沒有找到我們會沿著原型鏈在上一層的原型上繼續找,也就是在p1的原型的原型...,一直找下去,直到原型鏈的終極null

原型鏈更像__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'); // 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__。如果你想往原型上新增一些東西,你應該新增到Person.prototype上,那新增到__proto___可以嗎?當然可以,能生效,但是這樣不符合規範的,有效能問題和相容性問題,詳情點選這裡

早期的瀏覽器是沒有暴露__proto屬性的,因為原型類是一個內部的概念,後來一些瀏覽器逐漸支援,在ECMAScript2015規範中被標準化了,想要獲取某個物件的原型,建議老老實實的使用Object.getPrototypeOf()


我們現在已經知道了,當訪問obj.foo的時候,JavaScript通常在obj中這樣尋找fooobj.__proto__,obj.__proto__.__proto__...

定義一個類元件,你可能看不到原型鏈這套機制,但是extends(繼承)只是原型鏈的語法糖,React的類元件就是這樣訪問到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)
複製程式碼

換句話說,當你使用類的時候,一個例項的原型鏈對映這個類的層級

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

// `__proto__` chain
new Greeting()
  → Greeting.prototype
    → React.Component.prototype
      → Object.prototype
複製程式碼

因為原型鏈對映類的層級,那我們就能從一個繼承自React.Component的元件GreetingGreeting.prototype開始,順著原型鏈往下找:

// `__proto__` chain
new Greeting()
  → Greeting.prototype // ?️ We start here
    → React.Component.prototype // ✅ Found it!
      → Object.prototype
複製程式碼

實際上,x instanceof y就是做的這種查詢,它沿著x的原型鏈查詢y的原型。

通常這用來確定某個例項是否是一個類的例項:

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!)
複製程式碼

並且它也能用來檢測一個類是否繼承自另一個類:

console.log(Greeting.prototype instanceof React.Component);
// greeting
//   .__proto__ → Greeting.prototype (?️‍ We start here)
//     .__proto__ → React.Component.prototype (✅ Found it!)
//       .__proto__ → Object.prototype
複製程式碼

我們就能通過這種方式檢測出一個元件是函式元件還是類元件。


然而React並沒有這樣做。

作者此處還探討了兩種方案,在此略去,有興趣看原文喲。

實際上React對基礎的元件也就是React.Component新增了一個標記,並通過這個標記來區分一個元件是否是一個類元件。

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

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

像上面這樣把標記直接新增到基礎元件自身,有時候會出現靜態屬性丟失的情況,所以我們應該把標記新增到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就是這樣解決的。

後面還有幾段,參考文末另一位大兄弟的譯文吧。

後續

這文章有點長,涉及的知識點也比較多,最後的解決方案,看似挺簡單的,實際上走到這一步並不簡單,希望大家都有所收穫。 翻譯到一半的時候,在React的一個Issues中發現另一個人這篇文章的譯文,有興趣的童鞋,可以點選閱讀

相關文章