四个新功能,四段实现笔记:右键填充、全站搜索、快速添加与只读详情

2026-09-05 · liaolongdong

四个新功能,四段实现笔记:右键填充、全站搜索、快速添加与只读详情

四个新功能,四段实现笔记:右键填充、全站搜索、快速添加与只读详情

四个新功能的实现要点一览

这一批更新里,没有一个功能新增设置页开关,权限也只多了一个——contextMenus,而且它属于这一批里唯一的重量级功能(右键填充)。其余功能全部寄生在既有链路上:登录框、侧边栏、密码列表。这是刻意的——密码管理器的设置页每多一个开关,用户就要多做一次"这个我要不要开"的判断。

但这不意味着实现简单。四个功能各自撞上一个 Chrome 扩展特有的坑,值得单独记一段。

右键填充:Chrome 不告诉你用户右键点的是哪个元素

在输入框上右键 → 填充用户名 / 密码 / 两步验证码,或者生成并填充一个强密码。听起来是 chrome.contextMenus 十行代码的事。

第一个坑:contextMenus.onClicked 只给 frameId,不给被点击的元素。也就是说,后台知道"用户在这个 frame 里右键了",却不知道他点的是用户名框还是密码框——而"记忆中的密码填进哪个输入框"恰恰是这件事的全部意义。解法是让 content script 在 contextmenu 事件的捕获阶段自己记下目标输入框(entrypoints/content/contextMenuTarget.ts),后台点击回调里再带着这个 frame 定向下发填充消息。同一份记忆后来还被复用于会话失效时的解锁面板锚定——一个坑挖对了地方,第二个功能就白捡。

第二个坑:菜单父项标题过长会把二级菜单挤出屏幕。Chrome 只在同一上下文里有多个可见顶级项时,才把扩展的菜单项折叠到以扩展全名为标题的二级菜单下;只有一个顶级项时,它直接用你注册的那个父项。而这个扩展在商店里的名字带副标题("账号密码管理助手 - 本地加密密码管理器"),一旦用它当父级,标题宽到把二级菜单顶出可视区,用户右键后看到的是半截菜单。所以现在显式注册单个父项(输入框上下文里叫「填充账号密码」,页面空白处叫「账号密码管理助手」),层级深度和折叠方案完全一致,仍是二级。父项必须先于子项创建——Chrome 要求 parentId 指向的项已经存在;语言切换时整套菜单重建,因为菜单标题是按用户语言渲染的。

第三个取舍:「生成并填充强密码」豁免会话门控。注册新账号是生成强密码最高频的场景,而那一刻会话往往正是锁定的。这个动作只用 Web Crypto 现场生成一串随机字符,不读任何密文或明文条目,所以让它穿过会话检查是安全的——但跨 frame 门控(明文只发给顶层或同主域名 frame)和定向下发照常保留。安全例外必须比主路径更难被滥用,否则不该开这个口子。

内联面板:不可见的元素测不出高度

内联填充面板锚在登录框下方,空间不足时向上翻。第一版实现里,"先定位、再显示"的顺序有个隐蔽 bug:元素处于 display: noneoffsetHeight 恒为 0,于是首开时的上下空间判断拿到的是 0,面板永远"以为"下方空间够——在嵌套 iframe 的登录框这种窄视口里,直接被裁掉半截。

修法不是加延迟、不是等一帧,而是先置可见再定位:两者都在同一个同步任务里完成,浏览器不会在中间绘制,所以既量得到真实高度,也不会闪现错误位置。同时补上三层兜底——默认锚在输入框下方,仅当下方放不下且上方更宽时才上翻;所选一侧空间不足时压缩面板高度(列表内部滚动,压缩下限 120px,保证搜索行加至少一条账号仍然可用);视口比下限还窄时贴着视口摆放,宁可遮挡输入框也不裁切列表。还有一条容易被忘:定位过程中面板可能已经被关掉(比如输入框滚出了视口),此时必须中止后续的交互绑定与聚焦,否则会给一个不存在的面板绑监听器。

全站搜索:一个判据,两处复用

侧边栏默认只列当前域名匹配的账号——这是多环境隔离的立身之本。但"我记得存过一个 GitHub 账号,现在人在别的站上"这种查找需求,本站模式满足不了。于是搜索框右侧加了个图标,在「本站 / 全站」之间切换。

关键设计不是那个按钮,而是判据的唯一来源utils/passwordFilter.ts 里两个纯函数(matchesSiteScope 判定范围、filterEntriesByScope 执行过滤)同时回答两个问题——"这条要不要出现在列表里"和"这条能不能填进当前页"。这两个问题本质是同一个:填充依赖当前页存在对应输入框,域名不匹配的条目填进去必然失败。分成两份判断迟早会漂移,变成"列表里有但点了没反应"或者反过来。抽成无 Vue 依赖的纯函数还有个附带好处:它能被单元测试和基准直接打到。

外站条目的降级也来自同一个判据:canFill 为假时,整行变成"在新标签页打开该站点",但复制账号 / 密码 / 验证码、收藏、编辑都保留——用户来全站模式是为了"找",不是为了"填"。跳转到新标签页的 URL 不是拼字符串,而是走 toNavigableUrl:补默认协议、本地开发域名走 http、显式拒绝 javascript: 这类非导航协议(返回 null 就不渲染链接)。库里的 URL 是用户可编辑字段,属于不可信输入。

性能上曾担心"放开全库会不会拖慢首屏"。基准跑下来这个担心没成立:从 100 条到 2000 条,本站与全站两条路径的吞吐差异始终在 ±1% 噪声带内(benchmarks/sidepanel-p0.bench.ts 可复现)。原因有点反直觉——本站模式要为每一条候选做一次主机名比对(含 URL 解析与协议补全),全站模式反而只做一次浅拷贝加排序。慢的不是"看得多",而是"每看一条都要重新解析一次地址"

顺带一提:本站无结果而全库有命中时,空态会给一个「在全部条目中查找(N 条)」的按钮。用户不需要先想到"原来还有个切换按钮"——这是这类逃逸舱功能能不能被用起来的关键。

快速添加与只读详情:把"看一眼"和"改一下"分开

侧边栏顶栏的「+」打开一个只收五个字段的弹窗(账号 / 密码 / 网址 / 标签 / 备注),网址预填当前域名;本站没有账号时头部显示邀请,搜索无结果时空态也给入口——三处打开的是同一个弹窗。需要 TOTP 之类的完整字段时,弹窗底部把用户送去选项页。轻路径负责高频,重路径负责完整,别把弹窗做成选项页的复刻。

条目只读详情抽屉(components/options/PasswordDetailDrawer.vue)解决的是另一个高频动作:只想看完整备注、看密码历史,却必须进编辑弹窗。它的约束是纯展示——不写存储、不碰加密与会话,「编辑」只是把条目向上抛给父级去复用既有编辑流程,写入路径保持单一。密码默认掩码,明文可见性只是组件本地态,抽屉关闭动画结束时连同已加载的历史列表一起复位,不留残余引用。复制密码不是简单 writeText:它走 utils/clipboard.ts 里那层与 UI 无关的机制(写入、按配置定时清除、清除前校验内容未被用户替换、失焦时降级 execCommand 尽力清除),清除成功与否通过回调交回调用方,各入口用自己的文案提示。这段机制原本长在侧边栏里,抽出来之后详情抽屉与侧边栏共用同一份实现——两个入口的"复制后会自动清除"承诺不会各自演化。

这批功能的共同点

回头看,四条链路都遵守同一套约束:不加开关、除 contextMenus 外不加权限、不改存储结构与加密格式、不动既有条目的语义;新增的用户可见文案全部中英双语成对提交;每个功能都带回归测试,全仓库 632 项自动化测试(分布在 53 个测试文件)与 pnpm build 双端构建作为验收线。

密码管理器的功能增量本来就不该是"多几个按钮"。它更像是把同一条登录链路磨得再顺一点:少一次切 App、少一次点错输入框、少一次为了看一眼备注而误改数据。

源码与讨论:GitHub 仓库。已上架 Chrome 应用商店,完全免费,GPL-3.0 开源。


本文涉及的关键文件:entrypoints/background/contextMenuManager.ts(菜单注册与动作分发)、entrypoints/content/contextMenuTarget.ts(右键目标记忆)、entrypoints/content/inlineDropdown/InlineFillDropdown.ts(面板定位)、utils/passwordFilter.ts(范围与过滤判据)、utils/clipboard.ts(复制与自动清除)、components/options/PasswordDetailDrawer.vue(只读详情抽屉)。