首页
Preview

使用 Monaco 构建代码编辑器

我在 Snack 工作,这是一个用于 React Native 应用的在线代码编辑器。过去,Snack 的代码编辑器使用 Ace Editor,但现在我们已经迁移到了 Monaco Editor,因为它具有更好的用户体验。除了官方文档外,关于 Monaco 的资源并不多,因此很难弄清楚如何做一些事情,这真的是一场惊险之旅。 我想分享我在这个过程中学到的东西,希望能帮助其他人构建自己的编辑器。这不是一个全面的指南,我只涉及了我在 Snack 上工作的部分内容,但它应该能给你一个良好的开端。

构建编辑器和 Workers

首先,我需要先构建编辑器,以便在 Snack 上使用它。这主要是由于我们使用的技术栈的限制。(如果你不使用 Next.js 4,而是使用 Webpack 4,你就不需要担心这个问题。) NPM 上的 Monaco 编辑器包是作为 JavaScript 进行转译和发布的。但是,使用 Monaco 包需要我们使用 Webpack,因为它仍然包含用于 CSS 文件的 import 语句。我们还需要加载一些脚本作为 Web Workers,以提高编辑器的性能。 对于 Snack,我们使用的是 Next.js 4,它仍然在使用 Webpack 2。Monaco 文档上的 说明 可能是针对不同版本的,我无法让它们正常工作。globalObject: 'self' 选项在我们使用的 Webpack 版本上不起作用(它在 Webpack 4 上有效),结果 Workers 就无法正常工作了。我也无法让 CSS 导入正常工作,因为我找不到一种在生产环境下正确加载它们的方法。 因此,我决定首先通过 Webpack 运行 Monaco,以生成预构建的编辑器和 Workers 的捆绑包,并将 CSS 提取到单独的文件中。这使我能够轻松加载捆绑包,而无需担心构建配置的问题。这是构建编辑器的 Webpack 配置:

const webpack = require('webpack');
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const common = {
  mode: 'production',
  devtool: 'source-map',
  output: {
    filename: '[name].bundle.js',
  },
  optimization: {
    minimize: true,
    namedModules: true,
    concatenateModules: true,
  },
  plugins: [
    new webpack.DefinePlugin({ 'process.env': { NODE_ENV: 'production' } }),
    new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
    // Ignore require() calls in vs/language/typescript/lib/typescriptServices.js
    new webpack.IgnorePlugin(
      /^((fs)|(path)|(os)|(crypto)|(source-map-support))$/,
      /vs(\/|\\)language(\/|\\)typescript(\/|\\)lib/
    ),
    new webpack.ContextReplacementPlugin(
      /monaco-editor(\\|\/)esm(\\|\/)vs(\\|\/)editor(\\|\/)common(\\|\/)services/
    ),
    new MiniCssExtractPlugin({ filename: 'monaco.css' }),
  ],
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: 'babel-loader',
      },
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
};
module.exports = [
  {
    ...common,
    entry: {
      monaco: './index',
    },
    output: {
      ...common.output,
      path: path.resolve('dist', 'monaco'),
      library: 'monaco',
      libraryTarget: 'umd',
    },
  },
  {
    ...common,
    target: 'webworker',
    output: {
      ...common.output,
      path: path.resolve('dist', 'workers'),
    },
    entry: {
      // Language workers
      'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker',
      'json.worker': 'monaco-editor/esm/vs/language/json/json.worker',
      'ts.worker': 'monaco-editor/esm/vs/language/typescript/ts.worker',
    },
  },
];

请注意,我只构建了我实际需要的工作程序。如果你支持更多的语言,你也可以添加它们。在配置文件中引用的 index.js 文件如下所示,我们只导入我们使用的内容:

import 'monaco-editor/esm/vs/language/typescript/monaco.contribution';
import 'monaco-editor/esm/vs/language/json/monaco.contribution';

export * from 'monaco-editor/esm/vs/editor/edcore.main';

请注意,这是针对旧版本的说明。对于最新版本,你可以使用以下方式代替:

import * as editor from 'monaco-editor/esm/vs/editor/editor.main';

另一个问题是我们在 Snack 上使用 CDN 来提供 JavaScript 捆绑包,这会导致代码拆分和加载工作线程时出现问题,因为它们来自不同的域。解决方法是将工作线程作为 blob URL 加载。虽然不是理想的解决方法,但目前可行:

const code = `
  try {
    self.importScripts('${BASE_URL}/static/workers/${WORKER_NAME}.worker.bundle.js');
  } catch (e) {
    console.error('Failed to load worker: ', '${WORKER_NAME}', e);
  }
`;

if ('Blob' in window) {
  return URL.createObjectURL(new Blob([code], { type: 'application/javascript' }));
} else {
  return `data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`;
}

将编辑器作为 React 组件使用

Snack 是用 React 编写的。所以我想制作一个 React 组件来使用 Monaco。Monaco 只需要一个 DOM 节点来渲染,所以我们可以渲染一个 div 并将 ref 传递给 Monaco:

import * as React from 'react';
import * as monaco from 'monaco-editor';

export default class MonacoEditor extends React.Component {
  componentDidMount() {
    const { path, value, language, ...options } = this.props;
    const model = monaco.editor.createModel(value, language, path);
    this._editor = monaco.editor.create(this._node, options);
    this._editor.setModel(model);
  }

  componentWillUnmount() {
    this._editor && this._editor.dispose();
  }

  render() {
    return <div ref={c => this._node = c} />
  }
}

我们可能想将其用作受控组件。我们可以订阅编辑器内容的更改,如下所示:

const model = this._editor.getModel();

model.onDidChangeContent(() => {
  const value = model.getValue();
  this.props.onValueChange(value);
});

为了正确地将其用作受控组件,当内容通过 prop 更改时,我们还要更新编辑器内容:

const model = this._editor.getModel();

model.pushEditOperations(
  [],
  [
    {
      range: model.getFullModelRange(),
      text: value,
    },
  ]
);

使用 pushEditOperations 保留了撤消堆栈。你也可以使用 this._editor.executeEditsmodel.setValue,但这会破坏撤消堆栈,这通常是不可取的。

经过这些更改,它将如下所示:

import * as React from 'react';
import * as monaco from 'monaco-editor';

export default class MonacoEditor extends React.Component {
  componentDidMount() {
    const { path, value, language, onValueChange, ...options } = this.props;
    const model = monaco.editor.createModel(value, language, path);
  
    this._editor = monaco.editor.create(this._node, options);
    this._editor.setModel(model);
    this._subscription = model.onDidChangeContent(() => {
      this.props.onValueChange(model.getValue());
    });
  }

  componentDidUpdate(prevProps: Props) {
    const { path, value, language, onValueChange, ...options } = this.props;
  
    this._editor.updateOptions(options);
  
    const model = this._editor.getModel();
  
    if (value !== model.getValue()) {
      model.pushEditOperations(
        [],
        [
          {
            range: model.getFullModelRange(),
            text: value,
          },
        ]
      );
    }
  }

  componentWillUnmount() {
    this._editor && this._editor.dispose();
    this._subscription && this._subscription.dispose();
  }

  render() {
    return <div ref={c => this._node = c} />
  }
}

这段代码目前还不支持多个文件,但我们稍后会进行配置。

提高 JavaScript 和 React 开发体验

默认情况下,编辑器非常适合处理 JavaScript 文件,具有出色的智能感知功能。然而,我们想要使用社区中广受欢迎的 ESLint 和 Prettier 替换默认的验证器和格式化工具。 此外,当我们开始集成编辑器时,JSX 没有语法高亮显示。由于 Snack 旨在用于 React Native 应用程序,因此正确地高亮显示 JSX 代码非常重要。

使用 ESLint 验证 JavaScript

默认情况下,Monaco 使用 TypeScript 语言服务验证 JavaScript 文件。这会显示错误的下划线和滚动条上的标记。由于我们想要使用 ESLint 进行代码检查,因此我们选择禁用内置的验证器:

monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
  noSemanticValidation: true,
  noSyntaxValidation: true,
});

现在,代码没有任何验证。要使用 ESLint,我们可以创建一个工作器并使用 setModelMarkers API 在代码中突出显示错误。工作器将如下所示:

import ESLint from './eslint.bundle';

self.addEventListener('message', event => {
  const { code, version } = event.data;

  try {
    const markers = ESLint.verify(
      code,
      { /* ESLint config */ }
    ).map(err => ({
      startLineNumber: err.line,
      endLineNumber: err.line,
      startColumn: err.column,
      endColumn: err.column,
      message: `${err.message} (${err.ruleId})`,
      severity: 3,
      source: 'ESLint',
    }));

    self.postMessage({ markers, version });
  } catch (e) {
    /* Ignore error */
  }
});

在每次更改时,我们将发送代码到工作程序进行验证。代码可能如下所示:

// Reset the markers
monaco.editor.setModelMarkers(model, 'eslint', []);
// Send the code to the worker
worker.postMessage({
  code: model.getValue(),
  // Unique identifier to avoid displaying outdated validation
  version: model.getVersionId(),
});

你可能希望防抖动此代码以减少验证频率。 我们还需要添加一个监听器,以在验证数据返回时更新标记:

worker.addEventListener('message', ({ data }) => {
  const { markers, version } = data;
  const model = this._editor.getModel();
  if (model && model.getVersionId() === version) {
    monaco.editor.setModelMarkers(model, 'eslint', markers);
  }
});

由于 ESLint 默认不支持浏览器环境,因此我有一个自定义版本,其中包括必要的插件。

使用 Prettier 替换默认格式化程序

Monaco 的上下文菜单和命令面板中都有“格式化文档”选项。我们想要将其更改为使用Prettier。在 Monaco 中,格式化程序可以按语言进行更改,如下所示:

monaco.languages.registerDocumentFormattingEditProvider('javascript', {
  async provideDocumentFormattingEdits(model, options, token) {
    const prettier = await import('prettier/standalone');
    const babylon = await import('prettier/parser-babylon');
    const text = prettier.format(model.getValue(), {
      parser: 'babylon',
      plugins: [babylon],
      singleQuote: true,
    });

    return [
      {
        range: model.getFullModelRange(),
        text,
      },
    ];
  },
});

修复 JSX 的语法高亮

只有在你使用较旧版本的 Monaco 时,才需要此操作,因为最新版本默认支持 JSX 代码的基本语法高亮。 幸运的是,我们不必自己解决这个问题——Ives已经为Codesandbox解决了这个问题,并且很亲切地向我指出了源代码(非常感谢❤️)。我们所需要做的就是下载并包含此工作程序,然后像这样使用它:

worker.addEventListener('message', ({ data }) => {
  const model = this._editor.getModel();

  if (model && model.getVersionId() !== version) {
    return;
  }

  const decorations = classifications.map(classification => ({
    range: new monaco.Range(
      classification.startLine,
      classification.start,
      classification.endLine,
      classification.end
    ),
    options: {
      // Some class names to help us add some color to the JSX code
      inlineClassName: classification.type
        ? `${classification.kind} ${classification.type}-of-${
            classification.parentKind
          }`
        : classification.kind,
    },
  }));

  model.decorations = this._editor.deltaDecorations(
    model.decorations || [],
    decorations
  );
});

我们添加了以下 CSS 以设置颜色;你需要根据你的语法主题进行修改:

.JsxText {
  color: #5c6773;
}

.JsxSelfClosingElement,
.JsxOpeningElement,
.JsxClosingElement,
.tagName-of-JsxOpeningElement,
.tagName-of-JsxClosingElement,
.tagName-of-JsxSelfClosingElement {
  color: #41a6d9;
}

.name-of-JsxAttribute {
  color: #f08c36;
}

.name-of-PropertyAssignment {
  color: #86b300;
}

.name-of-PropertyAccessExpression {
  color: #f08c36;
}

配置多个文件

Snack是一个功能齐全的编辑器,支持编辑多个文件。为了正确支持多个文件(每个文件都有独立的滚动位置、光标状态、撤销/重做堆栈等),我们需要在使用它的方式上做一些改变。

保留撤销/重做堆栈

在之前的示例中,我们使用了一个叫做“model”的东西。对于Monaco来说,有一个editor的单一实例,而你可以有多个模型,每个模型表示一个文件。为了保留撤销/重做堆栈,我们需要确保为每个文件创建一个新模型,并根据打开的文件设置正确的模型。这个方法可以看起来像这样:

  1. 在启动时,为每个文件创建新的模型:monaco.editor.createModel(value, language, new monaco.Uri().with({ path }))
  2. 当一个文件被打开时,将编辑器的模型设置为相应的文件:this._editor.setModel(model)。我们可以使用monaco.editor.getModels()来检索现有的模型。
  3. 当文件被重命名或删除时,处理相应的模型,并为新路径创建一个新模型(如果被重命名)。

这样可以确保为所有文件保留正确的撤销/重做堆栈。值得注意的是,我们提前创建模型,而不是在文件打开时创建。这是因为只有在我们创建模型后,Monaco才能提供智能提示。通过提前创建模型,我们可以确保Monaco知道所有文件以提供智能提示。

保留选择和滚动位置

Monaco提供了一个API来获取编辑器的当前状态。我们可以保留每个文件的状态映射,并在打开另一个文件时恢复它:

editorStates.set(filePath, this._editor.saveViewState());

要恢复编辑器状态:

this._editor.restoreViewState(editorState);

为多个文件启用智能提示

为了启用导入文件的智能提示,我们需要提前创建所有模型,并将它们与worker同步。我们可以在componentDidMount中初始化所有模型以准备就绪。为了在创建模型时立即将它们同步到worker,我们可以执行以下操作:

monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true);

使“查找所有引用”工作

为了使“查找所有引用”工作,我们不得不对Monaco进行Monkeypatch:

import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { SimpleEditorModelResolverService } from 'monaco-editor/esm/vs/editor/standalone/browser/simpleServices';
/**
 * Monkeypatch to make 'Find All References' work across multiple files
 * https://github.com/Microsoft/monaco-editor/issues/779#issuecomment-374258435
 */
SimpleEditorModelResolverService.prototype.findModel = function(editor, resource) {
  return monaco.editor.getModels().find(model => model.uri.toString() === resource.toString());
};

这样可以让 Monaco 使用 URI 正确解析模型。

使“转到定义”功能正常工作

在创建编辑器时,我们可以传递一个第三个参数来指定覆盖项。在这里,我们可以指定一个 codeEditorService,其中包含一个 openCodeEditor 方法,当触发“转到定义”时将以某些选项调用该方法。在这里,我们可以处理切换到正确的文件,并将光标放置在正确的位置:

import { StaticServices } from 'monaco-editor/esm/vs/editor/standalone/browser/standaloneServices';

const codeEditorService = StaticServices.codeEditorService.get();

// Rest of the code

this._editor = monaco.editor.create(this._node, rest, {
  codeEditorService: Object.assign(Object.create(codeEditorService), {
    openCodeEditor: async ({ resource, options }, editor) => {
      // Open the file with this path
      // This should set the model with the path and value
      this.props.onOpenPath(resource.path);
      // Move cursor to the desired position
      editor.setSelection(options.selection);
      // Scroll the editor to bring the desired line into focus
      editor.revealLine(options.selection.startLineNumber);
      return {
        getControl: () => editor,
      };
    }),
  },
});

在早期版本的Monaco中,它被称为“editorService”,我们只需要实现“openEditor”方法(现在是“openCodeEditor”)。

杂项

应用自定义主题

我们可以创建一个从默认的浅色或深色主题继承的自定义主题,像这样:

monaco.editor.defineTheme('ace', {
  base: 'vs',
  inherit: true,
  rules: [
    { token: '', foreground: '5c6773' },
    { token: 'invalid', foreground: 'ff3333' },
    { token: 'emphasis', fontStyle: 'italic' },
    { token: 'strong', fontStyle: 'bold' },
    { token: 'variable', foreground: '5c6773' },
    { token: 'variable.predefined', foreground: '5c6773' },
    { token: 'constant', foreground: 'f08c36' },
    { token: 'comment', foreground: 'abb0b6', fontStyle: 'italic' },
    { token: 'number', foreground: 'f08c36' },
    { token: 'number.hex', foreground: 'f08c36' },
    { token: 'regexp', foreground: '4dbf99' },
    { token: 'annotation', foreground: '41a6d9' },
    { token: 'type', foreground: '41a6d9' },
    { token: 'delimiter', foreground: '5c6773' },
    { token: 'delimiter.html', foreground: '5c6773' },
    { token: 'delimiter.xml', foreground: '5c6773' },
    { token: 'tag', foreground: 'e7c547' },
    { token: 'tag.id.jade', foreground: 'e7c547' },
    { token: 'tag.class.jade', foreground: 'e7c547' },
    { token: 'meta.scss', foreground: 'e7c547' },
    { token: 'metatag', foreground: 'e7c547' },
    { token: 'metatag.content.html', foreground: '86b300' },
    { token: 'metatag.html', foreground: 'e7c547' },
    { token: 'metatag.xml', foreground: 'e7c547' },
    { token: 'metatag.php', fontStyle: 'bold' },
    { token: 'key', foreground: '41a6d9' },
    { token: 'string.key.json', foreground: '41a6d9' },
    { token: 'string.value.json', foreground: '86b300' },
    { token: 'attribute.name', foreground: 'f08c36' },
    { token: 'attribute.value', foreground: '0451A5' },
    { token: 'attribute.value.number', foreground: 'abb0b6' },
    { token: 'attribute.value.unit', foreground: '86b300' },
    { token: 'attribute.value.html', foreground: '86b300' },
    { token: 'attribute.value.xml', foreground: '86b300' },
    { token: 'string', foreground: '86b300' },
    { token: 'string.html', foreground: '86b300' },
    { token: 'string.sql', foreground: '86b300' },
    { token: 'string.yaml', foreground: '86b300' },
    { token: 'keyword', foreground: 'f2590c' },
    { token: 'keyword.json', foreground: 'f2590c' },
    { token: 'keyword.flow', foreground: 'f2590c' },
    { token: 'keyword.flow.scss', foreground: 'f2590c' },
    { token: 'operator.scss', foreground: '666666' }, //
    { token: 'operator.sql', foreground: '778899' }, //
    { token: 'operator.swift', foreground: '666666' }, //
    { token: 'predefined.sql', foreground: 'FF00FF' }, //
  ],
  colors: {
    'editor.background': '#fafafa',
    'editor.foreground': '#5c6773',
    'editorIndentGuide.background': '#ecebec',
    'editorIndentGuide.activeBackground': '#e0e0e0',
  },
});

那么我们可以全局应用主题:

monaco.editor.setTheme('ace');

遗憾的是,如果你有独立的编辑器实例,就无法应用不同的主题。

提供自动完成

我们还想为文件和可用模块提供自动完成。使用Monaco编写这个功能非常简单:

this._completionProvider = monaco.languages.registerCompletionItemProvider('javascript', {
  // These characters should trigger our `provideCompletionItems` function
  triggerCharacters: ["'", '"', '.', '/'],
  // Function which returns a list of autocompletion ietems. If we return an empty array, there won't be any autocompletion.
  provideCompletionItems: (model, position) => {
    // Get all the text content before the cursor
    const textUntilPosition = model.getValueInRange({
      startLineNumber: 1,
      startColumn: 1,
      endLineNumber: position.lineNumber,
      endColumn: position.column,
    });
    // Match things like `from "` and `require("`
    if (/(([\s|\n]+from\s+)|(\brequire\b\s*\())["|'][^'^"]*$/.test(textUntilPosition)) {
      // It's probably a `import` statement or `require` call
      if (textUntilPosition.endsWith('.') || textUntilPosition.endsWith('/')) {
        // User is trying to import a file
        return Object.keys(this.props.files)
          .filter(path => path !== this.props.path)
          .map(path => {
            let file = getRelativePath(this.props.path, path);
            // Only show files that match the prefix typed by user
            if (file.startsWith(prefix)) {
              // Remove typed text from the path so that don't insert it twice
              file = file.slice(typed.length);
              return {
                // Show the full file path for label
                label: file,
                // Don't keep extension for JS files
                insertText: file.replace(/\.js$/, ''),
                kind: monaco.languages.CompletionItemKind.File,
              };
            }
            return null;
          })
          .filter(Boolean);
      } else {
        // User is trying to import a dependency
        return Object.keys(this.props.dependencies).map(name => ({
          label: name,
          detail: this.props.dependencies[name],
          kind: monaco.languages.CompletionItemKind.Module,
        }));
      }
    }
  },
});

请注意,我们需要在组件卸载时处理完成提供程序,以防止内存泄漏。

总结

希望本指南能帮助你开始使用 Monaco 构建代码编辑器。为了让事情变得更简单,我还准备了一个包含样板代码的 GitHub 仓库,以便开始客户端开发。如果你有任何问题,请随时留言!

译自:https://blog.expo.dev/building-a-code-editor-with-monaco-f84b3a06deaf

版权声明:本文内容由TeHub注册用户自发贡献,版权归原作者所有,TeHub社区不拥有其著作权,亦不承担相应法律责任。 如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

点赞(0)
收藏(0)
皓月当空
名士风流,国士无双

评论(0)

添加评论