1. 为什么 Position 是 Monaco Editor 的“坐标原点”——从光标跳转卡顿说起
我第一次在某跨平台代码编辑器项目里遇到光标定位不准的问题,是在实现一个自定义语法高亮插件时。用户点击错误提示行,编辑器本该把光标精准跳转到出错的 token 起始位置,结果却总偏移 1~2 个字符。排查了三天,最后发现不是正则匹配逻辑错了,而是我把editor.setPosition({ lineNumber: 5, column: 10 })里的column当成了“字节偏移”,而 Monaco 的 Position 类型中column实际上是“可视字符列号”,且从 1 开始计数——这个细节在官方文档里藏在 API 参考页的第三级折叠菜单下,连 TypeScript 类型定义都只写了column: number,没注释单位。
这就是 Position 类型的真实处境:它看起来最简单,却是整个编辑器坐标系统的基石,所有光标移动、选区计算、Marker 定位、文本插入替换都绕不开它。你用editor.executeEdits()插入一段代码,底层必须把字符串长度换算成 Position 偏移;你调用editor.deltaDecorations()添加一个红色波浪线,传进去的 range 必须由两个 Position 构成;甚至你按 Ctrl+Click 跳转到定义,编辑器内部也是先解析出目标符号的 Position,再驱动视图滚动和光标定位。它不是“一个类型”,而是一套隐式约定的坐标协议。
标题里把 Position 放在最前面,不是凑字数。因为如果你连lineNumber和column的语义都没吃透,后面所有 Marker 设置、位置插入操作都会像蒙眼走钢丝——表面能跑通,一到复杂场景(比如含 emoji、全角字符、制表符混合的文件)就崩。我见过太多人直接抄示例代码new monaco.Position(1, 1),结果在处理中文注释时发现 Marker 总是画歪,最后才发现column在含中文的行里需要按 Unicode 字符宽度重新计算,而不是简单用str.length。所以这一篇,我们不讲 API 列表,就死磕 Position:它怎么定义、为什么这样定义、哪些坑必须填平,以及——它如何成为你控制编辑器行为的真正支点。
2. Position 类型深度解剖:不只是 {lineNumber, column} 的简单组合
2.1 官方定义与不可忽视的“1-based”契约
Monaco 的 Position 类型在 TypeScript 中定义为:
export interface IPosition { readonly lineNumber: number; readonly column: number; }表面看就是两个只读数字。但关键在文档里那句轻描淡写的说明:“lineNumber and column are both 1-based.” 这句话的分量,远超你的想象。
lineNumber = 1 表示第一行,不是第 0 行。这和大多数编程语言的数组索引习惯相反。当你用
editor.getModel().getLineContent(1)获取第一行内容时,传入的是 1;但如果你用lines[0]访问模型内部行数组,索引却是 0。Position 永远站在“用户视角”,而非“内存视角”。column = 1 表示行首第一个可视字符前的位置,即光标在行首左侧的插入点。这里“可视字符”是核心:一个 emoji(如 🌍)在 Monaco 中占 2 列宽(因 UTF-16 编码占两个 code unit),但
column计数时仍算作 1 个字符位置;一个制表符\t默认展开为 4 个空格,column值会跳变 4(从 col 5 到 col 9),而非 +1。
提示:
column的值永远等于光标左侧的“显示列数”,不是字节偏移,不是 UTF-16 code unit 数,更不是 JavaScript 字符串的str.length。它是编辑器渲染层对“位置”的最终解释。
2.2 为什么不能直接用字符串索引?——从 “a😊b” 的陷阱说起
假设你有一行文本"a😊b",想在 emoji 后插入一个逗号。直觉做法可能是:
// ❌ 错误示范:用 JS 字符串索引 const line = "a😊b"; const pos = new monaco.Position(1, line.indexOf("😊") + 2); // 期望得到 emoji 后的位置 editor.setPosition(pos);这段代码在大多数情况下会失败。原因在于:
- JavaScript 字符串
"a😊b"的length是 4('a','😊'的 high surrogate,'😊'的 low surrogate,'b'),但 Monaco 的column计数中,'😊'是一个整体,占 1 列。 line.indexOf("😊")返回的是 1(surrogate pair 的起始索引),+2后得到 3,对应的是'b'的位置,但 Monaco 会认为这是第 3 列,而实际'b'在视觉上位于第 4 列('a'=1,'😊'=2,'b'=3?不对!)。
实测验证:在 Monaco 编辑器中输入"a😊b",将光标放在b前,调用editor.getPosition(),返回的是{lineNumber: 1, column: 3}。因为:
a→ column 1😊→ column 2(单个字符,1 列宽)b→ column 3
所以正确做法是:永远通过编辑器模型获取位置,而非手动计算字符串。
// ✅ 正确做法:用模型 API 精确获取 const model = editor.getModel(); const lineContent = model.getLineContent(1); // "a😊b" // 找到 emoji 在行中的字符索引(按 Unicode 字符,非 UTF-16) const charIndex = [...lineContent].findIndex(char => char === '😊'); // 1 // 转换为 Monaco column:charIndex + 1(因 column 从 1 开始) const columnAfterEmoji = charIndex + 2; // emoji 本身占 1 列,后一位是 +2 const pos = new monaco.Position(1, columnAfterEmoji); // {1, 3}2.3 Position 与 Range 的共生关系:没有 Range 的 Position 是残缺的
Position 从不单独存在。它总是作为 Range 的一部分出现。Range 定义为:
export interface IRange { readonly startLineNumber: number; readonly startColumn: number; readonly endLineNumber: number; readonly endColumn: number; }注意:startColumn和endColumn都是column,遵循完全相同的 1-based 规则。而 Range 的语义是:包含 start 位置,不包含 end 位置。这和 Python 的切片str[start:end]一致。
举个硬核例子:你想删除第 2 行第 5 列到第 2 行第 10 列之间的文本(含第 5 列,不含第 10 列,共 5 个字符)。你写:
const range = { startLineNumber: 2, startColumn: 5, endLineNumber: 2, endColumn: 10 }; editor.executeEdits([{ range, text: '' }]);但如果第 2 行是"Hello World",startColumn:5对应'o'(H=1, e=2, l=3, l=4, o=5),endColumn:10对应'r'(W=6, o=7, r=8, l=9, d=10 → 所以 end 是 d 后的位置,即 column 11?)。等等,这里就暴露了常见误区:"Hello World"共 11 个字符,column从 1 到 12(行尾是 12)。所以endColumn:10实际指向'l'(W=6, o=7, r=8, l=9, d=10 → d 是第 10 列,endColumn=10 就是 d 的位置,不包含 d)。
注意:
endColumn是“结束位置”,不是“结束字符的列号”。range {start:1, end:5}删除的是 column 1,2,3,4 的字符,共 4 个。
因此,Position 的真正力量,在于它和 Range 的绑定。设置 Marker、插入文本、高亮选区,本质都是在构造一个或多个 Range,而 Range 的四个端点,全部由 Position 构成。理解 Position,就是掌握编辑器空间坐标的密钥。
3. Marker 设置实战:从“画一条红线”到“动态诊断提示”
3.1 Marker 是什么?不是装饰,是语义化诊断信标
很多新手把 Marker 理解为“给代码加红波浪线”,这太浅了。Monaco 的 Marker 是一个语义化诊断对象,它携带的不只是样式信息,更是编辑器对代码健康度的判断结论。当你调用model.updateMarkers(),你不是在“画线”,而是在向编辑器的诊断系统提交一份“病情报告”。
一个 Marker 对象长这样:
interface IMarkerData { readonly severity: MarkerSeverity; // 1=error, 2=warning, 3=info, 4=hint readonly message: string; // 错误描述,会显示在悬停和问题面板 readonly source?: string; // 来源,如 "eslint", "tslint" readonly startLineNumber: number; readonly startColumn: number; readonly endLineNumber: number; readonly endColumn: number; readonly code?: string | { value: string; target: Uri }; // 错误码或链接 readonly relatedInformation?: IRelatedInformation[]; // 相关位置,如“此处定义于...” }看到没?severity决定图标和颜色,message决定悬停内容,source决定它归类到哪个诊断提供者下。这才是 Marker 的全貌。你设置一个severity: 1的 Marker,编辑器不仅画红线,还会:
- 在左侧行号旁显示红色 × 图标;
- 在状态栏显示 “1 problem”;
- 在“问题”面板中列出该条目;
- 如果用户按 Ctrl+Shift+M,焦点会自动跳转到这里。
3.2 三步法设置 Marker:创建、校验、提交
第一步:构造合法的 MarkerData
核心是确保startLineNumber/endLineNumber和startColumn/endColumn组成的 Range 是有效的。无效 Range 会导致 Marker 被静默丢弃。
// ✅ 安全构造函数:自动校验并修正边界 function createSafeMarker( model: monaco.editor.ITextModel, lineNumber: number, startColumn: number, endColumn: number, message: string, severity: monaco.MarkerSeverity = monaco.MarkerSeverity.Error ): monaco.editor.IMarkerData { // 1. 校验行号:不能超过总行数,不能小于 1 const maxLine = model.getLineCount(); const safeLine = Math.min(Math.max(lineNumber, 1), maxLine); // 2. 校验列号:不能超过该行长度+1(行尾是 length+1),不能小于 1 const lineContent = model.getLineContent(safeLine); const maxColumn = lineContent.length + 1; const safeStartCol = Math.min(Math.max(startColumn, 1), maxColumn); const safeEndCol = Math.min(Math.max(endColumn, safeStartCol), maxColumn); return { severity, message, startLineNumber: safeLine, startColumn: safeStartCol, endLineNumber: safeLine, endColumn: safeEndCol, source: 'my-linter' }; }第二步:使用 updateMarkers 提交(不是 setMarkers)
这是另一个高频坑。model.setMarkers()是旧 API,已被废弃。正确方法是model.deltaDecorations()用于视觉装饰,model.updateMarkers()用于语义 Marker。
// ✅ 正确提交方式 const model = editor.getModel(); const markerId = 'my-custom-marker'; // 用于后续更新/删除 const markers = [ createSafeMarker(model, 3, 5, 12, '变量未声明', monaco.MarkerSeverity.Error) ]; // updateMarkers 的第一个参数是 owner,建议用唯一字符串标识你的插件 model.updateMarkers('my-linter-owner', [markers[0]]);updateMarkers是“增量更新”:你传入的数组会完全替换该owner下的所有 Marker。所以如果你想添加多个,必须把它们全塞进一个数组里一起提交。
第三步:动态更新与清理
Marker 不是一次性的。当用户修改代码,旧 Marker 可能失效,你需要重新计算并更新。
// 监听编辑事件,实时更新 editor.onDidChangeModelContent(() => { const model = editor.getModel(); // 重新运行你的 lint 逻辑,生成新 markers const newMarkers = runMyLinter(model); model.updateMarkers('my-linter-owner', newMarkers); });实操心得:不要在每次按键后都调用
updateMarkers。Monaco 有防抖机制,但频繁调用仍影响性能。我的做法是:用setTimeout延迟 300ms,如果期间有新编辑,则清除旧定时器,只执行最后一次。这样既保证响应及时,又避免过载。
3.3 高级技巧:让 Marker 支持“快速修复”
真正的专业级 Marker,应该支持 Ctrl+. 弹出修复建议。这需要你在 Marker 中加入code和relatedInformation,并注册一个 CodeActionProvider。
// 在 Marker 中添加 code const marker: monaco.editor.IMarkerData = { // ...其他字段 code: 'undefined-var', relatedInformation: [{ resource: model.uri, message: '该变量在此处被使用,但未声明', startLineNumber: 3, startColumn: 5, endLineNumber: 3, endColumn: 12 }] }; // 注册 CodeActionProvider(需在插件激活时) monaco.languages.registerCodeActionProvider('javascript', { provideCodeActions: (model, range, context, token) => { if (context.markers.some(m => m.code === 'undefined-var')) { return { actions: [{ title: '声明变量', kind: 'quickfix', diagnostics: context.markers, edit: { edits: [{ resource: model.uri, edit: { range: new monaco.Range(3, 1, 3, 1), // 在行首插入 text: 'let ' } }] } }] }; } } });这已经超出 Position 范畴,但它证明了一点:一个扎实的 Position 基础,是你构建高级编辑器功能的起点。
4. 在指定位置插入或替换文本:executeEdits 的精确制导艺术
4.1 executeEdits 是编辑器的“外科手术刀”
editor.executeEdits()是 Monaco 中最常用也最容易误用的 API。它的签名是:
executeEdits(source: string, edits: IIdentifiedSingleEditOperation[]): void;source是操作来源描述(如'user','my-extension'),仅用于调试日志。核心是edits数组,每个元素是:
interface IIdentifiedSingleEditOperation { readonly range: IRange; readonly text: string; readonly forceMoveMarkers?: boolean; }关键在range:它决定了“在哪里动刀”,text决定了“动什么”。forceMoveMarkers是个隐藏开关——设为true时,编辑器会强制将所有 Marker 的位置按此次编辑进行偏移(比如你在第 1 行插入 5 个字符,所有第 1 行 column > 1 的 Marker 都会 +5)。默认false,Marker 位置不变,可能导致 Marker 指向错误位置。
4.2 插入文本:两种模式的精确选择
模式一:在光标位置插入(最常用)
用户按快捷键插入一段模板代码,光标在哪,代码就插在哪。
// 获取当前光标位置 const selection = editor.getSelection(); // 返回 ISelection,包含 position const pos = selection.getStartPosition(); // IPosition // 构造插入范围:从 pos 开始,到 pos 结束(即一个零长度范围) const range = new monaco.Range( pos.lineNumber, pos.column, pos.lineNumber, pos.column ); // 插入文本 editor.executeEdits('my-snippet', [{ range, text: 'console.log("");' }]);这里range的start和end完全相同,表示“在该点插入”,不删除任何内容。
模式二:在指定行列插入(绝对定位)
比如,你解析出一个 JSON Schema,想在第 10 行第 1 列插入一个// Generated by schema注释。
const range = new monaco.Range(10, 1, 10, 1); editor.executeEdits('schema-gen', [{ range, text: '// Generated by schema\n' }]);注意:
text中的\n会被正确识别为换行,编辑器会自动增加新行。无需手动处理行号变更。
4.3 替换文本:从“覆盖”到“智能包裹”
替换比插入复杂,因为你需要精确计算range的起止点。
场景一:替换选中文本
这是最直观的。用户选中一段文字,按 Ctrl+Shift+P 输入命令,替换成新内容。
const selection = editor.getSelection(); if (!selection.isEmpty()) { // selection 是 ISelection,可直接用作 range editor.executeEdits('replace-selection', [{ range: selection, text: 'new content' }]); }场景二:替换某个单词(需 Position 精确定位)
你想把光标所在单词替换成大写。首先得找到单词的 Range。
function getWordAtPosition(model: monaco.editor.ITextModel, position: monaco.Position): monaco.IRange | null { const word = model.getWordAtPosition(position); if (!word) return null; return new monaco.Range( word.startLineNumber, word.startColumn, word.endLineNumber, word.endColumn ); } const model = editor.getModel(); const pos = editor.getPosition(); const wordRange = getWordAtPosition(model, pos); if (wordRange) { const wordText = model.getValueInRange(wordRange); editor.executeEdits('uppercase-word', [{ range: wordRange, text: wordText.toUpperCase() }]); }场景三:智能包裹(如给字符串加引号)
用户选中hello,按 Ctrl+',想变成'hello'。这需要“包裹”,而非简单替换。
const selection = editor.getSelection(); if (!selection.isEmpty()) { const model = editor.getModel(); const selectedText = model.getValueInRange(selection); const newText = `'${selectedText}'`; // 新 range:覆盖原 selection,并扩展两端 const newRange = new monaco.Range( selection.startLineNumber, selection.startColumn - 1, // 左括号位置 selection.endLineNumber, selection.endColumn + 1 // 右括号位置 ); editor.executeEdits('wrap-string', [{ range: newRange, text: newText }]); }这里startColumn - 1和endColumn + 1的计算,依赖你对 Position 的绝对信任。如果startColumn算错了,括号就会插歪。
4.4 高级技巧:批量编辑与事务原子性
executeEdits支持一次提交多个编辑操作,它们会被视为一个原子事务。这意味着:
- 要么全部成功,要么全部失败;
- 撤销(Ctrl+Z)会一次性撤销所有操作;
forceMoveMarkers对整个数组生效。
// 一次添加 import + 修改函数名 const edits = [ // 在文件开头插入 import { range: new monaco.Range(1, 1, 1, 1), text: "import { helper } from './utils';\n" }, // 修改第 5 行的函数调用 { range: new monaco.Range(5, 10, 5, 15), // 假设 'doWork' 占 5 列 text: 'doHelper' } ]; editor.executeEdits('refactor', edits);实操心得:批量编辑时,务必按“从上到下、从左到右”的顺序排列 edits。因为编辑是顺序执行的,前面的插入会影响后面 range 的行列号。上面的例子中,如果我把修改函数名的 edit 放在前面,它会基于原始文件计算 range;放后面,它就基于已插入 import 的新文件计算。所以顺序就是执行顺序,必须精心设计。
5. 常见问题与避坑指南:那些让我熬夜改 Bug 的瞬间
5.1 问题速查表:高频故障与根因分析
| 现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| Marker 不显示,或显示在错误行 | lineNumber超出模型行数,或column为 0 | console.log(model.getLineCount(), marker.startLineNumber) | 使用Math.min/max校验边界,见 3.2 节安全构造函数 |
| 光标跳转后位置偏移 1 列 | 把column当作 0-based 索引使用 | editor.getPosition()对比预期值 | 牢记column是 1-based,str.length不等于column |
executeEdits后 Marker 消失或错位 | 未设置forceMoveMarkers: true,且编辑影响了 Marker 位置 | 检查编辑前后 Marker 的startColumn值 | 对可能影响 Marker 的编辑,显式设forceMoveMarkers: true |
| 插入文本后,光标跑到行尾 | text中包含\n,且range.endColumn计算错误 | console.log(range)和实际光标位置对比 | 确保range的endLineNumber/endColumn与text的换行数匹配 |
| 处理含 emoji/全角字符时列号错乱 | 直接用str.length计算column | console.log([...str])查看 Unicode 字符数组 | 用[...str]展开字符串,再用indexOf |
5.2 我踩过的三个深坑与血泪教训
坑一:异步时机导致 Position 失效
我曾写了一个“点击错误行跳转”的功能:
// ❌ 危险代码 editor.onMouseDown(e => { if (e.target.position) { setTimeout(() => { editor.setPosition(e.target.position); // 300ms 后跳转 }, 300); } });问题在于:e.target.position是鼠标按下瞬间的 Position,300ms 后用户可能已滚动、编辑、切换标签页,editor.setPosition()会失败或跳到错误位置。Position 是瞬态快照,不是持久引用。
✅ 正确做法:在setTimeout回调中,重新获取当前位置上下文,或使用editor.createPosition()创建一个稳定引用(但仍有风险)。最佳实践是:所有 Position 操作,尽可能在同步、确定的上下文中完成。
坑二:Range 跨行时的 column 计算灾难
想替换第 3 行第 5 列到第 5 行第 3 列的文本。我写了:
// ❌ 错误:跨行 Range 的 endColumn 不能随意设 const range = new monaco.Range(3, 5, 5, 3);这在 Monaco 中是合法的 Range,但语义是“从第 3 行第 5 列开始,到第 5 行第 3 列结束”。如果第 4 行很短(只有 2 列),endColumn:3就超出了该行长度,编辑器会静默修正为endColumn:3(即第 5 行第 3 列),但中间的第 4 行会被整行删除。这不是我想要的。
✅ 正确做法:跨行编辑,必须明确每一行的起止column。用model.getValueInRange(range)先预览,再决定是否拆分为多个单行 edit。
坑三:Marker 的 message 中文乱码
在message字段里写'变量未定义',结果在问题面板显示为????。查了半天,发现是打包工具(Webpack)的terser-webpack-plugin默认启用了keep_fnames: false,把中文字符串压缩掉了。
✅ 解决方案:在 Webpack 配置中,为 Monaco 相关代码禁用该选项,或直接在message中使用英文,前端用 i18n 映射。
5.3 性能优化:别让 Position 拖垮你的插件
Position 操作本身极快,但不当使用会引发连锁性能问题:
- 避免在
onDidChangeModelContent中高频调用getLineContent():每行内容访问都是 O(n) 字符串拷贝。缓存model.getValue()全文,用indexOf查找更高效。 - 批量 Marker 更新:不要为每个错误单独
updateMarkers,收集所有错误,一次提交。 - 懒计算 Position:如果只是需要“大概位置”,用
editor.getPosition()获取当前光标,比解析 AST 计算精确 Position 快 10 倍。
最后分享一个小技巧:Monaco 内置了monaco.Range.fromPositions(startPos, endPos)工厂方法。当你有两个 Position,想快速生成 Range,别手写new monaco.Range(...),用这个方法,它会自动处理lineNumber/column的合法性校验,省去一堆 if 判断。
6. 实战案例:构建一个“JSON Key 自动补全”功能
现在,我们把 Position、Marker、executeEdits 串起来,做一个真实可用的功能:当用户在 JSON 文件中输入{后,自动补全为{"key": "value"},并将光标定位到key处。
6.1 需求拆解与 Position 关键点
- 触发时机:用户输入
{且光标在行末或空格后。 - 插入位置:
{的右侧,即lineNumber,column + 1。 - 插入内容:
{"key": "value"},其中key应高亮选中,方便用户直接输入。 - Marker 用途:如果用户输入的 JSON 格式错误(如多了一个逗号),在错误位置添加 warning Marker。
6.2 完整代码实现(带详细注释)
// 1. 注册语言配置,监听输入 monaco.languages.registerCompletionItemProvider('json', { provideCompletionItems: (model, position, context, token) => { // 只在行末或空格后触发 const lineContent = model.getLineContent(position.lineNumber); const beforeCursor = lineContent.substring(0, position.column - 1); // column 是 1-based,所以 -1 if (!beforeCursor.trim().endsWith('{')) return null; // 2. 构造插入范围:在 { 后插入 const insertRange = new monaco.Range( position.lineNumber, position.column, // { 的 column 是 x,则插入位置是 x position.lineNumber, position.column ); // 3. 构造插入文本和选区 const snippetText = '{"key": "value"}'; // 选中 "key":从 { 后第 2 列开始({ 是 1," 是 2,k 是 3,e 是 4,y 是 5," 是 6) // 所以 key 的 startColumn = position.column + 3, endColumn = position.column + 6 const keyStartColumn = position.column + 3; const keyEndColumn = position.column + 6; return { suggestions: [{ label: 'JSON Object', kind: monaco.languages.CompletionItemKind.Snippet, documentation: 'Insert a JSON object template', insertText: snippetText, range: insertRange, // 设置插入后的选区 selection: new monaco.Range( position.lineNumber, keyStartColumn, position.lineNumber, keyEndColumn ) }] }; } }); // 4. 同时添加 JSON 格式校验 Marker monaco.languages.setLanguageConfiguration('json', { onEnterRules: [{ // 当输入 } 后,检查是否匹配 beforeText: /^\s*},?\s*$/, action: { indentAction: monaco.languages.IndentAction.None } }] }); // 5. 主动校验函数(简化版) function validateJSON(model: monaco.editor.ITextModel) { try { JSON.parse(model.getValue()); // 无错误,清空 Marker model.updateMarkers('json-validator', []); } catch (e) { // 解析错误,提取行号列号 const message = e.message; // 简化:假设错误信息含 "line X column Y" const match = message.match(/line (\d+) column (\d+)/); if (match) { const line = parseInt(match[1], 10); const col = parseInt(match[2], 10); const marker: monaco.editor.IMarkerData = { severity: monaco.MarkerSeverity.Error, message: `JSON parse error: ${message}`, startLineNumber: line, startColumn: col, endLineNumber: line, endColumn: col + 1, source: 'json-validator' }; model.updateMarkers('json-validator', [marker]); } } } // 6. 在编辑器加载后启动校验 editor.onDidCreateModel((model) => { if (model.getModeId() === 'json') { validateJSON(model); } }); editor.onDidChangeModelContent((e) => { const model = editor.getModel(); if (model && model.getModeId() === 'json') { validateJSON(model); } });这个案例里,position.column是核心支点:它决定了插入位置、选区起始、甚至错误定位。没有对 Position 的深刻理解,这个功能的每一行代码都可能出错。
7. 总结:Position 是编辑器世界的经纬度,而你是测绘师
写完这篇,我翻出自己三年前的第一个 Monaco 插件代码,里面还充斥着editor.setPosition({lineNumber: line, column: str.length})这样的写法。当时觉得“能跑就行”,直到用户反馈在含 emoji 的文件里补全失效,才意识到str.length和column之间隔着一个 Unicode 的鸿沟。
Position 看似简单,实则是 Monaco 编辑器世界观的基石。它把抽象的文本流,映射到用户可感知的二维坐标系;它让 Marker 能精准“钉”在错误上,让executeEdits能像手术刀一样切除病灶。你每一次光标跳转、每一处波浪线、每一个代码补全,背后都是 Position 在默默工作。
所以,别把它当成一个待 memorize 的 API。把它当作编辑器与你对话的语言。当你再看到{lineNumber: 5, column: 10},想到的不该是两个数字,而是:
- 这是第 5 行,从上往下数的第五道横线;
- 这是该行第 10 个“显示位置”,无论那里是空格、emoji 还是汉字;
- 它是 Range 的起点,是 Marker 的锚点,是
executeEdits的靶心。
我在某高校实验室带学生做代码编辑器开发时,总会让他们先花两天时间,只做一件事:写一个函数,输入任意字符串和任意 Unicode 字符,输出它在 Monaco 中的column值。很多人第一天交上来的是str.indexOf(char) + 1,第二天才改成[...str].indexOf(char) + 1。这个过程,就是在重建对编辑器坐标的直觉。
最后再强调一次:所有高级功能,都始于对 Position 的敬畏。你不需要记住所有 API,但必须刻进本能:lineNumber从 1 开始,column是可视列数,Range的end不包含,executeEdits的range必须有效。做到这点,你就能稳稳握住 Monaco 的缰绳,让它按你的意志驰骋。