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

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

相关推荐
余生H4 分钟前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈
程序员-珍7 分钟前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
axihaihai11 分钟前
网站开发的发展(后端路由/前后端分离/前端路由)
前端
流烟默23 分钟前
Vue中watch监听属性的一些应用总结
前端·javascript·vue.js·watch
2401_8572979133 分钟前
招联金融2025校招内推
java·前端·算法·金融·求职招聘
茶卡盐佑星_44 分钟前
meta标签作用/SEO优化
前端·javascript·html
Ink1 小时前
从底层看 path.resolve 实现
前端·node.js
金灰1 小时前
HTML5--裸体回顾
java·开发语言·前端·javascript·html·html5
茶卡盐佑星_1 小时前
说说你对es6中promise的理解?
前端·ecmascript·es6
Promise5201 小时前
总结汇总小工具
前端·javascript