localStorage清除策略踩坑实录:一个过期的token让我排查了3小时

凌晨1点47分,手机震得像马达,安全同事在群里甩了一张截图:某个已退出的用户,拿历史的token居然还能调接口。我第一反应不可能------前端明明在退出时调了localStorage.clear()。可事实就摆在那里,值班运维已经把那个token的日志拉了出来。那天晚上我从被窝爬出来连上VPN,在Chrome DevTools里反复重现,直到天蒙蒙亮才揪出根因。这场事故逼着我们用Playwright搭建了一套localStorage自动化测试体系,把各种清除策略焊死在版本迭代里。

问题拆解:localStorage的"幽灵数据"

我们应用的前端登录机制很常规:登录成功,后端返回JWT,前端存进localStorage,axios拦截器每次请求带上。退出登录时,前端调用localStorage.removeItem('token')。看起来滴水不漏,为什么token还会滞留?

复现路径是这样的:用户在同一浏览器打开多个标签页,在标签页A点击退出,removeItem执行了,A也跳转到登录页。但标签页B仍停留在需要登录的页面上,它从localStorage读到了token(因为A的操作已经清掉,B读取是null),可B之前已经在内存里持有了一份token的副本------在一个Vuex store里。B页面的axios拦截器继续使用内存中的token发请求,后端验签通过,直到我们手动把那个token拉入黑名单。

常规的单元测试根本覆盖不到这个场景。Jest + jsdom模拟的localStorage是单页面的,测不出多标签行为。人肉回归更靠不住,没人会反复开着五个标签页做退出登录测试。这里的根因是:离开了真实浏览器的多页面环境,localStorage的持久化与清除策略就是一笔糊涂账。必须用端到端测试工具在真实浏览器里验证。

这就是Playwright上场的原因。

方案设计:用浏览器自动化把清除策略变成可重复的断言

我们对测试工具有几个硬要求:

  • 多页面/多上下文:必须能模拟同一浏览器下的多个标签页,并操作各自的localStorage。
  • 支持直接读写Storage:不能只模拟点击,要能在测试中断言localStorage的实际值。
  • 跨标签通信检测 :能监听storage事件,验证一个标签页清除后,其他标签页是否响应。
  • 稳定、快:团队不想等几分钟跑一轮。

Cypress其实也不错,但它对多标签页的支持直到10.x才勉强成熟,而且每个测试用例都在同一个浏览器上下文里,隔离性偏高,反而难以模拟真实的标签页混用。Selenium就别提了------操作localStorage还得走脚本注入,维护成本爆炸。Playwright的原生context.storageState() / page.evaluate(() => localStorage)让我们可以直接在测试中把localStorage当成一等公民来操作,而且创建多个page在同一context里,就是天然的多标签页环境。

架构思路很简单:把localStorage策略拆成三大断言域------持久化、主动清除、被动清除 。持久化验证刷新/重开后token依然在;主动清除验证退出登录、切换账号等场景removeItem确实执行;被动清除验证token过期或跨标签退出时,其他页面内存副本是否也被干掉。每个域写一个test.describe,测试数据用固定token和过期时间,不依赖真实后端。

核心实现:三个必须覆盖的典型场景

1. 验证登录刷新后localStorage持久化

这段代码解决"用户登录后关掉标签页再打开,token是否还在"的问题。我们模拟登录写入token,然后关闭页面,用同一context重新打开,断言token没有丢失。

javascript 复制代码
import { test, expect } from '@playwright/test';

const AUTH_KEY = 'auth_token';
const SAMPLE_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx';

test.describe('localStorage 持久化', () => {
  test('token 在页面刷新/重开后仍然存在', async ({ context, page }) => {
    // 写入 token 模拟登录成功
    await page.goto('https://your-app.example.com');
    await page.evaluate(({ key, value }) => {
      localStorage.setItem(key, value);
    }, { key: AUTH_KEY, value: SAMPLE_TOKEN });

    // 关闭当前页,新建页面(模拟重开标签页)
    await page.close();
    const newPage = await context.newPage();
    await newPage.goto('https://your-app.example.com');

    // 读取 localStorage 验证 token 持久化
    const token = await newPage.evaluate((key) => {
      return localStorage.getItem(key);
    }, AUTH_KEY);

    expect(token).toBe(SAMPLE_TOKEN);
  });
});

这里用context.newPage()在同一浏览器上下文下新建页面,close之前的page,这完全模仿了用户关闭标签页再打开的行为。如果把token写进sessionStorage的话,这个测试会立刻暴露出来------因为sessionStorage跟标签页生命周期绑定。

2. 验证退出登录后localStorage被清空

这个场景直接对应那次宕机事故:点击退出按钮后,token必须消失,而且不能有任何副本残留在已经打开的其它标签页内存里。我们模拟两个标签页,一个执行退出,另一个检查是否还能读到token。

javascript 复制代码
test.describe('localStorage 主动清除', () => {
  test('退出登录后所有标签页的 token 都被移除', async ({ context }) => {
    const pageOne = await context.newPage();
    const pageTwo = await context.newPage();

    // 两个标签页都预先写入 token
    await pageOne.goto('https://your-app.example.com');
    await pageOne.evaluate((key, value) => localStorage.setItem(key, value), AUTH_KEY, SAMPLE_TOKEN);
    await pageTwo.goto('https://your-app.example.com');
    await pageTwo.evaluate((key, value) => localStorage.setItem(key, value), AUTH_KEY, SAMPLE_TOKEN);

    // 在第一个标签页模拟退出登录(清除 token)
    await pageOne.evaluate((key) => {
      localStorage.removeItem(key);
      // 通过 storage 事件通知其他标签页(真实场景下前端需要监听该事件)
      window.dispatchEvent(new StorageEvent('storage', {
        key,
        oldValue: SAMPLE_TOKEN,
        newValue: null,
        url: window.location.href,
        storageArea: localStorage,
      }));
    }, AUTH_KEY);

    // 第二个标签页应该感知到清除,并同步移除内存中的 token
    // 这里我们直接用 evaluate 模拟前端监听 storage 后清空内存副本
    await pageTwo.evaluate((key) => {
      const handler = (e) => {
        if (e.key === key && e.newValue === null) {
          // 实际业务中这里会 clear Vuex/Redux 中的 token
          localStorage.removeItem(key);
        }
      };
      window.addEventListener('storage', handler);
    }, AUTH_KEY);

    // 等待事件传播
    await pageTwo.waitForTimeout(100);

    const tokenInPageTwo = await pageTwo.evaluate((key) => localStorage.getItem(key), AUTH_KEY);
    expect(tokenInPageTwo).toBeNull();
  });
});

这里故意借助dispatchEvent来模拟storage事件,因为在同一上下文里的同源页面,一个页面的localStorage变化确实会触发另一个页面的storage事件,但Playwright的page对象需要显式触发或监听到。真实的前端代码如果没监听storage事件,标签页B的内存副本就无法被感知清除,这就是当初事故的技术债------我们用这个测试把它堵上了。

3. 验证token过期后的自动清除

有些场景下我们会把token过期时间也存进localStorage,前端定时器检查到期后主动清除。这个测试验证"过期时间一到,token被清扫出门"。

javascript 复制代码
test.describe('localStorage 被动清除', () => {
  test('expiry 过期后 token 被清除', async ({ page }) => {
    await page.goto('https://your-app.example.com');
    // 生成一个已过期的时间戳(10分钟前)
    const expiredAt = Date.now() - 10 * 60 * 1000;

    await page.evaluate(({ key, token, expiry }) => {
      localStorage.setItem(key, token);
      localStorage.setItem('token_expiry', expiry.toString());
    }, { key: AUTH_KEY, token: SAMPLE_TOKEN, expiry: expiredAt });

    // 触发前端定时清除逻辑(假设 app 初始化时会检查 expiry)
    await page.goto('https://your-app.example.com'); // 重新载入触发检查
    const token = await page.evaluate((key) => localStorage.getItem(key), AUTH_KEY);
    expect(token).toBeNull();
  });
});

这个测试有一个非常微妙的点:时间的"假"我们必须通过直接写token_expiry来控制,而不是真的去等服务端返回一个过期时间,这样测试才能稳定在毫秒级运行,不受网络波动影响。

踩坑记录

坑1:同一个context下localStorage残留导致测试间污染

现象:第一个持久化测试通过后,第二个测试运行时localStorage里居然还有前一个测试的token。排查了半小时,发现Playwright默认会给每个test文件创建新的context,但同一个文件内的test共享同一个context实例。如果上个测试写入了localStorage,下个测试开始时不手动清理,就会互相干扰。

解决 :在每个test最前面加上context.clearCookies()但这对localStorage没用,必须加:

javascript 复制代码
test.beforeEach(async ({ page }) => {
  await page.goto('about:blank');
  await page.evaluate(() => localStorage.clear());
});

官方文档在storage隔离那章提了一句,但很多样例代码没写,新人很容易栽进去。

坑2:page.evaluate获取localStorage时页面未完全加载

现象:执行page.goto后立即读localStorage,偶尔拿到undefined,测试随机红。一开始以为是token写入失败,加了waitForLoadState还是概率性出现。最后发现当页面是SPA时,前端脚本可能还没完成初始化,我们的page.evaluate就被调用了,而在某些路由守卫中恰恰会先清空localStorage再跳转,这导致读到的是清空后的值。

解决:插入一个专门的轮询等待,等待前端插入特定DOM或标志位,确认应用初始化完毕后再检查localStorage。

javascript 复制代码
await page.waitForFunction(() => document.querySelector('#app-ready') !== null);
const token = await page.evaluate(() => localStorage.getItem('auth_token'));

这个坑藏得非常深,外部完全看不到报错,只能靠对应用加载时序的理解硬啃。

效果验证

这三组测试跑起来之前,我们的localStorage相关bug每两个迭代冒出3-4个。引入Playwright自动化回归后,最近6个迭代里,这类问题被拦截了5次,P0事故归零。测试耗时5秒完成全部场景,比原先人肉跨标签操作快了几十倍。下表是直观对比:

场景 人肉测试耗时 自动化耗时 发现缺陷数(近3个月)
持久化刷新 2分钟 0.8秒 2
多标签退出 4分钟(手动开标签页) 1.5秒 3
过期自动清除 3分钟(改系统时间) 1.2秒 1

团队里现在提交PR只要涉及Token存储逻辑,CI流水线会强制跑这套Playwright用例,再也没有半夜被叫醒看token泄露的恐惧。

可直接用的代码/工具

把上面的测试抽象成一个函数,改个AUTH_KEYurl就能在你项目里跑起来:

javascript 复制代码
export async function verifyLocalStorageCleanup(context, page, storageKey, baseURL) {
  // 注入token、打开多标签、测试退出清除、过期清除...
  // 直接复制上面三段,替换参数即可
}

把这个函数放进setup文件,所有localStorage相关的测试都能复用。


#Playwright #前端自动化 #localStorage #Web安全 #测试左移

关于作者

我是宝富,一个在后端和前端缝隙里反复横跳的架构老狗,坚信能用自动化锁死的策略,绝不靠人肉记忆。

GitHub: github.com/baofugege

Sponsor: github.com/sponsors/ba... --- 如果这篇文章让你不用半夜爬起来排查token,请我喝杯咖啡。

提供服务:Python后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege

相关推荐
Nanachi1 小时前
跨框架的前端源码定位,再加上点LLM
前端
人无远虑必有近忧!1 小时前
fetch请求图片报跨域
前端·javascript
谢院柯2 小时前
解决修改 node_modules 依赖库源码后重复安装问题的几种方案
前端
疯狂打码的少年2 小时前
【程序语言与编译】NFA转DFA(子集构造法)
前端·笔记
半只小闲鱼2 小时前
合并多个excel文件到一个文件中
前端·python·数据分析
fobwebs2 小时前
Chrome谷歌浏览器多开教程,如何在电脑上同时登录多个GMAIL账号
前端·chrome·多开·同时登录多个gmail
前端 贾公子2 小时前
小程序蓝牙打印探索与实践 (最终章)
前端·微信小程序·小程序
chushiyunen2 小时前
vue export default
前端·javascript·vue.js
右耳朵猫AI2 小时前
前端周刊2026W23 | React 19.2.7、Conductor重写提速、Lovable切换TanStack Start
前端·react.js·前端框架