用 Monaco Editor 搭一个带 Python 智能提示的在线编辑器
想在网页里做一个 Python 编辑器,不只是语法高亮,还要有代码补全、类型检查、跳转定义这些功能。这套东西的核心是 LSP(Language Server Protocol):编辑器前端和语言服务器之间通过标准协议通信,编辑器不需要自己实现语言分析逻辑。
整体架构是这样的:
浏览器 Monaco Editor
↕ WebSocket
Node.js 服务端(vscode-ws-jsonrpc)
↕ stdio
pyright 语言服务器进程
Monaco Editor 通过 WebSocket 连接到 Node.js 服务端,服务端再把消息转发给 pyright 进程,pyright 负责实际的 Python 分析。
搭建 LSP 服务端
服务端的职责很简单:接受 WebSocket 连接,把消息桥接给 pyright 的 stdio。
JS 版本
初始化项目
mkdir pyright-server && cd pyright-server
pnpm init
mkdir src && touch src/index.js
安装依赖
pnpm add pyright express ws vscode-ws-jsonrpc vscode-languageserver-protocol
配置 package.json,启用 ESM:
{
"type": "module",
"scripts": {
"dev": "node src/index.js"
}
}
src/index.js 完整代码
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
import express from 'express';
import { WebSocketServer } from 'ws';
import http from 'http';
import { WebSocketMessageReader, WebSocketMessageWriter } from 'vscode-ws-jsonrpc';
import { createConnection, createServerProcess, forward } from 'vscode-ws-jsonrpc/server';
const app = express();
const PORT = 7878;
const server = http.createServer(app);
// noServer: true,手动处理 upgrade 事件
const wsServer = new WebSocketServer({ noServer: true });
server.on('upgrade', (request, socket, head) => {
wsServer.handleUpgrade(request, socket, head, (ws) => {
const socket = {
send: content => ws.send(content, error => { if (error) throw error; }),
onMessage: cb => ws.on('message', data => cb(data)),
onError: cb => ws.on('error', cb),
onClose: cb => ws.on('close', cb),
dispose: () => ws.close()
};
if (ws.readyState === ws.OPEN) {
launchLanguageServer(socket);
}
});
});
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
function launchLanguageServer(socket) {
const __filename = fileURLToPath(import.meta.url);
const currentDirPath = resolve(dirname(__filename));
const processRunPath = resolve(currentDirPath, '../node_modules/pyright/dist/pyright-langserver.js');
const reader = new WebSocketMessageReader(socket);
const writer = new WebSocketMessageWriter(socket);
const socketConnection = createConnection(reader, writer, () => socket.dispose());
const serverConnection = createServerProcess(
'Pyright', 'node', [processRunPath, '--stdio'], {}
);
if (serverConnection) {
forward(socketConnection, serverConnection, message => message);
}
}
TypeScript 版本
TS 版本结构一样,多了类型定义和 tsconfig 配置。
安装依赖
pnpm add pyright express ws vscode-ws-jsonrpc vscode-languageserver-protocol
pnpm add -D typescript @types/node @types/express @types/ws tsx
初始化 tsconfig
npx tsc --init
编辑 tsconfig.json:
{
"compilerOptions": {
"moduleResolution": "nodenext",
"rootDir": "./src",
"outDir": "./dist",
"target": "ES2022",
"module": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
package.json 启用 ESM,用 tsx 直接运行 TS:
{
"type": "module",
"scripts": {
"dev": "tsx src/index.ts"
}
}
src/index.ts 完整代码
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
import express from 'express';
import { WebSocketServer } from 'ws';
import http from 'http';
import { IWebSocket, WebSocketMessageReader, WebSocketMessageWriter } from 'vscode-ws-jsonrpc';
import { createConnection, createServerProcess, forward } from 'vscode-ws-jsonrpc/server';
import { Message } from 'vscode-languageserver-protocol';
const app = express();
const PORT = 7878;
const server = http.createServer(app);
const wsServer = new WebSocketServer({ noServer: true });
server.on('upgrade', (request, socket, head) => {
wsServer.handleUpgrade(request, socket, head, (ws) => {
const socket: IWebSocket = {
send: content => ws.send(content, error => { if (error) throw error; }),
onMessage: cb => ws.on('message', data => cb(data)),
onError: cb => ws.on('error', cb),
onClose: cb => ws.on('close', cb),
dispose: () => ws.close()
};
if (ws.readyState === ws.OPEN) {
launchLanguageServer(socket);
}
});
});
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
function launchLanguageServer(socket: IWebSocket) {
const __filename = fileURLToPath(import.meta.url);
const currentDirPath = resolve(dirname(__filename));
const processRunPath = resolve(currentDirPath, '../node_modules/pyright/dist/pyright-langserver.js');
const reader = new WebSocketMessageReader(socket);
const writer = new WebSocketMessageWriter(socket);
const socketConnection = createConnection(reader, writer, () => socket.dispose());
const serverConnection = createServerProcess('Pyright', 'node', [processRunPath, '--stdio'], {});
if (serverConnection) {
forward(socketConnection, serverConnection, message => {
if (Message.isRequest(message)) {
console.log(`← ${message.method}`);
}
return message;
});
}
}
启动:
pnpm run dev
搭建 Monaco 前端
前端用 Vue + Vite,通过 monaco-editor-wrapper 连接 LSP 服务。
创建项目
pnpm create vite@latest
安装依赖
pnpm add vscode@npm:@codingame/monaco-vscode-api
pnpm add @codingame/monaco-vscode-keybindings-service-override
pnpm add @codingame/monaco-vscode-python-default-extension
pnpm add @codingame/monaco-vscode-editor-api
pnpm add @codingame/monaco-vscode-textmate-service-override
pnpm add monaco-languageclient
pnpm add vscode-ws-jsonrpc
pnpm add monaco-editor-wrapper
pnpm add -D @types/vscode
pnpm add -D @codingame/esbuild-import-meta-url-plugin
vite.config.ts,需要加 esbuild 插件处理 import.meta.url:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import importMetaUrlPlugin from '@codingame/esbuild-import-meta-url-plugin';
export default defineConfig({
optimizeDeps: {
esbuildOptions: {
plugins: [importMetaUrlPlugin]
},
include: ['vscode-textmate', 'vscode-oniguruma']
},
plugins: [vue()],
})
config/monaco-user-config.ts,配置编辑器和 LSP 连接:
import type { WrapperConfig } from 'monaco-editor-wrapper';
import * as vscode from 'vscode';
import { LogLevel } from 'vscode/services';
import { MonacoLanguageClient } from 'monaco-languageclient';
import { useWorkerFactory } from 'monaco-editor-wrapper/workerFactory';
import '@codingame/monaco-vscode-python-default-extension';
import getKeybindingsServiceOverride from '@codingame/monaco-vscode-keybindings-service-override';
import { createUrl } from 'monaco-languageclient/tools';
import { toSocket, WebSocketMessageReader, WebSocketMessageWriter } from 'vscode-ws-jsonrpc';
export const createUserConfig = (code: string): WrapperConfig => {
const url = createUrl({ secured: false, host: 'localhost', port: 7878 });
const webSocket = new WebSocket(url);
const iWebSocket = toSocket(webSocket);
const reader = new WebSocketMessageReader(iWebSocket);
const writer = new WebSocketMessageWriter(iWebSocket);
const configureMonacoWorkers = () => {
useWorkerFactory({
workerOverrides: {
ignoreMapping: true,
workerLoaders: {
TextEditorWorker: () => new Worker(
new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url),
{ type: 'module' }
),
TextMateWorker: () => new Worker(
new URL('@codingame/monaco-vscode-textmate-service-override/worker', import.meta.url),
{ type: 'module' }
)
}
}
});
};
return {
$type: 'extended',
htmlContainer: document.getElementById('monaco-editor-root')!,
logLevel: LogLevel.Debug,
languageClientConfigs: {
python: {
name: 'Python Language Server',
connection: {
options: {
$type: 'WebSocketDirect',
webSocket,
startOptions: {
onCall: (languageClient?: MonacoLanguageClient) => {
setTimeout(() => {
['pyright.restartserver', 'pyright.organizeimports'].forEach(cmd => {
vscode.commands.registerCommand(cmd, (...args) => {
languageClient?.sendRequest('workspace/executeCommand', { command: cmd, arguments: args });
});
});
}, 250);
},
reportStatus: true,
}
},
messageTransports: { reader, writer }
},
clientOptions: {
documentSelector: ['python'],
workspaceFolder: {
index: 0,
name: 'workspace',
uri: vscode.Uri.parse('/workspace'),
},
}
}
},
vscodeApiConfig: {
serviceOverrides: { ...getKeybindingsServiceOverride() },
userConfiguration: {
json: JSON.stringify({
'workbench.colorTheme': 'Default Dark Modern',
'editor.wordBasedSuggestions': 'off',
})
}
},
editorAppConfig: {
codeResources: {
modified: { text: code, uri: '/workspace/hello.py' }
},
monacoWorkerFactory: configureMonacoWorkers
}
};
};
components/Editor.vue:
<script setup lang="ts">
import { MonacoEditorLanguageClientWrapper } from 'monaco-editor-wrapper';
import { createUserConfig } from '../config/monaco-user-config';
const wrapper = new MonacoEditorLanguageClientWrapper();
const run = async () => {
if (wrapper.isStarted()) return;
// 注意:新版 API 需要传参,见踩坑部分
await wrapper.init(createUserConfig('print("Hello World")'), true);
await wrapper.start({ includeLanguageClients: true });
};
const stop = async () => {
if (!wrapper.isStarted()) return;
await wrapper.dispose();
};
const getContent = () => {
const editor = wrapper.getEditor();
if (editor) console.log(editor.getValue());
};
</script>
<template>
<div>
<button @click="run">开启编辑器</button>
<button @click="stop">关闭编辑器</button>
<button @click="getContent">获取内容</button>
</div>
<div id="monaco-editor-root"></div>
</template>
<style scoped>
#monaco-editor-root {
width: 800px;
height: 600px;
border: 1px solid #ccc;
}
</style>
踩坑记录
1. monaco-editor-wrapper 6.3.0 WebSocket 不工作
升级到 6.3.0 之后,编辑器启动了但和 LSP 服务没有任何交互。查了一下,是 init 和 start 的 API 签名变了:
// 旧写法(不工作)
await wrapper.init(createUserConfig("print('Hello, World!')"));
await wrapper.start();
// 新写法
await wrapper.init(createUserConfig("print('Hello, World!')"), true);
await wrapper.start({ includeLanguageClients: true });
init 第二个参数 includeLanguageClients 默认值变了,必须显式传 true,否则 Language Client 不会初始化。
2. pyright 无法跨文件检测
在浏览器里打开 test2.py,里面 from test1 import print_hello 会报 Import "test1" could not be resolved,即使 test1.py 也在编辑器里打开着。
原因是 pyright 运行在服务器上,它的文件系统里根本没有 test1.py。Monaco 编辑器里”打开”一个文件,只是在浏览器内存里,不会自动同步到服务器。
解法是在服务端拦截 textDocument/didOpen 事件,把文件内容写到服务器磁盘:
forward(socketConnection, serverConnection, message => {
if (message.method === 'textDocument/didOpen') {
const uri = message.params.textDocument.uri;
const uriPath = fileURLToPath(uri);
const content = message.params.textDocument.text;
fs.writeFileSync(uriPath, content);
}
return message;
});
同理,textDocument/didChange 也要处理,否则修改内容不会同步。这个方案是临时解法,monaco-languageclient 官方在做自动文件同步的功能(issue #834),等正式支持后会更干净。