6
头图
Author of this article: taro

At present, the team is working on building a low-code platform, which will support LowCode/ProCode dual-mode online development, and the ProCode scenario requires a relatively complete WebIDE running in the browser. At the same time, taking into account the needs of some possible block code platforms in the future, the WebIDE module is isolated separately in order to meet more personalized needs later.

Thanks to the power of monaco -editor, it is very easy to use monaco-editor to build a simple WebIDE, but it is not easy to add functions such as multi-file support, ESLint , Prettier , and code completion.

This article intends to share some experiences and solutions learned in the construction of WebIDE, hoping to help students with the same needs. At the same time, this is not a hands-on article, but only introduces some decision-making ideas and sample codes. For the complete code, see github , and also build a demo to experience (demo depends on a lot of static files, deployed on github pages, the access speed is too slow, it may not be loaded normally, you can view it after clone and run dev, the mobile terminal is also recommended to use chrome open), also provides an npm component that can be used directly as a react component.

Compared with the mature @monaco-editor/react in the industry, the WebIDE provided in this article directly aggregates the file directory tree, file navigation save state, etc. into the component, and provides support for basic capabilities such as Eslint, Prettier, etc. to reduce the cost of secondary development.

About CloudIDE and WebIDE

Before we start, let's talk about CloudIDE and WebIDE.

Previously, based on theia , the team built a set of CloudIDE (see this article for its related introduction) platform. On the container side, the middle communicates through rpc to complete the cloudification of the entire IDE.

The IDE shared in this article does not use a containerized solution, but runs some of the services originally running in remote containers, such as multilingual services, Eslint inspection, etc., in the browser through web workers based on monaco-editor. Compared with containerization solutions, lightweight IDEs do not have the capability of command-line terminals.

IDEs that rely on containerization technology and can provide complete terminal capabilities are called CloudIDEs in this article, and IDEs that only rely on browser capabilities are called WebIDEs in this article. The IDE I want to share in this article belongs to the latter.

Introduce monaco-editor

There are two main ways to introduce monaco-editor, amd or esm.

Both access methods are relatively easy, I have tried both.

Relatively speaking, it was more inclined to the esm method at first, but due to the issue , it can be used normally in the current project after packaging, but when it is released as an npm package and others use it, the packaging will go wrong.

Therefore, the first method is finally adopted. The monaco-editor is introduced by dynamically inserting the script tag. In the project, the timer polls the existence of window.monaco to determine whether the monaco-editor is loaded. If it is not completed, a loading is provided to wait.

Multiple file support

In the official example of monaco-editor, it is basically single-file processing, but multi-file processing is also very simple. This article only briefly introduces it here.

Multi-file processing mainly involves two APIs: monaco.editor.create and monaco.editor.createModel .

Among them, createModel is the core api for multi-file processing. Create different models according to the file path. When you need to switch, you can switch between multiple files by calling editor.setModel .

The general pseudocode for creating multiple files and switching is as follows:

 const files = {
    '/test.js': 'xxx',
    '/app/test.js': 'xxx2',
}

const editor = monaco.editor.create(domNode, {
    ...options,
    model: null, // 此处model设为null,是阻止默认创建的空model
});

Object.keys(files).forEach((path) =>
    monaco.editor.createModel(
        files[path],
        'javascript',
        new monaco.Uri().with({ path })
    )
);

function openFile(path) {
    const model = monaco.editor.getModels().find(model => model.uri.path === path);
    editor.setModel(model);
}

openFile('/test.js');

By writing a certain ui code, you can easily switch between multiple files.

Preserve the state before the switch

Through the above method, multi-file switching can be realized, but before and after the file switching, the scroll position of the mouse and the selected state of the text will be lost.

At this point, you can create a map to store the state of different files before switching. The core code is as follows:

 const editorStatus = new Map();
const preFilePath = '';

const editor = monaco.editor.create(domNode, {
    ...options,
    model: null,
});

function openFile(path) {
    const model = monaco.editor
        .getModels()
        .find(model => model.uri.path === path);
        
    if (path !== preFilePath) {
        // 储存上一个path的编辑器的状态
        editorStatus.set(preFilePath, editor.saveViewState());
    }
    // 切换到新的model
    editor.setModel(model);
    const editorState = editorStates.get(path);
    if (editorState) {
        // 恢复编辑器的状态
        editor.restoreViewState(editorState);
    }
    // 聚焦编辑器
    editor.focus();
    preFilePath = path;
}

The core is to use the saveViewState method of the editor instance to store the editor state, and restore it through the restoreViewState method.

file jump

As an excellent editor, monaco-editor itself is able to perceive the existence of other models and prompt related code completion. Although the relevant information can be seen on the hover, the most commonly used cmd + click cannot be jumped by default.

This one is also a relatively common problem. For detailed reasons and solutions, you can view this issue .

Simply put, the library itself does not implement this opening, because if the jump is allowed, there is no obvious way for the user to jump back.

In practice, it can be solved by overriding openCodeEditor, and if the jump result is not found, implement the model switch by yourself

     const editorService = editor._codeEditorService;
    const openEditorBase = editorService.openCodeEditor.bind(editorService);
    editorService.openCodeEditor = async (input, source) =>  {
        const result = await openEditorBase(input, source);
        if (result === null) {
            const fullPath = input.resource.path;
            // 跳转到对应的model
            source.setModel(monaco.editor.getModel(input.resource));
            // 此处还可以自行添加文件选中态等处理
        
            // 设置选中区以及聚焦的行数
            source.setSelection(input.options.selection);
            source.revealLine(input.options.selection.startLineNumber);
        }
        return result; // always return the base result
    };

controlled

In the actual writing of react components, it is often necessary to control the content of the file, which requires the editor to notify the outside world when the content changes, and also allows the outside world to directly modify the text content.

Let's talk about the monitoring of content changes first. Each model of monaco-editor provides a method such as onDidChangeContent to monitor file changes, and we can continue to transform our openFile function.

 
let listener = null;

function openFile(path) {
    const model = monaco.editor
        .getModels()
        .find(model => model.uri.path === path);
        
    if (path !== preFilePath) {
        // 储存上一个path的编辑器的状态
        editorStatus.set(preFilePath, editor.saveViewState());
    }
    // 切换到新的model
    editor.setModel(model);
    const editorState = editorStates.get(path);
    if (editorState) {
        // 恢复编辑器的状态
        editor.restoreViewState(editorState);
    }
    // 聚焦编辑器
    editor.focus();
    preFilePath = path;
    
    if (listener) {
        // 取消上一次的监听
        listener.dispose();
    }
    
    // 监听文件的变更
    listener = model.onDidChangeContent(() => {
        const v = model.getValue();
        if (props.onChange) {
            props.onChange({
                value: v,
                path,
            })
        }
    })
}

Solved the notification of internal changes to the outside world. If the outside world wants to directly modify the value of the file, it can be modified directly through model.setValue , but this direct operation will lose the editor's undo stack. If you want to keep the undo, you can use model. pushEditOperations to achieve replacement, the specific code is as follows:

 function updateModel(path, value) {
    let model = monaco.editor.getModels().find(model => model.uri.path === path);
    
    if (model && model.getValue() !== value) {
        // 通过该方法,可以实现undo堆栈的保留
        model.pushEditOperations(
            [],
            [
                {
                    range: model.getFullModelRange(),
                    text: value
                }
            ],
            () => {},
        )
    }
}

summary

Through the API provided by monaco-editor above, the entire multi-file support can be basically completed.

Of course, there is still a lot of work to implement, file tree list, top tab, unsaved state, file navigation, etc. However, this part belongs to the daily work of most of our front-end. Although the workload is not small, it is not complicated to implement, so I won't repeat it here.

ESLint support

monaco-editor itself has syntax analysis, but it only has syntax error checking, no code style checking. Of course, there should be no code style checking.

As a modern front-end development programmer, basically every project will have ESLint configuration. Although WebIDE is a simplified version, ESLint is still essential.

Program exploration

The principle of ESLint is to traverse the syntax tree and then check. Its core Linter does not depend on the node environment, and the official also has a separate package output. Specifically, after the official code of clone, execute npm run webpack to get the core package Post ESLint.js. Its essence is to package the linter.js file.

At the same time, the official also provides the official demo of ESLint based on the packaged product.

The linter is used as follows:

 import { Linter } from 'path/to/bundled/ESLint.js';

const linter = new Linter();

// 定义新增的规则,比如react/hooks, react特殊的一些规则
// linter中已经定义了包含了ESLint的所有基本规则,此处更多的是一些插件的规则的定义。
linter.defineRule(ruleName, ruleImpl);

linter.verify(text, {
    rules: {
        'some rules you want': 'off or warn',
    },
    settings: {},
    parserOptions: {},
    env: {},
})

If you only use the methods provided by the above linter, there are several problems:

  1. Too many rules, too tiring to write one by one and not necessarily in line with team norms
  2. The rules of some plugins cannot be used, such as the rules of ESLint-plugin-react and react-hooks which are strongly dependent on the react project.

Therefore, some targeted customization is required.

Customize the browser version of eslint

In daily react projects, the team basically configures most of the rules based on the ESLint-config-airbnb rules, and then adapts some rules according to the team.

By reading the code of ESLint-config-airbnb, it does two parts of the work:

  1. Most of the rules that come with ESLint are configured
  2. ESLint plugins, ESLint-plugin-react, ESLint-plugin-react-hooks rules, are also configured.

And ESLint-plugin-react, ESLint-plugin-react-hooks, the core is to add some rules for react and hooks.

So actually the solution is as follows:

  1. Use the linter class exported by the packaged ESLint.js
  2. With the help of its defineRule method, the rules of react, react/hooks are added
  3. Merge the rules of airbnb as a config collection of various rules for backup
  4. Call the linter.verify method and cooperate with the airbnb rules generated by 3 to achieve complete ESLint verification.

Through the above method, a linter for daily use and a ruleConfig for react projects can be generated. Since this part is relatively independent, I put it in a separate github repository yuzai/ESLint-browser , which can be used for reference as appropriate, or modified and used according to the current status of the team.

Determine when to call

After solving the customization of eslint, the next step is the timing of the call. In every code change, frequent and synchronous execution of ESLint's verify may cause the ui to be stuck. Here, my solution is:

  1. Execute linter.verify via webworker
  2. Notify workers in model.onDidChangeContent to execute. And reduce the execution frequency by anti-shake
  3. Get the current id through model.getVersionId to avoid the problem of incorrect results due to too long delay

The code of the main process core is as follows:

 // 监听ESLint web worker 的返回
worker.onmessage = function (event) {
    const { markers, version } = event.data;
    const model = editor.getModel();
    // 判断当前model的versionId与请求时是否一致
    if (model && model.getVersionId() === version) {
        window.monaco.editor.setModelMarkers(model, 'ESLint', markers);
    }
};

let timer = null;
// model内容变更时通知ESLint worker
model.onDidChangeContent(() => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
        timer = null;
        worker.postMessage({
            code: model.getValue(),
            // 发起通知时携带versionId
            version: model.getVersionId(),
            path,
        });
    }, 500);
});

The core code in the worker is as follows:

 // 引入ESLint,内部结构如下:
/*
{
    esLinter, // 已经实例化,并且补充了react, react/hooks规则定义的实例
    // 合并了airbnb-config的规则配置
    config: {
        rules,
        parserOptions: {
            ecmaVersion: 'latest',
            sourceType: 'module',
            ecmaFeatures: {
                jsx: true
            }
        },
        env: {
            browser: true
        },
    }
}
*/
importScripts('path/to/bundled/ESLint/and/ESLint-airbnbconfig.js');

// 更详细的config, 参考ESLint linter源码中关于config的定义: https://github.com/ESLint/ESLint/blob/main/lib/linter/linter.js#L1441
const config = {
    ...self.linter.config,
    rules: {
        ...self.linter.config.rules,
        // 可以自定义覆盖原本的rules
    },
    settings: {},
}

// monaco的定义可以参考:https://microsoft.github.io/monaco-editor/api/enums/monaco.MarkerSeverity.html
const severityMap = {
    2: 8, // 2 for ESLint is error
    1: 4, // 1 for ESLint is warning
}

self.addEventListener('message', function (e) {
    const { code, version, path } = e.data;
    const extName = getExtName(path);
    // 对于非js, jsx代码,不做校验
    if (['js', 'jsx'].indexOf(extName) === -1) {
        self.postMessage({ markers: [], version });
        return;
    }
    const errs = self.linter.esLinter.verify(code, config);
    const markers = errs.map(err => ({
        code: {
            value: err.ruleId,
            target: ruleDefines.get(err.ruleId).meta.docs.url,
        },
        startLineNumber: err.line,
        endLineNumber: err.endLine,
        startColumn: err.column,
        endColumn: err.endColumn,
        message: err.message,
        // 设置错误的等级,此处ESLint与monaco的存在差异,做一层映射
        severity: severityMap[err.severity],
        source: 'ESLint',
    }));
    // 发回主进程
    self.postMessage({ markers, version });
});

The main process listens for text changes, debounces and passes it to the worker for linter, and carries the versionId as the returned comparison mark. After the linter verification, the markers are returned to the main process, and the main process sets the markers.

The above is the complete process of the entire ESLint.

Of course, due to time constraints, only js and jsx are currently processed, but ts and tsx files are not processed. To support ts, you need to call the defineParser of the linter to modify the parser of the syntax tree, which is relatively troublesome. I haven't tried it yet. If there is any follow-up news, it will be modified and synchronized in the github repository yuzai/ESLint-browser .

Prettier Support

Compared with ESLint, Prettier officially supports browsers. See this official page for its usage. It supports the usage of amd, commonjs, and es modules, which is very convenient.

The core of its usage is to call different parsers to parse different files. In my current scenario, the following parsers are used:

  1. babel: handles js
  2. html: handle html
  3. postcss: used to process css, less, scss
  4. typescript: handle ts

The difference can be found in the official documentation , which will not be repeated here. A very simple usage code is as follows:

 const text = Prettier.format(model.getValue(), {
    // 指定文件路径
    filepath: model.uri.path,
    // parser集合
    plugins: PrettierPlugins,
    // 更多的options见:https://Prettier.io/docs/en/options.html
    singleQuote: true,
    tabWidth: 4,
});

In the above configuration, there is one configuration to pay attention to: filepath.

This configuration is used to tell Prettier what kind of file is currently and what parser needs to be called for processing. In the current WebIDE scenario, you can pass the file path. Of course, you can also use the parser field to specify which parser to use after calculating the file suffix.

When combined with monaco-editor, you need to monitor the cmd + s shortcut to save, and then format the code.

Considering that monaco-editor itself also provides formatting instructions, which can be formatted by ⇧ + ⌥ + F.

Therefore, compared with cmd + s, when executing a custom function, it is better to directly overwrite the built-in formatting instructions, and directly execute the instructions when cmd + s to complete the elegance of formatting.

Override is mainly through the languages.registerDocumentFormattingEditProvider method, the specific usage is as follows:

 function provideDocumentFormattingEdits(model: any) {
    const p = window.require('Prettier');
    const text = p.Prettier.format(model.getValue(), {
        filepath: model.uri.path,
        plugins: p.PrettierPlugins,
        singleQuote: true,
        tabWidth: 4,
    });

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

monaco.languages.registerDocumentFormattingEditProvider('javascript', {
    provideDocumentFormattingEdits
});
monaco.languages.registerDocumentFormattingEditProvider('css', {
    provideDocumentFormattingEdits
});
monaco.languages.registerDocumentFormattingEditProvider('less', {
    provideDocumentFormattingEdits
});

The window.require in the above code is the amd method. Since this article adopts the amd method when choosing to introduce monaco-editor, here Prettier also adopts the amd method incidentally, and introduces it from the cdn to reduce the size of the package. The specific code is as follows:

 window.define('Prettier', [
        'https://unpkg.com/Prettier@2.5.1/standalone.js',
        'https://unpkg.com/Prettier@2.5.1/parser-babel.js',
        'https://unpkg.com/Prettier@2.5.1/parser-html.js',
        'https://unpkg.com/Prettier@2.5.1/parser-postcss.js',
        'https://unpkg.com/Prettier@2.5.1/parser-typescript.js'
    ], (Prettier: any, ...args: any[]) => {
    const PrettierPlugins = {
        babel: args[0],
        html: args[1],
        postcss: args[2],
        typescript: args[3],
    }
    return {
        Prettier,
        PrettierPlugins,
    }
});

After completing the introduction of Prettier and providing the formatted provider, at this time, execute ⇧ + ⌥ + F to realize formatting, and the last step is to execute the command when the user cmd + s, using the editor.getAction method is Yes, the pseudo code is as follows:

 // editor为create方法创建的editor实例
editor.getAction('editor.action.formatDocument').run()

At this point, the entire Prettier process has been completed, organized as follows:

  1. Introduced by amd
  2. monaco.languages.registerDocumentFormattingEditProvider Modify monaco's default formatting code method
  3. editor.getAction('editor.action.formatDocument').run() performs formatting

code completion

monaco-editor itself already has common code completions, such as window variables, dom, css properties, etc. However, code completion in node_modules is not provided, such as the most common react, without prompts, the experience will be much worse.

After research, monaco-editor can provide code hints with at least two APIs:

  1. registerCompletionItemProvider , you need to customize the trigger rules and content
  2. addExtraLib , by adding index.d.ts, provides automatic completion of the variables parsed by index.d.ts during automatic input.

There are many articles on the Internet for the first scheme, but for actual needs, import react and react-dom. If this scheme is adopted, you need to complete the analysis of index.d.ts by yourself, and output the type definition scheme at the same time. It is very cumbersome to use and is not conducive to post-maintenance.

The second scheme is relatively hidden and discovered by accident. After verification, stackbliz uses this scheme. But stackbliz only supports ts jumping and code completion.

After testing, you only need to use addExtraLib in javascriptDefaults and typescriptDefaults in ts to achieve code completion.

The experience and cost are far better than those of option 1.

The problem with solution 2 is the analysis of unknown third-party packages. At present, stackbliz only parses .d.ts for direct npm dependencies. Relevant dependencies are not followed up. In fact, it can be understood that without the secondary analysis of .d.ts, the secondary introduced dependencies will not be parsed. Therefore, the current version does not parse index.d.ts, and only provides code completion and jumps that are directly dependent. However, ts itself provides the ability of type analysis , and later access will be synchronized in github.

Therefore, in the final use plan 2, the built-in type definitions of react and react-dom are not used for package analysis of secondary dependencies. The relevant pseudocode is as follows:

 window.monaco.languages.typescript.javascriptDefaults.addExtraLib(
    'content of react/index.d.ts',
    'music:/node_modules/@types/react/index.d.ts'
);

At the same time, the definition of .d.ts added by addExtraLib will automatically create a model. With the coverage scheme of openCodeEditor described above, the requirement of cmd + click to open index.d.ts can be realized, and the experience is better.

Subject replacement

Because the parser used by monaco-editor is different from that of vscode, it is impossible to directly use the theme that comes with vscode. Of course, there are ways. For details, you can refer to the tutorial on how to use VSCode theme in Monaco Editor. You can use vscode theme directly. I also adopted the scheme of this article, which is already very detailed, so I will not repeat the work here.

Preview sandbox

In this part, because there is a sandbox solution based on codesandbox in the company, when it is actually implemented in the company, the WebIDE described in this article is only used as a solution for code editing and display. The actual preview is based on the sandbox rendering solution of codesandbox.

In addition, thanks to the browser's natural support for modules, I have also tried the solution of previewing the jsx and less files after processing the jsx and less files in the service worker directly with the help of the browser's modules support without packaging. This solution can be used directly in simple scenarios, but in actual scenarios, there are cases where special processing is required for the file of node_modules, and no further attempts have been made.

I haven't done more in-depth attempts in this part, so I won't go into details.

At last

This article introduces in detail the necessary links to build a lightweight WebIDE based on monaco-editor.

Overall, monaco-editor's own capabilities are relatively complete. With its basic api and appropriate ui code, a usable WebIDE can be built very quickly. But it's not that easy to do well.

Based on the introduction of the relevant APIs, this article gives a more detailed introduction to the detailed processing of multiple files, the ESLint browser solution, the fit between Prettier and monaco, and the support for code completion. I hope it can be helpful to students who have the same needs.

Finally, the source code is provided. If you think it is good, it would be better to give a like or a star.

Reference article

  1. Building a code editor with Monaco
  2. Teach you how to use VSCode theme in Monaco Editor
This article is published from the NetEase Cloud Music technical team, and any form of reprinting of the article is prohibited without authorization. We recruit various technical positions all year round. If you are ready to change jobs and happen to like cloud music, then join us at grp.music-fe(at)corp.netease.com!

云音乐技术团队
3.6k 声望3.5k 粉丝

网易云音乐技术团队