关于React Redux 中useSelector依赖收集的问题

起因

最近组内大哥在查看项目内代码时,发现对于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
}

鄙人能力有限,只能用笨一点的方法,手动去收集每个组件的依赖,稍微减少一些重复代码的工作量,没能实现全自动化收集,如果有大神有更好的方法的话,欢迎大家在评论区贴出来。

相关推荐
掘了11 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅11 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅12 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅12 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment12 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅13 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊13 小时前
jwt介绍
前端
爱敲代码的小鱼13 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte13 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc
NEXT0613 小时前
前端算法:从 O(n²) 到 O(n),列表转树的极致优化
前端·数据结构·算法