上下文存储【下】

数据重用与生命周期

上期我们封装了一个 zustand-utils 的上下文存储,这期我们来扩展这个函数的功能,使得它更好用。

数据位于上下文中,则受到了 React 组件位置的限制,没了全局数据的优势, 如果这套 api 不仅能让 React 组件无感实现局部上下文,又能将数据当作普通对象那样随意取用传递,那在面对更多业务场景的时候才能游刃有余。

参考 react-form 的想法,通过 useForm 创建一个表单,绑定到组件后无需关心数据更新问题,最后提交时只需 form.getFieldsValue 即可,至于用去干嘛。。和库没关系。

上一次,我们将 useSelector 的 selector 参数包装为必传参数,防止有人不传使得组件频繁刷新。本着奥卡姆剃刀的原则,我们可以用上这个无参的同名函数

javascript 复制代码
	function useFactoryStore(): StoreApi<StoreType>;
	function useFactoryStore<T>(selector: (store: StoreType) => T): T;
	function useFactoryStore<T>(selector?: (store: StoreType) => T): T | StoreApi<StoreType>
    const store = useContext(StoreContext);
    if (!store) {
        throw new Error(`Missing Provider!`);
    }
    if(!selector) {
        return store;
    }
    return useStore(store, selector)
}

注意这里类型也扩展了

当不传递 selector 的时候,直接返回 store 实体,而不是 store 的数据内容

这时,我们想要两个组件公用一个上下文就很方便了,在移动端做数据编辑操作的时候,一屏没有办法装下全部操作项的时候,我们一般用弹窗作为载体展示部分子表单。弹窗的上下文一般就不在原页面的范围下。这时候可以

tsx 复制代码
const FormPage = () => {
    return <FormProvider init={{}}>
        <FullForm />
    </FormProvider>
}

const FullForm = () => {
    const form = useFormStore()
    return <Form>
        <FormItem onClick={() => {
            openEditorModal(form) // 封装某个form的挂载点
        }>
            <FormPreviewer data={form}>
        </FormItem>
    <Form>
}

const EditFormModal = ({ formStore }:{ formStore: FormStore }) => {
    // 传递到另一个Provider中使用
    return <FormProvider store={formStore}>
        <FormEditor>
    </FormProvider>
}

这样 Form 的 Previewer 和 Editor 就能共享同一份数据了,通过上下文又能让页面上可以存在多个 form,通过存储有能让数据跨多层组件管理而不用通过 props 传递,减少刷新,集成了多种优势。

上面没提到 Provider 也要相应更新另一种初始化方式,代码变更如下

javascript 复制代码
interface ProviderProps {
    children: ReactNode;
    /** 传入初始化该store的内容,结构与store一致 */
    init?: Partial<StoreType>;
    /** 传入StoreApi创建上下文的引用,需要通过不传selector的useStore获取 */
    reference?: StoreInstance;
}
const StoreProvider = ({ children, init, store }: ProviderProps) => {
    const storeRef = useRef<StoreInstance | null>(null);
    if (!storeRef.current) {
        if(init) storeRef.current = StoreBuilder(init);
        else if(store) storeRef.current = store
        else throw new Error("StoreProvider 没有任何一种初始化方式!")
    }
    return <StoreContext.Provider value={storeRef.current!}>{children}</StoreContext.Provider>;
};

通过 ID 共享

跨组件传递 store 都能把整个 store 实体扔过去了,如果能建立一个 id,直接从全局取货就好了。例如下面这种调用方法

javascript 复制代码
const Page1 = () => {
  return (
    <FileProvider init={{ file: readFile(path) }} id={`file:${path}`}>
      <Content />
    </FileProvider>
  );
};

const Page2 = () => {
  return (
    // 这里的 init 不会被调用!
    <TestColorProvider init={{ file: readFile(path) }} id={`file:${path}`}>
      <Content />
    </TestColorProvider>
  );
};

如果你在做一个类似 Vscode 这样的编辑器,用户对一个文件打开两个编辑窗(Tab)是很有可能的,这时候我们就没有必要传递第一个 tab 的 store 到第二个 tab,他们本身没有优先级关系,反而是文件路径应该作为他们的唯一 id,让他们表面上处于不同的组件树中,但实际上共用的是同一份数据,还能省去文件内容重复解析的性能消耗。

实现如下

javascript 复制代码
interface ProviderProps {
    children: ReactNode;
    /** 传入初始化该store的内容,结构与store一致 */
    init?: Partial<StoreType>;
    /** 传入StoreApi创建上下文的引用,需要通过不传selector的useStore获取 */
    store?: StoreInstance;
    /** 根据id自动重用Store*/
    id?: string;
}
// 全部上下文store会存一份在这里
const SharedStore = new Map<string,object>();

const StoreProvider = ({ children, init, store, id }: ProviderProps) => {
    const storeRef = useRef<StoreInstance | null>(null);
    if (!storeRef.current) {
        if(init) {
            if(id && SharedStore.has(id)) {
                storeRef.current = SharedStore.get(id);
            } else {
                storeRef.current = StoreBuilder(init);
                if(id) SharedStore.set(id, storeRef.current)
            }
        }
        else if(store) {
            storeRef.current = store
        }
        else throw new Error("StoreProvider 没有任何一种初始化方式!")
    }
    return <StoreContext.Provider value={storeRef.current!}>{children}</StoreContext.Provider>;
};

嗯,功能是实现了,但是 SharedStore 绝对是一个无限增长的内存实体,一定要在 useEffect 中做一个手动的引用计数去清理缓存的 Store

tsx 复制代码
const SharedStoreCount = new Map<string, number>();

useEffect(() => {
  if (!id) return;
  SharedStoreCount.set(id, (SharedStoreCount.get(id) || 0) + 1);
  return () => {
    SharedStoreCount.set(id, (SharedStoreCount.get(id) || 0) - 1);
    const count = SharedStoreCount.get(id);
    if (count <= 0) SharedStore.delete(id);
  };
}, []);

结尾

当我们允许外部共享存储的时候,存储的生命周期就变为了 既不是全局变量,又不随组件卸载的一个对象。 数据的存活周期内,如果有一些关联的监听器怎么办?例如我们需要监听系统外部的更新 api,确保存储内的数据不是因为一直存活而无法更新。

如果我们挂在第一个创建该 store 的组件中,那它卸载后可能连带着监听器一起卸载掉,如果挂在每一个有 provider 组件的组件中,那遇到一些不可重复监听的事件(例如补发 diff 数据的事件)会带来麻烦。

最好的方法就是把 effect 和 store 本身绑定,而非和组件绑定,effect 随 store 创建和消亡,同样通过一个引用计数来管理。

实现方式大差不差,这里留作课后作业吧,需要注意的是 id 不是必选参数,你可能需要用 store 对象本身作为存储 Map 的 key。

完整代码

相关推荐
释怀不想释怀3 分钟前
Ajax,vue生命周期(自动加载页面发出请求)Axios
前端·javascript·ajax
一点晖光5 分钟前
ios底部按钮被挡住
前端·ios·微信小程序
cz追天之路6 小时前
华为机考--- 字符串最后一个单词的长度
javascript·css·华为·less
Light606 小时前
CSS逻辑革命:原生if()函数如何重塑我们的样式编写思维
前端·css·响应式设计·组件化开发·css if函数·声明式ui·现代css
蜡笔小嘟7 小时前
宝塔安装dify,更新最新版本--代码版
前端·ai编程·dify
ModyQyW8 小时前
HBuilderX 4.87 无法正常读取 macOS 环境配置的解决方案
前端·uni-app
bitbitDown8 小时前
我的2025年终总结
前端
五颜六色的黑8 小时前
vue3+elementPlus实现循环列表内容超出时展开收起功能
前端·javascript·vue.js
wscats9 小时前
Markdown 编辑器技术调研
前端·人工智能·markdown
EnoYao9 小时前
Markdown 编辑器技术调研
前端·javascript·人工智能