redux+ts

安装reduxreact-redux@reduxjs/toolkit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// store/index.ts

import { configureStore } from '@reduxjs/toolkit';

import userSliceReducer from './userSlice';

const store = configureStore({
reducer: {
user: userSliceReducer,
},
});

// 从 store 本身推断出 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<typeof store.getState>;
// 推断出类型: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;

export default store;

定义 useDispatch, useSelector hook 类型

1
2
3
4
5
6
7
8
// store/hooks.ts

import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';

// 在整个应用程序中使用,而不是简单的 `useDispatch` 和 `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

创建一个 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
import { createSlice } from '@reduxjs/toolkit';
import { RootState, AppDispatch } from './index';

const userSlice = createSlice({
name: 'user',
initialState: {
value: 123,
},
reducers: {
incremented: (state) => {
// Redux Toolkit 允许在 reducers 中编写 "mutating" 逻辑。
// 它实际上并没有改变 state,因为使用的是 Immer 库,检测到“草稿 state”的变化并产生一个全新的
// 基于这些更改的不可变的 state。
state.value += 1;
},
decremented: (state) => {
state.value -= 1;
},
},
});

export const { incremented, decremented } = userSlice.actions;

export const selectCount = (state: RootState) => state.user.value;

export default userSlice.reducer;

组件使用

1
2
3
4
import { useAppSelector, useAppDispatch } from '@/store/hooks';

const count = useAppSelector((state) => state.user.value);
console.log('count: ', count);

react组件中的共享变量

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
// Feature.tsx
import React from react;

// 定义全局变量
let pageNumber = 1;

const Feature = () => {

const onLoadMore = () => {
setLoading(true);
setListData(
data.concat(
[...new Array(pageSize)].map(() => ({
createdAt: '',
uri: '',
loading: true,
})),
),
);
pageNumber++;

request.get(list, { pageNumber, pageSize }).then(res => {
// ...
})

return (
<div onClick={onLoadMore}>加载更多</div>
)
}
export default Feature;

当前文件中,我们在 Feature 函数前面定义了一个pageNumber变量,每次调用onLoadMorepageNumber++,这个逻辑在当前页面没有问题。
当我们离开这个组件,下次重新进入时,pageNumber的值不是预期的1,而是1+onLoadMore的调用次数。也就是说Feature函数前定义的变量,不会随Feature函数的销毁而销毁。

而且这个变量是被所有 Feature组件共享的。

参考: https://zh-hans.react.dev/learn/referencing-values-with-refs#fix-debouncing

Hooks-useLayoutEffect

useEffect 环节已经知道,useEffect 会在 DOM 渲染之后执行,也就是在下一轮事件循环中执行。
useLayoutEffect的回调函数,会在渲染之前执行,也就是会在 DOM 变更之后同步的触发重新渲染。

该 Demo 中我们点击页面中的数字,再在useLayoutEffect回调中变为随机数。但在页面上数字不会变为 0,而是直接变为新的随机数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React, { useLayoutEffect, useState, useEffect } from 'react';
import './styles.css';

export default () => {
const [count, setCount] = useState(0);

useLayoutEffect(() => {
if (count === 0) {
const randomNum = 10 + Math.random() * 200;

const now = performance.now();

while (performance.now() - now < 100) {
console.log(1);
}

setCount(randomNum);
}
}, [count]);

return <div onClick={() => setCount(0)}>{count}</div>;
};

如果把useLayoutEffect换成useEffect,页面中就会先出现 0,再变为一个随机数。

useLayoutEffect会在layout阶段同步执行回调。回调函数中,触发了状态更新setCount(randomNum),这会重新调度一个同步任务。
该任务会在commitRootflushSyncCallbakQeueu()函数中同步执行。

在线 demo 地址: https://codesandbox.io/s/pedantic-pare-zzqktq?file=/src/App.js

Hooks-useImperativeHandle+forwardRef

使用 ref 时,可用useImperativeHandle 自定义暴露给父组件的实例值,这比命令式使用 ref 更好。

语法

1
useImperativeHandle(ref, createHandle, [deps]);
  1. ref:需要被赋值的 ref 对象。
  2. createHandle:createHandle 函数的返回值作为 ref.current 的值。
  3. [deps]:依赖数组,依赖发生变化会重新执行 createHandle 函数。

因为函数式组件没有实例,所以无法使用ref获取到子组件实例。
这时候可以通过React.forwardRef包裹子组件,将ref传递下去,子组件通过第二个参数可接收到ref

父组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React, { useEffect, useState, useRef } from 'react';

const Parent = () => {
const whiteLinkRef = useRef(null);

const handleRemoveLink = () => {
console.log('whiteLinkRef.current: ', whiteLinkRef.current);
// 调用子组件暴露的方法
whiteLinkRef.current.handleSetChecked(false);
whiteLinkRef.current.handleSetHref('');
};

return <WhiteLink ref={whiteLinkRef} visible={showWhiteLink} />;
};
export default Parent;

子组件:
通过第二个参数接收ref

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React, { useEffect, useState, useImperativeHandle } from 'react';

const Child = (props, ref) => {
// 自定义暴露组件内部的方法
// 需要配合forwardRef使用
useImperativeHandle(ref, () => ({
handleSetChecked,
handleSetHref,
}));

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

export default React.forwardRef(Child);

Hooks-useRef

作用:

  1. 访问 DOM 节点/react 元素
    • useRef 替换 class 组件中的 createRef
    • 使用 forwardRef 传递 ref 引用
    • 使用 useImperativeHandle 自定义暴露实例值
  2. 保持变量引用

保持变量的引用

在下面这个例子中,如果我们只是想设置一个定时器,可以使用局部变量id,但是如果我们想要在其他地方清除这个定时器,useRef就够帮助我们保存定时器的引用。

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import React, { useState, useEffect, useRef } from 'react';
import './index.css';

const cardDataList = [
{
title: '杭州市通用5元券',
subTitle:
'杭味面馆非常好吃,太好吃了,相当不错,味道鲜美,特别划算,快快抢购,聚划算',
},
{
title: '杭州市10元券',
subTitle: '兰州拉面非常好吃',
},
];

const delay = (data) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(data);
}, 1000);
});
};

/** 这里是react实现方式**/
const CardReact = (props) => {
const { data } = props;
let [btnText, setBtnText] = useState(10);
let timer = useRef(null);

// 抢购
const handleBuy = () => {
delay('已抢购').then((res) => {
setBtnText(res);
});
};

// 开始倒计时
useEffect(() => {
timer.current = setInterval(() => {
setBtnText(--btnText);
}, 1000);
return () => {
timer.current && clearInterval(timer.current);
};
}, []);

// 满足条件后清除定时器
useEffect(() => {
if (btnText < 1) {
// If we just wanted to set an interval, we wouldn’t need the ref (id could be local to the effect),
// but it’s useful if we want to clear the interval from an event handler:
clearInterval(timer.current);
setBtnText('抢购');
}
}, [btnText]);

return (
<div className="card">
<div className="info">
<div className="title">{data.title}</div>
<div className="subTitle">{data.subTitle}</div>
</div>
<button className="btn" onClick={handleBuy}>
{btnText}
</button>
</div>
);
};

const CardList = (props) => {
return (
<>
{props.list.map((data) => (
<CardReact data={data} />
))}
</>
);
};

export default function a() {
return <CardList list={cardDataList} />;
}

ref 回调函数实参不更新问题

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
39
40
41
42
43
const CustomizeContext = createContext(defaultV);

export { CustomizeContext };

function Customize() {
const [sceneList, setSceneList] = useState<ILiveScene[]>([]);
const [activeLayerIndex, setActiveLayerIndex] = useState(-1);

const updateSceneList = (sceneList: ILiveScene[]) => {
setSceneList(cloneDeep(sceneList));
};

const updateActiveLayerIndex = (layerId: string | -1) => {
if (layerId === -1) {
setActiveLayerIndex(-1);
} else {
const layerIndex = sceneList[activeSceneIndex].layout.layers.findIndex(
(x) => x.id === layerId,
);

setActiveLayerIndex(layerIndex);
}
};

return (
<Container>
<CustomizeContext.Provider
value={{
liveId,
sceneList,
activeSceneIndex,
activeLayerIndex,
updateSceneList,
updateActiveSceneIndex,
updateActiveLayerIndex,
}}
>
<Scene />
</CustomizeContext.Provider>
</Container>
);
}
export default Customize;
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import {
useRef,
useState,
useEffect,
useContext,
useLayoutEffect,
} from 'react';
import { CustomizeContext } from '../../../index';

const Scene = ({ zIndex }: SceneProp) => {
const contextData = useContext(CustomizeContext);
const {
sceneList,
activeSceneIndex,
updateSceneList,
updateActiveLayerIndex,
activeLayerIndex,
} = contextData;

/**
* 保存drop节点,监听拖拽事件
* @param node drop节点
* @param layer 该节点对应的数据
*/
const handleDropRefCallback = (
node: any,
layer: ILiveScene['layout']['layers'][number],
) => {
const map = getMap();
if (node) {
map.set(layer.id, node);

// 按下非 active drop
node.addEventListener('mousedown', (e) => {
if (!activeDropStateRef.current.isDragging) {
activeDropStateRef.current.isResize = false;
activeDropStateRef.current.isDragging = true;
activeDropStateRef.current.startX = e.clientX;
activeDropStateRef.current.startY = e.clientY;
activeDropStateRef.current.node = node;
activeDropStateRef.current.id = layer.id;
activeDropStateRef.current.rect = layer.rect;

// node.style.zIndex = (zIndex + 1).toString();
updateActiveLayerIndex(layer.id);
}
});
} else {
map.delete(layer.id);
}
};

return (
<div className={styles.sceneContentWrap} style={sceneStyle}>
{sceneList[activeSceneIndex].layout.layers.map((layer, i) => {
let cp;
const { text, type, digitalHuman, image, video, z } = layer;

return (
<div
className={cx(styles.drop, {
[styles.active]: layer.id === activeDropStateRef.current.id,
})}
// key={layer.id}
key={i}
ref={(node) => handleDropRefCallback(node, layer, i)}
style={{
left: rect.x,
top: rect.y,
width: rect.width,
height: rect.height,
backgroundColor: type === 'text' ? text.backgroundColor : '',
zIndex: z,
}}
>
<img style={s} src={image.url} />;
</div>
);
})}
</div>
);
};
export default Scene;

有一个列表,为了保存列表项节点,我使用 ref 回调来处理。

有这么个场景,点击 layers 中的第一项并做删除操作,layers.splice(0, 1),然后更新 layers 引用

点击第二项时,我期望handleDropRefCallback 中的i为 0,因为 layer 已经更新了,但是结果为 1,也就是还是首次 i 的值

纳闷了好久,后面才发现是key属性key={layer.id}保持了该节点的稳定,该节点没有重新渲染,所以 ref 的回调函数还是旧的回调函数,i 的值还是第一项还未删除时的值。
解决这个问题,只需要将 key 改为key={i},此时列表项重新渲染,handleDropRefCallback 中的i就是期望值 0。

但是我们清楚,用下标作为 key 的值,如果列表发生更改(新增、删除)时,那么其他列表项的 key 值可能会发生变化,那么变化的部分就会造成没必要的重新渲染问题。所以我还是想用layer.id作为 key 值,通过 id,遍历 layers,查找当前项所在的下标位置。
结果却出乎意外,发现 Provied.value 对象中updateLayersFn函数中的 layers 还是旧值,难道 Provider 组件 value 对象中的函数引用了 layers,此时产生了闭包对象?

Hooks-useReducer

useReducesuseState的替代方案。

1
const [state, dispatch] = useReducer(reducer, initialArg, init);

state逻辑复杂且包含多个子值的时候,useReducer会比setState更适用。

可以使用dispatch发起一个动作,state可以获取到最新的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function reducer(state, action) {
switch (action.type) {
case 'setAssetsType':
return {
assetsType: action.payload.map((x) => ({
label: x.fundTypeName,
value: x.id,
})),
};
default:
throw new Error();
}
}

const Edit = (props) => {
const [state, dispatch] = useReducer(reducer, {});

// 获取资产类型
getAssetsType().then((res) =>
dispatch({ type: 'setAssetsType', payload: res }),
);
};

复杂的 redux 方案

/reducer/assets.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { createAction, handleActions } from 'redux-actions';

const SET_ASSETS_TYPE = 'SET_ASSETS_TYPE';

export const setAssetsType = createAction(SET_ASSETS_TYPE);

export default handleActions(
{
[SET_ASSETS_TYPE]: (state, action) => {
const { payload } = action;

return {
...state,
assetsType: payload,
};
},
},
{
assetsType: [],
},
);

reducer/index.js

1
2
3
4
5
6
import { combineReducers } from 'redux';
import assets from './assets';

export default combineReducers({
assets,
});

页面组件:每个 connect 的包装组件都会订阅(subscribe) store 的变化,并在变化时触发 render

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
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { setAssetsType } from 'ROOT/reducer/assets';

const mapState = ({ assets }) => ({
assetsType: assets.assetsType,
});
const mapActions = (dispatch) => ({
actions: bindActionCreators(
{
setAssetsType,
},
dispatch,
),
});

const List = ({ actions, assetsType }) => {
// 获取资产类型
getAssetsType().then((res) => {
const options = res.map((x) => ({ label: x.fundTypeName, value: x.id }));
actions.setAssetsType(options);
});
};

export default connect(mapState, mapActions)(List);

Hooks-useContext

useContetn接收一个 context 对象(React.createContext 的返回值),并返回该 context 的当前值。

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
import { createContext, useState, useMemo, useContext } from 'react';

const GlobalContext = createContext();

const App = () => {
const [a, updateA] = useState('');
const [b] = useState('');

const contextValue = useMemo(() => ({ a, b }), [a, b]);

return (
<GlobalContext.Provider value={contextValue}>
<ConsumeA />
<ConsumeB />
<input value={a} onChange={(e) => updateA(e.target.value)} />
</GlobalContext.Provider>
);
};

function ConsumeA() {
const { a } = useContext(GlobalContext);

return a;
}

function ConsumeB() {
const { b } = useContext(GlobalContext);

console.log('render b with: ', b);

return b;
}

export default App;

参考:
https://zhuanlan.zhihu.com/p/346616580
https://zh-hans.reactjs.org/docs/hooks-reference.html#usecontext
https://github.com/facebook/react/issues/15156

Hooks-useEffect

Effect hook 可以让你在函数组件中执行副作用操作。

在 React 中,由 state 的变化导致 UI 发生变化的过程是正常操作,其他操作行为:如数据请求、直接操作 DOM(改变 Document.title)等都是副作用操作。React 无法感知它的变化,所以被归类到 effect。

副作用操作时相对于操作 state 而言的。

每一次因为 state 的改变,都有一次对应副作用函数的执行时机。

基本使用

语法:

1
useEffect(() => {}, []);

Effect 的第一个参数为回调函数,该函数会在每次 DOM 渲染完成之后执行。在事件循环里,useEffect 在下一轮事件循环执行。
我们可以在 effect 中获取到最新的 count值,因为他在函数的作用域内。

第二个参数为一个数组,这是一个优化性能的可选项。

  1. 如果不传第二个参数,那么回调函数会在每一次渲染完成后执行
  2. 如果传入一个空数组[],那么回调函数会在组件挂载和卸载时执行
  3. 如果传入依赖项数组,如[count],那么仅会在依赖项改变时重新执行
1
2
3
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // 仅在 count 更改时更新

如何清除副作用

1
2
3
4
5
6
7
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.is = 2
  5. 组件渲染
  6. 组件渲染完成,clear1执行
  7. 副作用逻辑执行,返回另一个 clear 函数,命名为clear2
  8. 组件销毁,clear2执行

总结 clear 函数的执行,它的特征如下:

  • 每次副作用执行,都会返回一个新的 clear 函数
  • clear 函数会在下一次副作用逻辑执行之前执行(DOM 渲染完成之后)
  • 组件销毁时会执行一次

其他

渲染其实是一个比较模糊的概念。像div.style.left = '20px'这段代码,可以分为派发指令和 GUI 执行两个步骤。

1
2
3
4
5
div.style.left = '20px';

useLayoutEffect(() => {
div.style.left = '30px';
}, []);

像上面这个例子,useLayoutEffect 回调函数中的 div.style.left = '30px',会在 DOM 变更之后同步调用,在 GUI 线程执行之前执行,也就是在浏览器执行渲染之前执行。

Hooks-useEvent

useEvent解决了一个问题:如何同时保持函数引用不变与访问到最新状态

因为useCallback存在保持函数引用稳定时状态不更新的问题,所以引入了useEventhook。

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
import React, { useState, useCallback, useRef, useLayoutEffect } from 'react';
import { Button } from '../components';

export default function UseEvent() {
const [count4, setCount4] = useState(0);
const [count5, setCount5] = useState(0);

// 为了保持函数引用不变,依赖项为空
const stableClickFn = useCallback(() => {
// 访问到的总是初始值
setCount4(count4 + 1);
}, []);

// 使用useEvent,保持函数引用不变的同时状态实时更新
const stableClickFn2 = useEvent(() => {
setCount5(count5 + 1);
});

return (
<div>
<h1>useCallback + []</h1>
<Button onClickButton={stableClickFn}>stable button4</Button>

<div>count 只会更新一次</div>
<div>count4: {count4}</div>

<h1>useEvent</h1>
<Button onClickButton={stableClickFn2}>stable button4</Button>

<div>count 只会更新一次</div>
<div>count5: {count5}</div>
</div>
);
}

实现原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 模仿useEvent
function useEvent(handler) {
const handlerRef = useRef(null);

// In a real implementation, this would run before layout effects
useLayoutEffect(() => {
handlerRef.current = handler;
});

return useCallback((...args) => {
// In a real implementation, this would throw if called during render
const fn = handlerRef.current;
return fn(...args);
}, []);
}

理解一下:

  1. 为了返回一个稳定引用,那么最后返回的函数一定使用useCallback和依赖项空数组[]
  2. 又要在函数执行时访问到最新值,那么每次都要拿最新函数来执行,所以在 Hook 里使用 Ref 存储每次接收到的最新函数引用,在执行函数时,实际上执行的是最新的函数引用。

Hooks-useCallback和useMemo

记忆函数

在开发实践中,有一种优化手段叫做记忆函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function summation(target) {
let sum = 0;
for (let i = 1; i <= target; i++) {
sum += i;
}
console.log('sum: ', sum);
return sum;
}

summation(10);
summation(100);
summation(100);
summation(100);
summation(100);

这是一个求和函数,每次执行函数都会重新计算结果。当我们重复调用summation(100)时,内部的循环计算是不是有点冗余?因为传入相同的参数,必定得到相同的结果,因此如果传入参数一样,是不是可以不再重复计算直接使用上次的计算结果呢?

是的,利用闭包能够实现我们的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// memoFn.jsx
// 初始化一个非正常数字
let preTarget = -1;
let memoSum = 0;

export function memoSummation(target) {
// 传入参数与上次一样,直接返回缓存结果
if (prevTarget > 0 && prevTarget === target) {
return memoSum;
}

// 缓存本次传入的参数
prevTarget = target;
let sum = 0;
console.log('我出现,表示又重新计算了一次');
for (let i = 1; i <= target; i++) {
sum += i;
}
// 缓存本次计算结果
memoSum = sum;
return sum;
}
1
2
3
4
5
6
7
8
9
10
11
import { memoSummation } from './memoFn';

memoSummation(10);
memoSummation(50);
memoSummation(100);
memoSummation(50);
memoSummation(100);
memoSummation(100);
memoSummation(100);
memoSummation(100);
memoSummation(100);

'我出现,表示又重新计算了一次'只打印了 5 次。

memoFn.jsx模块用伪代码表示,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const memoFn = (function () {
let prevTarget = -1;
let memoSum = 0;
return function memoFn(target) {
// 传入参数与上次一样,直接返回缓存结果
if (prevTarget > 0 && prevTarget === target) {
return memoSum;
}

// 缓存本次传入的参数
prevTarget = target;
let sum = 0;
console.log('我出现,表示又重新计算了一次');
for (let i = 1; i <= target; i++) {
sum += i;
}
// 缓存本次计算结果
memoSum = sum;
return sum;
};
})();

const memoSummation = memoFn;

hooks 中的记忆函数
hooks 提供的 api 中,大多都有记忆功能,例如:

  • useState
  • useEffect
  • useCallback
  • useMemo
  • useLayoutEffect
  • useReducer
  • useRef

这里主要讲useCallbackuseMemo

useMemo

useMemo 缓存计算结果。它接收两个参数,第一个参数是回调函数(返回计算结果),第二个参数是依赖项(数组),当依赖项中某一项发生变化时,结果将会重新计算。
const memorizedValue = useMemo(() => {}, deps)

useCallback

useCallback 缓存函数。 他的使用跟useMemo几乎一样,当依赖项中的某一个发生变化时,返回一个新函数。
const memorizedFn = useCallback(() => {}, deps)

伪代码实现

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
/**
* useCallback 简易实现(不考虑多个useCallbacks的情况)
*/
let memoizedState = [];
let hookIndex = 0;
function useCallbacks(callback, nextDeps) {
// debugger
const prevState = memoizedState[hookIndex];

if (prevState) {
// 更新
let [prevCallback, prevDeps] = prevState;

// 对比依赖项
let same = nextDeps.every((item, index) => item === prevDeps[index]);

// 依赖项相同
if (same) {
console.log('返回旧函数');
return prevCallback;
}
}

// 首次挂载时 或者 更新时但依赖项不同,都返回新函数
memoizedState[hookIndex] = [callback, nextDeps];
console.log('返回新函数');
return callback;
}

export default useCallbacks;

useCallback 和 useMemo 的区别

  • useCallback 缓存的是callback函数本身,useMemo 缓存的是callback函数的计算结果,也可以是一个函数
  • 都是利用闭包缓存计算结果。只有在函数或计算的过程非常复杂时才考虑使用
  • useCallback/useMemo 可以用来优化子组件和当前组件。优化子组件时,可以防止子组件没必要的重复渲染,useCallback 需要配合 React.memo 一起使用,useMemo 不需要;优化当前组件时,useCallback 主要用于某个会多次 re-render 组件中的没有依赖项的函数,useMome 主要用于缓存复杂的计算逻辑。
  • useCallback(fn, deps) 相等于 useMemo(() => fn, deps)

是否需要优化

通过记忆函数的原理,我们应该知道,创建记忆函数并不是没有代价的,我们需要创建闭包,占用更多的内存,用以解决计算上的冗余

对于一个函数组件来说,我们在内部会创建许多函数,是否都有必要使用 useCallback 呢?思考下面代码

useCallback 是否有必要包裹 setCount 函数?

1
2
3
const memoAdd = useCallback(() => {
setCount(count++);
}, [count]);

存在依赖项时,如果 memoFn 不作为 props 传给子组件,仅做一个缓存函数的作用,这样是不是也有达到优化效果?

答案是没必要。创建函数的消耗很小,依赖项的对比反而有一定的性能开销。

当一个函数执行完毕,就会从函数调用栈被弹出,里面的内存也会被回收。对于函数组件来说也一样,当内部函数执行完毕后也会被释放掉。所以函数式组件的性能是非常快的。
而当我们使用 useCallback 时,由于新增了对闭包的使用,新增了依赖项的对比逻辑,如果盲目使用他们,可能会让组件变得更慢。大多数情况下你不需要使用 useCallback/useMemo。

那么,什么时候使用 useCallback 比较合适呢?

适用场景

函数或计算的过程非常复杂时,才优先考虑使用useCallback/useMemo

当函数(或子组件)非常复杂时

默认情况下,父组件的重新渲染会导致子组件也重新渲染。如果子组件的 props 未发生变化时,子组件就没有重新渲染的必要。

所以当函数非常复杂时,我们可以将 useCallbak 的返回值”缓存函数”传给子组件,然后配合子组件使用 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import React, { useState, useCallback, useMemo } from 'react';
import Button from './Button';

export default function UseCallback() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const [count3, setCount3] = useState(0);

// 未使用useCallback的情况下,handleClickButton1 函数引用每次都会变化
// 这会破坏子组件 memo 效果
const handleClickButton1 = () => {
setCount1(count1 + 1);
};

// 缓存函数:只有在依赖项发生改变时,才返回新的函数,否则返回缓存函数
// 子组件发现onClickButton属性没发生改变,就不会重新渲染,从而达到优化效果

// 使用useCallback,如果 count2 不变,handleClickButton2 引用不变
const handleClickButton2 = useCallback(() => {
setCount2(count2 + 1);
}, [count2]);

return (
<div>
<h1>未使用useCallback</h1>
<div>
<Button onClickButton={handleClickButton1}>Button1</Button>
</div>
<div>
<Button onClickButton={handleClickButton2}>Button2</Button>
<p>Button2 上的on函数使用了useCallback</p>
</div>
<div>
<Button
onClickButton={() => {
setCount3(count3 + 1);
}}
>
Button3
</Button>
</div>
</div>
);
}

子组件要配合 React.memo 一起使用。
Button 子组件,假设这个组件非常复杂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Button.jsx
import React from 'react';

// 假设这个组件非常复杂
const Button = ({ onClickButton, children, count }) => {
return (
<>
<button onClick={onClickButton}>{children}</button>
<span>{Math.random()}</span>
{count && <span>count: {count}</span>}
</>
);
};

// 注意,useCallback一定要配合React.memo一起使用,否则就是反向优化
export default React.memo(Button);

会多次re-render的组件中,且函数没有任何依赖时

如果某组件一定会多次re-render,且函数没有任何依赖,可以考虑使用 useCallback 降低多次执行带来的重复创建同样方法的负担。

1
2
3
4
5
<input type="text" onChange={inputChange} />;

const inputChange = useCallback((e) => {
setValue(e.target.value);
}, []);

同样的场景,如果函数只会渲染一次,那么使用 useCallback 就完全没必要。

闭包带来的问题

通过前面的例子可以知道,handleClickButton2缓存函数仅在 count不变时保持稳定。
如果想要保持handleClickButton2引用一直稳定,要把依赖项移除,用空数组作为参数,这会导致访问到的count总是初始值,逻辑上引发了更大的问题,也就是闭包问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React, { useState, useCallback, useMemo } from 'react';
import Button from './Button';

export default function UseCallback() {
const [count4, setCount4] = useState(0);

// 缓存函数引用保持稳定时状态不更新问题
const stableClickFn = useCallback(() => {
console.log(count4);
// 访问到的总是初始值
setCount4(count4 + 1);
}, []);

return (
<div>
<h1>缓存函数引用保持稳定时状态不更新问题</h1>
<Button onClickButton={stableClickFn}>stable button4</Button>
<div>count 只会更新一次</div>
<div>count4: {count4}</div>
</div>
);
}

想要解决这个问题,可以看hooks 闭包陷阱中的解决办法。

react 团队为了解决这个问题,准备引入useEvent,它具备以下属性:

  • 每次重新渲染时都不会重新创建该函数
  • 该函数将可以访问 props/state 的最新值

总结

useCallback/useMemo的作用在于利用记忆函数减少无效的re-render,来达到性能优化的作用。记忆函数的原理,是创建闭包,但创建闭包是有代价的,会占用更多的内存,用以解决计算上的冗余。所以我们需要根据场景来权衡是否有必要使用useCallback/useMemo

参考:
https://zhuanlan.zhihu.com/p/56975681
https://mp.weixin.qq.com/s/MnkycB8D9kCRrsXaLBfgXg

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×