GMTC全球大前端技术大会聚焦前端、移动、AI应用等领域15个热门专题方向,购票立减¥960 了解详情
写点什么

React Hooks 踩坑分享

2020 年 5 月 29 日

React Hooks踩坑分享

前言:React Hooks 被越来越多的人认可,整个社区都以积极的态度去拥抱它。在最近的一段时间笔者也开始在一些项目中尝试去使用 React Hooks。原本以为 React Hooks 很简单,和类组件差不多,看看 API 就能用起来了。结果在使用中遇到了各种各样的坑,通过阅读 React Hooks 相关的文章发现 React Hooks 和类组件有很多不同。由此,想和大家做一些分享。


如果要在项目中使用 React Hooks,强烈推荐先安装eslint-plugin-react-hooks(由 React 官方发布)。在很多时候,这个 eslint 插件在我们使用 React Hooks 的过程中,会帮我们避免很多问题。


本文主要讲以下内容:


  1. 函数式组件和类组件的不同

  2. React Hooks 依赖数组的工作方式

  3. 如何在 React Hooks 中获取数据


一、函数式组件和类组件的不同

React Hooks 由于是函数式组件,在异步操作或者使用 useCallBack、useEffect、useMemo 等 API 时会形成闭包。


先看一下以下例子。在点击了展示现在的值按钮三秒后,会 alert 点击次数:


function Demo() {  const [num, setNum] = useState(0);
const handleClick = () => { setTimeout(() => { alert(num); }, 3000); };
return ( <div> <div>当前点击了{num}次</div> <button onClick={() => { setNum(num + 1) }}>点我</button> <button onClick={handleClick}>展示现在的值</button> </div> );};
复制代码



我们按照下面的步骤去操作:


  • 点击num到 3

  • 点击展示现在的值按钮

  • 在定时器回调触发之前,点击增加num到 5。


可以猜一下 alert 会弹出什么?


分割线




其最后弹出的数据是 3。



为什么会出现这样的情况,最后的num不是应该是 5 吗?


上面例子中,num仅是一个数字而已。 它不是神奇的“data binding”, “watcher”, “proxy”,或者其他任何东西。它就是一个普通的数字像下面这个一样:


const num = 0;// ...setTimeout(() => {  alert(num);}, 3000);// ...
复制代码


我们组件第一次渲染的时候,从useState()拿到num的初始值为 0,当我们调用setNum(1),React 会再次渲染组件,这一次num是 1。如此等等:


// 第一次渲染function Demo() {  const num = 0; // 从useState()获取  // ...  setTimeout(() => {    alert(num);  }, 3000);  // ...}
// 在点击了一次按钮之后function Demo() { const num = 1; // 从useState()获取 // ... setTimeout(() => { alert(num); }, 3000); // ...}

// 又一次点击按钮之后function Demo() { const num = 2; // 从useState()获取 // ... setTimeout(() => { alert(num); }, 3000); // ...}
复制代码


在我们更新状态之后,React 会重新渲染组件。每一次渲染都能拿到独立的num状态,这个状态值是函数中的一个常量。


所以在num为 3 时,我们点击了展示现在的值按钮,就相当于:


function Demo() {  // ...  setTimeout(() => {    alert(3);  }, 3000)  // ...}
复制代码


即便 num 的值被点击到了 5。但是触发点击事件时,捕获到的num值为 3。




上面的功能,我们尝试用类组件实现一遍:


class Demo extends Component {  state = {    num: 0,  }
handleClick = () => { setTimeout(() => { alert(this.state.num); }, 3000); }
render() { const { num } = this.state; return ( <div> <p>当前点击了{num}次</p> <button onClick={() => { this.setState({ num: num + 1 }) }}>点击</button> <button onClick={this.handleClick}>展示现在的值</button> </div> ); }};
复制代码


我们按照之前同样的步骤去操作:


  • 点击num到 3

  • 点击展示现在的值按钮

  • 在定时器回调触发之前,点击增加num到 5



这一次弹出的数据是 5。


为什么同样的例子在类组件会有这样的表现呢?


我们可以仔细看一下 handleClick 方法:


handleClick = () => {  setTimeout(() => {    alert(this.state.num);  }, 3000)}
复制代码


这个类方法从 this.state.num 中读取数据,在 React 中 state 是不可变的。然而,this 是可变的。


通过类组件的this,我们可以获取到最新的 state 和 props。


所以如果在用户再点击了展示现在的值按钮的情况下我们对点击按钮又点击了几次,this.state将会改变。handleClick方法从一个“过于新”的state中得到了num


这样就引起了一个问题,如果说我们 UI 在概念上是当前应用状态的一个函数,那么事件处理程序和视觉输出都应该是渲染结果的一部分。我们的事件处理程序应该有一个特定的 props 和 state


然而在类组件中,我们通过this.state读取的数据并不能保证其是一个特定的 state。handleClick事件处理程序并没有与任何一个特定的渲染绑定在一起。


从上面的例子,我们可以看出 React Hooks 在某一个特定渲染中 state 和 props 是与其相绑定的,然而类组件并不是。


二、React Hooks 依赖数组的工作方式

在 React Hooks 提供的很多 API 都有遵循依赖数组的工作方式,比如 useCallBack、useEffect、useMemo 等等。


使用了这类 API,其传入的函数、数据等等都会被缓存。被缓存的内容其依赖的 props、state 等值就像上面的例子一样都是“不变”的。只有当依赖数组中的依赖发生变化,它才会被重新创建,得到最新的 props、state。所以在用这类 API 时我们要特别注意,在依赖数组内一定要填入依赖的 props、state 等值。


这里给大家举一个反例:


function Demo() {  const [num, setNum] = useState(0);
const handleClick = useCallback(() => { setNum(num + 1); }, []);
return ( <div> <p>当前点击了{num}次</p> <button onClick={handleClick}>点击</button> </div> );}
复制代码


useCallback本质上是添加了一层依赖检查。当我们函数本身只在需要的时候才改变。


在上面的例子中,我们无论点击多少次点击按钮,num的值始终为 1。这是因为useCallback中的函数被缓存了,其依赖数组为空数组,传入其中的函数会被一直缓存。


handleClick其实一直都是:


const handleClick = () => {    setNum(0 + 1);};
复制代码


即便函数再次更新,num的值变为 1,但是 React 并不知道你的函数中依赖了num,需要去更新函数。


唯有在依赖数组中传入了num,React 才会知道你依赖了num,在num的值改变时,需要更新函数。


function Demo() {  const [num, setNum] = useState(0);
const handleClick = useCallback(() => { setNum(num + 1); }, [num]); // 添加依赖num
return ( <div> <p>当前点击了{num}次</p> <button onClick={handleClick}>点击</button> </div> );};
复制代码


点击点击按钮,num 的值不断增加。


(其实这些归根究底,就是 React Hooks 会形成闭包)


三、如何在 React Hooks 中获取数据

在我们用习惯了类组件模式,我们在用 React Hooks 中获取数据时,一般刚开始大家都会这么写吧:


function Demo(props) {  const { query } = props;  const [list, setList] = useState([]);
const fetchData = async () => { const res = await axios(`/getList?query=${query}`); setList(res); };
useEffect(() => { fetchData(); // 这样不安全(调用的fetchData函数使用了query) }, []);
return ( <ul> {list.map(({ text }) => { return ( <li key={text}>{ text }</li> ); })} </ul> );};
复制代码


其实这样是不推荐的一种模式,要记住 effect 外部的函数使用了哪些 props 和 state 很难。这也是为什么 通常你会想要在 effect 内部去声明它所需要的函数。 这样就能容易的看出那个 effect 依赖了组件作用域中的哪些值:


function Demo(props) {  const { query } = props;  const [list, setList] = useState([]);
useEffect(() => { const fetchData = async () => { const res = await axios(`/getList?query=${query}`); setList(res); };
fetchData(); }, [query]);
return ( <ul> {list.map(({ text }) => { return ( <li key={text}>{ text }</li> ); })} </ul> );};
复制代码


但是如果你在不止一个地方用到了这个函数或者别的原因,你无法把一个函数移动到 effect 内部,还有一些其他办法:


  • 如果这函数不依赖 state、props 内部的变量。可以把这个函数移动到你的组件之外。这样就不用其出现在依赖列表中了。

  • 如果其不依赖 state、props。但是依赖内部变量,可以将其在 effect 之外调用它,并让 effect 依赖于它的返回值。

  • 万不得已的情况下,你可以把函数加入 effect 的依赖项,但把它的定义包裹进useCallBack。这就确保了它不随渲染而改变,除非它自身的依赖发生了改变。


另外一方面,业务一旦变的复杂,在 React Hooks 中用类组件那种方式获取数据也会有别的问题。


我们做这样一个假设,一个请求入参依赖于两个状态分别是 query 和 id。然而 id 的值需要异步获取(只要获取一次,就可以在这个组件卸载之前一直用),query 的值从 props 传入:


function Demo(props) {  const { query } = props;  const [id, setId] = useState();  const [list, setList] = useState([]);
const fetchData = async (newId) => { const myId = newId || id; if (!myId) { return; } const res = await axios(`/getList?id=${myId}&query=${query}`); setList(res); };
const fetchId = async () => { const res = await axios('/getId'); return res; };
useEffect(() => { fetchId().then(id => { setId(id); fetchData(id); }); }, []);
useEffect(() => { fetchData(); }, [query]);
return ( <ul> {list.map(({ text }) => { return ( <li key={text}>{ text }</li> ); })} </ul> );};
复制代码


在这里,当我们的依赖的query在异步获取id期间变了,最后请求的入参,其query将会用之前的值。(引起这个问题的原因还是闭包,这里就不再复述了)


对于从后端获取数据,我们应该用 React Hooks 的方式去获取。这是一种关注数据流和同步思维的方式。


对于刚才这个例子,我们可以这样解决:


function Demo(props) {  const { query } = props;  const [id, setId] = useState();  const [list, setList] = useState([]);
useEffect(() => { const fetchId = async () => { const res = await axios('/getId'); setId(res); };
fetchId(); }, []);
useEffect(() => { const fetchData = async () => { const res = await axios(`/getList?id=${id}&query=${query}`); setList(res); }; if (id) { fetchData(); } }, [id, query]);
return ( <ul> {list.map(({ text }) => { return ( <li key={text}>{ text }</li> ); })} </ul> );}
复制代码


一方面这种方式可以让我们的代码更加清晰,一眼就能看明白获取这个接口的数据依赖了哪些 state、props,让我们更多的去关注数据流的改变。另外一方面也避免了闭包可能会引起的问题。


但是同步思维的方式也会有一些坑,比如这样的场景,有一个列表,这个列表可以通过子元素的按钮增加数据:


function Children(props) {  const { fetchData } = props;
return ( <div> <button onClick={() => { fetchData(); }}>点击</button> </div> );};
function Demo() { const [list, setList] = useState([]);
const fetchData = useCallback(async () => { const res = await axios(`/getList`); setList([...list, ...res]); }, [list]);
useEffect(() => { fetchData(); }, [fetchData]);
return ( <div> <ul> {list.map(({ text }) => { return ( <li key={text}>{ text }</li> ); })} </ul> <Children fetchData={fetchData} /> </div> );};
复制代码


这种场景下,会一直加载数据,造成死循环。


每次调用fetchData函数会更新listlist更新后fetchData函数就会被更新。fetchData更新后useEffect会被调用,useEffect中又调用了fetchData函数。fetchData被调用导致list更新…


当出现这种 根据前一个状态更新状态 的时候,我们可以用 useReducer 去替换 useState:


function Children(props) {  const { fetchData } = props;
return ( <div> <button onClick={() => { fetchData(); }}>点击</button> </div> );};
const initialList = [];
function reducer(state, action) { switch (action.type) { case 'increment': return [...state, ...action.payload]; default: throw new Error(); }}
export default function Demo() { const [list, dispatch] = useReducer(reducer, initialList);
const fetchData = useCallback(async () => { const res = await axios(`/getList`); dispatch({ type: 'increment', payload: res }); }, []);
useEffect(() => { fetchData(); }, [fetchData]);
return ( <div> <ul> {list.map(({ text }) => { return ( <li key={text}>{ text }</li> ); })} </ul> <Children fetchData={fetchData} /> </div> );};
复制代码


React 会保证dispatch在组件的声明周期内保持不变。所以上面的例子中不需要依赖dispatch


用了useReducer我们就可以移除list依赖。不会再出现死循环的情况。


通过 dispatch 了一个 action 来描述发生了什么。这使得我们的fetchData函数和list状态解耦。我们的fetchData函数不再关心怎么更新状态,它只负责告诉我们发生了什么。更新的逻辑全都交由 reducer 去统一处理。


(我们使用函数式更新也能解决这个问题,但是更推荐使用 useReducer)


在某些场景下useReducer会比 useState 更适用。例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数。


如果大家遇到其它的一些复杂场景,用上面介绍的方法无法解决。那就试试用 useRef 吧。


文章如有疏漏、错误欢迎批评指正。


作者介绍


本文转载自公众号有赞 coder(ID:youzan_coder)。


原文链接


https://mp.weixin.qq.com/s?__biz=MzAxOTY5MDMxNA==&mid=2455760831&idx=1&sn=8a83cc2bacba8044fe1a0e6cc5ac498d&chksm=8c68699abb1fe08c997a74e6e51691a09b02e3f0b6e28995bf88cf6701bf73ea17eb642ffe6e&scene=27#wechat_redirect


2020 年 5 月 29 日 10:051578

评论

发布
暂无评论
发现更多内容

Three.js杂记(六)——3D模型

空城机

前端 WebGL 3D模型 3D可视化 three.js

moviepy简介及安装

老猿Python

Python 编程语言 Moviepy PyQt 音视频剪辑

moviepy音视频剪辑:视频剪辑基类VideoClip的属性及方法详解

老猿Python

Python 编程语言 Moviepy 音视频剪辑

Python-计算机视觉-OpenCV-video

Aldeo

Python OpenCV Video

12|PPT 教程|页面设计原则

青城

从一道美团春招笔试题目出发,揭开树DP的神秘面纱

面鲸

面试 数据结构与算法 笔试题

Three.js杂记(八)—— 文本几何体

空城机

前端 WebGL 3D渲染 3D可视化 three.js

moviepy音视频剪辑:moviepy中的剪辑基类Clip的属性和方法详解

老猿Python

Wireshark数据包分析学习笔记Day19

穿过生命散发芬芳

Wireshark 数据包分析 3月日更

Three.js杂记(十)——贴图

空城机

前端 WebGL 3D渲染 3D可视化 three.js

FastApi-01-初识

Python测试开发

Python Web FastApi

域名和服务器的购买和配置

空城机

阿里云 轻量级服务器 云翼计划

Three.js杂记(五)——坐标轴、光源

空城机

前端 WebGL 3D渲染 3D可视化 three.js

工程方法事例实战

风翱

软件工程 3月日更

Three.js杂记(九)—— 练习:地球

空城机

前端 WebGL 3D可视化 three.js

Three.js杂记(十一)—— 精灵与粒子(绘制中国地图)

空城机

前端 WebGL 3D渲染 3D可视化 three.js

去了解一下区块链

空城机

区块链 笔记 区块链发展

JS逐步教你做(自己版本)的视频播放器(html逻辑 css逻辑 js逻辑)

贵哥的编程大路

js

时间复杂度总结

我是程序员小贱

3月日更

渣硕试水字节跳动,本以为简历都过不了,123+HR面直接拿到意向书

云流

Java 程序员 架构 面试

《Redis 核心技术与实战》学习笔记 07

escray

redis 极客时间 学习笔记 3月日更 Redis 核心技术与实战

面试拜佛保过?圈内罕见阿里面试官手册,2021最强面试笔记非它莫属

云流

Java 程序员 架构 面试

不吹不黑,这是我近年来见过的最全的面试题库了(Java岗)

Java王路飞

Java 程序员 架构 面试 分布式

真香!Github一夜爆火,阿里性能优化不传之秘终于开源

互联网架构师小马

Java 性能优化 JVM 性能调优 调优

教你如何用霍夫变换完成扭曲车牌识别

程序媛观澜

机器学习 图像识别

2021春招Java后端开发面试总结:25个技术专题(最全面试攻略)

比伯

Java 编程 架构 面试 程序人生

Three.js杂记(七)—— 全景效果制作·上(含python爬虫偷碎图,canvas重组图片)

空城机

前端 WebGL 3D渲染 3D可视化 three.js

DCGM:监控Kubernetes集群的GPU资源

DCOS

kubernetest

如何减少加班导致的离职?

石云升

项目管理 28天写作 职场经验 管理经验 3月日更

Three.js杂记(十一)—— 精灵与粒子(绘制中国地图)

空城机

前端 WebGL 3D渲染 3D可视化 three.js

「产品经理训练营」作业 06:用户路径地图与漏斗模型

狷介

产品经理训练营

大数据技术升级脉络及认知陷阱

大数据技术升级脉络及认知陷阱

React Hooks踩坑分享-InfoQ