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

我正在参加「掘金·启航计划」

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

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

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

  • current,当前页
  • pageSize,页的大小
  • total,数据的总数
type Pagination = {
current: number;
pageSize: number;
total: number;
};
type Pagination = {
  current: number;
  pageSize: number;
  total: number;
};
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 });
// 分别定义 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 });
// 分别定义 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 });
};
// 分页器改变,只更新分页的页码或页大小
const updatePagination = (current, pageSize) => {
  setPagination({ ...pagination, current, pageSize });
};
// 分页器改变,只更新分页的页码或页大小 const updatePagination = (current, pageSize) => { setPagination({ ...pagination, current, pageSize }); };

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

const updateNestedObject = (foo) => {
setNestedObject({
...firstLevel,
{
...secondLevel,
{
...lastLevel,
foo,
}
}
})
}
const updateNestedObject = (foo) => {
  setNestedObject({
    ...firstLevel,
    {
      ...secondLevel,
      {
        ...lastLevel,
        foo,
      }
    }
  })
}
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>>];
function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
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];
}
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];
}
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 });
};
const [pagination, setPagination] = useLegacyState<Pagination>({ current: 1, pageSize: 10, total: 0 });



const updatePagination = (current, pageSize) => {
  setPagination({ current, pageSize });
};
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;
});
};
}
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;
    });
  };
}
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;
// 经过 useReactive 包装后的 state 是一个响应式 Proxy 对象
const state = useReactive({ count: 0 });




// 直接修改嵌套属性可以自动触发更新
state.count = 1;
// 经过 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;
}
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;
}
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);
const [, forceUpdate] = useReducer((c) => c + 1, 0);
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;
}
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;
}
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;
}
function useLatest<T>(value: T): { readonly current: T } {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}
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;
}
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;
}
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>
</>
);
}
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>
    </>
  );
}
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,对作者也是一种鼓励。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYHLTAVC' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片