译:使用 TypeScript,React,ANTLR 和 Monaco Editor 创建一个自定义 Web 编辑器(一)

4,505 阅读7分钟

你是否想知道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 CodeSublimeEclipse)中支持您的语言,而又不费吹灰之力,则最好将语言服务和 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 中的表达式可以是 addExpressioncompleteExpression。与正则表达式一样,星号(*)表示该表达式可能出现零次或多次。

每个表达式都以一个终端关键字(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](https://webpack.js.org/configuration/dev-server/), webpack-cli, [HtmlWebpackPlugin](https://github.com/jantimon/html-webpack-plugin), and [ts-loader](https://www.npmjs.com/package/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-dom
npm add -D typescript @types/react @types/react-dom ts-loader html-webpack-plugin webpack webpack-cli webpack-dev-server

在根路径创建 src 目录,并新建 index.tsindex.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 divNode
        divNode = 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#faq
You 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](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.languages.imonarchlanguage.html) 这是 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 yet
    defaultToken: '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 languages
    tokenizer: {
        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 以提供自动完成的语言服务。