前阵子我接手一个维护了快七年的老服务,业务逻辑倒不算难,真正的敌人是文件长度。一个核心类 800 多行,方法之间相互调用,我经常滚到屏幕中间就忘了自己是在类里还是已经在某个私有方法里,debug 到一半才发现改动放错了作用域,整个下午都在为这种低级错误买单。后来我在编辑器工作流里补了一套自己的“上下文模式”(context-mode),这个毛病基本绝迹了。今天就把它完整的实现思路、核心代码和踩坑记录整理出来。
context-mode 简单说,就是让编辑器实时告诉你“你现在写的代码处于哪个结构上下文里”,顺带把上下文之外的内容调暗、折叠,提供一种按需聚焦的工作状态。它不是某个商业软件,而是任何主流编辑器都可以自己实现的交互模式。这篇内容适合所有在长文件里挣扎的开发者,特别是写 Python、Java、C++ 这类函数体容易很长、嵌套很深语言的人;如果你正打算给自己的编辑器写插件,我也会把 DocumentSymbol、LSP 符号树、状态栏联动这些知识点完整过一遍。
1. 从“代码凭记忆”到“上下文可视化”:context-mode 解决的问题
1.1 长文件里最常见的迷失方式
先说说我在老项目里最典型的崩溃场景:一个 Java 类,一个方法就有 150 行,方法里套匿名内部类,匿名内部类里再回调一个 Lambda。光标停在 Lambda 里的某一行时,只看代码缩进,你能确定当前处在第几层吗?大多数时候要靠脑子把缩进层级翻译成逻辑层级,再回想外层是哪个方法。
这种“代码凭记忆”的工作方式在短文件里完全没问题,但文件一旦超过几百行,切换成本就指数级上升。你为了确认某个字段的定义,把滚动条往上拖,眼睛扫半天,等找到答案再滚回来,光标已经不知道丢到哪一行了。更有意思的是,很多人在按下Ctrl+F之前,根本说不清自己此刻在哪个函数里。
这一现象在 Python 里尤其明显,因为 Python 用缩进而不是花括号,嵌套装饰器、多层 if、列表推导挤在一起时,行号旁边的缩进线根本不够用。C++ 的匿名命名空间、JS 的 IIFE、Vue 单文件组件的<script>里的组合式 API 代码块,都是重灾区。
1.2 context-mode 提供的三类能力
我做的 context-mode 不是一个花哨的功能,它就解决三个问题:
| 能力 | 作用 | 对应的交互方式 |
|---|---|---|
| 上下文感知 | 知道当前光标在哪个类、哪个方法里 | 状态栏显示面包屑路径,比如UserService / getUserById / try |
| 上下文聚焦 | 把当前上下文以外的代码弱化,减少视觉干扰 | 一键调暗外部代码,或折叠所有除当前符号外的区域 |
| 上下文跳转 | 快速回到上下文链上的任意一层 | 状态栏路径可点击,悬停显示完整符号名 |
感知是基础。有了实时路径,你就不需要靠记忆维持“我在哪”。聚焦是进阶,目的是让注意力锁定在当前函数内部,滚动时也不会被周围代码带偏。跳转则是把上下文链当导航菜单用,想去哪一层点哪一层。
1.3 为什么叫“模式”:常驻提示和按需聚焦的区别
刚开始我想把路径一直显示在状态栏,后来发现这会造成视觉噪音——毕竟大部分时间你并不需要看路径。于是我把功能拆成了两个状态:一个叫“感知模式”,路径常驻显示但很小;另一个叫“聚焦模式”,开启后外部代码调暗、折叠按钮也才真正生效。
“模式”这个词在这里是有明确含义的。日常状态的编辑器是普通模式,你正常编辑;开启 context-mode 后,编辑器的行为会围绕“当前上下文”做出一系列联动:路径跟随、外部弱化、命令跳转。这种多行为联动比单一插件更像一种可切换的工作模式。你按一次快捷键进入,再按一次退出,操作心智非常清晰。
2. 编辑器到底怎么知道你在哪:上下文定位的核心逻辑
2.1 一切从符号树开始
要做到“知道你在哪”,关键是要拿到当前文档的结构化信息,而不是靠字符串匹配。主流编辑器都提供了符号树(Symbol Tree)这一层抽象:文档里的类、函数、方法、接口、变量,被组织成一棵树。树的根节点是文件里的顶层符号,子节点是嵌套在里面的结构。
拿 VS Code 举例,它通过vscode.executeDocumentSymbolProvider这个命令返回DocumentSymbol[]。每个DocumentSymbol至少包含几个关键字段:
name:符号名,比如getUserByIdkind:符号类型,比如类、方法、接口range:整个符号结构占用的范围,从声明到结束selectionRange:符号名本身占用的范围,选中的高亮区域children:子符号数组
interface DocumentSymbol { name: string; kind: SymbolKind; range: Range; selectionRange: Range; children?: DocumentSymbol[]; }你可以把range理解为书里一章的起止页码,而selectionRange是这一章的标题所在那一行。上下文路径,本质上就是从符号树根节点到光标所在节点的这条链条。
这里有个容易忽略的细节:range.contains(position)判断时,range.end这一行一般是不包含在内的(VS Code 的Range比较特殊,end 位置的列号是“下一个字符开始”的位置)。所以直接用光标位置去判断,在符号的最后一行很容易漏判。稳妥的做法是把光标位置向前回退一个字符再判断,或者干脆用position.isBefore(symbol.range.end)这种判据。
2.2 从光标反查符号链:LSP 与本地 API 的配合
executeDocumentSymbolProvider底层走的是语言服务器协议(LSP),也就是编辑器把“给我这文档的符号列表”这个请求发给语言服务器,语言服务器返回结构。这也是为什么不同类型的文件返回的符号结构差异会那么大。
拿到符号树之后,剩下的就是纯递归查找。从根节点开始,找到第一个range包含当前光标的符号,然后进入它的children继续找。这个算法非常直接,核心代码也就十几行:
function findContextChain( symbols: vscode.DocumentSymbol[], position: vscode.Position ): vscode.DocumentSymbol[] { for (const symbol of symbols) { if (symbol.range.contains(position)) { return [symbol, ...findContextChain(symbol.children ?? [], position)]; } } return []; }逻辑是:逐个符号检查,如果当前光标在这个符号范围内,就把这个符号加入链条,然后递归查它的子符号。子符号里可能又有嵌套,一路往下直到没有子符号或找不到匹配。
实测下来,对一个 5000 多个符号的中型项目文件,这种纯内存递归查找耗时不到 1 毫秒,性能上完全不用担心。真正耗时的是调用语言服务器获取符号树那一步,这个我们留到性能优化章节详细说。
2.3 没有语言服务器时的兜底方案
不是所有语言都有靠谱的 LSP 实现。比如 SQL、Markdown、部分配置文件,或者你在处理一段临时粘贴进来的日志,executeDocumentSymbolProvider返回的可能是空。这时候 context-mode 就退化成不可用状态吗?我建议准备一套兜底逻辑。
最实用的兜底是基于缩进推断。扫描光标前后的行,统计当前行的缩进层级,然后回溯找最近一层缩进更少的“声明行”,用正则匹配这行里像函数名、类名、块名的地方。这个方法听起来粗糙,但对付没有语言服务器的情况足够用了。
function inferContextFromIndent( lines: string[], position: vscode.Position ): string[] { const chain: string[] = []; let indent = indentOf(lines[position.line]); for (let line = position.line; line >= 0; line--) { if (line !== position.line && indentOf(lines[line]) < indent) { chain.unshift(extractNameFromLine(lines[line])); indent = indentOf(lines[line]); } } return chain; }注意这只是启发式方案,它无法处理“同级嵌套但没缩进的装饰器”“花括号和缩进不一致的代码”等特殊情况。所以我的策略是:先尝试语言服务器,失败再降级到缩进推断,降级时给状态栏路径加一个特殊前缀,比如“indent:”让用户知道当前精度有限。
2.4 两种方案怎么选
直觉上 LSP 方案一定更好,但实际工程里要看你的使用场景。
| 对比维度 | LSP 符号树 | 缩进启发式 |
|---|---|---|
| 准确度 | 高,结构真实 | 中低,嵌套复杂时容易错 |
| 首次调用耗时 | 80~200ms 甚至更高 | 几乎为零 |
| 语言覆盖 | 只覆盖有语言服务器的语言 | 纯文本都能跑 |
| 实现复杂度 | 低,编辑器封装了接口 | 低但边界情况多 |
| 适用场景 | 主力工作区、多语言混合 | 临时文件、日志、无 LSP 的文件 |
我的建议很直接:优先走 LSP,把它当主方案;缩进推断只做降级,不要在主力语言上依赖它。自己造词法分析器是性价比最低的方案,符号边界、注释里的假代码、字符串模板,每一个都够写一万字论文,没必要。
3. 手写一个最小可用的 context-mode 扩展
3.1 初始化工程与声明能力
我以 VS Code 扩展为例,因为它的 API 最贴近大众,另一个主流编辑器 Neovim 也可以做,但需要借助 LSP 客户端插件,代码会更复杂。用官方脚手架初始化一个 TypeScript 扩展:
npm install -g yo generator-code yo code生成器会让你选扩展类型,选择“New Extension (TypeScript)”,然后一路回车。生成出来的工程里有package.json、src/extension.ts、tsconfig.json这些标准文件。我们在package.json里声明命令、配置项和快捷键:
{ "contributes": { "commands": [ { "command": "contextMode.toggleFocus", "title": "Context Mode: Toggle Focus" } ], "keybindings": [ { "command": "contextMode.toggleFocus", "key": "ctrl+alt+f", "mac": "cmd+alt+f" } ], "configuration": { "title": "ContextMode", "properties": { "contextMode.dimOpacity": { "type": "number", "default": 0.35, "description": "聚焦模式下外部代码的透明度,0 到 1" } } } } }这里我只是截取了关键片段,实际工程里还要配置activationEvents,不过现代 VS Code 支持onStartupFinished这种触发时机,建议直接用它,不用逐个命令声明。
3.2 核心链路:符号查找与状态栏渲染
核心控制器就是一个类,负责监听事件、刷新状态栏、更新聚焦装饰。先看入口和主流程:
export function activate(context: vscode.ExtensionContext) { const controller = new ContextModeController(); context.subscriptions.push(controller); } class ContextModeController implements vscode.Disposable { private statusBarItem: vscode.StatusBarItem; private dimDecoration: vscode.TextEditorDecorationType; private disposables: vscode.Disposable[] = []; private focusMode = false; private lastVersion = -1; private cachedSymbols: vscode.DocumentSymbol[] = []; constructor() { this.statusBarItem = vscode.window.createStatusBarItem( vscode.StatusBarAlignment.Right, 120 ); this.dimDecoration = vscode.window.createTextEditorDecorationType({ opacity: '0.35' }); this.disposables.push( vscode.window.onDidChangeActiveTextEditor(() => this.refresh()), vscode.workspace.onDidChangeTextDocument((e) => this.onDocumentChange(e)), vscode.window.onDidChangeTextEditorSelection((e) => this.onSelectionChange(e)) ); this.refresh(); } private async refresh() { const editor = vscode.window.activeTextEditor; if (!editor) { this.statusBarItem.hide(); return; } if (this.lastVersion !== editor.document.version) { this.cachedSymbols = await vscode.commands.executeCommand( 'vscode.executeDocumentSymbolProvider', editor.document.uri ) ?? []; this.lastVersion = editor.document.version; } const chain = findContextChain(this.cachedSymbols, editor.selection.active); this.updateStatusBar(chain, editor.document.fileName); this.updateFocusDecoration(editor, chain); } }关键思路是“文档版本哈希”:语言服务器返回符号树后,只要文档内容没变,符号树就一直有效,不需要每次光标移动都重新请求。我把lastVersion和cachedSymbols都缓存在控制器里,光标移动时只跑纯内存的递归查找。这个设计后面会细讲,但请记住它是整个扩展流畅体验的基石。
更新状态栏时,我做了个简单的降级处理:
private updateStatusBar( chain: vscode.DocumentSymbol[], fileName: string ) { if (!chain.length) { this.statusBarItem.text = '$(circle-outline) 无上下文'; this.statusBarItem.show(); return; } const base = fileName.split('/').pop() ?? ''; const path = [base, ...chain.map(s => s.name)].join(' / '); this.statusBarItem.text = `$(symbol-misc) ${path}`; this.statusBarItem.tooltip = path; this.statusBarItem.show(); }3.3 聚焦模式:调暗比折叠更稳
聚焦模式最容易想到的实现是“把其他代码折叠起来”。但实测后我发现,VS Code 的折叠命令editor.fold/editor.unfold是围绕光标位置起作用的,你没法精确告诉它“只折叠这些 range 内外的内容”。要精确控制折叠,得走折叠提供器(FoldingRangeProvider),实现成本和维护成本都非常高。
我最终推荐用 decoration 方案:把当前上下文链覆盖的所有 range 保留,把整个文档减去这些 range 之后剩余的部分调暗。实现上就是“求差集”:
private updateFocusDecoration( editor: vscode.TextEditor, chain: vscode.DocumentSymbol[] ) { if (!this.focusMode) { editor.setDecorations(this.dimDecoration, []); return; } if (!chain.length) { editor.setDecorations(this.dimDecoration, []); return; } const whole = new vscode.Range( new vscode.Position(0, 0), new vscode.Position(editor.document.lineCount + 1, 0) ); const keepRanges = mergeRanges(chain.map(s => s.range)); const dimRanges = invertRanges(whole, keepRanges); editor.setDecorations(this.dimDecoration, dimRanges); } function mergeRanges(ranges: vscode.Range[]): vscode.Range[] { const sorted = ranges.slice().sort((a, b) => a.start.compareTo(b.start)); const merged: vscode.Range[] = []; for (const r of sorted) { const last = merged[merged.length - 1]; if (last && !r.start.isAfter(last.end)) { merged[merged.length - 1] = new vscode.Range( last.start, r.end.isAfter(last.end) ? r.end : last.end ); } else { merged.push(r); } } return merged; } function invertRanges( whole: vscode.Range, keep: vscode.Range[] ): vscode.Range[] { const result: vscode.Range[] = []; let cursor = whole.start; for (const r of keep) { if (r.start.isAfter(cursor)) { result.push(new vscode.Range(cursor, r.start)); } cursor = r.end; } if (cursor.isBefore(whole.end)) { result.push(new vscode.Range(cursor, whole.end)); } return result; }这段代码的核心逻辑是:先合并所有要保留的区域(比如类的 range 和方法的 range 有重叠),再把保留区域之外的每一段都变成一个新的 range,最后把这一批 range 交给 decoration 调暗。
decoration 方案有几个隐藏好处:不改变行号,脚本录制、断点命中、git diff 的行号都不会偏移;调暗是视觉级的,不会影响代码块的复制、查找;性能开销也远比频繁折叠折叠低。
3.4 本地验证:启动扩展宿主看效果
写完之后按F5启动 Extension Development Host,VS Code 会开一个新窗口加载你的扩展。打开一个真实的项目文件,把光标在不同方法之间移动,观察右下角状态栏。
- 光标在类声明上的时候,状态栏应该是
文件名 / 类名 - 移动到某个方法内部,变成
文件名 / 类名 / 方法名 - 方法里如果有嵌套 if,部分语言会继续往下钻,显示
文件名 / 类名 / 方法名 / if - 切到无语言服务器的文件(比如新建的
.sql),如果返回空链,状态栏进入“无上下文”状态,你可以测试降级逻辑是否生效
有一个细节我在第一版漏掉了:executeDocumentSymbolProvider返回的符号数量取决于语言服务器,不同语言差别巨大。TypeScript 的符号树很丰富,if/for/while 都会作为符号返回;而 Python 的函数内部,LSP 往往只返回函数和类,不会把 if 块算作独立符号。这没关系,得益于缓存机制,即使链短一点,用户也不会觉得卡顿。
4. 从“最小实现”到“真能用”:配置、快捷键与性能优化
4.1 配置项怎么设计
最小版本只有硬编码的调暗透明度,真正常用起来,我加了三个开关,全部放在contextMode配置节点下:
| 配置项 | 类型 | 默认值 | 作用 |
|---|---|---|---|
contextMode.enableStatusBar | boolean | true | 是否显示状态栏路径 |
contextMode.dimOpacity | number | 0.35 | 聚焦模式下外部代码透明度 |
contextMode.maxPathDepth | number | 0 | 路径最长显示层级,0 表示不限制 |
maxPathDepth是我在真实场景里加出来的。当文件路径特别深、类名又长时,状态栏会被撑爆,看起来非常难受。把它设为 3,就意味着只显示最近的三层,比如getUserById / try / callback,前面的类名和文件名全被省略。同时在 tooltip 里保留完整路径,悬停即可看全。
多语言工程还有一个潜在需求:对不同的语言使用不同的聚焦强度。比如 TypeScript 项目你希望把装饰器也调暗,但 Java 项目里注解往往包含重要信息,不应该跟着调暗。这个比较小众,我目前的实现里是全局统一配置,不推荐一开始就做复杂的语言级覆盖,等有真实团队反馈再上也不迟。
4.2 快捷键与命令注册细节
命令和快捷键的声明在package.json里,真正能用的绑定建议放在用户级keybindings.json,因为每个人习惯不同。默认绑定我用的是ctrl+alt+f/cmd+alt+f,行业惯例里这个键位代表“查找”,冲突可能性不算小,所以一定要允许用户覆盖。
{ "key": "cmd+alt+f", "command": "contextMode.toggleFocus", "when": "editorTextFocus" }when条件也很重要。不加限制的话,你在资源管理器里按这个快捷键,命令也会执行,但那时没有激活编辑器,行为就不稳定。我建议至少加上editorTextFocus,如果聚焦模式只在部分场景生效,可以再加editorLangId == typescript这类语言条件。
状态栏那一栏我同样注册了点击命令contextMode.showFullPath,点击后会弹出一个信息框显示完整上下文路径,方便不便悬停的场景(比如触控板)。
4.3 性能问题:别让每次光标移动都惊动语言服务器
这是整个项目里我踩得最深的一个坑。初版实现里,光标每次移动我都重新调用executeDocumentSymbolProvider,结果在 2 万行的文件里,光标移动一次至少卡 300 毫秒,彻底没法用。
核心问题的根源是语言服务器本身有状态、有网络通信开销,很不适合高频调用。解决方案就是我前面提到的文档版本缓存:只有文档变化时才重新取符号树,光标移动只在内存里的缓存树上查。
在用户打字的场景,文档变化的频率不低,因此还要在文档变化事件上做防抖。我用 200 毫秒的防抖窗口,连续打字不会触发多次 provider 请求:
private updateTimer: NodeJS.Timeout | undefined; private onDocumentChange(e: vscode.TextDocumentChangeEvent) { const editor = vscode.window.activeTextEditor; if (!editor || e.document !== editor.document) return; if (this.updateTimer) clearTimeout(this.updateTimer); this.updateTimer = setTimeout(() => this.refresh(), 200); }光标选择变化事件则完全不需要防抖,因为走的是缓存树,开销可以忽略。实测一组参考数据:一个 8000 行的 TypeScript 类文件,首次获取符号树耗时约 120ms,之后每次光标移动只有不到 1ms 的纯递归查找,完全无感。
但缓存方案有个副作用:如果语言服务器还没有就绪,第一次executeDocumentSymbolProvider返回空,缓存里存的就是空树,后续光标移动看着也是“无上下文”。我的处理是,在拿到空结果时把lastVersion置为-1,强制下一轮事件重新请求一次,而不是立即放弃。
5. 在真实工程里跑起来:意外情况与排查过程
5.1 多光标与选择区域:上下文到底该以谁为准
我第一次把扩展分享给同事时,他用鼠标拖了一长段选区问我:这段选区的上下文是什么?我愣住了,因为我的实现只取selection.active,也就是主光标的位置。
后来想清楚了一个原则:多光标场景下,上下文链只能代表主光标,硬要为每个光标都算路径,状态栏根本放不下,而且视觉上是灾难。所以跟踪主光标、其他光标不显示路径,是合理的默认选择。选区状态下,上下文可以取selection.active,也可以取selection.anchor,区别只是你更关心“光标结束位置”还是“光标起始位置”,我选择取active,因为这是大多数编辑器的 breadcrumb 插件的通用行为。
5.2 Vue/JSX 这类嵌入语言:符号树并不是万能的
Vue 单文件组件里,<template>区域在默认的 Vue 语言服务器下,往往不会返回DocumentSymbol。这导致光标在模板段落移动时,状态栏直接显示“无上下文”,体验很割裂。
我的解决方案是降级到缩进启发式:如果findContextChain返回空,就调用inferContextFromIndent,并把路径前缀改成indent:。这样在模板区至少能显示“当前在 template 第几层 div 里”,虽然不像函数路径那么精确,但比空白强得多。
JSX 也有类似问题。JSX 内部的普通 JS 表达式、字段访问,TypeScript 语言服务器通常不会给每个 JSX 元素建独立的符号节点,而是整体当作一个表达式。这就意味着你在 JSX 里写业务逻辑时,上下文链往往就停在最近的函数箭头处。接受这个粒度,不要试图让语言服务器给你做语义级别的 JSX 结构分析。
5.3 切换标签页、diff 视图与文档版本过期
这几个场景都有一个共性:编辑器对象在变化,但事件触发时机并不规律。
掉过的一个坑是 diff 视图。在 diff 编辑器中,vscode.window.activeTextEditor并不一定是你正在看的那一侧。左侧只读版本和右侧可编辑版本,哪个才应该绑定状态栏?我的经验是:如果当前 activeTextEditor 指向右侧可编辑文档,直接处理;如果是左侧只读侧,状态栏就不该变化,否则用户体验会很怪。实现方式是记录上一次处理的 URI,只有当 URI 变化或文档版本变化时才刷新。
文档版本过期是另一个隐患。外部格式化、符号重命名、重新打开文件等操作,会让语言服务器返回的符号树和当前文档内容不一致。我在refresh开头加了一个版本比较,一旦发现版本不一致,就强制清缓存并重新请求,避免状态栏显示一个已经不存在的旧方法名。
5.4 一条实测有效的降级策略
把前几节的结论汇总成一条排查链路,碰到问题按顺序走:
- 确认
activeTextEditor存在且文档不是只读侧。没有它就什么都不显示,不报错。 - 检查
document.version是否和缓存版本一致。不一致就重新取符号树。 - 调用
executeDocumentSymbolProvider。返回空时不要立刻清状态栏,把下次缓存版本标记为“需要重试”,等下一个事件到来再试。 - 符号树仍为空时,降级到缩进启发式,并在路径前加
indent:标识。 - 聚焦模式里,decoration 的 range 有可能因为文档编辑而错位。在
onDidChangeTextDocument里要调用一次updateFocusDecoration,哪怕路径没变,也要重新设置一次 decorations,否则旧的调暗区域会漂移。
这套降级策略我已经跑了两个多月,没再出现状态栏长期空白或者调暗区域错乱的情况。
6. 更进一步:context-mode 不止于代码编辑器
6.1 终端与日志场景的上下文锚点
写完编辑器扩展后我突然发现,这种“知道自己在哪个上下文里”的需求远远不限于 IDE。运维排障时,面对动辄几千行的日志文件,如果你能知道当前看到的日志来自哪个模块、哪个函数,定位速度会完全不同。最简单的做法是在日志输出里带上结构化上下文前缀,比如[payment-service / createOrder / retry],配合 grep 时全程心里有数。
6.2 长文阅读与写作工具
长文阅读工具的侧边栏大纲高亮,其实就是一个 context-mode。你在第 5 章第 3 节下面滚动时,侧边栏同步高亮对应章节标题,这就是“当前位置在整个文档结构中的上下文感知”。做写作工具时同样如此,当前正在编辑的是哪一节,用面包屑固定在顶部,写长文的人幸福感会直线上升。
6.3 AI 编程助手语境下的上下文信号
我最近做 AI 补全相关实验时发现,如果把“文件路径 + 当前上下文链”打包喂给模型,补全准确率明显好于只给文件名的方案。因为模型能根据符号路径推断出更精确的变量命名风格和函数意图。这个思路可以推广到任何 RAG 类的代码检索场景里:用文件路径 + 类名 + 方法名作为检索锚点,比全文搜索精准得多。本质上,context-mode 的核心贡献就是“把当前位置转换成一个语义坐标”,这个语义坐标在 AI 场景里一样值钱。
我自己用下来的最大感受是,状态栏里的那个小路径,看起来只是个文本片段,但它彻底改变了我在长文件里翻找的姿势。以前我得靠记忆反复上下滚动确认位置,现在只要扫一眼右下角,就知道自己站在哪棵树的哪根枝桠上。最后再分享一个小技巧:把maxPathDepth设置为 2,在超长文件里能进一步减少视觉噪音,路径的 tooltip 会保留完整信息,需要时悬停即可。去你的团队里把keybindings.json里那段快捷键配置发出去,这东西值得人手一份。