一个注入式扩展总得有个入口:点一下,打开面板、看状态、拨开关。

这个入口前后换了三种形态。

为什么不是工具栏图标

最自然的答案是浏览器工具栏上那个扩展图标。它确实有,点开是个 popup。

但它不能当主入口,两个原因:

① 扩展没法把自己固定到工具栏上。 新装的扩展默认藏在拼图图标的下拉里,要用户自己去点「固定」。Chrome 没有让扩展代码自己固定的 API——这件事我们试过不止一次,答案一直是没有。对不熟悉浏览器的用户来说,一个藏起来的入口约等于不存在。

② 它离工作区太远。 用户的注意力在页面上,工具栏在屏幕最顶端的角落。

所以工具栏图标最后只做一件事:账户与凭据录入(工号、自己的 API key)。不做状态展示——那是别处的事。

第一版:一颗浮动小球

入口改成了页面上一颗 40×40 的浮动圆球。它被做得很精致:

  • 贴屏幕右沿,三档垂直位置可选;
  • 旁边有个换位手柄,拖一下换档;
  • 可以折叠成半隐藏的把手,也可以停靠;
  • 有呼吸、眨眼、摇头的微动效;
  • 记住像素坐标。

对应的配置项有五六个。设计稿还单独做了一版动效稿。

它有一个无法靠打磨解决的问题:

它看起来就不属于这个页面。

一块带阴影、带渐变、会动的圆形色块,贴在一个平面风格的业务系统上——像一块外挂的膏药。用户每天看它八个小时。

还有一个技术上的附带问题:对 position: fixed 的元素做 transform: scale() 悬停动画,在重页面上会触发合成层闪现。那个动效后来改成了只换底色和阴影。

第二版:宿主顶栏里的一盏「状态灯」

决定把入口放进宿主页面自己的顶栏里——那一排原生图标旁边(通知铃铛、帮助问号)。

参照的是顶栏里一个原生元素的写法:一个表示网络状态的指示灯,彩色圆点 + 文字。照着做出来是一枚会变色、会脉冲的绿灯。

被打回了。用户的本意是一个图标,和旁边那排原生图标同款。

扩展是否在运行,不需要一盏常亮的灯来宣告。

真正需要传达给用户的信息只有一个:有没有未读的提醒。而这件事,宿主自己的通知铃铛早就有惯例了——右上角一颗小红点。

第三版:一枚安静的图标

最后的形态:

  • 宿主顶栏里的一枚线性 SVG 图标,尺寸、颜色、hover 底色逐项对齐旁边的原生图标(实测量出来的值,不是估的);
  • 有未读提醒时,右上角一颗红点;
  • 点击打开一个模态面板:身份与授权状态、各功能的开关速拨、到设置页的入口。

那颗小球连同它的五六个配置项整个删掉了——不是藏起来,是代码移除。

对应的旧配置字段在类型定义里还留着(标了 @deprecated、改成可选),只为了兼容用户存储里的旧数据能正常反序列化;默认配置里已经删掉,迁移时会清理残留的键。

⚠ 别因为「字段还在」就把开关加回设置页。 拨了不会有任何效果——一个不起作用的开关,比没有这个开关更糟。

放进别人的顶栏,要付的代价

入口放进宿主的 DOM 里,就要承担宿主 DOM 的所有不稳定性。

代价一:它会被整段重渲染掉

宿主是个 SPA,顶栏会被整段重建,挂进去的节点连带被移除。

所以用 MutationObserver 盯着,发现自己掉了就补挂。回调用 requestAnimationFrame 合并。

⚠ 这里有个细节:必须盯 body,不能只盯顶栏容器。 内容脚本在 document_end 运行,那时宿主往往还没渲染出顶栏——顶栏容器是 null,observer 挂不上去,就永远等不到「顶栏出现」这个事件。结果是入口只有在刷新时顶栏恰好已经存在的情况下才能挂上,看起来像时好时坏。

盯 body 的代价是回调会被各种表单、弹窗的 DOM 变动触发,所以回调本身必须极廉价:

function onMutation() {
if (document.getElementById(HOST_ID)?.isConnected) return; // 还在,什么都不做
tryMount(); // 掉了才补
}

一次 getElementById 加一次 isConnected,命中才做事。

代价二:某些页面上没有顶栏

在宿主的某些非工作台页面,顶栏根本不存在——入口不可见。

兜底是一个不依赖入口是否挂载的快捷键。它注册在全局的键位管理器里,和入口组件没有任何耦合:

找不到顶栏时,只是少了一个可以点的入口,功能本身不受影响。

入口不应该是唯一的路径。 这条在所有注入式 UI 里都成立:你依附的那块宿主 DOM 随时可能不在。

代价三:挂载顺序决定显示顺序

顶栏里不只一个扩展组件(入口图标、一条常驻消息条、一个监控胶囊、一个计数徽标)。它们都用 insertBefore(firstChild) 挂进去,后挂的排在最左边。

所以想要的显示顺序是 [徽标] | [监控] | [消息条] | [入口],挂载顺序就必须反过来。这件事在代码里写了注释——因为下一个改挂载顺序的人不会想到它决定的是视觉顺序。

一条关于设计稿的纪律

那颗小球的动效稿还留在仓库里,当作历史存档。我在它所在目录的 README 里写了一句:

这些 HTML 是历史设计稿存档,不是待办。别主动「重新设计 / 优化」它们。

这句话是有原因的:某次会话在上下文被压缩之后,把很早之前一个「重新设计这个动效稿」的任务参数误当成了当前待办,跑去重做一个早已废弃的设计。

留在仓库里的东西会被当成「还在进行中」。 如果它不是,就明确地写出来。


三版入口的演进,方向始终是变得更不显眼:从一个会动的色块,到一盏会变色的灯,到一枚和周围一模一样的图标。

每一步删掉的都是「让人注意到扩展存在」的东西。而这恰恰是对的——一个每天用八小时的工具,最好的状态是用户忘了它是外挂的。