React 构建 MonacoEditor 组件

基于 monaco-editor 封装 React 组件

Monaco Editor

Monaco Editor 是一款由微软开源的、基于 Web 技术的代码编辑器库。它不仅是知名桌面编辑器 VS Code 的核心编辑组件, 更是一个功能完备的在线代码编辑解决方案,能够为开发者提供媲美桌面 IDE 的专业级编辑体验。

microsoft/monaco-editor

React Monaco Editor

本文基于 React Monaco Editor 构建一个 ReactEditor 组件。

react-monaco-editor/react-monaco-editor

除基本功能外,额外扩展:

  • 自定义语法高亮扩展
  • 高亮代码行显示(支持类似 highlight 配置(形如 1,2-4,5))

完整代码

MonacoEditor 组件

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import { useTheme } from "@/hooks/use-theme"
import { Editor, type EditorProps, type Monaco, type OnMount } from "@monaco-editor/react"
import type { editor } from "monaco-editor"
import { useEffect, useRef } from "react"
import { getCustomLanguage } from "./languages"
import "./styles.css"

interface MonacoEditorProps {
  fontSize?: number
  readOnly?: boolean
  minimap?: boolean
  placeholder?: string
  highlightLines?: string // 格式如: "1,3,5-8"
  onValueChange?: (value: string) => void
}

const parseHighlightLines = (highlightStr?: string): number[] => {
  if (!highlightStr) return []

  const lines = new Set<number>()
  const parts = highlightStr.split(",")

  parts.forEach((part) => {
    const trimmed = part.trim()
    if (trimmed.includes("-")) {
      const [start, end] = trimmed.split("-").map(Number)
      if (!isNaN(start) && !isNaN(end)) {
        for (let i = start; i <= end; i++) {
          lines.add(i)
        }
      }
    } else {
      const num = Number(trimmed)
      if (!isNaN(num)) {
        lines.add(num)
      }
    }
  })

  return Array.from(lines).sort((a, b) => a - b)
}

/**
 * Monaco Editor
 */
export default function MonacoEditor({
  fontSize = 14,
  minimap = true,
  readOnly = false,
  placeholder,
  value,
  language,
  highlightLines,
  onValueChange,
  ...props
}: MonacoEditorProps & EditorProps) {
  const { theme } = useTheme()
  const editorRef = useRef<editor.IStandaloneCodeEditor>(null)
  const monacoRef = useRef<Monaco>(null)
  const decorationsRef = useRef<string[]>([]) // 存储当前装饰器的 ID

  const handleMount: OnMount = (editor, monaco) => {
    monacoRef.current = monaco
    editorRef.current = editor

    const customLang = getCustomLanguage(language)
    if (customLang) {
      customLang.registerLanguage(monaco)
    }

    props.onMount?.(editor, monaco)
  }

  useEffect(() => {
    const editor = editorRef.current
    const monaco = monacoRef.current
    if (!editor || !monaco) return

    const lineNumbers = parseHighlightLines(highlightLines)
    const stickyTargetClasses = new Set(lineNumbers.map((line) => `stickyLine${line}`))

    const newDecorations = lineNumbers.map((line) => ({
      range: new monaco.Range(line, 1, line, 1),
      options: {
        isWholeLine: true,
        className: "highlight-line",
        marginClassName: "highlight-line",
      },
    }))
    decorationsRef.current = editor.deltaDecorations(decorationsRef.current, newDecorations)

    const applyStickyHighlight = () => {
      const domNode = editor.getDomNode()
      if (!domNode) return

      const stickyLines = domNode.querySelectorAll("[data-sticky-line-index]")

      const highlightLines = new Set()

      Array.from(stickyLines).forEach((el) => {
        const isHighlightLine = Array.from(el.classList).some((cls) => stickyTargetClasses.has(cls))
        if (isHighlightLine) {
          const rawIndex = el.getAttribute("data-sticky-line-index")
          const index = !isNaN(Number(rawIndex)) ? Number(rawIndex) : -1
          highlightLines.add(index)
        }
      })

      Array.from(stickyLines).forEach((el) => {
        const rawIndex = el.getAttribute("data-sticky-line-index")
        const index = !isNaN(Number(rawIndex)) ? Number(rawIndex) : -1
        if (highlightLines.has(index)) {
          el.classList.add("highlight-line")
        } else {
          el.classList.remove("highlight-line")
        }
      })
    }

    applyStickyHighlight()

    const observer = new MutationObserver(applyStickyHighlight)
    const domNode = editor.getDomNode()
    if (domNode) {
      observer.observe(domNode, { childList: true, subtree: true })
    }

    return () => {
      observer.disconnect()
    }
  }, [highlightLines])

  return (
    <Editor
      value={value}
      language={language}
      onChange={(v) => onValueChange?.(v || "")}
      theme={theme === "dark" ? "vs-dark" : "light"}
      onMount={handleMount}
      options={{
        readOnly: readOnly,
        fontSize: fontSize,
        placeholder: placeholder,
        minimap: { enabled: minimap },
        smoothScrolling: true,
        scrollBeyondLastLine: false,
        scrollbar: {
          verticalScrollbarSize: 8,
          horizontalScrollbarSize: 8,
          verticalSliderSize: 8,
          horizontalSliderSize: 8,
          verticalHasArrows: false,
          horizontalHasArrows: false,
          useShadows: false,
        },
      }}
      {...props}
    />
  )
}

高亮行实现思路:

  1. 通过 monaco-editor 自带的 decoration (装饰器),增加常规代码高亮
  2. 粘性滚动时,类名、方法名行等会固定顶部,此时 decoration 失效。作者根据前端结构按如下步骤修改粘性滚动时高亮行样式:
    • 根据 stickyLineXXX 判断当前是否是需要高亮的行
    • 获取高亮行的 data-sticky-line-index 索引
    • 为高亮行添加 highlight-line 样式,非高亮行移除该样式

样式文件

主要修改滚动条,适配 shadcn/ui,以及高亮行显示配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
.monaco-editor .monaco-scrollable-element .scrollbar.vertical,
.monaco-editor .monaco-scrollable-element .scrollbar.horizontal {
  background: transparent;
}

.monaco-editor .monaco-scrollable-element > .scrollbar > .slider {
  background: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
  border-radius: 4px;
}

.monaco-editor .monaco-scrollable-element > .scrollbar > .slider:hover {
  background: color-mix(in oklch, var(--muted-foreground) 50%, transparent);
}

.monaco-editor .monaco-scrollable-element .scrollbar .arrow {
  display: none;
}

.editorPlaceholder {
  white-space: pre-wrap;
}

.highlight-line {
  background-color: rgba(255, 235, 59, 0.4) !important;
}

自定义语法高亮规则

.properties 文件语法高亮为例:

properties 文件语法高亮规则定义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
export const propertiesLanguage = {
  id: "properties",
  extensions: [".properties"],
  aliases: ["Properties", "properties"],

  registerLanguage(monaco: typeof import("monaco-editor")) {
    monaco.languages.register({ id: this.id })

    monaco.languages.setMonarchTokensProvider(this.id, {
      tokenPostfix: ".properties",

      keywords: ["true", "True", "TRUE", "false", "False", "FALSE", "null", "Null", "Null", "~"],

      numberInteger: /(?:0|[+-]?[0-9]+)/,
      numberFloat: /(?:0|[+-]?[0-9]+)(?:\.[0-9]+)?(?:e[-+][1-9][0-9]*)?/,
      numberOctal: /0o[0-7]+/,
      numberHex: /0x[0-9a-fA-F]+/,
      numberInfinity: /[+-]?\.(?:inf|Inf|INF)/,
      numberNaN: /\.(?:nan|Nan|NAN)/,
      numberDate: /\d{4}-\d\d-\d\d([Tt ]\d\d:\d\d:\d\d(\.\d+)?(( ?[+-]\d\d?(:\d\d)?)|Z)?)?/,

      escapes: /\\(?:[btnfr\\"']|[0-7][0-7]?|[0-3][0-7]{2})/,

      tokenizer: {
        root: [
          { include: "@whitespace" },

          [/[#!].*$/, "comment"],

          [/^([^\s=:]+)(\s*[=:]\s*)/, ["type", "operators"]],

          [/\\$/, "keyword.control"],

          [/@numberInteger(?![ \t]*\S+)/, "number"],
          [/@numberFloat(?![ \t]*\S+)/, "number.float"],
          [/@numberOctal(?![ \t]*\S+)/, "number.octal"],
          [/@numberHex(?![ \t]*\S+)/, "number.hex"],
          [/@numberInfinity(?![ \t]*\S+)/, "number.infinity"],
          [/@numberNaN(?![ \t]*\S+)/, "number.nan"],
          [/@numberDate(?![ \t]*\S+)/, "number.date"],

          [
            /.+?(?=(\s+#|$))/,
            {
              cases: {
                "@keywords": "keyword",
                "@default": "string",
              },
            },
          ],
        ],

        whitespace: [[/[ \t\r\n]+/, "white"]],
      },
    })

    monaco.languages.setLanguageConfiguration(this.id, {
      comments: { lineComment: "#" },
      brackets: [],
      autoClosingPairs: [],
      surroundingPairs: [],
      folding: { offSide: false },
      onEnterRules: [
        {
          beforeText: /#.*$/,
          action: {
            indentAction: monaco.languages.IndentAction.None,
            appendText: "# ",
          },
        },
      ],
    })
  },
}

统一导出自定义语法高亮规则

1
2
3
4
5
6
7
8
import { propertiesLanguage } from "./properties"

export const customLanguages = [propertiesLanguage]

export function getCustomLanguage(language?: string) {
  if (!language) return null
  return customLanguages.find((lang) => lang.id === language)
}

至此一个 MonacoEditor 组件完成。

使用方式

1
2
3
4
5
6
7
8
<MonacoEditor 
    height="300px" 
    language="sql" 
    value={sql} 
    readOnly 
    minimap={false}
    highlightLines={highlightLines}
    />

如果本文对您有所帮助,欢迎打赏支持作者!

Licensed under CC BY-NC-SA 4.0
最后更新于 2026-06-09 15:00
使用 Hugo 构建
主题 StackJimmy 设计