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

WashingtonHua發表於2019-02-27

讓我們來看一下這個以函式形式定義的 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 本身的資訊,但我們會涉及到 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章