Hooks与事件绑定
Hooks与事件绑定
在React中,我们经常需要为组件添加事件处理函数,例如处理表单提交、处理点击事件等。通常情况下,我们需要在类组件中使用this关键字来绑定事件处理函数的上下文,以便在函数中使用组件的实例属性和方法。React Hooks是React 16.8引入的一个新特性,其出现让React的函数组件也能够拥有状态和生命周期方法。Hooks的优势在于可以让我们在不编写类组件的情况下,复用状态逻辑和副作用代码,Hooks的一个常见用途是处理事件绑定。
描述
在React中使用类组件时,我们可能会被大量的this所困扰,例如this.props、this.state以及调用类中的函数等。此外,在定义事件处理函数时,通常需要使用bind方法来绑定函数的上下文,以确保在函数中可以正确地访问组件实例的属性和方法,虽然我们可以使用箭头函数来减少bind,但是还是使用this语法还是没跑了。
那么在使用Hooks的时候,可以避免使用类组件中的this关键字,因为Hooks是以函数的形式来组织组件逻辑的,我们通常只需要定义一个普通函数组件,并在函数组件中使用useState、useEffect等Hooks来管理组件状态和副作用,在处理事件绑定的时候,我们也只需要将定义的事件处理函数传入JSX就好了,也不需要this也不需要bind。
那么问题来了,这个问题真的这么简单吗,我们经常会听到类似于Hooks的心智负担很重的问题,从我们当前要讨论的事件绑定的角度上,那么心智负担就主要表现在useEffect和useCallback以及依赖数组上。其实类比来看,类组件类似于引入了this和bind的心智负担,而Hooks解决了类组件的心智负担,又引入了新的心智负担,但是其实换个角度来看,所谓的心智负担也只是需要接受的新知识而已,我们需要了解React推出新的设计,新的组件模型,当我们掌握了之后那就不会再被称为心智负担了,而应该叫做语法,当然其实叫做负担也不是没有道理的,因为很容易在不小心的情况下出现隐患。那么接下来我们就来讨论下Hooks与事件绑定的相关问题,所有示例代码都在https://codesandbox.io/s/react-ts-template-forked-z8o7sv。
事件绑定
使用Hooks进行普通的合成事件绑定是一件很轻松的事情,在这个例子中,我们使用了普通的合成事件onClick来监听按钮的点击事件,并在点击时调用了add函数来更新count状态变量的值,这样每次点击按钮时,count就会加1。
// https://codesandbox.io/s/hooks-event-z8o7sv
import { useState } from "react";
export const CounterNormal: React.FC = () => {
const [count, setCount] = useState(0);
const add = () => {
setCount(count + 1);
};
return (
<div>
{count}
<div>
<button onClick={add}>count++</button>
</div>
</div>
);
};
这个例子看起来非常简单,我们就不再过多解释了,其实从另一个角度想一下,这不是很类似于原生的DOM0事件流模型,每个对象只能绑定一个DOM事件的话,就不需要像DOM2事件流模型一样还得保持原来的处理函数引用才能进行卸载操作,否则是卸载不了的,如果不能保持引用的地址是相同的,那就会造成无限的绑定,进而造成内存泄漏,如果是DOM0的话,我们只需要覆盖即可,而不需要去保持之前的函数引用。实际上我们接下来要说的一些心智负担,就与引用地址息息相关。
另外有一点我们需要明确一下,当我们点击了这个count按钮,React帮我们做了什么。其实对于当前这个<CounterNormal />组件而言,当我们点击了按钮,那么肯定就是需要刷新视图,React的策略是会重新执行这个函数,由此来获得返回的JSX,然后就是常说的diff等流程,最后才会去渲染,只不过我们目前关注的重点就是这个函数组件的重新执行。Hooks实际上无非就是个函数,React通过内置的use为函数赋予了特殊的意义,使得其能够访问Fiber从而做到数据与节点相互绑定,那么既然是一个函数,并且在setState的时候还会重新执行,那么在重新执行的时候,点击按钮之前的add函数地址与点击按钮之后的add函数地址是不同的,因为这个函数实际上是被重新定义了一遍,只不过名字相同而已,从而其生成的静态作用域是不同的,那么这样便可能会造成所谓的闭包陷阱,接下来我们就来继续探讨相关的问题。
原生事件绑定
虽然React为我们提供了合成事件,但是在实际开发中因为各种各样的原因我们无法避免的会用到原生的事件绑定,例如ReactDOM的Portal传送门,其是遵循合成事件的事件流而不是DOM的事件流,比如将这个组件直接挂在document.body下,那么事件可能并不符合看起来DOM结构应该遵循的事件流,这可能不符合我们的预期,此时可能就需要进行原生的事件绑定了。此外,很多库可能都会有类似addEventListener的事件绑定,那么同样的此时也需要在合适的时机去添加和解除事件的绑定。由此,我们来看下边这个原生事件绑定的例子:
// https://codesandbox.io/s/react-ts-template-forked-z8o7sv?file=/src/counter-native.tsx
import { useEffect, useRef, useState } from "react";
export const CounterNative: React.FC = () => {
const ref1 = useRef<HTMLButtonElement>(null);
const ref2 = useRef<HTMLButtonElement>(null);
const [count, setCount] = useState(0);
const add = () => {
setCount(count + 1);
};
useEffect(() => {
const el = ref1.current;
const handler = () => console.log(count);
el?.addEventListener("click", handler);
return () => {
el?.removeEventListener("click", handler);
};
}, []);
useEffect(() => {
const el = ref2.current;
const handler = () => console.log(count);
el?.addEventListener("click", handler);
return () => {
el?.removeEventListener("click", handler);
};
}, [count]);
return (
<div>
{count}
<div>
<button onClick={add}>count++</button>
<button ref={ref1}>log count 1</button>
<button ref={ref2}>log count 2</button>
</div>
</div>
);
};
在这个例子中,我们分别对ref1与ref2两个button进行了原生事件绑定,其中ref1的事件绑定是在组件挂载的时候进行的,而ref2的事件绑定是在count发生变化的时候进行的,看起来代码上只有依赖数组[]和[count]的区别,但实际的效果上差别就很大了。在上边在线的CodeSandbox中我们首先点击三次count++这个按钮,然后分别点击log count 1按钮和log count 2按钮,那么输出会是如下的内容:
0 // log count 1
3 // log count 2
此时我们可以看出,页面上的count值明明是3,但是我们点击log count 1按钮的时候,输出的值却是0,只有点击log count 2按钮的时候,输出的值才是3,那么点击log count 1的输出肯定是不符合我们的预期的。那么为什么会出现这个情况呢,其实这就是所谓的React Hooks闭包陷阱了,其实我们上边也说了为什么会发生这个问题,我们再重新看一下,Hooks实际上无非就是个函数,React通过内置的use为函数赋予了特殊的意义,使得其能够访问Fiber从而做到数据与节点相互绑定,那么既然是一个函数,并且在setState的时候还会重新执行,那么在重新执行的时候,点击按钮之前的add函数地址与点击按钮之后的add函数地址是不同的,因为这个函数实际上是被重新定义了一遍,只不过名字相同而已,从而其生成的静态作用域是不同的,那么在新的函数执行时,假设我们不去更新新的函数,也就是不更新函数作用域的话,那么就会保持上次的count引用,就会导致打印了第一次绑定的数据。
那么同样的,useEffect也是一个函数,我们那么我们定义的事件绑定那个函数也其实就是useEffect的参数而已,在state发生改变的时候,这个函数虽然也被重新定义,但是由于我们的第二个参数即依赖数组的关系,其数组内的值在两次render之后是相同的,所以useEffect就不会去触发这个副作用的执行。那么实际上在log count 1中,因为依赖数组是空的[],两次render或者说两次执行依次比较数组内的值没有发生变化,那么便不会触发副作用函数的执行;那么在log count 2中,因为依赖的数组是[count],在两次render之后依次比较其值发现是发生了变化的,那么就会执行上次副作用函数的返回值,在这里就是清理副作用的函数removeEventListener,然后再执行传进来的新的副作用函数addEventListener。另外实际上也就是因为React需要返回一个清理副作用的函数,所以第一个函数不能直接用async装饰,否则执行副作用之后返回的就是一个Promise对象而不是直接可执行的副作用清理函数了。
useCallback
在上边的场景中,我们通过为useEffect添加依赖数组的方式似乎解决了这个问题,但是设想一个场景,如果一个函数需要被多个地方引入,也就是说类似于我们上一个示例中的handler函数,如果我们需要在多个位置引用这个函数,那么我们就不能像上一个例子一样直接定义在useEffect的第一个参数中。那么如果定义在外部,这个函数每次re-render就会被重新定义,那么就会导致useEffect的依赖数组发生变化,进而就会导致副作用函数的重新执行,显然这样也是不符合我们的预期的。此时就需要将这个函数的地址保持为唯一的,那么就需要useCallback这个Hook了,当使用React中的useCallback Hook时,其将返回一个memoized记忆化的回调函数,这个回调函数只有在其依赖项发生变化时才会重新创建,否则就会被缓存以便在后续的渲染中复用。通过这种方式可以帮助我们在React组件中优化性能,因为其可以防止不必要的重渲染,当将这个memoized回调函数传递给子组件时,就可以避免在每次渲染时重新创它,这样可以提高性能并减少内存的使用。由此,我们来看下边这个使用useCallback进行事件绑定的例子:
// https://codesandbox.io/s/react-ts-template-forked-z8o7sv?file=/src/counter-callback.tsx
import { useCallback, useEffect, useRef, useState } from "react";
export const CounterCallback: React.FC = () => {
const ref1 = useRef<HTMLButtonElement>(null);
const ref2 = useRef<HTMLButtonElement>(null);
const [count, setCount] = useState(0);
const add = () => {
setCount(count + 1);
};
const logCount1 = () => console.log(count);
useEffect(() => {
const el = ref1.current;
el?.addEventListener("click", logCount1);
return () => {
el?.removeEventListener("click", logCount1);
};
}, []);
const logCount2 = useCallback(() => {
console.log(count);
}, [count]);
useEffect(() => {
const el = ref2.current;
el?.addEventListener("click", logCount2);
return () => {
el?.removeEventListener("click", logCount2);
};
}, [logCount2]);
return (
<div>
{count}
<div>
<button onClick={add}>count++</button>
<button ref={ref1}>log count 1</button>
<button ref={ref2}>log count 2</button>
</div>
</div>
);
};
在这个例子中我们的logCount1没有useCallback包裹,每次re-render都会重新定义,此时useEffect也没有定义数组,所以在re-render时并没有再去执行新的事件绑定。那么对于logCount2而言,我们使用了useCallback包裹,那么每次re-render时,由于依赖数组是[count]的存在,因为count发生了变化useCallback返回的函数的地址也改变了,在这里如果有很多的状态的话,其他的状态改变了,count不变的话,那么这里的logCount2便不会改变,当然在这里我们只有count这一个状态,所以在re-render时,useEffect的依赖数组发生了变化,所以会重新执行事件绑定。在上边在线的CodeSandbox中我们首先点击三次count++这个按钮,然后分别点击log count 1按钮和log count 2按钮,那么输出会是如下的内容:
0 // log count 1
3 // log count 2
那么实际上我们可以看出来,在这里如果的log count 1与原生事件绑定例子中的log count 1一样,都因为没有及时更新而保持了上一次render的静态作用域,导致了输出0,而由于log count 2及时更新了作用域,所以正确输出了3,实际上这个例子并不全,我们可以很明显的发现实际上应该有其他种情况的,我们同样先点击count++三次,然后再分情况看输出:
logCount函数不用useCallback包装。useEffect依赖数组为[]: 输出0。useEffect依赖数组为[count]: 输出3。useEffect依赖数组为[logCount]: 输出3。
logCount函数使用useCallback包装,依赖为[]。useEffect依赖数组为[]: 输出0。useEffect依赖数组为[count]: 输出0。useEffect依赖数组为[logCount]: 输出0。
logCount函数使用useCallback包装,依赖为[count]。useEffect依赖数组为[]: 输出0。useEffect依赖数组为[count]: 输出3。useEffect依赖数组为[logCount]: 输出3。
虽然看起来情况这么多,但是实际上如果接入了react-hooks/exhaustive-deps规则的话,发现其实际上是会建议我们使用3.3这个方法来处理依赖的,这也是最标准的解决方案,其他的方案要不就是存在不必要的函数重定义,要不就是存在应该重定义但是依然存在旧的函数作用域引用的情况,其实由此看来React的心智负担确实是有些重的,而且useCallback能够完全解决问题吗,实际上并没有,我们可以接着往下聊聊useCallback的缺陷。
useMemoizedFn
同样的,我们继续来看一个例子,这个例子可能相对比较复杂,因为会有一个比较长的依赖传递,然后导致看起来比较麻烦。另外实际上这个例子也不能说useCallback是有问题的,只能说是会有相当重的心智负担。
const getTextInfo = useCallback(() => { // 获取一段数据
return [text.length, dep.length];
}, [text, dep]);
const post = useCallback(() => { // 发送数据
const [textLen, depLen] = getTextInfo();
postEvent({ textLen, depLen });
}, [getTextInfo, postEvent]);
useEffect(() => {
post();
}, [dep, post]);
在这个例子中,我们希望达到的目标是仅当dep发生改变的时候,触发post函数,从而将数据进行发送,在这里我们完全按照了react-hooks/exhaustive-deps的规则去定义了函数。那么看起来似乎并没有什么问题,但是当我们实际去应用的时候,会发现当text这个状态发生变化的时候,同样会触发这个post函数的执行,这是个并不明显的问题,如果text这个状态改变的频率很低的话,甚至在回归的过程中都可能无法发现这个问题。此外,可以看到这个依赖的链路已经很长了,如果函数在复杂一些,那复杂性越来越高,整个状态就会变的特别难以维护。
那么如何解决这个问题呢,一个可行的办法是我们可以将函数定义在useRef上,那么这样的话我们就可以一直拿到最新的函数定义了,实际效果与直接定义一个函数调用无异,只不过不会受到react-hooks/exhaustive-deps规则的困扰了。那么实际上我们并没有减缓复杂性,只是将复杂性转移到了useRef上,这样的话我们就需要去维护这个useRef的值,这样的话就会带来一些额外的心智负担。
const post = useRef(() => void 0);
post.current = () => {
postEvent({ textLen, depLen });
}
useEffect(() => {
post.current();
}, [dep]);
那么既然我们可以依靠useRef来解决这个问题,我们是不是可以将其封装为一个自定义的Hooks呢,然后因为实际上我们并没有办法阻止函数的创建,那么我们就使用两个ref,第一个ref保证永远是同一个引用,也就是说返回的函数永远指向同一个函数地址,第二个ref用来保存当前传入的函数,这样发生re-render的时候每次创建新的函数我们都将其更新,也就是说我们即将调用的永远都是最新的那个函数。这样通过两个ref我们就可以保证两点,第一点是无论发生多少次re-render,我们返回的都是同一个函数地址,第二点是无论发生了多少次re-render,我们即将调用的函数都是最新的。由此,我们就来看下ahooks是如何实现的useMemoizedFn。
type noop = (this: any, ...args: any[]) => any;
type PickFunction<T extends noop> = (
this: ThisParameterType<T>,
...args: Parameters<T>
) => ReturnType<T>;
function useMemoizedFn<T extends noop>(fn: T) {
const fnRef = useRef<T>(fn);
// why not write `fnRef.current = fn`?
// https://github.com/alibaba/hooks/issues/728
fnRef.current = useMemo(() => fn, [fn]);
const memoizedFn = useRef<PickFunction<T>>();
if (!memoizedFn.current) {
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args);
};
}
return memoizedFn.current as T;
}
那么使用的时候就很简单了,可以看到我们使用useMemoizedFn时是不需要依赖数组的,并且虽然我们在useEffect中定义了post函数的依赖,但是由于我们上边保证了第一点,那么这个在这个组件被完全卸载之前,这个依赖的函数地址是不会变的,由此我们就可以保证只可能由于dep发生的改变才会触发useEffect,而且我们保证的第二点,可以让我们在re-render之后拿到的都是最新的函数作用域,也就是textLen和depLen是能够保证是最新的
,不会存在拿到了旧的函数作用域里边值的问题。
const post = useMemoizedFn(() => {
postEvent({ textLen, depLen });
});
useEffect(() => {
post.current();
}, [dep, post]);
每日一题
https://github.com/WindrunnerMax/EveryDay
参考
https://juejin.cn/post/7194368992025247804
https://juejin.cn/post/7098137024204374030
https://react.dev/reference/react/useCallback
Hooks与事件绑定的更多相关文章
- Vue事件绑定原理
Vue事件绑定原理 Vue中通过v-on或其语法糖@指令来给元素绑定事件并且提供了事件修饰符,基本流程是进行模板编译生成AST,生成render函数后并执行得到VNode,VNode生成真实DOM节点 ...
- MVVM设计模式和WPF中的实现(四)事件绑定
MVVM设计模式和在WPF中的实现(四) 事件绑定 系列目录: MVVM模式解析和在WPF中的实现(一)MVVM模式简介 MVVM模式解析和在WPF中的实现(二)数据绑定 MVVM模式解析和在WPF中 ...
- 7 HTML&JS等前端知识系列之jquery的事件绑定
preface 我们知道,每一个a,input等等标签都可以为其绑定一个事件,onclick也好,focus 也罢,都可以绑定的.但是众神key想过这个问题没有,倘若这里有1000个input标签需要 ...
- 兼容8事件绑定与解绑addEventListener、removeEventListener和ie的attachEvent、detachEvent
兼容8事件绑定与解绑addEventListener.removeEventListener和ie的attachEvent.detachEvent ;(function(){ // 事件绑定 bi ...
- jQuery中事件绑定到bind、live、delegate、on方法的探究
1. 给页面上的某个元素绑定事件,最初采用下面的方式实现: $(‘selector’).click(function(){ //code }); 缺点: 不能同时绑定多个事件,不能绑定动态的元素. 后 ...
- jQuery中的事件绑定方法
在jQuery中,事件绑定方法大致有四种:bind(),live(), delegate(),和on(). 那么在工作中应该如何选择呢?首先要了解四种方法的区别和各自的特点. 在了解这些之前,首先要知 ...
- Vue - 事件绑定
1.内联方式: A:将事件处理器绑定到一个方法中,以下所有事件都以click事件作为案例 注意:内联方式下事件处理器只能绑定一个方法,要是想要绑定多个方法,依旧还是使用js中的addEventList ...
- jQuery 2.0.3 源码分析 事件绑定 - bind/live/delegate/on
事件(Event)是JavaScript应用跳动的心脏,通过使用JavaScript ,你可以监听特定事件的发生,并规定让某些事件发生以对这些事件做出响应 事件的基础就不重复讲解了,本来是定位源码分析 ...
- 深入学习jQuery事件绑定
× 目录 [1]bind [2]trigger [3]delegate[4]on[5]one 前面的话 javascript有HTML.DOM0级.DOM2级和IE这四种事件处理程序,而jQuery对 ...
- jQuery-1.9.1源码分析系列(十) 事件系统——事件绑定
事件绑定的方式有很多种.使用了jQuery那么原来那种绑定方式(elem.click = function(){...})就不推荐了,原因? 最主要的一个原因是elem.click = fn这种方式只 ...
随机推荐
- linux中安装启动postgresql教程
安装: 官网地址:https://www.postgresql.org 选择下载版本: https://www.postgresql.org/ftp/source 下载方式: wget https:/ ...
- 第一天1h
//摄氏度和华氏度之间的换算//20211120//ZhangWenjing#include<stdio.h>int main(void){ int f = 0; int c = 0; s ...
- git 更改子项目索引
git update-index --cacheinfo 160000 97ed2f63b07c73bad9a4d55e96e25292 source/lvdao/crf-sdk git reset ...
- CH573 CH582 OTA例程讲解(使用固定库+扩大APP空间)
例程中提供的两种OTA就不过多介绍了,在BLE目录下有一个PDF专门讲解:WCH蓝牙空中升级(BLE OTA) 方式一是带库升级,整个codeflash分成四个区域,Jump IAP,APP,OTA, ...
- 12-如何使用Genarator逆向工程
使用逆向工程,帮我们更快的建立pojo类.mapper接口及xml映射文件等,无需手写,替代了一部分的mybatis功能. 一.导入MyGenarator逆向工程项目 二.修改xml配置文件 三.执行 ...
- OSPF v3与v2的区别
- 钉钉回调事件-asp.net core 开发钉钉回调事件
最近有一个钉钉的项目,所以,接下来的日子里,我会把钉钉开发中遇到的问题都整理成博客,供大家参考. 钉钉开发者后台,添加好了小程序,需要提供一个回调URL地址,用于数据的同步.如下图 根据钉钉提供的de ...
- Linux & 标准C语言学习 <DAY12_2>
一.堆内存 1.什么是堆内存 是进程的一个内存段(text.data.bss.stack.heap) 由程序员手动管理 特点是足够大,缺点是使用 ...
- RunnerGo相较于Jmeter优劣势分析
RunnerGo是一款基于go语言研发的开源测试平台.在这里我想从性能测试方面.结构方面以及功能方面对比两款产品. 性能方面: Runner基于go语言研发,相对于jmeter来说更轻量级.所以性能测 ...
- Mathematica做数字图像处理中的ImageConvolve练习
目录 ImageConvolve练习 original right sobel 垂直边检测 top sobel 水平边检测 通过一个平方内核的卷积使图像变平滑 类似ConstantArray[1, { ...