Cookie Store API 应该怎样安全地管理浏览器 Cookie?
Cookie Store API 用异步方法读取、写入和监听 Cookie,适合封装主题、语言和会话辅助状态,但不能绕过 HttpOnly、SameSite、Secure 或服务端权限校验。
为什么需要新的 Cookie 访问方式
document.cookie 看起来只是一个字符串,但每次读取都要把当前作用域可见的 Cookie 拼接出来。Cookie 较多、页面频繁读取,或者代码在滚动和输入事件中反复访问它时,同步调用可能阻塞主线程。它还不方便监听其他代码对 Cookie 的修改,组件往往只能定时读取。
Cookie Store API 提供 Promise 形式的 get、set、delete,并支持 change 事件。它改善的是访问方式和代码组织,不会改变 Cookie 的安全模型,也不会让前端获得读取 HttpOnly Cookie 的权限。
先封装,再让业务使用
不要让每个组件自己拼接 Cookie 字符串。应该集中定义名称、路径和生命周期,并为不支持新 API 的浏览器保留降级:
const themeCookie = {
name: 'theme',
path: '/',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
};
function readLegacyCookie(name) {
const prefix = `${encodeURIComponent(name)}=`;
const item = document.cookie
.split('; ')
.find((part) => part.startsWith(prefix));
return item ? decodeURIComponent(item.slice(prefix.length)) : null;
}
export async function getTheme() {
if (globalThis.cookieStore) {
const cookie = await cookieStore.get(themeCookie.name);
return cookie?.value ?? 'system';
}
return readLegacyCookie(themeCookie.name) ?? 'system';
}
export async function setTheme(theme) {
const value = theme === 'light' || theme === 'dark' ? theme : 'system';
if (globalThis.cookieStore) {
await cookieStore.set({ ...themeCookie, value });
return;
}
document.cookie = `${encodeURIComponent(themeCookie.name)}=${encodeURIComponent(value)}; Path=/; Max-Age=${themeCookie.maxAge}; SameSite=Lax`;
}
封装层负责编码、默认值和降级,组件只关心 getTheme() 和 setTheme()。这样以后更换存储方式、调整过期时间或修复 Cookie 属性时,不需要搜索整个项目里的字符串拼接代码。
Cookie 的作用域决定谁能看到它
Cookie 是否可读,取决于名称、域名、路径、协议和浏览器的存储策略。设置在 /account 路径下的 Cookie,在 / 页面不一定可见;使用不同 Domain 或子域名也会影响发送范围。删除时必须使用与原 Cookie 匹配的 name、path 和 domain,否则你以为删除成功,旧 Cookie 可能仍然存在。
同名 Cookie 在不同路径下可以同时存在。服务端收到请求时可能看到多个同名值,前端只删除其中一个会导致登录状态或配置表现不一致。修改 Cookie 规则时,应先盘点线上已有的路径和域名,再设计迁移策略。
身份认证不能交给可读 Cookie
登录会话通常应该由服务端设置 HttpOnly、Secure 和合适的 SameSite Cookie。HttpOnly 可以阻止 JavaScript 读取会话值,即使使用 Cookie Store API 也一样。这样能降低 XSS 直接窃取会话令牌的风险。
前端可以读取一个非敏感的 session_hint 来决定是否提前展示加载状态,但最终身份必须由服务端根据会话 Cookie 判断。不能因为前端读不到 HttpOnly Cookie 就把令牌复制到 localStorage 或普通 Cookie 中。CSRF 防护、权限校验、会话失效和刷新策略仍然是服务端责任。
监听变化时要区分自己和其他上下文
function watchTheme(onChange) {
if (!globalThis.cookieStore) return () => {};
const handler = (event) => {
const changed = event.changed.find((cookie) => cookie.name === 'theme');
const deleted = event.deleted.some((cookie) => cookie.name === 'theme');
if (changed) onChange(changed.value);
if (deleted) onChange('system');
};
cookieStore.addEventListener('change', handler);
return () => cookieStore.removeEventListener('change', handler);
}
组件挂载时注册监听,卸载时必须移除,否则页面长时间运行后会累积回调。自己调用 setTheme 后不要等待 change 事件才更新当前组件,应立即更新本地状态;监听主要用于响应其他代码或其他上下文的变化。
监听事件也不是跨站同步协议。它不能监听别的域名,也不能保证所有嵌入场景下的存储分区都相同。需要跨标签页传递复杂状态时,应使用 BroadcastChannel 或重新读取共享数据,而不是把 Cookie change 当成消息队列。
服务端渲染和缓存会改变设计
如果服务端根据 Cookie 输出主题、语言或用户信息,客户端监听 Cookie 变化不会自动改变已经生成的 HTML。变化后需要重新请求数据或刷新相关区域。包含用户身份的响应还要正确设置缓存策略,避免 CDN 把一个用户的内容提供给另一个用户。
主题和语言这类非敏感偏好可以在首屏早期读取,以减少闪烁;登录状态则应让服务端决定,客户端只负责展示加载中、已登录或未登录等状态。不要为了让客户端更方便而暴露认证凭据。
异步访问仍然需要处理并发
两个请求同时刷新会话时,不能让它们各自写入不同 Cookie,再由最后完成的请求覆盖前一个结果。请求层应合并刷新任务,并在登出后拒绝旧请求写回状态。cookieStore.set() 成功只表示浏览器接受了设置,不代表服务器已经处理了业务,也不代表下一次请求一定已经完成。
测试时需要覆盖新 API 不存在、HTTPS 与 HTTP、过期和删除、不同 Path、HttpOnly 不可读、多个请求并发刷新以及页面重新打开等情况。Cookie Store 是更好的访问接口,但它不会替你解决认证协议、并发控制和安全问题。