[譯] React 是怎麼知道組件是 Class 還是 Function 的?
本文翻譯自 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 toolet 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 從會持續尋找foo
從obj
、 obj.__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.prototypec.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.prototypeconsole.log(greeting instanceof React.Component); // true
// greeting (🕵️ We start here)
// .__proto__ → Greeting.prototype
// .__proto__ → React.Component.prototype (✅ Found it!)
// .__proto__ → Object.prototypeconsole.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 設計的很成功,它的使用可能甚至從不會思考過其運行原理,相反的,可以更專注於開發其應用。
但如果你也感到好奇…很高興你知道它是怎麼運作的。