本文是『horseshoe·React專題』系列文章之一,後續會有更多專題推出
來我的 GitHub repo 閱讀完整的專題文章
來我的 個人部落格 獲得無與倫比的閱讀體驗
使用者需要與UI產生互動,所以UI需要一個反應機制,使用者執行特定操作,就觸發特定的回撥函式,開發者再把這個機制掛載到DOM元素上。
DOM事件開發者再熟悉不過了,沒了它頁面就是死的。
那麼React的事件機制有什麼特殊嗎?
不誇張的說,React是一個UI虛擬機器一樣的存在,在被掛載到頁面上之前,UI在React的全權掌控下。React會幹出什麼來誰也說不準。
讓我們來看看React對DOM事件機制做了什麼手腳。
事件委託
事件委託我們都知道,因為有冒泡機制,開發者可以在父級元素監聽事件,通過邏輯判斷使得只有子元素的事件才會觸發監聽回撥,這樣就實現了子元素的事件監聽委託給父元素。
在前端刀耕火種時期,事件委託解決了兩個痛點。
- 處理龐大的列表時,無需為每個列表項繫結事件監聽。
- 動態掛載的元素無需作額外的事件監聽處理。
可以看到,事件委託的紅利主要是效能提升,大量重複的事件監聽可以交由一個事件監聽統一分發。
這樣的好處,React會不要?
不過,React做的更徹底。
一個React應用只有一個事件監聽器,這個監聽器掛載在document
上。你沒聽錯,就是這麼粗暴。所有的事件都由這個監聽器統一分發。
元件掛載和更新時,會將繫結的事件分門別類的放進一個叫做EventPluginHub
的事件池裡。事件觸發時,根據事件產生的Event
物件找到觸發事件的元件,再通過元件標識和事件型別從事件池裡找到對應的事件監聽回撥,然後就是打個響指。
原生DOM事件系統會為每個事件生成一個Event
物件,你去列印出來看看,這玩意有多少屬性。所以React一不做二不休,基於Event
物件建立了一個合成事件物件。它能解決什麼問題呢?
- 它能實現跨瀏覽器的表現一致性,因為React做了很多相容性的處理。相容性問題是前端的毒瘤啊。
- 如果事件多次觸發,合成事件物件會被複用,提高效能。
一般來說,當元素被解除安裝,元素繫結的事件監聽器也要清除。要不然JavaScript放個removeEventListener
介面出來幹什麼?
因為React實現了對事件的統一管理,所以這些髒活累活都自動幫你幹了,你不需要手動清除JSX上繫結的事件監聽器。這同時也可以提高效能,因為開發者多半會忘記清除。當然原生事件React就無能為力了。
說到原生事件,React合成事件與原生事件是什麼關係呢?
答案是沒有關係,互不干擾。
以下例子,即便阻止了冒泡,點選按鈕依然會同時觸發document事件。放心,不是相容性的問題。合成事件擁有獨立的冒泡機制,它只能阻止頂層的合成事件。
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<button onClick={this.handleClick}>Click</button>
);
}
componentDidMount() {
document.addEventListener('click', (event) => console.log(event));
}
handleClick = (event) => {
event.stopPropagation();
console.log(event);
}
}
export default App;
複製程式碼
React知道你事多,所以在合成事件物件下面儲存了原生事件物件nativeEvent
,以備不時之需。
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<button onClick={this.handleClick}>Click</button>
);
}
componentDidMount() {
document.addEventListener('click', (event) => console.log(event));
}
handleClick = (event) => {
event.nativeEvent.stopPropagation();
console.log(event);
}
}
export default App;
複製程式碼
哈?你說還是不行?
莫不是你計算機壞了,聽我一句勸,砸了吧。
別慌別慌,這裡還有一個知識點:
原生事件物件裡除了stopPropagation
之外還有stopImmediatePropagation
(是不是從來沒用過),有什麼區別?
stopImmediatePropagation
不僅會阻止頂層事件的冒泡,連自身元素繫結的其他事件也會阻止。因為同一個元素可以繫結多個事件,而事件觸發順序是根據繫結順序來的,只要使用了這個方法,它之後繫結的兄弟事件也別想蹦躂了。
那這跟React有什麼關係呢?
你忘了?上面講到,React有一套合成事件機制,所有事件都由document統一分發。
所以呀,別看這倆一個在button上,一個在document上,其實它們都是在document上觸發的。
這下理解了為什麼要用stopImmediatePropagation
阻止冒泡了吧,它們是曹丕和曹植啊。
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<button onClick={this.handleClick}>Click</button>
);
}
componentDidMount() {
document.addEventListener('click', (event) => console.log(event));
}
handleClick = (event) => {
event.nativeEvent.stopImmediatePropagation();
console.log(event);
}
}
export default App;
複製程式碼
不信再看一個衍生例子。
這回stopImmediatePropagation
不僅不能阻止body事件,body事件還會先於button觸發。鐵證,React所有事件都是由document統一分發的。
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<button onClick={this.handleClick}>Click</button>
);
}
componentDidMount() {
document.body.addEventListener('click', (event) => console.log(event));
}
handleClick = (event) => {
event.nativeEvent.stopImmediatePropagation();
console.log(event);
}
}
export default App;
複製程式碼
合成事件的非同步處理
先來看例子,大家覺得最終state裡的value是什麼?
答案是程式崩潰。
別看了,沒有語法錯誤。
報錯資訊裡說Cannot read property 'value' of null
,說明target為空,問題是target怎麼會為空呢?
癥結就在於React追求極致的效能。在合成事件機制裡,一旦事件監聽回撥被執行,合成事件物件就會被銷燬,而setState的回撥是非同步的,等它執行的時候合成事件物件早就被銷燬了。這就是target為空的原因。
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<button onClick={this.handleClick}>Click</button>
);
}
handleClick = (event) => {
this.setState((prevState) => ({ value: event.target.value }));
}
}
export default App;
複製程式碼
如果實在有這樣的需求,React也有錦囊妙計:event.persist()
。
這就是告訴React,你別回收了,我還要拿去釣妹子呢。
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<button onClick={this.handleClick}>Click</button>
);
}
handleClick = (event) => {
event.persist();
this.setState((prevState) => ({ value: event.target.value }));
}
}
export default App;
複製程式碼
我們再來看一種情況。
臥槽,怎麼又可以了?我啥也沒跟React說呀。
我們都說setState是非同步(或者說批量更新)的,那是說渲染非同步,而賦值給value是同步的。
所以這個時候value是有值的。
那為什麼回撥形式的setState得不到值呢?回撥嘛,你想嘛,是同步還是非同步。
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<button onClick={this.handleClick}>Click</button>
);
}
handleClick = (event) => {
this.setState({ value: event.target.value });
}
}
export default App;
複製程式碼
繫結this
鬼知道JavaScript裡的this幹倒了多少人。
其實,要弄清楚this的指向,只要找到呼叫者就行了。呼叫者,就是this的題眼。
為什麼例子中的函式在非嚴格模式下指向window,而在嚴格模式下指向undefined呢?
因為在JavaScript刀耕火種時代,window既是視窗物件,也是全域性物件。而所有的全域性變數(包括函式)都掛載在window下面。
非嚴格模式下這個函式的呼叫者就是window。
後面人們覺得這樣太八路軍了,甚至有人覺得這是JavaScript最大的設計失誤。所以之後的嚴格模式、class類和ESModule的全域性變數都不再掛載到window上,反正能找補一點是一點。
所以嚴格模式下這個函式沒有呼叫者,或者叫神之呼叫,所以this指向undefined。
function something() {
console.log(this);
}
複製程式碼
科普了一下this,我們來看看this在React中有什麼么蛾子。
(假裝)我們都知道,下面例子的this列印出來是undefined。
關鍵是為什麼?
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<button onClick={this.handleClick}>Click</button>
);
}
handleClick() {
console.log(this);
}
}
export default App;
複製程式碼
先看一個別的例子。
obj.something是一個函式,action也是一個函式,區別在於呼叫者。一個函式一旦被重新賦值,它的呼叫者就可能發生變化。
const obj = {
something: function() {
console.log(this);
},
};
obj.something(); // 列印obj
const action = obj.something;
action(); // (假設嚴格模式)列印undefined
複製程式碼
再回到之前的例子,關鍵在這一句onClick={this.handleClick}
,注意回撥已經被重新賦值了,不管將來它的呼叫者是誰,這時它已經和元件例項無關了。
然後,我們要知道,React會把同一型別的事件push到一個佇列裡,一旦document監聽到這類事件,就會依次執行佇列裡的回撥,直到冒泡被阻止。
就像這樣[handleDivClick, handleButtonClick]
,想象一下被這樣處理之後,執行時呼叫者是誰。
這就是上面例子列印出來是undefined的原因。
其實React早期版本,程式會自動繫結this到元件例項,但是有人覺得這樣會使部分開發者以為this指向元件例項就是理所應當的,所以取消了這一操作。
於是呢,開發者要手動繫結this。我們來看看繫結this的花樣。
在JSX裡面直接繫結this
簡單粗暴對吧。再怎麼狸貓換太子,我都綁的死死的。
不過呢,bind的效能是堪憂的。而且你發現沒有,每一次重新render都會重新bind一次。
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<button onClick={this.handleClick.bind(this)}>Click</button>
);
}
handleClick() {
console.log(this);
}
}
export default App;
複製程式碼
包裹一層箭頭函式
箭頭函式會繼承父作用域的this,這裡的父作用域當然就是元件例項。
可是得額外包裹一層箭頭函式,而且每次觸發事件都會生成一個箭頭函式。
當然事件需要傳參的時候沒的說,必須得包裹一層箭頭函式。
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<button onClick={() => this.handleClick()}>Click</button>
);
}
handleClick() {
console.log(this);
}
}
export default App;
複製程式碼
在建構函式裡手動繫結
這也是React官方推薦的寫法。
此寫法的意思是:把一個繫結了this的回撥賦值給例項的屬性。
缺點是如果事件比較多,建構函式裡會有點擁擠。
而且往深層處想,這個回撥被掛載在了原型上,同時也被掛載在了例項上。重複掛載。
import React, { Component } from 'react';
class App extends Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
render() {
return (
<button onClick={this.handleClick}>Click</button>
);
}
handleClick() {
console.log(this);
}
}
export default App;
複製程式碼
回撥直接寫在例項上
這種寫法叫做屬性初始化器(Property Initializers)。目前還不是JavaScript正式的語法,不過babel可以提前讓開發者使用。
首先說明,該寫法的關鍵不是直接寫在例項上,而是箭頭函式。因為箭頭函式會繼承父作用域的this,所以回撥中的this指向元件例項。
不信你把箭頭函式改成匿名函式試試。
那我能不能把箭頭函式寫在原型上呢?你甭管我用什麼辦法。
也是可以的,只是有點麻煩。
屬性初始化器的寫法不會將回撥重複掛載,不需要重複繫結,語法也相當優雅。
等成為了JavaScript正式的語法,React官方一定會推薦這種寫法的。
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<button onClick={this.handleClick}>Click</button>
);
}
handleClick = () => {
console.log(this);
}
}
export default App;
複製程式碼
React專題一覽