关于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
}

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

相关推荐
逐·風2 小时前
unity关于自定义渲染、内存管理、性能调优、复杂物理模拟、并行计算以及插件开发
前端·unity·c#
Devil枫3 小时前
Vue 3 单元测试与E2E测试
前端·vue.js·单元测试
尚梦4 小时前
uni-app 封装刘海状态栏(适用小程序, h5, 头条小程序)
前端·小程序·uni-app
GIS程序媛—椰子4 小时前
【Vue 全家桶】6、vue-router 路由(更新中)
前端·vue.js
前端青山4 小时前
Node.js-增强 API 安全性和性能优化
开发语言·前端·javascript·性能优化·前端框架·node.js
毕业设计制作和分享5 小时前
ssm《数据库系统原理》课程平台的设计与实现+vue
前端·数据库·vue.js·oracle·mybatis
清灵xmf7 小时前
在 Vue 中实现与优化轮询技术
前端·javascript·vue·轮询
大佩梨7 小时前
VUE+Vite之环境文件配置及使用环境变量
前端
GDAL7 小时前
npm入门教程1:npm简介
前端·npm·node.js
小白白一枚1118 小时前
css实现div被图片撑开
前端·css