原文地址:https://medium.com/better-programming/create-a-custom-web-editor-using-typescript-react-antlr-and-monaco-editor-part-1-2f710c69c18c

你是否想知道Visual Studio(Online),CodeSandbox 或 Snack 等 Web 编辑器如何工作?还是您想制作自定义的 Web 或桌面编辑器而又不知道如何开始?
在本文中,我将介绍 Web 编辑器是如何工作的,并且我们将创建一个自定义语言。
我们要构建的语言编辑器很简单。它声明一个 TODO 列表,然后将一些预定义的指令应用于它们。我将这种语言称为 TodoLang。以下一些示例是这个语言的说明:

  1. ADD TODO "Make the world a better place"
  2. ADD TODO "read daily"
  3. ADD TODO "Exercise"
  4. COMPLETE TODO "Learn & share"

我们只需使用以下命令添加一些 TODOs:

  1. ADD TODO "TODO_TEXT";

我们可以使用 COMPLETE TODO “todo_text” 来表示完成的 TODO,以便解释该代码的输出可以告诉我们剩余的 TODO 和到目前为止已经完成的 TODO 。这是我出于本文目的发明的一种简单语言。它似乎没有用,但是它包含了本文中我需要介绍的所有内容。

我们将使编辑器支持以下功能:

  • 自动格式化
  • 自动完成
  • 语法高亮
  • 语法和语义验证

注意:编辑器一次仅支持一个代码或文件编辑。它不支持多个文件或代码编辑。

TodoLang 语义规则
以下是一些我将用于 TodoLang 代码的语义验证的语义:

  • 如果使用 ADD TODO 说明定义了 TODO ,我们可以重新添加它。
  • 在 TODO 中应用中,COMPLETE 指令不应在尚未使用声明 ADD TODO 前。

在本文的后面,我将回到这些语义规则。
在深入研究代码之前,让我们先从 Web 编辑器或任何常规编辑器的一般架构开始。

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

从上面的模式可以看出,通常,任何编辑器中都有两个线程。一个负责 UI 内容,例如等待用户输入一些代码或执行某些操作。另一个线程接受用户所做的更改并进行繁重的计算,其中包括代码解析和其他编译工作。

对于编辑器中的每个更改(可能是用户输入的每个字符,或者直到用户停止输入 2 秒钟为止),消息都会发送给Language Service Worker 执行某些操作。Worker 本身将使用包含结果的消息进行响应。例如,当用户输入一些代码并想要格式化该代码(单击Shift + Alt + F)时,Worker 将收到一条消息,其中包含操作“格式化”和要格式化的代码。这应该使用异步来操作以具有良好的用户体验。

另一方面,语言服务负责解析代码,生成抽象语法树(AST),查找任何可能的语法或词法错误,使用 AST 查找任何语义错误,格式化代码等。

我们可以通过 LSP协议 使用一种新的高级方式来处理语言服务,但是在此示例中,语言服务和编辑器将处于同一进程(即浏览器)中,而没有任何后端处理。如果您希望在其他编辑器(例如 VS CodeSublimeEclipse)中支持您的语言,而又不费吹灰之力,则最好将语言服务和 worker 分开。使用 LSP 将使您能够为其他编辑器制作插件以支持您的语言。查看 LSP 页面以了解更多信息。

编辑器提供了一个界面,允许用户输入代码并执行一些操作。当用户输入内容时,编辑器应查阅配置列表,以突出显示代码标记(关键字,类型等)。这可以通过语言服务来完成,但是对于我们的示例,我们将在编辑器中完成。我们将在以后看到如何做。

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

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。与正则表达式一样,星号(*)表示该表达式可能出现零次或多次。

每个表达式都以一个终端关键字(addtodo complete)开头,并带有一个标识 TODO 的字符串(“…”)。

  1. grammar TodoLangGrammar;
  2. todoExpressions : (addExpression)* (completeExpression)*;
  3. addExpression : ADD TODO STRING EOL;
  4. completeExpression : COMPLETE TODO STRING EOL;
  5. ADD : 'ADD';
  6. TODO : 'TODO';
  7. COMPLETE: 'COMPLETE';
  8. STRING: '"' ~ ["]* '"';
  9. EOL: [\r\n] +;
  10. WS: [ \t] -> skip;

Monaco-Editor

Monaco Editor 是为VS Code提供支持的代码编辑器。这是一个 JavaScript 库,提供用于语法高亮显示,自动完成等功能的API。

开发工具

TypeScript, webpack,webpack-dev-server, webpack-cli, HtmlWebpackPlugin, and ts-loader.

因此,让我们从启动项目开始。

启动一个新的TypeScript项目

为此,让我们启动我们的项目:

  1. npm init

创建tsconfig.json具有以下最低内容的文件:

  1. {
  2. "compilerOptions": {
  3. "target": "es6",
  4. "module": "commonjs",
  5. "allowJs": true,
  6. "jsx": "react"
  7. }
  8. }

为 webpack 添加 webpack.config.js 配置文件:

  1. const path = require('path');
  2. const htmlWebpackPlugin = require('html-webpack-plugin');
  3. module.exports = {
  4. mode: 'development',
  5. entry: {
  6. app: './src/index.tsx'
  7. },
  8. output: {
  9. filename: 'bundle.[hash].js',
  10. path: path.resolve(__dirname, 'dist')
  11. },
  12. resolve: {
  13. extensions: ['.ts', '.tsx', '.js', '.jsx']
  14. },
  15. module: {
  16. rules: [
  17. {
  18. test: /.tsx?/,
  19. loader: 'ts-loader'
  20. }
  21. ]
  22. },
  23. plugins: [
  24. new htmlWebpackPlugin({
  25. template: './src/index.html'
  26. })
  27. ]
  28. }

为 React 和 TypeScrip t添加依赖项:

  1. npm add react react-dom
  2. 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 的核心版本。

添加包:

  1. npm add monaco-editor-core

我们还需要一些 CSS loader,因为 Monaco 在内部使用它们:

  1. npm add -D style-loader css-loader

将这些规则添加到 webpack 配置中的 module 属性中:

  1. {
  2. test: /\.css$/,
  3. use: ['style-loader', 'css-loader']
  4. }

最后,将CSS添加到已解析的扩展中:

  1. extensions: ['.ts', '.tsx', '.js', '.jsx','.css']

现在我们准备创建编辑器组件。创建一个 React 组件(我们将命名为 Editor),并返回一个具有 ref 属性的元素,以便我们可以使用其引用让 Monaco API 将编辑器注入其中。

要创建 Monaco 编辑器,我们需要调用 monaco.editor.create。并传入一些参数 editor、languageId 及 theme 等。请查看文档以获取更多详细信息。

添加一个文件,其中将包含以下所有语言配置src/todo-lang

  1. export const languageID = 'todoLang' ;

src/components 中添加 Editor 组件:

  1. import * as React from 'react';
  2. import * as monaco from 'monaco-editor-core';
  3. interface IEditorPorps {
  4. language: string;
  5. }
  6. const Editor: React.FC<IEditorPorps> = (props: IEditorPorps) => {
  7. let divNode;
  8. const assignRef = React.useCallback((node) => {
  9. // On mount get the ref of the div and assign it the divNode
  10. divNode = node;
  11. }, []);
  12. React.useEffect(() => {
  13. if (divNode) {
  14. const editor = monaco.editor.create(divNode, {
  15. language: props.language,
  16. minimap: { enabled: false },
  17. autoIndent: true
  18. });
  19. }
  20. }, [assignRef])
  21. return <div ref={assignRef} style={{ height: '90vh' }}></div>;
  22. }
  23. export { Editor };

基本上,我们在挂载时使用回调钩子来获取 div 的引用,因此可以将其传递给create函数。
现在,您可以将编辑器组件添加到应用程序中,并根据需要添加一些样式。

使用 Monaco API 注册我们的语言

为了使 Monaco Editor 支持我们定义的语言(例如,当我们创建编辑器时,我们指定了语言ID),我们需要使用API monaco.languages.register 进行注册。让我们在中创建一个 src/todo-lang 名为的文件 setup。我们还需要实现 monaco.languages.onLanguage 一个回调,以在语言配置就绪时调用该回调。(我们稍后将使用此回调来注册语言提供程序以进行语法高亮,自动完成,格式化等):

  1. import * as monaco from "monaco-editor-core";
  2. import { languageExtensionPoint, languageID } from "./config";
  3. export function setupLanguage() {
  4. monaco.languages.register(languageExtensionPoint);
  5. monaco.languages.onLanguage(languageID, () => {
  6. });
  7. }

现在,在 index.tsx 调用 setupLanguage

为 Monaco 添加 Worker

到目前为止,如果您运行该项目并在浏览器中打开它,则会收到有关 Web Worker 的错误消息:

  1. 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
  2. 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 添加到入口:

  1. entry: {
  2. app: './src/index.tsx',
  3. "editor.worker": 'monaco-editor-core/esm/vs/editor/editor.worker.js'
  4. },

更改 webpack 的输出的全局变量为 self ,到目前为止,这是webpack配置文件的内容:

  1. const path = require('path');
  2. const htmlWebpackPlugin = require('html-webpack-plugin');
  3. module.exports = {
  4. mode: 'development',
  5. entry: {
  6. app: './src/index.tsx',
  7. "editor.worker": 'monaco-editor-core/esm/vs/editor/editor.worker.js'
  8. },
  9. output: {
  10. globalObject: 'self',
  11. filename: (chunkData) => {
  12. switch (chunkData.chunk.name) {
  13. case 'editor.worker':
  14. return 'editor.worker.js';
  15. default:
  16. return 'bundle.[hash].js';
  17. }
  18. },
  19. path: path.resolve(__dirname, 'dist')
  20. },
  21. resolve: {
  22. extensions: ['.ts', '.tsx', '.js', '.jsx', '.css']
  23. },
  24. module: {
  25. rules: [
  26. {
  27. test: /\.tsx?/,
  28. loader: 'ts-loader'
  29. },
  30. {
  31. test: /\.css/,
  32. use: ['style-loader', 'css-loader']
  33. }
  34. ]
  35. },
  36. plugins: [
  37. new htmlWebpackPlugin({
  38. template: './src/index.html'
  39. })
  40. ]
  41. }

从上面的错误我们可以看到,Monaco 从全局变量 MonacoEnvironment 调用方法 getWorkerUrl 。转到 setupLanguage 并添加以下内容:

  1. import * as monaco from "monaco-editor-core";
  2. import { languageExtensionPoint, languageID } from "./config";
  3. export function setupLanguage() {
  4. (window as any).MonacoEnvironment = {
  5. getWorkerUrl: function (moduleId, label) {
  6. return './editor.worker.js';
  7. }
  8. }
  9. monaco.languages.register(languageExtensionPoint);
  10. monaco.languages.onLanguage(languageID, () => {
  11. });
  12. }

这将告诉 Monaco 怎么去寻找 worker,我们将添加自定义的 language service worker 。
运行该应用程序,您应该看到一个尚不支持任何功能的编辑器:
使用 TypeScript,React,ANTLR 和 Monaco Editor 创建一个自定义 Web 编辑器(一) - 图3

添加语法高亮和语言配置

在本节中,我们将添加一些关键字高亮。
Monaco Editor使用Monarch库,该使我们能够使用 JSON 创建声明性语法突出显示器。如果您想了解有关此语法的更多信息,请查看其文档。
这是用于语法高亮显示,代码折叠等的Java配置示例
src/todo-lang 中创建 config.ts 。我们将使用 Monaco API 配置 TodoLang 的高亮及令牌生成器:monaco.languages.setMonarchTokensProvider。它带有两个参数,即语言 ID 和 type 的配置IMonarchLanguage
这是 TodoLang 的配置:

  1. import * as monaco from "monaco-editor-core";
  2. import IRichLanguageConfiguration = monaco.languages.LanguageConfiguration;
  3. import ILanguage = monaco.languages.IMonarchLanguage;
  4. export const monarchLanguage = <ILanguage>{
  5. // Set defaultToken to invalid to see what you do not tokenize yet
  6. defaultToken: 'invalid',
  7. keywords: [
  8. 'COMPLETE', 'ADD',
  9. ],
  10. typeKeywords: ['TODO'],
  11. escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
  12. // The main tokenizer for our languages
  13. tokenizer: {
  14. root: [
  15. // identifiers and keywords
  16. [/[a-zA-Z_$][\w$]*/, {
  17. cases: {
  18. '@keywords': { token: 'keyword' },
  19. '@typeKeywords': { token: 'type' },
  20. '@default': 'identifier'
  21. }
  22. }],
  23. // whitespace
  24. { include: '@whitespace' },
  25. // strings for todos
  26. [/"([^"\\]|\\.)*$/, 'string.invalid'], // non-teminated string
  27. [/"/, 'string', '@string'],
  28. ],
  29. whitespace: [
  30. [/[ \t\r\n]+/, ''],
  31. ],
  32. string: [
  33. [/[^\\"]+/, 'string'],
  34. [/@escapes/, 'string.escape'],
  35. [/\\./, 'string.escape.invalid'],
  36. [/"/, 'string', '@pop']
  37. ]
  38. },
  39. }

我们基本上为 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),并将其配置作为第二个参数:

  1. import * as monaco from "monaco-editor-core";
  2. import { languageExtensionPoint, languageID } from "./config";
  3. import { monarchLanguage } from "./TodoLang";
  4. export function setupLanguage() {
  5. (window as any).MonacoEnvironment = {
  6. getWorkerUrl: function (moduleId, label) {
  7. return './editor.worker.js';
  8. }
  9. }
  10. monaco.languages.register(languageExtensionPoint);
  11. monaco.languages.onLanguage(languageID, () => {
  12. monaco.languages.setMonarchTokensProvider(languageID, monarchLanguage);
  13. });
  14. }

运行应用程序。编辑器现在应该支持语法高亮显示。
使用 TypeScript,React,ANTLR 和 Monaco Editor 创建一个自定义 Web 编辑器(一) - 图4
这是到目前为止该项目的源代码:amazzalel-habib / TodoLangEditor
在本文的下一部分,我将介绍语言服务。我将使用 ANTLR 生成 TodoLang 词法分析器和解析器,并使用解析器提供的 AST 实现编辑器的大多数功能。然后,我们将了解如何创建 Worker 以提供自动完成的语言服务。