起因
最近组内大哥在查看项目内代码时,发现对于react-redux 的使用,部分同事用法不太对
js
export const testSlice = createSlice({
name: 'testSlice',
initialState,
reducers
});
export const useTest = () =>
const test = useSelector((state) => state.test);
//......do something
return {
test: {
...test,
isVisible: test.sbList.some(coder => coder.age > 35)
}.
actions: bindActionCreators(testSlice.actions, useDispatch())
}
}
上面的代码,创建了一个slice,并且通过useTest这个hooks将test数据和修改数据的actions暴露出去。这样看起来没什么问题,使用起来也很方便,只要在组件里调用 useTest,然后解构赋值读取返回值就可以了。
但是, useTest 返回的 test 这个数据对象,首先来说,每次调用都会返回一个新的对象,而不是原汁原味的 test store的数据;第二,这里返回的 useSelector().test 对象,变成了一个普通的对象,而不是useSelector() 返回的,react-redux 处理过的依赖收集和push页面更新的数据对象,再加上 isVisible 这种派生的数据,很容易导致一些问题。
所以组里的大哥要求我们不再使用hooks的形式去获取数据和actions,而是直接在组件里使用 useSelector获取,并进行派生计算数据。
发现问题
在对代码修改的过程中,我不禁想到一个问题,那就是组件内使用 useSelector((state) => state.test) 函数返回的states对象,在每个使用带的组件内,可能只会读取一两个state,但是这里是返回了 test整个states对象,那么页面中通过action修改当前页面未使用的state时,会引起组件的重新调用吗?
抱着试一试的心态,我在云内重新创建了一个项目:
js
export const testSlice = createSlice({
name: 'test',
initialState: {
value: 0,
name: '35大限'
},
reducers: {
increment: (state) => {
state.value += 1;
},
changeName: (state, action) => {
state.name = action.payload;
}
},
});
export const Components = () => {
const { value } = useSelector((state: RootState) => state.test);
const { increment, changeName } = bindActionCreators(testSlice.actions, useDispatch());
console.log('rerender')
return (
<div>
<button
onClick={() => increment()}
>
value is {value}
</button>
<button
onClick={() => changeName(Math.floor(Math.random() * 100) + '')}
>
change name
</button>
</div>
);
}
分析问题
上面的组件中,state数据只使用了 value, 但是会在点击第二个按钮时,修改test store中 name的值,此时查看控制台,会发现每次修改都会输出 'rerender',问了下组内的老大哥,才知道redux 会对useSelector callback中返回的数据进行监听,也就是上面返回的 state.test,而我们都知道,虽然在@redux/toolkit 中,我们可以只修改对应的某个state的值,但是实际上这只是toolkit 做好的封装处理,内部的实现其实还是遵循 immutable 原则, 不会直接修改原来的states对象里面的值,而是将原来的states 重新赋值。所以上面的changeName reducer,在底层的实现中可以理解为:
js
function testSliceReducer (state, action) {
switch (action.type) {
case 'changeName':
state = {
...state,
name: action.payload
}
break;
......
}
}
因此每次只要修改test store中的数据,useSelector((state) => state.test) 返回的都是一个全新的对象,所以就会引起函数组件的重新调用。 虽然当前页面没有使用name 这个state,组件调用之后,diff前后组件树发现没有变化,也就不会真正重新渲染,实际造成的功能损耗没有一开始想像的那么严重。但是这种不该发生的重新调用,最好还是控制一下,避免在复杂项目中导致一些不可预期的后果。
解决思路
那么有什么方法能够解决这个问题呢?最简单也是使用频率最低的,就是当前组件如果只使用了store中的一个state的话,那么我们可以这么写:
const value = useSelector((state: RootState) => state.test.value);
这里useSelector 返回的值只有state.test.value,那么当前组件也就只会对 value 有反应,其他state修改时不会重新调用函数组件。
但是,实际开发中大家都知道,一个稍微大一些组件很少会只读取一个state的,那么在需要读取多个state时应该怎么处理呢?其实redux库的作者也帮我们想到了这个问题,所以库里暴露出一个函数 shallowEqual
,我们可以把他作为useSelector的第二个参数,配合以下用法:
js
import { useSelector, shallowEqual } from 'react-redux';
const { value, otherValue } = useSelector((state: RootState) => ({
value: state.test.value,
otherValue: state.test.otherValue
}), shallowEqual);
这样的话,当前组件就只会对 value 和 otherValue 这两个用到的state 进行监听,其他state的变化不会导致组件重新调用。
有同学可能发现了一个问题,那就是上面 useSelector函数的 selector 函数返回值,不再是一个单纯的store或者state的直接返回,而是自己解构赋值,并重新创建了一个包含当前组件使用到的state的数据对象,那么为什么要这么做呢?直接返回state.test不可以吗?毕竟已经有了 shallowEqual 函数了,这里我们就需要看一下shallowEqual 这个函数的内部实现了:
js
function is(x, y) {
if (x === y) {
return x !== 0 || y !== 0 || 1 / x === 1 / y;
} else {
return x !== x && y !== y;
}
}
function shallowEqual(objA, objB) {
if (is(objA, objB))
return true;
if (typeof objA !== "object" || objA === null || typeof objB !== "object" || objB === null) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length)
return false;
for (let i = 0; i < keysA.length; i++) {
if (!Object.prototype.hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) {
return false;
}
}
return true;
}
浏览上面的代码,我们可以发现 shallowEqual 函数,其实对就是selector函数的返回值,在action变化前后,进行了一次浅比较,它其实是不会关注你当前页面使用到哪个state的,也不能关注到,所以我们在selector函数中直接返回 test 这个store的全部state的话,shallowEqual 函数也就没有意义了,那样每次action修改其中的每一个state,都会让当前组件重新调用。
有的同学看到这里可能会吐槽,难道我每次使用大量的state的时候,都要先在selector函数里解构赋值,然后组成新的对象,再进行返回吗?这样做好麻烦啊?就我目前了解到的redux提供的能力来看,好像是这样的,但是程序员都比较懒,不太想一直写这种繁琐的重复代码,所以我们可以把库里的 shallowEqual 函数改写一下,让改写后的函数可以收集当前组件的依赖
下面是我的函数改写实现:
js
import { shallowEqual } from 'react-redux';
type ShallowEqualParams = Parameters<typeof shallowEqual>;
/**
* @description: 手动收集依赖,获取可选比较的(而不是全量比较)的shallowEqual函数
* @param keys?: string[] 可选地当前组件依赖的state的key
* @return shallowEqual函数,正常放在useSelector函数的第二个参数位置即可
*/
export const getShallowEqual = (keys?: string[]) => {
if (keys) {
return (objA: ShallowEqualParams[0], objB: ShallowEqualParams[1]) => keys.every((key) => is(objA[key], objB[key]));
}
return (objA: ShallowEqualParams[0], objB: ShallowEqualParams[1]) => shallowEqual(objA, objB);
};
function is(x: unknown, y: unknown) {
if (x === y) {
return x !== 0 || y !== 0 || 1 / x === 1 / y;
} else {
return x !== x && y !== y;
}
}
以上函数在组件中可以这么使用:
js
const shallowEqual = getShallowEqual(['value', 'otherValue']);
export const Components = () => {
const { value } = yuseSelector((state: RootState) => state.test, shallowEqual);
//......do something
}
鄙人能力有限,只能用笨一点的方法,手动去收集每个组件的依赖,稍微减少一些重复代码的工作量,没能实现全自动化收集,如果有大神有更好的方法的话,欢迎大家在评论区贴出来。