Skip to content

React Hooks


一、什么是 Hooks

有状态的函数式组件。

  • React 认为,UI 视图是数据的一种视觉映射,即UI = F(DATA),这里的 F 需要负责对输入数据进行加工、并对数据的变更做出响应
  • 公式里的F在 React 里抽象成组件,React 是以组件(Component-Based)为粒度编排应用的,组件是代码复用的最小单元
  • 在设计上,React 采用 props 属性来接收外部的数据,使用 state 属性来管理组件自身产生的数据(状态),而为了实现(运行时)对数据变更做出响应需要,React 采用基于类(Class)的组件设计!
  • React 一直都提倡使用函数组件,但是有时候需要使用 state 或者其他一些功能时,只能使用类组件,因为函数组件没有实例,没有生命周期函数,只有类组件才有;
  • Hooks 是 React 16.8 新增的特性,它可以在不编写 class 的情况下使用 state 以及其他的 React 特性
  • 如果你在编写函数组件并意识到需要向其添加一些 state,以前的做法是必须将其它转化为 class。现在你可以直接在现有的函数组件中使用 Hooks 凡是 use 开头的 React API 都是 Hooks

二、Hooks 解决的问题

1. 类组件的不足

  • 状态逻辑难复用: 在组件之间复用状态逻辑很难,可能要用到 render props (渲染属性)或者 HOC(高阶组件),但无论是渲染属性,还是高阶组件,都会在原先的组件外包裹一层父容器(一般都是 div 元素),导致层级冗余

HOC 使用(老生常谈)的问题

  • 嵌套地狱,每一次 HOC 调用都会产生一个组件实例
  • 可以使用类装饰器缓解组件嵌套带来的可维护性问题,但装饰器本质上还是 HOC
  • 包裹太多层级之后,可能会带来 props 属性的覆盖问题

Render Props:

  • 数据流向更直观了,子孙组件可以很明确地看到数据来源

  • 但本质上Render Props是基于闭包实现的,大量地用于组件的复用将不可避免地引入了 callback hell 问题

  • 丢失了组件的上下文,因此没有this.props属性,不能像 HOC 那样访问this.props.children

  • 趋向复杂难以维护:

    • 在生命周期函数中混杂不相干的逻辑(如:在 componentDidMount 中注册事件以及其他的逻辑,在 componentWillUnmount 中卸载事件,这样分散不集中的写法,很容易写出 bug )
    • 类组件中到处都是对状态的访问和处理,导致组件难以拆分成更小的组件
  • this 指向问题:父组件给子组件传递函数时,必须绑定 this,react 中的组件四种绑定 this 方法的区别

  • webpack 编译后 class 的 size 要比 function 组件大,性能也没 function 好(Function Component 编译后就是一个普通的 function,function 对 js 引擎是友好的)

  • Function Component 是纯函数,利于组件复用和测试

Hooks 优势

  • 能优化类组件的三大问题
  • 能在无需修改组件结构的情况下复用状态逻辑(自定义 Hooks )
  • 能将组件中相互关联的部分拆分成更小的函数(比如设置订阅或请求数据)
  • 副作用的关注点分离:副作用指那些没有发生在数据向视图转换过程中的逻辑,如 ajax 请求、访问原生 dom 元素、本地持久化缓存、绑定/解绑事件、添加订阅、设置定时器、记录日志等。以往这些副作用都是写在类组件生命周期函数中的。而 useEffect 在全部渲染完毕后才会执行,useLayoutEffect 会在浏览器 layout 之后,painting 之前执行。

注意事项

  • 只能在函数内部的最外层调用 Hook,不要在循环、条件判断或者子函数中调用
  • 只能在 React 的函数组件中调用 Hook,不要在其他 JavaScript 函数中调用

hooks

React Hooks 能够让函数组件拥有内部状态的基本原理

利用闭包,记住了上一次的值,如下

javascript
const useState = (function () {
  let state = null
  return function (value) {
    // 第一次调用时没有初始值,因此使用传入的初始值赋值
    state = state || value
    function dispatch(newValue) {
      state = newValue
      console.log('render happen')
    }
    return [state, dispatch]
  }
})()

function Demo() {
  const [counter, setCounter] = useState('0')
  console.log(counter)
  return function (value) {
    setCounter(value)
  }
}

const render = Demo() // log 0
render(12)
Demo() // log 12
Demo() // log 12

更为相似的例子

typescript
// state.js
let state = null

export const useState = (value: number) => {
  // 第一次调用时没有初始值,因此使用传入的初始值赋值
  state = state || value

  function dispatch(newValue) {
    state = newValue
    // 假设此方法能触发页面渲染
    render()
  }

  return [state, dispatch]
}

在其他模块中引入并使用。

typescript
import React from 'react'
import { useState } from './state'

function Demo() {
  // 使用数组解构的方式,定义变量
  const [counter, setCounter] = useState(0)

  return (
    <div onClick={() => setCounter(counter + 1)}>
      hello world, {counter}
    </div>
  )
}

export default Demo()

执行上下文 state(模块 state)以及在 state 中创建的函数 useState

当 useState 在 Demo 中执行时,访问了 state 中的变量对象,那么闭包就会产生。

react hooks 提供的 api,大多都有记忆功能。例如

  • useState
  • useEffect
  • useLayoutEffect
  • useReducer
  • useRef
  • useMemo 记忆计算结果
  • useCallback 记忆函数体

useState

每次渲染都是独立的闭包, setTimeout中打印的是上一次的值

jsx
function Test() {
  console.log('render 1')
  const [state, setSate] = useState(0)
  function AlertNum() {
    setSate(state + 1)
    setTimeout(() => {
      setSate((number) => number + 1)
      // setState(state+1) // 加1不起作用,因为这个state是之前的那个及0,而之前就已经加1变成1了,
      // setState(state + 2)// state会变成2
      alert(state) // 0
    }, 3000)
  }
  return (
    <div>
      <p>{state}</p>
      <button onClick={() => setSate((prev) => prev + 1)}>add</button>
      <br />
      <button onClick={AlertNum}>AlertNum</button>
    </div>
  )
}
jsx
function Demo() {
	console.log('render 1')
	const [state, setState] = useState(111)

	const [obj, setObj] = useState({
		a: 1,
		b: 2
	})
	function test() {
		obj.a = 2
		// setObj({ ...obj, a: 12})
		setObj(obj) // 不会导致render,所以不会渲染,但是当state变化,Demo组件会重新render, 而由于闭包的特性, obj的a已经变化,所以显示的a也会变成2
		console.log(obj)
	}
    return ()
}

使用 memo 的区别

jsx
import React, { memo, useState } from 'react'

function Counter1(props) {
  console.log(`Counter ${props.name} render`)

  // 这个函数只在初始渲染时执行一次,后续更新状态重新渲染组件时,该函数就不会再被调用
  function getInitState() {
    return { number: props.number }
  }

  let [counter, setCounter] = useState(getInitState)
  let [counter1, setCounter2] = useState(props.number) // props变化时,这个counter1依然是第一次的值
  return (
    <>
      <h1>name: {props.name}</h1>
      <p>{counter.number}</p>
      <button onClick={() => setCounter({ number: counter.number + 1 })}>
        +
      </button>
      <button onClick={() => setCounter(counter)}>setCounter</button>
    </>
  )
}

const Counter1Memo = memo(Counter1)

function Test() {
  console.log('render 1')
  const [state, setSate] = useState(0)

  function AlertNum() {
    setSate(state + 1)
    setTimeout(() => {
      setSate((number) => number + 1)
      // setState(state+1) // 加1不起作用,因为这个state是之前的那个及0,而之前就已经加1变成1了,
      // setState(state + 2)// state会变成2
      alert(state) // 0
    }, 3000)
  }
  // 但state变化时
  // name 是1,2,3的都要render
  return (
    <div>
      <p>{state}</p>
      <button onClick={() => setSate((prev) => prev + 1)}>add</button>
      <br />
      <button onClick={AlertNum}>AlertNum</button>
      <Counter1 number={state} name="1" key={1} />
      <Counter1 number={12} name="2" key={2} />
      <Counter1Memo number={state} name="3" key={3} />
      <Counter1Memo number={12} name="4" key={4} />
    </div>
  )
}

export default Test
jsx
import React, { memo, useCallback, useMemo, useState } from 'react'

function SubCounter({ onClick, data }) {
  console.log('SubCounter render')
  return <button onClick={onClick}>{data.number}</button>
}

const SubCounter2 = memo(SubCounter)

let oldData
let oldAddClick
export default function Counter2() {
  console.log('Counter render')
  const [name, setName] = useState('计数器')
  const [number, setNumber] = useState(0)
  // 父组件更新时,这里的变量和函数每次都会重新创建,那么子组件接受到的属性每次都会认为是新的
  // 所以子组件也会随之更新,这时候可以用到 useMemo
  // 有没有后面的依赖项数组很重要,否则还是会重新渲染
  // 如果后面的依赖项数组没有值的话,即使父组件的 number 值改变了,子组件也不会去更新
  // const data = useMemo(()=>({number}),[]);
  const data = useMemo(() => ({ number }), [number]) // number变化了sub才会重新render,name变了不会
  console.log('data===oldData ', data === oldData)
  oldData = data

  // 有没有后面的依赖项数组很重要,否则还是会重新渲染
  const addClick = useCallback(() => {
    setNumber(number + 1)
  }, [number]) // number变化了sub才会重新render,name变了不会
  console.log('addClick===oldAddClick ', addClick === oldAddClick)
  oldAddClick = addClick
  return (
    <>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <SubCounter2 data={data} onClick={addClick} />
    </>
  )
}

useEffect

Here's the crux of the issue: useEffect is not a lifecycle hook. It's a mechanism for synchronizing side effects with the state of your app.

依赖数组使用的Object.is()对比

useEffect 解决了哪些问题

  1. 函数组件没有生命周期。

  2. ajax、事件绑定等业务逻辑耦合在生命周期中

  3. 业务逻辑散乱在不同的生命周期中

Effect Hook 可以让你在函数组件中执行副作用操作。数据获取,设置订阅以及手动更改 React 组件中的 DOM 都属于副作用。类比于 class component,可以把 useEffect Hook 看做 componentDidMount,componentDidUpdate 和 componentWillUnmount 这三个函数的组合。但也不尽相同。

在 function 组件中,每当 DOM 完成一次渲染,都会有对应的副作用执行,useEffect 用于提供自定义的执行内容,它的第一个参数(作为函数传入)就是自定义的执行内容。为了避免反复执行,传入第二个参数(由监听值组成的数组)作为比较(浅比较)变化的依赖,比较之后值都保持不变时,副作用逻辑就不再执行。 useEffect 还是异步执行的,所谓的异步就是被 React 使用 requestIdleCallback 封装的,只在浏览器空闲时候才会执行,这就保证了不会阻塞浏览器的渲染过程。

    1. 只在第一次渲染时执行,第二个参数传空数组。即没有传入比较变化的变量,则比较结果永远都保持不变,那么副作用逻辑就只能执行一次。
jsx
const [list, setList] = useState(0)

// DOM渲染完成之后副作用执行
useEffect(() => {
  recordListApi().then((res) => {
    setList(res.data)
  })
  // 记得第二个参数的使用
}, [])
    1. 创造一个变量,来作为变化值,实现目的的同时防止循环执行
jsx
import React, { useState, useEffect } from 'react'
import './style.scss'

export default function AnimateDemo() {
  const [list, setList] = useState(0)
  const [loading, setLoading] = useState(true)

  // DOM渲染完成之后副作用执行
  useEffect(() => {
    if (loading) {
      // 自身判断是否执行
      recordListApi().then((res) => {
        setList(res.data)
        setLoading(false)
      })
    }
  }, [loading])

  return (
    <div className="container">
      <button onClick={() => setLoading(true)}>点击刷新</button>

      <FlatList data={list} />
    </div>
  )
}
  1. return 一个 clear 函数清除副作用
  • 每次副作用执行,都会返回一个新的 clear 函数
  • clear 函数会在下一次副作用逻辑之前执行(DOM 渲染完成之后)
  • 组件销毁也会执行一次

componentWillUnmount不一样,componentWillUnmount整个过程中只执行一次。

例子

js
useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange)
  function clear() {
    ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange)
  }
  return clear
})

假设在组件的使用过程中,外部传入的 props 参数 id,改变了两次,第一次传入id: 1, 第二次传入id: 2

整个过程是:

  1. 传入props.id = 1
  2. 组件渲染
  3. DOM 渲染完成,副作用逻辑执行,返回清除副作用函数clear,命名为clear1
  4. 传入props.id = 2
  5. 组件渲染
  6. 组件渲染完成,clear1执行
  7. 副作用逻辑执行,返回另一个clear函数,命名为clear2
  8. 组件销毁,clear2执行

下面的打印顺序是:

jsx
import React, { useState, useEffect } from 'react'

export default function AnimateDemo() {
  const [counter, setCounter] = useState(0)

  useEffect(() => {
    const timer = setTimeout(() => {
      console.log('setCounter')
      setCounter(counter + 1)
    }, 3000)
    console.log('effect:', timer)

    return () => {
      console.log('clear:', timer)
      clearTimeout(timer)
    }
  })

  console.log('before render')

  return (
    <div className="container">
      <div className="el">{counter}</div>
    </div>
  )
}
git
before render
effect: 0
setCounter --- 3s后
before render
clear: 0 -- 第二次渲染完成,执行上一次返回的clear函数
effect: 1
before render
clear: 1
effect: 2
before render
clear: 2
...
clear: xx -- 组件销毁时

第一次渲染是打印 render,并且执行副作用函数, 打印 effect,并且返回清除副作用的函数 clear, 3 秒后打印 setCounter,执行setCounter, 组件重新渲染,打印 render,渲染完成后执行上一次的 clear,接着执行副作用函数,一直循环,直到销毁时执行 clear 函数。

和 setInterval

jsx
import React, { useState, useEffect } from 'react'

export default function App() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1)
    }, 1000)
    return () => clearInterval(id)
  }, [])
  return <div style={{ fontSize: '100px' }}>{count}</div>
}

错误示例,初始显示 0,一秒后永远都会显示 1,同样的代码用 class 组件来实现,就不会有这个问题, class 组件和函数组件的代码的差异在于,class 组件中的 this.state 是可变的!每一次的更新都是对 state 对象的一个更新,一次又一次的 setInterval 中引用的都会是新 state 中的值。 然而在函数组件中情况就不一样了。函数组件由于每次更新都会经历重新调用的过程,useEffect(callback) 中的回调函数都是全新的,这样其中引用到的 state 值将只跟当次渲染绑定。

jsx
import React, { useState, useEffect } from 'react'

export default function App() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const id = setInterval(() => {
      // 这里
      setCount((prevState) => prevState + 1)
    }, 1000)
    return () => clearInterval(id)
  }, [])
  return <div style={{ fontSize: '100px' }}>{count}</div>
}

使用 useRef

typescript
import React, { useState, useEffect, useRef } from 'react'

function App() {
  const [counter, setCounter] = useState(0)

  const ref = useRef(null)

  function addCounter() {
    console.log(counter)
    setCounter(counter + 1)
  }

  useEffect(() => {
    ref.current = addCounter // 重新赋值current,实际上是addCounter函数每次重新生成,所以引用的counter是最新的
  })

  useEffect(() => {
    const id = setInterval(() => {
      ref.current && ref.current()
    }, 1000)
    return () => clearInterval(id)
  }, [])

  return <p>{counter}</p>
}

export default App
jsx
function Counter() {
  const [count, setCount] = useState(0)
  const savedCallback = useRef()

  function callback() {
    setCount(count + 1)
  }

  useEffect(() => {
    savedCallback.current = callback
  })

  useEffect(() => {
    function tick() {
      savedCallback.current()
    }

    let id = setInterval(tick, 1000)
    return () => clearInterval(id)
  }, [])

  return <h1>{count}</h1>
}

自定义 hooks

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

  useInterval(() => {
    setCount(count + 1)
  }, 1000)

  return <h1>{count}</h1>
}

function useInterval(callback, delay) {
  const savedCallback = useRef()

  useEffect(() => {
    savedCallback.current = callback
  })

  useEffect(() => {
    function tick() {
      savedCallback.current()
    }

    let id = setInterval(tick, delay)
    return () => clearInterval(id)
  }, [delay])
}

useEffect 中不能使用 async function

ahook 的 useAsyncEffect

ts
import type { DependencyList } from 'react'
import { useEffect } from 'react'

function useAsyncEffect(
  effect: () => AsyncGenerator<void, void, void> | Promise<void>,
  deps: DependencyList
) {
  function isGenerator(
    val: AsyncGenerator<void, void, void> | Promise<void>
  ): val is AsyncGenerator<void, void, void> {
    return typeof val[Symbol.asyncIterator] === 'function'
  }
  useEffect(() => {
    const e = effect()
    let cancelled = false
    async function execute() {
      if (isGenerator(e)) {
        while (true) {
          const result = await e.next()
          if (cancelled || result.done) {
            break
          }
        }
      } else {
        await e
      }
    }
    execute()
    return () => {
      cancelled = true
    }
  }, deps)
}

export default useAsyncEffect

使用 guide

React 中有两个重要的概念:

  1. Rendering code(渲染代码)
  2. Event handlers(事件处理器)
  • Rendering code指「开发者编写的组件渲染逻辑」,最终会返回一段JSX。比如,如下组件内部就是 Rendering code:
tsx
function App() {
  const [name, update] = useState('Song')

  return <div>Hello {name}</div>
}

Rendering code 的特点是:他应该是不带副作用的纯函数

  • Event handlers是「组件内部包含的函数」,用于执行用户操作,可以包含副作用

下面这些操作都属于 Event handlers:

  • 更新 input 输入框
  • 提交表单
  • 导航到其他页面

如下例子中组件内部的changeName方法就属于Event handlers

tsx
function App() {
  const [name, update] = useState('Song')

  const changeName = () => {
    update('Sysuke')
  }

  return <div onClick={changeName}>Hello {name}</div>
}

但是,并不是所有副作用都能在Event handlers中解决。

比如,在一个聊天室中,「发送消息」是用户触发的,应该交给Event handlers处理。

除此之外,聊天室需要随时保持和服务端的长连接,「保持长连接」的行为属于副作用,但并不是用户行为触发的。

对于这种:在视图渲染后触发的副作用,就属于 effect,应该交给 useEffect 处理。

conclusion

当我们编写组件时,应该尽量将组件编写为纯函数

对于组件中的副作用,首先应该明确: 是「用户行为触发的」还是「视图渲染后主动触发的」?

执行顺序

tsx
const Child: FC<{ name: string }> = ({ name }) => {
  useEffect(() => {
    console.log(name + ' effect')
    return () => {
      console.log(name + ' clear')
    }
  }, [])
  return <div>{name}</div>
}
const Parent = () => {
  useEffect(() => {
    console.log('Parent effect')
    return () => {
      console.log('Parent clear')
    }
  }, [])
  return (
    <div>
      Parent
      <Child name="child1" />
      <Child name="child2" />
    </div>
  )
}

const App = () => {
  const [show, setShow] = useState(true)
  return (
    <>
      <button onClick={() => setShow(!show)}>show</button>
      {show && <Parent />}
    </>
  )
}

如果是StrictMode组件会 render 两次,如果你安装了React DevTools,第二次渲染的日志信息将显示为灰色,以柔和的方式显式在控制台。

子组件的 effet 首先执行,然后执行父组件的

子组件的 clean 执行,然后父组件的 clean 也执行,顺序和 effect 执行顺序一致

useLayoutEffect

js
useLayoutEffect(() => {
  // do side effects
  return () => {} /* cleanup */
}, [dependency, array]);

会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。这是和useEffect唯一的区别。

  1. useLayoutEffectcomponentDidMountcomponentDidUpdate触发时机一致(都在在 DOM 修改后且浏览器渲染之前);
  2. useLayoutEffect要比useEffect更早的触发执行;
  3. useLayoutEffect会阻塞浏览器渲染,切记执行同步的耗时操作。

解析 useEffect 和 useLayoutEffect

深入理解 React useLayoutEffect 和 useEffect 的执行时机

useEffect 和 useLayoutEffect 的区别

useEffect 在渲染时是异步执行,并且要等到浏览器将所有变化渲染到屏幕后才会被执行。

useLayoutEffect 在渲染时是同步执行,其执行时机与 componentDidMount,componentDidUpdate 一致

除非要修改 DOM 并且不让用户看到修改 DOM 的过程,才考虑使用 useLayoutEffect,否则应当使用 useEffect。

jsx
export default function FuncCom() {
  const [counter, setCounter] = useState(0)

  useEffect(() => {
    if (counter === 12) {
      // 耗时的操作 500ms
      const pre = Date.now()
      while (Date.now() - pre < 500) {}
      setCounter(2)
    }
  })
  return (
    <div
      style={{
        fontSize: '100px'
      }}
    >
      <div onClick={() => setCounter(12)}>{counter}</div>
    </div>
  )
}

初始屏幕上是 0,当点击触发 setCounter 后,屏幕上先是出现了 12,最后变为了 2:

换成了 useLayoutEffect 后,屏幕上只会出现 0 和 2,这是因为 useLayoutEffect 的同步特性,会在浏览器渲染之前同步更新 DOM 数据,哪怕是多次的操作,也会在渲染前一次性处理完,再交给浏览器绘制。这样不会导致闪屏现象发生。

但如果在if (counter === 12) {这里 F12 debug,屏幕上会显示 12。

ssr

next.js的ssr中使用useLayoutEffect会有warning,由于 useLayoutEffect 是同步执行的,它会在服务器端和客户端都执行,但由于服务器端没有真实的 DOM 环境,可能会导致一些问题,例如引起样式计算的阻塞,或者导致服务器端和客户端的 DOM 结构不一致。

为了解决这个问题,Next.js 引入了 useEffect 的替代方案 useLayoutEffect,该钩子函数在服务器端渲染时会被自动替换为 useEffect,以确保在服务器端渲染过程中不会触发同步的 DOM 操作。

useEffect happens *after* mount/update, but the server doesn’t mount so it doesn’t happen. it [useEffect] won’t run on the server, but it also won’t warn.

useEffect也不会在ssr时运行,他被设计来就是在dom渲染后的执行副作用。可以用在ssr,但不会run,也不会像useLayoutEffect那样有 warning。

自定义 Hooks

自定义 hooks 都会以use开头,以表示该方法只能在函数式组件中使用。感觉就是对原有函数组件中依赖于 state 的逻辑的抽离

自定义 Hooks 实现了逻辑片段复用

而和普通函数更强一点的是,自定义 hooks 还能够封装异步逻辑片段。

example

typescript
// .useEqualArr.tsx
import { useState } from 'react'

function equalArr(a: number[], b: number[]) {
  if (a.length !== b.length) {
    return false
  }
  if (a.length === 0 && b.length === 0) {
    return true
  }
  return a.every((item, i) => item === b[i])
}

export default function useEqualArr() {
  const [arrA, setArrA] = useState<number[]>([])
  const [arrB, setArrB] = useState<number[]>([])
  const isEqual = equalArr(arrA, arrB)

  return {
    arrA,
    setArrA,
    arrB,
    setArrB,
    isEqual
  }
}

使用

typescript
import React from 'react'
import useEqualArr from './useEqualArr'

export default function EqualArr() {
  const { arrA, arrB, setArrA, setArrB, isEqual } = useEqualArr()
}

example:封装一个公用页面初次加载 Hooks

typescript
import { useState, useEffect } from 'react'

export default function useInitial<T, P, V>(
  api: (params: P) => Promise<T>,
  params: P,
  defaultData: V
) {
  const [loading, setLoading] = useState(true)
  const [response, setResponse] = useState(defaultData)
  const [errMsg, setErrmsg] = useState('')

  useEffect(() => {
    if (!loading) {
      return
    }
    getData()
  }, [loading])

  function getData() {
    api(params)
      .then((res) => {
        setResponse(res)
      })
      .catch((e) => {
        setErrmsg(errMsg)
      })
      .finally(() => {
        setLoading(false)
      })
  }

  return {
    loading,
    setLoading,
    response,
    errMsg
  }
}

在页面中使用

typescript
export default function FunctionDemo() {
  // 只需要传入api, 对应的参数与返回结果的初始默认值即可
  const { loading, setLoading, response, errMsg } = useInitial(
    api,
    { id: 10 },
    {}
  )
}

刷新页面: setLoading(true);

useReducer

typescript
import React, { useReducer } from 'react'
import { Button } from 'antd'

enum Actions {
  Increment = 'Increment',
  Decrement = 'Decrement',
  Rest = 'Rest'
}

const Reducer = (state: number, action: Actions) => {
  switch (action) {
    case Actions.Increment:
      return state + 1
    case Actions.Decrement:
      return state - 1
    case Actions.Rest:
      return 0
    default:
      return state
  }
}

export default function ReactHooksWay() {
  const initialState: number = 0
  const [counter, dispatch] = useReducer(Reducer, initialState)
  return (
    <div>
      <h3>counter: {counter}</h3>
      <Button onClick={() => dispatch(Actions.Increment)}>
        {Actions.Increment}
      </Button>
      <Button onClick={() => dispatch(Actions.Decrement)}>
        {Actions.Decrement}
      </Button>
      <Button onClick={() => dispatch(Actions.Rest)}>
        {Actions.Rest}
      </Button>
    </div>
  )
}
  • 使用场景:为了简单起见,如果你的状态依赖其他状态和上次的值,考虑使用 useReducer,而不是使用很多个 useState

使用useReducer优化:

tsx
import { useEffect, useState } from 'react'

export function CounterOne() {
  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))}
      />
    </>
  )
}

现在count的值不仅仅依赖自己上一次的值,还依赖step状态。 它可以正确运行。但问题在于每一次改变step后,计时器都会被销毁重建

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

当写类似setSomething(something => ...)这种代码的时候,也许就是考虑使用reducer的契机。reducer可以让你把组件内发生了什么(actions)和状态如何响应并更新分开表述。

tsx
import { useEffect, useReducer } from 'react'

type State = {
  count: number
  step: number
}

enum Action {
  TICK = 'tick',
  STEP = 'step',
}

const initialState: State = {
  count: 0,
  step: 1,
}
function reducer(
  state: State,
  action: { type: Action.TICK } | { type: Action.STEP; val: number }
) {
  const { count, step } = state
  if (action.type === Action.TICK) {
    return { count: count + step, step }
  } else if (action.type === Action.STEP) {
    return { count, step: action.val }
  } else {
    throw new Error()
  }
}
export function CounterReducer() {
  const [state, dispatch] = useReducer(reducer, initialState)
  const { count, step } = state

  useEffect(() => {
    console.log('two')

    const id = setInterval(() => {
      dispatch({ type: Action.TICK }) // 更新count
    }, 1000)
    return () => clearInterval(id)
  }, [dispatch])

  return (
    <>
      <h1>{count}</h1>
      <input
        value={step}
        onChange={(e) => {
          // 更新step
          dispatch({
            type: Action.STEP,
            val: Number(e.target.value),
          })
        }}
      />
    </>
  )
}

使用useReducer可以完美解决上述问题,因为React会保证dispatch在组件的声明周期内保持不变

可以从依赖中去除dispatch, setState, 和useRef包裹的值因为React会确保它们是静态的。不过设置了它们作为依赖也没什么问题。

与其在Effect中去获取状态,不如只是dispatch一个action来描述行为,这使得Effect与状态解耦,Effect再也不用关心具体的状态了~

Example from: useEffect你真的会用嘛

useContext

ContextProvider

typescript
import React, { createContext, useState, Dispatch, ReactNode } from 'react'

interface Injected {
  counter: number
  setCounter: Dispatch<any>
  increment: () => any
  decrement: () => any
}
// eslint-disable-next-line
export const context = createContext<Injected>({} as Injected)

interface Props {
  children?: ReactNode
}

export function CounterProvider({ children }: Props) {
  const [counter, setCounter] = useState(0)

  const value = {
    counter,
    setCounter,
    increment: () => setCounter(counter + 1),
    decrement: () => setCounter(counter - 1)
  }

  return <context.Provider value={value}>{children}</context.Provider>
}
typescript
import React, { useContext } from 'react'
import { Button } from 'antd'
import { context, CounterProvider } from './ContextProvider'

function Counter() {
  const { counter = 0, increment, decrement } = useContext(context)

  return (
    <div style={{ width: '400px' }}>
      <h3>第一层组件</h3>
      <div
        style={{ width: '40px', margin: '100px auto', fontSize: '40px' }}
      >
        {counter}
      </div>
      <Button onClick={increment}>递增</Button>
      <Button onClick={decrement}>递减</Button>
      <TwoChild />
      <MemoTwoChild />
    </div>
  )
}

function TwoChild() {
  console.log('render')
  return (
    <div>
      <h2>第二层组件</h2>
      <p>hello</p>
      <ThirdChild />
    </div>
  )
}

const MemoTwoChild = React.memo(TwoChild) // context变化时,不会打印render

function ThirdChild() {
  const { counter = 0, increment, decrement } = useContext(context)

  return (
    <div style={{ width: '200px', margin: 'auto' }}>
      <h3>第三层组件</h3>
      <div
        style={{ width: '40px', margin: '100px auto', fontSize: '40px' }}
      >
        {counter}
      </div>
      <Button onClick={increment}>递增</Button>
      <Button onClick={decrement}>递减</Button>
    </div>
  )
}

export default () => (
  <CounterProvider>
    <Counter />
  </CounterProvider>
)

useRef

在函数式组件中,useRef 是一个返回可变引用对象的函数。该对象.current属性的初始值为 useRef 传入的参数initialVale

返回的对象将在组件整个生命周期中持续存在。当 useRef 的内容发生变化时,它不会通知。更改.current属性不会导致重新 render 呈现。因为它一直是一个引用。

const ref = useRef(initialValue);

通常情况下,useRef 有两种用途,

  1. 访问 DOM 节点,或者 React 元素 自定义组件 ref,使用 React.createRef()或者useRef,外加React.forwardRef
jsx
const FancyButton = React.forwardRef((props, ref) => (
  <button ref={ref} className="FancyButton">
    {props.children}
  </button>
))

// 你可以直接获取 DOM button 的 ref:
const ref = React.createRef()
;<FancyButton ref={ref}>Click me!</FancyButton>

hooks way

typescript
import React, { forwardRef, useState, ChangeEvent } from 'react'

export interface InputProps {
  value?: string
  onChange?: (value: string) => any
}

function Input({ value, onChange }: InputProps, ref: any) {
  const [_value, setValue] = useState(value || '')

  const _onChange = (e: ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value
    setValue(value)
    onChange && onChange(value)
  }

  return (
    <div>
      自定义Input组件
      <input value={_value} onChange={_onChange} ref={ref} />
    </div>
  )
}

export default forwardRef(Input)
  1. 保持变量引用

createRef的区别

useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component.

useRef 在 react hook 中的作用, 正如官网说的, 它像一个变量, 类似于 this , 它就像一个盒子, 你可以存放任何东西. createRef 每次渲染都会返回一个新的引用,而 useRef 每次都会返回相同的引用

typescript
import React, { useRef, useEffect } from 'react'

export default function Timer() {
  const timerRef = useRef<NodeJS.Timeout>()

  useEffect(() => {
    timerRef.current = setInterval(() => {
      console.log('do something')
    }, 1000)

    // 组件卸载时,清除定时器
    return () => {
      timerRef.current && clearInterval(timerRef.current)
    }
  }, [])

  return <div>// ...</div>
}

example: 界面上显示出上一个 count 的值

typescript
import React, { useState, useRef, useEffect } from 'react'

const usePrevious = (state: any) => {
  const ref = useRef()
  useEffect(() => {
    ref.current = state
  })

  return ref.current
}

export default function () {
  const [counter, setCounter] = useState(0)
  const prevCounter = usePrevious(counter)

  return (
    <div>
      <button onClick={() => setCounter(counter + 1)}>+ 1</button>
      <button onClick={() => setCounter(counter - 1)}>- 1</button>
      <p>
        Now: {counter}, before: {prevCounter}
      </p>
    </div>
  )
}

explain: useRef 每次都会返回相同的引用,第一次渲染时,counter 为 0,而执行到自定义的 hook,usePrevious时,传入的 state 是 0, 但useEffect副作用函数是在 dom 渲染完执行,所以return的值是undefined,页面的prevCounter则没有显示值。 当setCounter时,函数重新运行,取到的是之前传入的counter,所以页面显示counter是 1,prevCounter是 0。

useImperativeHandle

useImperativeHandle可以让我们在使用ref时自定义暴露给父组件的实例值。

typescript
import React, {
  useRef,
  useImperativeHandle,
  forwardRef,
  Ref,
  useState,
  ChangeEvent
} from 'react'

export interface InputProps {
  value?: string
  onChange?: (value: string) => any
}

export interface XInput {
  focus: () => void
  blur: () => void
  setInputValue: (value: string) => void
}

function Input({ value, onChange }: InputProps, ref: Ref<XInput>) {
  const inputRef = useRef<HTMLInputElement>(null)
  const [_value, setValue] = useState(value || '')

  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current && inputRef.current.focus()
    },
    blur: () => {
      inputRef.current && inputRef.current.blur()
    },
    setInputValue: (value: string) => {
      setValue(value)
    }
  }))

  const _onChange = (e: ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value
    console.log(value)
    setValue(value)
    onChange && onChange(value)
  }

  return (
    <div>
      自定义Input组件
      <input value={_value} onChange={_onChange} ref={inputRef} />
    </div>
  )
}

export default forwardRef(Input)

使用

typescript
import React, { useRef, useState } from 'react'
import Input from './components/Input'

const Demo = () => {
  const textInput = useRef<any>(null)
  const [text, setText] = useState('')

  const focusTextInput = () => {
    if (textInput.current) {
      textInput.current.focus()
      textInput.current.setInputValue('hello world')
    }
  }

  return (
    <>
      <Input ref={textInput} onChange={setText} value={text} />
      <button onClick={focusTextInput}>
        点击我,input组件获得焦点并设置input value
      </button>
      <div>{text}</div>
    </>
  )
}

export default Demo

compare: component 父组件调用子组件方法

jsx
import React, { Component } from 'react'

export default class Parent extends Component {
  onRef = (ref) => {
    this.child = ref
  }

  click = () => {
    this.child.myName()
  }

  render() {
    return (
      <div>
        <Child onRef={this.onRef} />
        <button onClick={this.click}>click</button>
      </div>
    )
  }
}

class Child extends Component {
  componentDidMount() {
    this.props.onRef(this)
  }

  myName = () => console.log('child log')

  render() {
    return <p>child</p>
  }
}

useMemo

记忆函数useMemouseCallback也是靠闭包实现,记忆函数并非完全没有代价,我们需要创建闭包,占用更多的内存,用以解决计算上的冗余。

useMemo缓存计算结果。它接收两个参数,第一个参数为计算过程(回调函数,必须返回一个结果),第二个参数是依赖项(数组),当依赖项中某一个发生变化,结果将会重新计算。 如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值;

typescript
function useMemo<T>(factory: () => T, deps: DependencyList | undefined): T

example: base 只会在第一次渲染时计算及运行expensiveFn, state变化重新 render 时,不会在运行,除非useMemo第二个参数没有或者,是[num]

typescript
import React, { useState, useMemo } from 'react'
import { Divider, Button } from 'antd'

export default function () {
  const [num, setNum] = useState(0)

  // 一个非常耗时的一个计算函数
  // result 最后返回的值是 49995000
  function expensiveFn() {
    let result = 0

    for (let i = 0; i < 10000; i++) {
      result += i
    }

    console.log(result) // 49995000
    return result
  }

  // const base = expensiveFn()

  const base = useMemo(expensiveFn, [])

  return (
    <div>
      <h3>example one</h3>
      <h3>count:{num}</h3>
      <Button onClick={() => setNum(num + base)}>+1</Button>
    </div>
  )
}

useCallback

useCallback 的使用几乎与 useMemo 一样,不过 useCallback 缓存的是一个函数体,当依赖项中的一项发现变化,函数体会重新创建。 useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

  1. 函数比较复杂,用useCallback避免重复创建同样方法的负担
  2. 当函数当做 props 传递给子组件时,可以使用useCallback,避免当父组件重新render时,重新创建发送导致子组件更新。
typescript
function useCallback<T extends (...args: any[]) => any>(
  callback: T,
  deps: DependencyList
): T

优化总结

React 的性能优化方向主要是两个:一个是减少重新 render 的次数(或者说减少不必要的渲染)另一个是减少计算的量。

一个组件重新重新渲染,一般三种情况:

  1. 要么是组件自己的状态改变
  2. 要么是父组件重新渲染,导致子组件重新渲染,但是父组件的 props 没有改变
  3. 要么是父组件重新渲染,导致子组件重新渲染,但是父组件传递的 props 改变

减少不必要的渲染,可以使用use.memouseCallback,或者之前的shouldComponentUpdatepureComponent

useMemo 做计算结果缓存

原理

useState 和 useReducer 都是关于状态值的提取和更新,从本质上来说没有区别,从实现上,可以说 useState 是 useReducer 的一个简化版,其背后用的都是同一套逻辑。

React Hooks 保存状态的位置其实与类组件的一致:

  • 两者的状态值都被挂载在组件实例对象FiberNodememoizedState属性中。
  • 两者保存状态值的数据结构完全不同;类组件是直接把 state 属性中挂载的这个开发者自定义的对象给保存到 memoizedState 属性中;而 React Hooks 是用链表来保存状态的,memoizedState属性保存的实际上是这个链表的头指针。 链表的节点:
flow
// react-reconciler/src/ReactFiberHooks.js
export type Hook = {
  memoizedState: any, // 最新的状态值
  baseState: any, // 初始状态值,如`useState(0)`,则初始值为0
  baseUpdate: Update<any, any> | null,
  queue: UpdateQueue<any, any> | null, // 临时保存对状态值的操作,更准确来说是一个链表数据结构中的一个指针
  next: Hook | null // 指向下一个链表节点
}

hooks 分为mount阶段update阶段

在 mount 阶段,每当调用 Hooks 方法,比如useStatemountState就会调用mountWorkInProgressHook 来创建一个 Hook 节点,并把它添加到Hooks链表上

useState 和 useReducer 都是使用了一个queue链表来存放每一次的更新。以便后面的update阶段可以返回最新的状态。每次调用dispatchAction方法(useState,useReducer 第二个参数返回的修改 state 的函数)的时候,就会形成一个新的 update 对象,添加到 queue 链表上, 而且这个是一个循环链表dispatchAction方法的实现:

实现
javascript
// react-reconciler/src/ReactFiberHooks.js
// 去除特殊情况和与fiber相关的逻辑
function dispatchAction(fiber, queue, action) {
  const update = {
    action,
    next: null
  }
  // 将update对象添加到循环链表中
  const last = queue.last
  if (last === null) {
    // 链表为空,将当前更新作为第一个,并保持循环
    update.next = update
  } else {
    const first = last.next
    if (first !== null) {
      // 在最新的update对象后面插入新的update对象
      update.next = first
    }
    last.next = update
  }
  // 将表头保持在最新的update对象上
  queue.last = update
  // 进行调度工作
  scheduleWork()
}

useEffect

useEffect 的保存方式与 useState / useReducer 类似,也是以链表的形式挂载在FiberNode.updateQueue中。

mount 阶段:mountEffect

  1. 根据函数组件函数体中依次调用的 useEffect 语句,构建成一个链表并挂载在FiberNode.updateQueue中,链表节点的数据结构为:
flow
const effect: Effect = {
  tag, // 用来标识依赖项有没有变动
  create, // 用户使用useEffect传入的函数体
  destroy, // 上述函数体执行后生成的用来清除副作用的函数
  deps, // 依赖项列表
  next: (null: any)
}
  1. 组件完成渲染后,遍历链表执行。

update 阶段:updateEffect

  1. 同样在依次调用 useEffect 语句时,判断此时传入的依赖列表,与链表节点Effect.deps中保存的是否一致(基本数据类型的值是否相同;对象的引用是否相同),如果一致,则在Effect.tag标记上NoHookEffect

执行阶段

在每次组件渲染完成后,就会进入 useEffect 的执行阶段:function commitHookEffectList()

  1. 遍历链表
  2. 如果遇到Effect.tag被标记上NoHookEffect的节点则跳过。
  3. 如果Effect.destroy为函数类型,则需要执行该清除副作用的函数(至于这Effect.destroy是从哪里来的,下面马上说到)
  4. 执行Effect.create,并将执行结果保存到Effect.destroy(如果开发者没有配置 return,那得到的自然是 undefined 了,也就是说,开发者认为对于当前 useEffect 代码段,不存在需要清除的副作用);注意由于闭包的缘故, Effect.destroy实际上可以访问到本次Effect.create函数作用域内的变量。

是先清除上一轮的副作用,然后再执行本轮的 effect 的。

In case I don't see you. Good afternoon, good evening, and good night.