一、背景介绍
Web 编辑器是开发者在编写代码时必不可少的工具,本文将针对 monaco 编辑器的 dom 结构进行介绍并重点讲述其中 minimap 的实现原理。
二、编辑器的 dom 结构
先来看一下monaco 编辑器的基本dom 结构:


- overflow-guard 编辑器的主要区域:
- margin: 左侧的代码行号区域,上面列有代码行号,折叠代码图标以及加断点标记。
- editor-scrollable: 主要代码工作区域。
- scroll-decoration: 滚动阴影装饰窄条,当开始滚动时出现在顶部。
- textarea:鼠标输入框,是一种可以自由控制光标位置的技巧,使用原生 textarea 光标能力在指定位置呈现光标闪烁状态。
- overlayWidgets: 可以渲染任意内容小部件,例如编辑器内的查找框即是一个 Overlay Widget。
- minimap: 右侧的缩略代码小地图。
- overflowingContentWidgets :编辑器 hover 展示内容,如中文错误说明。
- shadow-root-host:与右键相关的内容。
了解了 monaco 编辑器的基本 dom 结构之后,再来详细看一下其中 minimap 相关的内容。
三、Monaco editor 中 minimap 原理
3.1 minimap 简介
minimap 即表示代码的缩略图,一般置于编辑器右侧,可缩略展示整体代码结构,并支持一些拖动、点击等能力,实现代码的快速移动和定位。
- 滚动:缩略图与编辑器内容进行同步滚动
- 拖拽:缩略图中的滑块支持拖拽能力,拖拽时编辑器内容同步移动
- 点击:缩略图的非滑块区域,通过点击操作定位到对应的代码位置,编辑器同步移动

minimap 的dom 结构如下所示:
xml
<div data-mprt="8" class="minimap slider-mouseover">
<div class="minimap-shadow-hidden" ></div>
<canvas></canvas>
<canvas class="minimap-decorations-layer"></canvas>
<div class="minimap-slider""></div>
</div>
- minimap-shadow-hidden: 主要起UI装饰作用
- canvas:绘制整个代码的缩略图
- canvas:minimap-decorations-layer, 绘制语法错误的行,如图中标红的两行,起到装饰的作用
- minimap-slider:minimap 上的半透明罩滑块,如图中灰色区域,表示当前代码在整个文档中的滚动位置,并提供相应的事件能力。
3.2 minimap 的绘制流程
了解了 minimap 的基本dom 结构之后,我们接下来介绍的是 minimap 的整体绘制流程。主要包含 代码缩略图、装饰器缩略图和半透明遮罩层的绘制过程。
3.2.1 绘制代码缩略图
- 触发渲染
每次更改内容触发代码缩略图的重新渲染,为了提高性能,monaco 只重新渲染更改部分内容,避免多次重复渲染。
ini
// needed 为需要重新渲染的行数据
const [_dirtyY1, _dirtyY2, needed] = InnerMinimap._renderUntouchedLines(imageData, startLineNumber, endLineNumber, minimapLineHeight, this._lastRenderData);
、、、、、
// 循环所有行内容,只有更新内容的行才会进行重新渲染
for (let lineIndex = 0, lineCount = endLineNumber - startLineNumber + 1; lineIndex < lineCount; lineIndex++) {
if (needed[lineIndex]) {
InnerMinimap._renderLine(imageData, renderBackground, background.a, useLighterFont, renderMinimap, minimapCharWidth, tokensColorTracker, foregroundAlpha, charRenderer, dy, innerLinePadding, tabSize, lineInfo[lineIndex], fontScale, minimapLineHeight);
}
renderedLines[lineIndex] = new MinimapLine(dy);
}
- 渲染字符
代码缩略图主要是对主体编辑器内容进行映射,编辑器中承载的是各种字符,这里是采用了将各种字符映射为对应像素信息的方式:
因为生成缩略图需要耗费大量的计算资源(像素级计算),因此预先将 ascii 码 32 到 126 之间的字符的像素数据,按照两种尺寸(宽1px高2px,和宽2px高4px)提前准备到一个 map 里,使用时快速访问。
css
export const prebakedMiniMaps = {
1: once(() => decodeData('0000511D6300CF609C709645A78432005642574171487021003C451900274D35D762755E8B629C5BA856AF57BA649530C167D1512A272A3F6038604460398526BCA2A968DB6F8957C768BE5FBE2FB467CF5D8D5B795DC7625B5DFF50DE64C466DB2FC47CD860A65E9A2EB96CB54CE06DA763AB2EA26860524D3763536601005116008177A8705E53AB738E6A982F88BAA35B5F5B626D9C636B449B737E5B7B678598869A662F6B5B8542706C704C80736A607578685B70594A49715A4522E792')),
2: once(() => decodeData('000000000000000055394F383D2800008B8B1F210002000081B1CBCBCC820000847AAF6B9AAF2119BE08B8881AD60000A44FD07DCCF107015338130C00000000385972265F390B406E2437634B4B48031B12B8A0847000001E15B29A402F0000000000004B33460B00007A752C2A0000000000004D3900000084394B82013400ABA5CFC7AD9C0302A45A3E5A98AB000089A43382D97900008BA54AA087A70A0248A6A7AE6DBE0000BF6F94987EA40A01A06DCFA7A7A9030496C32F77891D0000A99FB1A0AFA80603B29AB9CA75930D010C0948354D3900000C0948354F37460D0028BE673D8400000000AF9D7B6E00002B007AA8933400007AA642675C2700007984CFB9C3985B768772A8A6B7B20000CAAECAAFC4B700009F94A6009F840009D09F9BA4CA9C0000CC8FC76DC87F0000C991C472A2000000A894A48CA7B501079BA2C9C69BA20000B19A5D3FA89000005CA6009DA2960901B0A7F0669FB200009D009E00B7890000DAD0F5D092820000D294D4C48BD10000B5A7A4A3B1A50402CAB6CBA6A2000000B5A7A4A3B1A8044FCDADD19D9CB00000B7778F7B8AAE0803C9AB5D3F5D3F00009EA09EA0BAB006039EA0989A8C7900009B9EF4D6B7C00000A9A7816CACA80000ABAC84705D3F000096DA635CDC8C00006F486F266F263D4784006124097B00374F6D2D6D2D6D4A3A95872322000000030000000000008D8939130000000000002E22A5C9CBC70600AB25C0B5C9B400061A2DB04CA67001082AA6BEBEBFC606002321DACBC19E03087AA08B6768380000282FBAC0B8CA7A88AD25BBA5A29900004C396C5894A6000040485A6E356E9442A32CD17EADA70000B4237923628600003E2DE9C1D7B500002F25BBA5A2990000231DB6AFB4A804023025C0B5CAB588062B2CBDBEC0C706882435A75CA20000002326BD6A82A908048B4B9A5A668000002423A09CB4BB060025259C9D8A7900001C1FCAB2C7C700002A2A9387ABA200002626A4A47D6E9D14333163A0C87500004B6F9C2D643A257049364936493647358A34438355497F1A0000A24C1D590000D38DFFBDD4CD3126'))
};
每个基础字符对应的基础像素信息在 canvas 中渲染结果:基础信息可以理解为每一个字符对应的每个像素的亮度,通过各个像素的不同亮度来区分不同的字符特征。
其中 once 函数为一个公共的方法,保证传入的函数只会执行一次,并记录下原始函数的执行结果,之后再次调用该函数时,会直接返回上一次记录的结果,而不会再次执行原始函数。避免了性能浪费。
针对字符像素内容的计算,也只需要在开始时计算一次就可以了,后面可以直接应用结果。
ini
export function once(fn) {
const _this = this;
let didCall = false;
let result;
return function () {
if (didCall) {
return result;
}
didCall = true;
result = fn.apply(_this, arguments);
return result;
};
}
使用 MinimapCharRenderer 渲染每一行中每一个基础字符。
一个字符是一个矩形,通过循环这个矩形中的每一个像素,计算出输出图像中该像素的最终颜色值,最终得到整个字符内容的数据结构。这里每个像素的最终颜色值是读取实际代码中该字符的前景色、背景色以及缩略字符中每个像素数据中的亮度值,叠加计算而成。
ini
renderChar(target, dx, dy, chCode, color, foregroundAlpha, backgroundColor, backgroundAlpha, fontScale, useLighterFont, force1pxHeight) {
// 获取字符在map中的索引
const charIndex = getCharIndex(chCode, fontScale);
、、、、
const dest = target.data;
、、、、
for (let y = 0; y < renderHeight; y++) {
let column = row;
for (let x = 0; x < charWidth; x++) {
// 将颜色信息叠加
const c = (charData[sourceOffset++] / 255) * (foregroundAlpha / 255);
dest[column++] = backgroundR + deltaR * c;
dest[column++] = backgroundG + deltaG * c;
dest[column++] = backgroundB + deltaB * c;
dest[column++] = destAlpha;
}
row += destWidth;
}
}
当渲染内容为中文或其他不在 map 预先设置范围内的字符时,getCharIndex()方法会将超出范围的索引映射回有效范围内的索引,即在较小的字体比例下,可以使用任何 ASCII 字符替代超出索引范围的字符。
kotlin
// 较小的字体下,可以使用任意 ASCII 字符替代超出索引范围的字符
export const getCharIndex = (chCode, fontScale) => {
chCode -= 32 /* START_CH_CODE */;
if (chCode < 0 || chCode > 96 /* CHAR_COUNT */) {
if (fontScale <= 2) {
return (chCode + 96 /* CHAR_COUNT */) % 96 /* CHAR_COUNT */;
}
return 96 /* CHAR_COUNT */ - 1; // unknown symbol
}
return chCode;
};
3.2.2 绘制装饰器缩略图
装饰器缩略图即如下图所示标红区域内容内容:
- 创建一个与 minimap 高度相同的画布(minimap-decorations-layer),并在其中绘制装饰器。
- 获取当前可见区域内的装饰器信息,然后按照一定的优先级(zIndex)对这些装饰器进行排序。装饰内容主要包括选中行和报错行。
kotlin
renderDecorations(layout) {
if (this._renderDecorations) {
this._renderDecorations = false;
// 获取选取和装饰器相关信息,根据 z-index 优先级排序
const selections = this._model.getSelections();
selections.sort(Range.compareRangesUsingStarts);
const decorations = this._model.getMinimapDecorationsInViewport(layout.startLineNumber, layout.endLineNumber);
decorations.sort((a, b) => (a.options.zIndex || 0) - (b.options.zIndex || 0));
、、、、
// 绘制装饰器
const highlightedLines = new ContiguousLineMap(layout.startLineNumber, layout.endLineNumber, false);
// 行装饰器
this._renderSelectionLineHighlights(canvasContext, selections, highlightedLines, layout, lineHeight);
this._renderDecorationsLineHighlights(canvasContext, decorations, highlightedLines, layout, lineHeight);
const lineOffsetMap = new ContiguousLineMap(layout.startLineNumber, layout.endLineNumber, null);
// 行内选中或报错内容的装饰器
this._renderSelectionsHighlights(canvasContext, selections, lineOffsetMap, layout, lineHeight, tabSize, characterWidth, canvasInnerWidth);
this._renderDecorationsHighlights(canvasContext, decorations, lineOffsetMap, layout, lineHeight, tabSize, characterWidth, canvasInnerWidth);
}
}
_renderDecorationsLineHighlights:循环处理绘制行装饰器。
- 如果同一行已经有更高优先级的装饰生效,则不再处理这个装饰内容。
- 每一次绘制一行的矩形
ini
_renderDecorationsLineHighlights(canvasContext, decorations, highlightedLines, layout, lineHeight) {
const highlightColors = new Map();
// Loop backwards to hit first decorations with higher `zIndex`
for (let i = decorations.length - 1; i >= 0; i--) {
const decoration = decorations[i];
const minimapOptions = decoration.options.minimap;
const startLineNumber = Math.max(layout.startLineNumber, decoration.range.startLineNumber);
const endLineNumber = Math.min(layout.endLineNumber, decoration.range.endLineNumber);
、、、、
const decorationColor = minimapOptions.getColor(this._theme.value);
、、、、
canvasContext.fillStyle = highlightColor;
for (let line = startLineNumber; line <= endLineNumber; line++) {
// 同一行已经有更高优先级的装饰生效,则不再处理这个装饰内容
if (highlightedLines.has(line)) {
continue;
}
highlightedLines.set(line, true);
const y = (startLineNumber - layout.startLineNumber) * lineHeight;
// 装饰器是一个矩形
canvasContext.fillRect(MINIMAP_GUTTER_WIDTH, y, canvasContext.canvas.width, lineHeight);
}
}
}
3.2.3 绘制半透明罩*

半透明罩层相当于minimap 的滚动条。
kotlin
this._sliderMouseDownListener = dom.addStandardDisposableListener(this._slider.domNode, 'mousedown', (e) => {
// 处理拖动
this._startSliderDragging(e.buttons, e.posx, e.posy, e.posy, this._lastRenderData.renderedLayout);
});
startMonitoring 是一个公共的监视鼠标事件的方法。其中使用截流方式对鼠标拖动事件进行处理。回调函数为下面的 handleMouseMove, 将代码区域滑动到指定位置
javascript
_startSliderDragging(initialButtons, initialPosX, initialPosY, posy, initialSliderState) {
// 显示滑块
this._slider.toggleClassName('active', true);
const handleMouseMove = (posy, posx) => {
、、、、
this._model.setScrollTop(initialSliderState.getDesiredScrollTopFromDelta(mouseDelta));
};
、、、
this._sliderMouseMoveMonitor.startMonitoring(this._slider.domNode, initialButtons, standardMouseMoveMerger, (mouseMoveData) => handleMouseMove(mouseMoveData.posy, mouseMoveData.posx), () => {
this._slider.toggleClassName('active', false);
});
dom.addStandardDisposableListener: 公共的DOM 节点添加事件监听器方法。
3.3 minimap 的事件
缩略图的拖动、滚动等事件触发后,会同时触发编辑器的滚动,编辑器内容的滚动也会触发缩略图中滑块的位置改变。

3.3.1 点击跳转
点击 minimap 实现代码跳转,监听其点击事件,得到鼠标在 minimap 中的偏移量,计算其对应行号,使用 revealLineNumber 方法将代码区域滚动到指定行。
ini
this._mouseDownListener = dom.addStandardDisposableListener(this._domNode.domNode, 'mousedown', (e) => {
e.preventDefault();
// 计算偏移量
const minimapLineHeight = this._model.options.minimapLineHeight;
const internalOffsetY = (this._model.options.canvasInnerHeight / this._model.options.canvasOuterHeight) * e.browserEvent.offsetY;
const lineIndex = Math.floor(internalOffsetY / minimapLineHeight);
// 过界点击即为最后一行
lineNumber = Math.min(lineNumber, this._model.getLineCount());
this._model.revealLineNumber(lineNumber);
});
3.3.2 拖动
点击半透明罩层事件,监听鼠标移动,计算鼠标在垂直方向上的偏移量,即当前垂直位置与初始垂直位置之间的差值,重新设置滚动条的滚动位置。
javascript
_startSliderDragging(initialButtons, initialPosX, initialPosY, posy, initialSliderState) {
// 滑块活跃,颜色改变
this._slider.toggleClassName('active', true);
const handleMouseMove = (posy, posx) => {
const mouseOrthogonalDelta = Math.abs(posx - initialPosX);
....
const mouseDelta = posy - initialPosY;
this._model.setScrollTop(initialSliderState.getDesiredScrollTopFromDelta(mouseDelta));
};
if (posy !== initialPosY) {
handleMouseMove(posy, initialPosX);
}
// 监视全局鼠标移动事件,鼠标移动时触发handleMouseMove函数,
this._sliderMouseMoveMonitor.startMonitoring(this._slider.domNode, initialButtons, standardMouseMoveMerger, (mouseMoveData) => handleMouseMove(mouseMoveData.posy, mouseMoveData.posx), () => {
this._slider.toggleClassName('active', false);
});
}
四、其他实现 minimap 的方法
minimap 能力不仅可以应用在编辑器中,在网页中也可以使用。下面介绍几种在网页中实现 minimap 的方法。
4.1 其他编辑器 minimap 实现
常见的其他 web 编辑器如 code-mirror 和 ace-editor 本身均不支持 minimap 能力,需要用户自己进行扩展。
4.2 网页 minimap 实现
- 使用 iframe 承载html 内容
css
# 这里获取整个document下的html
let html = document.documentElement.outerHTML
# 将html内容写入到目标iframe中
targetIframe.wirte(html)
通过在 iframe 上增加滑块及其他点击、滚动、拖拽等事件,可以实现网页的 minimap 效果,可以承载 html 上的任意内容,如实绘制。
- 网页 minimap 实现:CSS element() 函数
css element 函数可以将网站中的某部分当作图片渲染,但是这个属性目前只有 firfox 浏览器支持。我们可以简单看一下它的原理。
css
<div id="minimap"></div>
<div id="article"> <!-- content --> </div>
#minimap {
background: rgba(254,213,70,.1) -moz-element(#article) no-repeat center / contain;
position: fixed; right: 10px; top: 10px; /* more style */
}
上面的代码实现了获取 article 中内容,并将其转换为图片,作为 minimap 的背景被渲染出来,利用这个特性,可以实现 minimap 的展示。
背景准备好后,可以在其顶部添加一个滑块,它将用于操作小地图滚动。并增加点击、滚动、拖拽等事件,实现整体效果。
五、总结
本文主要介绍了 monaco-editor 的 dom 结构特征并重点讲述了其中 minimap 的实现过程。minimap 呈现了代码的缩略样式并提供了一些事件以快速定位代码。其主要依赖 canvas 实现绘制。同时介绍了一些在网页中实现 minimap 的方式。
六、参考资料
七、往期回顾
- juejin.cn/post/728011...
- juejin.cn/post/728043...
- juejin.cn/post/731447...
- juejin.cn/post/731464...
- juejin.cn/post/735100...
团队招聘
我们是快手数平前端团队,主要负责快手大数据平台中从采集,加工,消费整条链路相关产品的前端建设工作,涉及到生产、分析、流量、AB、专题等多个领域,目标是建设更自助,更快速,更先进的数据产品。
来到这里你能接触到:
- 快手数据平台是如何通过先进技术支持起 EB 级别数据量的;
- 日均百亿级别的日志上报的埋点基础设施的是如何设计与迭代的;
- 超过百万代码量的独立项目带来的工程化挑战;
- 2D/3D可视化、在线编辑器、电子表格等细分领域的探索;
- 自研工具产品来提升团队效率,用数据来说话;
- ......
我们希望能你:
- 有坚实的计算机/前端基础,不只是 MVVM 工程师;
- 有一定的 B 端项目/可视化相关的经验更佳,或非常热爱;
- 能发现问题,分析问题,并解决问题;
- 敢于创新,善于沟通,时刻关注前沿技术;
- 【加分项】有一定产品/交互/设计 sense;
- 【加分项】开源项目/社区;
团队氛围:
- 来这里你能接触到大数据中各个领域的专家,大家都非常 nice,没有架子,随时坦诚沟通,互相学习;
- 技术很重要,业务更重要,定期的培训分享都少不了;
- 团队氛围融洽,就事论事,风清气正,拒绝勾心斗角;
- 团队年轻有活力,既要工作好,也要玩的好; 加入我们,一起做点好玩的!
投递邮箱
感兴趣的同学,欢迎投寄简历: 609413629@qq.com