本文是『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专题一览