从 React Hooks 正式发布到如今,我一直在项目使用它。可是,在使用 Hooks 的过程当中,我也进入了一些误区,致使写出来的代码隐藏 bug 而且难以维护。这篇文章中,我会具体分析这些问题,并总结一些好的实践,以供你们参考。javascript
useState
的出现,让咱们可使用多个 state 变量来保存 state,好比:html
const [width, setWidth] = useState(100); const [height, setHeight] = useState(100); const [left, setLeft] = useState(0); const [top, setTop] = useState(0);
但同时,咱们也能够像 Class 组件的 this.state
同样,将全部的 state 放到一个 object
中,这样只需一个 state 变量便可:java
const [state, setState] = useState({ width: 100, height: 100, left: 0, top: 0 });
那么问题来了,到底该用单个 state 变量仍是多个 state 变量呢?react
若是使用单个 state 变量,每次更新 state 时须要合并以前的 state。由于 useState
返回的 setState
会替换原来的值。这一点和 Class 组件的 this.setState
不一样。this.setState
会把更新的字段自动合并到 this.state
对象中。typescript
const handleMouseMove = (e) => { setState((prevState) => ({ ...prevState, left: e.pageX, top: e.pageY, })) };
使用多个 state 变量可让 state 的粒度更细,更易于逻辑的拆分和组合。好比,咱们能够将关联的逻辑提取到自定义 Hook 中:编程
function usePosition() { const [left, setLeft] = useState(0); const [top, setTop] = useState(0); useEffect(() => { // ... }, []); return [left, top, setLeft, setTop]; }
咱们发现,每次更新 left
时 top
也会随之更新。所以,把 top
和 left
拆分为两个 state 变量显得有点多余。数组
在使用 state 以前,咱们须要考虑状态拆分的「粒度」问题。若是粒度过细,代码就会变得比较冗余。若是粒度过粗,代码的可复用性就会下降。那么,到底哪些 state 应该合并,哪些 state 应该拆分呢?我总结了下面两点:浏览器
- 将彻底不相关的 state 拆分为多组 state。好比
size
和position
。- 若是某些 state 是相互关联的,或者须要一块儿发生改变,就能够把它们合并为一组 state。好比
left
和top
。
function Box() { const [position, setPosition] = usePosition(); const [size, setSize] = useState({width: 100, height: 100}); // ... } function usePosition() { const [position, setPosition] = useState({left: 0, top: 0}); useEffect(() => { // ... }, []); return [position, setPosition]; }
使用 useEffect
hook 时,为了不每次 render 都去执行它的 callback,咱们一般会传入第二个参数「dependency array」(下面统称为依赖数组)。这样,只有当依赖数组发生变化时,才会执行 useEffect
的回调函数。闭包
function Example({id, name}) { useEffect(() => { console.log(id, name); }, [id, name]); }
在上面的例子中,只有当 id
或 name
发生变化时,才会打印日志。依赖数组中必须包含在 callback 内部用到的全部参与 React 数据流的值,好比 state
、props
以及它们的衍生物。若是有遗漏,可能会形成 bug。这其实就是 JS 闭包问题,对闭包不清楚的同窗能够自行 google,这里就不展开了。app
function Example({id, name}) { useEffect(() => { // 因为依赖数组中不包含 name,因此当 name 发生变化时,没法打印日志 console.log(id, name); }, [id]); }
在 React 中,除了 useEffect 外,接收依赖数组做为参数的 Hook 还有 useMemo
、useCallback
和 useImperativeHandle
。咱们刚刚也提到了,依赖数组中千万不要遗漏回调函数内部依赖的值。可是,若是依赖数组依赖了过多东西,可能致使代码难以维护。我在项目中就看到了这样一段代码:
const refresh = useCallback(() => { // ... }, [name, searchState, address, status, personA, personB, progress, page, size]);
不要说内部逻辑了,光是看到这一堆依赖就使人头大!若是项目中处处都是这样的代码,可想而知维护起来多么痛苦。如何才能避免写出这样的代码呢?
首先,你须要从新思考一下,这些 deps 是否真的都须要?看下面这个例子:
function Example({id}) { const requestParams = useRef({}); requestParams.current = {page: 1, size: 20, id}; const refresh = useCallback(() => { doRefresh(requestParams.current); }, []); useEffect(() => { id && refresh(); }, [id, refresh]); // 思考这里的 deps list 是否合理? }
虽然 useEffect
的回调函数依赖了 id
和 refresh
方法,可是观察 refresh
方法能够发现,它在首次 render 被建立以后,永远不会发生改变了。所以,把它做为 useEffect
的 deps 是多余的。
其次,若是这些依赖真的都是须要的,那么这些逻辑是否应该放到同一个 hook 中?
function Example({id, name, address, status, personA, personB, progress}) { const [page, setPage] = useState(); const [size, setSize] = useState(); const doSearch = useCallback(() => { // ... }, []); const doRefresh = useCallback(() => { // ... }, []); useEffect(() => { id && doSearch({name, address, status, personA, personB, progress}); page && doRefresh({name, page, size}); }, [id, name, address, status, personA, personB, progress, page, size]); }
能够看出,在 useEffect
中有两段逻辑,这两段逻辑是相互独立的,所以咱们能够将这两段逻辑放到不一样 useEffect
中:
useEffect(() => { id && doSearch({name, address, status, personA, personB, progress}); }, [id, name, address, status, personA, personB, progress]); useEffect(() => { page && doRefresh({name, page, size}); }, [name, page, size]);
若是逻辑没法继续拆分,可是依赖数组仍是依赖了过多东西,该怎么办呢?就好比咱们上面的代码:
useEffect(() => { id && doSearch({name, address, status, personA, personB, progress}); }, [id, name, address, status, personA, personB, progress]);
这段代码中的 useEffect
依赖了七个值,仍是偏多了。仔细观察上面的代码,能够发现这些值都是「过滤条件」的一部分,经过这些条件能够过滤页面上的数据。所以,咱们能够将它们看作一个总体,也就是咱们前面讲过的合并 state:
const [filters, setFilters] = useState({ name: "", address: "", status: "", personA: "", personB: "", progress: "" }); useEffect(() => { id && doSearch(filters); }, [id, filters]);
若是 state 不能合并,在 callback 内部又使用了 setState
方法,那么能够考虑使用 setState
callback 来减小一些依赖。好比:
const useValues = () => { const [values, setValues] = useState({ data: {}, count: 0 }); const [updateData] = useCallback( (nextData) => { setValues({ data: nextData, count: values.count + 1 // 由于 callback 内部依赖了外部的 values 变量,因此必须在依赖数组中指定它 }); }, [values], ); return [values, updateData]; };
上面的代码中,咱们必须在 useCallback
的依赖数组中指定 values
,不然咱们没法在 callback 中获取到最新的 values
状态。可是,经过 setState
回调函数,咱们不用再依赖外部的 values
变量,所以也无需在依赖数组中指定它。就像下面这样:
const useValues = () => { const [values, setValues] = useState({}); const [updateData] = useCallback((nextData) => { setValues((prevValues) => ({ data: nextData, count: prevValues.count + 1, // 经过 setState 回调函数获取最新的 values 状态,这时 callback 再也不依赖于外部的 values 变量了,所以依赖数组中不须要指定任何值 })); }, []); // 这个 callback 永远不会从新建立 return [values, updateData]; };
最后,还能够经过 ref
来保存可变变量。之前咱们只把 ref
用做保持 DOM 节点引用的工具,可 useRef
Hook 能作的事情远不止如此。咱们能够用它来保存一些值的引用,并对它进行读写。举个例子:
const useValues = () => { const [values, setValues] = useState({}); const latestValues = useRef(values); latestValues.current = values; const [updateData] = useCallback((nextData) => { setValues({ data: nextData, count: latestValues.current.count + 1, }); }, []); return [values, updateData]; };
在使用 ref
时要特别当心,由于它能够随意赋值,因此必定要控制好修改它的方法。特别是一些底层模块,在封装的时候千万不要直接暴露 ref
,而是提供一些修改它的方法。
说了这么多,归根到底都是为了写出更加清晰、易于维护的代码。若是发现依赖数组依赖过多,咱们就须要从新审视本身的代码。
- 依赖数组依赖的值最好不要超过 3 个,不然会致使代码会难以维护。
若是发现依赖数组依赖的值过多,咱们应该采起一些方法来减小它。
- 去掉没必要要的依赖。
- 将 Hook 拆分为更小的单元,每一个 Hook 依赖于各自的依赖数组。
- 经过合并相关的 state,将多个依赖值聚合为一个。
- 经过
setState
回调函数获取最新的 state,以减小外部依赖。- 经过
ref
来读取可变变量的值,不过须要注意控制修改它的途径。
useMemo
?该不应使用 useMemo
?对于这个问题,有的人历来没有思考过,有的人甚至不以为这是个问题。无论什么状况,只要用 useMemo
或者 useCallback
「包裹一下」,彷佛就能使应用远离性能的问题。但真的是这样吗?有的时候 useMemo
没有任何做用,甚至还会影响应用的性能。
为何这么说呢?首先,咱们须要知道 useMemo
自己也有开销。useMemo
会「记住」一些值,同时在后续 render 时,将依赖数组中的值取出来和上一次记录的值进行比较,若是不相等才会从新执行回调函数,不然直接返回「记住」的值。这个过程自己就会消耗必定的内存和计算资源。所以,过分使用 useMemo
可能会影响程序的性能。
要想合理使用 useMemo
,咱们须要搞清楚 useMemo
适用的场景:
让咱们来看个例子:
interface IExampleProps { page: number; type: string; } const Example = ({page, type}: IExampleProps) => { const resolvedValue = useMemo(() => { return getResolvedValue(page, type); }, [page, type]); return <ExpensiveComponent resolvedValue={resolvedValue}/>; };
注:ExpensiveComponent
组件包裹了React.memo
。
在上面的例子中,渲染 ExpensiveComponent
的开销很大。因此,当 resolvedValue
的引用发生变化时,做者不想从新渲染这个组件。所以,做者使用了 useMemo
,避免每次 render 从新计算 resolvedValue
,致使它的引用发生改变,从而使下游组件 re-render。
这个担心是正确的,可是使用 useMemo
以前,咱们应该先思考两个问题:
useMemo
的函数开销大不大?在上面的例子中,就是考虑 getResolvedValue
函数的开销大不大。JS 中大多数方法都是优化过的,好比 Array.map
、Array.forEach
等。若是你执行的操做开销不大,那么就不须要记住返回值。不然,使用 useMemo
自己的开销就可能超太重新计算这个值的开销。所以,对于一些简单的 JS 运算来讲,咱们不须要使用 useMemo
来「记住」它的返回值。page
和 type
相同时,resolvedValue
的引用是否会发生改变?这里咱们就须要考虑 resolvedValue
的类型了。若是 resolvedValue
是一个对象,因为咱们项目上使用「函数式编程」,每次函数调用都会产生一个新的引用。可是,若是 resolvedValue
是一个原始值(string
, boolean
, null
, undefined
, number
, symbol
),也就不存在「引用」的概念了,每次计算出来的这个值必定是相等的。也就是说,ExpensiveComponent
组件不会被从新渲染。所以,若是 getResolvedValue
的开销不大,而且 resolvedValue
返回一个字符串之类的原始值,那咱们彻底能够去掉 useMemo
,就像下面这样:
interface IExampleProps { page: number; type: string; } const Example = ({page, type}: IExampleProps) => { const resolvedValue = getResolvedValue(page, type); return <ExpensiveComponent resolvedValue={resolvedValue}/>; };
还有一个误区就是对建立函数开销的评估。有的人以为在 render 中建立函数可能会开销比较大,为了不函数屡次建立,使用了 useMemo
或者 useCallback
。可是对于现代浏览器来讲,建立函数的成本微乎其微。所以,咱们没有必要使用 useMemo
或者 useCallback
去节省这部分性能开销。固然,若是是为了保证每次 render 时回调的引用相等,你能够放心使用 useMemo
或者 useCallback
。
const Example = () => { const onSubmit = useCallback(() => { // 考虑这里的 useCallback 是否必要? doSomething(); }, []); return <form onSubmit={onSubmit}></form>; };
我以前看过一篇文章(连接在文章的最后),这篇文章中提到,若是只是想在从新渲染时保持值的引用不变,更好的方法是使用 useRef
,而不是 useMemo
。我并不一样意这个观点。让咱们来看个例子:
// 使用 useMemo function Example() { const users = useMemo(() => [1, 2, 3], []); return <ExpensiveComponent users={users} /> } // 使用 useRef function Example() { const {current: users} = useRef([1, 2, 3]); return <ExpensiveComponent users={users} /> }
在上面的例子中,咱们用 useMemo
来「记住」users
数组,不是由于数组自己的开销大,而是由于 users
的引用在每次 render 时都会发生改变,从而致使子组件 ExpensiveComponent
从新渲染(可能会带来较大开销)。
做者认为从语义上不该该使用 useMemo
,而是应该使用 useRef
,不然会消耗更多的内存和计算资源。虽然在 React 中 useRef
和 useMemo
的实现有一点差异,可是当 useMemo
的依赖数组为空数组时,它和 useRef
的开销能够说相差无几。useRef
甚至能够直接用 useMemo
来实现,就像下面这样:
const useRef = (v) => { return useMemo(() => ({current: v}), []); };
所以,我认为使用 useMemo
来保持值的引用一致没有太大问题。
在编写自定义 Hook 时,返回值必定要保持引用的一致性。由于你没法肯定外部要如何使用它的返回值。若是返回值被用作其余 Hook 的依赖,而且每次 re-render 时引用不一致(当值相等的状况),就可能会产生 bug。好比:
function Example() { const data = useData(); const [dataChanged, setDataChanged] = useState(false); useEffect(() => { setDataChanged((prevDataChanged) => !prevDataChanged); // 当 data 发生变化时,调用 setState。若是 data 值相同而引用不一样,就可能会产生非预期的结果。 }, [data]); console.log(dataChanged); return <ExpensiveComponent data={data} />; } const useData = () => { // 获取异步数据 const resp = getAsyncData([]); // 处理获取到的异步数据,这里使用了 Array.map。所以,即便 data 相同,每次调用获得的引用也是不一样的。 const mapper = (data) => data.map((item) => ({...item, selected: false})); return resp ? mapper(resp) : resp; };
在上面的例子中,咱们经过 useData
Hook 获取了 data
。每次 render 时 data
的值没有发生变化,可是引用却不一致。若是把 data
用到 useEffect
的依赖数组中,就可能产生非预期的结果。另外,因为引用的不一样,也会致使 ExpensiveComponent
组件 re-render,产生性能问题。
若是由于 prop 的值相同而引用不一样,从而致使子组件发生 re-render,不必定会形成性能问题。由于 Virtual DOM re-render ≠ DOM re-render。可是当子组件特别大时,Virtual DOM 的 Diff 开销也很大。所以,仍是应该尽可能避免子组件 re-render。
所以,在使用 useMemo
以前,咱们不妨先问本身几个问题:
回答出上面这几个问题,判断是否应该使用 useMemo
也就再也不困难了。不过在实际项目中,仍是最好定义出一套统一的规范,方便团队中多人协做。好比第一个问题,开销很大如何定义?若是没有明确的标准,执行起来会很是困难。所以,我总结了下面一些规则:
1、应该使用
useMemo
的场景
保持引用相等:
- 对于组件内部用到的 object、array、函数等,若是用在了其余 Hook 的依赖数组中,或者做为 props 传递给了下游组件,应该使用
useMemo
。- 自定义 Hook 中暴露出来的 object、array、函数等,都应该使用
useMemo
。以确保当值相同时,引用不发生变化。- 使用
Context
时,若是Provider
的 value 中定义的值(第一层)发生了变化,即使用了 Pure Component 或者React.memo
,仍然会致使子组件 re-render。这种状况下,仍然建议使用useMemo
保持引用的一致性。计算成本很高
- 好比
cloneDeep
一个很大而且层级很深的数据2、无需使用 useMemo 的场景
- 若是返回的值是原始值:
string
,boolean
,null
,undefined
,number
,symbol
(不包括动态声明的 Symbol),通常不须要使用useMemo
。- 仅在组件内部用到的 object、array、函数等(没有做为 props 传递给子组件),且没有用到其余 Hook 的依赖数组中,通常不须要使用
useMemo
。
在 Hooks 出现以前,咱们有两种方法能够复用组件逻辑:Render Props 和高阶组件。可是这两种方法均可能会形成 JSX「嵌套地狱」的问题。Hooks 的出现,让组件逻辑的复用变得更简单,同时解决了「嵌套地狱」的问题。Hooks 之于 React 就像 async / await 之于 Promise 同样。
那 Hooks 能替代高阶组件和 Render Props 吗?官方给出的回答是,在高阶组件或者 Render Props 只渲染一个子组件时,Hook 提供了一种更简单的方式。不过在我看来,Hooks 并不能彻底替代 Render Props 和高阶组件。接下来,咱们会详细分析这个问题。
高阶组件是一个函数,它接受一个组件做为参数,返回一个新的组件。
function enhance(Comp) { // 增长一些其余的功能 return class extends Component { // ... render() { return <Comp />; } }; }
高阶组件采用了装饰器模式,让咱们能够加强原有组件的功能,而且不破坏它原有的特性。例如:
const RedButton = withStyles({ root: { background: "red", }, })(Button);
在上面的代码中,咱们但愿保留 Button
组件的逻辑,但同时咱们又想使用它原有的样式。所以,咱们经过 withStyles
这个高阶组件注入了自定义的样式,而且生成了一个新的组件 RedButton
。
Render Props 经过父组件将可复用逻辑封装起来,并把数据提供给子组件。至于子组件拿到数据以后要怎么渲染,彻底由子组件本身决定,灵活性很是高。而高阶组件中,渲染结果是由父组件决定的。Render Props 不会产生新的组件,并且更加直观的体现了「父子关系」。
<Parent> {(data) => { // 你父亲已经把江山给你打好了,并给你留下了一堆金币,至于怎么花就看你本身了 return <Child data={data} />; }} </Parent>
Render Props 做为 JSX 的一部分,能够很方便地利用 React 生命周期和 Props、State 来进行渲染,在渲染上有着很是高的自由度。同时,它不像 Hooks 须要遵照一些规则,你能够放心大胆的在它里面使用 if / else、map 等各种操做。
在大部分状况下,高阶组件和 Render Props 是能够相互转换的,也就是说用高阶组件能实现的,用 Render Props 也能实现。只不过在不一样的场景下,哪一种方式使用起来简单一点罢了。
将上面 HOC 的例子改为 Render Props,使用起来确实要「麻烦」一点:
<RedButton> {(styles)=>( <Button styles={styles}/> )} </RedButton>
没有 Hooks 以前,高阶组件和 Render Props 本质上都是将复用逻辑提高到父组件中。而 Hooks 出现以后,咱们将复用逻辑提取到组件顶层,而不是强行提高到父组件中。这样就可以避免 HOC 和 Render Props 带来的「嵌套地狱」。可是,像 Context 的 <Provider/>
和 <Consumer/>
这样有父子层级关系(树状结构关系)的,仍是只能使用 Render Props 或者 HOC。
对于 Hooks、Render Props 和高阶组件来讲,它们都有各自的使用场景:
Hooks:
getSnapshotBeforeUpdate
和 componentDidCatch
还不支持。不过,能使用 Hooks 的场景仍是应该优先使用 Hooks,其次才是 Render Props 和 HOC。固然,Hooks、Render Props 和 HOC 不是对立的关系。咱们既能够用 Hook 来写 Render Props 和 HOC,也能够在 HOC 中使用 Render Props 和 Hooks。
1.若 Hook 类型相同,且依赖数组一致时,应该合并成一个 Hook。不然会产生更多开销。
const dataA = useMemo(() => { return getDataA(); }, [A, B]); const dataB = useMemo(() => { return getDataB(); }, [A, B]); // 应该合并为 const [dataA, dataB] = useMemo(() => { return [getDataA(), getDataB()] }, [A, B]);
2.参考原生 Hooks 的设计,自定义 Hooks 的返回值可使用 Tuple 类型,更易于在外部重命名。但若是返回值的数量超过三个,仍是建议返回一个对象。
export const useToggle = (defaultVisible: boolean = false) => { const [visible, setVisible] = useState(defaultVisible); const show = () => setVisible(true); const hide = () => setVisible(false); return [visible, show, hide] as [typeof visible, typeof show, typeof hide]; }; const [isOpen, open, close] = useToggle(); // 在外部能够更方便地修更名字 const [visible, show, hide] = useToggle();
3.ref
不要直接暴露给外部使用,而是提供一个修改值的方法。
4.在使用 useMemo
或者 useCallback
时,确保返回的函数只建立一次。也就是说,函数不会根据依赖数组的变化而二次建立。举个例子:
export const useCount = () => { const [count, setCount] = useState(0); const [increase, decrease] = useMemo(() => { const increase = () => { setCount(count + 1); }; const decrease = () => { setCount(count - 1); }; return [increase, decrease]; }, [count]); return [count, increase, decrease]; };
在 useCount
Hook 中, count
状态的改变会让 useMemo
中的 increase
和 decrease
函数被从新建立。因为闭包特性,若是这两个函数被其余 Hook 用到了,咱们应该将这两个函数也添加到相应 Hook 的依赖数组中,不然就会产生 bug。好比:
function Counter() { const [count, increase] = useCount(); useEffect(() => { const handleClick = () => { increase(); // 执行后 count 的值永远都是 1 }; document.body.addEventListener("click", handleClick); return () => { document.body.removeEventListener("click", handleClick); }; }, []); return <h1>{count}</h1>; }
在 useCount
中,increase
会随着 count
的变化而被从新建立。可是 increase
被从新建立以后, useEffect
并不会再次执行,因此 useEffect
中取到的 increase
永远都是首次建立时的 increase
。而首次建立时 count
的值为 0,所以不管点击多少次, count
的值永远都是 1。
那把 increase
函数放到 useEffect
的依赖数组中不就行了吗?事实上,这会带来更多问题:
increase
的变化会致使频繁地绑定事件监听,以及解除事件监听。useEffect
,可是 increase
的变化会致使 useEffect
屡次执行,不能知足需求。如何解决这些问题呢?
1、经过 setState
回调,让函数不依赖外部变量。例如:
export const useCount = () => { const [count, setCount] = useState(0); const [increase, decrease] = useMemo(() => { const increase = () => { setCount((latestCount) => latestCount + 1); }; const decrease = () => { setCount((latestCount) => latestCount - 1); }; return [increase, decrease]; }, []); // 保持依赖数组为空,这样 increase 和 decrease 方法都只会被建立一次 return [count, increase, decrease]; };
2、经过 ref
来保存可变变量。例如:
export const useCount = () => { const [count, setCount] = useState(0); const countRef = useRef(count); countRef.current = count; const [increase, decrease] = useMemo(() => { const increase = () => { setCount(countRef.current + 1); }; const decrease = () => { setCount(countRef.current - 1); }; return [increase, decrease]; }, []); // 保持依赖数组为空,这样 increase 和 decrease 方法都只会被建立一次 return [count, increase, decrease]; };
咱们总结了在实践中一些常见的问题,并提出了一些解决方案。最后让咱们再来回顾一下:
若是发现依赖数组依赖的值过多,咱们应该采起一些方法来减小它。
setState
回调函数获取最新的 state,以减小外部依赖。ref
来读取可变变量的值,不过须要注意控制修改它的途径。为了确保不滥用 useMemo
,咱们定义了下面几条规则:
string
, boolean
, null
, undefined
, number
, symbol
(不包括动态声明的 Symbol),则不须要使用 useMemo
。useMemo
。useMemo
。以确保当值相同时,引用不发生变化。ref
不要直接暴露给外部使用,而是提供一个修改值的方法。useMemo
或者 useCallback
时,能够借助 ref
或者 setState
callback,确保返回的函数只建立一次。也就是说,函数不会根据依赖数组的变化而二次建立。参考文章: