ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

vim-chrome:将Vim操作哲学移植到Chrome的浏览器扩展

vim-chrome:将Vim操作哲学移植到Chrome的浏览器扩展 写代码写了十年我用 Vim 的时间占了其中九年。日常大部分工作流都是围绕 Vim 键位展开的但有一个场景一直让我很割裂只要一切到 Chrome 查文档、看页面手就不得不离开键盘去摸鼠标。频繁切换工具链带来的割裂感比想象中更消耗专注力。于是我用业余时间做了个叫 vim-chrome 的浏览器扩展把编辑器里那一套键盘即鼠标的操作哲学移植到浏览器里。这篇文章会从为什么做、怎么做到踩过的坑完整讲一遍。1. 被鼠标拖慢的手从 Vim 到 Chrome 的按键迁移1.1 一个让我崩溃的日常场景先描述一个我每天都会经历的片段。我在终端里用 Vim 改配置光标在文件里飞dd删行、yy复制、gg跳到文件头整套动作行云流水。然后想查一个 API 的用法切到浏览器要移动鼠标去找链接、点击、滚动、再点。等查完资料切回终端手要重新找到键盘时那种流畅感已经断掉了。刚开始我以为这只是习惯问题忍忍就好。但时间一长我发现这其实是个工具链效率问题大量操作本可以靠键盘完成但 Chrome 默认不给你按键驱动的交互方式。Vim 用户最大的优势是肌肉记忆它不应该被限制在终端里。vim-chrome 就是要接管浏览器里的那部分高频操作让我在查阅文档时手可以不离开键盘。1.2 为什么不用现成的 Vimium有现成的 Vimium、cVim 这些扩展我确实用过一段时间功能也很成熟。但最终促使我决定自己写一个 vim-chrome 的原因有三点一是自定义深度。我希望键位映射像 Vim 的 vimrc 一样完全由我控制而不是在现成扩展提供的配置项里做选择题。二是概念上的差异。我想实现一个更接近 Vim 编辑器的交互模型比如网页光标概念、gf打开链接引用的路径、在浏览器里执行类 Vim 命令。这些需求在现有扩展里很难通过配置满足。三是扩展开发本身对我有吸引力。写一个浏览器扩展能让我把编辑器交互思维转化为浏览器原生接口上的实现这本身就是一次很好的技术练习。所以 vim-chrome 更像是一个实验项目而不是单纯替代 Vimium 的工具。1.3 这个项目的目标和边界做之前我给自己定了两个约束。第一个约束是只覆盖日常高频使用的 80% 操作不做面面俱到的完整镜像。第二个约束是必须能优雅降级遇到复杂页面、需要输入的场景扩展要自动让位不能造成阻塞。核心的目标场景有三个文档阅读、代码搜索GitHub、Stack Overflow 这类页面、以及长页面浏览。这几个场景里网页上的操作模式和编辑器的文本操作模式有很强的对应关系适合用键盘驱动。2. 项目骨架一个 Chrome 扩展的三大部件2.1 用 Manifest V3 还是 V2老一点的文章都喜欢用 Manifest V2因为它可以常驻后台页写起来自由。但按照 Chrome 的政策V2 的淘汰是必然的越早迁移到 Manifest V3MV3越好。我这里用的就是 MV3manifest.json如下{ manifest_version: 3, name: vim-chrome, version: 0.2.0, description: 把 Vim 的操作哲学带到 Chrome 浏览器里, permissions: [tabs, bookmarks, storage], host_permissions: [all_urls], background: { service_worker: src/background.js }, content_scripts: [ { matches: [http://*/*, https://*/*], css: [src/content.css], js: [src/content.js], run_at: document_idle, all_frames: true } ], action: { default_popup: src/popup.html, default_title: vim-chrome } }要点是permissions里声明了tabs和bookmarks这样才能在命令栏里实现关闭标签页、添加书签这类操作。content_scripts里设置了run_at: document_idle等 DOM 基本解析完再注入避免太早导致某些元素还没渲染出来。这个时间点选得比较稳实测在大多数站点上都不会抢跑。顺带提一个细节如果你还在用老版本的 Chrome比如在 Win7 上只能跑老版本浏览器MV3 的某些 API 可能行为不一致。一般来说建议保持浏览器版本尽量新很多老梗比如 109 版本之后对扩展策略的调整在更新版本里都已经收敛了。2.2 内容脚本、后台服务与弹窗的分工Chrome 扩展通常由三部分组成各自职责很清晰。content script内容脚本注入到页面上下文负责拦截键盘事件、操作 DOM、绘制界面。它是 vim-chrome 的交互核心。background service worker后台服务负责处理标签页的创建、关闭、查询等浏览器级操作以及跨域资源的请求代理。popup弹窗提供简单的开关配置比如白名单站点、键位配置文件的入口。这三个部分之间通过chrome.runtime.sendMessage通信。举个例子当我在页面里按g字母直接跳转到页面顶部这是 content script 内部处理的不需要通知后台。但当我按:q想关闭当前标签页时content script 无法直接操作标签页必须把消息发给后台由后台调用chrome.tabs.remove完成。这样设计的考虑是权限隔离。content script 理论上可以访问页面 DOM但它不能也不应该随意调用浏览器级接口通过消息转发能清晰控制每个动作的能力边界。2.3 代码结构一览我的项目文件结构很简单适合作为自己开发的起点参考vim-chrome/ ├── manifest.json ├── src/ │ ├── content.js # 页面内的按键捕获、光标移动、命令栏 │ ├── content.css # 界面样式通过 shadow DOM 注入 │ ├── background.js # 标签页操作、右键菜单、安装初始化 │ └── popup.html/js # 设置面板 └── README.md不要觉得代码少就低估工作量。真正复杂的部分在 content.js 里那里集中了键位解析、元素定位、Shadow DOM 渲染和事件流控制。我把它当成一个小型前端项目来写每个模块尽量保持独立后续才能方便扩展新命令。3. 让页面动起来键位映射与网页光标系统3.1 网页上的光标长什么样Vim 的强大之处在于它有当前光标位置这个概念。网页虽然是 DOM 结构而不是文本流但同样需要有一个类似的当前焦点元素。在 vim-chrome 里我实现了一个可视化的高亮框页面中会被绘制一个细边框标记当前选中的元素。初始进入页面时它会定位在第一个可见的正文区域或第一个可交互元素上。后面所有移动指令都是把高亮框从一个元素移到另一个元素。高亮框的实现并不复杂先拿到目标元素的getBoundingClientRect()再在一个覆盖全屏的固定层里设置对应的top、left、width、height。难点在于滚动时保持同步这需要监听scroll事件使用{ passive: true, capture: true }实时更新位置。3.2 元素级移动和文本级移动的妥协从 Vim 过渡过来的人下意识会以为j是向下移动一行k是向上移动一行。但网页不是纯文本如果强行按字符或行移动在结构化的页面上会非常碎片化。我最终采用的核心思路是按元素级别处理移动而不是按字符级别。j和k的含义被定义为在当前屏幕可视区域内找到位于当前高亮元素下方或上方最近的一个可见元素。搜索时会对候选元素做两层过滤第一层是计算每个元素相对视口的位置过滤掉display: none、visibility: hidden和宽高为 0 的元素第二层是按距离排序选择最近的目标。这里有个绕不开的技术难点遍历全页面所有 DOM 元素的开销不可忽略。实际上我会通过document.elementsFromPoint()按点取样或者先圈定一个候选列表再排序避免每次按键都遍历几万个节点。3.3 常用键位如何映射下面这张表是我在 vim-chrome 里的核心键位映射看完大概能理解网页和编辑器的差异Vim 键位vim-chrome 行为j/k向下/向上移动到最近的可见元素h/l向左/向右移动按可见区域边缘滑动w/b跳到下一个/前一个链接或按钮gg回到页面顶部并定位到第一个元素G跳到页面底部dd删除最外层高亮元素如关闭卡片、折叠列表项yy复制当前元素的链接或选中文本到剪贴板f进入链接直选模式给每个可见链接分配编号按编号跳转/打开页面内搜索类似 Vim 的斜杠搜索gf打开当前高亮链接的 URL对应 Vim 中打开文件路径的习惯前面几项都容易理解重点说下dd和yy。编辑器里的删除和复制针对的是文本行浏览器里不存在行的概念所以我把它映射到 DOM 元素层面。dd在高亮一个列表卡片时会先给元素加一个淡出动画然后移除它。这个操作在长列表页比如 GitHub 任务列表、RSS 阅读器里非常实用可以快速清理不看的内容。3.4 一个关键的实现细节如何不让按键冲突这是开发过程中最让我头疼的部分。最开始我直接监听keydown事件结果在页面上输入中文时按下j键字符也丢了、光标也跟着跳体验非常糟。解决方案是设计一套按键放行机制。在事件捕获阶段判断当前事件目标是否是输入框、textarea、contenteditable元素或者扩展自身绘制的命令栏。只有目标满足条件时才进入 Vim 模式的处理逻辑否则直接放行。这个判断看起来简单但坑在于一些富文本编辑器会通过 div 模拟输入框单纯判断标签名不够。我后来加了一个更稳妥的做法检查event.target.closest(input, textarea, [contenteditabletrue])同时再看看元素上是否设置了roletextbox。额外补充如果页面处于全屏编辑模式比如文档站的代码编辑器就直接禁用扩展的按键接管。即使有了这些保护我还是在 Gmail、Figma 这类重键盘操作的应用里遇到冲突。最终的应对是在设置里加了站点白名单默认在mail.google.com、docs.google.com、figma.com上直接停用 Vim 键位需要的用户再手动解除限制。4. 冒号背后的命令栏在浏览器里执行 Vim 命令4.1 在网页上画一个命令栏Vim 的另一个核心心智模型是命令行模式。在浏览器里我希望能按冒号:后直接输入命令回车执行。光是这一点就值得做它能让关闭当前页面从找标签页右上角的叉号变成按下:q加回车。命令栏的 UI 我放在页面顶部fixed 定位仿照 Vim 的底部命令行样式。但为了让视觉融入现代页面我加了一点毛玻璃效果和圆角在深色、浅色页面下都能保持可读性。样式之所以能干净我用了 Shadow DOM 包裹——这点等下细说算是提前埋个伏笔。命令栏的逻辑也很简单按下:,render 一个输入框监听keydown回车调用命令解析器Esc取消并销毁。命令解析器我用了一个非常基础的分发式结构每条命令就是一段独立的处理函数。4.2 内置命令清单这样设计能很快支撑命令非常轻盈命令作用:tabe url或:o url新建标签页并打开指定 URL:q关闭当前标签页:w把当前页面地址保存到书签对应保存的语义:bm打上书签并起一个更容易记忆的名字:set nohlsearch关闭搜索高亮:theme dark切换深色模式:help显示命令帮助面板:w是我特意做的一个小幽默网页本身不能保存文件但保存到书签算是浏览器里最接近写入某个位置的操作。每次在网页上按:w后自动弹一个小通知显示当前页面已保存到书签意外地有仪式感。4.3 打开链接引用的文件路径——一个有趣的移植Vim 里有个高频操作叫gf作用是打开光标所在路径对应的文件。这个习惯带到浏览器里就是打开当前选中链接对应的 URL。这个功能对看技术文档非常有用。比如在浏览 API 文档时当前高亮在参数类型链接上直接按gf就能打开链接跳转一个新标签页全程手不离键盘。如果高亮的元素不是链接gf会尝试向上找最近的a祖先元素每次都成功跳转的可能性很高。我当时觉得把文件路径的概念映射成 URL 顺理成章但实际做的时候发现有许多边界点击高亮链接时普通文本链接、新窗口链接、下载链接都要区分。处理方式是给链接元素加一个状态类如果带download属性就不做跳转而交给浏览器下载器处理。5. 血泪排查输入框失控、iframe 穿透和扩展加载问题5.1 第一次装上去页面全乱了初版测试时我在content_scripts里直接引入了一个简单的 CSS 文件来定义高亮框和命令栏的样式结果在不少网站上页面出现了整体错位有几个站点的按钮甚至变小了。排查后发现问题出在全局样式污染content script 的 CSS 会和页面自身样式叠在一起可能会影响一些带约束的布局。修复方式是改用 Shadow DOM。所有高亮框、命令栏、提示 toast 全部放到一个挂在document.documentElement下的根节点里通过attachShadow({ mode: open })隔离样式。这样页面看到的是一个无样式影响的宿主节点内部样式则由 vim-chrome 自己完全掌控。这个改动让样式污染问题从源头消失了。5.2 iframe 里的世界很多页面内嵌了 iframe比如文档站的代码运行器、各种评论区。如果 content script 不配置all_frames: true在 iframe 里按快捷键毫无反应配置了之后又会发现 iframe 内的事件无法冒泡到顶层焦点和光标状态在 iframe 和主页面之间切换时容易乱。我的处理方式是给 iframe 里高亮的目标元素在切换到父级时额外使用window.top.postMessage通知顶层清除或重建高亮状态。同时在 iframe 内不做标签页级操作所有需要关闭标签页、新建标签页的命令都只触发消息转发由后台统一处理。这块调试是最费时间的。建议你在自己的开发里直接把 iframe 相关的问题当作一个独立的测试专项列出十到二十个不同站点的 iframe 场景再逐一验证。5.3 Chrome 的该扩展程序未列在应用商店中提示如果你准备自己加载一个未打包的扩展会看到 Chrome 发出警告该扩展程序未列在 Chrome 应用商店中并可能是在您不知情的情况下添加的。我第一次看到这行字也愣了一下以为是浏览器检测到什么异常但其实这只是一个安全提醒。原因很简单通过加载已解压的扩展程序方式安装的扩展没有经过 Chrome 应用商店的审核所以浏览器主动提示风险。这是对所有本地开发者的常规提醒不是针对某个具体扩展的封禁。开发阶段要做的操作是在chrome://extensions/页面打开开发者模式选择加载已解压的扩展程序然后指向项目目录。加载后每次改动manifest.json都需要在扩展卡片上点刷新按钮而改动 content script 只需要刷新被测试的页面即可生效不需要重新加载扩展。这里要提醒一句既然这个警告是面向可疑扩展的你自己开发时更要小心只加载自己写的或者能完整审计源码的项目避免随便从网上下载未打包扩展包来装。对于没有开源、没有明确来源的扩展宁可不用。5.4 某些网页的快捷键冲突以 Chrome 内置快捷键为例Vim 用户理论上会想去占用Ctrl 组合键或Cmd 组合键但这在浏览器里是大忌。Chrome 环境里有一批内置快捷键是浏览器优先处理的比如Cmd RMac 强制刷新、Cmd L聚焦地址栏、Cmd T新标签页这些事件在普通 web 页面里根本拦截不到就算拦截到也不应该覆盖。所以 vim-chrome 的主要按键尽量控制在单键或双键前缀里比如这里用到的组合是g开头gg、gf、G而不是直接占用 Ctrl/J、Ctrl/K 这类组合。这样既能在大多数页面里运行又不会和 Chrome 的基础操作能力冲突。在开发时如果发现某个网站响应了意外动作建议用事件监听回放思路排查在目标页面打开 DevTools 的 Global Listeners 面板看是否有页面自身的keydown监听器在早于 content script 捕获阶段处理了按钮。这类问题通常是站点自身的应用级快捷键拦截通过白名单排除即可。6. 给想抄作业的人核心代码与扩展方向6.1 content script 的核心按键处理代码这部分我把最重要的按键接收机制和光标元素移动逻辑贴出来代码不复杂关键在几个边界条件的处理。// 按键分发 document.addEventListener(keydown, (event) { if (isEditableTarget(event.target)) return; if (isWhiteListedSite(window.location.hostname)) return; const key event.key; const handled handleKeyEvent(event); if (handled) { event.preventDefault(); event.stopPropagation(); } }, { capture: true, passive: false }); function handleKeyEvent(event) { const tag event.key.toLowerCase(); if (tag : !commandBarOpen) { openCommandBar(); return true; } if (commandBarOpen) { return false; // 命令栏已打开时由输入框自行处理 } if (event.shiftKey) { if (tag G) { moveToBottom(); return true; } } else { switch (tag) { case j: moveToNext(1); return true; case k: moveToNext(-1); return true; case h: moveBySide(-1); return true; case l: moveBySide(1); return true; case g: if (lastKeyPress g) { moveToTop(); resetLastKey(); } else { storeLastKey(g); } return true; case f: enterLinkHints(); return true; case /: openSearchBar(); return true; } } return false; }有几个实现时的细节值得注意。输入框判断放在最前面确保页面里的input、textarea、富文本编辑器不受干扰。判断函数我会同时检查closest结果和roletextbox。gg这类双键指令需要记录上一次按键。这里的实现只是一个简单记忆更复杂的做法是用按键序列状态机来支持多组合但当前功能用不到。事件监听用capture: true目的是在事件到达页面自身监听器之前就接管。对于大多数页面这能确保按键不会被阻塞。6.2 background service worker 的标签页操作后台部分相对简单核心是接收消息并调用浏览器 API代码大致如下chrome.runtime.onMessage.addListener((msg, sender, sendResponse) { switch (msg.type) { case TAB_CLOSE: chrome.tabs.remove(sender.tab.id); sendResponse({ ok: true }); break; case TAB_NEW: chrome.tabs.create({ url: msg.url }); sendResponse({ ok: true }); break; case BOOKMARK_ADD: chrome.bookmarks.create({ title: msg.title, url: msg.url, parentId: 1 }); sendResponse({ ok: true }); break; default: sendResponse({ ok: false }); } });实际项目里我还加入了统一的错误处理比如sender.tab不存在时直接返回失败响应避免 content script 一直等待。扩展开发里消息响应的生命周期很有意思传统sendResponse在异步操作后容易失效所以如果内部有await一定要在消息监听函数末尾return true来延展响应通道否则回调会丢失。6.3 目前实测效果和下一步计划我把 vim-chrome 装进日常环境后实测了 GitHub、MDN、知乎、Stack Overflow 这几个主战场。最顺滑的是 GitHub列表页上的 issue 卡片可以被j/k快速扫过d可以收起不需要的卡片其次是 MDN用gf直接在文档之间跳转翻文档感觉很接近编辑器操作。在性能方面超长页面比如无限滚动上频繁计算元素位置时会有一点掉帧这是getBoundingClientRect()本身的开销问题。优化思路是合并多次滚动回调的布局读取或者把候选元素集合缓存起来。目前版本在实际使用中表现可以接受后续计划把元素位置计算改成requestAnimationFrame驱动进一步降低计算频率。下一步的项目方向我想给命令栏增加一个命令历史类似 Vim 的:历史记录这样常用命令不用每次敲一遍。另一个计划是支持自定义键位配置文件让用户可以像写.vimrc一样写自己的键位映射。开发 vim-chrome 这个项目最大的收获不是“我拥有一个浏览器扩展”而是把所有 Vim 操作哲学迁移到浏览器的过程中重新理解了两件事编辑器的键盘设计和浏览器的内容模型之间其实存在大量可以被映射的语义关系以及任何交互设计都要以不干扰原有场景为前提。如果看完这篇文章你有自己的键位映射想法强烈建议直接照着 vim-chrome 的骨架改一版你会在开发过程中发现不少开放接口远比我这里写完的部分更有意思。
返回列表