本文将详细介绍我所写的库fods的设计思路,以解决前端数据请求的破壁,让不同的人不同的团队不同的组件,可以在相同的数据请求中各自独立工作(孤岛效果)。
问题的产生
让我们先来看看我们在开发中遇到的问题,通过这些问题,我们来思考问题背后的一些本质。
最早我们在组件中,直接通过ajax请求数据,在请求到数据之后把data写到state上来处罚组件更新。但是,我们很快发现,不同的组件可能请求了相同的接口。因此,我们会尝试将ajax请求封装起来,并在不同组件中引用这个封装,在封装中,我们对data进行缓存,这样当同一个接口的数据已经存在时,请求就不会被同时发起两次。不过,这种方法还是有缺陷,因为当data数据不存在时,如果此时两个组件同时发起请求,仍然会发出同一接口的两次请求。于是,我们尝试直接缓存promise对象,这样,当第二个发起请求的组件直接获得第一个请求发起的promise,当resolved时,两个组件可以同时在then中被激发。这样看起来问题解决了。
然而,现实还是残酷的。我们发现,由于两个不同团队发布的业务组件,往往由于发布后以孤岛的形式存在于应用的不同地方。不同团队是否采用了相同的封装,需要我们在制度层面加以保障。于此同时,我们发现,特别是在一些展示数据面板的应用中,两个孤岛组件如果请求了相同的接口,却无法在因为数据发生新的推送时同步更新。这里举个例子,A和B两个组件被放在一个面板中,它们都请求了同一个接口的数据,只是采用了不同的数据子集渲染成不同的分析图表。当用户在A中输入了自己的信息,完成提交后,组件会重新拉取数据,然后重新渲染A。这很好理解。但是,重点来了,此时,难道B不应该被同步更新吗?
当我们的组件体系逐渐丰富起来,我们会开始因为数据如何传递而感到麻烦。因为我们往往将请求和state放在一个顶层组件中,再将state传递给子组件,如果组件层级比较深,就非常麻烦,因为有时候,中间组件根本不需要使用这部分数据,只是透传,太无聊了。为了应对这种跨多个组件层级的问题,我们引入全局store来解决。例如我们使用redux或vuex来定义全局状态管理器,让不同层级的组件都从store中取同一个数据来用,这样当数据发生变化时,虽处两个层级不同的组件也可同时重绘。因此,很长一段时间里,store是我们存数据的主要场所。
然而,随着我们对项目架构的需求增加,我们希望部分组件是独立的,不依赖基座应用,这些组件就像孤岛一样处于应用这片大海中,保持着自己的独立性,从而不被其他部分干扰,保证其运行的稳定性和长期维护的可靠。可此时,我们会发现,如何从store的架构中剖离出来是一个麻烦的问题。
pinia等全局状态管理器虽然解决了部分问题,让我们可以在孤岛中也可以使用全局store(或者说该store可以被多个孤岛连接),这种能够在孤岛间形成“虽互不影响但又共享数据”的局面,我称之为“孤岛隧穿”。虽然pinia解决的不错,可是我们仍然发现,pinia是挑平台的,它只能在vue的组件体系中去实践这种设计,因为它依赖了vue的reactivity的部分。
如上所述,在前端,数据请求的管理,说简单也简单,但是说麻烦也是一件非常麻烦的事,而且至今没有一种合理有效的通用方案。
问题的思考
如何让两个组件形成孤岛效应,互不影响呢?我认为核心的点,是解耦,也就是将数据请求的具体过程从组件中被解耦出去。我们在组件中,以使用者的姿态,“汲取”数据而不“生产”数据,那么对于组件而言,数据本身就像props一样,是静态的,是一个依赖项,而非一个行为项。
但是两个不同的组件,以“汲取”姿态同时依赖一个数据,并不能确保它们的关联性。就像前文说的一样,相同的数据源发生变化时,引用该数据的不同组件,应该同时全部应用新数据来重新渲染。这种既依赖,又影响,但又不直接影响的“孤岛隧穿”局面,在pinia中较好的体现出来,但在平台无关的场景下,我们不希望我们的数据被proxy包裹时,应该怎么去实现呢?
我们往往需要借助一些设计模式来实现某些能力,现在我们会引入订阅发布模式。通过订阅发布,我们可以让vue之外的任何应用都做到“孤岛隧穿”。
我们在数据源和具体应用之间,设计了一层“数据源层”。抽象出这一层的作用是将数据请求从具体的应用代码中解耦出去,做到上文提到的保持孤岛效应。数据源层暴露出的接口确保了应用层的独立性,应用层只会把数据源作为依赖,而无需关心数据源的数据是如何请求得到的,这样,我们就能让整个应用中,同一接口的数据只有一个来源。
同时,我们在数据源层实现了订阅发布,在应用层通过hooks封装,自动订阅被依赖的数据源变更,当变更发生时,组件自动更新。
如上图所示,对于A和B两位开发者而言,他们的视角范围内的东西很少,虽然在数据源层,SourceA和SourceB之间又有依赖关系,但是,在应用层,这些依赖关系是不可见的,对于B而言,他只汲取SourceB来使用,同时在用户输入的情况下,他还会调用renew(他不需要关注renew的实际过程,他可以认为renew就是刷新数据,虽然renew本质上是刷新A,但是这个过程对开发者封闭),进而整个应用中的组件都会随着renew的发生而更新界面,对于B而言,数据源层的内部逻辑,以及隐藏在更后面的数据请求,他都不需要关心,他只需要关心离自己最近的SourceB即可。
以上就是fods的全部设计思路。基于这一思路,fods可以在vue、react中使用,也可以在web、nodejs等支持javascript的应用中使用。它不依赖任何第三方库,体积小巧而稳定。
更多思考
由于在fods中,采用了订阅发布、缓存、依赖收集、hash签名等技术或思路,使用者在第一次使用时,会有些惊讶,“凭什么它可以做到这个效果?”例如下面这段代码:
const queryBook = source(async (id) => {
const res = await fetch(`/api/book/${id}`);
const data = await res.json();
return data;
});
const book1 = await queryBook(1);
const book2 = await queryBook(2);
const book = await queryBook(1); // 不会发出具体请求
看似非常简单,实则这里导出的 `queryBook` 可谓大有学问。它有很强的设计哲学,例如在fods中,有一个compose接口,它用以解决批量查询中的一些问题。
例如我们有一个这样的接口:
GET https://xx/api/records?id=xxx,xxx
这个接口可以通过一次传入多个id来批量查出多个记录。然而,在我们的组件开发中,我们常常会左右为难,我到底是应该在单个组件中传1个id去查当前组件要的数据呢?还是在什么地方把所有要查的id数据一次性取出,再将单条数据传到对应的组件去呢?
使用compose则不需要担心这个问题,它会把多参数的请求进行合并,我们只需要在单个组件中关心自己的请求id,把这个id作为参数拿去请求,compose则会合并短时间内在页面中多个组件同时发起的请求,通过上面这个接口把所有需要的数据一次请求回来,于此同时,当数据回来之后,会按照其请求id的特性,把数据分配给不同的组件。
更妙的是,当我们只需要更新其中1-2个id对应的数据时,它也只拉取给定的这1-2个id对应的数据,而不会因为初始参数不同重新刷新所有数据。
const [book1, book2] = await queryBooks([1, 2]);
// 假设此处在同一时刻另外一个组件中
const [book3] = await queryBooks([3]);
// 假设完毕,优雅退出
// 上面虽然执行了两处queryBooks,但实际上,它会把所有的id合并为一个数组后,只发起一个请求
queryBooks.renew([1]);
const [newBook1] = await queryBooks([1]);
这样的巧妙设计,使得我们以前很多问题都可以迎刃而解。这完全归功于抽象出数据源层,秉持“开放封闭”原则,应用层只需要调用数据源层的对应接口即可使用,而无需关心数据源本身是如何做数据请求、如何做数据缓存、如何做数据响应的。
结语
从封装请求本身,到抽象出数据源层,我们通过将不同组件对相同数据源的诉求变为对相同事物(数据源对象)的依赖,通过这种表达上简单的关系,避免了从组件到请求到store更新再回到组件首尾循环的关系,从而提升了长期维护性。不过,任何一个工具,想要发挥它的最佳能力,还需要在实践中不断的磨合,最终找到适合项目本身的最佳姿态。
如果你对fods感兴趣,可以通过github关注,点个small~star~star会让你的心情更美丽。
https://github.com/tangshuang/fods