1. 程式人生 > >[譯]React如何區別class和function

[譯]React如何區別class和function

undefined 們的 tac set 函數聲明 null cte 無法 won

原文 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 ?thisPerson實例 ??TypeError
function ?thisPerson實例 ??this指向windowundefined

這就是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實際上去尋找objfoo,找不到再去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