[譯]React如何區別class和function
原文 How Does React Tell a Class from a Function?
譯註:
一分鐘概覽——
React最後采用了在React.Component
上加入isReactComponent
標識作為區分。
1.在這之前,考慮了ES6的區分方法,但是由於Babel的存在,這個方法不可用。
2.總是調用new
,對於一些純函數組件不適用。而且對箭頭函數使用new
會出錯。
3.把問題約束到React組件下,通過判定原型鏈來做,但是可能有多個React實例導致判定出錯,所以在原型上添加了標識位,標識位是一個對象,因為早期Jest會忽略普通類型如Boolean型。
4.API檢測也是可行的,但是API的發展無法預測,每個檢測都會帶來額外的損耗,所以不是主要做法,但是在現在版本裏已經加入了render
prototype.render
存在,但是prototype.isReactComponent
不存在的場景,這樣會拋出一個warning。
以下正文。
思考一下下面這個使用function定義的Greeting
組件:
function Greeting() {
return Hello;
}
React也支持class定義:
class Greeting extends React.Component {
render() {
return Hello;
}
}
(直到最近,這是唯一可以使用類似state
這種功能的方法。)
當你在使用<Greeting />
// Class or function — whatever.
但是React自己是關心這些不同的!
如果Greeting
是一個函數,React需要去調用它:
// Your code
function Greeting() {
return Hello;
}
// Inside React
const result = Greeting(props); // Hello
但是如果Greeting
是類,React就需要用new
關鍵字去實例化一個對象,然後立刻調用它的render
方法。
// Your code class Greeting extends React.Component { render() { return Hello; } } // Inside React const instance = new Greeting(props); // Greeting {} const result = instance.render(); // Hello
React有一個相同的目的——得到一個渲染完畢的node(在這個例子裏,<p>Hello</p>
)。但是如果定義Greeting
決定了剩下的步驟。
所以React是如何知道一個組件是類還是函數?
就像我之前的博客,你不需要知道這個東西對於React而言的效果。我同樣好幾點不了解這些。請不要把這個問題變成一個面試題。事實上,比起React,這篇博客更關註於JavaScript。
這篇博客寫給那些富有好奇心的讀者,他們想知道為什麽React能以一種確定的方式工作。你是這樣的人嗎?一起深入探討吧!
這是一段漫長的旅程。這篇博客不會寫很多關於React的東西,但是會一掠JavaScript本身的風采,諸如:new
,this
,class
,箭頭函數
,prototype
,__proto__
,instanceof
,以及這些東西如何在JavaScript中合作。幸運的是,在你使用React的時候,你不必想太多這些事。
首先,我們需要明白為什麽區分函數和類如此重要。註意我們怎麽使用new
操作符去調用一個類:
// If Greeting is a function
const result = Greeting(props); // Hello
// If Greeting is a class
const instance = new Greeting(props); // Greeting {}
const result = instance.render(); // Hello
先來對new
操作符做了什麽給出一個粗淺的定義。
以前,JavaScript沒有類的概念。然而,你也可以用純函數去描述一種近似於類的模式。具體而言,你可以在調用函數之前,添加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
就會指向一些全局變量,也沒什麽用了(例如:window或undefined)。所以我們的代碼就會奔潰,或者做些蠢事像是設置了window.name
。
通過添加一個new
操作符,就像告訴編譯器:“Hey,JavaScript,我知道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直接支持類特性之前模擬的方法。
所以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設計而言,捉住開發者的意圖是很重要的。
如果你寫函數,JavaScript無法猜測它是直接調用(如alert
)還是想對待構造器(如new Person()
)一樣對待它。如果對類似Person
這樣的函數忘記指定new
操作符,會帶來令人費解的表現。
類語法讓我們可以告訴編譯器:“這不僅是一個函數,它是一個類並且擁有一個構造器。”如果你忘了調用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
被指向了window
而不是我們期望的george
。
然而,這也意味著React需要在實例任何類對象之前調用new
。如同前面而言,如果少了這一步,就會拋出錯誤。
class Counter extends React.Component {
render() {
return Hello;
}
}
// ?? React can‘t just do this:
const instance = Counter(props);
這是個大麻煩。
在查看React如何解決這個問題之前,應該清楚,大部分人為了讓代碼可以跑在舊瀏覽器裏,通常使用Babel或者其他編譯器去處理類似class這種現代語法。所以在我們的設計裏,必須要考慮編譯器。
在Babel早前的版本裏,類能夠不通過new
去調用。然而,這個bug最後被修復了——通過生成下面這些代碼。
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
你也許在構建之後的bundle裏看到過這樣的代碼。這是所有_classCallCheck
函數所做的事情。(你可以選擇“loose mode(寬松模式)”來使得編譯器繞過這些檢查,但可能會使得最終生成的class代碼很復雜。)
到目前為止,你應該大概了解了有new
和無new
的區別。
new Person() |
Person() |
|
---|---|---|
class |
?this 是Person 實例 |
??TypeError |
function |
?this 是Person 實例 |
??this 指向window 或undefined |
這就是React正確調用組件的重要之處。如果通過class聲明組件,就必須使用new
去調用它。
所以這樣React就能檢查是否是class了嗎?
沒那麽簡單!即使我們可以區別ES6 class和function,但是這樣並不能判斷Babel這樣的工具生成的代碼。對於瀏覽器而言,他們都只是函數而言。真是不走運。
好吧,那React只能每次都使用new
了嗎?然而,這樣也不行。
對於一般的函數,如果通過new
去調用,就會新建一個對象實例並將this
指向它。對於寫成構造器的函數(就像Person
),這樣做是可行的,但是對於一般的函數而言,就很奇怪了。
function Greeting() {
// We wouldn’t expect `this` to be any kind of instance here
return Hello;
}
這樣雖然是可以容忍的,但是還有兩個問題使得我們不得不拋棄這種做法。
第一個問題是對箭頭函數使用new
,它並不會被Babel處理,直接加new
會拋出一個錯誤。
const Greeting = () => Hello;
new Greeting(); // ?? Greeting is not a constructor
這種表現是符合預期的,也是符合箭頭函數設計的。箭頭函數的特殊點之一就是沒有自己的this
值,它只能從最近的函數閉包內獲取this
值。
class Friends extends React.Component {
render() {
const friends = this.props.friends;
return friends.map(friend =>
);
}
}
OK,即使箭頭函數沒有自己的this
值,但是也不意味著它完全不能用作構造器!
const Person = (name) => {
// ?? This wouldn’t make sense!
this.name = name;
}
因此,JavaScript不允許使用new
去調用箭頭函數。如果這麽做,就會盡早的拋出一個錯誤。這和不能不用new
去調用一個類有點類似。
這個設計很好但是卻影響了我們的計劃。React不能在所有東西上都加上new
,因為這樣可能會破壞箭頭函數。我們可以通過檢測prototype
去區分箭頭函數和普通函數。
(() => {}).prototype // undefined
(function() {}).prototype // {constructor: f}
但是這樣對Babel轉移後的函數並不好使。這也不算是大問題,但是還有一個問題讓我們徹底放棄了這個想法。
另一個原因在於使用new
之後,React就無法支持那些返回string這種基本類型的函數了。
function Greeting() {
return ‘Hello‘;
}
Greeting(); // ? ‘Hello‘
new Greeting(); // ?? Greeting {}
這是new
操作符的另一個怪異設計。正如我們之前看到的,new
告訴JavaScript引擎創建一個對象並把this
指向它,之後將它返回給我們。
然而,JavaScript允許被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
然而,new
同樣會忽略那些非對象類型的返回值。如果只是return一個string或者nunber,就像沒寫return一樣。
function Answer() {
return 42;
}
Answer(); // ? 42
new Answer(); // ?? Answer {}
如果用了new
調用函數,就沒有什麽辦法獲得一個基本類型的return。所以,如果React一直用new
調用函數,直接返回string的函數將不能正常使用。
這是不可接受的,所以需要妥協一下。
到目前為止,我們學到了什麽?React需要使用new
去調用classes(包括Babel轉移後的),但是還需要不用new
直接調用一般函數和箭頭函數。但是卻沒有一種可靠的方法區分它們。
如果不能提出通用解法,是不是可以把問題再細分一下?
當你使用class去定義一個組件,你一般會使用繼承React.Component
,然後去使用一些內建方法,比如this.setState()
。與其檢測全部的class,不如只檢測React.Component
的子類呢?
劇透:這也是React的做法。
一般而言,檢查子類通用的做法就是使用instance of
。如果檢查Greeting
是不是React組件,就需要使用Greeting.prototype instanceof React.Component
:
class A {}
class B extends A {}
console.log(B.prototype instanceof A); // true
我知道你在想什麽。這裏發生了什麽?為了解答這個問題,我們需要明白JavaScript原型機制。
你可能聽說過“原型鏈”。每個JavaScript對象都可能有一個“prototype”。當調用fred.syaHi()
時,如果fred
上沒有sayHi()
,就會在它的原型上去尋找。如果沒找到,則繼續向上找,就像鏈條一樣。
令人費解的事,類或者函數的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
原型在函數或者類上到底是啥?通過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()
又會被移出標準。
我仍然覺得很困惑,一個屬性稱為原型但不給你一個有用的原型(例如,fred.prototype
是undefined,因為fred
不是一個函數)。就我而言,我認為最大的原因是,,哪怕是有經驗的開發人員也常常會誤解JavaScript原型。
這篇博客太長了,已經講完80%了。繼續。
我們知道對於obj.foo
,JavaScript實際上去尋找obj
的foo
,找不到再去obj.__proto__
,obj.__proto__.__proto__
……
通過使用class,沒有必要直接去使用這個機制,extends
在原型鏈下也能工作的很好。下面的例子講述了為什麽React類實例能獲取像setState
這樣的方法。
class Greeting extends React.Component {
render() {
return Hello;
}
}
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
通過類層次和__proto__
鏈的一一對應,我們可以循著原型鏈找到父級。
// `__proto__` chain
new Greeting()
→ Greeting.prototype // ??? We start here
→ React.Component.prototype // ? Found it!
→ Object.prototype
x instanceof Y
就是使用__proto__
鏈進行查找。就是在x.__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!)
它也可以用來確定一個類是否繼承自另一個類。
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實例,是一個錯誤的設計,但是歷史包袱畢竟可能存在,所以還是要避免在這種情況下使用instanceof
。(通過使用Hooks,我們可能需要強制維持兩份環境了。)
另一種方法可以檢測render()
的存在,但是有個問題,無法預測日後API的變化。每次檢測都要花費時間,不希望以後API發生變化之後,又加一個。而且,如果實例上聲明了render()
,也會繞過這個檢測。
所以,React在基類上增加了一個特殊的標識。React檢測這個標識的存在,這樣區別是否是React Component。
起初,這個標識依賴於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版本中,有自動Mock的機制。Mock後的數據會忽略基本類型的屬性,會破壞檢測。感謝Jest。
isReactComponent
至今仍在使用。
如果沒有繼承React.Component
,React在原型上沒有發現isReactComponent
標識,就會像對待普通類一樣對待它。現在就知道為什麽Cannot call a class as a function
問題下得票最多的回答建議添加extends React.Component
。最後,一個警告已經被加入到React中,用來檢測prototype.render
存在,但是prototype.isReactComponent
不存在的場景。
你可能覺得這是一個關於替換的故事。實際的解決辦法很簡單,但是,我還需要解釋為什麽要選擇這個方案,以及還存在哪些別的選擇。
以我的經驗,這對於library級別的API來說,是很常見的。為了讓API簡單易用,你常常需要考慮語義(也許在一些語言裏,還包括未來方向),runtime性能,編譯與否,時間成本,生態,打包解決方案,及時warning,還有很多事情。最後的方案不一定優雅,但一定經得起考驗。
如果API設計得很成功,這些過程對於用戶就是透明的。他們可以專註於開發APP。
但是如果你很好奇,能幫助你理解它如何工作也很棒。
[譯]React如何區別class和function