上下文存储【上】

45 阅读3分钟

并不全局的全局存储

在 web 开发的学习中,你也许都已经学习过 vue 的 pinia,react 的 redux/dva/jotai/zustand 等存储方法。

他们都教你创建一个可以全局访问的的对象,并去全局读取/写入他们。但是在 H5 开发中,我们有很多这样的场景。例如下面的抖音界面

【视频瀑布流】-【个人主页】-【视频瀑布流】

两个甚至多个视频瀑布流使用了完全相同的组件,但是他们读取的数据是完全不同的, 你可能想象只需要传递不同的 props 就行了!

但是在移动端应用中,瀑布流几乎是最复杂的场景,他们一个组件可能包括如下业务

  • 虚拟列表
  • 瀑布流布局
  • 曝光检查
  • 双向无限滚动加载
  • 动态插入数据

这样的场景下很难不用一个 store 去管理住列表的数据,每个深层组件都有可能对列表进行更新操作。

这种情况你可能想到 context,如果在 vue 中你就无需担心那么多,但在 react 中 context 中每个字段刷新都会导致子组件刷新。

因此我们可以有个更好的实现:把 store 对象放进 context 里!store 本身是一个不可变对象,但是其内部变化会让组件内部订阅者更新。

设计过程

我们以 zustand 为例

下面这个包很好的解决了这样的问题,他通过上下文来创建多个 store

image.png

我们来简单 复原一份

export const useTestStore = createStore<Store>((set) => ({
  color: "#ff0000",
  setColor: (color: string) => set({ color }),
}));

const Component = () => {
  const color = useTestStore((s) => s.color);
  return <div>{color}</div>;
};

我们原本创建存储的时候,都直接创建了存储本身,但是现在我们需要多份存储实例。

于是我们改成函数的方法,每次调用的时候产生一个实例

import { createStore, useStore } from "zustand";
import { createContext, useContext, useRef } from "react";

const TestStoreBuilder = (initColor?: string) =>
  createStore<Store>((set) => ({
    color: initColor || "#ff0000",
    setColor: (color: string) => set({ color }),
  }));
const TestStoreContext = createContext<ReturnType<
  typeof TestStoreBuilder
> | null>(null);

export const TestStoreContainer = (props: { children: ReactNode }) => {
  const { children } = props;
  // 更好的做法是 const [storeRef] = useState(TestStoreBuilder) // 这样不会重复调用 TestStoreBuilder
  const storeRef = useRef(TestStoreBuilder());

  return (
    <TestStoreContext.Provider value={storeRef.current}>
      {children}
    </TestStoreContext.Provider>
  );
};

export function useTestStore<T>(selector: (state: Store) => T) {
  const store = useContext(TestStoreContext);
  if (!store) {
    throw new Error("Missing Provider");
  }
  return useStore(store, selector);
}

export const Component = () => {
  //  注意在这里面 useColorStore,会报错,因为不在上下文中
  return (
    <TestStoreContainer>
      <Content />
    </TestStoreContainer>
  );
};
const Content = () => {
  // 在这里面 useColorStore,和直接使用刚才的体验是一致的
  const color = useTestStore((s) => s.color);
  return <div>{color}</div>;
};

上面的方法不够通用!改成通用的函数。

通用化

export function createProviderStore<StoreType>(
    StoreBuilder: (initState: DeepPartial<StoreType>) => StoreApi<StoreType>,
) {
    type StoreInstance = ReturnType<typeof StoreBuilder>;
    const StoreContext = createContext<StoreInstance | undefined>(undefined);
    interface ProviderProps {
        children: ReactNode;
        /** 传入初始化该store的内容,结构与store一致 */
        init?: Partial<StoreType>;
    }
    const StoreProvider = ({ children, init = {}}: ProviderProps) => {
        const storeRef = useRef<StoreInstance | null>(null);
        if (!storeRef.current) {
                storeRef.current = StoreBuilder(init);
        }
        return <StoreContext.Provider value={storeRef.current!}>{children}</StoreContext.Provider>;
    };
    function useFactoryStore<T>(selector: (store: StoreType) => T): T {
        const store = useContext(StoreContext);
        if (!store) {
            throw new Error(`Missing Provider!`);
        }
        return useStore(store, selector)
    }
    return { StoreProvider, useFactoryStore };
}

这样,对于全部 StoreBuilder 都可以通过这个函数直接创建上下文了

相比直接食用 zustand 的 store,这里还把导出的 useFactoryStore(selector)参数改为必传了,解决很多忘传 selector 导致组件频繁刷新的问题

此处还新增了 init 的参数的支持,如果你写过 ssr 相关的代码,你会知道状态初始值有多重要

使用案例

export const {
  StoreProvider: TestColorProvider,
  useFactoryStore: useColorTest,
} = createProviderStore(TestStoreBuilder);

const Page = () => {
  return (
    <TestColorProvider init={{ color }}>
      <Content />
    </TestColorProvider>
  );
};

const Content = () => {
  const color = useColorTest((s) => s.color);
  return color;
};