事件
转载于 Github https://github.com/veedrin/horseshoe 作者博客 博客入口 https://veedrin.com/
用户需要与 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;