你是否想知道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-dom
npm 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 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。
这是 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 以提供自动完成的语言服务。