Cookie、localStorage、XSS 这三个东西凑在一起从来不是课程大纲随手排的。前端做到一定阶段你会发现它们其实是一条链上的三个环节Cookie 是浏览器最早给出的状态记忆方案localStorage 是后来补上的大容量本地仓库而 XSS 则是把这两者同时捅穿的那把刀。我在带新人的时候反复强调一句话——你选择把什么数据放在哪里直接决定了攻击者拿到一个注入点之后能造成多大的破坏。很多人学存储只记住 API 怎么调等到线上出了登录态丢失、跨标签页不同步、被安全扫描报高危才开始回头补这一课。这篇内容面向的是已经能写页面、正在往能扛事阶段走的前端也适合后端同学补一下浏览器侧的知识盲区。我会把 Cookie 的字段、localStorage 的脾气、XSS 的触发链路拆开讲中间穿插我自己踩过的坑和一套可以直接抄走的封装。不讲空话每个结论都会说清楚为什么是这样。读完之后你至少能做到两件事一是遇到 Cookie 不生效能按顺序排查而不是乱试二是知道登录凭证该放哪、为什么不能放 localStorage。1. 先把三种存储的边界划清楚1.1 Cookie 不是存储方案它是 HTTP 状态的一种补丁很多人一开始就把 Cookie 理解成浏览器的小仓库这个认知会让你后面所有判断都跑偏。Cookie 诞生的根本原因是 HTTP 协议本身是无状态的——服务端处理完一个请求就把你忘了下一个请求过来它根本认不出你是谁。为了让服务端能记住用户才有了 Cookie 这套机制服务端通过响应头Set-Cookie下发一小段键值对浏览器存下来之后每次符合规则的请求都自动带上。注意每次和自动这两个词它们决定了 Cookie 的全部优缺点。优点是省事你不需要在业务代码里手动带上身份凭证缺点是它无条件参与请求哪怕你只是请求一张图片、一个静态 JS 文件只要同源同路径Cookie 就会跟着走。这意味着 Cookie 里的每一个字节都在消耗带宽。我见过一个项目在 Cookie 里塞了用户权限列表的 JSON压缩前接近 3KB结果首页几十个静态资源请求全部带着它光这一项就把首屏时间拖慢了两百多毫秒。所以 Cookie 的正确定位是为 HTTP 补上状态的小型、高频、自动携带的信使而不是数据仓库。它天生适合放会话标识这种短小、需要服务端每次都能看到的东西。你把用户偏好、草稿、缓存列表往里塞本质上是在用一把螺丝刀当锤子使。1.2 localStorage 与 sessionStorage 的定位差异localStorage 是 HTML5 时代补上的能力它的设计目标和 Cookie 完全不同纯客户端、大容量、不参与网络请求。数据只在 JS 里读写浏览器不会自动把它发给任何人。这个特性带来两个直接后果——一是它不会拖慢请求二是服务端永远看不到它除非你手动读出来放进请求体。sessionStorage 和 localStorage 的 API 一模一样区别只在生命周期和作用域sessionStorage 绑定在标签页会话上关掉这个标签页数据就没了但刷新页面数据还在因为刷新不算新会话。这个细节很多人记混实际调试时经常有人问为什么我刷新之后 sessionStorage 还在答案就是它本来就该在。我自己总结的选型直觉是这样的如果这份数据服务端必须知道而且每次请求都得知道用 Cookie如果这份数据只服务当前这一次浏览行为比如多步表单的中间态、单页应用的路由滚动位置用 sessionStorage如果这份数据要跨会话存活比如主题偏好、草稿、最近搜索记录用 localStorage。如果数据量大到几 MB 甚至几十 MB比如离线图片缓存、大量结构化记录那这三个都不合适该上 IndexedDB。1.3 一张对照表把选型定下来光说概念容易忘我给你整理了一张表实际开发中拿不准的时候直接对照。维度CookielocalStoragesessionStorage容量上限单个约 4KB约 5MB各内核实现有差异同 localStorage生命周期由 Expires / Max-Age 决定不设则为会话级永久除非主动删除标签页关闭即清空是否随请求发送是符合 Domain/Path 的请求自动携带否否访问方式document.cookie受 HttpOnly 限制localStorage.getItem等同 localStorage作用域同源 Domain Path同源且同浏览器同配置文件同源 同标签页跨标签页共享是是否同步 / 异步同步同步同步主要风险CSRF、被 XSS 读取未设 HttpOnly 时XSS 可直接读取全部内容同 localStorage典型用途会话 ID、轻量偏好主题、草稿、非敏感缓存表单中间态、页面级状态表格里最容易被忽略的是最后两行。Cookie 有 HttpOnly 这个浏览器级别的保险柜而 localStorage 没有——只要页面里能跑攻击者的脚本localStorage 里的东西就是明文摆在桌上。这一点我们在第 4 章会展开但你现在就该记住这个结论localStorage 的安全边界完全等同于你页面的 XSS 防御水平。2. Cookie 的细节拆解那些文档一句话带过的东西2.1 一个 Set-Cookie 头里到底能写什么服务端下发 Cookie 的标准写法是这样的Set-Cookie: sessionId8f3a9c2e; Path/; Domainexample.com; Max-Age7200; HttpOnly; Secure; SameSiteLax分号后面这些叫属性Attribute它们不是数据而是告诉浏览器怎么存、什么时候发、能不能被 JS 看见。这里有个非常关键、但初学者经常搞错的点HttpOnly 只能由服务端通过 Set-Cookie 设置前端用document.cookie是写不出 HttpOnly 的。你在控制台里敲document.cookie a1; HttpOnly浏览器会直接忽略这个属性。原因很直白——如果 JS 能给自己写的 Cookie 打上 HttpOnly那 HttpOnly 就毫无意义了攻击者注入脚本之后第一件事就是把自己的窃取标记设成 HttpOnly。所以这个开关必须牢牢握在服务端手里。理解这一点你就明白为什么前端设置 HttpOnly这种需求本身就是矛盾的遇到只能改后端。关于过期时间Expires和Max-Age都能用但Max-Age优先级更高。Expires是一个绝对时间点依赖客户端时钟Max-Age是相对秒数由浏览器自己算。用户电脑时间不准的情况比你想象的常见尤其是一些没联网校准的设备所以生产环境我更倾向用Max-Age。想立刻删除一个 Cookie就把Max-Age设成 0 或者负数。2.2 Domain 与 Path决定发不发而不是存不存这两个属性最容易产生误解。它们的作用不是限制能不能读而是限制什么时候发。浏览器在发起请求前会做一次匹配请求的目标域名是否匹配 Cookie 的 Domain请求的路径是否匹配 Cookie 的 Path两个都通过才会把 Cookie 加进请求头。Domain 的规则是不设置时Cookie 只对当前域名生效不包含子域名。如果显式设置成.example.com带不带前导点现在效果一样那么a.example.com和b.example.com都能共享。这个机制常被用来做单点登录——主站登录后把会话 Cookie 的 Domain 设成父域各子站就都能拿到。但它也是一把双刃剑任何一个子域被攻破攻击者都能往父域写 Cookie进而影响所有子站。所以如果你的业务里有一些安全性较弱的子域比如用户自定义内容的展示域千万别把核心会话 Cookie 的 Domain 放到父域上。Path 的规则更微妙。默认值是当前请求路径的目录部分比如从/admin/user/list下发的 Cookie默认 Path 是/admin/user。匹配规则是前缀匹配/admin能匹配/admin和/admin/xxx但/admin匹配不了/administrator——浏览器会按路径段来比不是纯字符串前缀。这个细节决定了你把管理后台的 Cookie 限制在/admin路径下是可行的能减少它在公开页面请求中的暴露面。2.3 HttpOnly、Secure、SameSite 三件套这三个属性是 Cookie 安全的主干值得单独拎出来说清楚各自防的是什么。HttpOnly防的是 XSS 读取。设上之后document.cookie里就看不到这个 Cookie 了只有网络请求会自动带上。它不能阻止 XSS 冒用身份发起请求脚本可以fetch一个带凭证的接口但能阻止攻击者把凭证偷走带到别的地方长期使用。这个区别很重要前者是在你家偷东西后者是配了你家钥匙带走。后者危害大得多。Secure防的是明文传输泄露。设上之后这个 Cookie 只在 HTTPS 连接中发送。在 HTTP 页面上它不会被发出去本地开发时用http://localhost调试经常会遇到Cookie 设了但不生效八成就是忘了这个属性在非 HTTPS 环境下会被拦。注意localhost在多数浏览器里被当作安全上下文特例处理但局域网 IP 访问就不是了这个坑我踩过不止一次。SameSite防的是跨站请求伪造CSRF。它控制跨站请求时要不要带 Cookie有三个值取值行为适用场景Strict任何跨站请求都不带安全性要求极高的操作但会让从外部链接点进来时显示未登录Lax跨站的顶层 GET 导航带上其余不带大多数业务站点的默认选择None总是带上但必须同时设Secure需要被第三方站点嵌入或跨站调用的场景主流 Chromium 内核从较早的版本开始就把未显式声明 SameSite 的 Cookie 默认按Lax处理了。这个变化当年掀翻了一批iframe 里嵌第三方页面导致登录态丢失的项目。如果你的系统需要被别的站点用 iframe 嵌入或者要给第三方站点提供需要登录态的接口就必须显式写SameSiteNone; Secure光写 None 不写 Secure 会被浏览器直接拒绝。2.4 前端操作 Cookie 的封装前端的 Cookie API 设计得相当反人类——document.cookie读的时候给你一整串a1; b2写的时候又只能一次写一个而且没有删除方法只能靠设过期。所以我一般会在项目里放一层薄封装// cookie.js export function setCookie(name, value, options {}) { const { days, path /, domain, secure location.protocol https:, sameSite Lax } options; let str ${encodeURIComponent(name)}${encodeURIComponent(value)}; if (typeof days number) { const expires new Date(Date.now() days * 86400000); str ; Expires${expires.toUTCString()}; } str ; Path${path}; if (domain) str ; Domain${domain}; if (secure) str ; Secure; if (sameSite) str ; SameSite${sameSite}; document.cookie str; } export function getCookie(name) { const target ${encodeURIComponent(name)}; // 不同内核分隔符可能是 ; 也可能是 ;统一用正则切 const parts document.cookie.split(/;\s*/); for (const part of parts) { if (part.startsWith(target)) { return decodeURIComponent(part.slice(target.length)); } } return null; } export function removeCookie(name, options {}) { setCookie(name, , { ...options, days: -1 }); }这段代码里有三个实战细节值得说。第一encodeURIComponent必须加Cookie 的值里不能出现分号、逗号、空格这些字符中文更是必须编码否则会被截断。第二读取时用正则切分而不是固定; 因为我确实遇到过某些环境下分隔符不带空格的诡异情况。第三removeCookie必须传和写入时完全一致的path和domain否则删不掉——因为它匹配的是另一条记录这是排查Cookie 删不掉问题的第一嫌疑。还有一点前端能写的 Cookie 属性是有限制的HttpOnly写不了前面说过Secure在 HTTP 页面下写了也无效。所以如果你的登录态需要 HttpOnly 保护就必须走服务端下发的路径前端的封装只适合处理一些非敏感的场景标记。3. localStorage 的实操细节3.1 API 只有几个方法坑却不少localStorage 的 API 简单到只有setItem、getItem、removeItem、clear和key但它有几个反直觉的行为不踩过根本想不到。第一个是所有值都会被转成字符串。你存一个数字1取出来是字符串1存一个对象取出来是[object Object]。我第一次遇到这个问题时调试了半小时最后发现是忘了序列化。所以实际项目中我基本不直接用原生 API全部包一层 JSON 序列化。第二个是读取不存在的键返回null而不是undefined。这看起来是小事但如果你写if (localStorage.getItem(token))没问题写if (localStorage.getItem(count) 0)就会因为null 0为 false 而踩坑。第三个是存储的是 UTF-16 字符串所以一个字符可能占两个字节容量估算时不能简单按字节算。这也是为什么有时候你算着只存了 3MB却提示超限。第四点是它是同步阻塞的。这一点最容易被忽视。localStorage.setItem写大数据时会阻塞主线程JSON 序列化一个大对象同样阻塞。我做过一个粗略的测试在普通办公本上序列化并写入一个 2MB 的 JSON耗时大概在几十毫秒级别这个时间足够让一个动画掉帧。所以千万不要在滚动事件、requestAnimationFrame回调里做 localStorage 写入。3.2 容量、超限与数据损坏容量上限大约 5MB但这个数字并不可靠。不同内核实现不一样同一内核在不同平台也可能不同而且它统计的是字符串长度而不是实际字节。我给自己的规矩是单个键的值不超过 500KB总量控制在 2MB 以内留足余量。超限时会抛QuotaExceededError这是一个必须捕获的异常否则整个业务流程会直接中断。很多人在写入时不做 try-catch等到用户存了几百条草稿之后突然报错用户体验非常糟糕。function safeSet(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (err) { // 不同浏览器的 name / code 不完全一致做个兜底判断 const isQuota err.name QuotaExceededError || err.name NS_ERROR_DOM_QUOTA_REACHED || err.code 22; if (isQuota) { console.warn(本地存储空间不足建议清理旧数据); // 这里可以触发一次清理策略比如删掉最早的草稿 } return false; } }另一个坑是数据损坏。localStorage 是纯字符串存储用户手动改、插件乱写、版本升级后结构变化都可能让JSON.parse抛错。我现在的习惯是解析失败就当数据不存在直接删掉重新初始化而不是让异常往上冒。上面的store.get里那个try/catch就是这个思路。3.3 用 storage 事件做跨标签页同步用户开了两个标签页在 A 页退出登录B 页还显示着登录状态——这是很典型的体验问题。解决办法就是storage事件。window.addEventListener(storage, (event) { // 只在同源的其他标签页修改时触发当前页面自己改不会触发 if (event.key app:login-state) { const next event.newValue ? JSON.parse(event.newValue) : null; if (!next) { // 别处退出了这边也跳回登录页 location.replace(/login); } } });这个事件有几个必须记住的特性否则你会以为它坏了。第一它只在其他标签页修改时触发修改数据的那一方自己不会收到这是为了防止死循环。第二event.key在你调用clear()时是null需要单独判断。第三它传递的是字符串快照newValue和oldValue都是原始字符串需要自己解析。第四它是异步派发的不保证顺序。最后一个特性坑过不少人如果你要同步的是复杂状态光靠storage事件传递字符串会有精度和顺序问题这时候更稳妥的做法是只传一个版本号或事件信号让对方收到信号后自己去读最新数据。3.4 分层什么该放什么不该放我把 localStorage 里的内容按敏感度分了三层这个分类习惯帮我在做安全评审时省了很多事。第一层是完全公开的数据比如主题色、语言、折叠面板的展开状态、列表页的排序方式。这类数据泄露了没有任何影响可以放心放。第二层是有隐私属性但不致命的比如搜索历史、浏览记录、未提交的草稿。这类数据泄露会让用户不舒服但不至于造成账号损失。存放时可以做一层简单混淆不是加密只是防止用户直接看懂并且设置合理的过期时间。第三层是绝对不能放的包括登录凭证、会话标识、支付相关信息、身份证件号、密码、密钥。这类数据一旦通过 XSS 泄露就是事故级别。为什么不能放因为它没有任何浏览器级别的保护页面里只要有一行攻击者能控制的脚本整个 localStorage 就是可读的JSON.stringify(localStorage)一行代码全部拿走。很多人会反驳说我可以加密啊。这里我要泼一盆冷水前端加密的密钥也在前端攻击者既然能执行脚本就能拿到你的密钥也能直接 hook 你的解密函数。前端加密对 XSS 几乎没有防护价值它只能防用户手动打开控制台看防不了真正的攻击。**真正的解法是不放或者放到 HttpOnly Cookie 里让 JS 根本读不到。**这个思路上的差别比任何加密算法都重要。4. XSS存储选型被拉下水的根源4.1 XSS 的三种形态与触发链路XSS跨站脚本的本质是攻击者让本不属于页面的脚本在受害者的浏览器里执行并且享有与页面同等的权限。注意同等权限这几个字这是它破坏力的来源——脚本能读 localStorage、能读非 HttpOnly 的 Cookie、能发起带凭证的请求、能改页面内容、能监听键盘输入。按注入位置和持久性通常分三类反射型是攻击载荷放在 URL 参数里服务端原样回显到页面。用户点了构造好的链接脚本就执行了。这种不持久需要诱导点击。存储型是载荷被存进了数据库比如评论、昵称、商品标题所有看到这个内容的用户都会中招。这是危害最大的一种因为它不需要诱导只要有浏览量就会持续传播。DOM 型比较特殊整个链条都发生在前端服务端完全不参与。典型场景是从location.hash、location.search里取值然后直接塞进innerHTML。我见过一个内部工具用 hash 来做搜索关键词高亮代码大概是container.innerHTML text.replace(kw, mark kw /mark)看起来人畜无害实际上如果 hash 里塞了带onerror的标签直接就是可执行的。三类里反射型和 DOM 型的入口通常在前端可控的输入点存储型的入口在服务端渲染的输出点。但它们能造成的后果是一样的因为最终都是在同源上下文里执行。4.2 为什么 localStorage 里的凭证特别危险回忆一下 Cookie 的三件套HttpOnly 让 JS 读不到SameSite 限制跨站发送Secure 限制明文传输。localStorage 一个都没有。这意味着两件事。第一XSS 一旦成立localStorage 就是全量泄露不需要任何额外条件。攻击者甚至不用知道你的键名遍历一遍就行。第二攻击者能带着这份凭证离开你的站点在任意时间、任意网络环境下使用。Cookie 有 Domain 限制偷走了也只能在对应域用而一个 token 字符串拿走后攻击者可以在自己的机器上直接调接口。我做威胁建模时经常用一个类比来解释这个差别把会话 Cookie带 HttpOnly比作门禁卡它只能刷你家这栋楼的门而且你不能把卡复制带走只能站在门口用把 localStorage 里的 token 比作写着你身份证号的纸条谁拿到纸条谁就能去任何地方冒用。如果你的架构是前后端分离token 只能放前端的话那弥补方案不是换个地方存而是缩短 token 有效期、加刷新机制、加设备指纹绑定、加异常行为检测。存哪儿只是第一层这些才是真正降低损失的手段。4.3 一个最小复现与它的边界为了说明问题我在本地起了一个静态页面做演示不涉及任何真实站点。!-- 危险示例仅用于本地理解原理 -- div idpreview/div script // 模拟用户输入的评论内容被直接插入 const userInput img srcx onerrorconsole.log(\script executed\); document.getElementById(preview).innerHTML userInput; /script打开控制台会看到script executed被打印出来。这里有个很多人不知道的细节通过 innerHTML 插入的script标签是不会执行的但img onerror、svg onload、iframe srcdoc这类通过事件属性触发的载荷会执行。所以我用 innerHTML 但没看到 alert应该是安全的这个判断是完全错误的只是你碰巧用了不触发的那一种。修复方式很简单如果内容就是纯文本document.getElementById(preview).textContent userInput;textContent会把所有字符当作文本处理会被转义成lt;不会解析成标签。如果确实需要富文本比如允许加粗、链接那就不能靠textContent了得走白名单过滤。原理是解析出一棵 DOM 树只保留允许的标签和属性其余全部丢弃。常见做法是用成熟的净化库而不是自己写正则——用正则过滤 HTML 是一个经典的反模式因为 HTML 的语法太灵活总有你没想到的绕过方式比如标签嵌套、属性名变形、编码绕过等等。另一个高频场景是链接。下面的写法要避免// 危险没有校验协议 link.href userProvidedUrl;因为userProvidedUrl可以是javascript:alert(1)。正确做法是解析后校验协议只放行http:、https:相对路径单独处理。function safeUrl(raw) { try { const u new URL(raw, location.origin); if ([http:, https:].includes(u.protocol)) return u.href; } catch (e) { /* 解析失败当非法 */ } return #; }4.4 防御的层次编码、CSP、HttpOnly、校验XSS 防御不是一招制敌是分层设防。我把它们按挡住的比例和成本排了个序实际项目里建议至少做到前三层。第一层是输出编码。核心原则是数据在进入 HTML 上下文时转义而不是在输入时过滤。为什么因为同一份数据可能进入 HTML、进入 JS 字符串、进入 URL、进入 CSS不同上下文的转义规则完全不同。在输入时统一过滤要么过滤不干净要么把合法字符也干掉了。现代框架各类组件化框架的模板语法默认就会做这件事所以不要用框架提供的跳过转义的逃生口去拼接用户内容这是最常见的自毁行为。**第二层是 CSP内容安全策略。**它是一个响应头告诉浏览器这个页面只允许加载哪些来源的脚本。配置得当的话即使攻击者成功注入了脚本标签浏览器也会拒绝执行。Content-Security-Policy: default-src self; script-src self; object-src none; base-uri self; frame-ancestors self这里的关键是尽量不要用unsafe-inline。一旦加上它内联脚本可以执行CSP 的防护就基本失效了。如果必须用内联脚本比如一些统计代码可以用 nonce 机制服务端每次渲染时生成一个随机串同时写进 CSP 头和 script 标签的nonce属性只有匹配的才执行。上线 CSP 的正确姿势是先用Content-Security-Policy-Report-Only跑一段时间收集违规报告把业务里实际的资源来源摸清楚再切换成强制模式。直接上强制模式大概率会把线上打挂我就见过因为漏配了某个 CDN 域名导致整站样式全崩的事故。第三层是 Cookie 的 HttpOnly。它不阻止 XSS 执行但能保证会话凭证不被偷走。这是纵深防御里性价比最高的一条——加一个属性成本几乎为零收益是即使被 XSS账号也不会被长期接管。第四层是输入校验和业务限制。比如评论内容长度限制、富文本白名单、上传文件类型校验。这些不能替代输出编码但能缩小攻击面。还有一条经常被忽略依赖治理。很多 XSS 是通过第三方组件、第三方脚本引入的。一个能随意加载任意远程脚本的页面等于把安全边界交给了别人。我现在的习惯是给所有外链脚本加integrity校验如果对方支持、限制加载来源、定期核查依赖版本。5. 常见问题与排查实录5.1 Cookie 不生效的排查顺序这是我这几年被问得最多的一类问题。我给你一个固定的排查顺序按这个走基本五分钟内能定位。第一步看响应头有没有 Set-Cookie。打开网络面板找到那个请求看 Response Headers。如果没有问题在服务端跟前端没关系。这一步能筛掉一半的问题。第二步看浏览器有没有真的存下来。在应用面板的 Cookie 列表里找。如果没有但响应头有通常是这几个原因Domain写成了当前域不匹配的值Path和当前页面路径不匹配Secure属性在 HTTP 页面下被拒SameSiteNone但没加SecureCookie 名或值里有非法字符。第三步看后续请求有没有带上。存下来了但请求不带重点查这三项跨域请求是否设置了credentials: include服务端的Access-Control-Allow-Origin是否是具体域名而非*带凭证时不允许通配符服务端是否返回了Access-Control-Allow-Credentials: true。第四步看是不是被覆盖或删除了。同名同 Domain 同 Path 的 Cookie 会互相覆盖如果两个地方用了同一个名字后写的会顶掉前面的而且属性也会被一起覆盖。我把这些整理成了一张速查表现象最可能的原因处理方式响应头有 Set-Cookie面板里没有Domain / Path 不匹配改成当前域名或不设 DomainHTTP 环境设不上Secure 属性本地开发去掉 Secure 或用 HTTPSiframe 里登录态丢失SameSite 默认 Lax显式设SameSiteNone; Secure跨域请求不带 Cookie前端未开 credentialsfetch 加credentials: include跨域请求不带 Cookie服务端 CORS 配置不对Origin 具体化 Allow-Credentials删除不掉Path / Domain 不一致删除时传相同属性值被截断含特殊字符未编码用 encodeURIComponent偶发丢失超过 4KB 被丢弃拆分或改用其他存储5.2 localStorage 的典型故障localStorage 的问题集中在三类。第一类是读取失败或数据为 null。常见原因有三个键名拼错这个真的很高频尤其是团队里不同人用了不同命名风格在隐私模式下被限制部分浏览器的无痕模式会给一个受限的 localStorage写入直接抛错跨子域访问localStorage 严格按源隔离a.example.com和b.example.com是两个完全独立的存储空间不共享。第三个原因经常有人搞混以为和 Cookie 的 Domain 一样能配置实际上 localStorage 没有任何跨域共享机制。第二类是配额超限。症状是某些用户的草稿保存失败但你自己测试时一切正常。原因是老用户积累的数据多。排查方法是在控制台统计一下各键的长度Object.keys(localStorage) .map(k ({ key: k, size: (localStorage[k] || ).length })) .sort((a, b) b.size - a.size) .slice(0, 10);这样能一眼看出是谁把空间吃掉了。处理上要么加清理策略LRU 淘汰要么把大数据迁到 IndexedDB。第三类是同步阻塞导致的卡顿。症状是页面在某些操作后明显掉帧。判断方法是打开性能面板录一段看主线程上有没有一个长长的任务调用栈指向setItem或JSON.stringify。解决办法是把大写入拆分成小块或者用requestIdleCallback挪到空闲时段更彻底的是换成 IndexedDB 的异步写。还有一种隐蔽的情况多个标签页同时写同一个键后写的覆盖先写的。localStorage 没有并发控制如果你的应用允许多开就需要给数据加上版本号或时间戳读取时做一下合并判断否则用户的编辑内容会莫名其妙丢失。5.3 我踩过的几个具体的坑说几个印象深的。有一次做活动页需要在用户点击后跳转到登录登录完再跳回来中间用一个标记记录来源。我把这个标记存在了 sessionStorage 里本地测试一切正常。上线后部分用户反馈跳回来之后落到首页了。查了半天才发现登录流程里有一个环节会新开窗口新窗口是新会话sessionStorage 是空的。换成 localStorage 并加了过期时间就好了。这件事让我记住了只要流程里可能出现新窗口或外部跳转就别指望 sessionStorage。还有一次是 Cookie 删不掉。前端调removeCookie(token)没反应我一开始怀疑是浏览器缓存后来发现是因为写入时 Path 是/admin删除时没传 Path默认成了/浏览器认为是两个不同的 Cookie。这类问题最好的预防方式就是把 Cookie 的读写删都收在一个模块里属性配置集中管理绝不在业务代码里裸写document.cookie。最后一个比较典型的是 XSS 扫描报了一个高危位置在一个用户昵称的展示上。我看代码明明用了模板语法应该是自动转义的。追下去发现是某个组件为了做高亮效果用了innerHTML加手动替换。这个例子说明框架的自动转义只在它控制的范围内有效任何一次手动操作 DOM 都是一次自我豁免。后来我们加了一条团队规则代码评审时看到innerHTML、outerHTML、insertAdjacentHTML、document.write这几个关键词就自动触发安全 review效果不错。6. 我在项目里沉淀下来的一套习惯6.1 登录态方案怎么选按项目形态分我一般这样决定。如果是传统的服务端渲染项目或者前后端同域部署首选HttpOnly Secure SameSiteLax 的会话 Cookie。这是安全性最好、实现成本最低的方案。前端完全不需要关心 token 存在哪也不用怕 XSS 偷凭证。如果是前后端分离、跨域部署没法共享 Cookie那就只能走 token。这时候我会做这几件事token 有效期压到 15 到 30 分钟配 refresh tokenrefresh token 走 HttpOnly Cookie如果条件允许或者放在独立域名下localStorage 里只放非敏感的用户信息快照比如昵称、头像、主题。如果对安全要求特别高比如涉及资金操作那就加二次验证关键操作要求重新输入密码或者走独立验证通道不依赖长期凭证。这个思路的本质是承认前端存储不可信用流程来兜底。我还想强调一点不要把是否登录的判断完全交给前端存储。前端的登录态只是 UI 展示用的真正的权限校验必须在服务端每个接口上做。我见过太多项目前端判断了角色就以为安全了结果接口裸奔改个 localStorage 就能进管理页面。这个坑非常致命。6.2 一层薄封装收益远超成本我现在每个项目的utils里都会有几个固定文件cookie.js、storage.js、security.js。它们的作用不是炫技而是把所有容易写错的地方收口到一处。storage.js的职责是统一加前缀避免键名冲突、自动 JSON 序列化、支持过期时间、捕获配额异常、提供带类型的读写接口。const PREFIX app:; export const store { set(key, value, ttlMs) { const payload { v: value, t: Date.now(), e: ttlMs ? Date.now() ttlMs : 0 }; try { localStorage.setItem(PREFIX key, JSON.stringify(payload)); return true; } catch (err) { console.error([storage] 写入失败, key, err); return false; } }, get(key, fallback null) { const raw localStorage.getItem(PREFIX key); if (!raw) return fallback; try { const payload JSON.parse(raw); if (payload.e Date.now() payload.e) { localStorage.removeItem(PREFIX key); return fallback; } return payload.v; } catch (err) { // 数据损坏直接清掉避免反复报错 localStorage.removeItem(PREFIX key); return fallback; } }, remove(key) { localStorage.removeItem(PREFIX key); }, // 清理过期项可以在应用启动时跑一次 purgeExpired() { Object.keys(localStorage) .filter(k k.startsWith(PREFIX)) .forEach(k { try { const payload JSON.parse(localStorage.getItem(k)); if (payload payload.e Date.now() payload.e) { localStorage.removeItem(k); } } catch (e) { localStorage.removeItem(k); } }); } };加前缀这个事看着不重要但我真切地遇到过第三方脚本用了一个很通用的键名比如config和我们自己的数据撞车导致配置莫名其妙被改。加个前缀两秒钟的事能省掉一下午的排查。security.js的职责是提供一个可靠的转义函数和一个 URL 白名单校验函数供那些确实需要手动操作 DOM 的地方调用。虽然理论上不该有这种地方但现实项目里总有一些历史代码给它们一个正确的工具比要求所有人重写要现实得多。6.3 几条我当成硬规矩的东西最后分享几条我在团队里推行的规矩都是被事故教育出来的。凭证不进 localStorage这一条没有例外。如果架构上确实做不到那就在评审记录里显式标注风险并配套缩短有效期、加异常检测。不标注、当作没这回事是最危险的。所有 Cookie 操作走封装禁止裸写 document.cookie。因为裸写必然会漏属性而且散落在各处之后改一个安全策略要全项目搜索。代码里出现 innerHTML 类 API 必须说明数据来源。如果来源完全可控比如纯静态模板加注释说明如果来源涉及用户输入必须走净化。CSP 从第一天就配 Report-Only。等出事了再加你会发现违规报告多得根本没法处理因为历史包袱已经堆成山了。上线前跑一遍存储体检。检查 localStorage 里有没有可疑的敏感字段、Cookie 有没有漏 HttpOnly、有没有超长 Cookie。这个检查可以写成脚本放进构建流程成本很低。最后分享一个我自己一直在用的小技巧在开发环境里给localStorage.setItem打一个补丁把每次写入的键名、大小、调用栈打印出来。跑一遍核心业务流程你就能清楚看到自己到底往本地塞了多少东西哪些是必要的、哪些是顺手写进去忘记删的。我用这个办法在一个老项目里清理出了将近 800KB 的僵尸数据启动速度肉眼可见地快了一截。存储这件事用起来容易管起来才是功夫。