ARTICLE · 1192367
插件说我没登录,可我明明登录了——一次 Chrome 扩展「未登录」误报的三层排查
同一个浏览器,同一个上午。网页端用得好好的,点开自己写的剪藏插件,弹窗却一脸正气:请先登录。

这种 bug 最烦人:两边都"看起来对"。网页说我登录了,插件说我没登录,总有一个在撒谎——也可能两个都没撒谎,撒谎的是表象。
我的排查顺序立了条规矩:不信任何一方的自我报告,只对三样东西——契约、现实、日志。一层一层来。
第一层:先对契约
插件的登录判定很朴素:用 chrome.cookies 读 youhuale.cn 域下名为 token 的 cookie,读到就算登录。这个契约两头各有承诺——服务端登录回调要种下这个 cookie(HttpOnly + Secure + 24 小时有效期),插件按同样的名字和路径去读。
任何一头变卦,bug 就有了完美藏身处。恰好这套系统最近在迁移,"cookie 种到了新域名"是头号嫌疑人。所以第一步不是改代码,是 curl 一把登录链路:
curl -s -o /dev/null -w '%{redirect_url}\n' \ 'https://youhuale.cn/auth/authorize?dest=/ui/home'
返回的 302 里,redirecturi 仍然指向 youhuale.cn 的回调;服务端代码里 setcookie("token", ...) 那一行也原封未动;再 dig 一下传说中要启用的新域名——还没解析。契约两头都没变。
排除一个,还剩两个。
第二层:去现实里量
契约说 cookie 应该在,那它到底在不在?不猜,去 cookie 罐子里数。
有个细节:HttpOnly 的 cookie 网页 JS 读不到,但 chrome.cookies 是扩展特权 API。在 chrome://extensions 里点开扩展的 service worker 控制台,可以直接查:
chrome.cookies.getAll({domain:"youhuale.cn"}, cs => console.table(cs.map(c => ({name: c.name, path: c.path, secure: c.secure,expires: c.expirationDate ? new Date(c.expirationDate*1000).toISOString() : "会话"}))))
表格里 token 和 id_token 都在:path 是根路径,secure 为 true,明天上午才过期。再按插件的真实姿势调一次单点查询 chrome.cookies.get({url:"https://youhuale.cn/", name:"token"})——也拿到了,value 长度 962,没有分区属性。
也就是说:插件拥有读这份 cookie 的一切权限,cookie 也真实存在、真实有效。凭证无罪。
到这里矛盾升到顶点:每个零件单独测都是好的,拼起来却是坏的。这种时候该怀疑的就不是零件,是流水线——大概率有段代码根本没跑过。
第三层:红色按钮里的真凶
Chrome 扩展有个容易被忽略的入口:chrome://extensions 的扩展卡片上,如果「错误」按钮是红色的,说明浏览器替你记着一条没人接住的异常。点开,一行报错:
Uncaught TypeError: Cannot read properties of undefined (reading 'addEventListener')
堆栈指向 popup.js 第 116 行,代码是 els.openHome.addEventListener(...)。而 els 是 popup 开头那张元素映射表——表里压根没有 openHome 和 openOptions 这两个键。footer 里两个链接的 id 加了,文件末尾的监听也注册了,唯独中间这张表漏配。从第一个版本起就漏着,半年没人发现。
关键的连锁反应在这:这行代码在 ES 模块顶层。模块求值时,任何一行顶层异常都会截断它后面的所有代码——包括文件最后一行那个孤零零的 boot()。
而 boot() 是整个 popup 唯一调用 authState()(登录态检测)的地方。它永远执行不到,登录态检测从没运行过,弹窗每次打开都停在 HTML 初始态。
那"未登录"三个字哪来的?来自另一条路径:我点过一次"存入",后台当时拿不到凭证,返回了 needLogin,弹窗收到回执,把界面切成了登录引导页。注意——这是一条与登录态检测完全无关的渲染路径,它渲染的是"刚才那次存入失败的原因",不是"当前登录状态"。我把失败原因看成了状态显示,被表象骗了整整两层。
修复只值两行:els 补上缺的两个键;顺手把 boot() 挪到所有监听器注册之前,让渲染主干不再垫底。

复盘:三条可以带走的
一、界面状态 ≠ 事实。看到任何反常状态,先问一句:这是哪条代码路径给出的答案?状态渲染路径和业务事实路径经常不是同一条,bug 就藏在两条路径的裂缝里。
二、初始化主干永远别垫底。模块顶层一行异常就能截断后面全部求值;把 init/boot 放在文件末尾,等于让前面所有代码都握着它的生死。要么放最前,要么单独包一层保护。
三、被吞掉的异常不会消失,只是换了个地方待着。扩展的红色错误按钮、进程的 stderr、失败任务的日志——总有一处留着真相。当所有零件单独测都是好的,去翻这些地方,而不是把零件再测一遍。
结语
这次 bug 的修复只值两行代码,损坏面也只是一个自用插件的弹窗。但排查走完了完整的司法程序:先验证契约,再丈量现实,最后传唤日志——每一步只用一个动作,证实或证伪一个嫌疑。
小 bug 的价值从来不在损坏面有多大,而在排查路径干不干净。路径干净,两行的 bug 也练得出可迁移的手艺;路径脏,P0 事故也只能收获一句"可能是网络问题"。















































































