跳到主要内容

在React中巧妙使用Hook

· 阅读需 13 分钟

我从2021年5月开始实习,当时只会一点Vue和基础的js,结果到公司以后上手的就是typescript + React,着实让人难顶。有幸的是正因为开始学习 React 晚,因此上手就是函数式组件。在这几个月的实习中我对与React,函数式编程,typescript学习到了很多。

初识Hook

使用 React Hook 自然离不开函数式编程。函数式编程相对于原来的oop,将所有逻辑转变为函数,在函数调用中处理数据。 React 函数式组件和函数式编程相比我觉得还是差一部分距离的,因此这里所述的还是基于React组件内使用的思想。

相对于复杂的 Vue2 / React 生命周期,函数式组件只有基本的几个 Hook api 就可以快乐编写了。 之前看过某大佬的讲解视频,对这个几个 hook api 讲解十分清晰。

基于状态的声明式框架

上手 Vue / React,不难看到这样一句话:基于状态 / 数据的声明式框架。 最开始学习的时候,确实不理解其实际意思,因为当时写 Vue 2 ,所有数据都是扔在 data 字段。 后来写 React,才明白什么是状态。

不管是函数式组件还是对象组件,框架都需要判断在什么时候去重新渲染组件,因此框架提供了相应的 API ,判断某些数据变化之后,再去决定重新渲染组件。 对于 Vue2 来说就是 data 字段返回的对象,对于 React 来说就是 state 。

useState 就是一个创建 state 的 hook,可以用它来创建一个只读的状态对象,和一个可以用来修改状态的方法。利用这个 hook 可以轻易创建 需要管理的状态,也就是核心数据。只有在state发生变化时,组件的 function 才会被运行。

import { useState } from "react";

export default function App() {
const [num, setNum] = useState(0);
console.log(num);
const someObject = {
num: num,
random: Math.floor(Math.random() * 100)
};
console.log(someObject);

return (
<div className="App">
<div>Num is {num}</div>
<div>Object is {someObject.random}</div>
<button onClick={() => {setNum(num + 1)}}>
Add
</button>
<button
onClick={() => {
someObject.random = -1;
console.log(someObject);
}}
>
Random
</button>
</div>
);
};

可以在 codesandbox 中打开这个 Demo。 这个 Demo 主要展势了如何借助 useState 来编写 React 组件。在 Demo 完成渲染后,我们可以看见 console 确实输出了两行内容, 一行是 num 这个 State,一行是普通的 js 对象,之后只有点击 Add 按钮,才能触发渲染,重新打印。我们从以下几点去进行思考:

  1. 只有触发 state 的变化后,才会重新渲染数据,点击 Random 按钮后,确实发生了数据变化,但是并没有渲染到页面上。

  2. 点击 Add 按钮后,num 这个状态如实地增加了 1 ,但是我们传入 onClick 的一个匿名函数,也是个对象,这个对象是否发生了变化呢?

  3. 每次函数运行后,someObject 的随机字段发生变化,说明重新生成了一个对象。可以借助这一点来重新计算一个值,在每次渲染后重新使用。 但这一点也会成为问题,比如希望初次渲染的时候去请求服务器的数据,但是并不需要每次重新渲染都去请求一遍,这该如何处理?

对于第一点,我们可以很清楚了:如果你希望某个数据发生变化后,就把组件重新渲染一遍,那么尽量保证能触发 state 的变化。下面来谈谈后两点。

收集依赖,更新对象

先来讨论第二点,我们在 onClick 处传入了一个匿名函数,在里面直接去访问这个 state ,即设置新的值为 state + 1 , 那么这个匿名函数会随 state 在更新时变化吗?答案是肯定的,我们可以自己定义一个子组件,然后把修改 state 的匿名函数作为 props 传入, 如果在子组件中触发这个匿名函数执行,那么会导致父组件的 state 变化,那么父组件重新渲染,我们会惊奇的发现:子组件也发生变化了。 这说明子组件的 props 发生了变化。相对的,我们把这个匿名函数包装以下:

import { useCallback } from 'react';

// in function component
const onClickCallback = useCallback(()=>{
setNumber(number + 1);
},[]);

我们会惊奇的发现:执行这个包装后的函数,不会引起子组件的渲染,并且 state 还可以发生变化,但是 state 只能变化到 1, 并且疯狂点击,打印也只会出现两次 1。这就需要了解 useCallback 这个 hook 了。

useCallback 是一个用来包装函数的 hook,它可以根据每次渲染时的变化,来决定是否更新函数,保证组件内部拿到的 callback 是最新的。 第一次参数就是 callback,第二个则是依赖数组,如果依赖数组内部的值发生了变化,它会主动更新 callback,否则不更新。 这里我们设置了空数组,也就是说 callback 在组件挂载时就已经确定了,之后再次渲染也不会更新,这样内部的 number 值永远都是 0 ,它只会 给 state 设置 1。因此我们的组件不会再更新了。

其次,我们的组件函数执行了两次,因为 console.log 执行了两遍,按理来说,第一次执行时 state 已经是 1 了,第二次触发后应该不触发重新渲染, 事实上也是这样的:第二次 random 值发生了变化,打印在了 console,但没有被渲染。这就需要我们将 number 添加在依赖数组里,这样每次重新渲染 触发 number 更新时,onClickCallback 这个函数会被重新生成一次,callback 里拿到的就是最新的 number 了。

useCallback 主要解决了依赖问题:组件函数内部定义的函数会在每次组件函数执行时重新生成一次,如果其依赖没有变动,就会成为一次无用的更新。 这个 hook 可以处理依赖,仅在每次依赖更新时才会重新生成函数,这样就保证函数依赖的变量永远是最新的,起到一种记忆的效果。

以此类推,React 不仅可以帮助我们“记忆”函数,也可以记录一个变量。useMemo 就是这样一个 hook,和 useCallback 类似的,它接受两个参数, 第一个是 factory 函数,用来处理值,第二个是依赖数组。比如可以这样使用:

const feedback = useMemo(()=>{
if(score > 90){
return 'Congratulation'
} else if(score > 60) {
return 'Not bad'
} else {
return 'Attention'
}
},[score])

这个 feedback 的值,只会在 score 变化时才会变化,并且只有在变化时参数函数才会被运行。

那么是不是有必要将所有值都用 useCallbackuseMemo 记录起来?对于一些简单的场景,没必要使用这两个 hook,可能反倒引起不必要的性能牺牲, 例如前面的 callback 例子,我们的组件仅有一个 state,每次更新实际上都会导致 useCallback 重新生成 callback,这就没必要使用 hook 了。 此外可能还会因为漏写依赖,导致生成的函数 / 对象没有即时更新导致出现bug。 可以阅读 Kent C. Dodds 的博客 When to useMemo and useCallback 拓展了解这方面内容。

合理地执行副作用

如果你使用过 Class API 的 React,或者 Vue2,对生命周期这个知识应该不陌生,比如 Vue2 的 onMount 和 onCreated 谁先执行的问题。在 Hook API 里,组件的生命周期概念被淡化了, 因为说到底一个组件函数的执行机会只有挂载和更新。但是开发者依然需要类似的API来在状态改变 / 组件更新的时候做一些其他工作,比如请求API打日志,读写数据到Local Storage。 基于此,useEffect hook 提供的能力,可以解决这个问题。它类似 Vue watch 的功能,能够在依赖变更的时候执行一些副作用。比如我们需要在组件内创建一个定时任务,每5秒提醒一下去喝水:

// in function compoent
useEffect(() => {
const timer = setInterval(() => {
console.log('It is time to drink water!');
}, 5000);

return () => {
clearInterval(timer);
};
}, []);

这段代码的意图是在组件挂载的时候创建一个定时任务,并且在组件销毁之后清除定时任务,避免产生不必要的问题,尽管可能关闭页面的时候组件才会销毁。我们来探究一下这个 hook 究竟该如何使用。 这里 useEffect 接受两个参数,第一个是执行的回调,第二个是执行的依赖,这里提供了一个空数组,表示仅在组件挂载的时候执行一次,如果你还需要在其他时机执行,那么需要添加相关依赖。 在执行回调里,我们创建了一个 setInterval 任务,来定期的执行 console.log,这个 API 返回一个代表任务的数字,以支持清除定时任务。可以看到执行的回调返回了一个回调,内容是清除定时任务。 useEffect 的回调参数,允许返回一个任务,用来在下次相关依赖更新之前执行,用来清除上次的副作用。