proxy-memoize代替reselect

1,686 阅读5分钟

引言

在像 React 这样的前端框架中,对象不变性非常重要。但其实它本身并不支持强制不变性。那这个库利用了 ProxyWeakMap,并提供了记忆功能。仅当参数(对象)的使用部分发生变化时,记忆函数才会重新计算原始函数。

通过引言我们已经知道了它的优点,那么你可能会好奇他是如何实现的,那么你可以看看下面这个介绍,如果你只关心它是如何使用你也可以跳过这一小节:

如何工作

当它(重新)计算一个函数时,它将用代理(递归地,根据需要)包装一个输入对象并调用该函数。当它完成时,它将检查什么是受影响的。这个受影响其实是在函数调用期间访问的输入对象的路径列表

当它下一次接收到一个新的输入对象时,它将检查受影响路径中的值是否被更改。如果是被更改,那么它将重新计算函数。否则,它将返回一个缓存结果。默认缓存大小为1,可配置。

一个个说吧,首先要包装成对象:显然这里需要注意:一个要被记忆的函数必须是一个只接受一个对象作为参数的函数。

//要为对象
const fn = (x) => ({ foo: x.foo });
const memoizedFn = memoize(fn);
//不支持
const unsupportedFn1 = (number) => number * 2;
const unsupportedFn2 = (obj1, obj2) => [obj1.foo, obj2.foo];

再来说它是如何检查受影响的。 下面这个例子是一个实例不是解释哈,我们先理解表层,再来更深一层的理解如何实现:

const fn = (obj) => obj.arr.map((x) => x.num);
const memoizedFn = memoize(fn);

const result1 = memoizedFn({
  arr: [
    { num: 1, text: 'hello' },
    { num: 2, text: 'world' },
  ],
})

// 受影响的是 "arr[0].num", "arr[1].num" and "arr.length"

const result2 = memoizedFn({
  arr: [
    { num: 1, text: 'hello' },
    { num: 2, text: 'proxy' },
  ],
  extraProp: [1, 2, 3],
})

// 受影响的对象num的值并没有改变,于是:
console.log('result1 === result2 =>',result1 === result2) //true

这个神奇的效果是如何实现的呢?

你可以通过proxy-memoize了解到其中使用跟踪和影响的比较是通过内部库proxy-compare完成的。

简单介绍一下 proxy-compare : 这是一个从 react-tracked 中提取的库,只提供与代理的比较特性。(实际上,react-tracked v2将使用这个库作为依赖项。)

该库导出了两个主要功能: createDeepProxy 和 isDeepChanged

工作原理:

const state = { a: 1, b: 2 };
const affected = new WeakMap();
const proxy = createDeepProxy(state, affected);
proxy.a // touch a property
isDeepChanged(state, { a: 1, b: 22 }, affected) // is false
isDeepChanged(state, { a: 11, b: 2 }, affected) // is true

状态可以是嵌套对象,只有当触及某个属性时,才会创建新的代理。当然如果你想深究createDeepProxy和isDeepChanged是如何实现的,你可以去看proxy-compare源码,我这里就不过多介绍了。

接下来介绍它配合React Context和React Redux这两个主要场景的使用,我这里放的是自己写的例子,当然你也可以看官网给出的例子都行。

Usage with React Context

如果将proxy-memoizeuseMemo 一起使用,我们将能够获得类似 react-tracked 的好处。

官方实例Sandbox:codesandbox.io/s/proxy-mem…

import memoize from 'proxy-memoize';

const MyContext = createContext();

const Component = () => {
  const [state, dispatch] = useContext(MyContext);
  const render = useMemo(() => memoize(({ firstName, lastName }) => (
    <div>
      First Name: {firstName}
      <input
        value={firstName}
        onChange={(event) => {
          dispatch({ type: 'setFirstName', firstName: event.target.value });
        }}
      (Last Name: {lastName})
      />
    </div>
  )), [dispatch]);
  return render(state);
};

const App = ({ children }) => (
  <MyContext.Provider value={useReducer(reducer, initialState)}>
    {children}
  </MyContext.Provider>
);

当上下文发生变化时,组件将re-render。怎样才不会每次re-render呢,在这个例子中我们可以发现除非 firstName 没有改变,否则它返回memoized的react 元素树,re-render 将不会发生。这种行为不同于react-tracked,但还是有优化的。

Usage with React Context 实际上使用可能没有那么广泛,但是如果你们项目中有使用了许多 ReactContext 确实是可以用这个来优化。

接下来要说的我觉得是最广泛的应用场景(当然我是说的大部分项目)

Usage with React Redux

Instead of reselect.

他两都是解决这个问题的:可以创建可记忆的(Memoized)、可组合的 selector 函数、可以用来高效地计算 Redux store 里的衍生数据。

如果你没用过proxy-memoize,你大概率是使用的reselect来编写选择器 selector 函数 ,这里我们来对比两个库,我这里举一个简单的例子,但是往往state结构是没有这么简单的,这里只是个演示。

其实在对比中你就可以知道memoize如何使用以及他的优化好处了。

为啥说代替reselect

相信看了下面的例子你能明白:

const fn = memoize((x:State) => ({ sum: x.a + x.b, diff: x.a - x.b }));

const fn1 = createSelector(
    [(state:State)=>state],
    (state) => {
        return {
            sum :state.a+state.b,
            diff:state.a-state.b
        }
    }
)

console.log("fn=>",(fn({ a: 1, b: 2 })))//{sum: 3, diff: -1}
console.log("fn =>",(fn({ a: 1, b: 2 ,c:3}) === fn({ a: 1, b: 2 ,c:1})))//true
console.log("fn1=>",(fn1({ a: 1, b: 2}) === fn1({ a: 1, b: 2})))//false

当然我发现如果扩展成这样也是可以的(偶然的发现,可能确实是因为这个state太简单了吧),但是写起来就更复杂(尤其是层级深需要的值多的时候,并且当需要的是数组中属性值时,这就实现不了)

    const selectA = (state:State)=>state.a
    const selectB = (state:State)=>state.b
    const selectSub = createSelector(
        selectA,
        selectB,
        (a,b) => {
            return {
                sum :a+b,
                diff:a-b
            }
        }
    )
    console.log("fn1=>",(fn1({ a: 1, b: 2}) === fn1({ a: 1, b: 2})))//true

那么久来个稍微复杂一点的例子吧

import { useDispatch, useSelector } from 'react-redux';
import memoize from 'proxy-memoize';

const Component = ({ id }) => {
  const dispatch = useDispatch();
  const selector = useMemo(() => memoize((state) => ({
    firstName: state.users[id].firstName,
    lastName: state.users[id].lastName,
  })), [id]);
  const { firstName, lastName } = useSelector(selector);
  return (
    <div>
      First Name: {firstName}
      <input
        value={firstName}
        onChange={(event) => {
          dispatch({ type: 'setFirstName', firstName: event.target.value });
        }}
      />
      (Last Name: {lastName})
    </div>
  );
};

同理我们也来对比一下:

/**
* 对比
*/
const fn = memoize((state:State) => state.users.map((user) => user.firstName))
const fn1 = createSelector(
[(state:State)=>state.users],
(users) => {
    return users.map((user)=>user.firstName)
})
console.log("fn =>",fn({count:1 ,text: '', users: [{firstName:"hh",lastName:"ll"}]}) === fn({count:1 ,text: '', users: [{firstName:"hh",lastName:"lllll"}]}))//true
console.log("fn1 =>",fn1({count:1 ,text: '1', users: [{firstName:"hh",lastName:"ll"}]}) === fn({count:1 ,text: '', users: [{firstName:"hh",lastName:"ll"}]}))//false
  

可以发现,我们要取的值是在一个数组里,并且我们只要数组里的firstName这个属性,按reselect来的话我们要先拿到数组再去遍历拿到里面的值,所以检测变化就是检测这个数组变化咯。这时你就能发现memoize的简洁和优化

memoize((state) => state.users.map((user) => user.firstName))

它不会每次都创建,只有在用户长度更改或 firstName 中的一个更改时,才会重新计算这个值。

总结

这个其实是我工作中调研的一个库,这个知识无偿分享给大家,也不知道大家喜不喜欢这种硬核一点的知识分享哈,那如果你觉得写的还不错的话,点个赞再走吧💖