2118 字
约 7 分钟
17
S14-A26 · 什么该"存下来":`sessionStorage` 的三条边界

一个后台管理系统把"筛选条件"存进 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. 自测

  1. sessionStorage 与 localStorage 的作用域差别是什么?"复制标签页"为什么会带一份过去?
  2. "身份/权限"为什么不该进 localStorage?它会被什么读到?
  3. "跨账号残留"的三种成因里,哪一种"最隐蔽"?
  4. "键里带用户前缀"解决了什么?为什么它是"最小改动"?
  5. storage 事件"本页不触发"意味着什么?本页要怎么更新?
  6. 判断"这份数据能不能存"的那个问题是什么?

补一句"团队"建议:"前端能存什么"应当写成"一张允许清单"——"允许:主题、语言、折叠状态;禁止:token、部门、客户名、手机号"——因为"存储"这件事"写一行代码就生效",而"它泄露"要"等一次 XSS 或一次换账号"才被发现.

而"允许清单"这件事,比"每次评审时想起来"可靠。

而"清单"最省心的地方在于:它能在"评审"时被看见。

而"被看见"之后,它就不再依赖"某个人记得"。

9. 一句话总结

前端存储的关键不是"存不存",而是"存给谁":sessionStorage 是"这个标签页",localStorage 是"这台设备的这个浏览器"。于是"与用户无关的偏好"可以存,"与用户有关的业务数据"必须"带用户前缀 + 登出清理",而"身份与敏感信息"根本不进前端存储——**因为"存储"在前端等于"对脚本可见",而"对脚本可见"就等于"对 XSS 可见".

S14-A26 · 什么该"存下来":`sessionStorage` 的三条边界
https://www.clxhxhhr.top/posts/6708/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。