[譯] React 是如何區分 Class 和 Function 的 ?
讓我們來看一下這個以函式形式定義的Greeting
元件:
function Greeting() { return <p>Hello</p>; } 複製程式碼
React 也支援將他定義成一個類:
class Greeting extends React.Component { render() { return <p>Hello</p>; } } 複製程式碼
(直到最近,這是使用 state 特性的唯一方式)
當你要渲染一個<Greeting />
元件時,你並不需要關心它是如何定義的:
// 是類還是函式 —— 無所謂 <Greeting /> 複製程式碼
但React 本身 在意其中的差別!
如果Greeting
是一個函式,React 需要呼叫它。
// 你的程式碼 function Greeting() { return <p>Hello</p>; } // React 內部 const result = Greeting(props); // <p>Hello</p> 複製程式碼
但如果Greeting
是一個類,React 需要先用new
操作符將其例項化,然後
呼叫剛才生成例項的render
方法:
// 你的程式碼 class Greeting extends React.Component { render() { return <p>Hello</p>; } } // 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
操作符來呼叫一個類的:
// 如果 Greeting 是一個函式 const result = Greeting(props); // <p>Hello</p> // 如果 Greeting 是一個類 const instance = new Greeting(props); // Greeting {} const result = instance.render(); // <p>Hello</p> 複製程式碼
我們來簡單看一下new
在 JavaScript 是幹什麼的。
在過去,JavaScript 還沒有類。但是,你可以使用普通函式來模擬。
具體來講,只要在函式呼叫前加上new
操作符,你就可以把任何函式當做一個類的建構函式來用:
// 只是一個函式 function Person(name) { this.name = name; } var fred = new Person('Fred'); // :white_check_mark: Person {name: 'Fred'} var george = Person('George'); // :red_circle: 沒用的 複製程式碼
現在你依然可以這樣寫!在 DevTools 裡試試吧。
如果你呼叫Person('Fred')
時沒有
加new
,其中的this
會指向某個全域性且無用的東西(比如,window
或者undefined
),因此我們的程式碼會崩潰,或者做一些像設定window.name
之類的傻事。
通過在呼叫前增加new
,我們說:“嘿 JavaScript,我知道Person
只是個函式,但讓我們假裝它是個建構函式吧。
建立一個{}
物件並把Person
中的this
指向那個物件,以便我可以通過類似this.name
的形式去設定一些東西,然後把這個物件返回給我。
”
這就是new
操作符所做的事。
var fred = new Person('Fred'); // 和 `Person` 中的 `this` 等效的物件 複製程式碼
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 Person { constructor(name) { this.name = name; } sayHi() { alert('Hi, I am ' + this.name); } } let fred = new Person('Fred'); fred.sayHi(); 複製程式碼
如果你寫了一個函式,JavaScript 沒辦法判斷它應該像alert()
一樣被呼叫,還是應該被視作像new Person()
一樣的建構函式。忘記給像Person
這樣的函式指定new
會導致令人費解的行為。
類語法允許我們說:“這不僅僅是個函式 —— 這是個類並且它有建構函式”。如果你在呼叫它時忘了加new
,JavaScript 會報錯:
let fred = new Person('Fred'); // :white_check_mark:如果 Person 是個函式:有效 // :white_check_mark:如果 Person 是個類:依然有效 let george = Person('George'); // 我們忘記使用 `new` // :flushed: 如果 Person 是個長得像建構函式的方法:令人困惑的行為 // :red_circle: 如果 Person 是個類:立即失敗 複製程式碼
這可以幫助我們在早期捕捉錯誤,而不會遇到類似this.name
被當成window.name
對待而不是george.name
的隱晦錯誤。
然而,這意味著 React 需要在呼叫所有類之前加上new
,而不能把它直接當做一個常規的函式去呼叫,因為 JavaScript 會把它當做一個錯誤對待!
class Counter extends React.Component { render() { return <p>Hello</p>; } } // :red_circle: React 不能簡單這麼做: const instance = Counter(props); 複製程式碼
這意味著麻煩。
在我們看到 React 如何處理這個問題之前,很重要的一點就是要記得大部分 React 的使用者會使用 Babel 等編譯器來編譯類等現代化的特性以便能在老舊的瀏覽器上執行。因此我們需要在我們的設計中考慮編譯器。
在 Babel 的早期版本中,類不加new
也可以被呼叫。但這個問題已經被修復了 —— 通過生成額外的程式碼的方式。
function Person(name) { // 稍微簡化了一下 Babel 的輸出: if (!(this instanceof Person)) { throw new TypeError("Cannot call a class as a function"); } // Our code: this.name = name; } new Person('Fred'); // :white_check_mark: OK Person('George');// :red_circle: 無法把類當做函式來呼叫 複製程式碼
你或許已經在你構建出來的包中見過類似的程式碼,這就是那些_classCallCheck
函式做的事。(你可以通過啟用“loose mode”來關閉檢查以減小構建包的尺寸,但這或許會使你最終轉向真正的原生類時變得複雜)
至此,你應該已經大致理解了呼叫時加不加new
的差別:
new Person()
|
Person()
|
|
---|---|---|
class
|
:white_check_mark:this
是一個Person
例項 |
:red_circle:TypeError
|
function
|
:white_check_mark:this
是一個Person
例項 |
:flushed:this
是window
或undefined
|
這就是 React 正確呼叫你的元件很重要的原因。
如果你的元件被定義為一個類,React 需要使用new
來呼叫它
所以 React 能檢查出某樣東西是否是類嗎?
沒那麼容易!即便我們能夠ofollow,noindex">在 JavaScript 中區分類和函式 ,面對被 Babel 等工具處理過的類這還是沒用。對瀏覽器而言,它們只是不同的函式。這是 React 的不幸。
好,那 React 可以直接在每次呼叫時都加上new
嗎?很遺憾,這種方法並不總是有用。
對於常規函式,用new
呼叫會給它們一個this
作為物件例項。對於用作建構函式的函式(比如我們前面提到的Person
)是可取的,但對函式元件這或許就比較令人困惑了:
function Greeting() { // 我們並不期望 `this` 在這裡表示任何型別的例項 return <p>Hello</p>; } 複製程式碼
這暫且還能忍,還有兩個其他 理由會扼殺這個想法。
關於為什麼總是使用new
是沒用的的第一個理由是,對於原生的箭頭函式(不是那些被 Babel 編譯過的),用new
呼叫會丟擲一個錯誤:
const Greeting = () => <p>Hello</p>; new Greeting(); // :red_circle: Greeting 不是一個建構函式 複製程式碼
這個行為是遵循箭頭函式的設計而刻意為之的。箭頭函式的一個附帶作用是它沒有
自己的this
值 ——this
解析自離得最近的常規函式:
class Friends extends React.Component { render() { const friends = this.props.friends; return friends.map(friend => <Friend // `this` 解析自 `render` 方法 size={this.props.size} name={friend.name} key={friend.id} /> ); } } 複製程式碼
OK,所以 **箭頭函式沒有自己的this
。**但這意味著它作為建構函式是完全無用的!
const Person = (name) => { // :red_circle: 這麼寫是沒有意義的! this.name = name; } 複製程式碼
因此,
JavaScript 不允許用new
呼叫箭頭函式。
如果你這麼做,你或許已經犯了錯,最好早點告訴你。這和 JavaScript 不讓你不加
new
去呼叫一個類是類似的。
這樣很不錯,但這也讓我們的計劃受阻。React 不能簡單對所有東西都使用new
,因為會破壞箭頭函式!我們可以利用箭頭函式沒有prototype
的特點來檢測箭頭函式,不對它們使用new
:
(() => {}).prototype // undefined (function() {}).prototype // {constructor: f} 複製程式碼
但這對於被 Babel 編譯過的函式是沒用 的。這或許沒什麼大不了,但還有另一個原因使得這條路不會有結果。
另一個我們不能總是使用new
的原因是它會妨礙 React 支援返回字串或其它原始型別的元件。
function Greeting() { return 'Hello'; } Greeting(); // :white_check_mark: 'Hello' new Greeting(); // :flushed: Greeting {} 複製程式碼
這,再一次,和Operators%2Fnew" rel="nofollow,noindex">
new
操作符
的怪異設計有關。如我們之前所看到的,new
告訴 JavaScript 引擎去建立一個物件,讓這個物件成為函式內部的this
,然後把這個物件作為new
的結果給我們。
然而,JavaScript 也允許一個使用new
呼叫的函式返回另一個物件以覆蓋
new
的返回值。或許,這在我們利用諸如“物件池模式”來對元件進行復用時是被認為有用的:
// 建立了一個懶變數 zeroVector = null; function Vector(x, y) { if (x === 0 && y === 0) { if (zeroVector !== null) { // 複用同一個例項 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); // :astonished: b === c 複製程式碼
然而,如果一個函式的返回值不是
一個物件,它會被new
完全忽略
。如果你返回了一個字串或數字,就好像完全沒有return
一樣。
function Answer() { return 42; } Answer(); // :white_check_mark: 42 new Answer(); // :flushed: Answer {} 複製程式碼
當使用new
呼叫函式時,是沒辦法讀取原始型別(例如一個數字或字串)的返回值的。因此如果 React 總是使用new
,就沒辦法增加對返回字串的元件的支援!
這是不可接受的,因此我們必須妥協。
至此我們學到了什麼?React 在呼叫類(包括 Babel 輸出的)時需要用
new
,但在呼叫常規函式或箭頭函式時(包括 Babel 輸出的)不需要用
new
,並且沒有可靠的方法來區分這些情況。
如果我們沒法解決一個籠統的問題,我們能解決一個具體的嗎?
當你把一個元件定義為類,你很可能會想要擴充套件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 中的每一個物件都有一個“原型”。當我們寫fred.sayHi()
但fred
物件沒有sayHi
屬性,我們嘗試到fred
的原型上去找sayHi
屬性。要是我們在這兒找不到,就去找原型鏈的下一個原型 ——fred
的原型的原型,以此類推。
費解的是,一個類或函式的prototype
屬性並不
指向那個值的原型。我沒開玩笑。
function Person() {} console.log(Person.prototype); //不是 Person 的原型 console.log(Person.__proto__); // :flushed: Person 的原型 複製程式碼
因此“原型鏈”更像是__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'); // 設定 `fred.__proto__` 為 `Person.prototype` 複製程式碼
那個__proto__
鏈才是 JavaScript 用來查詢屬性的:
fred.sayHi(); // 1. fred 有 sayHi 屬性嗎?不。 // 2. fred.__proto__ 有 sayHi 屬性嗎?是的,呼叫它! fred.toString(); // 1. fred 有 toString 屬性嗎?不。 // 2. fred.__proto__ 有 toString 屬性嗎?不。 // 3. fred.__proto__.__proto__ 有 toString 屬性嗎?是的,呼叫它! 複製程式碼
在實戰中,你應該幾乎永遠不需要直接在程式碼裡動到__proto__
除非你在除錯和原型鏈相關的問題。如果你想讓某樣東西在fred.__proto__
上可用,你應該把它放在Person.prototype
,至少它最初是這麼設計的。
__proto__
屬性甚至一開始就不應該被瀏覽器暴露出來,因為原型鏈應該被視為一個內部概念,然而某些瀏覽器增加了__proto__
並最終勉強被標準化(但已被廢棄並推薦使用Object.getPrototypeOf()
)。
然而一個名叫“原型”的屬性卻給不了我一個值的“原型”這一點還是很讓我困惑(例如,fred.prototype
是未定義的,因為fred
不是一個函式)。個人觀點,我覺得這是即便有經驗的開發者也容易誤解 JavaScript 原型鏈的最大原因。
這篇部落格很長,是吧?已經到 80% 了,堅持住。
我們知道當說obj.foo
的時候,JavaScript 事實上會沿著obj
,obj.__proto__
,obj.__proto__.__proto__
等等一路尋找foo
。
在使用類時,你並非直接面對這一機制,但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.prototype console.log(c.__proto__.__proto__.__proto__); // Object.prototype c.render();// 在 c.__proto__ (Greeting.prototype) 上找到 c.setState();// 在 c.__proto__.__proto__ (React.Component.prototype) 上找到 c.toString();// 在 c.__proto__.__proto__.__proto__ (Object.prototype) 上找到 複製程式碼
換句話說,
當你在使用類的時候,例項的__proto__
鏈“映象”了類的層級結構:
// `extends` 鏈 Greeting → React.Component → Object (間接的) // `__proto__` 鏈 new Greeting() → Greeting.prototype → React.Component.prototype → Object.prototype 複製程式碼
2 條鏈。
既然__proto__
鏈映象了類的層級結構,我們可以檢查一個Greeting
是否擴充套件了React.Component
,我們從Greeting.prototype
開始,一路沿著__proto__
鏈:
// `__proto__` chain new Greeting() → Greeting.prototype //️ 我們從這兒開始 → React.Component.prototype // :white_check_mark: 找到了! → Object.prototype 複製程式碼
方便的是,x instanceof Y
做的就是這類搜尋。它沿著x.__proto__
鏈尋找Y.prototype
是否在那兒。
通常,這被用來判斷某樣東西是否是一個類的例項:
let greeting = new Greeting(); console.log(greeting instanceof Greeting); // true // greeting (️ 我們從這兒開始) //.__proto__ → Greeting.prototype (:white_check_mark: 找到了!) //.__proto__ → React.Component.prototype //.__proto__ → Object.prototype console.log(greeting instanceof React.Component); // true // greeting (️ 我們從這兒開始) //.__proto__ → Greeting.prototype //.__proto__ → React.Component.prototype (:white_check_mark: 找到了!) //.__proto__ → Object.prototype console.log(greeting instanceof Object); // true // greeting (️ 我們從這兒開始) //.__proto__ → Greeting.prototype //.__proto__ → React.Component.prototype //.__proto__ → Object.prototype (:white_check_mark: 找到了!) console.log(greeting instanceof Banana); // false // greeting (️ 我們從這兒開始) //.__proto__ → Greeting.prototype //.__proto__ → React.Component.prototype //.__proto__ → Object.prototype (:no_good: 沒找到!) 複製程式碼
但這用來判斷一個類是否擴充套件了另一個類還是有效的
console.log(Greeting.prototype instanceof React.Component); // greeting //.__proto__ → Greeting.prototype (️ 我們從這兒開始) //.__proto__ → React.Component.prototype (:white_check_mark: 找到了!) //.__proto__ → Object.prototype 複製程式碼
這種檢查方式就是我們判斷某樣東西是一個 React 元件類還是一個常規函式的方式。
然而 React 並不是這麼做的 :flushed:
關於instanceof
解決方案有一點附加說明,當頁面上有多個 React 副本,並且我們要檢查的元件繼承自另一個
React 副本的React.Component
時,這種方法是無效的。在一個專案裡混合多個 React 副本是不好的,原因有很多,但站在歷史角度來看,我們試圖儘可能避免問題。(有了 Hooks,我們或許得
強制避免重複)
另一點啟發可以是去檢查原型鏈上的render
方法。然而,當時還不確定
元件的 API 會如何演化。每一次檢查都有成本,所以我們不想再多加了。如果render
被定義為一個例項方法,例如使用類屬性語法,這個方法也會失效。
因此, React 為基類增加了 一個特別的標記。React 檢查是否有這個標記,以此知道某樣東西是否是一個 React 元件類。
最初這個標記是在React.Component
這個基類自己身上:
// React 內部 class Component {} Component.isReactClass = {}; // 我們可以像這樣檢查它 class Greeting extends Component {} console.log(Greeting.isReactClass); // :white_check_mark: 是的 複製程式碼
然而,有些我們希望作為目標的類實現並沒有
複製靜態屬性(或設定非標準的__proto__
),標記也因此丟失。
這也是為什麼 React 把這個標記移動到了
React.Component.prototype
:
// React 內部 class Component {} Component.prototype.isReactComponent = {}; // 我們可以像這樣檢查它 class Greeting extends Component {} console.log(Greeting.prototype.isReactComponent); // :white_check_mark: 是的 複製程式碼
說真的這就是全部了。
你或許奇怪為什麼是一個物件而不是一個布林值。實戰中這並不重要,但早期版本的 Jest(在 Jest 商品化之前)是預設開始自動模擬功能的,生成的模擬資料省略掉了原始型別屬性,破壞了檢查 。謝了,Jest。
一直到今天,React 都在用
isReactComponent
進行檢查。
如果你不擴充套件React.Component
,React 不會在原型上找到isReactComponent
,因此就不會把元件當做類處理。現在你知道為什麼解決Cannot call a class as a function
錯誤的得票數最高的答案 是增加extends React.Component
。最後,我們還增加了一項警告
,當prototype.render
存在但prototype.isReactComponent
不存在時會發出警告。
你或許會覺得這個故事有一點“標題黨”。實際的解決方案其實真的很簡單,但我花了大量的篇幅在轉折上來解釋為什麼 React 最終選擇了這套方案,以及還有哪些候選方案。
以我的經驗來看,設計一個庫的 API 也經常會遇到這種情況。為了一個 API 能夠簡單易用,你經常需要考慮語義化(可能的話,為多種語言考慮,包括未來的發展方向)、執行時效能、有或沒有編譯時步驟的工程效能、生態的狀態以及打包方案、早期的警告,以及很多其它問題。最終的結果未必總是最優雅的,但必須要是可用的。
如果最終的 API 成功的話,它的使用者 永遠不必思考這一過程。他們只需要專心建立應用就好了。
但如果你同時也很好奇...知道它是怎麼工作的也是極好的。
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為掘金 上的英文分享文章。內容覆蓋Android 、iOS 、前端 、後端 、區塊鏈 、產品 、設計 、人工智慧 等領域,想要檢視更多優質譯文請持續關注掘金翻譯計劃 、官方微博、知乎專欄 。