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

Hooks-useState

与 UI 更新相关的状态才使用 state 。如果只想要一个数据持久化,请使用 useRef

使用

1
const [state, setState] = useState(initialState);

如果初始的 initialState 需要通过复杂计算获得,则可以传入一个函数。

1
2
3
4
const [state, setState] = useState(() => {
const initialState = someExpensiveComputation(props);
return initialState;
});

伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function SM() {
let state = null;
return function myUseState(value) {
state = state || value; // 第一次调用没有初始值,因此使用传入的初始值赋值

function dispatch(newValue) {
state = newValue;
// 假设此方法能触发页面渲染
// render()
}

return [state, dispatch];
};
}

异步还是同步

useState 返回的 setCount 和 class 组件的 this.setState 一样,在事件处理函数内部执行的是异步的,在执行之后并不能获取到最新的值。

怎么获取最新的值

如果新的 state 需要通过先前的 state 计算得出,可以将函数传给 setState。该函数接收先前的 state,并返回一个更新后的值。

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

useEffect(() => {
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
// 注意,这里不能获取到count最新值
}, []);

如果需要使用最新的 state,可以在 useEffect 里获取到
当触发更新的函数(即setCount)接收一个函数时,会创建对应的update。这里调用了 3 次就会有 3 个update。所以count为 3。

为什么返回值是个数组

数组解构时,变量可以命名为任意名称,取值由它的位置决定。

1
2
const [state, setState] = useState(null);
const [state2, setState2] = useState(null);

对象解构时,变量必须与属性同名才能取到正确的值。

1
2
const { state, setState } = useState(null);
const { state: state2, setState: setState2 } = useState(null);

如果使用返回的是对象,当代码块中多次使用 useState 时,对象属性得重命名才不会冲突。相比之下数组代码显示的简洁。

但数组也有不足之处:

  • 返回值必须按顺序取
  • 返回参数较多时,又不是所有返回值都需要的情况下,写法会比较奇怪。例如只使用 setState: const [, setState] = useState(null)

避免不必要的 useState

案例来自波神的文章: https://mp.weixin.qq.com/s/nR9zczAZ5WZb1oG7w5cJDA
截取文章的其中一段:

Hooks闭包陷阱

闭包陷阱

以下两段代码,来自 https://github.com/hacker0limbo/my-blog/issues/6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function createIncrement(i) {
let value = 0;
function increment() {
value += i;
console.log(value);
const message = `Current value is ${value}`;
return function logValue() {
console.log('value: ', value);
console.log(message);
};
}

return increment;
}

const inc = createIncrement(1);
const log = inc(); // 1
inc(); // 2
inc(); // 3

log(); // "Current value is 1"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function createIncrementFixed(i) {
let value = 0;
function increment() {
value += i;
console.log(value);
return function logValue() {
const message = `Current value is ${value}`;
console.log(message);
};
}

return increment;
}

const inc = createIncrementFixed(1);
const log = inc(); // 1
inc(); // 2
inc(); // 3

log(); // "Current value is 3"

截取文章中的一段话,如下:



对于这句话,我再做下解释:
在 value 变量修改值之后,message 变量保存的值仍旧是原始的值。这是因为基础数据类型的值是不可变的。

基础数据类型的值是不可变的

让我们举例说明,看下这段代码,思考 a 的值为多少?

1
2
3
4
5
6
let a = 1;
let b = a;
b++;

console.log(a);
console.log(b);

b++,b 的值被改变了,但是 a 没被改变,依然为 1。这意味着 a b 的等价,并不表示他们是同一个值。也就是说,a 赋予 b 的时候,重新给 b 分配了一块内存空间。因此我们说,基础数据类型,是按值访问的。 用图表示

基础数据类型的比较,是值在比较

1
2
3
const a = 1;
const b = 1;
a === b; // true

当 a 和 b 在比较的时候,本质上是值在比较。所以我们说基础数据类型的值是不可变的。

出现闭包陷阱的原因

为第一段代码第 6 行打上断点

  1. 开始执行createIncrement函数,先定义基础数据类型 value并赋值为 0,然后返回increment函数并赋值给inc
  2. inc 执行,进入increment函数,执行到第 8 行,value+=i,变量value的值变成了 1,同时产生了闭包对象Closure(createIncrement),该闭包对象存在increment作用域当中。

  1. 接着执行到第 10 行,声明了message变量,并将基础数据类型 value的值赋给message,此时value为 1,所以message = "Current value is 1",然后返回logValue函数,increment函数执行完毕,并赋值给log变量。因为闭包对象Closure(createIncrement)的产生,log变量将会引用着increment函数第一次执行时创建的作用域。如图所示,作用域里的 Local 对象「活动对象」中的message = "Current value is 1"

  1. 解释第 25 行,执行logValue函数,寻找message变量。 先查找 Local 活动对象,没找到,发现作用域链中的闭包对象Closure(increment)中存在message,且值为Current value is 1,打印messagelogValue函数执行完毕。
1
2
Local 活动对象:仅仅只有处于栈顶的执行上下文,才会生成 Local 对象。
除了 Local 活动对象,作用域链中的其他几种变量对象(Closure/Module/Global)都能够在函数解析时确定

虽然Closure(createIncrement)中的value已经变成了 3,但是并不影响基础数据类型 message 变量中的 value 值。
那是不是只要将引用类型赋值给 message 变量,就可以解决闭包陷阱问题?

解决办法

让我们稍微改下代码,将value改为引用类型,再赋值给 message

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
// 闭包陷阱
// 参考: https://github.com/hacker0limbo/my-blog/issues/6
const Trap = (props) => {
// debugger
function createIncrement(i) {
let value = {
a: 0,
};
function increment() {
value.a += i;
const message = value;

return function logValue() {
console.log('value: ', value);
console.log('message: ', message);
};
}

return increment;
}

const inc = createIncrement(1);
const log = inc(); // 1
inc(); // 2
inc(); // 3

log(); // "Current value is 3"

return (
<>
<div>闭包陷阱</div>
</>
);
};

export default Trap;

第一次执行increment函数时,value.a += 1,所以value = { a: 1 }message = { a: 1 }increment函数执行完毕,并赋值给log变量。

后面执行logValue时,虽然createIncrement 函数第一次执行时创建的作用域中的message = { a: 1 },但是message的引用指向着value,所以当value = { a: 3 }时,message也等于{ a: 3 }。所以将引用类型赋值给message确实能解决闭包陷阱问题。

小结:

  1. log变量引用着increment函数第一次执行时创建的作用域,此时message = "Current value is 1"
  2. 执行三次increment函数后,value变成了 3,然后执行logValue函数而message中的value依旧为 1 的原因是value变量是个基础数据类型。
  3. 想要拿到value变量的最新值,我们只需要把value变量从基础数据类型改为引用数据类,并让message指向value变量即可。

思考以下三种不同赋值方式message的值:

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
function createIncrement(i) {
let value = {
a: 0,
};
function increment() {
value.a += i;
const message = `Current value is ${value.a}`;

return function logValue() {
console.log('value: ', value); // { a: 3 }
console.log('message: ', message); // Current value is 1
};
}

return increment;
}

function createIncrement(i) {
let value = {
a: 0,
};
function increment() {
value.a += i;
const message = {
a: value.a,
};

return function logValue() {
console.log('value: ', value); // { a: 3 }
console.log('message: ', message); // { a: 1 }
};
}

return increment;
}

function createIncrement(i) {
let value = {
a: 0,
};
function increment() {
value.a += i;
const message = {
a: value,
};

return function logValue() {
console.log('value: ', value); // { a: 3 }
console.log('message: ', message); // { a: { a: 3 } }
};
}

return increment;
}

hooks 出现的闭包陷阱,其实跟上面的问题是一样的。

Hooks 中的闭包陷阱

1
useEffect(fn, [deps]);

在 react hook 中有个经典的闭包陷阱

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

// hooks 中的闭包陷阱
const HookClosureTrap = () => {
const [count, setCount] = useState(0);

useEffect(() => {
// 获取到的count永远是0,这是因为闭包陷阱导致的问题
// 当依赖项为空数组时,fn只会执行一次,useEffect永远引用着HookClosureTrap第一次执行时创建的作用域。第一次创建是count为0
setInterval(() => {
setCount(count + 1);
}, 1000);
}, []);

return (
<>
<h1>hooks 中的闭包陷阱</h1>
<div>count: {count}</div>
</>
);
};

export default HookClosureTrap;

在这个例子中,我们给useEffect第二个参数deps传了个空数组,并使用setInterval希望隔秒count+1,然而实际效果是首次渲染时页面中count0,然后过了 1 秒页面中的count更新为1,之后一直保持为1不变,这就是useEffect的引起的闭包问题。
因为当依赖项为空数组时,fn(useEffect 的第一个参数)只会执行一次,而 useEffect 永远引用着 HookClosureTrap 第一次执行时创建的作用域。第一次创建时 count 为 0。

解决办法

添加依赖项

添加 count 依赖项,依赖项改变时 fn 重新执行,因为 setCount 会触发重新渲染,那么我们就能获取到 HookClosureTrap 最新的作用域,也就能获取到最新的 count

1
2
3
4
5
6
// 解决办法1:添加依赖项
useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 1000);
}, [count]);

虽然获取到了最新值,但是 count 显示结果却很混乱。
这是因为每次执行 useEffect 时就会创建一个定时器timer,下次执行时,也会执行上次创建的定时器。
所以我们需要在 useEffect 中返回一个回调函数,清除本次创建的定时器。

1
2
3
4
5
6
7
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);

return () => timer && clearInterval(timer);
}, [count]);

setState 函数式更新

setState 可以接收一个函数,函数的参数是上一次计算后该状态的值,可以理解为最新值

1
2
3
4
5
6
7
8
9
10
11
12
// 解决办法2:使用setState函数式更新

useEffect(() => {
const timer = setInterval(() => {
// 这里打印的count,依旧永远是0
console.log('count: ', count);

setCount((prev) => prev + 1);
}, 1000);

return () => timer && clearInterval(timer);
}, []);

useRef

1
const refContainer = useRef(initialValue);

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内持续存在。

也就是说,使用 useRef, 可以保持变量的引用不变。
这简直跟解决闭包陷阱的办法一模一样,就是把value变量改为了引用类型。

我们修改 ref 的值后,setState 触发更新,重新渲染时,拿到的虽然是 HookClosureTrap 第一次执行时创建的作用域,ref 引用地址不变,但是它的值已经被改变了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 解决办法3:使用useRef
const countRef = useRef(0);

useEffect(() => {
const timer = setInterval(() => {
setCount(++countRef.current);

// 还有一种不常见的写法
// 对count使用前置自增运算符,也能得到正确的效果
// setCount(++count)
}, 1000);

return () => timer && clearInterval(timer);
}, []);

useReducer

useReducer 同 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
import { useEffect, useRef, useReducer } from 'react';

function countReducer(state, { type, payload }) {
switch (type) {
case 'add':
return {
count: state.count + payload,
};
default:
throw new Error();
}
}

// hooks 中的闭包陷阱
const HookClosureTrap = () => {
const [state, dispatch] = useReducer(countReducer, { count: 0 });

// 解决办法4: 使用useReducer
// useReducer 同 useRef 类似,使用引用数据类型解决这个问题
useEffect(() => {
setInterval(() => {
dispatch({ type: 'add', payload: 1 });
}, 1000);
}, []);

return (
<>
<h1>hooks 中的闭包陷阱</h1>

<div>state.count: {state.count}</div>
</>
);
};

export default HookClosureTrap;

除了useEffect,像useMemouseCallback都有 deps 参数,在这些 hook 里用到某个 state 时,如果 deps 为空数组,虽然 state 变了,但是却没有执行新传入的函数,依旧引用的之前的 state。

总结

createIncrement这段代码中,出现闭包陷阱的原因是,第一次执行increment后,该作用域被log变量保存(因为产生了闭包(Closure(createIncrement))所以能被保存),作用域中的Local对象中message = "Current value is 1"message是基础数据类型,所以后续打印message的值仍为第一次执行increment的值。如果希望message中的value为最新值,将value变量改为引用数据类型,并让message指向value变量即可。
react hooks 出现闭包陷阱的原因也是如此,采用添加依赖项、使用 setState 函数式更新、useRef、useReducer 都可以解决这个问题。

参考:
http://www.ferecord.com/react-hooks-closure-traps-problem.html
https://www.jianshu.com/p/6a512f78536a
https://cloud.tencent.com/developer/article/2016207
https://juejin.cn/post/6844904193044512782

Your browser is out-of-date!

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

×