React Hook之useState、useEffect和useContext

6,030 阅读15分钟

前言

一周的砖又快搬完了,又到了开心快乐的总结时间~这两周一直在 hook 函数的“坑”里,久久不能自拔。应该也不能叫做“坑”吧,还是自己太菜了,看文档不仔细,很多以为不重要,但在实际应用中却很关键的点总是被自己忽略。所以我准备多花点时间,把官网的一些 hook 函数,再回过头看一遍,整理整理(在整理的过程,我觉得更容易发现问题和总结经验)。

这篇文章主要整理一下 React 中的三个基础 Hook:

  • useState
  • useEffect
  • useContext

其他相关的 Hook 可以看一下我的另一篇文章:React Hook之“额外的 Hook”

useState

useState 相比其他 hooks 还是很简单的,主要就是用来定义变量。官方文档描述的也很清楚,对此已经很熟练的看官大大可以跳过哦~

相遇--初识

const [count, setCount] = useState(0)
  • 调用 useState 方法做了什么?

    定义一个“state变量”。

  • useState 需要什么参数?

    useState 方法接收一个参数,作为变量初始化的值。(示例中调用 useState 方法声明一个 “state变量” count,默认值为 0。)

  • useState 方法的返回值是什么?

    返回当前 state 以及更新 state 的函数。

相知--使用useState

React 会确保 setState 函数的标识是稳定的,并且不会在组件重新渲染时发生变化。这就是为什么可以安全地从 useEffect 或 useCallback 的依赖列表中省略 setState。

首先我们先通过 useState 方法定义三个变量(包含基本类型和引用类型的数据)分别为:count、studentInfo、subjectList,然后对它们的值进行修改。

const [count, setCount] = useState(0)

const [studentInfo, setStudentInfo] = useState({name: '小文', age: 18, gender: '女'})

const [subjectList, setSubjectList] = useState([
  { id: 0, project_name: '语文' },
  { id: 1, project_name: '数学' }
])
  • 修改 count 值为1
setCount(1)
  • 修改 studentInfo 对象的 age 属性,值为 20;并添加 weight 属性,值为 90
setStudentInfo({
  ...studentInfo,
  age: 20,
  weight: 90
})
  • 修改 subjectList 数组的第二项的 project_name 属性,值为体育;并添加第三项 { id: 2, project_name: '音乐' }
# 忽略这里的深拷贝,优雅的方式有很多:immutable.js、immer.js、loadsh
let temp_subjectList = JSON.parse(JSON.stringify(subjectList))
temp_subjectList[1].project_name = '体育'
temp_subjectList[2] = { id: 2, project_name: '音乐' }
setSubjectList(temp_subjectList)

我们在实际的开发中,会用到 React 提供的 Eslint 插件来检查 Hook 的规则和 effect 的依赖,当检测出某一块的代码缺少依赖时,会给出警告,如果给出的警告是缺少 setState 函数,那我们就可以忽略它。(后面讲到 useEffect 的时候会再补充)

React 会确保 setState 函数的标识是稳定的,并且不会在组件重新渲染时发生变化。这就是为什么可以安全地从 useEffect 或 useCallback 的依赖列表中省略 setState。(官网)

函数式更新

如果新的 state 需要通过使用先前的 state 计算得出,那么可以将函数传递给 setState。该函数将接收先前的 state,并返回一个更新后的值。(对于引用类型的数据,上面一样,例如数组。也是不可以直接对变量进行操作的)

使用上面定义的变量:

  • 点击按钮累加 count
<button onClick={() => setCount(prevCount => ++prevCount)}>+ 累加</button>
  • 修改 studentInfo 对象的 age 属性,值为 20
setStudentInfo(prevState => {
  # 也可以使用 Object.assign
  return {...prevState, age: 20}
})

惰性初始 state

如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的 state,此函数只在初始渲染时被调用:

const [state, setState] = useState(() => {
  const initialState = someExpensiveComputation(props)
  return initialState
})

实际应用

我们在实际应用中,经常会遇到一些结构比较复杂的数据,如果每个地方都使用 useState 去定义这些复杂结构的数据,估计会累死。

这里就分享一下我在项目中使用的一个插件 use-immer,一些基本类型数据和“直接替换的数据”(例如有一个数组 arr,对 arr 修改是直接赋值 setArr([...]),这样的数据我会选择用 useState 来声明,对于大部分引用类型的数据我会使用 use-immer 提供的 useImmer 方法来声明。下面我们就来看看它是如何使用的吧~

  1. 安装
npm install immer use-immer
  1. 引用
import { useImmer } from 'use-immer'
  1. 重新声明上面用到的 subjectList
const [subjectList, setSubjectList] = useImmer([
  { id: 0, project_name: '语文' },
  { id: 1, project_name: '数学' }
])
  1. 修改 subjectList 数组的第二项的 project_name 属性,值为体育;并添加第三项 { id: 2, project_name: '音乐' }
setSubjectList(draft => {
  draft[1].project_name = '体育'
  draft[2] = { id: 2, project_name: '音乐' }
})

需要注意的是,这里的 setSubjectList 方法接收的是一个函数,该函数接收一个参数 draft,可以理解为是变量 subjectList 的副本。这种写法是不是有种 “家” 的感觉呢,感兴趣的可以深入了解一下哦(immutable、immer、use-immer)。

useEffect

关于 useEffect 函数,我个人的建议是先把官网上的介绍看一遍,再多研读研读《useEffect 完整指南》。看完会发现,我们对它已经有了更加深刻的认识。这里也仅仅是我在学习的过程整理的笔记,内容就是《useEffect 完整指南》简化。

useEffect 会在浏览器绘制后延迟执行,但会保证在任何新的渲染前执行。React 将在组件更新前刷新上一轮渲染的 effect。

每一次渲染

重点:关于每一次渲染(rendering),组件都会拥有自己的:

  1. Props and State
  2. 事件处理函数
  3. Effects

Props and State

写一个计数器组件 Counter:

function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  )
}

Counter组件第一次渲染的时候,从 useState() 拿到 count 的初始值为 0。当我们调用 setCount(1)时,React 会再次渲染该组件,此时 count 的值为 1,以此类推,每一次渲染都是独立的。

# During first render
function Counter() {
  const count = 0; # Returned by useState()
  # ...
  <p>You clicked {count} times</p>
  # ...
}

# After a click, our function is called again
function Counter() {
  const count = 1; # Returned by useState()
  # ...
  <p>You clicked {count} times</p>
  # ...
}

# After another click, our function is called again
function Counter() {
  const count = 2; # Returned by useState()
  # ...
  <p>You clicked {count} times</p>
  # ...
}

Counter 组件中的 count 仅仅是一个常量,这个常量由 React 提供。当调用 setCount 的时候,React 会带着一个不同的 count 值再次调用组件。然后,React会更新DOM以保持和渲染输出一致。

最关键 的就是:任意一次渲染中的 count 常量都不会随着时间改变。渲染输出会变是因为 Counter 组件被调用,而在每一次调用引起的渲染中,它包含的 count 常量都是独立的。也就是说组件的每次渲染,props 和 state 都是独立的。

事件处理函数

修改一下计数器组件 Counter 的例子。

组件内容:有两个按钮,一个按钮用来修改 count 的值,另一个按钮在 3s 延迟后展示弹窗。

function Counter() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count)
    }, 3000)
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  )
}
  • 点击按钮修改 count 的值为 3。
  • 点击另一个按钮打开弹窗。
  • 在弹窗弹出前,点击按钮修改 count 的值为 5。

此时,弹窗中的展示的 count 值为 3。

分析:

首先整个过程进行了 6 次渲染。

  1. 初始化渲染:render0;
  2. 修改 count 值为 3,进行 3 次渲染:render1 -> render2 -> render3;
  3. 点击按钮打开弹窗,此时组件是 “render3 状态”;
  4. 修改 count 值为 5,进行 2 次渲染:render3 -> render5 -> render5;
# 组件状态:render0 -> render1 -> render2 -> render3
function Counter() {
  const count = 3
  # ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count)
    }, 3000)
  }
  # ...
}

# 组件状态: render3
# 触发事件处理函数 handleAlertClick,此时该函数捕获 count 值为 3,并将在 3 秒后打开弹窗。
function Counter() {
  const count = 3
  # ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + 3)
    }, 3000)
  }
  # ...
}

# 组件状态:render3 -> render5 -> render5
function Counter() {
  const count = 5
  # ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count)
    }, 3000)
  }
  # ...
}

Effects

修改 Counter 组件,点击 3 次按钮:

function Counter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`)
    }, 3000)
  })

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  )
}

分析: 整个过程组件进行了四次渲染:

  1. 初始化,render0:打印 You clicked 0 times;
  2. 修改 count 值为1,render1:打印 You clicked 1 times;
  3. 修改 count 值为2,render2:打印 You clicked 2 times;
  4. 修改 count 值为3,render3:打印 You clicked 3 times;

通过整个例子我们可以知道,在每次渲染中,useEffect 也是独立的。

并不是 coun t的值在“不变”的 effect 中发生了改变,而是 effect 函数本身在每一次渲染中都不相同。

清除 effect

当我们在 useEffect 中使用了定时器或者添加了某些订阅,可以通过 useEffect 返回一个函数,进行清除定时器或者取消订阅等操作。但我们需要知道的是,清除是 “滞后” 的。(这里是个人的理解,可能描述的不准确)

看一下例子,在 useEffect 中打印点击的次数:

function Example() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    console.log(`You clicked ${count} times`)
    return() => {
      console.log('销毁')
    }
  })

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  )
}

点击按钮 3 次,控制台中打印的结果如下:

  1. You clicked 0 times
  2. 销毁
  3. You clicked 1 times
  4. 销毁
  5. You clicked 2 times
  6. 销毁
  7. You clicked 3 times

从打印结果我们可以很容易看出,上一次的 effect 是在重新渲染时被清除的。

补充:那么组件的整个重新渲染的过程是怎么样的呢?

假设现在有 render0 和 render1 两次渲染:

  1. React 渲染 render1 的UI;
  2. 浏览器绘制,并呈现 render1 的UI;
  3. React 清除 render0 的 effect;
  4. React 运行 render1 的 effect;

React 只会在浏览器绘制后运行 effects。这使得你的应用更流畅因为大多数effects并不会阻塞屏幕的更新。

通过下面这个例子,来印证一下这个结论吧~

function Example() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    setCount(99)
    console.log(count)
    return() => {
      console.log('销毁')
    }
  })

  console.log('我肯定最先执行!')

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  )
}

运行代码,在控制台我们可以看到以下输出(此时组件状态为初始化:render0):

  1. 我肯定最先执行!
  2. 0
  3. 我肯定最先执行!
  4. 销毁
  5. 99
  6. 我肯定最先执行!

根据打印结果,我们可以分析出:

  1. React 渲染初始化的UI(render0);
  2. 执行 useEffect;
  3. 调用 setCount 方法修改 count 值为 99,组件重新渲染(render1);
  4. 根据执行顺序,继续执行 render0 状态下的 useEffect,打印 count 的值为 0。(render0 下 count 值为0)
  5. React 重新渲染UI(render1);
  6. 执行 render0 的 useEffect 的清除函数;
  7. 执行 render1 的 useEffect;
  8. 调用 setCount 方法修改 count 值为 99(由于传入的值没有改变,所以组件没有重新渲染);
  9. 打印 count 的值为 99;

其中 我肯定最先执行!,这个打印我理解的是:组件被调用了,React 判断是否需要渲染。然后才有了上面的一系列步骤,如果理解有误还请帮忙指出。

设置依赖

实际应用中,我们不需要在每次组件更新时,都去执行某些 effects,这个时候我们可以给 useEffect 设置依赖,告诉 React 什么时候去执行 useEffect。

看下面这个例子,只有在 name 发生改变时,才会执行这个 useEffect。如果将依赖设置为空数组,那么这个 useEffect 只会执行一次。

useEffect(() => {
  document.title = 'Hello, ' + name
}, [name])

正确地设置依赖

引出问题:首先需求很简单,通过定时器,每过一秒就将 count 的值累加 1。

const [count, setCount] = useState(0)

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1)
}, 1000)
  return () => clearInterval(id)
}, [])

我们只希望设置一次 setInterval 定时器,所以将依赖设置为了 [],但是由于组件每次渲染拥有独立的 state 和 effects,所以上面代码中的 count 值,一直是 0,当一次执行完 setCount后,后续的setCount操作都是无效的。

那既然这样,我们可以在依赖里面添加依赖 count就可以解决问题了吧?思路是正确的,但是这样就违背了我们 “我们只希望 setInterval 执行一次” 的初衷,且很可能造成一些不必要的bug。

解决方案:使用函数式更新(前面有讲到的哦)。

const [count, setCount] = useState(0)

useEffect(() => {
  const id = setInterval(() => {
    setCount(preCount => preCount + 1)
}, 1000)
  return () => clearInterval(id)
}, [])

但是在实际应用中,这种方式还远远不能满足我们的需求。比如在依赖多个数据的时候:

function Counter() {
  const [count, setCount] = useState(0)
  const [step, setStep] = useState(1)

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step)
    }, 1000);
    return () => clearInterval(id)
  }, [step])

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  )
}

当我们在修改 step 变量时,会重新设置定时器。这是我们不愿意看到的,那应该怎么去优化呢?这个时候我们就需要用到useReducer了。

当我们想更新一个状态,并且这个状态更新依赖于另一个状态的值时,我们可能需要使用useReducer去替换它们。

import React, { useReducer, useEffect } from 'react'
import ReactDOM from 'react-dom'

const initialState = {
  count: 0,
  step: 1,
}

function reducer(state, action) {
  const { count, step } = state
  if (action.type === 'tick') {
    return { count: count + step, step }
  } else if (action.type === 'step') {
    return { count, step: action.step }
  } else {
    throw new Error()
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState)
  const { count, step } = state

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' })
    }, 1000)
    return () => clearInterval(id)
  }, [dispatch])

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => {
        dispatch({
          type: 'step',
          step: Number(e.target.value)
        })
      }} />
    </>
  )
}

React 会保证 dispatch 在组件的声明周期内保持不变。

关于函数

在 useEffect 中调用了定义在外部的函数时,我们可能会遗漏依赖。所以我们可以将函数的定义放到useEffect中。

但是当我们有一些可复用的函数定义在外部,此时应该怎么处理呢?

  1. 如果这个函数没有使用组件内的任何值,我们可以将它放到组件外部定义。
function getFetchUrl(query) {
  return 'xxx?query=' + query
}

function SearchResults() {
  useEffect(() => {
    const url = getFetchUrl('react')
  }, [])

  useEffect(() => {
    const url = getFetchUrl('redux')
  }, [])
}
  1. 使用 useCallback 包装。
function SearchResults() {
  const [query, setQuery] = useState('react')

  const getFetchUrl = useCallback(() => {
    return 'xxx?query=' + query
  }, [query])

  useEffect(() => {
    const url = getFetchUrl()
  }, [getFetchUrl])
}

如果 query 保持不变,getFetchUrl也会保持不变,我们的 effect 也不会重新运行。但是如果 query 修改了,getFetchUrl 也会随之改变,因此会重新请求数据。

useCallback本质上是添加了一层依赖检查。它以另一种方式解决了问题 - 我们使函数本身只在需要的时候才改变,而不是去掉对函数的依赖。

关于 useReducer和 useCallback 的更多内容,我会在后面的笔记中整理出来。

useContext

useContext的使用场景。

在一个典型的 React 应用中,数据是通过 props 属性自上而下(由父及子)进行传递的,但这种做法对于某些类型的属性而言是极其繁琐的(例如:地区偏好,UI 主题),这些属性是应用程序中许多组件都需要的。Context 提供了一种在组件之间共享此类值的方式,而不必显式地通过组件树的逐层传递 props。(引用自官网)

直接看一个例子吧~

  1. 创建顶层组件 Container。
import React, { useState, createContext } from 'react'
import Child1 from './Child1'
import Child2 from './Child2'

// 创建一个 Context 对象
export const ContainerContext = createContext({})

function Container() {
  const [state, setState] = useState({child1Color: 'pink', child2Color: 'skyblue'})
  const changeChild1Color = () => {
    setState({
      ...state,
      child1Color: 'lightgreen'
    })
  }

  return (
    <>
      <ContainerContext.Provider value={state}>
        <Child1></Child1>
        <Child2></Child2>
      </ContainerContext.Provider>
      <button onClick={changeChild1Color}>修改child1颜色</button>
    </>
  )
}

export default Container
  1. 创建子组件Child1。
import React, {useContext} from 'react'
import { ContainerContext } from './Container'

function Child1() {
  const value = useContext(ContainerContext)
  return <h1 style={{color: value.child1Color}}>我是Child1组件</h1>
}

export default Child1
  1. 创建子组件Child2。
import React, {useContext} from 'react'
import { ContainerContext } from './Container'

function Child2() {
  const value = useContext(ContainerContext)
  return <h1 style={{color: value.child2Color}}>我是Child2组件</h1>
}

export default Child2

我们可以通过这个简单的 demo 来了解一下 useContext 相关的基础知识。

基本使用方法分析:

  1. 通过 React 提供的 createContext 方法创建一个 Context 对象。

    Container组件中,通过export const ContainerContext = createContext({})创建了一个 Context 对象,并设置了默认值为 {}。

    注意:默认值只有在组件所处的树中没有匹配到 Provider 时,默认值才会生效。

    稍微修改一下 Container 中返回的组件,这个时候 Child1 和 Child2 读取的 context 就是创建 Context 对象时的默认值了。

    <>
      <Child1></Child1>
      <Child2></Child2>
      <button onClick={changeChild1Color}>修改child1颜色</button>
    </>
    
  2. 每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化。

    Container 组件中的 Context 对象返回 ContainerContext.Provider 组件,它接收一个value 属性,传递给消费组件。同时包裹在其内部的消费组件(Child1和Child2)可以订阅 context 的变化。

    一个 Provider React 组件可以和多个消费组件有对应关系。多个 Provider React 组件 也可以嵌套使用,里层的会覆盖外层的数据。

  3. 在消费组件中,使用 useContext 订阅 context。

    注意:useContext 的参数必须是 context 对象本身。

基本使用方式就是这样了,由于useContext我用的还比较少,这里就先不做过多的介绍了。

值得注意的是:

  1. 订阅了 context 的组件,总会在 context 值变化时重新渲染。如果重渲染组件的开销较大,可以通过使用 memoization 来优化。
  2. 使用 Context 一定程度上会使组件的复用性降低,我们需要合理的取舍。

总结

学习的过程中,我经常会有种错觉:我会了。其实这种“会”,也只是对某个知识点的“眼熟”。真正需要动手去完成的时候,就会发现一头雾水。就像刚开始接触前端的时候,看到别人的代码,总会恍然大悟,但自己却写不出来一样。再加上很多知识点学过的那两天可以记得,但是一段时间不用就会遗忘,又要重新学习。

所以我想要改变这种困境,通过整理自己的学习过程,加深印象的同时也方便以后查阅。

希望这篇文章对你同样也有所帮助,如果有建议欢迎留言~

啰嗦了这么多,小伙伴们给我留个赞吧,谢谢~

参考文章:

useEffect 完整指南
react 官网