
做技术这行久了你会发现真正折磨人的从来不是复杂算法而是最基础的操作反复出错。比如在一个两千行的遗留文件里你想确认当前这个 else 到底归哪个 if 管光标滚到屏幕中间脑子里只剩下刚才那行函数签名长什么样的模糊印象。我在这个场景里浪费过太多时间直到认真用上了 context-mode——它做的事其实特别朴素把你正在浏览的位置对应的父级作用域固定显示在编辑器顶部让你任何时候都知道自己在哪个函数、哪个类、哪个代码块里。VSCode 里叫 Sticky ScrollVim/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默认 5Neovim 这边类似。默认 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 Diffdiff 里的^和-已经明确标出了变动上下文顶部再贴一个函数名很冗余。快速浏览解释器 REPL 或 Jupyter 输出那些文件没有明确的类函数结构context 显示的往往是Module或随机节点没意义。编写长篇文章或 Markdown 笔记标题层级确实有意义但当你已经打开导航栏时顶部 context 和导航栏功能重叠二选一即可。我在配置里用文件类型做了个简单的白名单只对python, go, typescript, javascript, rust, lua, java, cpp等主流代码类型启用其他类型一律关掉实测下来体验干净很多。具体到每个场景是否关闭本质上是信息增益和视觉噪音的权衡没有标准答案但你可以通过配置试验出自己的偏好。5. 如果不想装插件一个用 Treesitter extmark 徒手实现的 context-mode5.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_definitionGo 里是func_declarationC 里有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让它能把光标直接跳到当前显示的那个上下文起始行这样你不仅知道自己在哪还能一键回到函数头部重新读一遍——这个组合拳是我最近半年最满意的一次配置升级。