一个后台管理系统把"筛选条件"存进 sessionStorage,用户很满意——直到有人在"另一个标签页"打开同一页面,发现"筛选条件不见了"。而更糟的一次:某用户"退出登录"后换账号登录,页面"恢复了上一个账号的筛选条件",里面包含"上一个账号才有的部门名".
前端"持久化"有三条边界:作用域(标签页/浏览器)、生命周期(会话/长期)、以及"归谁所有"(用户/设备)。这个"跨账号残留"是"共享设备"上的隐形事故:"前一个人"的筛选条件、"后一个人"打开就看到——**而"看起来像"系统把他的数据"弄混了".
而"随便存"最典型的后果,是"跨账号残留".
"作用域"可以"用一次两标签页实验"看清,比记文档快:
// 三步实验
1 A 页:sessionStorage.setItem('k','1'); localStorage.setItem('l','1')
2 新开 B 页:sessionStorage.getItem('k') → null;localStorage.getItem('l') → "1"
3 复制 A 页(Ctrl+拖动):新页 sessionStorage 里"k"存在(被复制)
// 判据:sessionStorage "只在本页活着",复制时会"带走一份"
1. 两种存储的作用域
| 存储 | 作用域 |
|---|---|
sessionStorage |
单个标签页(复制标签页会"复制一份") |
localStorage |
同源"所有标签页"共享 |
第一项的"单标签页"是"新标签页看不到"的根源——**而它"复制标签页"时"会带过去一份",于是"两个标签页各有一份、互不同步".
2. "三条边界"的对应关系
| 数据 | 该放哪 |
|---|---|
| 临时的筛选/滚动位置 | sessionStorage |
| 长期偏好(主题、语言) | localStorage |
| 身份/权限 | 不存(用 Cookie/服务端会话) |
| 敏感数据(部门、客户名) | 不存(或"登出即清") |
第三项的"机制"是"同源脚本全可读":"任何一段被注入的 JS"都能"读全部键"——于是"存 token"等于"把 token 交给'每一个第三方脚本'".
第三项与第四项是"纪律":"身份信息"进 localStorage 等于"把令牌交给脚本"**——**而 XSS 能读到它.
"登出清理"要"按前缀",而不是"逐个记住":
// 三行"登出清理"(清理所有业务键)
Object.keys(localStorage)
.filter(k => k.startsWith('u:'))
.forEach(k => localStorage.removeItem(k));
// 保留 "theme"/"lang" 这类"与用户无关"的键
// 判据:登出后"只剩偏好类键"
3. "跨账号残留"的三种成因
| 成因 | 说明 |
|---|---|
| 登出"只清 token" | 业务缓存没清 |
| "记住筛选"键不含用户 | 换账号"读到同一键" |
| 共享设备(前台/教室) | 上一个人的痕迹 |
第二项是"最隐蔽"的:filter:department 这个键"不含用户 id"第二项的"触发条件"很具体:"同一台电脑、同一浏览器、两个账号先后登录"——**于是"切换后"看不懂"为什么筛选里有个'自己没权限的部门'".
——**于是"换账号"读到的是"上一个账号的值".
"读的时候要'校验归属'",否则"残留"仍会被"读到":
// 三步"安全读"
1 key = `u:${uid}:v2:filter:department`
2 读到值后"校验":该值是否属于"当前用户的可见集合"
3 不属于 → "忽略并删除"该键
// 判据:即使"键没清干净",也"读不到不该看的"
4. "键设计"的三种做法
| 做法 | 效果 |
|---|---|
| 键里带"用户/租户"前缀 | 隔离(推荐) |
| 登出时"清空整个 storage" | 简单(可能误清"主题") |
| 只存"本次会话"的内存 | 最安全(刷新即丢) |
第一项的"代价"是"键变长":"多几十字节"——而它换来"隔离",且"登出时"可以"按前缀批量清".
第一项是"最小改动":u:{uid}:filter:department**——**于是"换账号"读到的是"自己的键(不存在)".
"跨标签页同步"的实现要"两处代码",而它只有十行:
// 两处"同步"
1 写:setItem 后"本页"立即 setState(本页不触发 storage 事件)
2 读:window.addEventListener('storage', e => { if (e.key?.startsWith('u:')) setState(parse(e.newValue)) })
// 判据:两个标签页"改一个、另一个跟着变"
5. "同步"的三种处理
| 场景 | 做法 |
|---|---|
| 两个标签页都要"实时一致" | storage 事件(localStorage 才触发) |
| 只要"各自独立" | sessionStorage(不触发) |
| 需要"跨设备" | 服务端存(用户偏好表) |
第一项要"注意":storage 事件"只在'另一个'标签页触发",本页不触发第一项的"实现"要"两处都更新":"本页"改完 state 再写 storage;"别页"监听事件后改 state——**而"只写不更新 state"会让"本页 UI 落后".
——**于是"本页"要"自己更新 state".
"共享设备"这种场景,最稳是"用会话级存储 + 短 TTL",而不是"依赖登出":
// 三种"共享设备"的做法
1 sessionStorage + "关页即清"(最省心)
2 localStorage + "登出清理"(要"记得清")
3 内存 + "刷新即丢"(最安全,体验最差)
// 判据:多人共用一台机器时,选 1 或 3
6. 排错:五种"持久化问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 新标签页"没筛选" | sessionStorage 作用域 |
改 localStorage 或"接受" |
| 换账号"看到旧数据" | 键不含用户 | 键加前缀 |
| 登出后"还能看到" | 未清理 | 登出清业务键 |
| 两页不同步 | storage 事件只跨页 |
本页自行更新 |
| 页面"读到过期结构" | 无版本号 | 键加 v |
7. 边界:什么情况"不存"更对
| 场景 | 为什么 | 做法 |
|---|---|---|
| 敏感信息(部门/客户名) | 泄露面 | 不存 |
| "身份/权限" | 易被篡改 | 服务端会话 |
| 有"服务端偏好表" | 天然跨设备 | 存服务端 |
| 状态"本来就短命"(弹窗开合) | 不必持久 | 内存 |
最后一条与"演进"有关的边界:"存储结构要带版本":u:{uid}:v2:filter——于是"改字段"时"旧结构能被忽略",而不是"读到 undefined 后崩在'某处取属性'".
判断口径一句话:"这份数据"换个人(或换台设备)看到,会不会"出问题""**。会(身份、部门、客户),不存或"登出即清 + 键带用户";不会(主题、语言、筛选),存下来"体验更好"——**而"最省心的规则"是:localStorage 只放"与用户无关的偏好",其余"要么不存、要么带用户前缀".
8. 自测
sessionStorage与localStorage的作用域差别是什么?"复制标签页"为什么会带一份过去?- "身份/权限"为什么不该进
localStorage?它会被什么读到? - "跨账号残留"的三种成因里,哪一种"最隐蔽"?
- "键里带用户前缀"解决了什么?为什么它是"最小改动"?
storage事件"本页不触发"意味着什么?本页要怎么更新?- 判断"这份数据能不能存"的那个问题是什么?
补一句"团队"建议:"前端能存什么"应当写成"一张允许清单"——"允许:主题、语言、折叠状态;禁止:token、部门、客户名、手机号"——因为"存储"这件事"写一行代码就生效",而"它泄露"要"等一次 XSS 或一次换账号"才被发现.
而"允许清单"这件事,比"每次评审时想起来"可靠。
而"清单"最省心的地方在于:它能在"评审"时被看见。
而"被看见"之后,它就不再依赖"某个人记得"。
9. 一句话总结
前端存储的关键不是"存不存",而是"存给谁":sessionStorage 是"这个标签页",localStorage 是"这台设备的这个浏览器"。于是"与用户无关的偏好"可以存,"与用户有关的业务数据"必须"带用户前缀 + 登出清理",而"身份与敏感信息"根本不进前端存储——**因为"存储"在前端等于"对脚本可见",而"对脚本可见"就等于"对 XSS 可见".