React專題:事件

weixin_34166847發表於2018-09-04

本文是『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專題一覽

什麼是UI

JSX

可變狀態

不可變屬性

生命週期

元件

事件

操作DOM

抽象UI

相關文章