# stream-monaco [![NPM version](https://img.shields.io/npm/v/stream-monaco?color=a1b858&label=)](https://www.npmjs.com/package/stream-monaco) [![English Docs](https://img.shields.io/badge/docs-English-blue)](README.md) [![NPM downloads](https://img.shields.io/npm/dm/stream-monaco)](https://www.npmjs.com/package/stream-monaco) [![Bundle size](https://img.shields.io/bundlephobia/minzip/stream-monaco)](https://bundlephobia.com/package/stream-monaco) [![License](https://img.shields.io/npm/l/stream-monaco)](./LICENSE) 流式更新版 Monaco Editor + Shiki 语法高亮,提供框架无关内核,适配 Vue、React、Svelte、Solid、Preact 与 Vanilla JS。 ### 项目简介 `stream-monaco` 提供一个与框架无关的内核来集成 Monaco 编辑器与 Shiki 语法高亮,针对流式更新与高效高亮做了优化;可在无 Vue 的环境下使用,同时也提供对 Vue 3 的友好支持与示例。 IMPORTANT: Since v0.0.32 the library enables a default time-based throttle for `updateCode` (`updateThrottleMs = 50`) to reduce CPU usage under high-frequency streaming. Set `updateThrottleMs: 0` in `useMonaco()` options to restore previous behavior (only RAF-based coalescing). ### 特性 - 🚀 **无需 Vue 也可使用** - 核心与框架无关 - 🌿 **与 Vue 3 组合式 API 兼容** - 提供示例与最佳实践 - 🔁 **可用于任意框架**:Vue、React、Svelte、Solid、Preact,或纯 JS/TS 说明:内部响应式基于 `alien-signals` 的轻薄适配层实现,因此核心逻辑不再强依赖 Vue。Vue 仍然完全支持,但被标记为可选的 peer 依赖,使库在非 Vue 环境也可复用核心能力,且对现有 API 无破坏。 - 🎨 **Shiki 高亮** - 使用 Shiki 实现高效的语法高亮,支持 TextMate 语法和 VS Code 主题 - 📝 **流式更新** - 支持流式输入更新,实时响应代码变化 - `registerMonacoThemes(themes, languages): Promise` — 使用 shiki 创建或获取高亮器并把主题注册到 Monaco,返回解析为 shiki highlighter 的 Promise,便于复用(例如渲染页面片段)。 `getOrCreateHighlighter(themes, languages): Promise` — 直接获取或创建一个 highlighter(并受内部缓存管理)。如需直接控制 shiki highlighter(例如调用 `codeToHtml` 或 `setTheme`),请使用此方法并自行处理加载/错误逻辑。 注意:如果你只使用 Monaco 编辑器并在 `createEditor` 时传入了全量 `themes`,通常只需调用 `monaco.editor.setTheme(themeName)` 即可。 补充:如果你要切换到一个**不在初始 `themes` 列表里的主题**(例如运行时才决定的 `andromeeda`),建议使用 `useMonaco()` 返回的 `await setTheme(themeName)`,而不是直接调用 `monaco.editor.setTheme(themeName)`。 `setTheme()` 会确保主题被注册;并且在可能的情况下,会对底层 Shiki highlighter 进行一次 `loadTheme(themeName)` 的兜底,避免出现 `Theme ... not found, you may need to load it first` 这类“主题未加载”的错误。 配置:`useMonaco()` 不会自动同步 Shiki highlighter;如果你需要在切换主题时同步页面中独立的 Shiki 渲染,请手动使用 `getOrCreateHighlighter(...)` 并调用高亮器实例的 `setTheme`。 ### 安装 使用 pnpm 安装: ```bash pnpm add stream-monaco ``` 使用 npm 安装: ```bash npm install stream-monaco ``` 使用 yarn 安装: ```bash yarn add stream-monaco ``` ### 示例工程 - `examples/streaming-demo`:Vue 3 + Vite 示例,包含 streaming、diff、diff UX 路由 - `examples/react-demo`:React + Vite 示例,演示不重建编辑器的主题/语言切换与 `appendCode()` 流式追加 快速启动: ```bash pnpm -C examples/streaming-demo dev pnpm -C examples/react-demo dev ``` ### 基础使用(Vue) #### 简单示例 ```vue ``` #### 完整配置示例 ```vue ``` ### 在非 Vue 环境使用(Vanilla) 无需安装 Vue,直接在任意 TS/JS 环境中使用: ```ts import { useMonaco } from 'stream-monaco' const container = document.getElementById('editor')! const { createEditor, updateCode, setTheme, cleanupEditor } = useMonaco({ themes: ['vitesse-dark', 'vitesse-light'], languages: ['javascript', 'typescript'], MAX_HEIGHT: 500, }) await createEditor(container, 'console.log("Hello")', 'javascript') updateCode('console.log("World")', 'javascript') await setTheme('vitesse-light') cleanupEditor() ``` ```html
``` ### React 基础用法 ```tsx import { useEffect, useRef } from 'react' import { useMonaco } from 'stream-monaco' export function MonacoEditor() { const containerRef = useRef(null) const { createEditor, cleanupEditor } = useMonaco({ themes: ['vitesse-dark', 'vitesse-light'], languages: ['typescript', 'javascript'], }) useEffect(() => { if (containerRef.current) { createEditor( containerRef.current, 'console.log("Hello, Monaco!")', 'typescript', ) } return () => cleanupEditor() }, []) return (
) } ``` 说明:Svelte/Solid/Preact 的集成方式与 React 类似——在挂载时创建编辑器实例,卸载时清理即可。 ### 获取当前代码(getCode) 创建编辑器后,您可以随时使用 `getCode()` 获取当前的代码内容。这在用户手动编辑编辑器内容时特别有用: ```vue ``` 对于 Diff 编辑器,`getCode()` 返回两侧的代码: ```ts const { createDiffEditor, getCode } = useMonaco() await createDiffEditor(container, '旧代码', '新代码', 'javascript') const codes = getCode() // codes = { original: '旧代码', modified: '新代码' } ``` ### Diff 编辑器使用(Vue) #### 快速开始 ```vue ```` ### 流式追加 + 语言切换(快速示例) ### 自动滚动配置说明 下面是与自动滚动行为相关的可配置项及推荐值: - `revealDebounceMs` (number, ms) - 说明:在流式追加或短时间内多次更新时,会把多次 reveal 请求合并成一次。减少滚动频率与抖动。 - 默认:75 - 建议:流式输出时保留 50-150,静态或实时编辑可设为 0 以禁用合并。 - `revealBatchOnIdleMs` (number | undefined) - 说明:如果设置为正数(例如 200),系统会在最后一次追加后等待该毫秒数再执行一次“最终”滚动。这适合大量小片段追加后一次性滚动到底部。 - 默认:undefined(禁用) - `revealStrategy` ("bottom" | "centerIfOutside" | "center") - 说明:控制使用哪种 reveal API。 - `bottom`:使用 `revealLine`(靠近底部,变化明显) - `centerIfOutside`:使用 `revealLineInCenterIfOutsideViewport`(默认,更温和,只在目标不在视口内时居中) - `center`:使用 `revealLineInCenter`(总是居中) 这些选项已添加到 `useMonaco()` 的配置中,并可通过 TypeScript 的 `RevealStrategy` 枚举(库导出)进行引用。 ```vue ```` 更多完整示例请见 examples/ 目录。 #### 行为说明(增量与 RAF) - `updateDiff` 使用 `requestAnimationFrame` 合并同一帧内的多次调用,减少重排与布局开销。 - 当新内容以旧内容为前缀时,采用“仅追加”的策略,避免全量替换带来的性能损耗。 - 其他情况下执行“最小中段替换”,在模型上计算公共前后缀,只替换中间变化段,减少编辑器刷新范围。 - `updateOriginal` / `updateModified` 为即时增量更新,适合单侧独立流式场景。 - 可通过 options.diffAutoScroll 关闭 Diff 编辑器 modified 侧的自动滚动;默认开启以保持与单编辑器一致的体验。 #### 显式流式追加(推荐) 当你是标准的“持续在末尾追加”场景,建议直接使用显式追加 API,可减少 diff 计算并获得最佳实时性: ```ts const { createDiffEditor, appendOriginal, appendModified } = useMonaco({ themes: ['vitesse-dark', 'vitesse-light'], languages: ['typescript'], }) await createDiffEditor(container, '', '', 'typescript') // 只向 original 侧持续追加 appendOriginal('line 1\n') appendOriginal('line 2\n') // 只向 modified 侧持续追加 appendModified('out 1\n') appendModified('out 2\n') ``` 提示:在 `updateDiff`/`updateOriginal`/`updateModified` 中,当检测到“语言未变且严格前缀追加”时,内部也会自动走“立即追加”的快路径;否则进入 `requestAnimationFrame` 合并 + 最小替换。 #### 视图模式切换与模型访问 你可以获取 Diff 的两个模型来做更底层控制,或切换视图模式: ```ts const { createDiffEditor, getDiffEditorView, getDiffModels } = useMonaco({ themes: ['vitesse-dark', 'vitesse-light'], languages: ['typescript'], }) await createDiffEditor(container, left, right, 'typescript') // 切换为内联模式 getDiffEditorView()?.updateOptions({ renderSideBySide: false }) // 获取模型:你可以自行订阅内容变化等底层行为 const { original, modified } = getDiffModels() original?.onDidChangeContent?.(() => { /* ... */ }) modified?.onDidChangeContent?.(() => { /* ... */ }) ``` ### API 参考 #### useMonaco(options?) ##### 参数 | 参数 | 类型 | 默认值 | 描述 | | -------------------------- | ------------------------------------------------------------ | ----------------------------------- | --------------------------------------------------------------- | | `MAX_HEIGHT` | `number` | `500` | 编辑器最大高度(像素) | | `readOnly` | `boolean` | `true` | 是否为只读模式 | | `themes` | `MonacoTheme[]` | `['vitesse-dark', 'vitesse-light']` | 主题数组,至少包含两个主题 | | `languages` | `MonacoLanguage[]` | 见默认语言列表 | 支持的编程语言数组 | | `theme` | `string` | - | 初始主题名称 | | `isCleanOnBeforeCreate` | `boolean` | `true` | 是否在创建前清理之前注册的资源 | | `onBeforeCreate` | `function` | - | 编辑器创建前的钩子函数 | | `autoScrollOnUpdate` | `boolean` | `true` | 更新内容时若接近底部则自动滚动 | | `autoScrollInitial` | `boolean` | `true` | 是否默认启用自动滚动 | | `autoScrollThresholdPx` | `number` | `32` | 自动滚动的像素阈值 | | `autoScrollThresholdLines` | `number` | `2` | 自动滚动的行数阈值 | | `diffAutoScroll` | `boolean` | `true` | 是否启用 Diff modified 侧自动滚动 | | `diffHideUnchangedRegions` | `boolean \| object` | `true` | 是否折叠 Diff 中未改动区块(支持传 Monaco 配置对象) | | `diffLineStyle` | `'background' \| 'bar'` | `'background'` | 控制变更行是偏背景块还是偏 review 竖条风格 | | `diffAppearance` | `'auto' \| 'light' \| 'dark'` | `'auto'` | 控制 diff 外层 chrome 明暗,代码 token 仍跟随当前主题 | | `diffUnchangedRegionStyle` | `'line-info' \| 'line-info-basic' \| 'metadata' \| 'simple'` | `'line-info'` | 控制折叠未改动区块的展示方式 | | `diffHunkActionsOnHover` | `boolean` | `false` | 是否启用 hover hunk 的上下分区局部 Revert / Stage(需显式开启) | | `diffHunkHoverHideDelayMs` | `number` | `160` | hover 操作浮层离开后的隐藏延迟(毫秒) | | `onDiffHunkAction` | `function` | - | hunk 操作回调(返回 `false` 可阻止默认编辑) | | `diffUpdateThrottleMs` | `number` | `50` | Diff 流式更新节流时间(毫秒) | ##### 返回值 | 方法/属性 | 类型 | 描述 | | ------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `createEditor` | `(container: HTMLElement, code: string, language: string) => Promise` | 创建并挂载编辑器到指定容器 | | `createDiffEditor` | `(container: HTMLElement, original: string, modified: string, language: string) => Promise` | 创建并挂载 Diff 编辑器 | | `cleanupEditor` | `() => void` | 销毁编辑器并清理容器 | | `updateCode` | `(newCode: string, codeLanguage: string) => void` | 更新编辑器内容和语言(RAF 合并、增量优化) | | `appendCode` | `(appendText: string, codeLanguage?: string) => void` | 在编辑器末尾追加文本 | | `updateDiff` | `(original: string, modified: string, codeLanguage?: string) => void` | 批量更新 Diff 内容(RAF 合并、增量优化) | | `updateOriginal` | `(newCode: string, codeLanguage?: string) => void` | 仅更新 original(即时增量) | | `updateModified` | `(newCode: string, codeLanguage?: string) => void` | 仅更新 modified(即时增量) | | `setDiffModels` | `(models: DiffModelPair, options?: DiffModelTransitionOptions) => Promise` | 原地切换整对 Diff models;当新旧内容相同,会先预热 Monaco 的 diff view model,再走保留视图状态的低抖动切换 | | `setTheme` | `(theme: MonacoTheme, force?: boolean) => Promise` | 切换编辑器主题;`force=true` 时即使当前主题相同也会强制重应用 | | `refreshDiffPresentation` | `() => void` | 在不 remount 的情况下,重算 diff chrome / unchanged overlay 的表现层 | | `setLanguage` | `(language: MonacoLanguage) => void` | 切换编辑器语言 | | `getCurrentTheme` | `() => string` | 获取当前主题名称 | | `getEditor` | `() => typeof monaco.editor` | 获取 Monaco 的静态 editor 对象 | | `getEditorView` | `() => MonacoEditorInstance \| null` | 获取当前编辑器实例 | | `getDiffEditorView` | `() => MonacoDiffEditorInstance \| null` | 获取当前 Diff 编辑器实例 | | `getDiffModels` | `() => DiffModels` | 获取 Diff 两侧模型 | | `getMonacoInstance` | `() => typeof monaco` | 获取 Monaco 模块实例 | | `setUpdateThrottleMs` | `(ms: number) => void` | 动态调整 `updateCode` 的时间节流 | | `getUpdateThrottleMs` | `() => number` | 获取当前 `updateCode` 节流值 | | `getCode` | `() => MonacoCodeValue` | **获取编辑器当前代码**
- 普通编辑器返回 `string`
- Diff 编辑器返回 `{ original, modified }`
- 无编辑器返回 `null`
**用途**:获取用户手动编辑后的最新代码或程序更新后的内容 | | `appendOriginal` | `(appendText: string, codeLanguage?: string) => void` | 在 original 末尾追加(显式流式) | | `appendModified` | `(appendText: string, codeLanguage?: string) => void` | 在 modified 末尾追加(显式流式) | #### 支持的主题 包括但不限于: - `vitesse-dark` / `vitesse-light` - `github-dark` / `github-light` - `dracula` / `dracula-soft` - `one-dark-pro` / `one-light` - `tokyo-night` - `material-theme` 系列 - `catppuccin` 系列 - 以及更多... #### 支持的语言 包括但不限于: - `javascript` / `typescript` / `jsx` / `tsx` - `vue` / `html` / `css` / `scss` / `less` - `python` / `java` / `csharp` / `cpp` / `rust` / `go` - `json` / `yaml` / `toml` / `xml` - `markdown` / `dockerfile` - 以及 100+ 种语言... ### 最佳实践 #### 1. 性能优化 ```typescript // 只加载需要的语言,减少包体积 const { createEditor } = useMonaco({ languages: ['javascript', 'typescript'], // 只加载必要的语言 themes: ['vitesse-dark', 'vitesse-light'], }) ``` #### 2. 内存管理 ```vue ``` ### 故障排除 #### 1. 打包后编辑器无法显示 确保正确配置了 Monaco Editor 的 Web Workers。 **Vue CLI 4(Webpack 4)注意:** Webpack 4 无法解析 `import.meta.url`。请改用 `stream-monaco/legacy` 入口(不包含 `import.meta`),并在项目侧配置 Monaco workers。 推荐使用 `monaco-editor-webpack-plugin` 来处理 Monaco workers: ```bash pnpm add -D monaco-editor-webpack-plugin ``` `vue.config.js`: ```js const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin') module.exports = { configureWebpack: { plugins: [ new MonacoWebpackPlugin({ languages: ['json', 'css', 'html', 'typescript'], }), ], }, } ``` 并尽早调用一次(例如 `main.ts`): ```ts import { preloadMonacoWorkers } from 'stream-monaco/legacy' preloadMonacoWorkers() ``` 默认的 `stream-monaco` 与 `stream-monaco/legacy` 入口在模块求值时会做一次 best-effort 的 Monaco worker 安装。包元数据只把这些已发布入口文件标记为有 side effects,其余内部模块仍保持正常 tree-shaking。 #### 2. Diff 编辑器流式更新时内容区空白 确保在调用 `createEditor` / `createDiffEditor` 之前已正确配置 Monaco 的 workers(建议尽早调用 `preloadMonacoWorkers()`)。 #### 3. 主题不生效 检查主题名称是否正确,确保主题已在 `themes` 数组中注册。 #### 4. 语言高亮不工作 确保语言已在 `languages` 数组中包含,并且 Shiki 支持该语言。 ### 贡献 欢迎提交 Issue 或 PR 来改进此项目! ### 开发 ```bash # 克隆项目 git clone https://github.com/Simon-He95/stream-monaco.git # 安装依赖 pnpm install # 启动开发服务器 pnpm dev # 构建 pnpm build ``` ### :coffee: [buy me a cup of coffee](https://github.com/Simon-He95/sponsor) ### License [MIT](./license) ### Sponsors

## 致谢 ### Clearing shiki highlighter cache The library caches shiki highlighters internally to avoid recreating them for the same theme combinations. In long-running applications that may dynamically create many distinct theme combinations, you can clear the cache to free memory or fully reset the shared highlighter state (for example in tests or on app shutdown): - `clearHighlighterCache()` — clears cached highlighters and resets the shared Monaco/Shiki highlighter state - `getHighlighterCacheSize()` — returns number of cached entries Call `clearHighlighterCache()` when you are certain highlighters are no longer needed (for example during teardown), otherwise leaving the cache enabled provides a performance benefit by reusing previously-created highlighters.