前端缓存与数据管理
@知识结构
前端缓存与数据存储包含哪些内容?
- HTTP 缓存
- Service Worker
- API 缓存
- 状态管理
- 状态四象限
- UI状态
- 远程数据状态
- 本地状态
- 全局状态
- 复杂嵌套数据的管理
- 客户端存储
- localStorage
- sessionStorage
- Cookie
- IndexedDB
@数据归一化
什么是数据归一化(Normalization)?
规一化是一种简化嵌套数据结构的方法。虽然这个概念常用于后端数据库设计,但在前端(如状态管理库或API缓存)中应用归一化,可以带来显著的好处。例如,一个包含用户、帖子、评论和标签的复杂数据结构,通过规一化,可以将用户、帖子、评论和标签分别存储为独立的实体集合,并通过唯一的ID进行关联,而不是将它们层层嵌套。这样不仅消除了冗余、提高了存储和查询性能,当一个实体被更新时,所有引用该实体的部分都能自动反映最新状态,还避免了“缓存陈旧”的问题。
数据归一化的原则是什么?
规一化主要围绕三个核心原则:
- 扁平化数据结构: 尽可能地扁平化数据结构,减少不必要的嵌套。
- 单独存储实体: 将不同的实体(例如学生和大学)分开存储,避免将一个实体的完整信息嵌套在另一个实体中。
- 通过唯一ID索引和关联: 扁平化后的各个实体被存储在对象中而不是列表中来提高查找效率,并且在不同的实体之间的关系,很自然地用唯一的ID进行了关联。例如,如果数据存储在一个列表中,查找特定ID的数据需要遍历整个列表(O(n)复杂度)。但如果数据通过唯一ID存储在对象中,可以直接通过ID访问数据(O(1)复杂度),从而实现更快的查找和更高的性能。
在规一化后如何保证数据的顺序?
将数据存储为对象(通过ID访问)可以提高查找效率,但不保证顺序。如果需要维护特定顺序(例如用户列表的显示顺序),可以引入一个额外的“allIds”字段,该字段是一个数组,存储实体ID的有序列表。
@客户端存储
Local Storage
- 大小: 以键值对的形式存储数据。最重要的一点是,存储的“值”始终是字符串类型。这意味着存的值是对象时,每次存取都会发生序列化和反序列化操作。每个域的大小限制在在 5MB 左右
- 持久: 只要不主动清理则持久存储。
- 同步: 所有操作都是同步的,会阻塞主线程,可能影响性能。
- 范围: 数据按域(domain)存储在同一个 Local Storage 中,并且在同一域的所有标签页和窗口之间共享。
- 用例: 存储用户偏好(如主题模式)、购物车信息(未登录用户)、不敏感的缓存数据。
如果打开同一个域的多个标签页,则同一域下的所有标签页都可以访问和修改 Local Storage 数据。但 Local Storage 并不是“响应式”的,一个标签页中对 Local Storage 数据的修改不会自动触发另一个标签页中 UI 的更新。这可能导致:
- UI 状态不一致: 如果一个标签页修改了Local Storage 的数据,而另一个标签页正在显示旧的数据状态,用户界面可能会变得不一致。
- 内存状态与存储状态不同步: 应用程序内存中的数据状态可能与Local Storage 中的实际数据不同步。 因此,在设计多标签页应用时,需要谨慎处理Local Storage ,确保数据的一致性和正确的UI行为,例如可以监听 storage 事件来响应其他标签页的修改。
Session Storage
- 大小: 也是键值对形式存储字符串数据。约为5MB。
- 持久: 数据仅在当前浏览器会话期间(即当前标签页或窗口)有效。
- 同步: 所有操作也是同步的,可能阻塞主线程。
- 范围: 仅限于创建它的特定标签页,不同标签页之间不共享(即使是同一域的页面,复制标签页除外,复制标签页会复制一份会话存储的数据)。
- 用例: 存储临时敏感数据(如一次性表单数据),或仅在当前会话中需要的用户操作历史。
Cookies
- 大小: 也是键值对形式存储数据,但容量非常小(通常为4KB/域)。
- 持久: 通过配置 expires 控制。可以是会话性的(在浏览器关闭时过期)或持久性的(通过设置过期日期)。
- 同步: 数据会在每次HTTP请求时自动发送到服务器,并随服务器响应一起返回给客户端,用来为HTTP提供上下文。这是Cookie的最核心特征。
- 范围: 遵循同源策略,但可以通过设置domain和path属性控制可见性。
- 安全: 可以通过HttpOnly(防止JavaScript访问,防止XSS攻击)、Secure(仅限HTTPS)、SameSite(防止CSRF攻击)等属性增强安全性。
- 用例: 用户认证信息(如会话ID、认证令牌)、用户偏好(需要服务器感知的)、跟踪用户行为。
Cookie主要分为两种类型:会话Cookie(Session Cookie)和持久性Cookie(Persistent Cookie)。
- 会话Cookie:这种Cookie没有设置明确的过期日期。它们在用户关闭浏览器会话时(即关闭浏览器窗口)就会过期并被删除。在同一会话中,即使打开多个标签页,也可以访问和传输这些Cookie。
- 持久性Cookie:这种Cookie被设置为在特定的未来日期过期。一旦设置了过期日期,即使关闭浏览器,Cookie也会保留在客户端,直到达到设定的过期时间。这种Cookie常用于“记住我”功能,使用户在下次访问时无需重新登录。
IndexedDB
- 大小: 用于在客户端存储大量结构化数据,支持事务、索引和异步操作。通常大于100MB,可以存储大量数据,甚至包括文件和Blob。
- 持久: 数据永久存储,除非用户手动清除或通过代码删除。
- 异步: 所有操作都是异步的,不会阻塞主线程,适合处理大型数据集和复杂查询。
- 范围: 遵循同源策略。
- 用例: 离线应用的数据存储(如WhatsApp消息历史、Google文档编辑、Microsoft Teams配置)、有复杂数据操作的大型客户端。
IndexedDB的数据库与传统数据库的比较?
IndexedDB使用类似于关系型数据库的组织结构,但它是一个NoSQL数据库。它的主要组织概念包括:
- 数据库 (Database):一个Web应用程序可以创建一个或多个IndexedDB数据库,每个数据库都有一个名称和版本号。版本号用于管理数据库架构的升级和降级。
- 表/对象存储 (Object Store):数据库内部包含一个或多个“对象存储”,这类似于关系型数据库中的“表”。每个对象存储都存储着一系列记录。
- 记录 (Record):每条记录都是一个键值对。键是记录的唯一标识符(类似于主键),值可以是任何JavaScript数据类型,包括复杂对象(如JSON对象)、文件(File)和二进制大对象(Blob)。
- 索引 (Index):可以在对象存储中的字段上创建索引,以加速对非键字段的查询操作。例如,如果一个friends对象存储包含name和age字段,你可以为name或age创建索引,以便更快地按名称或年龄查找朋友。
- 事务(Transactions): IndexedDB支持事务。多个读取、写入、更新、删除操作可以被组合成一个操作单元。成功失败逻辑类似 Promise.all() ,通过整体的成功和失败,确保了数据操作的一致性和完整性。
一般的业务开发中,使用Dexie.js来操作IndexedDB。Dexie.js是一个流行且强大的IndexedDB封装库, Dexie.js提供了一个更简洁、更直观、更面向对象的方式来操作IndexedDB数据库。
@HTTP 缓存
什么是HTTP缓存?
HTTP缓存是一种浏览器层面的缓存机制,通过利用HTTP协议中的特定头部(如Cache-Control、Expires、Last-Modified和ETag)来控制资源的存储和重用。其中 Cache-Control 和 Expiress 属于强缓存机制。Last-Modified 和 ETag 都属于“协商缓存”机制,“协商缓存”流程需要依据“强缓存”头部的某个值作为开关开启。当浏览器首次请求一个资源时,服务器会在响应中包含这些缓存头部。
浏览器使用缓存的整个流程是怎样的?
浏览器首先会检查本地缓存。如果请求的资源存在于浏览器缓存中,它会根据资源的缓存策略(通过HTTP响应头配置)来决定是否直接使用缓存。
如果缓存有效(例如,在 Cache-Control 的 max-age 期限内或 Expires 日期之前),浏览器会直接从缓存中获取数据(通常显示为“memory cache”或“disk cache”,状态码通常不是200)。
- Cache-Control: 这是最强大和优先的缓存头。它允许服务器定义细粒度的缓存策略,例如:
- max-age=
<seconds>: 资源在多少秒内是新鲜的,可以直接从缓存中获取而无需重新验证。 - public / private: 指示资源是否可以被任何缓存(包括共享代理缓存)缓存,或只能被客户端私有缓存。
- no-cache: 强制缓存每次都向服务器重新验证资源是否已更改(即使资源本身在缓存中)。
- no-store: 明确禁止任何缓存存储此资源的副本。
- must-revalidate: 即使资源变得陈旧,也必须在重用前与原始服务器进行重新验证。
- max-age=
- Expires: 这个头指定了一个具体的日期和时间,在此日期之后,缓存的资源被认为是陈旧的,需要重新验证。它的优先级低于 Cache-Control。
如果缓存无效或不存在,浏览器会向服务器发起请求。对于配置了 Last-Modified 或 ETag 头的资源,浏览器会发送一个条件请求(带有 If-Modified-Since 或 If-None-Match 头),询问服务器资源是否已更新。如果资源未修改,服务器会返回 304 Not Modified 状态码,指示浏览器使用其本地缓存的资源。如果资源已修改,服务器则返回 200 OK 状态码和新的资源数据。
- Last-Modified: 服务器在响应中发送此头,指示资源的最后修改时间。客户端在后续请求时可以发送 If-Modified-Since 请求头,如果资源未被修改,服务器会返回 304 Not Modified。
- ETag (Entity Tag): 这是一个资源内容的哈希值。服务器在响应中发送此头。客户端在后续请求时可以发送 If-None-Match 请求头,如果服务器上的ETag与客户端的ETag匹配,说明资源未修改,服务器会返回 304 Not Modified。ETag通常比 Last-Modified 更精确,因为它能识别内容微小变化的更新(例如,即使文件内容未变,修改时间也可能改变)。
如何强制浏览器从服务器获取最新数据,而不是使用缓存?
要强制浏览器从服务器获取最新数据,而不是使用本地缓存,一种常用的技术是改变请求资源的URL。
例如,通过在URL后面添加一个随机的查询参数(通常称为“缓存清除”或“版本号”): image.gif?v=random_hash
@Service Worker
什么是 Service Worker?
Service Worker 脚本独立于网页主线程运行在浏览器后台,作为一个可编程的代理层工作在浏览器和网络之间。
- 缓存命中逻辑可以依据具体需要自己写,这提供了更强的控制力和更灵活的缓存策略。
- 它在触达浏览器中的 HTTP 缓存之前拦截请求,方便实现离线功能。
Service Worker 如何使用?
Service Worker 使用流程,一般分为注册、安装、激活、拦截四个阶段。
假设你做了一个个人博客(只有几个静态页面:index.html, style.css, app.js),希望:
- 第一次访问时正常加载。
- 之后即使 断网,用户仍然能看到网站内容。
首先要在 app.js 里 【1】注册:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => {
console.log('✅ Service Worker 注册成功:', reg);
})
.catch(err => {
console.error('❌ 注册失败:', err);
});
}注意:Service Worker 只能在 HTTPS 或
localhost下运行。
然后在 sw.js 中 【2】安装:
const CACHE_NAME = 'my-blog-cache-v1';
const PRECACHE_ASSETS = [
'/',
'/index.html',
'/style.css',
'/app.js',
'/logo.png'
];
self.addEventListener('install', event => {
console.log('📦 正在安装 Service Worker...');
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll(PRECACHE_ASSETS);
})
);
});在安装阶段,预先缓存网站的关键资源后,用户即使断网,下次访问仍然能加载页面。
【3】激活 阶段一般需要清理旧缓存:
self.addEventListener('activate', event => {
console.log('⚡ Service Worker 激活中...');
event.waitUntil(
caches.keys().then(keys => {
return Promise.all(
keys.map(key => {
if (key !== CACHE_NAME) {
console.log('🧹 删除旧缓存:', key);
return caches.delete(key);
}
})
);
})
);
});【4】拦截 阶段的逻辑如下所示:
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cacheResponse => {
// 如果缓存里有,直接返回
if (cacheResponse) {
console.log('📂 缓存命中:', event.request.url);
return cacheResponse;
}
// 否则走网络请求,并缓存结果
return fetch(event.request).then(networkResponse => {
return caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
});
})
);
});如何调试Service Workers?
- 打开网站 →
F12→Application面板。 - 左侧菜单找到 Service Workers:
- 可以看到
sw.js的注册情况。 - 可以手动点击 Update / Unregister。
- 可以看到
- 在 Cache Storage 里可以看到
my-blog-cache-v1,里面存的就是我们缓存的资源。 - 在 Network 面板,断开网络(点
Offline),刷新页面。
@前端API缓存
什么是前端API缓存?
广义的API 缓存有以下几种类型,包括:
- 浏览器缓存: 客户端(浏览器)存储数据。
- 内存缓存: 应用程序内部的内存中存储数据。
- 服务器端缓存: 服务器通过服务缓存请求。
我们这里主要指的是前端应用中的内存缓存,也就是指在客户端应用程序层面缓存API响应数据,以减少重复的网络请求。
有哪些流行的库可以帮助实现 API 缓存?
前端API缓存通常通过第三方库来实现。这些库在内部维护一个内存缓存对象,并对外提供 Hook 或函数,一方面与缓存交互,一方面又与网络层进行交互。当应用程序发起 API 调用(例如 GraphQL 或 REST API 调用)时,它会首先检查内存缓存中是否存在所需数据。
比较流行的有:
- React Query (现在改名叫 TanStack Query): 首选。提供丰富的功能,如缓存策略、加载状态、错误处理等,尤其适用于 React 应用。
- SWR: 另一个流行的库,出现时间要早于React Query。名字比较奇怪,是 "Stale-while-revalidate"的缩写,强调在后台重新验证数据的同时,立即返回数据,以提高用户体验。
- Apollo Client: 专门用于 GraphQL 的状态管理库。
有哪些核心的“获取策略”,如何选择?
API 缓存工具的核心在于“获取策略(Fetch Policies)”,以不同态度应对请求和缓存需求。常见的策略包括:
- Cache Only (仅缓存): 如果数据在缓存中可用,直接从缓存返回数据,不进行任何网络请求。
- Network Only (仅网络): 始终发起网络请求来获取最新数据,不检查或使用缓存中的数据。
- Cache and Network (缓存和网络): 首先从缓存中返回数据,然后同时发起网络请求,并在数据更新后刷新 UI。这对于非关键(非瞩目)但需要及时更新的数据非常有用(例如产品描述)。
- Cache First (缓存优先): 首先尝试从缓存中获取数据。如果缓存中没有,则发起网络请求。
- No Cache (关闭缓存): 不使用缓存,也不将任何数据存储到缓存中。所有请求都直接发送到网络,并且响应数据不会被缓存。这与“Network Only”不同,因为“Network Only”只是不检查缓存,但可能仍然会将数据写入缓存,而“No Cache”则明确禁止缓存。
选择合适的 API 缓存策略取决于数据的“关键性”和“实时性”要求:
- 高关键性数据(例如银行账户余额): 通常应使用“Network Only”或“No Cache”策略。
- 非关键但需要更新的数据(例如产品描述、博客文章): “Cache and Network”策略非常适用。
- 不经常变化的数据(例如配置信息): “Cache Only”或“Cache First”策略可以大大减少网络请求。
@状态管理
Redux 的数据流是怎样的?
Redux 遵循单向数据流原则,核心概念包括 UI、Action、Reducer 和 Store。
- UI:用户界面,用户在这里进行操作,例如点击过滤器或添加新条目。
- Action:UI 发出“动作”指令(如“添加一个新条目”),Action 只是描述了“要做什么”,而不关心“如何做”。Action 在代码中表现为一个字符串名称。
- Reducer:Reducer 接收 Action,并包含实现特定逻辑的代码(即“如何做”)。例如,在 Reducer 中定义了如何在现有列表中添加一个新条目。
- Store:Store 是存储应用程序状态的地方。Reducer 对数据进行处理后,会更新 Store 中的状态。
- UI 更新:当 Store 中的状态发生变化时,UI 会自动更新,因为 Store 采用了订阅者模式,会通知所有监听其变化的组件进行更新。
Zustand 库有哪些特点,它与 Redux 有何不同?
Zustand 是一个轻量级的状态管理库,主要为 React 应用程序设计,但也可以与任何框架一起使用。它与 Redux 相比具有以下特点:
- 简单:Zustand 旨在提供一个极简的 API,可以使用类似 React useState Hook 的方式来定义和访问 Store。
- Hooks:它大量利用 React Hooks 的特性,使得状态的定义、访问和更新都非常符合 React 的习惯。
- 直接更新状态:与 Redux 严格的 reducer 模式不同,Zustand 直接在 Store 定义中包含用于更新状态的方法,大幅减少了状态管理所需的样板代码。
@综合用例
什么是“干净登出”?
用户登出(logout)时 一般依靠 Clear-Site-Data HTTP响应头实现,它的功能十分强大。当服务器在响应中发送此头时,它可以指示浏览器清除与当前站点相关联的多种类型的数据,包括:
- cache:清除浏览器缓存。
- cookies:清除所有该域名的Cookie。
- storage:清除所有客户端存储数据,包括LocalStorage、SessionStorage、IndexedDB等。
通过在服务器端配置登出路由发送Clear-Site-Data头,可以确保当用户登出时,所有与该会话或用户相关的客户端数据(无论是Cookie、本地存储还是缓存)都被彻底清除。这比单独清除Cookie或逐个清除其他存储机制更加全面和安全,大大降低了信息泄露或会话劫持的风险,从而实现了更可靠的“硬性”登出。