在Monaco Editor中實現(xiàn)斷點設(shè)置的方法詳解
Monaco Editor 是 vscode 等產(chǎn)品使用的代碼編輯器,功能強大(且復(fù)雜),由微軟維護。編輯器沒有原生提供設(shè)置斷點的功能,本文在 React + TypeScript(Vite)框架下使用 @monaco-editor/react 并介紹開發(fā)斷點顯示時踩到的坑。最終展示 鼠標(biāo)懸浮顯示斷點按鈕,點擊后進行斷點的設(shè)置或移除。
本文不涉及調(diào)試功能的具體實現(xiàn),可閱讀 DAP 文檔
最終實現(xiàn)可直接拉到文末。

搭建 Playground
React + TypeScript,使用的封裝為 @monaco-editor/react。
建立項目并配置依賴:
yarn create vite monaco-breakpoint ... yarn add @monaco-editor/react
依賴處理完成后,我們編寫簡單的代碼將編輯器展示到頁面中:(App.tsx)
import Editor from '@monaco-editor/react'
import './App.css'
function App() {
return (
<>
<div style={{ width: '600px', height: '450px' }}>
<Editor theme='vs-dark' defaultValue='// some comment...' />
</div>
</>
)
}
export default App

接下來在該編輯器的基礎(chǔ)上添加設(shè)置斷點的能力。
一種暴力的辦法:手動編寫斷點組件
不想閱讀 Monaco 文檔,最自然而然的想法就是手動在編輯器的左側(cè)手搭斷點組件并顯示在編輯器旁邊。
首先手動設(shè)置如下選項
- 編輯器高度為完全展開(通過設(shè)置行高并指定 height 屬性)
- 禁用代碼折疊選項
- 為編輯器外部容器添加滾動屬性
- 禁用小地圖
- 禁用內(nèi)部滾動條的消費滾動
- 禁用超出滾動
編輯器代碼將變?yōu)椋?/p>
const [code, setCode] = useState('')
...
<div style={{ width: '600px', height: '450px', overflow: 'auto' }}>
<Editor
height={code.split('\n').length * 20}
onChange={(value) => setCode(value!)}
theme='vs-dark'
value=[code]
language='python'
options={{
lineHeight: 20,
scrollBeyondLastLine: false,
scrollBeyondLastColumn: 0,
minimap: { enabled: false },
scrollbar: { alwaysConsumeMouseWheel: false },
fold: false,
}}
/>
</div>

現(xiàn)在編輯器的滾動由父容器的滾動條接管,再來編寫展示斷點的組件。我們希望斷點組件展示在編輯器的左側(cè)。 先設(shè)置父容器水平布局:
display: 'flex', flexDirection: 'row'
編寫斷點組件(示例):
<div style={{ width: '600px', height: '450px', overflow: 'auto', display: 'flex', flexDirection: 'row' }}>
<div
style={{
width: '20px',
height: code.split('\n').length * 20,
background: 'black',
display: 'flex',
flexDirection: 'column',
}}
>
{[...Array(code.split('\n').length)].map((_, index) => (
<div style={{ width: '20px', height: '20px', background: 'red', borderRadius: '50%' }} key={index} />
))}
</div>
<Editor ...

目前斷點組件是能夠展示了,但本文不在此方案下進行進一步的開發(fā)。這個方案的問題:
- 強行設(shè)置組件高度能夠展示所有代碼,把 Monaco 的性能優(yōu)化整個吃掉了。這樣的編輯器展示代碼行數(shù)超過一定量后頁面會變的非常卡,性能問題嚴(yán)重。
- 小地圖、超行滾動、代碼塊折疊等能力需要禁用,限制大。
本來目標(biāo)是少讀點 Monaco 的超長文檔,結(jié)果實際上動了那么多編輯器的配置,最終也還是沒逃脫翻文檔的結(jié)局。果然還是要使用更聰明的辦法做斷點展示。
使用 Decoration
在 Monaco Editor Playground 中有使用行裝飾器的例子,一些博文也提到了可以使用 DeltaDecoration 為編輯器添加行裝飾器。不過跟著寫實現(xiàn)的時候出現(xiàn)了一些詭異的問題,于是查到 Monaco 的 changelog 表示該方法已被棄用,應(yīng)當(dāng)使用 createDecorationsCollection。 這個 API 的文檔在 這里。

為了能使用 editor 的方法,我們先拿到編輯器的實例(本文使用的封裝庫需要這么操作。如果你直接使用了原始的 js 庫或者其他封裝,應(yīng)當(dāng)可以用其他的方式拿到實例)。 安裝 monaco-editor js 庫:
yarn add monaco-editor
將 Playground 代碼修改如下:
import Editor from '@monaco-editor/react'
import * as Monaco from 'monaco-editor/esm/vs/editor/editor.api'
import './App.css'
import { useState } from 'react'
function App() {
const [code, setCode] = useState('')
function handleEditorDidMount(editor: Monaco.editor.IStandaloneCodeEditor) {
// 在這里就拿到了 editor 實例,可以存到 ref 里后面繼續(xù)用
}
return (
<>
<div style={{ width: '600px', height: '450px' }}>
<Editor
onChange={(value) => setCode(value!)}
theme='vs-dark'
value=[code]
language='python'
onMount={handleEditorDidMount}
/>
</div>
</>
)
}
export default App
Monaco Editor 的裝飾器是怎樣設(shè)置的?
方法 createDecorationsCollection 的參數(shù)由 IModelDeltaDecoration 指定。其含有兩個參數(shù),分別為 IModelDecorationOptions 和 IRange。Options 參數(shù)指定了裝飾器的樣式等配置信息,Range 指定了裝飾器的顯示范圍(由第幾行到第幾行等等)。
// 為從第四行到第四行的范圍(即僅第四行)添加樣式名為breakpoints的行裝飾器
const collections: Monaco.editor.IModelDeltaDecoration[] = []
collections.push({
range: new Monaco.Range(4, 1, 4, 1),
options: {
isWholeLine: true,
linesDecorationsClassName: 'breakpoints',
linesDecorationsTooltip: '點擊添加斷點',
},
})
const bpc = editor.createDecorationsCollection(collections)
該方法的返回實例提供了一系列方法,用于添加、清除、更新、查詢該裝飾器集合狀態(tài)。詳見 IEditorDecorationsCollection。
維護單個裝飾器組:樣式表
我們先寫一些 css:
.breakpoints {
width: 10px !important;
height: 10px !important;
left: 5px !important;
top: 5px;
border-radius: 50%;
display: inline-block;
cursor: pointer;
}
.breakpoints:hover {
background-color: rgba(255, 0, 0, 0.5);
}
.breakpoints-active {
background-color: red;
}
添加裝飾器。我們先添加一個 1 到 9999 行的范圍來查看效果(省事)。當(dāng)然更好的辦法是監(jiān)聽行號變動。
const collections: Monaco.editor.IModelDeltaDecoration[] = []
collections.push({
range: new Monaco.Range(1, 1, 9999, 1),
options: {
isWholeLine: true,
linesDecorationsClassName: 'breakpoints',
linesDecorationsTooltip: '點擊添加斷點',
},
})
const bpc = editor.createDecorationsCollection(collections)

裝飾器有了,監(jiān)聽斷點組件的點擊事件。使用 Monaco 的 onMouseDown Listener。
editor.onMouseDown((e) => {
if (e.event.target.classList.contains('breakpoints'))
e.event.target.classList.add('breakpoints-active')
})

看起來似乎沒有問題?Monaco 的行是動態(tài)生成的,這意味著設(shè)置的 css 樣式并不能持久顯示。

看來還得另想辦法。
維護單個裝飾器組:手動維護 Range 列表
通過維護 Ranges 列表可以解決上述問題。 不過我們顯然能注意到一個問題:我們希望設(shè)置的斷點是行綁定的。

假設(shè)我們手動維護所有設(shè)置了斷點的行數(shù),顯示斷點時若有行號變動,斷點將保留在原先的行號所在行。監(jiān)聽前后行號變動非常復(fù)雜,顯然不利于實現(xiàn)。有沒有什么辦法能夠直接利用 Monaco 的機制?
維護兩個裝飾器組
我們維護兩個裝飾器組。一個用于展示未設(shè)置斷點時供點擊的按鈕(①),另一個用來展示設(shè)置后的斷點(②)。行號更新后,自動為所有行設(shè)置裝飾器①,用戶點擊①時,獲取實時行號并設(shè)置②。斷點的移除和獲取均通過裝飾器組②實現(xiàn)。
- 點擊①時,調(diào)用裝飾器組②在當(dāng)前行設(shè)置展示已設(shè)置斷點。
- 點擊②時,裝飾器組②直接移除當(dāng)前行已設(shè)置的斷點,露出裝飾器①。
如需獲取設(shè)置的斷點情況,可直接調(diào)用裝飾器組②的 getRanges 方法。
多個裝飾器組在編輯器里是怎樣渲染的?
兩個裝飾器都會出現(xiàn)在編輯器中,和行號在同一個父布局下。

順便折騰下怎么拿到當(dāng)前行號:設(shè)置的裝飾器和展示行號的組件在同一父布局下且為相鄰元素,暫且先用一個笨辦法拿到。
// onMouseDown事件下 const lineNum = parseInt(e.event.target.nextElementSibling?.innerHTML as string)
實現(xiàn)
回到 Playground:
import Editor from '@monaco-editor/react'
import * as Monaco from 'monaco-editor/esm/vs/editor/editor.api'
import './App.css'
import { useState } from 'react'
function App() {
const [code, setCode] = useState('')
function handleEditorDidMount(editor: Monaco.editor.IStandaloneCodeEditor) {
// 在這里就拿到了 editor 實例,可以存到 ref 里后面繼續(xù)用
}
return (
<>
<div style={{ width: '600px', height: '450px' }}>
<Editor
onChange={(value) => setCode(value!)}
theme='vs-dark'
value=[code]
language='python'
onMount={handleEditorDidMount}
options={{ glyphMargin: true }} // 加一行設(shè)置Monaco展示邊距,避免遮蓋行號
/>
</div>
</>
)
}
export default App
斷點裝飾器樣式:
.breakpoints {
width: 10px !important;
height: 10px !important;
left: 5px !important;
top: 5px;
border-radius: 50%;
display: inline-block;
cursor: pointer;
}
.breakpoints:hover {
background-color: rgba(255, 0, 0, 0.5);
}
.breakpoints-active {
width: 10px !important;
height: 10px !important;
left: 5px !important;
top: 5px;
background-color: red;
border-radius: 50%;
display: inline-block;
cursor: pointer;
z-index: 5;
}
整理下代碼:
const bpOption = {
isWholeLine: true,
linesDecorationsClassName: 'breakpoints',
linesDecorationsTooltip: '點擊添加斷點',
}
const activeBpOption = {
isWholeLine: true,
linesDecorationsClassName: 'breakpoints-active',
linesDecorationsTooltip: '點擊移除斷點',
}
function handleEditorDidMount(editor: Monaco.editor.IStandaloneCodeEditor) {
const activeCollections: Monaco.editor.IModelDeltaDecoration[] = []
const collections: Monaco.editor.IModelDeltaDecoration[] = [
{
range: new Monaco.Range(1, 1, 9999, 1),
options: bpOption,
},
]
const bpc = editor.createDecorationsCollection(collections)
const activeBpc = editor.createDecorationsCollection(activeCollections)
editor.onMouseDown((e) => {
// 加斷點
if (e.event.target.classList.contains('breakpoints')) {
const lineNum = parseInt(e.event.target.nextElementSibling?.innerHTML as string)
const acc: Monaco.editor.IModelDeltaDecoration[] = []
activeBpc
.getRanges()
.filter((item, index) => activeBpc.getRanges().indexOf(item) === index) // 去重
.forEach((erange) => {
acc.push({
range: erange,
options: activeBpOption,
})
})
acc.push({
range: new Monaco.Range(lineNum, 1, lineNum, 1),
options: activeBpOption,
})
activeBpc.set(acc)
}
// 刪斷點
if (e.event.target.classList.contains('breakpoints-active')) {
const lineNum = parseInt(e.event.target.nextElementSibling?.innerHTML as string)
const acc: Monaco.editor.IModelDeltaDecoration[] = []
activeBpc
.getRanges()
.filter((item, index) => activeBpc.getRanges().indexOf(item) === index)
.forEach((erange) => {
if (erange.startLineNumber !== lineNum)
acc.push({
range: erange,
options: activeBpOption,
})
})
activeBpc.set(acc)
}
})
// 內(nèi)容變動時更新裝飾器①
editor.onDidChangeModelContent(() => {
bpc.set(collections)
})
}

看起來基本沒有什么問題了。
注意到空行換行時的內(nèi)部處理策略是跟隨斷點,可以在內(nèi)容變動時進一步清洗。
editor.onDidChangeModelContent(() => {
bpc.set(collections)
const acc: Monaco.editor.IModelDeltaDecoration[] = []
activeBpc
.getRanges()
.filter((item, index) => activeBpc.getRanges().indexOf(item) === index)
.forEach((erange) => {
acc.push({
range: new Monaco.Range(erange.startLineNumber, 1, erange.startLineNumber, 1), // here
options: {
isWholeLine: true,
linesDecorationsClassName: 'breakpoints-active',
linesDecorationsTooltip: '點擊移除斷點',
},
})
})
activeBpc.set(acc)
props.onBpChange(activeBpc.getRanges())
})
完整實現(xiàn)
App.tsx:
import Editor from '@monaco-editor/react'
import * as Monaco from 'monaco-editor/esm/vs/editor/editor.api'
import './App.css'
import { useState } from 'react'
import './editor-style.css'
function App() {
const [code, setCode] = useState('# some code here...')
const bpOption = {
isWholeLine: true,
linesDecorationsClassName: 'breakpoints',
linesDecorationsTooltip: '點擊添加斷點',
}
const activeBpOption = {
isWholeLine: true,
linesDecorationsClassName: 'breakpoints-active',
linesDecorationsTooltip: '點擊移除斷點',
}
function handleEditorDidMount(editor: Monaco.editor.IStandaloneCodeEditor) {
const activeCollections: Monaco.editor.IModelDeltaDecoration[] = []
const collections: Monaco.editor.IModelDeltaDecoration[] = [
{
range: new Monaco.Range(1, 1, 9999, 1),
options: bpOption,
},
]
const bpc = editor.createDecorationsCollection(collections)
const activeBpc = editor.createDecorationsCollection(activeCollections)
editor.onMouseDown((e) => {
// 加斷點
if (e.event.target.classList.contains('breakpoints')) {
const lineNum = parseInt(e.event.target.nextElementSibling?.innerHTML as string)
const acc: Monaco.editor.IModelDeltaDecoration[] = []
activeBpc
.getRanges()
.filter((item, index) => activeBpc.getRanges().indexOf(item) === index) // 去重
.forEach((erange) => {
acc.push({
range: erange,
options: activeBpOption,
})
})
acc.push({
range: new Monaco.Range(lineNum, 1, lineNum, 1),
options: activeBpOption,
})
activeBpc.set(acc)
}
// 刪斷點
if (e.event.target.classList.contains('breakpoints-active')) {
const lineNum = parseInt(e.event.target.nextElementSibling?.innerHTML as string)
const acc: Monaco.editor.IModelDeltaDecoration[] = []
activeBpc
.getRanges()
.filter((item, index) => activeBpc.getRanges().indexOf(item) === index)
.forEach((erange) => {
if (erange.startLineNumber !== lineNum)
acc.push({
range: erange,
options: activeBpOption,
})
})
activeBpc.set(acc)
}
})
// 內(nèi)容變動時更新裝飾器①
editor.onDidChangeModelContent(() => {
bpc.set(collections)
})
}
return (
<>
<div style={{ width: '600px', height: '450px' }}>
<Editor
onChange={(value) => {
setCode(value!)
}}
theme='vs-dark'
value=[code]
language='python'
onMount={handleEditorDidMount}
options={{ glyphMargin: true, folding: false }}
/>
</div>
</>
)
}
export default App
editor-style.css:
.breakpoints {
width: 10px !important;
height: 10px !important;
left: 5px !important;
top: 5px;
border-radius: 50%;
display: inline-block;
cursor: pointer;
}
.breakpoints:hover {
background-color: rgba(255, 0, 0, 0.5);
}
.breakpoints-active {
width: 10px !important;
height: 10px !important;
left: 5px !important;
top: 5px;
background-color: red;
border-radius: 50%;
display: inline-block;
cursor: pointer;
z-index: 5;
}

以上就是在Monaco Editor中實現(xiàn)斷點設(shè)置的方法詳解的詳細(xì)內(nèi)容,更多關(guān)于Monaco Editor斷點設(shè)置的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
JS 新增Cookie 取cookie值 刪除cookie 舉例詳解
cookie很實用的一個功能,可以判斷某個狀態(tài),下面與大家分享下JS 如何新增Cookie 取cookie值 刪除cookie,感興趣的朋友可以參考下2014-10-10
JavaScript創(chuàng)建防篡改對象的方法分析
這篇文章主要介紹了JavaScript創(chuàng)建防篡改對象的方法,結(jié)合具體實例形式分析了javascript基于不可擴展對象、密封的對象和凍結(jié)的對象實現(xiàn)防篡改對象的相關(guān)操作技巧,需要的朋友可以參考下2018-12-12
JavaScript中判斷頁面關(guān)閉、頁面刷新的實現(xiàn)代碼
這篇文章主要介紹了JavaScript中判斷頁面關(guān)閉、頁面刷新的實現(xiàn)代碼,在一些特殊的場合中會用到這個技術(shù),需要的朋友可以參考下2014-08-08
js 定時器setTimeout無法調(diào)用局部變量的解決辦法
javascript中定時器setTimeout無法調(diào)用局部變量,只需要將setTimeout的第一個參數(shù)改成函數(shù)對象,而不是字符串,就可以了2013-11-11
JS運動相關(guān)知識點小結(jié)(附彈性運動示例)
這篇文章主要介紹了JS運動相關(guān)知識點,總結(jié)分析了JavaScript運動所涉及的相關(guān)知識點與注意事項,并附帶了一個JavaScript彈性運動的實例供大家參考,需要的朋友可以參考下2016-01-01

