做技术这行久了你会发现,真正折磨人的从来不是复杂算法,而是最基础的操作反复出错。比如在一个两千行的遗留文件里,你想确认当前这个 else 到底归哪个 if 管,光标滚到屏幕中间,脑子里只剩下"刚才那行函数签名长什么样"的模糊印象。我在这个场景里浪费过太多时间,直到认真用上了 context-mode——它做的事其实特别朴素:把你正在浏览的位置对应的"父级作用域"固定显示在编辑器顶部,让你任何时候都知道自己在哪个函数、哪个类、哪个代码块里。VSCode 里叫 Sticky Scroll,Vim/Neovim 生态里是 context.vim 以及基于 Treesitter 的各类实现。这篇文章我会把它的交互逻辑、背后算法、配置参数、性能取舍全部摊开来讲,甚至最后给出一版不需要装插件、用几十行 Lua 手搓的最小实现,适合所有需要频繁阅读长文件、维护老旧代码库的开发者。
1. context-mode 要治的病:滚动三秒后,你确定自己还在原来的上下文里吗
1.1 代码阅读中的"断链时刻"到底是怎么发生的
很多编辑器用户都有个共同体验:一份文件打开超过 5 分钟,滚动超过三屏,人就开始"晕代码"。这种晕不是看不懂语法,而是丢失了空间参照系。人类的短期记忆容量非常有限,对屏幕位置的记忆又高度依赖视觉锚点——当你把光标从函数头部拖到函数中段,原本占据视野的那个函数签名被滚出屏幕,你就失去了最关键的坐标。
我举个例子。你维护一个 Go 服务,某个 handler 有三百行,里面嵌套了三层 if 加两个 for。你为了查一个边界条件,从文件顶部一路滚到第 217 行。此时屏幕上是各种各样的业务逻辑,没有一行的长相能告诉你"我还在 CreateOrder 这个函数里"。你只能往回滚确认,或者依赖 IDE 的代码折叠侧边栏猜。来回这么一折腾,上下文切换的成本直接翻倍。context-mode 要解决的就是这一件事:让"当前所在作用域"这个信息永远停留在你的视野边缘,而不是要求你把它牢牢记住。
1.2 传统工具为什么都差了一口气
在 Sticky Scroll 出现之前,大家也不是完全没有工具可用。缩略图(Minimap)能给你一个全局形状,但它没法回答"我具体在第几个函数里";函数列表侧边栏(如 VSCode 的 Outline、Vim 的 Tagbar)能让你看到全貌,但每次确认都要移动视线、扫一眼列表、再跳回来,注意力被打断得很厉害;跳转列表和书签也只能减少滚动距离,不能消除"滚动后重新定位"这个心智负担。
真正好用的交互应该像开车时的导航浮动条:你不需要停下来看地图,余光一扫就知道当前处在哪条路上。context-mode 就是在编辑器顶部画出一条这样的"道路指示条"——它把你正在浏览位置的直接父作用域、更高层父作用域逐级摆出来,滚动时实时更新,视线不用离开代码区就能完成定位。这个设计思路不是某个编辑器的专利,而是近几年各大编辑器和 IDE 不约而同长出来的共同解法。
2. 两种主流实现背后,藏着两套"我在哪"的判定逻辑
2.1 VSCode Sticky Scroll:基于折叠信息与缩进模型的两种判定
VSCode 从 1.70 版本开始引入 Sticky Scroll,早期藏在实验性设置里,后来成了默认开启的正式功能。它的核心决策点是:编辑器怎么知道"当前这行代码属于哪个作用域"?VSCode 给了两套模型。
第一套叫折叠提供者模型(foldingProvider)。语言服务负责返回一个折叠区域列表,例如某个类的行区间、某个函数的行区间、某个 if 块的行区间。Sticky Scroll 拿到这些区间后,只需要判断当前视口顶部所在的行落在哪些区间里,把这些区间的起始行(也就是类名、函数签名那几行)提取出来,依次显示在顶部。这个模型准确度高,因为它是基于真实的语法结构生成的,不是猜的。
第二套叫缩进模型(indentationModel)。当某个文件类型没有对应的折叠提供者时,VSCode 会退回到缩进计算——看当前行往上数,有哪些行的缩进量比"当前行缩进量"更少,那些行就有可能是作用域起始行。缩进模型在 Python、YAML 这类语言上体验不错,但在大括号语言上偶尔会闹笑话,因为一个不换行的大括号写法就会打乱缩进逻辑。
这两套模型可以手动切换,设置项是editor.stickyScroll.defaultModel,日常用默认值就行。VSCode 内部会优先尝试折叠提供者,拿不到再降级到缩进。值得留意的是editor.stickyScroll.maxLineCount,默认是 5,控制顶部最多显示几级上下文,文件嵌套太深的时候会出现省略号,后面我会专门讲这个参数。
2.2 Vim/Neovim 生态:context.vim 与 Treesitter 的路线差异
Vim/Neovim 这边的实现历史其实比 VSCode 更早。老牌的 context.vim 走的是"缩进 + 语法模式"混合识别:插件在每次窗口滚动时,扫描当前视口顶部的代码行,用缩进关系判断哪些行可以作为上下文行,再结合当时的语法高亮状态做微调,最后把这些行复制成"虚拟行"渲染在窗口顶部。
这套方案的好处是兼容性好,Vim 8 和 Neovim 都能跑,不需要额外依赖;缺点也明显——它对语言的识别是"启发式"的,遇到switch里不规范的缩进、预处理指令横插一杠的场景,偶尔会找错父作用域。
后来 Neovim 全面普及 Treesitter 之后,社区出现了另一派实现:直接解析语法树,拿到当前光标位置所在的语法节点,再沿着 parent 一路向上找,把函数定义节点、类定义节点、循环节点等"看起来像作用域"的祖先节点提取出来。这个方案准确率高出一大截,因为它不是在猜缩进,而是在看真实的语法结构。缺点是必须依赖 Treesitter 解析器,冷门文件类型可能没有对应的 parser,这时候就得退回到缩进方案。
2.3 一张表看清两种路线的适用边界
| 实现路线 | 代表作品 | 判定依据 | 优点 | 短板 |
|---|---|---|---|---|
| 折叠/区间模型 | VSCode Sticky Scroll | 语言服务返回的折叠区域 | 准确、稳定 | 依赖语言服务,冷门语言可能缺失 |
| 缩进模型 | VSCode 缩进兜底、context.vim 旧版 | 缩进量变化 | 通用性强,任何文本都能算 | 大括号不换行、宏定义时易判错 |
| 语法树模型 | Neovim 下基于 Treesitter 的实现 | AST 节点层级 | 准、快、可定制性强 | 需要 parser,冷门语言没辙 |
| 混合模型 | context.vim 较新版本 | 缩进为主,语法辅助 | 兼容性和准确性平衡 | 配置参数多,需要按语言调 |
从实际体验来说,VSCode 用户基本不用操心判定逻辑,开着默认就行。Neovim 用户如果要折腾,我更推荐直接上 Treesitter 路线,后面第三节就是用这种方式配置的。
3. 手把手把 context-mode 搬进 Neovim:从最小配置到真正顺手
3.1 先选型:context.vim 还是 Treesitter 实现
如果你还在用 Vim 8,唯一值得考虑的成熟方案就是 context.vim,它是纯 VimScript 写的,安装门槛低。如果你已经切到 Neovim 且装了 nvim-treesitter,那直接用 Treesitter 实现会更省心。我自己是在 Neovim 0.9 之后才彻底搬过来的,原因很简单:Treesitter 已经成了 Neovim 内置能力,解析器安装也自动化了,这时候再用语法模式做启发式识别,总觉得有点吃亏。
具体到插件选择,我目前用的是社区比较活跃的 nvim-sticky(基于 Treesitter 的 sticky scroll 实现),配合 lazy.nvim 做管理。如果你更喜欢传统的 context.vim,它到现在依然维护,作为对比也可以两个都装一下试试手感。下面以 nvim-sticky 为例讲配置,因为这个方案和 VSCode 的 Sticky Scroll 观感最接近,也是我实测下来最顺手的一个。
3.2 安装与最小配置
用 lazy.nvim 管理的话,配置块大概长这样:
{ "brenoprata10/nvim-sticky", dependencies = { "nvim-treesitter/nvim-treesitter", }, event = "VeryLazy", opts = {}, }配置完重启 Neovim,随便打开一个 Python 或 TypeScript 文件,滚动几下就能看到窗口顶部出现当前函数、类的固定提示。如果你之前没有装过 Treesitter parser,记得先运行:TSInstall把对应语言的 parser 装齐,否则插件找不到解析器就会静默失效,这是最容易被忽略的一步。
如果你走 context.vim 路线,配置更简单:
Plug 'wellle/context.vim' let g:context_enabled = 1 let g:context_max_height = 5两种方案的视觉差异在于:context.vim 是把作用域起始行原样复制到顶部,看起来像"多贴了几行代码";nvim-sticky 更接近 VSCode 的样式,按作用域层级横向排列,当前所在层高亮,视觉上更克制。
3.3 关键配置项逐个拆解
界面跑通之后,真正决定好不好用的是下面这几个参数。我按自己的配置逐条说一下。
第一,最大显示行数。VSCode 里的editor.stickyScroll.maxLineCount默认 5,Neovim 这边类似。默认 5 意味着最多同时展示 5 级父作用域。日常够用,但如果你经常处理那种 4 层 if 嵌套 + 函数 + 类 = 6 层的情况,建议调到 6 或 7。调太高也不好,会遮住太多编辑区。我实测下来,5 到 6 是甜点区间,超过 8 就开始影响正文阅读了。
第二,最小窗口高度。有些实现叫min_window_height,意思是当窗口高度小于某个值(比如 10 行)时自动关闭 context 渲染。这个参数很实用:你分屏分得很碎的时候,窗口本身就只有十几行高,再被 context 吃掉两行,正文什么都看不见。我建议保留默认阈值,不要为了"到处都显示"把它设成 0。
第三,忽略的文件类型。context-mode 在代码文件里是神器,在 markdown 和纯文本里就有点尴尬。你读一篇长文档的时候,顶部悬浮的是各级标题,信息量其实也可以,但在 diff 窗口、git 提交信息页面里,它除了遮挡视线没别的作用。建议把gitcommit、diff、qf这些类型直接排除,可以用类似vim.g.sticky_excluded_filetypes的配置项控制。
第四,行号显示。部分实现支持在 context 行右侧显示原始行号,方便你"看到某行上下文就知道它在文件的哪个位置"。这个功能我一开始觉得好,后来还是关掉了,因为行号会增加横向宽度,配合宽字符注释的时候排版会轻微跳动。这里纯粹看个人习惯,不关也不影响使用。
3.4 跑通之后,真正影响手感的是配色和事件触发
很多人装上插件后发现"怎么有时候出来有时候不出现",大概率是没有注意触发时机。Sticky 类插件通常监听WinScrolled和CursorMoved事件来更新顶部内容。如果你用的终端比较老,或者远程 SSH 延迟高,滚动事件跟不上,就会出现"顶部内容还停留上一段"的错觉。这不是插件坏了,是事件刷新被终端吞吐卡住了。我自己的解决方式是限制最大行数、适当调低刷新频率,并优先在有 GPU 加速的终端里用,比如 Kitty、Alacritty。
配色方面,ctx 行的高亮组通常需要手动绑定,否则会顶着默认蓝色跑。我在配置里会做这样几行映射:
vim.api.nvim_set_hl(0, "StickyLine", { link = "NormalFloat" }) vim.api.nvim_set_hl(0, "StickyLineCurrent", { link = "Directory" })把普通 context 行接到浮动窗背景色上,把当前层接到文件目录色上,视觉上就不会和正文抢注意力。
4. 云端上的效果与屏幕前的代价:我实测下来需要接受的取舍
4.1 大文件和高频滚动的性能分水岭
context-mode 不是零成本的。每滚动一次,编辑器都要重新计算"当前作用域链",然后生成虚拟行插入视口顶部。对几千行的小文件来说,这个计算量可以忽略不计;但当你打开一个 20 万行的日志文件、或者 5 万行的 SQL 迁移脚本时,连续快速滚动会明显感觉到渲染变慢、光标发飘。
我做过一个粗略验证:在同一台机器上,打开一个 6 万行的 TypeScript 文件,关闭 context 时滚动流畅度几乎满帧,开启后快速滚动会有可感知的掉帧。原因不难理解,每次滚动都要调用 Treesitter 重新解析视口附近的语法节点,而解析器在大文件上的初始化成本本来就高。
应对办法有两个。第一,给超大文件设置豁免,比如超过 5000 行的文件自动关闭 context,这个可以写在 autocmd 里。第二,把max_line_count上限调低,层级越少,每次渲染需要复制的行就越少。服务端代码还好,前端打包产物这类动辄上万行的文件,我基本是直接关掉这个功能的。
4.2 窄屏分屏与信息密度问题
第二个取舍来自屏幕宽度。VSCode 的 Sticky Scroll 是按层级横向铺开的,最多 5 层也就是一行高度;而 context.vim 和部分 Neovim 实现是纵向堆叠的,每个上下文占一行,5 层就占 5 行。如果你把屏幕竖切成三栏,每栏只有 60 个字符宽,纵向堆叠的 context 会吃掉很大的编辑空间。
这段我踩过坑:有段时间我习惯左中右三栏,左边目录、中间代码、右边 LSP 信息,中间代码栏高度本来就不够,context 再占 5 行,一个函数还没看完屏幕就满了。后来我把这个窗口的 context 最小高度阈值调到 15 行,低于 15 行高度直接不显示,情况立刻改善。所以我建议:如果你是多分屏重度用户,优先考虑横向排布的 Sticky Scroll 方案,或者学会接受"窗口太窄时不显示上下文"这个设定。
4.3 与配色、LSP、语义高亮的联动细节
第三个容易被忽视的问题是高亮一致性。Treesitter 渲染 context 行时,如果直接复制原始代码行,却不复制它原本的高亮信息,顶部看起来就会像一份"没有语法高亮的纯文本",和下面的彩色代码一对比非常难受。大部分插件会尽量用当前 buffer 的高亮逻辑重新渲染,但当你开了很多自定义配色主题时,context 区域可能用的还是主题的默认值。
解决办法是检查当前配色主题有没有为 Sticky 相关高亮组提供定义,没有的话手动补上。这一条对追求"所见即所得"的人很重要:让顶部 context 的颜色和正文相同,你的眼睛就会自动把它当成正文的一部分,而不是一块碍眼的补丁。
4.4 哪些场景主动关掉反而更舒服
最后说说关停的时机。Conquer of Completion 里有个词叫"注意力预算",context 会时时刻刻占用你视野边缘的一点空间。在普通编码场景下这是信息增益,但在下面这些场景里它纯粹是噪音:
- 查看 Git Diff:diff 里的"^"和"-"已经明确标出了变动上下文,顶部再贴一个函数名很冗余。
- 快速浏览解释器 REPL 或 Jupyter 输出:那些文件没有明确的类函数结构,context 显示的往往是"Module"或随机节点,没意义。
- 编写长篇文章或 Markdown 笔记:标题层级确实有意义,但当你已经打开导航栏时,顶部 context 和导航栏功能重叠,二选一即可。
我在配置里用文件类型做了个简单的白名单,只对python, go, typescript, javascript, rust, lua, java, cpp等主流代码类型启用,其他类型一律关掉,实测下来体验干净很多。具体到每个场景是否关闭,本质上是"信息增益"和"视觉噪音"的权衡,没有标准答案,但你可以通过配置试验出自己的偏好。
5. 如果不想装插件:一个用 Treesitter + extmark 徒手实现的 context-mode
5.1 思路拆解:语法树找祖先进程 + 虚拟行渲染
理解了原理之后,你会发现 context-mode 的核心只有两步:第一步,找到"当前光标所在位置的父作用域节点";第二步,把这些节点的起始行渲染到窗口顶部。Neovim 内置的 Treesitter 和 extmark 恰好能同时搞定这两件事。
第一步的关键 API 是vim.treesitter.get_node({ pos = ... }),它能返回当前位置所在的语法节点。拿到这个节点后,不断调用node:parent()往上走,就能得到一整条祖先链。我们不需要把所有祖先都显示出来,只需要筛出那些"像作用域"的节点,比如节点类型里包含function、class、method、for、while、if关键词的类型。这就是 VSCode foldingProvider 模型的极简版。
第二步的关键是 extmark 的virt_lines参数。Neovim 允许你在某个缓冲区位置上方插入不实际改变文件内容的虚拟行。每次滚动或光标移动时,我们清除旧的虚拟行,再插入新的上下文行,就实现了实时刷新的效果。
5.2 一个极简的 Lua 实现
下面这份代码是个可运行的示意版本,依赖 Neovim 0.10+ 的 Treesitter 和 extmark API,我加上了必要注释。你可以把它放进~/.config/nvim/after/plugin/mini-context.lua试跑:
local group = vim.api.nvim_create_augroup("MiniContextMode", { clear = true }) local ns = vim.api.nvim_create_namespace("MiniContextMode") local extmark_id = nil local SCOPE_TYPES = { function_definition = true, method_definition = true, class_definition = true, if_statement = true, while_statement = true, for_statement = true, } local function compute_context_nodes(bufnr, lnum) local ok, parser = pcall(vim.treesitter.get_parser, bufnr) if not ok or not parser then return {} end local node = vim.treesitter.get_node({ pos = { lnum - 1, 0 } }) if not node then return {} end local nodes = {} -- 沿父节点链向上收集所有作用域节点 while node do if SCOPE_TYPES[node:type()] then table.insert(nodes, 1, node) end node = node:parent() end return nodes end local function render_context() local winid = vim.api.nvim_get_current_win() local bufnr = vim.api.nvim_get_current_buf() local lnum = vim.api.nvim_win_get_cursor(winid)[1] -- 先清除上一次渲染的虚拟行 if extmark_id then pcall(vim.api.nvim_buf_del_extmark, bufnr, ns, extmark_id) extmark_id = nil end -- 低于 10 行的窗口不渲染,避免遮挡正文 if vim.fn.winheight(0) < 10 then return end local nodes = compute_context_nodes(bufnr, lnum) if #nodes == 0 then return end local virt_lines = {} for _, nd in ipairs(nodes) do local start_row = nd:start() local line = vim.api.nvim_buf_get_lines(bufnr, start_row, start_row + 1, false)[1] if line and line ~= "" then table.insert(virt_lines, { { "▍ " .. line, "Comment" } }) end end extmark_id = vim.api.nvim_buf_set_extmark(bufnr, ns, 0, { virt_lines = virt_lines, virt_lines_above = true, hl_mode = "combine", }) end vim.api.nvim_create_autocmd({ "CursorMoved", "WinScrolled" }, { group = group, callback = render_context, })这段代码在原理层面能跑通,但它只是教学演示,不要指望它能立刻达到我前面说的那些插件的完成度——它没有处理不同语言的节点类型差异、没有做参数缓存、也没有考虑嵌套深度的上限。不过对想搞懂"context-mode 到底怎么实现"的人来说,把它读一遍,比翻十篇文档都管用。
5.3 这个 DIY 版本的短板与补救
极简实现最大的短板是节点类型不匹配。Treesitter 在每个语言里定义的节点类型不一样,Python 里是function_definition,Go 里是func_declaration,C++ 里有function_definition也有template_declaration。要适配所有语言,你就得维护一份很大的类型映射表。这也是为什么社区插件值得直接用的原因——它们已经替你维护好了这些映射。
另外,上面这份代码没有限制SCOPE_TYPES中的if_statement和while_statement路径,在某些语言中它们会被频繁插入,容易造成顶部堆满。补救方法是只保留顶层函数和类(即"函数层级以上"的节点),把if、for、while这些块级节点忽略掉——这其实就是你的个人偏好问题了:你是想看到"在哪个 if 里",还是"在哪个函数里",二者选其一或都保留,取决于你的导航习惯。
6. 跳出编辑器:把 context-mode 的思维带走
6.1 从编辑器到文档:长文导航的同一诉求
context-mode 的底层思想其实不止适用于写代码。你读一份 200 页的技术文档,或者维护一份 5 万字的项目周报时,同样会遇到"往下滚了几屏就忘了这是第几章第几节"的问题。很多写作软件里的"面包屑导航"、Notion 里顶部的页面层级、GitBook 的左侧目录,本质上都是 context-mode 的变体:把当前所在层级固定显示出来,减少读者的空间记忆负担。
我自己写长文档的方法是:把 markdown 的各级标题当作作用域节点,编辑区顶部常驻当前章节标题。这和代码里的函数签名、类名用途一模一样。你可以把它当作用户习惯来培养——不管用哪个工具,注意"让位置上下文常驻视野",这个动作本身就能显著降低阅读长内容的疲劳感。
6.2 AI 编程工具中,context-mode 的思维正在变成一种交互范式
这几年的 AI 编程工具,也把"上下文"变成了一个可调节的显式概念。你可能已经注意到,Copilot Chat、Cursor 的对话面板都需要你选定一定的上下文范围——选择当前文件、选择整个工作区、或者手动引用某些文件。这个选择和 context-mode 的层级筛选是同一个逻辑:上下文给得太多,AI 注意力被稀释;给得太少,AI 答非所问。
你想想看,AI 处理一个函数时,它最需要看到的也是"这个函数处于哪个类、哪个模块、被哪些上层调用"——这恰恰就是 context-mode 固化在编辑器里的那一层"父级作用域链"。理解了这层关系,你在给 AI 提问时就会自然带出更多上下文信息,比如先贴类定义、再贴函数签名、最后贴报错行,效果比直接丢一段 500 行的报错日志好得多。这个习惯,和你在编辑器中学会"先看 context 行的类名,再往下读代码"是一致的。
6.3 真正好用的工具,是让"你在哪"这件事永远不用想
我始终觉得,工具链的最高评价不是"功能多",而是"交互负担小"。context-mode 之所以让我有写一篇文章的冲动,是因为它把一件曾经需要刻意记忆的事情变成了下意识反应——视线扫一眼窗口顶部,就知道自己在哪个作用域里,然后可以放心继续看代码。
这种"无意识导航"的能力,放在更大的工作流里同样成立。你维护一个项目时,需要时刻知道当前任务处于哪个模块、哪个版本周期、哪个依赖关系之下,这和使用 context-mode 时需要的空间定位感没有本质区别。好的工作习惯,往往就是把这种"顶层作用域常驻视野"的机制,从一个编辑器功能扩展成一种思考方式。
我自己用下来最深的体会是:它不会让你的代码写得更好,但会让你的阅读效率明显提高,而阅读效率恰恰是所有后续修改、重构、排错的前提。如果你正被"滚动后找不到自己在哪"折磨,不用犹豫,去把你的编辑器 context-mode 配好,第一周你可能都意识不到它的存在,一个月后关掉它试试,你会立刻知道少了什么。最后再分享一个小技巧:在 Neovim 里给 context 行绑定一个快捷键,比如gz,让它能把光标直接跳到当前显示的那个上下文起始行,这样你不仅知道自己在哪,还能一键回到函数头部重新读一遍——这个组合拳,是我最近半年最满意的一次配置升级。