[譯] React 是如何區分 Class 和 Function 的 ?

讓咱們來看一下這個以函數形式定義的 Greeting 組件:html

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

React 也支持將他定義成一個類:前端

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

(直到 最近,這是使用 state 特性的惟一方式)react

當你要渲染一個 <Greeting /> 組件時,你並不須要關心它是如何定義的:android

// 是類仍是函數 —— 無所謂
<Greeting />
複製代碼

React 自己在乎其中的差異!ios

若是 Greeting 是一個函數,React 須要調用它。git

// 你的代碼
function Greeting() {
  return <p>Hello</p>;
}

// React 內部
const result = Greeting(props); // <p>Hello</p>
複製代碼

但若是 Greeting 是一個類,React 須要先用 new 操做符將其實例化,而後 調用剛纔生成實例的 render 方法:github

// 你的代碼
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 自己的信息,但咱們會涉及到 newthisclass、箭頭函數、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'); // ✅ Person {name: 'Fred'}
var george = Person('George'); // 🔴 沒用的
複製代碼

如今你依然能夠這樣寫!在 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();
複製代碼

捕捉開發者的本意 是語言和 API 設計中很是重要的一點。

若是你寫了一個函數,JavaScript 沒辦法判斷它應該像 alert() 同樣被調用,仍是應該被視做像 new Person() 同樣的構造函數。忘記給像 Person 這樣的函數指定 new 會致使使人費解的行爲。

類語法容許咱們說:「這不只僅是個函數 —— 這是個類而且它有構造函數」。 若是你在調用它時忘了加 new,JavaScript 會報錯:

let fred = new Person('Fred');
// ✅  若是 Person 是個函數:有效
// ✅  若是 Person 是個類:依然有效

let george = Person('George'); // 咱們忘記使用 `new`
// 😳 若是 Person 是個長得像構造函數的方法:使人困惑的行爲
// 🔴 若是 Person 是個類:當即失敗
複製代碼

這能夠幫助咱們在早期捕捉錯誤,而不會遇到相似 this.name 被當成 window.name 對待而不是 george.name 的隱晦錯誤。

然而,這意味着 React 須要在調用全部類以前加上 new,而不能把它直接當作一個常規的函數去調用,由於 JavaScript 會把它當作一個錯誤對待!

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

// 🔴 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");
  }
  // 咱們的代碼:
  this.name = name;
}

new Person('Fred'); // ✅ OK
Person('George');   // 🔴 沒法把類當作函數來調用
複製代碼

你或許已經在你構建出來的包中見過相似的代碼,這就是那些 _classCallCheck 函數作的事。(你能夠經過啓用「loose mode」來關閉檢查以減少構建包的尺寸,但這或許會使你最終轉向真正的原生類時變得複雜)


至此,你應該已經大體理解了調用時加不加 new 的差異:

new Person() Person()
class this 是一個 Person 實例 🔴 TypeError
function this 是一個 Person 實例 😳 thiswindowundefined

這就是 React 正確調用你的組件很重要的緣由。 若是你的組件被定義爲一個類,React 須要使用 new 來調用它

因此 React 能檢查出某樣東西是不是類嗎?

沒那麼容易!即使咱們可以 在 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(); // 🔴 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) => {
  // 🔴 這麼寫是沒有意義的!
  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(); // ✅ 'Hello'
new Greeting(); // 😳 Greeting {}
複製代碼

這,再一次,和 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); // 😲 b === c
複製代碼

然而,若是一個函數的返回值 不是 一個對象,它會被 new 徹底忽略。若是你返回了一個字符串或數字,就好像徹底沒有 return 同樣。

function Answer() {
  return 42;
}

Answer(); // ✅ 42
new Answer(); // 😳 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__); // 😳 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 // ✅ 找到了!
      → Object.prototype
複製代碼

方便的是,x instanceof Y 作的就是這類搜索。它沿着 x.__proto__ 鏈尋找 Y.prototype 是否在那兒。

一般,這被用來判斷某樣東西是不是一個類的實例:

let greeting = new Greeting();

console.log(greeting instanceof Greeting); // true
// greeting (🕵️‍ 咱們從這兒開始)
//   .__proto__ → Greeting.prototype (✅ 找到了!)
//     .__proto__ → React.Component.prototype
//       .__proto__ → Object.prototype

console.log(greeting instanceof React.Component); // true
// greeting (🕵️‍ 咱們從這兒開始)
//   .__proto__ → Greeting.prototype
//     .__proto__ → React.Component.prototype (✅ 找到了!)
//       .__proto__ → Object.prototype

console.log(greeting instanceof Object); // true
// greeting (🕵️‍ 咱們從這兒開始)
//   .__proto__ → Greeting.prototype
//     .__proto__ → React.Component.prototype
//       .__proto__ → Object.prototype (✅ 找到了!)

console.log(greeting instanceof Banana); // false
// greeting (🕵️‍ 咱們從這兒開始)
//   .__proto__ → Greeting.prototype
//     .__proto__ → React.Component.prototype
//       .__proto__ → Object.prototype (🙅‍ 沒找到!)
複製代碼

但這用來判斷一個類是否擴展了另外一個類仍是有效的

console.log(Greeting.prototype instanceof React.Component);
// greeting
//   .__proto__ → Greeting.prototype (🕵️‍ 咱們從這兒開始)
//     .__proto__ → React.Component.prototype (✅ 找到了!)
//       .__proto__ → Object.prototype
複製代碼

這種檢查方式就是咱們判斷某樣東西是一個 React 組件類仍是一個常規函數的方式。


然而 React 並非這麼作的 😳

關於 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); // ✅ 是的
複製代碼

然而,有些咱們但願做爲目標的類實現 並無 複製靜態屬性(或設置非標準的 __proto__),標記也所以丟失。

這也是爲何 React 把這個標記 移動到了 React.Component.prototype

// React 內部
class Component {}
Component.prototype.isReactComponent = {};

// 咱們能夠像這樣檢查它
class Greeting extends Component {}
console.log(Greeting.prototype.isReactComponent); // ✅ 是的
複製代碼

說真的這就是所有了。

你或許奇怪爲何是一個對象而不是一個布爾值。實戰中這並不重要,但早期版本的 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 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索