一、前言
自从去年 React 16.8 带着它独有的 Hooks 闯入我的世界,我就被这种简洁有趣的语法和实现方案所深深吸引。
Hooks 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
但是,在网络上的项目实践中,却很少能找到 hooks 比较全面的教程(包括性能优化,最佳实践等等),就像盖一栋楼,大部分教程或者文章都是在提供盖楼的砖块,却没有好好教你如何盖一整栋楼。
React 官方对这方面的文档也是不怎么友好,你或许知道 hooks 的存在,或许知道怎么用 hooks 写出自己想要的页面或者组件,但却不知道怎么使用它们构建一个完整的项目,而且还要能够避免多余的渲染或者延时。
和 Vue 比较不同的是,开发者需要掌握好 JS 的使用才能更好地驾驭 React,如果你是:
- 没写过 React 但是想用 React 框架写项目的前端开发者;
- 会写 Class Component 模式的 React,想学习 hooks 用法的前端开发者;
那么这篇文章可以让你轻松使用 hooks 并且用它们来编写项目页面。
文章绝大部分不会涉及到源码分析的内容,对于刚入门的新手也十分友好。
二、Hooks 介绍与使用
hooks 是 React 函数式组件编程中最核心的内容。众所周知像数据驱动 mvvm 模式下的前端框架一般都会涉及两个重要的组成部分——状态管理和生命周期。而在一个 React 纯函数式组件中,它这样就可以直接运作
function Component () {
return (<div>test</div>)
}
而在 Class Component 中你需要在类里面声明定义 constructor 以及各种生命周期等等,这些都是原本纯函数中不具备的东西,而 hooks 的作用就是把这些纯函数不具备的功能引如使用。但也不是哪里都可以使用这些 hooks,React 对 hooks 的使用有严格的限制。
1.只在最顶层使用 Hook
不要在循环,条件或嵌套函数中调用 Hook,确保总是在你的 React 函数的最顶层调用他们。
2.只在 React 函数中调用 Hook,不要在普通的 JavaScript 函数中调用 Hook。你可以:
✅ 在 React 的函数组件中调用 Hook
✅ 在自定义 Hook 中调用其他 Hook
虽然官方文档对各个 hook 的说明都已经非常清晰完整,这里简要的补充一些说明,也说明一下使用这些 hooks 时的一些需要注意的地方。
① useState 的使用
useState,就是在函数中使用 state。大多数使用方法如下:
const [state, setState] = useState(defaultState)
1. setState(newState)
2. setState(oldState => ({...oldState, newKey: value}))
3. return (<div>{state}</div>)
看起来使用 useState 会比较神奇,但其实它就是一个 ES6 数组解构的语法糖
const [a, b] = [1, 2]
a == 1 // true
b == 2 // true
而 useState 方法也就是相当于返回一个状态值跟设置这个状态的方法给前面数组里相同下标定义的变量。
这里要注意第二种使用方法,React 会对多个 setState 进行合并处理以高频触发 render 更新,所以在 setState 里传入的方法会在合并之后执行。
除此之外,在非同步代码中调用的 setState 时会立即触发渲染(立即触发渲染并不意味着你可以在同步代码中获取到最新的 state)。
你可能需要在异步或者 effect 中注意使用 setState,因为这些过程中你可能会忽略组件当前是否已被卸载,在被卸载的组件里调用 setState 时,容易造成内存泄露。
② useEffect 的使用
与 useEffect 相对应的是生命周期,但它没有那么多乱七八糟的生命周期命名,也不需要定义变量赋值,大多数使用场景如下:
1. useEffect(() => { console.log('hooks') }, [dep1, dep2, ...deps])
2. useEffect(() => { console.log('hooks') }, [])
3. useEffect(() => { console.log('hooks') }) // 不传依赖
4. useEffect(() => { return () => { console.log('hooks') }}, [dep1, dep2, ...deps])
5. useEffect(() => { return () => { console.log('hooks') }}, [])
首先 useEffect 传入的方法(我们可以称之为 effect 方法)会组件 render 完毕之后才会执行,也就意味着 useEffect 是会有造成无限循环渲染的可能的(如果你在 useEffect 里调用了 setState,同时又依赖了这个 state)。
useEffect 第二个参数传入的是 effect 的依赖,当依赖发生变化时就会触发执行 effect 里的代码,它相当于 watch 依赖中的所有变量,当变量发生变化时触发执行。
发生变化并不是指 React 会监听这个依赖何时产生变更,而是指对比前一次渲染和后一次渲染时的依赖值的结果,当依赖值不同时,认为是发生变化。
当组件或者页面首次加载时,effect 都会执行一次。如果依赖传入空数组,那 effect 也就只执行一次,而不传值时,每一次组件 render 都会执行 effect。而 effect 里返回的函数,会在卸载组件时触发执行,相当于是组件 destroy 时调用的方法。
不过有一点需要注意的是,effect 里所有的变量、参数会在执行 useEffect 的时候被闭包,使你无法获取到最新的变量值,所以你可能会踩到下面两个坑点:
function Page1(props) {
const [state, setState] = useState('abc')
useEffect(() => {
console.log(state) // 'abc'
setState('efg')
setTimeout(() => { console.log(state) }, 5000) // 5秒之后仍旧是 'abc'
}, [state])
}
function Page2(props) {
const [state, setState] = useState('abc')
useEffect(() => {
setState('efg')
return () => { console.log(state) }// 页面各种操作后最后卸载时仍旧是 'abc'
}, [])
}
以及还有这两种问题混合起来的问题
function Page3(props) {
const [state, setState] = useState('abc')
// 希望页面卸载时用的是最新的 state,但是首次加载时执行 effect 的代码
useEffect(() => {
setState('efg')
console.log(state) // 'abc'
return () => { console.log(state) }// 仍旧是 'abc'
}, [state])
}
解决这种场景可以通过把 effect 分开来写,如下:
function Page4(props) {
const [state, setState] = useState('abc')
// 希望页面卸载时用的是最新的 state,但是首次加载时执行 effect 的代码
useEffect(() => { // 首次加载时执行
setState('efg')
}, [])
useEffect(() => { // 卸载时也能拿到最新的 state
return () => { console.log(state) } // 'efg'
}, [state])
}
③ useMemo 和 useCallback 的使用
useMemo 和 useCallback,为什么要把这两个 hooks 放在一起说呢?因为这两个 hook 本质上是同一个。
useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。
useMemo 也是写 React 项目时很重要的一个 hook,Memo 全称是 Memorizer,它可以把值存储起来,使我们拿到该值的时候不需要再经过相同的计算过程。例如
const memoValue = useMemo(() => {
return new Array(100000).fill(1).map((item, index) => index)
}, [...deps])
useMemo 的第二个参数跟 useEffect 一样都是依赖,也就是当依赖发生变化时,存储的值才会重新调用 useMemo 里的方法重新取值,这样可以避免每次 render 都反复执行相同一个复杂耗时的取值方法也就提高了组件或者页面性能,这在渲染大规模数据的时候非常重要。
有一点区别就是,当依赖传入一个空数组时,useMemo 相当于存储了一个从加载组件到卸载为止都不会发生变化的值。
useMemo 可以存储任何数据也包括组件:
const memoValue = useMemo(() => {
return new Array(100000).fill(1).map((item, index) => index)
}, [])
const memoNode = useMemo(() => {
return new Array(100000).fill(1).map((item, index) => (<div>{index}</div>))
}, [])
...
return (
<div>{memoNode}</div>
)
useCallback 也是同理,可以避免因为多次 render 而每次都重新声明函数的过程。
const handleClick = useCallback((e) => {
// your code
}, [])
使用 useMemo 和 useCallback 的目的很简单,就是为了使变量在多次渲染中保持不变,一来可以避免在存储值需要大量计算消耗的时候多次计算,二是使引用到这些值的 memo 子组件不重新渲染。
④ useReducer 的使用
useReducer 常用于表单的数据管理,来看看它的使用:
// 1.声明默认 state
const defaultState = {
stateA: 'abc',
stateB: 'efg'
}
// 2.声明 reducer 方法
function reducer (state=defaultState, action) {
const newState = {...state}
const value = action.value
switch(action.key) {
case 'stateA': { // switch 的 case 可以加括号限定作用域
newState.stateA = value
return newState
}
case 'stateB': {
newState.stateB = value
return newState
}
default: {
return newState // reducer 最后结果必须返回一个 state
}
}
}
// 3. 在 React 中使用
function App () {
const [data, dispatch] = useReducer(reducer, defaultState)
const click = (e) => {
dispatch({key: 'stateA', value: 'efg'})
dispatch({key: 'stateB', value: 'abc'})
}
return (
<>
<div>{data.stateA}-{data.stateB}</div>
<button onClick={click}>click</button>
</>
)
}
你可以看到,使用 useReducer 需要不少代码量,你需要声明一个 reducer 方法用来分发处理数据,然后在 reducer 里面处理每一种 key 的过程。
const [stateA, setStateA] = useState('abc')
const [stateB, setStateB] = useState('efg')
....
setStateA('efg')
setStateB('abc')
useState 其实是 useReducer 的一种简单实现:
function useState(defaultState) {
// reducer 方法为 null 时,相当于不对 state 进行处理直接返回 dispatch 的值
return useReducer(null, defaultState)
}
这也说明了其实 reducer 方法并不是一定要按着上面的模板写,它就是个普通的 JS 方法。如果你知道 redux,你可能会以为要像 redux 一样写一堆 constants 常量来定义 action.type,然后在 reducer 里一样的写大量模板性的代码,实际上这不是必要的。比如你也可以这样:
const defaultState = { a: 0, b: 0}
function reducer(state=defaultState, action) {
let newState = {...state}
if (action === 'clear') return defaultState
else if (action.a) newState.a = action.a
else newState.b = action.b
return newState
}
function App () {
const [data, dispatch] = useReducer(reducer, defaultState)
const click = useCallback((e) => {
dispatch({a: 1})
dispatch({b: 2})
// dispatch('clear')
}, [])
return (
<>
<div>{data.a}-{data.b}</div>
<button onClick={click}>click</button>
</>
)
}
我们也可以直接封装一个便捷的方法来生成我们的 reducer,这样当我们页面有很多表单的时候都可以这样来得到我们的 reducer 方法,就不用逐个写 reducer 的内部逻辑了: codesandbox 例子
// 遍历 action 的 key,对应更改 state[key] 为 action[key]
const getPublicReducer = (defaultState) => (state = defaultState, action) => {
if (action === "clear") return defaultState;
const newState = { ...state };
const keys = Object.keys(action).filter(
(key) => defaultState[key] !== void 0
);
if (!keys.length) return state;
keys.forEach((ele) => (newState[ele] = action[ele]));
return newState;
};
// usage
const defaultStateA = { a: 0, b: 0 };
const defaultStateB = { c: 0, d: 0 };
const reducerA = getPublicReducer(defaultStateA);
const reducerB = getPublicReducer(defaultStateB);
export default function App() {
const [dataA, dispatchA] = useReducer(reducerA, defaultStateA);
const [dataB, dispatchB] = useReducer(reducerB, defaultStateB);
const clickA = useCallback((e) => {
dispatchA({ a: 1 });
dispatchA({ b: 2 });
dispatchA({ a: 3, b: 4 });
// dispatchA('clear')
}, []);
const clickB = useCallback((e) => {
dispatchB({ c: 5 });
dispatchB({ d: 6 });
dispatchB({ c: 7, d: 8 });
// dispatchB('clear')
}, []);
return (
<>
<div>{dataA.a}-{dataA.b}</div>
{/* 0-0 click 后为 3-4 */}
<div>{dataB.c}-{dataB.d}</div>
{/* 0-0 click 后为 7-8 */}
<br />
<button onClick={clickA}>clickA</button>
<button onClick={clickB}>clickB</button>
</>
);
}
值得注意的是,在声明 reducer 方法的时候必须要返回一个最后处理 state 的结果值。
如果有用过 Array.prototype.reduce 这个方法的同学应该知道,reduce 也需要把最后的累加值 account 返回给下次循环使用,这点 reducer 也保持基本一致,只不过 reducer 是返回给下次调用 dispatch 的时候使用。
⑤ useRef 的使用
useRef 可以用来获取一个实际的页面节点,如:
const div = useRef(null)
useEffect(() => {
console.log(ref.current) // <div>123</div>
}, [])
return (
<div ref={div}>123</div>
)
文章在此之前提过,useEffect 的执行是在页面加载之后,所以你可以在 effect 执行时取到对应的元素节点。
useRef 实际上是可以存储任何数据,并且你可以对 ref.current 进行任意改写,只是它永远不会触发组件 render:
const data = useRef({ a: 1 })
console.log(data.current) // {a: 1} 再次 render 时为 {b: 1} useRef 不会重新再声明 ref 的值
data.current = { b: 1 }
console.log(data.current) // {b: 1}
所以这也诞生一个 React 常用技巧,可以通过 useRef 保存方法来替代 useCallback。这样的方法不会在方法内部状态发生变更时,不会触发子组件更新。
function App() {
const [state, setState] = useState(false)
const func = useRef(() => {})
func.current = () => {
console.log(state)
}
return (
<Menu onClick={func.current}></Menu>
)
}
ahooks 库里就有一个这样的 hook useMemoizedFn。
⑥ useImperativeHandle 的使用
useImperativeHandle 这个 hooks 需要搭配 useRef 和 forwardRef 这两个 api 使用,主要用于暴露组件内部的方法(因为在 ref 中参数的更改不会触发渲染,所以暴露 state 之类的状态值就失去了意义),forwardRef可以定义一个自定义组件的 ref 值并暴露给外部组件使用,如下所示:
const Comp = forwardRef((props, ref) => {
const compMethod = (str) => console.log(str)
const inputRef = useRef(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
log: compMethod
}))
return (
<input type="text" ref={inputRef} />
)
})
function App () {
const ref = useRef(null)
useEffect(() => {
const { focus, log } = ref.current
focus()
log('input')
}, [])
return (
<Comp ref={ref} />
)
}
ref 的使用在后续组件章节中会在提到有关 ref 透传的使用。
三、React 的更新机制
当你在使用 hooks 编写前端功能时,你看到的每个函数组件本质上是组件的 render 方法,例如
function A () {
return (<div>a</div>)
}
function B (props) {
return (<div>{props.value}</div>)
}
function App() {
return (<div>
<A />
<B value="b" />
</div>)
}
这就意味着,React 要渲染就必须调用这个组件函数,而在调用 render 函数后,React 内部会生成新的虚拟节点树,React 会对更新前后的节点树进行浅比较,对产生变更的节点进行 全量更新。
全量更新指组件一旦产生更新,所有子组件都会重新 render,除非子组件处理了 memo 缓存。
① 函数式组件什么时候更新
官网文档是这么说的,当组件的 props 变化或者调用 setState 时,组件以及子组件就会被重新渲染。
但你也可以认为 React 所有的更新都将来自于 useState hook 返回的 setState 方法。因为 props 的变化是来自于祖先组件的 setState 产生的更新。
在 JS 中,变量分为简单类型和引用类型,而属性的改变指的就是简单类型的值变化,或者是引用类型在栈中的指向发生变化,这意味着像对象或数组一样的引用类型,如果要触发页面渲染,我们需要返回相应的浅拷贝的值。如:
const [a, setA] = useState([1,2,3])
setState(_ => {
_.push(4)
return _
// 不会触发渲染 因为_没有被改变
})
const [b, setB] = useState([1,2,3])
setState(_ => {
_.push(4)
return [..._]
// 触发渲染
})
控制组件的更新是写 React 最重要的一环之一,特别是在面临着大数据渲染时,避免不必要的重复渲染或者函数调用可能直接影响到整个页面的性能。
注意,因为 React 的更新是只要变动,render 的函数就重新调用全量更新,这意味着当发生上述组件更新时,该组件或者是当前页面下的所有节点都将逐个重新渲染。
在 Class Component 中,我们可以通过 SCU(Should Component Update) 来控制是否更新当前组件从而达到手动控制子组件是否触发重新渲染的目的,而在 Function Component 中,我们有这样两个选择,一是前面 hooks 介绍讲的 useMemo,二是 React.memo,二者都用了 memo 作为关键字,那我们来看看他们有什么不同。
② React.memo 和 useMemo
React.memo 是直接从 React 库中导出的方法,用 React.memo 包裹的组件在渲染时,默认会先对组件更新前后的每个属性进行浅比较, 若有属性前后不同,则渲染该组件。来看这个例子:
import React, {memo} from 'react'
let num = 1
// const ChildComp = (props => { // 没有加 memo
const ChildComp = memo(props => { // 加了 memo
const { name } = props
console.log('render child ', name) // 当触发重渲染的时候会打印
return (
<div>{name}</div>
)
})
const FatherComp = props => {
console.log('render father') // 当触发重渲染的时候会打印
const [a, setA] = useState('a')
const [b, setB] = useState('b')
const hanldePlus = () => {
setA('a' + num)
setB('b')
num++
}
return (
<div>
<button style={{padding: '15px'}}>plus</button>
<ChildComp name={a} />
<ChildComp name={b} />
</div>
)
}
function App () {
return <FatherComp />
}
我们来对比一下是否使用 memo 两者的区别:codesandbox 例子
点击 plus 按钮可以看到,当使用 memo 的时候,虽然父组件调用了 setB 触发了父组件的更新,但是子组件由于 name 值并没有改变所以没有触发渲染。
而当不使用 memo 时, 子组件即使 name 值没有改变,但仍旧触发了重渲染。
React.memo 和 useMemo 两个名字和功能上基本相同,但在使用上也有一些差别,准确地来说,React.memo 是传入一个React 组件之后返回一个经过处理的组件,我们可以在 JSX 中直接使用它,而 useMemo 首先只能在函数式组件或者自定义 hooks 中使用,其次它存储的是一个值,如果要想用 useMemo 去实现上面的例子,那么要这么写:
import React, { useMemo } from 'react'
let num = 1
const ChildComp = props => { // 没有加 memo
const { name } = props
console.log('render child ', name) // 当触发重渲染的时候会打印
return (
<div>{name}</div>
)
}
const FatherComp = (props) => {
console.log("render father"); // 当触发重渲染的时候会打印
const [a, setA] = useState("a");
const [b, setB] = useState("b");
const handlePlus = () => {
setA("a" + num);
setB("b");
num++;
};
// 不同点
const CompA = useMemo(() => ( <ChildComp name={a}/> ), [a])
const CompB = useMemo(() => ( <ChildComp name={b}/> ), [b])
return (
<div>
<button style={{ padding: "15px" }} onClick={handlePlus}> plus </button>
{CompA}
{CompB}
</div>
);
};
function App () {
return <FatherComp />
}
最后结果是和上面截图保持一致的。
除此之外你也可以在 React.memo 方法的第二个参数传入 isPropsEqual 方法,该方法会提供更新前后的 props,由你来决定是否产生更新。
import { memo } from 'react'
const A = memo((props) => {
const { id, ...rest } = props
return (...)
}, (prev, next) => prev.id === next.id)
React.memo 默认第二个参数传的是
Object.is方法
③ createContext 和 useContext 的使用(不建议)
因为我觉得 useContext 是一个比较“危险”的 hooks,原因在于它可以越过 memo 或者 useMemo 进行组件更新,它脱离了 React 的对 props 和 state 的监听更新规范。在调用 useContext 时,React 会收集当前组件渲染方法,之后在 Provider 产生变更时更新这些组件,这就意味着外部的 memo 限制将会失效
而在一般项目中,我们通常有更好的选择如 redux、mobx 等库去管理我们的全局数据和请求,而且写 useContext 也不算简便,先介绍一下它的使用方法: codesandbox 例子
import React, {useState, memo, useContext, createContext} from 'react'
let num = 1
const Context = createContext(defaultData) // defaultData 是默认值,当找不到祖先的 Context 时才被使用
const Comp = memo(() => {
console.log('render')
const { name } = useContext(Context)
return ( <div>{name}</div> )
})
function App () {
const [value, setValue] = useState('abc')
const click = () => {
setValue('bcd' + num)
num ++
}
// 子组件使用该 Context 时需要在祖先组件嵌套一层 Provider 并提供数据 value
// 注意,我们在 createContext 的时候给予了一个 defaultData
// 当子组件的 useContext 找不到祖先节点的 context 时才会使用 defaultData
return (
<Context.Provider value={{name: 'roxz', value}}>
<Comp />
<button onClick={click}>click</button>
</Context.Provider>
)
}
在上面例子中我们点击 button 会触发 Comp 组件重渲染,即使你从 Context 中拿出来的值没有发生改变,它一样触发了更新。
你还看不到这里面其实还有另外一个坑点,就是父组件 App 的 render 重新渲染,会使 Provider 里传的 value 对象也更新了新的地址,而最优使用 Context 除了要注意底下组件的更新同时,最好是把 Context 的内容提取为一层独立组件,如下:
import React, {useState, memo, useContext, createContext} from 'react'
let num = 1
const Context = createContext(defaultData) // defaultData 默认值, 当找不到 Context
const Comp = memo(() => {
console.log('render')
const { name } = useContext(Context)
return ( <div>{name}</div> )
})
const TheProvider = memo((props) => {
const [value, setValue] = useState('abc')
return (
<Context.Provider value={{name: 'roxz', value, setValue}}>
{props.children}
</Context.Provider>
)
})
function Button () {
const { setValue } = useContext(Context)
const click = () => {
setValue('bcd' + num)
num ++
}
return (<button onClick={click}>click</button>)
}
function App () {
// 子组件使用该 Context 时需要在祖先组件嵌套一层 Provider 并提供数据 value
// 注意,我们在 createContext 的时候给予了一个 defaultData
// 当子组件的 useContext 找不到祖先节点的 context 时才会使用 defaultData
return (
<TheProvider>
<Comp />
<Button />
</TheProvider>
)
}
但在某些特殊状况下因为这种更新方式也能达到性能优化的效果,比如在列表渲染的时候,我们可以跳过列表项的更新直接进入列表项里面的组件更新,例如列表中的 checkbox 组件等等。
④ useContext 和 useRef 搭配
由于以上情况的存在,useContext 这个 hooks 需要人们更谨慎的使用,但是如果我们只使用 Context,那为了性能我们可能需要把 Context 中可能会影响的变量抽出来单独使用一个新的 Context,如果变量太多的话我们可能会写成这样子:
import React, {useState, useContext, createContext} from 'react'
const A_Context = createContext({})
const B_Context = createContext({})
const C_Context = createContext({})
function App () {
const [a, setA] = useState('')
const [b, setB] = useState('')
const [c, setC] = useState('')
return (
<A_Context.Provide value = {{a, setA}}>
<B_Context.Provide value = {{b, setB}}>
<C_Context.Provide value = {{c, setC}}>
<Content />
</C_Context.Provide>
</B_Context.Provide>
</A_Context.Provide>
)
}
更好的方式可以搭配 useRef 来控制值的变更,如下:
import React, {useState, useMemo, useContext, createContext} from 'react'
const Context = createContext({})
function useRefContext (context) {
const [state, setState] = useState(Math.random())
useContext(context)
}
function App () {
const [a, setA] = useState('')
const [b, setB] = useState('')
const [c, setC] = useState('')
const value = useRef({})
value.current = {
a, setA,
b, setB,
c, setC
}
return (
<Context.Provide value = {value.current}>
<Content />
</Context.Provide>
)
}
function Content () {
return (
<A />
<B />
<C />
)
}
function A () {
const [_, update] = useState(Math.random())
const { a, setA } = useContext(Context)
const onClick = () => {
setA(Math.random())
update()
}
return (...)
}
...
如果你觉得这样很麻烦,同时又不想引入一些第三方状态管理库,可以看这篇文章 ~
结语
这里是 Xekin(/zi:kin/),以上这就是本篇文章分享的全部内容了,喜欢的掘友们可以点赞关注点个收藏~
最近摸鱼时间比较多,写了一些奇奇怪怪有用但又不是特别有用的工具,不过还是非常有意思的,之后会一一写文章分享出来,感谢各位支持。
我还是喜欢写没人写过的东西~