前言
本文中通過探討這個問題,涉及到了JavaScript中大量的重要概念像原型、原型鏈、this、類、繼承等,通過思考這個問題對這些知識進行一個回顧,不失為一個好的學習方法,但如果你只是想知道這個問題的答案,就像作者說的那樣,直接滾動到底部吧。
限於本人水平有限,翻譯不到位的地方,敬請諒解。
正文
在React中我們可以用函式定義一個元件:
function Greeting() {
return <p>Hello</p>;
}
複製程式碼
同樣可以使用Class定義一個元件:
class Greeting extends React.Component {
render() {
return <p>Hello</p>;
}
}
複製程式碼
在React推出Hooks之前,Class定義的元件是使用像state這樣的功能的唯一方式。
當你想渲染的時候,你不需要關心它是怎樣定義的:
// Class or function — whatever.
<Greeting />
複製程式碼
但是React會關心這些不同。
如果Greeting
是一個函式,React需要像下面這樣呼叫:
// Your code
function Greeting() {
return <p>Hello</p>;
}
// Inside React
const result = Greeting(props); // <p>Hello</p>
複製程式碼
但是如果Greeting
是一個類,React需要用new
命令建立一個例項,然後呼叫建立的例項的render
方法:
// Your code
class Greeting extends React.Component {
render() {
return <p>Hello</p>;
}
}
// Inside React
const instance = new Greeting(props); // Greeting {}
const result = instance.render(); // <p>Hello</p>
複製程式碼
那麼React是怎麼分辨class
或者function
的呢?
這會是一個比較長的探索之旅,這篇文章不會過多的討論React,我們將探索new,this,class,箭頭函式,prototype,__proto__,instanceof
的某些方面以及它們是怎麼在JavaScript中一起工作的。
首先,我們需要理解為什麼區分functions和class之間不同是如此重要,注意怎樣使用new
命令去呼叫一個class:
// If Greeting is a function
const result = Greeting(props); // <p>Hello</p>
// If Greeting is a class
const instance = new Greeting(props); // Greeting {}
const result = instance.render(); // <p>Hello</p>
複製程式碼
下面讓我們一起來了解下new
命令在JavaScript中究竟是幹什麼的。
之前JavaScript並沒有Class,但是你能用一個正常的函式去模擬Class。具體地說,你可以使用任何通過new呼叫的函式去模擬class的建構函式:
// 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命令呼叫函式,相當於我們說:“JavaScript,你好,我知道Person
僅僅只是一個普通函式但是讓我們假設它就是類的一個建構函式。建立一個{}
物件然後傳入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新增Class(類)之前是怎樣模擬Class(類)的。
如果你定義了一個函式,JavaScript是不能確定你會像alert()
一樣直接呼叫或者作為一個建構函式像new Person()
。忘了使用new命令去呼叫像Person
這樣的函式將會導致一些令人困惑的行為。
Class(類)的語法相當於告訴我們:“這不僅僅是一個函式,它是一個有建構函式的類”。如果你在呼叫Class(類)的時候,忘了加new命令,JavaScript將會丟擲一個錯誤:
et 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
複製程式碼
這將幫助我們及早的發現錯誤,而不是等到出現明顯的bug的時候才知道,像this.name
變成了window.name
而不是george.name
。
不管怎樣,這意味著React需要使用new命令去呼叫所有的類,它不能像呼叫正常函式一樣去呼叫類,如果這樣做了,JavaScript會報錯的!
class Counter extends React.Component {
render() {
return <p>Hello</p>;
}
}
// ? React can't just do this:
const instance = Counter(props);
複製程式碼
上面是錯誤的寫法。
在我們講React是怎麼解決的之前,我們要知道大多數人會使用Babel去編譯React專案,目的是為了讓專案中使用的最新特性像class(類)能夠相容低端的瀏覽器,這樣我們就需要了解的Babel的編譯機制。
在Babel早期的版本中,class(類)可以使用new命令呼叫。但是它通過一些額外的程式碼去修復這個問題:
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
複製程式碼
你可能有在打包出來的檔案中看到過上面的程式碼,這就是_classCallCheck
所做的事情。
到目前為止,你應該已經大概掌握了使用new命令和不使用new命令之間的差別:
這就是為什麼React需要正確呼叫元件是如此重要的原因。如果你使用class(類)定義一個元件,React需要使用new命令去呼叫。
那麼React能判斷出一個元件是否是由class(類)定義的呢?
沒那麼容易,即使我們能分辨出函式和class(類):
function isClass(func) {
return typeof func === 'function'
&& /^class\s/.test(Function.prototype.toString.call(func));
}
複製程式碼
但如果我們使用了像Babel這樣的編譯工具,上面的方法是不會起作用的,Babel會將class(類)編譯為:
// 類
class Person {
}
// Babel編譯後
"use strict";
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
var Person = function Person() {
_classCallCheck(this, Person);
};
複製程式碼
對於瀏覽器來說,它們都是普通的函式。
ok,React裡面的函式能不能都使用new命令呼叫呢?答案是不能。
用new命令呼叫普通函式的時候,會傳入一個物件例項作為this
,像上面的Person
那樣將函式作為建構函式來使用是可以的,但是對於函式式的元件卻會讓人懵逼的:
function Greeting() {
// We wouldn’t expect `this` to be any kind of instance here
return <p>Hello</p>;
}
複製程式碼
即使你能這樣寫,下面的兩個原因會杜絕你的這種想法。
第一個原因:使用new命令呼叫箭頭函式(未經Babel編譯過)會報錯
const Greeting = () => <p>Hello</p>;
new Greeting(); // ? Greeting is not a constructor
複製程式碼
這樣的報錯是故意的並且遵從箭頭函式的設計。箭頭函式的一大特點是它沒有自己的this
,this
繫結的是定義的時候繫結的,指向父執行上下文:
class Friends extends React.Component {
render() {
const friends = this.props.friends;
return friends.map(friend =>
<Friend
// `this` is resolved from the `render` method
size={this.props.size}
name={friend.name}
key={friend.id}
/>
);
}
}
複製程式碼
Tips:
如果不太理解的童鞋,可以參考下面的文章
ok,箭頭函式沒有自己的this
,這就意味著它不能作為建構函式:
const Person = (name) => {
// ? This wouldn’t make sense!
this.name = name;
}
複製程式碼
因此,JavaScript不能使用new命令呼叫箭頭函式,如果你這樣做了,程式就會報錯,和你不用new命令去呼叫class(類)一樣。
這是非常好的,但是不利於我們的計劃,因為箭頭函式的存在,React不能只用new命令去呼叫,當然我們也能試著去通過箭頭函式沒有prototype
去區分它們,然後不用new命令呼叫:
(() => {}).prototype // undefined
(function() {}).prototype // {constructor: f}
複製程式碼
但是如果你的專案中使用了Babel,這也不是個好主意,還有另一個原因使這條路徹底走不通。
這個原因是使用new命令呼叫React中的函式式元件,會獲取不到這些函式式元件返回的字串或者其他基本資料型別。
function Greeting() {
return 'Hello';
}
Greeting(); // ✅ 'Hello'
new Greeting(); // ? Greeting {}
複製程式碼
關於這點,我們需要知道new命令到底幹了什麼?
通過new操作符呼叫建構函式,會經歷以下4個階段
- 建立一個新的物件;
- 將建構函式的this指向這個新物件;
- 指向建構函式的程式碼,為這個物件新增屬性,方法等;
- 返回新物件。
關於這些內容在全方位解讀this-這波能反殺有更為詳細的解釋。
如果React只使用new命令呼叫函式或者類,那麼就無法支援返回字串或者其他原始資料型別的元件,這肯定是不能接受的。
到目前為止,我們知道了,React需要去使用new命令呼叫class(包括經過Babel編譯的),不使用new命令呼叫正常函式和箭頭函式,這仍沒有一個可行的方法去區分它們。
當你使用class(類)宣告一個元件,你肯定想繼承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中每一個物件都有一個“prototype(原型)”。
下面的示例和圖來源於前端基礎進階(九):詳解物件導向、建構函式、原型與原型鏈,個人覺得比原文示例更能說明問題
// 宣告建構函式
function Person(name, age) {
this.name = name;
this.age = age;
}
// 通過prototye屬性,將方法掛載到原型物件上
Person.prototype.getName = function() {
return this.name;
}
var p1 = new Person('tim', 10);
var p2 = new Person('jak', 22);
console.log(p1.getName === p2.getName); // true
複製程式碼
當我們想要呼叫p1上的getName方法時,但是p1自身並沒有這個方法,它會在p1的原型上尋找,如果沒有找到我們會沿著原型鏈在上一層的原型上繼續找,也就是在p1的原型的原型...,一直找下去,直到原型鏈的終極null。
原型鏈更像__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'); // 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__
。如果你想往原型上新增一些東西,你應該新增到Person.prototype
上,那新增到__proto___
可以嗎?當然可以,能生效,但是這樣不符合規範的,有效能問題和相容性問題,詳情點選這裡。
早期的瀏覽器是沒有暴露__proto
屬性的,因為原型類是一個內部的概念,後來一些瀏覽器逐漸支援,在ECMAScript2015規範中被標準化了,想要獲取某個物件的原型,建議老老實實的使用Object.getPrototypeOf()
。
我們現在已經知道了,當訪問obj.foo
的時候,JavaScript通常在obj
中這樣尋找foo
,obj.__proto__,obj.__proto__.__proto__
...
定義一個類元件,你可能看不到原型鏈這套機制,但是extends(繼承)
只是原型鏈的語法糖,React的類元件就是這樣訪問到React.Component
中像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(); // 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)
複製程式碼
換句話說,當你使用類的時候,一個例項的原型鏈對映這個類的層級
// `extends` chain
Greeting
→ React.Component
→ Object (implicitly)
// `__proto__` chain
new Greeting()
→ Greeting.prototype
→ React.Component.prototype
→ Object.prototype
複製程式碼
因為原型鏈對映類的層級,那我們就能從一個繼承自React.Component
的元件Greeting
的Greeting.prototype
開始,順著原型鏈往下找:
// `__proto__` chain
new Greeting()
→ Greeting.prototype // ?️ We start here
→ React.Component.prototype // ✅ Found it!
→ Object.prototype
複製程式碼
實際上,x instanceof y
就是做的這種查詢,它沿著x的原型鏈查詢y的原型。
通常這用來確定某個例項是否是一個類的例項:
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並沒有這樣做。
作者此處還探討了兩種方案,在此略去,有興趣看原文喲。
實際上React對基礎的元件也就是React.Component
新增了一個標記,並通過這個標記來區分一個元件是否是一個類元件。
// Inside React
class Component {}
Component.isReactClass = {};
// We can check it like this
class Greeting extends Component {}
console.log(Greeting.isReactClass); // ✅ Yes
複製程式碼
像上面這樣把標記直接新增到基礎元件自身,有時候會出現靜態屬性丟失的情況,所以我們應該把標記新增到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
複製程式碼
React就是這樣解決的。
後面還有幾段,參考文末另一位大兄弟的譯文吧。
後續
這文章有點長,涉及的知識點也比較多,最後的解決方案,看似挺簡單的,實際上走到這一步並不簡單,希望大家都有所收穫。 翻譯到一半的時候,在React的一個Issues中發現另一個人這篇文章的譯文,有興趣的童鞋,可以點選閱讀。