Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hooks 时代,如何优雅地更新你的复杂状态数据 #51

Open
campcc opened this issue Jun 25, 2023 · 0 comments
Open

Hooks 时代,如何优雅地更新你的复杂状态数据 #51

campcc opened this issue Jun 25, 2023 · 0 comments

Comments

@campcc
Copy link
Owner

campcc commented Jun 25, 2023

大家好,我是 Monch,今天想跟大家分享的是,如何在 React Hooks 中更优雅地更新复杂的状态数据,这里的复杂状态可能是,

  • Objects,包含多个属性值的 Object 对象
  • Nested Object,嵌套的 Object 对象

相信大家在日常开发中都会遇到如下的场景,比如一个分页器对象可能由以下几部分组成,

  • current,当前页
  • pageSize,页的大小
  • total,数据的总数
  • ...
type Pagination = {
  current: number;
  pageSize: number;
  total: number;
};

定义分页器的状态时,更好的做法是将上面的属性值合并为一个 pagination 对象,而不是分别定义,

// 分别定义 current, pageSize, total 等状态,不推荐 👎
const [current, setCurrent] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [total, setTotal] = useState<number>(0);

// 合并为一个 pagination 状态,推荐 👍
const [pagination, setPagination] = useState<Pagination>({ current: 1, pageSize: 10, total: 0 });

这样的状态定义带来的一个弊端是,如果我们只需要更新其中的部分属性值,为了保留状态对象的其他属性,我们需要浅拷贝一次,再合并需要更新的属性值。比如下面我们需要在 pagination 的页码和页大小改变时,更新分页器状态,

// 分页器改变,只更新分页的页码或页大小
const updatePagination = (current, pageSize) => {
  setPagination({ ...pagination, current, pageSize });
};

如果这个复杂状态是一个嵌套对象Nested Object),那看起来就更糟糕了,我们需要逐层拷贝,一直到待更新属性所在的层级,

const updateNestedObject = (foo) => {
  setNestedObject({
    ...firstLevel,
    {
      ...secondLevel,
      {
        ...lastLevel,
        foo,
      }
    }
  })
}

实际场景中可能不会有这么深的层级,但是嵌套对象的场景是确实存在的。如果你的状态定义是一个嵌套对象,那么你很可能需要优先考虑将它拆分为多个状态,而不是一直嵌套下去,或者使用我们接下来介绍的一些方式。

useLegcyState

when we update a state variable, we replace its value. This is different from this.setState in a class, which merges the updated fields into the object

众所周知,不同于 ”class 时代“ 类组件 this.setState 的自动合并,在 hooks 中我们通过 useState 定义的状态,调用 dispatcher 更新时,React 不会帮我们自动合并,而是直接替换,

function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];

为了避免每次都需要拷贝对象,我们可以考虑自己实现一个自定义 hook 辅助进行属性值的自动合并,

function useLegacyState<T>(initialState: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
  const [state, setState] = useState<T>(initialState);

  /**
   * setState 时自动进行浅拷贝
   * @param newState
   */
  const legacySetState = (newState: T) => {
    setState({ ...state, ...newState });
  };

  return [state, legacySetState];
}

这样一来,更新分页器时,只需要传入待更新的属性值就可以了,

const [pagination, setPagination] = useLegacyState<Pagination>({ current: 1, pageSize: 10, total: 0 });

const updatePagination = (current, pageSize) => {
  setPagination({ current, pageSize });
};

看起来还不错,我们还可以考虑对嵌套对象提供支持,拷贝时逐层地遍历嵌套对象,找到合适的位置更新属性值,感兴趣的同学可以自己尝试封装一下。对于嵌套状态的更新,其实社区很早就有其他版本的方案,比如 immer

useImmer

ImmerMobx 的作者 mweststrate 在 2018 年 2 月发布的一个支持不可变状态的库,核心原理基于 JavaScript 的 Proxy 对象,支持柯里化,状态经过 Immer 后会被代理为 draft,对 draft 的修改会生成不可变状态,

image.png

mweststrate 后面也提供了对应的 React hook 版本的实现 useImmer,经过 useImmer 包装后的 draft 是一个响应式对象,通过 draft 对象来修改状态,就可以避免手动进行深拷贝和合并操作,

import { useImmer } from "use-immer";

function App() {
  const [nestedObj, updateNestedObj] = useImmer({
    name: "Niki de Saint Phalle",
    artwork: {
      title: "Blue Nana",
      city: "Hamburg",
      image: "https://i.imgur.com/Sd1AgUOm.jpg",
    },
  });

  const updateCity = (city) => {
    updateNestedObj((draft) => {
      draft.artwork.city = city;
    });
  };
}

immer 的实现基于 Proxy 和不可变状态,结合 React 使用时多少有些别扭,有没有更简单的方式呢?

其实,我们完全可以去掉不可变状态,仅基于响应式实现来处理复杂的状态数据更新。

useReactive

我们来构想一下这个 useReactive 的 hook,它的用法和 useState 类似,但可以动态地设置值,

// 经过 useReactive 包装后的 state 是一个响应式 Proxy 对象
const state = useReactive({ count: 0 });

// 直接修改嵌套属性可以自动触发更新
state.count = 1;

实现上,我们如何将状态数据变成响应式并且与 React 结合呢?需要考虑下面的两个问题,

  1. 如何检测值的改变,即 state.count = 1 设置后,如何让真实的状态 state.count 变成 1 ?
  2. 如何刷新视图,让页面看到效果 ?

observer

针对第 1 点,前面我们介绍了可以使用 Proxy 来包装我们的状态对象,实现上,我们考虑用一个 observer 函数来将状态对象变成响应式对象,需要注意如果状态对象是深层嵌套的,需要对每一层都进行代理,

type Callback = (...args: any[]) => void;

function observer<T extends Record<string, any>>(initialState: T, callback: Callback): T {
  const proxy = new Proxy<T>(initialState, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      return typeof res === "object" ? observer(res, callback) : res;
    },

    set(target, key, value) {
      const res: boolean = Reflect.set(target, key, value);
      callback();
      return res;
    },
  });

  return proxy;
}

observer 接受一个初始的状态对象 initialState 以及一个回调函数,返回一个 Proxy 对象,我们劫持了代理对象的 gettersetter,在读取代理对象的值时,将其包装为响应式对象,设置值时执行回调函数。有了 observer,接下来就是如何与 React 视图结合。

forceUpdate

针对第 2 点,在触发更新后,React hooks 语法下如何让视图也进行刷新?

是不是只需要利用 useStateuseReducer 这类 hook 的原生能力即可,我们调用第二个返回值的 dispatch 函数,触发状态改变就可以让当前组件强制刷新,这里我们选择 useReducer,将 dispatch 函数直接命名为 forceUpdate

const [, forceUpdate] = useReducer((c) => c + 1, 0);

解决了上述两个问题,我们的 useReactive 就基本实现了,只需要在代理对象设置值时调用 forceUpdate 触发视图更新即可,同时作为一个通用 hook,可以考虑使用 useMemo 对包装后的响应式对象进行缓存,

function useReactive<T extends Record<string, any>>(initialState: T): T {
  const [, forceUpdate] = useReducer((c) => c + 1, 0);

  const state: T = useMemo(() => {
    return observer(initialState, () => {
      forceUpdate();
    });
  }, []);

  return state;
}

上面的代码已经满足我们的需求了,为了避免 React Hooks 的闭包陷阱,我们还可以考虑对状态对象 initialState 做一层处理,始终代理最新的状态。

useLatest

避免闭包问题的思路就是永远返回最新的值,实现上,我们可以使用 useRef 对值进行缓存,

function useLatest<T>(value: T): { readonly current: T } {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}

使用 useLatest 优化后,最终的 useReactive 实现如下,

function useReactive<T extends Record<string, any>>(initialState: T): T {
  const ref = useLatest(initialState);
  const [, forceUpdate] = useReducer((c) => c + 1, 0);

  const state: T = useMemo(() => {
    return observer(ref.current, () => {
      forceUpdate();
    });
  }, []);

  return state;
}

我们写一个经典的计数器例子来验证下 useReactive,

import React, { useEffect } from "react";
import useReactive from "../reactive/useReactive";

export default function Counter() {
  const state = useReactive({ count: 0 });

  // 验证 React Hooks 闭包陷阱
  useEffect(() => {
    setInterval(() => {
      console.log("count: ", state.count);
    }, 1000);
  }, []);

  return (
    <>
      <p>count: {state.count}</p>
      <button onClick={() => state.count++}>add</button>
      <button onClick={() => state.count--}>minus</button>
      <button onClick={() => (state.count = 0)}>reset</button>
    </>
  );
}

你可以点击 这里 查看 useReactive 的效果。

实际上,上述的响应式 hook 基本就是 ahooksuseReactive 的实现,不过 ahooks 还考虑了,

  • 原对象和 Proxy 代理对象的缓存
  • 状态对象值的类型判断,仅针对 plainObject 和数组进行代理
  • 使用增强的 useCreation 进行缓存,可以理解为增强的 useRefuseMemo,缓存值保持最新值,避免实例化的性能隐患

如果你需要在生产环境尝试 useReactive,建议直接使用 ahooks

小结

React Hooks 时代,对于复杂状态数据的更新,我们可以考虑,

  • 拆分为多个状态避免 Nested Object State
  • 使用类 “class” 时代的自定义 hook useLegcyState 帮助我们自动合并状态
  • 使用基于 Proxy 和不可变状态的 useImmer
  • 使用基于响应式的 useReactive

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant