[譯] React 是怎麼知道組件是 Class 還是 Function 的?

realdennis
23 min readDec 24, 2018

--

本文翻譯自 Dan Abramov 在個人部落格中的文章 How Does React Tell a Class from a Function? Dan 是 React 核心團隊的成員,同時也是 redux 作者。

本篇文章雖以 React 為題,但是其中提到更多的是 JavaScript 的函數類別以及原型鏈的概念,我認為即使不是 React 開發者也必須通讀這篇,對於 API 開發,或是解決問題的思路,以及各個方案的 side-effect ,Dan在這篇文章寫得相當易懂且幽默。

假設 Greeting 是一個透過函數定義的組件。

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

React 的組件也可以用Class來做定義。

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

(直到 hooks 之前,這是唯一能拿到組件的 state 的方法)

當你在 render 函數中使用 <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 是 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 開發,這些事你並不需要,多年來我也一直不明白。請不要把它當做面試題目,事實上,這篇文章所探討的更多的是 JavaScript ,而非 React 。

這篇文章是為了一個好奇的讀者,他想知道 React 是以哪種方式去運作的。你也是那個好奇的人嗎?讓我們一起挖掘深一點。

這會是一個很長的旅程,繫好安全帶,這篇文章沒有太多關於 React 本身的介紹,我們轉而討論 new, this, class,箭頭函數, prototype, __proto__, instanceof, 以及他們在 JavaScript 中是如何運作。幸運的是,當你在使用 React 的時候並不需要思考這麼多。如果你正在實作 React 的話…

(如果你很想快點知道答案,可以直接滾動到最下面。)

首先我們必須知道,為什麼以不同的方式處理函數與類別很重要。注意,我們在呼叫類別的時候需要透過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 並沒有 classes 。然而,你可以透過普通函數 (plain function) 做到相似的模式。具體來說,你可以使用任何函數作為 class 的 constructor ,然後在呼叫前添加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 將會指向一個全局或無用的東西 (舉個例子, window 或是undefined)。使用我們的程式碼將會崩潰或是做一些愚蠢的事,比如修改 window.name

透過在呼叫前加入 new ,我們說:「嘿 JavaScript !我知道 Person 是一個函數,但讓我們假設它類似於類別中的建構函數,創建一個{}物件,並將 Person 函數指向該物件,這樣我就可以賦值像是this.name 之類的東西在裡頭,然後再把物件回傳給我」。

這就是new 算子在做的事。

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

new 算子還可以令 fred 物件使用Person.prototype 中的任何東西。

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 支援之前,大家透過這種方式來模擬類別。

所以 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 將會導致一些混亂的行為。

類別語法讓我們告訴 JavaScript :「這不僅僅是個函數 — 它是類別而且擁有一個建構式」。 如果你忘記在呼叫前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

這有助於我們在早期發現錯誤,而不是等待像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 如何解決這個問題之前,重要的是要記得大多數人使用 React 時會使用像是 Babel 這樣的編譯器來編譯現代功能,比如舊瀏覽器的 Classes。所以我們需要在設計時,將編譯器考慮其中。

在 Babel 的早期版本中,類別可以直接呼叫而不用透過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 函數所做的事。(你可以通過選擇 “loose mode” 而不進行檢查,來減少你的打包檔案大小,但這可能導致轉換原生 class 變得複雜。)

到目前為止,你應該能大致上了解在呼叫某些東西前加不加入new 的差別。

這正是 React 能夠正確呼叫你的組件的重要原因。如果你的組件被定義為 class , React 需要使用 new 呼叫它。

所以, React 只是檢查一下組件是不是類別嗎?

並沒有這麼簡單!就算我們可以知道其是類別或是函數,這仍然無法讓透過 Babel 編譯的類別運作,對於瀏覽器而言,他們只是個簡單的函數。React 真是不走運。

好的,或許 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 編譯的),使用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最好早點告訴你,無論如何這麼做都會產生錯誤。這就好比 JavaScript 不允許你在沒有使用new 的情況下呼叫 class 。

這很不錯,但也影響了我們的計劃。 React 不能僅僅是在每次呼叫前都使用 new ,這將會破壞箭頭函數!但是,我們可以透過是否缺少prototype,來辨別是否為箭頭函數。

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

但這對於經過 Babel 編譯的函數並沒有什麼用。這可能不是什麼大問題,但還有另外一個原因,讓這個想法破滅。

另一個不能總使用 new理由是,它會阻止組件返回字串與原始形別。然而 React 是支持的。

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

這再次與 new 的怪設計有關。如同我們前面所看到的,new 告訴 JavaScript 引擎去產生一個物件,並讓物件成為函數的 this ,最後回傳這個物件。

然而,JavaScript 允許在函數裡頭呼叫 new 來複寫原本的回傳物件。推測這樣的設計,也許是為了可複用實例的設計模式:

// Created lazilyvar 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

然而,如果函數不是物件(對象), new 也會完全忽略了函數的非物件回傳值。如果你回傳的是一個字串或是數字,就像是完全沒回傳任何東西。

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

使用new 時完全沒有方法去讀取這些原始形別的回傳值。所以如果 React 總是使用new ,就沒有辦法支援僅回傳字串的組件。

這令人不太滿意,所以我們需要一點妥協。

到目前為止我們學到了什麼? React 需要去透過 new 呼叫類別(包含 Babel 的輸出),但也必須不使用 new 來呼叫一般函數、箭頭函數(包含 Babel 的輸出)。並沒有可靠的方法來區分它們。

如果我們不能解決一般問題 (general) ,那能夠解決特定、個別(specific)的問題嗎?

當你定義一個類別組件時,你可能需要extend 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的原型(prototypes)。

你可能對於“原型鏈”(prototype chain)相當熟悉。每一個JavaScript中的物件皆擁有“原型”。但我們寫下fred.sayHi() 但是 fred 物件並沒有 sayHi 屬性時,我們會去尋找fred 的原型上是否有 sayHi 的屬性。 如果沒有,我們會往鏈上的下一個原型尋找 — fred’s prototype’s prototype,等等。

令人困惑的是,類別與函數的 prototype 屬性並非指向他們的原型。我沒有在開玩笑的!

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

所以“原型鏈”更像是__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!

在實務上,除非是在 debug 某些與原型鏈相關的內容,否則你幾乎永遠不需要直接從程式碼操作__proto__ 。如果你想要在fred.__proto__ 上放點東西,你應該把它放在Person.prototype 。至少這是原型鏈最初設計的方式。

甚至瀏覽器不該暴露__proto__ 屬性,在原型鏈一開始被視為一個內部的概念。但是有些瀏覽器添加了__proto__ ,最終它被勉強的標準化 (但仍不鼓勵使用Object.getPrototypeOf()

然而名為prototype 的屬性卻沒有給與對應的原型(舉例來說,因為fred 不是函數,使用 fred.prototype 回傳 undefined),這令我感到非常不解。就個人而言,我認為這是即使經驗豐富的開發人員也會誤解 JavaScript 原型的主要原因。

這是一篇很長的文章,對吧?我必須說我們已經講了80%了,繼續看下去!

我們知道,但我們使用 obj.foo ,實際上 JavaScript 從會持續尋找fooobjobj.__proto__obj.__proto__.__proto__,一直持續下去。

對於類別而言,你不會直接暴露這個機制,但是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.prototypeconsole.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

2 Chainz.

由於__proto__ 鏈正好對應類別的層次解構,我們可以透過觀察 Greeting.prototype 與它的__proto__ chain, 確認Greeting 是否是React.Component 的擴展。

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

方便的是x instanceof Y 正是在做這件事。它依照x.__proto__ chain 來尋找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!)

但這也能確定一個類別是否為另一個類別的擴展:

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 所做的。 😳

對於使用 instanceof 這方法的其中一個的警告是,當頁面上有多個 React 副本,而我們正在檢查的組件若繼承於另一個 React 副本的React.Component ,它起不了作用。在一個專案裡頭混入多個 React 副本是不好的,原因有幾個,但從歷史上來看,我們盡可能避免問題。(如果使用 Hook ,我們可能需要強制把重複的刪除。)

另一種具啟發式的方法可能是在原型上檢查是否有render 方法存在。但是,當時並不確定組件 API 會如何發展。每一次的檢查都需要成本,所以我們不想多次檢查。如果將render 定義為實例的的方法,例如使用類屬性語法,這也不起作用。

取而代之, React 在其基本組件添加了一個特殊的 flag 。 React檢查是否存在該 flag ,這就是為什麼它能夠卻是否為 React 組件類。

最初的 flag 是在 React.Component class 上:

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

但是,某些 class 的 implementation 並沒有實作靜態屬性複製(或非標準的設定__proto__),所以 flag 會遺失。

這也是為什麼 React 會把 flag 移至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

這就是實際上的全部內容

你可能會好奇為什麼 flag 是一個物件,而非一個布林值。它在實作中並不是那麼重要,但在早期版本的 Jest (更早以前是Good™️)默認啟用的 automocks 功能。生成的 mocks 省略了原始形別,破壞了檢查的過程,謝謝你,Jest。

isReactComponent 的檢查至今仍被 React 使用著

如果你不 extend React.Component , React 無法尋找 isReactComponent 是否在原型鏈上,也沒有辦法將其視為類組件。現在你知道為什 「extends React.Component加上去」會是Cannot call a class as a function error的最高票的答案。最後,當prototype.render 存在,但是prototype.isReactComponent 不存在,警告標籤會被加上。

你可能會覺得這篇文章有點釣魚 ( bait-and-switch)。實際的解決方法是如此的簡單,但我接著解釋為什麼 React 最終使用了這個解決方案,以及替代方案是什麼。

根據我的經驗,作為函式庫的 API 也會經常遇到這種情況。為了使 API 簡單易用,經常需要考慮語言的語義(可能對於多種語言、包括未來的方向),運行時的性能,有和沒有編譯時的人體工學、生態系統、包裝解決的方案、早期預警和其他很多的事。最後的結果可能不是那麼的優雅,但它必須是實用的。

如果最終 API 設計的很成功,它的使用可能甚至從不會思考過其運行原理,相反的,可以更專注於開發其應用。

但如果你也感到好奇…很高興你知道它是怎麼運作的。

--

--

No responses yet