1. 先把这三个东西摆到台面上
聊到前端数据存储,绕不开的就是 localStorage、sessionStorage、cookie 这三个老熟人。面试的时候会被问,实际开发中更是天天用,但说句实话,能把这仨的细节彻底说清楚的人真不多。比如就有人问过:cookie 到底存不存在请求头里?localStorage 怎么按 id 删数据?为什么开发者工具里看不到 cookie?这些看似基础的问题,真到用的时候反而容易卡壳。
本篇就当一次个人项目复盘,把我这些年在这三个存储方案上踩过的坑、总结出来的规律、以及整理好的工具函数一次性倒出来。不管你是刚入行前端的新手,还是干了几年想查漏补缺的老手,这篇应该都能给你点实在的东西。全程没有啥高深理论,都是能直接抄作业的写法。
2. 认知模型:三个容器,三种脾气
2.1 cookie 是“每次都要带上的身份证”
cookie 的诞生时间最早,它的核心使命其实和存储没啥关系,而是为了帮服务器识别客户端状态。HTTP 协议本身是无状态的,服务器不知道你上一次请求干了啥,cookie 就是服务器发给浏览器的一张小纸条,浏览器每次请求都会把这张纸条原样塞回给服务器。
这就解释了为什么 cookie 会有 4KB 左右的体积限制,也解释了为什么 cookie 会在请求头里出现。热词里有人问“cookie 是在请求头里吗”,答案是:cookie 既存在于请求头,也存在于 document.cookie。请求头发出去的是给服务器看的,document.cookie 是给 JS 脚本看的,这两者底层是同一份数据,只不过暴露的通道不同。
有两点需要特别注意:第一,cookie 的读写支持跨域名配置,通过 Domain 属性可以控制哪些域名能收到这张纸条;第二,cookie 的存储方向是双向的,服务器也能通过 Set-Cookie 响应头往浏览器里种 Cookie。所以你要是只在前端用 document.cookie 操作它,反而是把这个原生于“前后端协作”的机制给降级成了纯本地存储。
2.2 localStorage 是“永不设限的保险柜”
HTML5 时代给前端带来了 Web Storage,其中 localStorage 是最常用的一个。它的特点非常鲜明:数据持久保存在浏览器里,没有过期时间,除非用户手动清除或者你的代码主动删除,否则它永远在那儿。
它的 API 是同步的,且非常简洁:setItem、getItem、removeItem、clear,这四个方法基本覆盖了全部需求。因为它只存在于浏览器端,不参与网络请求,所以也不会像 cookie 那样拖累请求体积。存储空间一般浏览器会给到 5MB 左右,比 cookie 大得多,但也别真的把它当数据库用。
从开发者的角度,localStorage 就像是给每一个源(协议+域名+端口)分配了一个独立保险柜。你在 http://a.com 存的东西,在 http://b.com 是拿不到的,这个同源限制一定要记牢,否则跨域名联调的时候会一脸懵。
2.3 sessionStorage 是“关标签页就销毁的临时纸片”
sessionStorage 和 localStorage 的 API 几乎一模一样,区别只有一个:生命周期。sessionStorage 的生命周期被限定在一个标签页的会话中,只要标签页还开着,哪怕你刷新页面或者跳转到同源的其他页面,数据都还在;一旦标签页关闭,数据立刻没了。
这意味着 sessionStorage 非常适合存“既然这个会话还没结束,就帮我临时记住”的数据。比如用户在填写一个多步骤表单,每一步的草稿可以塞进 sessionStorage,刷新也不会丢,等他最终提交完或者关掉页面,数据自然清理,不需要你手动管。另外要注意:如果你把一个页面复制成两个标签页,这两个标签页的 sessionStorage 是彼此独立的,复制出来的新标签页会拷贝原标签页的会话数据,但在那之后两者就各走各的了。
3. cookie 的完整操作细节与封装
3.1 读取和写入的真实写法
最原始的 cookie 读写方式其实就是读写字符串,但大多数人从来不直接操作原始字符串,而是封装成工具函数。先用最简单的方式说明原理:
// 写入一个cookie document.cookie = "token=abc123; path=/; max-age=86400"; // 读取全部cookie console.log(document.cookie); // 输出类似 "token=abc123; theme=dark"你会注意到 document.cookie 返回的是一个以分号分隔的字符串,而不是一个对象。而且每次写入一个 cookie 并不是覆盖全部,而是追加或者更新其中某一项。这个特性在封装工具函数时非常关键。
再比如删除一个 cookie,标准做法不是调什么 API,而是把它的过期时间改成过去的时间点:
// 删除cookie document.cookie = "token=; path=/; max-age=-1";3.2 项目实战级 cookie 工具函数
在实际项目中直接写上面这种原生代码很不利于维护,我自己在项目里常用的一套封装长这样,你可以直接拷走用:
const CookieUtil = { // 设置cookie // key: cookie名,value: cookie值,days: 有效天数,path: 生效路径,domain: 域名 set(key, value, days = 7, path = "/", domain = "") { const expires = days ? `; expires=${new Date(Date.now() + days * 864e5).toUTCString()}` : ""; document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent( value )}${expires}; path=${path}${domain ? `; domain=${domain}` : ""}`; }, // 获取指定cookie get(key) { const cookies = document.cookie.split("; ").reduce((res, item) => { // 判断字符串是否包含等号,避免解析到空项 if (item.includes("=")) { const parts = item.split("="); res[decodeURIComponent(parts.shift())] = decodeURIComponent(parts.join("=")); } return res; }, {}); return cookies[decodeURIComponent(key)] ?? null; }, // 删除cookie remove(key, path = "/", domain = "") { this.set(key, "", -1, path, domain); }, }; // 用法 CookieUtil.set("username", "张三", 30); console.log(CookieUtil.get("username")); // 输出张三 CookieUtil.remove("username");这里有两个细节值得说:第一,所有存储的值我都做了 encodeURIComponent 编码,就是为了解决中文乱码问题。热词里专门有“cookie 中文”这个词,实际开发中如果不做编码,中文字符在 cookie 里经常会出现显示异常或者传递失败的情况。第二,get 方法里把等号后面的内容全部 join 回来了,这是为了防止 value 本身包含等号导致解析出错,这个坑我在项目里真实踩过。
3.3 cookie 的属性到底怎么配置才合适
cookie 的属性和权限控制密切相关,我挑几个重点讲:
- Expires / Max-Age:过期时间,前者是具体时间点,后者是相对秒数。不设置的话,cookie 就是会话级的,浏览器一关就没了。
- Domain:控制哪些域名能读取这个 cookie。设为 .example.com 的话,子域名都共享。但千万别随手设成顶级域名,容易引发安全问题。
- Path:控制 cookie 在哪个路径下生效。设成 / 表示全站可用。
- Secure:标记为 Secure 的 cookie 只能通过 HTTPS 协议传输,本地 http 环境下测试时要留意,否则明明种了却看不到。
- HttpOnly:这个是给后端用的,设置之后 JS 的 document.cookie 就读不到这个 cookie 了,能有效降低 XSS 攻击风险。
- SameSite:控制跨站请求时是否携带 cookie。设为 Strict 能防止很多 CSRF 攻击,但也要考虑第三方登录跳转的兼容性问题。
提示:很多时候前端用 cookie 存登录信息,但安全要求高一点的系统都应该让后端把认证 cookie 设置为 HttpOnly,前端只负责后续请求携带它,而不是去读它。这也是为什么你在 chrome 开发者工具里看不到某些 cookie 的原因之一——不是没存,是 JS 被禁止访问了。
4. localStorage 和 sessionStorage 的实操要点
4.1 基础操作与 Storage 事件监听
这两个的 API 是完全一样的,简单列一下:
// 存 localStorage.setItem("token", "abcdef"); // 取 const token = localStorage.getItem("token"); // 删单个 localStorage.removeItem("token"); // 清空 localStorage.clear();sessionStorage 的用法一模一样,把前缀换成 sessionStorage 就行。不过在真实项目中,直接存字符串的场景往往不够用,因为我们经常要存对象。但 localStorage 原生只能存字符串,所以必须手动做序列化和反序列化:
// 存对象 const user = { name: "李四", age: 28 }; localStorage.setItem("user", JSON.stringify(user)); // 取对象 const userStr = localStorage.getItem("user"); const userObj = userStr ? JSON.parse(userStr) : null;另一个容易被忽略的是 Storage 事件。当 localStorage 或 sessionStorage 中的数据发生变化时,浏览器会触发 storage 事件,但有一个限制:这个事件只在其他标签页里触发,触发页面本身是收不到的。
window.addEventListener("storage", (event) => { console.log("变化的key:", event.key); console.log("旧值:", event.oldValue); console.log("新值:", event.newValue); });这个特性的价值在于多标签页同步。比如你在一个标签页里登录了,另一个标签页监听到 localStorage 变化后可以立刻刷新用户信息,不需要依赖复杂的手写消息传递。
4.2 按 id 删除 localStorage 数据的实战写法
热词里有人专门搜“根据id删除localstorage数据”,这在实际开发中确实是高频需求。比如你有一个列表,每个 item 都有自己的 id,你希望按 id 只删对应的缓存,而不是清空全部。一个能够直接落地的写法如下:
// 假设存储的数据格式是 book_123、book_456、user_profile 这种带前缀的key function removeById(targetId) { // 要遍历所有的key Object.keys(localStorage).forEach((key) => { // 判断key是否以book_开头 if (key.startsWith("book_")) { // 从key中提取id部分 const id = key.split("_")[1]; if (id === String(targetId)) { localStorage.removeItem(key); console.log(`已删除: ${key}`); } } }); } // 调用 removeById(123);如果你存储的不是分散的 key,而是一个大数组,那么更合理的做法是整体取出、过滤、再写回:
function removeItemFromList(listKey, itemId) { const raw = localStorage.getItem(listKey); if (!raw) return; const list = JSON.parse(raw); const newList = list.filter((item) => item.id !== itemId); localStorage.setItem(listKey, JSON.stringify(newList)); } // 调用 removeItemFromList("bookList", 123);这两种方式对应两种不同的数据组织结构,第一种适合散列存储,第二种适合集中式数组存储。从可维护性看,我建议大规模业务数据尽量用第二种,统一管理更容易控制。
4.3 localStorage 的容量限制与超限处理
很多人知道 localStorage 有 5MB 限制,但没想过超限之后会发生什么:setItem 会直接抛出一个 QuotaExceededError 异常。如果你不捕获它,后果是页面脚本中断,后续代码全部不执行。所以在写入关键数据时,我习惯做一个安全封装:
function safeSetItem(key, value) { try { localStorage.setItem(key, value); } catch (e) { // 常见情况是超出容量限制 console.error("存储失败:", e); // 这里可以做一些降级处理,比如裁剪老数据 pruneOldData(); } }容量测试的快速方法:写一个循环往里面塞字符串,直到报错为止,看看你的浏览器到底给了多少空间。不同浏览器结果不一样,别指望 5MB 是统一的硬指标。
5. 三个存储方案的选型对比
5.1 从使用场景反推技术选型
到现在为止,我已经把三个存储方案的核心机制和 API 都过了一遍。但真正到项目里,你不可能只看 API 就决定用哪个,还是得看场景需求。我把我的选型经验整理成了下面的对比表,配合具体场景来解读:
| 维度 | cookie | localStorage | sessionStorage |
|---|---|---|---|
| 容量 | 约4KB | 约5MB | 约5MB |
| 生命周期 | 可设置过期时间或会话级 | 持久保存,无过期 | 关闭标签页即失效 |
| 参与请求 | 自动携带在请求头中 | 不参与请求 | 不参与请求 |
| 作用域 | 可跨子域名共享 | 同源窗口共享 | 当前标签页独享 |
| 存储类型 | 字符串(需编码) | 字符串(可序列化) | 字符串(可序列化) |
| 主要用途 | 会话标识、用户追踪 | 长期缓存、偏好设置、离线数据 | 临时表单、页面状态、会话内草稿 |
| 安全风险 | 易受XSS/CSRF攻击 | 易受XSS攻击 | 易受XSS攻击 |
5.2 具体场景下的选择建议
如果我遇到下面这些情况,通常是这样选的:
存登录凭证 token:优先让后端设置 HttpOnly cookie,前端只负责访问接口时自动携带。如果后端比较老不支持,退而求其次存 localStorage,然后每次请求前手动附上 Authorization 头。千万不用 sessionStorage,用户一开新标签页就掉线,体验太差了。
存购物车数据:用 localStorage。购物车往往是跨会话、跨页面的,用户关掉浏览器再打开还希望能看到。而且购物车数据量不大,5MB 绰绰有余。如果多标签页购物车需要实时同步,配合前面说的 storage 事件就能实现。
存表单草稿:用 sessionStorage。用户填到一半刷新了、误关了标签页导致内容丢失,这体验确实不好。但你又不想让草稿永久留在浏览器里,这时候 sessionStorage 就是最合适的方案。
存用户主题偏好、语言设置:用 localStorage。这是典型的长期偏好,打开页面时读取一次,设置后永久生效。
存页面试探性状态,比如某个提示是否展示过:用 localStorage 还是 sessionStorage,取决于“是否希望下次打开浏览器还记住”。记住就用前者,仅仅本次会话记住就用后者。
6. 经典问题排查与踩坑记录
6.1 开发者工具里看不到 cookie,怎么回事?
有人搜“chrome开发者工具没有cookie”,这个问题我给自己排查过好多次。常见原因有三个:
- cookie 设置了 HttpOnly,Application 面板的 Storage -> Cookies 里能看到,但 JS 没法读到,不属于前端逻辑的问题。
- cookie 被 SameSite 或者 Secure 属性拦了,本地用 http 测试 Secure cookie 时,登录接口种的 cookie 根本不会落下来。
- 你使用了隐私模式或者第三方插件把所有 cookie 禁掉了。
排查思路很简单:打开开发者工具的 Network 面板,看请求的响应头 Set-Cookie 或请求头 Cookie,如果这里能看到就说明 cookie 机制正常工作,只是你看的位置不对。问题往往出在 Domain、Path、Secure 这些属性上,逐项检查即可。
6.2 cookie 为什么没生效?
这类问题最常发生在跨域场景。你在 a.example.com 种了一个 Domain 为 a.example.com 的 cookie,然后在 b.example.com 去请求,它当然不会带。所以判断 cookie 是否该带上的时候,第一件事就是看当前访问的域名和 cookie 的 Domain 是否匹配。
另一个高频问题是前端种 cookie 时没写 Path,导致默认 path 为当前页面路径。比如当前页面是 /admin/login,种下的 cookie 只在 /admin/login 路径下有效,你跳到 /admin/dashboard 就没了。所以前端种 cookie 时必须显式设置 path=/,这是我在项目里踩过最多次的坑之一。
6.3 js 怎么判断字符串是否包含关键字?
这个热词和存储本身没直接关系,但和 localStorage 的前缀匹配、cookie name 解析确实有关联。JavaScript 里判断字符串包含一般有三种方式:
const str = "book_123_notes"; // 方式一:includes,ES6引入,最常用 const hasPrefix = str.includes("book_123"); // 方式二:indexOf,老项目里常见 const idx = str.indexOf("book_123"); if (idx !== -1) { // 包含 } // 方式三:正则表达式 const hasMatch = /book_123/.test(str);includes 语义最清晰,indexOf 兼容性最好,正则适合复杂模式匹配。结合我们前面写的 removeById 函数,判断 localStorage 中哪些 key 需要删除,本质上就是判断 key 字符串是否包含目标模式。
6.4 容量超限和数据污染问题
localStorage 虽然容量比 cookie 大,但很容易被人忽略的是它并没有自动清理功能。长期跑下来的业务,如果不做版本管理,数据格式一变就会出问题。比如你之前存的是数组,现在改成对象了,旧的缓存结构读出来之后 JSON.parse 可能报错或者解析出无法识别的结构。
所以我在项目里会给缓存加一个版本号后缀:user_v1、user_v2,读取时只认当前版本,旧版本直接删除重写。这是一种非常廉价但有效的缓存迁移策略。另外,凡是读取 localStorage 的操作全都要包一层 try-catch,因为用户可能手动修改数据、第三方脚本可能篡改数据,所有不可信的输入都应当被防御性处理。
7. 封装一套通用本地存储工具
基于上面这些经验,我在项目里经常会维护一套统一封装,把 localStorage 和 sessionStorage 的常用操作集中管理,同时自动处理 JSON 序列化和异常捕获。这套方案本身就是我从这个项目中沉淀下来的最大收获。
const StorageEngine = { // 带过期时间的本地存储 // key: 存储键,value: 存储值,expires: 过期时间(毫秒),默认7天 set(key, value, expires = 7 * 24 * 3600 * 1000) { const payload = { data: value, expires: Date.now() + expires, }; localStorage.setItem(key, JSON.stringify(payload)); }, // 读取,如果过期返回null并自动清理 get(key) { const raw = localStorage.getItem(key); if (!raw) return null; try { const payload = JSON.parse(raw); if (payload.expires && Date.now() > payload.expires) { localStorage.removeItem(key); return null; } return payload.data; } catch (e) { // 解析失败,直接清理脏数据 localStorage.removeItem(key); return null; } }, // 删除 remove(key) { localStorage.removeItem(key); }, }; // 用法 StorageEngine.set("userProfile", { name: "王五" }, 3600 * 1000); const profile = StorageEngine.get("userProfile");这套封装里把“带过期时间”这个能力做进去了,弥补了 localStorage 没有过期机制的短板。很多业务数据本质上是有时效的,比如活动配置、接口缓存、临时令牌,手动打时间戳太麻烦,封装一层就一劳永逸。
注意:这里的过期时间是前端软逻辑,用户清缓存或者换设备后依然无效。真正对安全有要求的场景,时效校验必须靠后端而不是前端。
还有个加分项:尽量把所有的存储 key 统一收敛到一个常量文件里,避免散落在各个业务文件中。比如:
export const STORAGE_KEYS = { TOKEN: "access_token", USER_INFO: "user_info_v1", CART_LIST: "cart_list_v2", THEME_MODE: "theme_mode", };统一管理的核心价值在于:你以后想改某个 key 的命名规则、想升级缓存版本、想统计哪些数据被存了,都只需要改一处就好。
最后再说一个个人经验:虽然 localStorage 和 sessionStorage 用起来非常简单,但在高安全场景(如支付、个人隐私数据)里要慎重,任何存到前端的敏感数据都有可能被 XSS 窃取。我在实际开发里的底线是:密码绝不存前端,token 尽量走后端 HttpOnly cookie,非敏感的用户偏好才放 localStorage。把握好这条线,你在这三个方案上的知识基本就过关了。