前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

React Hooks 详解,useState useEffect useContext 与 useReducer

首页2019-09-01 16:30:40Front-End
ReactHooksuseEffect函数组件

一个只有「点我」两个字的按钮,用 class 写要多少行?构造函数、super()、初始化 state、绑定 this、定义方法、render 返回,一共六段,实际有效逻辑只有一行。这还只是一个按钮,真实项目里一个页面组件动辄七八个生命周期方法,同一件事的逻辑被拆在 componentDidMount 和 componentWillUnmount 两头。

Hooks 就是来把这套东西拆掉的。这篇按原文的顺序过一遍四个核心 Hook,每个都补上当年没讲的边界条件,最后单开一节说明 2019 到今天 React 19 之间这套 API 发生了什么变化。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 组件类到底重在哪,Hooks 想解决的是什么问题
  • useState 的函数式更新、惰性初始化,以及它和 class 里 setState 不合并这个差异
  • useContext 共享状态的写法,和它带来的重渲染代价
  • useReducer 什么时候比 useState 划算,它和 Redux 的界限在哪
  • useEffect 的依赖数组语义、清理函数、以及请求竞态怎么处理
  • Hooks 的两条硬规则,以及 React 18/19 上的新变化

# 一、组件类的缺点

React 的核心是组件。v16.8 版本之前,组件的标准写法是类(class)。下面是一个简单的组件类:


import React, { Component } from "react";

export default class Button extends Component {
  constructor() {
    super();
    this.state = { buttonText: "Click me, please" };
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    this.setState(() => {
      return { buttonText: "Thanks, been clicked!" };
    });
  }
  render() {
    const { buttonText } = this.state;
    return <button onClick={this.handleClick}>{buttonText}</button>;
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

这个组件类仅仅是一个按钮,但可以看到,它的代码已经很「重」了。真实的 React App 由多个类按照层级一层层构成,复杂度成倍增长。再加入 Redux,就变得更复杂。

代码量只是表面。真正难受的是两件事。

一是 this 的指向问题。handleClick 必须在构造函数里 bind,或者写成 class 属性配合箭头函数,忘了就是运行时报 Cannot read property 'setState' of undefined。二是逻辑复用没有好办法,高阶组件和 render props 都能做,但代价是组件树里多出一堆没有实际渲染意义的包装层,React DevTools 里翻五层才找到自己的组件。

还有一个更隐蔽的问题。同一件事的代码会被生命周期强行拆开:订阅写在 componentDidMount,退订写在 componentWillUnmount,中间隔着几百行;而不相干的事情反而被塞在同一个方法里。逻辑按「时机」组织,而不是按「关注点」组织。

# 二、Hook 的含义

  • React Hooks 的意思是,组件尽量写成纯函数,如果需要外部功能和副作用,就用钩子把外部代码「钩」进来。React Hooks 就是那些钩子
  • 你需要什么功能,就使用什么钩子。React 默认提供了一些常用钩子,你也可以封装自己的钩子
  • 所有的钩子都是为函数引入外部功能,所以 React 约定,钩子一律使用 use 前缀命名,便于识别。你要使用 xxx 功能,钩子就命名为 usexxx

React 默认提供的四个最常用的钩子:

  • useState()
  • useContext()
  • useReducer()
  • useEffect()

use 前缀不只是命名习惯,它是 ESLint 规则赖以工作的信号。eslint-plugin-react-hooks 靠函数名是不是 use 开头来判断这是不是一个 Hook,进而检查调用位置合不合法。名字写成 getUserState 而不是 useUserState,规则就管不到它了。

# 三、useState():状态钩子

useState() 用于为函数组件引入状态(state)。纯函数不能有状态,所以把状态放在钩子里面。

用户点击按钮会导致按钮的文字改变,文字取决于用户是否点击,这就是状态。使用 useState() 重写如下:

import React, { useState } from "react";

export default function  Button()  {
  const  [buttonText, setButtonText] =  useState("Click me,   please");

  function handleClick()  {
    return setButtonText("Thanks, been clicked!");
  }

  return  <button  onClick={handleClick}>{buttonText}</button>;
}
@前端进阶之旅: 代码已经复制到剪贴板

对比一下上面的 class 版本,构造函数没了,bind 没了,this 没了,六段变成了三行。

useState() 这个函数接受状态的初始值作为参数,上例的初始值为按钮的文字。该函数返回一个数组,第一个成员是一个变量(上例是 buttonText),指向状态的当前值。第二个成员是一个函数,用来更新状态,约定是 set 前缀加上状态的变量名(上例是 setButtonText)。

# 3.1 三个当年没讲清楚的点

第一个是状态不合并。class 里的 this.setState({ a: 1 }) 会和已有 state 做浅合并,b 字段还在。useState 的 setter 是整体替换,你写 setUser({ name: 'x' }),原来的 age 就没了。想保留得自己展开:

setUser(prev => ({ ...prev, name: 'x' }))
@前端进阶之旅: 代码已经复制到剪贴板

第二个是函数式更新。setter 接受一个函数,参数是上一次的状态值。连续更新同一个状态时必须用这个形式:

// 这样写只会加 1,两次拿到的 count 是同一个快照
setCount(count + 1)
setCount(count + 1)

// 这样写才是加 2
setCount(c => c + 1)
setCount(c => c + 1)
@前端进阶之旅: 代码已经复制到剪贴板

原因是 count 是这次渲染闭包里的一个常量,在这次渲染的整个生命周期内它都不会变。这就是所谓的闭包陷阱,在 setTimeout、事件监听、异步回调里表现得最明显,因为它们捕获的可能是好几次渲染之前的值。

第三个是惰性初始化。useState(expensiveCalc()) 里的 expensiveCalc() 每次渲染都会被执行,只是返回值从第二次起被丢掉了。要避免这个浪费,把它包成函数传进去:

const [data, setData] = useState(() => expensiveCalc())
@前端进阶之旅: 代码已经复制到剪贴板

这三条里,第二条踩的人最多。

# 四、useContext():共享状态钩子

如果需要在组件之间共享状态,可以使用 useContext()。现在有两个组件 Navbar 和 Messages,我们希望它们之间共享状态:

<div className="App">
  <Navbar/>
  <Messages/>
</div>
@前端进阶之旅: 代码已经复制到剪贴板

第一步是使用 React Context API,在组件外部建立一个 Context:

const AppContext = React.createContext({});
@前端进阶之旅: 代码已经复制到剪贴板

组件封装代码如下:

<AppContext.Provider value={{
  username: 'superawesome'
}}>
  <div className="App">
    <Navbar/>
    <Messages/>
  </div>
</AppContext.Provider>
@前端进阶之旅: 代码已经复制到剪贴板

AppContext.Provider 提供了一个 Context 对象,这个对象可以被子组件共享。

Navbar 组件的代码如下:

const Navbar = () => {
  const { username } = useContext(AppContext);
  return (
    <div className="navbar">
      <p>AwesomeSite</p>
      <p>{username}</p>
    </div>
  );
}
@前端进阶之旅: 代码已经复制到剪贴板

useContext() 钩子函数用来引入 Context 对象,从中获取 username 属性。

Message 组件的代码也类似:

const Messages = () => {
  const { username } = useContext(AppContext)

  return (
    <div className="messages">
      <h1>Messages</h1>
      <p>1 message for {username}</p>
      <p className="message">useContext is awesome!</p>
    </div>
  )
}
@前端进阶之旅: 代码已经复制到剪贴板

上面这段代码有个隐患,value 写的是对象字面量。Provider 所在的组件每次重渲染,这个对象都是新引用,所有消费这个 Context 的组件都会跟着重渲染,哪怕 username 一个字都没变。

正确的做法是把 value 用 useMemo 稳住:

const value = useMemo(() => ({ username }), [username])
@前端进阶之旅: 代码已经复制到剪贴板

还有一个绕不过去的限制。Context 的更新粒度是整个 value 对象,你没法做到「只订阅 value 里的某个字段」。一个大 Context 里塞了用户信息、主题、语言三样东西,改语言会让读用户信息的组件也重渲染。所以实践中的做法是按变更频率拆分多个 Context,而不是塞进一个大对象。

这也是 Zustand、Jotai 这类库存在的理由之一,它们在订阅粒度上比 Context 细得多。

# 五、useReducer():action 钩子

  • React 本身不提供状态管理功能,通常需要使用外部库。这方面最常用的库是 Redux
  • Redux 的核心概念是,组件发出 action 与状态管理器通信。状态管理器收到 action 以后,使用 Reducer 函数算出新的状态,Reducer 函数的形式是 (state, action) => newState
  • useReducer() 钩子用来引入 Reducer 功能
const [state, dispatch] = useReducer(reducer, initialState);
@前端进阶之旅: 代码已经复制到剪贴板

上面是 useReducer() 的基本用法,它接受 Reducer 函数和状态的初始值作为参数,返回一个数组。数组的第一个成员是状态的当前值,第二个成员是发送 action 的 dispatch 函数。

下面是一个计数器的例子。用于计算状态的 Reducer 函数如下:

fe
  • 一、组件类的缺点
  • 二、Hook 的含义
  • 三、useState():状态钩子
    • 3.1 三个当年没讲清楚的点
  • 四、useContext():共享状态钩子
  • 五、useReducer():action 钩子
    • 5.1 什么时候该从 useState 换成 useReducer
  • 六、useEffect():副作用钩子
    • 6.1 请求竞态
    • 6.2 只订阅不清理
  • 七、Hooks 的两条规则
  • 八、2019 到现在,这套 API 变了什么
  • 总结
  • 参考

← React 新特性详解,memo、lazy、Suspense 与 Hooksawait 在 forEach 中不生效的原因与四种替代写法 →