你是否想知道Visual Studio(Online),CodeSandbox 或 Snack 等 Web 编辑器如何工作?还是您想制作自定义的 Web 或桌面编辑器而又不知道如何开始?
在本文中,我将介绍 Web 编辑器是如何工作的,并且我们将创建一个自定义语言。
我们要构建的语言编辑器很简单。它声明一个 TODO 列表,然后将一些预定义的指令应用于它们。我将这种语言称为 TodoLang。以下一些示例是这个语言的说明:
ADD TODO "Make the world a better place"ADD TODO "read daily"ADD TODO "Exercise"COMPLETE TODO "Learn & share"
我们只需使用以下命令添加一些 TODOs:
ADD TODO "TODO_TEXT";
我们可以使用 COMPLETE TODO “todo_text” 来表示完成的 TODO,以便解释该代码的输出可以告诉我们剩余的 TODO 和到目前为止已经完成的 TODO 。这是我出于本文目的发明的一种简单语言。它似乎没有用,但是它包含了本文中我需要介绍的所有内容。
我们将使编辑器支持以下功能:
- 自动格式化
- 自动完成
- 语法高亮
- 语法和语义验证
注意:编辑器一次仅支持一个代码或文件编辑。它不支持多个文件或代码编辑。
TodoLang 语义规则
以下是一些我将用于 TodoLang 代码的语义验证的语义:
- 如果使用 ADD TODO 说明定义了 TODO ,我们可以重新添加它。
- 在 TODO 中应用中,COMPLETE 指令不应在尚未使用声明 ADD TODO 前。
在本文的后面,我将回到这些语义规则。
在深入研究代码之前,让我们先从 Web 编辑器或任何常规编辑器的一般架构开始。

从上面的模式可以看出,通常,任何编辑器中都有两个线程。一个负责 UI 内容,例如等待用户输入一些代码或执行某些操作。另一个线程接受用户所做的更改并进行繁重的计算,其中包括代码解析和其他编译工作。
对于编辑器中的每个更改(可能是用户输入的每个字符,或者直到用户停止输入 2 秒钟为止),消息都会发送给Language Service Worker 执行某些操作。Worker 本身将使用包含结果的消息进行响应。例如,当用户输入一些代码并想要格式化该代码(单击Shift + Alt + F)时,Worker 将收到一条消息,其中包含操作“格式化”和要格式化的代码。这应该使用异步来操作以具有良好的用户体验。
另一方面,语言服务负责解析代码,生成抽象语法树(AST),查找任何可能的语法或词法错误,使用 AST 查找任何语义错误,格式化代码等。
我们可以通过 LSP协议 使用一种新的高级方式来处理语言服务,但是在此示例中,语言服务和编辑器将处于同一进程(即浏览器)中,而没有任何后端处理。如果您希望在其他编辑器(例如 VS Code,Sublime 或 Eclipse)中支持您的语言,而又不费吹灰之力,则最好将语言服务和 worker 分开。使用 LSP 将使您能够为其他编辑器制作插件以支持您的语言。查看 LSP 页面以了解更多信息。
编辑器提供了一个界面,允许用户输入代码并执行一些操作。当用户输入内容时,编辑器应查阅配置列表,以突出显示代码标记(关键字,类型等)。这可以通过语言服务来完成,但是对于我们的示例,我们将在编辑器中完成。我们将在以后看到如何做。

Monaco 提供了一个API monaco.editor.createWebWorker 来使用内置的 ES6 Proxies 创建代理 Web worker 。使用 getProxy 方法获取代理对象(语言服务)。要访问语言服务工作者中的任何服务,我们将使用此代理对象来调用任何方法。所有方法都将返回 Promise 对象。
查看 Comlink,这是 Google 开发的一个小型库,使用 ES6 Proxies 使与 Web workers 的交互变得愉快。
闲话少说,让我们开始编写一些代码。
我们将使用什么?
React
视图相关。
ANTLR
根据其网站上的定义,“ ANTLR(另一种语言识别工具)是一种强大的解析器生成器,用于读取,处理,执行或翻译结构化文本或二进制文件。它被广泛用于构建语言,工具和框架。ANTLR 从语法上生成了一个解析器,可以构建和遍历解析树。” ANTLR 支持许多语言作为目标,这意味着它可以生成 Java,C#和其他语言的解析器。对于这个项目,我将使用 ANTLR4TS,它是 ANTLR 的 Node.js 版本,可以在 TypeScript 中生成一个词法分析器和解析器。
ANTLR 使用特殊的语法来声明语言语法,该语法通常放置在*.g4文件中。它使您可以在单个组合语法文件中定义词法分析器和解析器规则。在此存储库中,您将找到许多知名语言的语法文件。
此语法语法使用被称为符号 Backus normal form (BNF) 来描述语法)的语言。
TodoLang语法
这是我们的 TodoLang 的简化语法。它为 TodoLang 声明了一个根规则 todoExpressions,该规则包含表达式列表。TodoLang 中的表达式可以是 addExpression或completeExpression。与正则表达式一样,星号(*)表示该表达式可能出现零次或多次。
每个表达式都以一个终端关键字(add,todo 或complete)开头,并带有一个标识 TODO 的字符串(“…”)。
grammar TodoLangGrammar;todoExpressions : (addExpression)* (completeExpression)*;addExpression : ADD TODO STRING EOL;completeExpression : COMPLETE TODO STRING EOL;ADD : 'ADD';TODO : 'TODO';COMPLETE: 'COMPLETE';STRING: '"' ~ ["]* '"';EOL: [\r\n] +;WS: [ \t] -> skip;
Monaco-Editor
Monaco Editor 是为VS Code提供支持的代码编辑器。这是一个 JavaScript 库,提供用于语法高亮显示,自动完成等功能的API。
开发工具
TypeScript, webpack,webpack-dev-server, webpack-cli, HtmlWebpackPlugin, and ts-loader.
因此,让我们从启动项目开始。
启动一个新的TypeScript项目
为此,让我们启动我们的项目:
npm init
创建tsconfig.json具有以下最低内容的文件:
{"compilerOptions": {"target": "es6","module": "commonjs","allowJs": true,"jsx": "react"}}
为 webpack 添加 webpack.config.js 配置文件:
const path = require('path');const htmlWebpackPlugin = require('html-webpack-plugin');module.exports = {mode: 'development',entry: {app: './src/index.tsx'},output: {filename: 'bundle.[hash].js',path: path.resolve(__dirname, 'dist')},resolve: {extensions: ['.ts', '.tsx', '.js', '.jsx']},module: {rules: [{test: /.tsx?/,loader: 'ts-loader'}]},plugins: [new htmlWebpackPlugin({template: './src/index.html'})]}
为 React 和 TypeScrip t添加依赖项:
npm add react react-domnpm add -D typescript @types/react @types/react-dom ts-loader html-webpack-plugin webpack webpack-cli webpack-dev-server
在根路径创建 src 目录,并新建 index.ts 和 index.html 包含一个 id 为 container 的 div。
添加 Monaco Editor 组件
如果您以TypeScript,HTML或Java等现有语言作为目标,则不必重新发明轮子。Monaco Editor 和 Monaco Languages支持其中大多数语言。
对于我们的示例,我们将使用名为 monaco-editor-core 的 Monaco Editor 的核心版本。
添加包:
npm add monaco-editor-core
我们还需要一些 CSS loader,因为 Monaco 在内部使用它们:
npm add -D style-loader css-loader
将这些规则添加到 webpack 配置中的 module 属性中:
{test: /\.css$/,use: ['style-loader', 'css-loader']}
最后,将CSS添加到已解析的扩展中:
extensions: ['.ts', '.tsx', '.js', '.jsx','.css']
现在我们准备创建编辑器组件。创建一个 React 组件(我们将命名为 Editor),并返回一个具有 ref 属性的元素,以便我们可以使用其引用让 Monaco API 将编辑器注入其中。
要创建 Monaco 编辑器,我们需要调用 monaco.editor.create。并传入一些参数 editor、languageId 及 theme 等。请查看文档以获取更多详细信息。
添加一个文件,其中将包含以下所有语言配置src/todo-lang:
export const languageID = 'todoLang' ;
在 src/components 中添加 Editor 组件:
import * as React from 'react';import * as monaco from 'monaco-editor-core';interface IEditorPorps {language: string;}const Editor: React.FC<IEditorPorps> = (props: IEditorPorps) => {let divNode;const assignRef = React.useCallback((node) => {// On mount get the ref of the div and assign it the divNodedivNode = node;}, []);React.useEffect(() => {if (divNode) {const editor = monaco.editor.create(divNode, {language: props.language,minimap: { enabled: false },autoIndent: true});}}, [assignRef])return <div ref={assignRef} style={{ height: '90vh' }}></div>;}export { Editor };
基本上,我们在挂载时使用回调钩子来获取 div 的引用,因此可以将其传递给create函数。
现在,您可以将编辑器组件添加到应用程序中,并根据需要添加一些样式。
使用 Monaco API 注册我们的语言
为了使 Monaco Editor 支持我们定义的语言(例如,当我们创建编辑器时,我们指定了语言ID),我们需要使用API monaco.languages.register 进行注册。让我们在中创建一个 src/todo-lang 名为的文件 setup。我们还需要实现 monaco.languages.onLanguage 一个回调,以在语言配置就绪时调用该回调。(我们稍后将使用此回调来注册语言提供程序以进行语法高亮,自动完成,格式化等):
import * as monaco from "monaco-editor-core";import { languageExtensionPoint, languageID } from "./config";export function setupLanguage() {monaco.languages.register(languageExtensionPoint);monaco.languages.onLanguage(languageID, () => {});}
现在,在 index.tsx 调用 setupLanguage 。
为 Monaco 添加 Worker
到目前为止,如果您运行该项目并在浏览器中打开它,则会收到有关 Web Worker 的错误消息:
Could not create web worker(s). Falling back to loading web worker code in main thread, which might cause UI freezes. Please see https://github.com/Microsoft/monaco-editor#faqYou must define a function MonacoEnvironment.getWorkerUrl or MonacoEnvironment.getWorker
Language services 会创建 Web Worker,以计算UI线程之外的繁重工作。它们几乎不需要任何开销,不需要担心,只要正常使用即可。
Monaco Editor 使用了一个 Web Worker,我认为它是用于高亮和执行其它行为。我们将创建另一个用于处理语言服务的 worker。
首先需要将 Monaco’s editor web worker 通过 webpack 打包。将此 worker 添加到入口:
entry: {app: './src/index.tsx',"editor.worker": 'monaco-editor-core/esm/vs/editor/editor.worker.js'},
更改 webpack 的输出的全局变量为 self ,到目前为止,这是webpack配置文件的内容:
const path = require('path');const htmlWebpackPlugin = require('html-webpack-plugin');module.exports = {mode: 'development',entry: {app: './src/index.tsx',"editor.worker": 'monaco-editor-core/esm/vs/editor/editor.worker.js'},output: {globalObject: 'self',filename: (chunkData) => {switch (chunkData.chunk.name) {case 'editor.worker':return 'editor.worker.js';default:return 'bundle.[hash].js';}},path: path.resolve(__dirname, 'dist')},resolve: {extensions: ['.ts', '.tsx', '.js', '.jsx', '.css']},module: {rules: [{test: /\.tsx?/,loader: 'ts-loader'},{test: /\.css/,use: ['style-loader', 'css-loader']}]},plugins: [new htmlWebpackPlugin({template: './src/index.html'})]}
从上面的错误我们可以看到,Monaco 从全局变量 MonacoEnvironment 调用方法 getWorkerUrl 。转到 setupLanguage 并添加以下内容:
import * as monaco from "monaco-editor-core";import { languageExtensionPoint, languageID } from "./config";export function setupLanguage() {(window as any).MonacoEnvironment = {getWorkerUrl: function (moduleId, label) {return './editor.worker.js';}}monaco.languages.register(languageExtensionPoint);monaco.languages.onLanguage(languageID, () => {});}
这将告诉 Monaco 怎么去寻找 worker,我们将添加自定义的 language service worker 。
运行该应用程序,您应该看到一个尚不支持任何功能的编辑器:
添加语法高亮和语言配置
在本节中,我们将添加一些关键字高亮。
Monaco Editor使用Monarch库,该库使我们能够使用 JSON 创建声明性语法突出显示器。如果您想了解有关此语法的更多信息,请查看其文档。
这是用于语法高亮显示,代码折叠等的Java配置示例。
在 src/todo-lang 中创建 config.ts 。我们将使用 Monaco API 配置 TodoLang 的高亮及令牌生成器:monaco.languages.setMonarchTokensProvider。它带有两个参数,即语言 ID 和 type 的配置IMonarchLanguage。
这是 TodoLang 的配置:
import * as monaco from "monaco-editor-core";import IRichLanguageConfiguration = monaco.languages.LanguageConfiguration;import ILanguage = monaco.languages.IMonarchLanguage;export const monarchLanguage = <ILanguage>{// Set defaultToken to invalid to see what you do not tokenize yetdefaultToken: 'invalid',keywords: ['COMPLETE', 'ADD',],typeKeywords: ['TODO'],escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,// The main tokenizer for our languagestokenizer: {root: [// identifiers and keywords[/[a-zA-Z_$][\w$]*/, {cases: {'@keywords': { token: 'keyword' },'@typeKeywords': { token: 'type' },'@default': 'identifier'}}],// whitespace{ include: '@whitespace' },// strings for todos[/"([^"\\]|\\.)*$/, 'string.invalid'], // non-teminated string[/"/, 'string', '@string'],],whitespace: [[/[ \t\r\n]+/, ''],],string: [[/[^\\"]+/, 'string'],[/@escapes/, 'string.escape'],[/\\./, 'string.escape.invalid'],[/"/, 'string', '@pop']]},}
我们基本上为 TodoLang 中的每种关键字指定 CSS 类或令牌名称。例如,对于关键字 COMPLETE 以及 ADD,我们还配置 Monaco 给字符串着色,方法是为它们提供一个类型为 CSS 的类,该类由 Monaco 预定义。你可以使用 [defineTheme](https://microsoft.github.io/monaco-editor/api/modules/monaco.editor.html#definetheme) API 并创建一个新的 CSS class 调用 setTheme 之后即可覆盖原有主题。
要告诉 Monaco 考虑此配置,请在 onLanguage 回调函数中使用设置函数 call [monaco.languages.setMonarchTokensProvider](https://microsoft.github.io/monaco-editor/api/modules/monaco.languages.html#setmonarchtokensprovider),并将其配置作为第二个参数:
import * as monaco from "monaco-editor-core";import { languageExtensionPoint, languageID } from "./config";import { monarchLanguage } from "./TodoLang";export function setupLanguage() {(window as any).MonacoEnvironment = {getWorkerUrl: function (moduleId, label) {return './editor.worker.js';}}monaco.languages.register(languageExtensionPoint);monaco.languages.onLanguage(languageID, () => {monaco.languages.setMonarchTokensProvider(languageID, monarchLanguage);});}
运行应用程序。编辑器现在应该支持语法高亮显示。
这是到目前为止该项目的源代码:amazzalel-habib / TodoLangEditor。
在本文的下一部分,我将介绍语言服务。我将使用 ANTLR 生成 TodoLang 词法分析器和解析器,并使用解析器提供的 AST 实现编辑器的大多数功能。然后,我们将了解如何创建 Worker 以提供自动完成的语言服务。
