React 中的 Hook

React 中的 Hook

我们在之前 React 中的 MixinReact 中的 HOC 的章节当中介绍了 MixinHOC 的相关内容,了解了它们大致的实现原理和使用方式,也知道了 HOC 的出现就是为了替代 Mixin,但是 HOC 也并非完美无缺,它其实也是存在着一些缺陷的,比如

  • HOC 需要在原组件上进行包裹或者嵌套,如果大量使用 HOC,将会产生非常多的嵌套,这让调试变得非常困难
  • HOC 可以劫持 props,在不遵守约定的情况下也可能造成冲突

所以在本章当中我们就来看看 React 中的 Hook 是如何同时解决 MixinHOC 所带来的问题的

为什么要使用 Hook

如果说为什么要使用 Hook,那么我们可以先来看看 Hook 主要解决的问题,主要有下面三个

  • 在组件之间复用状态逻辑很难,Hook 使你在无需修改组件结构的情况下复用状态逻辑
  • 复杂组件变得难以理解,Hook 将组件中相互关联的部分拆分成更小的函数(比如设置订阅或请求数据)
  • 难以理解的 ClassHook 使你在非 Class 的情况下可以使用更多的 React 特性

那么到底什么是 Hook 呢?

什么是 Hook

React16.8 的版本当中新增了 Hook 这个特性,它可以让我们在不编写 Class 的情况下使用 state 以及其他的 React 特性,使用 Hook,你可以在将含有 state 的逻辑从组件中抽象出来,这将可以让这些逻辑容易被测试,同时 Hook 可以帮助你在不重写组件结构的情况下复用这些逻辑,所以它也可以作为一种实现状态逻辑复用的方案

React 内置了一些像 useState 这样的 Hook,你也可以创建你自己的 Hook 来复用不同组件之间的状态逻辑,其实简单来说 Hook 就是我们常见的 JavaScript 函数,但是使用它们会有两个额外的规则,如下

  • 只能在函数最外层调用 Hook,不要在循环、条件判断或者子函数中调用
  • 只能在 React 函数式组件或自定义 Hook 中使用 Hook,不要在其他 JavaScript 函数中调用

之所以不要在循环当中去调用 Hook,这是因为 Hook 是通过数组实现的,每次 useState 都会改变下标,React 需要利用调用顺序来正确更新相应的状态,如果 useState 被包裹循环或条件语句中,那每就可能会引起调用顺序的错乱,从而造成意想不到的错误,我们可以利用 eslint 插件来帮助我们避免这些问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 安装
npm install eslint-plugin-react-hooks --save-dev

// 配置
{
"plugins": [
// ...
"react-hooks"
],
"rules": {
// ...
"react-hooks/rules-of-hooks": "error"
}
}

当然官方也提供了一些默认的 Hook,如下

钩子名 作用
useState 初始化和设置状态
useEffect componentDidMount/componentDidUpdate/componentWillUnmount 结合体,所以可以监听 useState 定义值的变化
useContext 定义一个全局的对象,类似 Context
useReducer 可以增强函数提供类似 Redux 的功能
useCallback 记忆作用,共有两个参数,第一个参数为一个匿名函数,就是我们想要创建的函数体,第二参数为一个数组,里面的每一项是用来判断是否需要重新创建函数体的变量,如果传入的变量值保持不变,返回记忆结果,如果任何一项改变,则返回新的结果
useMemo 作用和传入参数与 useCallback 一致,useCallback 返回函数,useDemo 返回值
useRef 获取 ref 属性对应的 DOM
useImperativeMethods 自定义使用 Ref 时公开给父组件的实例值
useMutationEffect 作用与 useEffect 相同,但在更新兄弟组件之前,它在 React 执行其 DOM 改变的同一阶段同步触发
useLayoutEffect 作用与 useEffect 相同,但在所有 DOM 改变后同步触发

我们下面来看几个平时使用频率较高的 Hook

useState()

那么为什么要使用 useState 呢?简单来说就是为了在函数组件里面使用 Class 组件才有的 setState 方法,因为当我们一个函数组件想要有自己维护的 state 的时候,不得已只能转换成 Class 组件,这样操作会很麻烦,所以就有了 useState,下面我们来看看如何使用

1
2
3
4
5
6
7
8
9
export default function Button() {
const [text, setText] = useState('click me')

function handleClick() {
setText('clicked')
}

return <button onClick={handleClick}>{text}</button>
}

在上面示例当中,useState 就是一个 Hook,通过在函数组件里调用它来给组件添加一些内部 stateReact 会在重复渲染时保留这个 state

useState 会返回一对值,包括『当前状态』和『一个让你更新它的函数』,你可以在事件处理函数中或其他一些地方调用这个函数,它类似 Class 组件的 this.setState,但是它不会把新的 state 和旧的 state 进行合并,useState 唯一的参数就是初始 state,值得注意的是,不同于 this.state,这里的 state 不一定要是一个对象,并且这个初始 state 参数只有在第一次渲染时会被用到

更新方式

另外 useState 的更新方式也有两种,即直接更新和函数式更新,其应用场景的区分点在于

  • 直接更新不依赖于旧 state 的值
  • 函数式更新依赖于旧 state 的值

比如在上面的示例当中,我们只是简单的调用了 handleClick,但是如果新的 state 需要通过使用先前的 state 计算得出,那么可以将函数传递给 setState,该函数将接收先前的 state,并返回一个更新后的值

1
2
3
4
5
6
7
8
9
10
11
export default function App() {
const [count, setCount] = useState(0)
return (
<div>
<p>{count}</p>
<button onClick={_ => setCount(0)}>重置</button>
<button onClick={() => setCount(prev => prev + 1)}>增加</button>
<button onClick={() => setCount(prev => prev - 1)}>减少</button>
</div>
)
}

实现合并

Class 组件中的 setState 方法不同,useState 不会自动合并更新对象,而是直接替换它,不过我们可以使用函数式的 setState 结合展开运算符来达到合并更新对象的效果

1
2
3
4
setState(prevState => {
// 也可以使用 Object.assign
return { ...prevState, ...updatedValues }
})

惰性初始化 State

initialState 参数只会在组件的初始渲染中起作用,后续渲染时会被忽略,也就是说 useState 的初始值,只在第一次有效,也就是再次更新是无效的,比如下面这个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const Child = ({ data }) => {
const [name, setName] = useState(data)
return (
<div>
<div>Child</div>
<div>{name} -- {data}</div>
</div>
)
}

const App = _ => {
const [count, setCount] = useState(0)
const [name, setName] = useState('zhangsan')
return (
<div>
<div>{count}</div>
<button onClick={_ => setCount(count + 1)}>update count</button>
<button onClick={_ => setName('list')}>update name</button>
<Child data={name} />
</div>
)
}

其应用场景在于,因为创建初始 state 很昂贵时,例如需要通过复杂计算获得,那么则可以传入一个函数,在函数中计算并返回初始的 state,此函数只在初始渲染时被调用

小结

  • 不像 Class 中的 this.setStateHook 更新 state 变量总是替换它而不是合并它
  • 推荐使用多个 state 变量而不是单个 state 变量,因为 state 的替换逻辑而不是合并逻辑,并且利于后续的相关 state 逻辑抽离
  • 调用 State Hook 的更新函数并传入当前的 state 时,React 将跳过子组件的渲染及 effect 的执行(React 使用 Object.is 比较算法来比较 state

useEffect()

useEffect 主要用来引入具有副作用的操作,比如数据获取、订阅或者手动修改过 DOM 等操作就可以称之为副作用,这里我们就以数据请求为例,在之前我们处理数据请求一般都是放在 componentDidMount 当中来进行的,但是现在我们可以放在 useEffect 当中来进行,useEffect 的用法如下

1
2
3
4
5
6
7
useEffect(() => {
// 只要组件 render 后就会执行
})

useEffect(() => {
// 只有 count 改变时才会执行
}, [count])

useEffect 接受两个参数

  • 第一个参数是一个回调函数,在第组件一次 render 和之后的每次 update 后运行,React 保证在 DOM 已经更新完成之后才会运行回调
  • 第二个参数是一个状态依赖数组,当配置了状态依赖项后,只有检测到配置的状态变化时,才会调用回调函数,第二个参数可以省略,这时每次组件渲染时,就会重新执行 useEffect()

执行顺序

另外关于 useEffect 的执行顺序,有下面几点需要我们注意

  • 正常情况下,useEffect 会在 render 后按照前后顺序执行
  • useEffect 内部执行是异步的
  • useEffect 的回调函数也是按照先后顺序同时执行的

下面我们就先来看一个使用 useEffect 的示例,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const Book = ({ id }) => {
const [loading, setLoading] = useState(true)
const [book, setBook] = useState({})

useEffect(() => {
setLoading(true)
fetch(`http://rap2api.taobao.org/app/mock/251195/list/${id}/`)
.then(res => res.json())
.then(data => {
setBook(data)
setLoading(false)
})
}, [id])

if (loading === true) {
return <p>Loading ...</p>
}

return (
<div>
<p>{book.title}</p>
</div>
)
}

export default function App() {
const [show, setShow] = useState('1')
return (
<div>
<Book id={show} />
<div>
<button onClick={_ => setShow('1')}>第一页</button>
<button onClick={_ => setShow('2')}>第二页</button>
</div>
</div>
)
}

在上面示例当中,我们传入 [id] 作为 useEffect 第二个参数,如果 id 的值在重新渲染的时候没有发生变化,React 会跳过这个 effect,这就实现了性能的优化,不过这里也需要注意

  • 如果你要使用此优化方式,请确保数组中包含了所有外部作用域中会随时间变化并且在 effect 中会使用的变量,否则你的代码会引用到先前渲染中的旧变量,即数组最好包含所有在 effect 当中使用的可能变化的变量
  • 如果想执行只运行一次的 effect(仅在组件挂载和卸载时执行),可以传递一个空数组([])作为第二个参数,这就告诉 React 你的 effect 不依赖于 propsstate 中的任何值,所以它永远都不需要重复执行

所以我们可以利用传递空数组的特性来模拟 componentDidMountcomponentWillUnmount,其中 componentDidMount 等价于 useEffect 的回调仅在页面初始化完成后执行一次,当 useEffect 的第二个参数传入一个空数组时可以实现这个效果,但是我们最好忘掉生命周期,只记副作用

同时,useEffect 的第一个参数还可以返回一个函数,当页面渲染了下一次更新的结果后,执行下一次 useEffect 之前,会调用这个函数(组件卸载的时候也会执行清除操作),这个函数常常用来对上一次调用 useEffect 进行清理

1
2
3
4
5
6
useEffect(() => {
// 这里是某些操作
return function cleanup() {
// 命名为 cleanup 是为了表明此函数的目的,但其实也可以返回一个箭头函数或者给起一个别的名字
}
})

下面是一个具体的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default function HookTest() {
const [count, setCount] = useState(0)
useEffect(() => {
console.log('执行...', count)
return () => {
console.log('清理...', count)
}
}, [count])
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => { setCount(count + 1); setNumber(number + 1) }}>Click me</button>
</div>
)
}

在控制台当中我们看到对应的输出,但是如果加上浏览器渲染的情况,结果应该是这样的

1
2
3
4
5
6
7
8
9
10
11
渲染  ==> 1
执行 ==> 1
渲染 ==> 2
清理 ==> 1
执行 ==> 2
渲染 ==> 3
清理 ==> 2
执行 ==> 3
渲染 ==> 4
清理 ==> 3
执行 ==> 4

那么这里就存在一个问题了,那就是为什么在浏览器渲染完后,再执行清理方法的时候还能找到上次的 state 呢?原因很简单,因为我们在 useEffect 中返回的是一个函数,这形成了一个闭包,这能保证我们上一次执行函数存储的变量不被销毁和污染,可以参考下面的代码来进行理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var flag = 1
var clean

function effect(flag) {
return function () {
console.log(flag)
}
}

clean = effect(flag)
flag = 2
clean()

clean = effect(flag)
flag = 3
clean()

clean = effect(flag)

执行后的结果是下面这样的

1
2
3
4
5
effect  ==> 1
clean ==> 1
effect ==> 2
clean ==> 2
effect ==> 3

注意事项

但是关于 useEffect 的使用也有一些需要注意的地方,主要有以下几点

  • useEffect 里面使用到的 state 的值,是会被固定在 useEffect 内部,不会被改变,除非 useEffect 刷新,重新固定 state 的值,也就是说它是在生成 useEffect 时候的当前值,不会随着时间改变而变化
1
2
3
4
5
6
7
8
9
const [count, setCount] = useState(0)

useEffect(() => {
const timer = setInterval(() => {
console.log(count)
setCount(count + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
  • useEffect 不能被判断包裹
1
2
3
4
5
6
7
8
const [count, setCount] = useState(0)

if (2 < 5) {
useEffect(() => {
const timer = setInterval(() => setCount(count + 1), 1000)
return () => clearInterval(timer)
})
}
  • useEffect 不能被打断
1
2
3
4
5
6
7
const [count, setCount] = useState(0)

useEffect(...)

return // 函数提前结束了

useEffect(...)

关于以上几点的原因,其实是跟 useEffect 的生成执行规则有关,我们会在 深入 useEffect 章节当中来深入探讨为什么会这样

小结

  • 可以把 useEffect Hook 看做 componentDidMountcomponentDidUpdatecomponentWillUnmount 这三个函数的组合
  • ReactClass 组件中,render 函数是不应该有任何副作用的,一般来说在这里执行操作太早,我们基本上都希望在 React 更新 DOM 之后才执行我们的操作

useContext()

useContext() 通常用来处理多层级传递数据的方式,在以前组件树中,跨层级祖先组件想要给孙子组件传递数据的时候,除了一层层 props 往下透传之外,我们还可以使用 React Context API 来帮我们做这件事

也就是说,如果需要在多个组件之间共享状态,那么这种情况下就可以考虑使用 useContext(),其实和 React 之前提供的 Context 的使用方式有些类似,比如下面这种情况,我们有 AB 两个组件,它们需要共享标题内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const AppContext = React.createContext({})

const A = () => {
const { title } = useContext(AppContext)
return (
<div>
<p>标题为 { title }</p>
<p>A 组件的内容</p>
</div>
)
}

const B = () => {
const { title } = useContext(AppContext)
return (
<div>
<p>标题为 { title }</p>
<p>B 组件的内容</p>
</div>
)
}

export default function App() {
return (
<div>
<AppContext.Provider value={{title: '文章标题'}}>
<A></A>
<B></B>
</AppContext.Provider>
</div>
)
}

我们首先使用了 React.createContext({}) 在组件外部建立一个 Context,然后使用 <AppContext.Provider> 来提供了一个 Context 对象,这个对象可以被子组件共享,这里需要注意的是不能直接使用 <AppContext>,否则会报错,另外需要注意在传递值的时候使用的两层大括号

最后我们在需要被共享内容的子组件内使用 useContext() 钩子函数用来引入 Context 对象,从中来获取 title 属性,这是因为当前的 Context 的值是由上层组件中距离当前组件最近的 <MyContext.Provider>value prop 所决定

但是这里有一个需要我们注意的地方,比如我们上方的 B 组件变成了下面这种情况,即不在依赖 Context,但是渲染的时候仍然放在 <AppContext.Provider> 当中

1
2
3
4
5
6
7
const B = () => {
return (
<div>
<p>B 组件的内容</p>
</div>
)
}

这样一来就会涉及到下面两个问题

  • useContext 的组件总会在 Context 值变化时重新渲染, 所以 <MyContext.Provider> 包裹的越多,层级越深,性能会造成影响
  • <MyContext.Provider>value 发生变化时候,包裹的组件无论是否订阅 content value,所有组件都会从新渲染

所以在这种情况下,我们自然而然的可以想到,如果组件没有订阅的话,是不是可以避免不必要的渲染,答案是有的,我们可以使用 React.memo 来进行优化,我们将 B 组件调整如下

1
2
3
const B = React.memo((props) => {
return <div>B 组件的内容</div>
})

但是需要注意的是,默认情况下 React.memo() 只会对复杂对象做浅层对比,如果你想要控制对比过程,那么请将自定义的比较函数通过第二个参数传入来实现,这部分内容我们会在下面的 useMemo() 部分来进行介绍,也可以参考 Preventing rerenders with React.memo and useContext hook 了解更多

useReducer()

React 本身不提供状态管理功能,通常我们需要借住一些第三方库(比如 Redux)来进行实现,而 Redux 的核心概念是,组件不能直接修改共享状态,而是需要发出 action 与状态管理器通信,状态管理器收到 action 以后,使用 reducer 函数计算出新的状态进行返回

通常 reducer 函数的形式是 (state, action) => newState,而 useReducers() 钩子就是用来引入 reducer 功能的,比如下面这个计数器的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const myReducer = (state, action) => {
switch (action.type) {
case 'add':
return {
...state,
count: state.count + 1
}
case 'dec':
return {
...state,
count: state.count - 1
}
case 'reset':
return {
...state,
count: action.payload || 0
}
default:
return state
}
}

export default function App() {
const [state, dispatch] = useReducer(myReducer, { count: 10 })
return (
<div>
<p>{state.count}</p>
<button onClick={_ => dispatch({ type: 'reset' })}>重置</button>
<button onClick={_ => dispatch({ type: 'add' })}>增加</button>
<button onClick={_ => dispatch({ type: 'dec' })}>减少</button>
</div>
)
}

以上就是 useReducer 的简单用法,可以发现其相对于 Redux 而言代码简化了不少,所以 useReducer() 在这些方面是可以取代 Redux 的,但是它并没有提供例如中间件(middleware)和时间旅行(time travel)等功能,所以还是根据实际情况来进行使用

useRef()

使用 useRef Hook,我们可以轻松的获取到 DOMref

1
2
3
4
5
6
7
8
9
10
11
12
export default function Input() {
const inputEl = useRef(null)
const onButtonClick = () => {
inputEl.current.focus()
}
return (
<div>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</div>
)
}

但是 useRef 并不仅仅可以用来当作获取 ref 使用,使用 useRef 产生的 refcurrent 属性是可变的,这意味着你可以用它来保存一个任意值,但是需要注意的是,这样方式有点类似于全局作用域,一处被修改,其他地方全都会更新

1
2
3
4
5
6
7
8
9
const [count, setCount] = useState(0)
const countRef = useRef(0)

useEffect(() => {
const timer = setInterval(() => {
setCount(++countRef.current)
}, 1000)
return () => clearInterval(timer)
}, [])

不过相对于上面这个示例,采用下面这种方式更为妥善一些

1
2
3
4
5
6
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(timer)
}, [])

这样一来我们就不需要实时保存当前的值了,而是调整成了仅仅告知 React,让其去递增状态就行,而不用管它现在具体是什么值,更为深层次的运行原理可以参考 深入 useEffect 来了解更多

同样的,我们也可以使用 useRef 来模拟 componentDidUpdatecomponentDidUpdate 就相当于除去第一次调用的 useEffect,我们可以借助 useRef 生成一个标识,来记录是否为第一次执行

1
2
3
4
5
6
7
8
9
10
function useDidUpdate(callback, prop) {
const init = useRef(true)
useEffect(() => {
if (init.current) {
init.current = false
} else {
return callback()
}
}, prop)
}

简单总结一下就是

  • useRef 返回一个可变的 ref 对象,它会在每次渲染时返回同一个 ref 对象,在整个组件的生命周期内是唯一的
  • useRef 可以保存任何可变的值,即也可以存储那些不需要引起页面重新渲染的数据
  • 如果你刻意地想要从某些异步回调中读取最新的 state,你可以用一个 ref 来保存它,修改它,并从中读取(关于这点可以参考官方文档当中的 有类似实例变量的东西吗?

useMemo()

先来看看我们为什么要使用 useMemo 呢?我们还是以上面 useState 章节当中的示例为例,稍微的调整一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const Child = ({ data }) => {
console.log(`子组件渲染`)
return (
<div>
<div>{data.name}</div>
</div>
)
}

const App = () => {
const [count, setCount] = useState(0)
const [name, setName] = useState('zhangsan')
const data = {
name
}
return (
<div>
<div>{count}</div>
<button onClick={() => setCount(count + 1)}>update count</button>
<Child data={data} />
</div>
)
}

仔细观察控制台的输出可以发现,在我们每次点击按钮去更新 count 的时候,我们的子组件每次也都会跟着重新渲染,但是我们此时并没有去修改 name 的值,也就是说子组件用到的值其实是没有改变的,那么这样一来就是多余的渲染了,在这样的情况下我们通常会使用 React.memo 来解决这样的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const isEqual = (prevProps, nextProps) => {
if (prevProps.name !== nextProps.name) {
return false
}
return true
}

const Child = memo(({ data }) => {
console.log(`子组件渲染`)
return (
<div>
<div>{data.name}</div>
</div>
)
}, isEqual)

const App = () => {
const [count, setCount] = useState(0)
const [name, setName] = useState('zhangsan')
const data = {
name
}
return (
<div>
<div>{count}</div>
<button onClick={() => setCount(count + 1)}>update count</button>
<Child data={data} />
</div>
)
}

我们使用 React.memo 将我们的子组件包裹了起来,并且传入了第二个参数 isEqual,它是一个函数,它的作用是判断两次 name 是否一致,只有在不一致的时候才会重新触发渲染

调整以后我们再次运行,可以发现此时子组件在 name 属性不改变的情况下就不会再次重新渲染了,但是在这种情况下,我们需要额外提供一个比对函数来对传入的数据进行比对后再决定是否重新渲染,那么有没有更为简便的方式呢?方法是有的,在这种情况下我们就可以考虑使用 useMemo,调整后代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const Child = (data) => {
return useMemo(() => {
console.log(`子组件渲染`)
return <div>
<p>{data.name}</p>
</div>
}, [data.name])
}

const App = () => {
const [count, setCount] = useState(0)
const [name, setName] = useState('zhangsan')
const data = {
name
}
return (
<div>
<div>{count}</div>
<button onClick={() => setCount(count + 1)}>update count</button>
<Child data={data} />
</div>
)
}

我们使用 useMemo 将我们子组件的渲染部分包裹了起来,并且传递了第二个参数,它是一个依赖数组,作用与我们上面的比对函数 isEqual 是一致的,这样一来我们就不在需要手动的提供比对函数了,React 在渲染的时候就会事先根据我们提供的依赖数组里面的 name 值先去判断一下,如果没有改变则会跳过这次更新,因为 useMemo 帮助我们暂存了上一次的 name 结果,但是在 useMemo 使用过程当中有一个我们需要注意的地方,那就是 useMemo 是在 render 期间执行的,所以在其中不能进行一些额外的副操作,比如网络请求等

看到这里,你也许会问了,那么 React.memouseMemo 又有什么区别呢?其实简单来说 React.memouseMemo 实现的功能都是一样的,首先就是 React.memo 是在最外层包装了整个组件,并且需要手动的去写一个方法来比对数据是否有所改变来决定是否重新渲染,而在某些场景下,我们只是希望组件的部分内容不进行重新渲染,而不是整个组件全都不去重新渲染,也就是说想要实现局部 Pure 的功能,针对于这种情况就可以使用 useMemo 的方式来替代 React.memo

另外需要注意的一点就是,如果函数组件被 React.memo 包裹,并且其实现中拥有 useStateuseContextHook 的时候,那么当 context 发生变化时,它是仍会重新渲染的,所以在使用的时候一定要小心

那么什么又是 Pure 功能呢?其实就是之前比较流行使用的 React.PureComponent 功能,它和 React.Component 类似,都是定义一个组件类,不同的是 React.Component 并没有实现 shouldComponentUpdate()React.PureComponent 通过 propsstate 的浅比较实现了,它与 React.memo/useMemo 最主要的区别就是

React.PureComponent 是作用在类中,而 React.memo 是作用在函数中

一般来说,如果组件的 propsstate 相同时,render 的内容也一致,那么就可以使用 React.PureComponent 来提高组件的性能,下面是一个 React.PureComponent 简单的使用示例

1
2
3
4
5
6
7
8
9
10
11
12
// 组件直接继承 React.PureComponent 即可
class TwentyOneChild extends React.PureComponent {
render() {
return <div>{this.props.name}</div>
}
}

export default class TwentyOne extends React.Component {
render() {
return <TwentyOneChild name={`React.PureComponent`}></TwentyOneChild>
}
}

useCallback()

我们在上面介绍的 useMemo 解决了值的缓存问题,那么如果想要缓存函数的话,我们该如何处理呢?来看下面这个示例,也就是上面 useMemo 当中的示例,我们简单的调整了一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const App = () => {
const [count, setCount] = useState(0)
const onChange = e => {
setCount(e.target.value)
}
return (
<div>
<div>{count}</div>
<button onClick={() => setCount(count + 1)}>update count</button>
<Child onChange={onChange} />
</div>
)
}

const Child = ({ onChange }) => {
return useMemo(() => {
console.log(`子组件渲染`)
return <input type="text" onChange={onChange} />
}, [onChange])
}

当我们点击按钮的时候,会生成了一个 onChange 函数,赋值给了子组件,但是我们可以发现,子组件还是会随着按钮的点击而不停的渲染,尽管子组件什么都没有做,在这种情况下,我们就可以使用 useCallback 将我们的 onChange 函数包裹起来即可,如下

1
2
3
const onChange = useCallback(e => {
setCount(e.target.value)
}, [])

再次点击后就可以发现,子组件不会再重复的渲染了,其实本质上来说,useCallbackuseMemo 类似,都是有着缓存的作用,不同之处可能在于

  • useMemo 是缓存值的
  • useCallback 是缓存函数的

另外如果没有依赖,我们可以添加空的依赖,也就是空数组

Mixin/Render Props/HOC/Hook 的优缺点

我们在之前的内容当中介绍过了 Mixin/Render Props/HOC/Hook 的一些用法和需要注意的地方,所以在本小节当中我们就来简单的汇总一下,看看它们几者之间的优缺点

  • Mixin 的缺陷
    • 组件与 Mixin 之间存在隐式依赖(Mixin 经常依赖组件的特定方法,但在定义组件时并不知道这种依赖关系)
    • 多个 Mixin 之间可能产生冲突(比如定义了相同的 state 字段)
    • Mixin 倾向于增加更多状态,这降低了应用的可预测性,导致复杂度剧增
    • 隐式依赖导致依赖关系不透明,维护成本和理解成本迅速攀升
      • 难以快速理解组件行为,需要全盘了解所有依赖 Mixin 的扩展行为,及其之间的相互影响
      • 组价自身的方法和 state 字段不敢轻易删改,因为难以确定有没有 Mixin 依赖它
      • Mixin 也难以维护,因为 Mixin 逻辑最后会被打平合并到一起,很难搞清楚一个 Mixin 的输入输出
  • HOC 相比 Mixin 的优势
    • HOC 通过外层组件通过 Props 影响内层组件的状态,而不是直接改变其 State 不存在冲突和互相干扰,这就降低了耦合度
    • HOC 具有天然的层级结构(组件树结构),这降低了复杂度
  • HOC 的缺陷
    • 扩展性限制,HOC 无法从外部访问子组件的 State 因此无法通过 shouldComponentUpdate() 滤掉不必要的更新(提供了 React.PureComponent() 来解决这个问题)
    • Ref 传递问题,Ref 被隔断(提供了 React.forwardRef() 来解决这个问题)
    • Wrapper HellHOC 可能出现多层包裹组件的情况,多层抽象同样增加了复杂度和理解成本
    • 命名冲突,如果高阶组件多次嵌套,没有使用命名空间的话会产生冲突,然后覆盖老属性
    • 不可见性,HOC 相当于在原有组件外层再包装一个组件,无法得知外层的包装是什么
  • Render Props 优点
    • 上述 HOC 的缺点 Render Props 都可以解决
  • Render Props 缺陷
    • 使用繁琐,HOC 使用只需要借助装饰器语法通常一行代码就可以进行复用,Render Props 无法做到如此简单
    • 嵌套过深,Render Props 虽然摆脱了组件多层嵌套的问题,但是转化为了函数回调的嵌套
  • React Hooks 优点
    • 减少状态逻辑复用的风险,多个 Hook 之间互不影响,这让我们不需要在把一部分精力放在防止避免逻辑复用的冲突上
    • 避免地狱式嵌套,Hook 解决了 HOCRender Props 的嵌套问题,更加简洁
    • 解耦让组件更容易理解,Hook 可以更方便地把 UI 和状态分离,可以让我们更大限度的将公用逻辑抽离,将一个组件分割成一个个更小的函数,做到更彻底的解耦
    • 组合,Hook 中可以引用另外的 Hook 形成新的 Hook
    • 函数友好,Hook 为函数组件而生,从而解决了类组件的几大问题
      • this 指向容易错误
      • 分割在不同声明周期中的逻辑使得代码难以理解和维护
      • 代码复用成本高(高阶组件容易使代码量剧增)
  • React Hooks 缺陷
    • 额外的学习成本(Functional ComponentClass Component 之间的困惑)
    • 写法上有限制(不能出现在条件、循环中),并且写法限制增加了重构成本
    • 破坏了 PureComponentReact.memo() 浅比较的性能优化效果(为了取最新的 propsstate,每次 render() 都要重新创建事件处函数)
    • 在闭包场景可能会引用到旧的 stateprops
    • React.memo() 并不能完全替代 shouldComponentUpdate()(因为拿不到 state change,只针对 props change

不过还是那句老话,我们应该根据实际使用场景来进行选择

Hook 的本质

其实我们在上面提到的 Hook,本身就是为了解决组件间逻辑公用的问题的,回顾我们现在的做法,几乎都是面向生命周期编程

Hook 的出现是把这种面向生命周期编程变成了面向业务逻辑编程,让我们不用再去关注生命周期

而且在最新的 React 中,预置了大量的 Hook,其中比较重要两个的就是 useStateuseEffect

  • useState 使我们在不借助 ES6 Class 的前提下,在组件内部使用 state 成为可能
  • useEffect 取代了 componentDidMount/componentDidUpdate/componentWillUnmount,提供了一个统一的 API

当然除了这两个比较重要的和上面我们介绍到的几个其他的 Hook 之外,我们还可以在 官方文档 中发现更多 Hook

深入 Hook 的原理

本小节内容主要参考 Under the hood of React’s hooks system

我们在之前的章节当中花了大量篇幅来介绍了官方提供的一些 Hook,以及在使用它的过程当中的一些注意事项,但是我们并没有太过深入的去介绍 Hook 内部运行的机制,所以在本节当中我们就来简单的了解一些它的运行机制,首先我们先来看看 React Hook 系统的简单示意图,如下

Dispatcher

Dispatcher 是一个包含了 Hook 函数的共享对象,基于 ReactDOM 的渲染状态,它将会被动态的分配或者清理,并且它将会确保用户不能在 React 组件之外获取到 Hook源码),在切换到正确的 Dispatcher 来呈现根组件之前,我们通过一个名为 enableHooks 的标志来启用或者禁用 Hook,在技术上来说,这就意味着我们可以在运行时开启或关闭 HookReact 16.6+ 版本的实验性功能中也加入了它,但它默认处于禁用状态(源码

当我们完成渲染工作后,我们会废弃 Dispatcher 并禁止 Hook,来防止在 ReactDOM 的渲染周期之外不小心使用了它,这个机制能够保证用户不会做傻事(源码),Dispatcher 在每次 Hook 的调用中都会被函数 resolveDispatcher() 解析,如果是在 React 的渲染周期之外,React 将会提示我们说 Hook 只能在函数组件内部调用(源码

下面是我们模拟的 Dispatcher 的简单实现方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let currentDispatcher
const dispatcherWithoutHooks = { /* ... */ }
const dispatcherWithHooks = { /* ... */ }

function resolveDispatcher() {
if (currentDispatcher) return currentDispatcher
throw Error(`Hooks can't be called`)
}

function useXXX(...args) {
const dispatcher = resolveDispatcher()
return dispatcher.useXXX(...args)
}

function renderRoot() {
currentDispatcher = enableHooks ? dispatcherWithHooks : dispatcherWithoutHooks
performWork()
currentDispatcher = null
}

Hook 队列

React 后台,Hook 会被表示为节点,并以调用顺序连接起来,这样表示的原因是 Hook 并不是被简单的创建然后丢弃,它们有一套独有的机制,一个 Hook 会有数个属性,我们首先需要明确以下几点

  • 在初次渲染的时候,它的初始状态会被创建
  • 它的状态可以在运行时更新
  • React 可以在后续渲染中记住 Hook 的状态
  • React 能根据调用顺序提供给你正确的状态
  • React 知道当前 Hook 属于哪个部分

另外,我们需要重新思考我们看待组件状态的方式,目前,我们只把它看作一个简单的对象,也就是下面这样

1
2
3
4
5
{
foo: 'foo',
bar: 'bar',
baz: 'baz',
}

但是当处理 Hook 的时候,状态需要被看作是一个队列,每个节点都表示了对象的一个模块

1
2
3
4
5
6
7
8
9
10
{
memoizedState: 'foo',
next: {
memoizedState: 'bar',
next: {
memoizedState: 'bar',
next: null
}
}
}

在每个函数组件调用前,一个名为 prepareHooks() 的函数将先被调用,在这个函数中,当前结构和 Hook 队列中的第一个 Hook 节点将被保存在全局变量中,这样我们无论何时调用 Hook 函数(useXXX()),它都能知道运行上下文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
let currentlyRenderingFiber
let workInProgressQueue
let currentHook

function prepareHooks(recentFiber) {
currentlyRenderingFiber = workInProgressFiber
currentHook = recentFiber.memoizedState
}

function finishHooks() {
currentlyRenderingFiber.memoizedState = workInProgressHook
currentlyRenderingFiber = null
workInProgressHook = null
currentHook = null
}

function resolveCurrentlyRenderingFiber() {
if (currentlyRenderingFiber) return currentlyRenderingFiber
throw Error(`Hooks can't be called`)
}

function createWorkInProgressHook() {
workInProgressHook = currentHook ? cloneHook(currentHook) : createNewHook()
currentHook = currentHook.next
workInProgressHook
}

function useXXX() {
const fiber = resolveCurrentlyRenderingFiber()
const hook = createWorkInProgressHook()
// ...
}

function updateFunctionComponent(recentFiber, workInProgressFiber, Component, props) {
prepareHooks(recentFiber, workInProgressFiber)
Component(props)
finishHooks()
}

Hook 队列的简单实现

一旦更新完成,一个名为 finishHooks() 的函数将会被调用,在这个函数中,Hook 队列的第一个节点的引用将会被保存在渲染了的结构的 memoizedState 属性中,这就意味着 Hook 队列和它的状态可以在外部定位到,也就是说我们可以从外部读取某一组件记忆的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const ChildComponent = () => {
useState('foo')
useState('bar')
useState('baz')
return null
}

const ParentComponent = () => {
const childFiberRef = useRef()

useEffect(() => {
let hookNode = childFiberRef.current.memoizedState

assert(hookNode.memoizedState, 'foo')
hookNode = hooksNode.next
assert(hookNode.memoizedState, 'bar')
hookNode = hooksNode.next
assert(hookNode.memoizedState, 'baz')
})

return <ChildComponent ref={childFiberRef} />
}

State Hook

下面我们来看看在官方提供的一些 Hook 当中,它们是如何运作的,我们先从使用最为广泛的 State Hook 开始看起

你也许会很吃惊,但是 useState 这个 Hook 在后台其实是使用了 useReducer,并且它将 useReducer 作为预定义的 reducer源码),这意味着 useState 返回的结果实际上已经是 reducer 的状态,同时也是 action dispatcher

1
2
3
function basicStateReducer(state, action) {
return typeof action === 'function' ? action(state) : action
}

所以正如我们期望的那样,我们可以直接将 action dispatcher 和新的状态传入,也就是说我们也可以传入带 action 函数的 dispatcher,这个 action 函数可以接收旧的状态并返回新的状态,这意味着,当你向组件树发送状态设置器的时候,你可以修改父级组件修改状态,同时不用将它作为另一个属性传入,比如下面这个根据旧状态返回新状态的示例

1
2
3
4
5
6
7
8
9
10
11
const ParentComponent = () => {
const [name, setName] = useState()
return <ChildComponent toUpperCase={setName} />
}

const ChildComponent = (props) => {
useEffect(() => {
props.toUpperCase((state) => state.toUpperCase())
}, [true])
return null
}

Effect Hook

最后我们再来看看 Effect Hook 是如何工作的,Effect Hook 对于组件的生命周期影响很大,但是它和其他 Hook 的行为有一些区别,并且它有一个附加的逻辑层

  • 它在渲染时被创建,但是在浏览器绘制后运行
  • 如果给出了销毁指令,它们将在下一次绘制前被销毁
  • 它会按照定义的顺序被运行

因此,就应该有另一个队列来保存这些 Effect Hook,并且在绘制后能够被定位到,通常来说应该是组件保存包含了 effect 节点的队列,每个 effect 节点都是一个不同的类型,并能在适当的时候被定位到

  • 在修改之前调用 getSnapshotBeforeUpdate() 实例(源码
  • 运行所有插入、更新、删除和 ref 的卸载(源码
  • 运行所有生命周期函数和 ref 回调函数,生命周期函数会在一个独立的通道中运行,所以整个组件树中所有的替换、更新、删除都会被调用,这个过程还会触发任何特定于渲染器的初始 Effect Hook源码
  • useEffect() 调度的 effect 也被称为 被动的 effect

Effect Hook 将会被保存在组件一个称为 updateQueue 的属性上,每个 effect 节点都有如下的结构(源码

  • tag,一个二进制数字,它控制了 effect 节点的行为(下面会进行介绍)
  • create,『绘制之后』运行的回调函数
  • destroy,它是 create() 返回的回调函数,将会在『初始渲染前』运行
  • inputs,一个集合,该集合中的值将会决定一个 effect 节点是否应该被销毁或者重新创建
  • next,它指向下一个定义在函数组件中的 effect 节点

React 官方提供了一些比较特殊的 Hook,比如 useMutationEffect()useLayoutEffect(),其实这两个 Effect Hook 内部使用了 useEffect(),实际上这就意味着它们能创建 Effect Hook,但是却使用了不同的 tag 属性值,这个 tag 属性值是由二进制的值组合而成(源码),下面是 React 支持的 Effect Hook 类型

1
2
3
4
5
6
7
8
export const NoEffect = /*             */ 0b00000000;
export const UnmountSnapshot = /* */ 0b00000010;
export const UnmountMutation = /* */ 0b00000100;
export const MountMutation = /* */ 0b00001000;
export const UnmountLayout = /* */ 0b00010000;
export const MountLayout = /* */ 0b00100000;
export const MountPassive = /* */ 0b01000000;
export const UnmountPassive = /* */ 0b10000000;

这些二进制值中最常用的情景是使用管道符号(|)连接,将比特相加到单个某值上,然后我们就可以使用符号(&)检查某个 tag 属性是否能触发一个特定的动作,如果结果是非零的,就表示能触发,关于位运算符更为详细的内容可以参考 标志位与掩码,下面是如何使用 React 的二进制设计模式的示例

1
2
3
const effectTag = MountPassive | UnmountPassive
assert(effectTag, 0b11000000)
assert(effectTag & MountPassive, 0b10000000)

下面是 React 支持的 Effect Hook,以及它们的 tag 属性(源码

  • UnmountPassive | MountPassiveDefault effect
  • UnmountSnapshot | MountMutationMutation effect
  • UnmountMutation | MountLayoutLayout effect

下面是 React 如何检查动作触发的(源码

1
2
3
4
5
6
if ((effect.tag & unmountTag) !== NoHookEffect) {
// Unmount
}
if ((effect.tag & mountTag) !== NoHookEffect) {
// Mount
}

所以,基于我们前面介绍的 Effect Hook,我们可以实际操作,也就是从外部向组件插入一些 effect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function injectEffect(fiber) {
const lastEffect = fiber.updateQueue.lastEffect
const destroyEffect = () => {
console.log('on destroy')
}

const createEffect = () => {
console.log('on create')
return destroy
}

const injectedEffect = {
tag: 0b11000000,
next: lastEffect.next,
create: createEffect,
destroy: destroyEffect,
inputs: [createEffect],
}

lastEffect.next = injectedEffect
}

const ParentComponent = (
<ChildComponent ref={injectEffect} />
)

参考

# React

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×