【ArchSummit】如何通过AIOps推动可量化的业务价值增长和效率提升?>>> 了解详情
写点什么

React Hooks 踩坑分享

  • 2020-05-29
  • 本文字数:5848 字

    阅读完需:约 19 分钟

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-05-29 10:053386

评论

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

关于「后浪」,ta 们在说什么?

boyzcl

年轻人 系列 后浪

关于CodeReview的一些思考

Yezhiwei

准备重读测试驱动开发

escray

学习 CSD 认证实战营

新人工作的时候遇到问题怎么办

波波

学习 编程 职场 新人

开通InfoQ写作平台测试

ytl

[读书随笔]从哲学上的问题分类看TDD

老狗

哲学 TDD

对你来说,阅读是另一种生活的方式吗?

叶小鍵

信仰

小天同学

人生 个人成长 思考 读书感悟 信仰

JAVA小抄-000-初始

NoNoGirl

Java

详解iOS性能优化,安装包瘦身

Usama Bin Laden

ios 源码分析 性能优化 性能 原理

技术人赚钱的9个路线

品牌运营|陆晓明

副业 赚钱 技术人 码农 生财有术

赚钱的6个层次

品牌运营|陆晓明

创业 技术人 赚钱思维 层次 商机

Redis学习笔记(基础命令)

编程随想曲

redis

UITableView 手势延迟导致subview无法完成两次绘制

AlienJunX

系统的伸缩性以及扩展性设计

Janenesome

读书笔记 程序员 架构

年轻人的世界

boyzcl

年轻人 系列

我也曾对架构师的力量一无所知

曲水流觞TechRill

习惯与惯性

伯薇

个人成长 习惯 习惯养成 提升能力

关于沟通成本的一些认知

大鱼读书

项目管理 软件开发

瞎琢磨先生の好物推荐(软件/网站)

瞎琢磨先生

瞎琢磨先生の常用的 linux 命令

瞎琢磨先生

Linux Shell

Redis源码之常用数据结构和函数

心平气和

redis

如何对Code Review的评论进行分级

宝玉

代码审查 Code Review

婚姻就是合伙开公司,各自做好自己的工作很重要

鼎玉谷

管理 婚姻 公司 付出 人情

重新认识Go语言中的slice

麻瓜镇

Go 语言

读懂才会用 : Redis的多线程

小眼睛聊技术

Java redis 学习 程序员 编程语言 后端

如何利用数据异构实现多级缓存或者数据迁移

松花皮蛋me

缓存 分布式 分库分表

金融「中台」十宗罪

FinClip

中台 企业中台 业务中台

在今天种下一棵树

陈医僧Ethan

感悟 育儿

基于vue(element ui) + ssm + shiro 的权限框架

吴邪

Hello World !

ATGU:阿宝哥

Java Hello World ! Info

React Hooks踩坑分享_移动_苏木_InfoQ精选文章