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 也执行

useLayoutEffect ​

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

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

  1. useLayoutEffect和componentDidMount和componentDidUpdate触发时机一致(都在在 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的区别

createRef主要用于类组件中,用于访问DOM元素或子组件的实例,每次渲染都会返回一个新的ref对象,因此在类组件中使用时,需要在构造函数中初始化,以确保ref对象的唯一性。

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 ​

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

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

原理 ​

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

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

  • 两者的状态值都被挂载在组件实例对象FiberNode的memoizedState属性中。
  • 两者保存状态值的数据结构完全不同;类组件是直接把 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 方法,比如useState,mountState就会调用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.