2023年11月06日
使用缓存来解耦前端代码的优势
我们可以认同解耦是一种良好的实践,它简化了代码并提高了项目的可维护性。
解耦代码的常见方式是将责任分为不同的层。一个非常常见的划分是:
- 视图层 :负责渲染HTML并与用户交互
- 领域层 :负责业务逻辑
- 基础设施层 :负责从后端获取数据并将其返回给领域层(在这里,通常使用存储库模式,它只是一个获取数据的契约。契约是唯一的,但你可以有多个实现;例如,一个用于REST API,另一个用于GraphQL API。你应该能够在不改变代码中的其他部分的情况下更改实现。)
让我们看一些使用案例的例子,这些案例中非常典型的是将性能置于解耦之上。(剧透:我们两者都可以兼得。)
假设你有一个返回产品列表的端点,其中一个字段是category_id
。响应可能是这样的(我删除了其他字段以便举一个简单的例子):
[ { id: 1, name: "Product 1", category_id: 1 }, { id: 2, name: "Product 2", category_id: 2 }, ... ]
我们需要在前端显示类别名称(而不是ID),因此我们需要调用另一个端点来获取类别名称。该端点返回的内容可能是这样的:
[ { id: 1, name: "Mobile" }, { id: 2, name: "TVs" }, { id: 3, name: "Keyboards" }, ... ]
你可以认为后端应该执行连接并返回一个完整的请求,但这并不总是可能的。
我们可以在前端进行连接,在负责恢复产品的函数或方法中。我们可以同时发出两个请求并连接信息。例如:
async function getProductList(): Promise<Product[]> { const products = await fetchProducts() const categories = await fetchCategories() return products.map(product => { const category = categories.find(category => category.id === product.category_id); return { ...product, category_name: category.name } }) }
我们的应用程序不需要知道我们需要两次调用来恢复信息,我们可以在前端使用category_name
而不会出现任何问题。
现在想象一下,你需要在下拉菜单中显示类别列表。例如,在你的视图中,代码可能是这样的:
<template> <dropdown :options="categories" /> <product-list :products="products" /> </template> <script lang="ts" setup> import { fetchCategories, getProductList } from '@/repositories'; const categories = await fetchCategories(); const products = await getProductList(); </script>
在这一点上,你意识到你正在对同一个端点进行两次调用以恢复相同的数据 - 你用来组成产品列表的数据 - 这在性能、网络负载、后端负载等方面都不好。
此时,你开始考虑如何减少对后端的调用次数;在这种情况下,只需重用类别列表。你可能会有诱惑将调用移到视图中并连接产品和类别:
// ❌❌❌ 不好的解决方案 <template> <dropdown :options="categories" /> <product-list :products="products" /> </template> <script lang="ts" setup> import { fetchCategories, fetchProducts } from '@/repositories'; const categories = await fetchCategories(); const products = await fetchProducts().map(product => { const category = categories.find(category => category.id === product.category_id); return { ...product, category_name: category.name }; }); </script>
通过这样做,你解决了性能问题,但又增加了另一个基础设施、视图 和领域的耦合 。现在你的视图知道了基础设施(后端)中数据的形式,这使得代码重用变得困难。我们可以深入研究并使情况变得更糟:如果你的页眉在另一个需要类别列表的组件中,会发生什么?你需要全局考虑应用程序。
想象一个更复杂的情景:一个需要在页眉、产品列表、过滤器和页脚中使用类别的场景。
通过之前的方法,你的应用层(Vue、React等)需要考虑如何获取数据以最小化请求。这并不好,因为应用层应该专注于视图,而不是基础设施。
使用全局存储
解决这个问题的一种方法是使用全局存储(Vuex、Pinia、Redux等)来委托请求,并且只在视图中使用存储。存储只应在尚未加载数据时加载数据,视图不应关心数据是如何加载的。*这听起来像缓存,对吧?*我们解决了性能问题,但基础设施和视图仍然耦合。
基础设施缓存拯救
为了尽可能解耦基础设施和视图,我们应该将缓存移到基础设施层(负责从后端获取数据的层)。通过这样做,我们可以随时调用基础设施方法,只需向后端发出单个请求;但重要的概念是,领域、应用程序和视图对缓存、网络速度、请求数量等一无所知。
基础设施层只是一个获取数据的层,具有契约(如何请求数据以及如何返回数据)。遵循解耦原则,我们应该能够更改基础设施层的实现,而不必更改领域、应用程序或视图层。例如,我们可以用使用GraphQL的后端替换使用REST的后端,并且可以在不进行两次请求的情况下获取带有类别名称的产品。但是,这是基础设施层应该关心的事情,而不是视图 。
你可以遵循不同的策略来在基础设施层实现缓存:HTTP缓存(代理或浏览器内部缓存),但在这些情况下,为了更好的灵活性,最好让我们的应用程序(再次是基础设施层)管理缓存。
如果你使用Axios,你可以使用Axios Cache Interceptor来管理基础设施层的缓存。这个库使缓存变得非常简单:
// 从axios缓存拦截器页面的示例 import Axios from 'axios'; import { setupCache } from 'axios-cache-interceptor'; // 相同的对象,但带有更新后的类型。 const axios = setupCache(Axios); const req1 = axios.get('https://api.example.com/'); const req2 = axios.get('https://api.example.com/'); const [res1, res2] = await Promise.all([req1, req2]); res1.cached; // false res2.cached; // true
你只需要用缓存拦截器包装axios
实例,该库将负责其余的工作。
TTL
TTL是缓存将保持有效的时间。在此时间之后,缓存将失效,并且下一个请求将发送到后端。TTL是一个非常重要的概念,因为它定义了数据的新鲜程度。
当你缓存数据时,一个具有挑战性的问题是数据不一致性。在我们的例子中,我们可以考虑购物车。如果它被缓存,用户添加了一个新产品,如果你的应用程序发出请求以获取购物车的更新版本,它将获取缓存的版本,用户将看不到新产品。有策略可以使缓存失效并解决这个问题,但这超出了本文的范围。但你需要知道这不是一个微不足道的问题:不同的用例需要不同的策略。
TTL越长,数据不一致性问题就越大,因为在这段时间内可能会发生更多的事件。
但对于我们正在寻找的目标(轻松解耦代码),非常低的TTL(例如,10秒)足以解决数据不一致性问题。
为什么低TTL足够?
想象一下用户如何与应用程序交互:
- 用户将请求一个URL(它可以是SPA的一部分或SSR页面)。
- 应用程序将创建页面的布局,挂载独立的组件:页眉、页脚、过滤器和内容(例如产品列表)。
- 每个组件都会请求它需要的数据。
- 应用程序将使用恢复的数据渲染页面,并将其发送到浏览器(SSR)或在DOM中注入/更新(SPA)。
所有这些过程在每个页面更改中都会重复执行(在SPA中可能是部分执行),最重要的是:它在很短的时间内执行(也许是毫秒)。因此,使用低TTL,我们可以非常确定地说我们只会向后端发送一个请求,而且我们不会像在下一页更改或用户交互中那样出现数据不一致性问题,因为缓存已过期,我们将获取新鲜的数据。
总结
低TTL的缓存策略是解耦基础设施和视图的一个非常好的解决方案:
- 开发人员不需要考虑如何在视图层最小化请求数据。如果你需要在子组件中获取类别列表,你只需请求它,而不需要考虑另一个组件是否正在请求相同的数据。
- 避免维护全局应用程序状态(存储)
- 更自然地遵循存储库模式中的契约进行多个请求,并在基础设施层中进行连接。
- 一般来说,简化了代码的复杂性
- 由于TTL非常低(也许对于一些非常特定的用例),没有缓存失效的挑战。