3

打造轻量级 WebIDE,看这一篇文章就够啦

 2 years ago
source link: https://segmentfault.com/a/1190000041686221
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

打造轻量级 WebIDE,看这一篇文章就够啦

本文作者:芋仔

目前团队正在着手搭建低代码平台,该平台将支持 LowCode/ProCode 双模式在线开发,而 ProCode 场景便需要一个功能相对完备的运行在浏览器的 WebIDE。同时考虑到未来可能的一些区块代码平台的需求,将 WebIDE 模块单独抽离,以便应对后期更多的个性化需求。

得益于 monaco-editor 的强大,使用 monaco-editor 去搭建一个简单的 WebIDE 非常容易,但是要把多文件支持、ESLintPrettier、代码补全等功能加进去,并不是一件容易的事情。

本文意在分享在建设 WebIDE 中学到的一些经验及解决方案,希望能够帮助到有同样需求的同学。同时,这不是一篇手把手的文章,仅仅是介绍一些决策的思路及示例代码。完整的代码见 github,同时也搭建了一个 demo 可以体验(demo 依赖不少静态文件,部署在 github pages 上,访问速度过慢可能无法正常加载,可以clone后run dev查看,移动端也建议通过chrome打开),也提供了一个 npm 组件可以当作 react 组件直接使用。

相比于业内成熟的 @monaco-editor/react,本文提供的 WebIDE 把文件目录树,文件导航保存态等等直接聚合进组件内部,同时提供了 Eslint, Prettier 等基础能力 的支持,可以比较大程度的降低二次开发的成本。

关于 CloudIDE 和 WebIDE

正文开始之前,先谈一谈 CloudIDE 和 WebIDE。

之前在团队中基于 theia,搭建了一套 CloudIDE(其相关介绍见此文章)平台,其本质是将 IDE 的前端运行在浏览器 or 本地 electron,而文件系统,多语言服务等,运行在远端容器侧,中间通过 rpc 进行通信从而完成整个 IDE 的云端化。

而本文分享的 IDE,并不采用容器化的方案,而是基于 monaco-editor 将部分原本运行在远端容器服务的比如:多语言服务、Eslint 检查等通过 web worker 运行在浏览器中。相对容器化方案来讲,轻量级的 IDE 并不具备命令行终端的能力。

对于依赖容器化技术,能够提供完整终端能力的 IDE,在本文中,称之为 CloudIDE,而仅仅依赖浏览器能力的 IDE,本文称之为 WebIDE。本文想要分享的 IDE 属于后者。

引入monaco-editor

引入 monaco-editor 的方式主要是两种,amd 或者 esm。

两者接入方式都比较容易,我均有尝试。

相对来讲,起初更偏向于 esm 方式,但是由于 issue 问题,导致打包后,在当前项目中可以正常使用,但是当把它作为 npm 包发布后,他人使用时,打包会出错。

故最终采取第一种方式,通过动态插入 script 标签来引入 monaco-editor,项目中通过定时器轮询 window.monaco 是否存在来判断 monaco-editor 是否加载完成,如未完成,提供一个 loading 进行等待。

多文件支持

monaco-editor 的官方例子中,基本都是单文件的处理,不过多文件处理也非常简单,本文在此处仅做简单的介绍。

多文件处理主要涉及到的就是 monaco.editor.create 以及 monaco.editor.createModel 两个api。

其中,createModel 就是多文件处理的核心 api。根据文件路径创建不同的 model,在需要切换时,通过调用 editor.setModel 即可实现多文件的切换

创建多文件并切换的一般的伪代码如下:

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');

通过再编写一定的 ui 代码,可以非常轻易的实现多文件的切换。

保留切换之前状态

通过上述方法,可以实现多文件切换,但是在文件切换前后,会发现鼠标滚动的位置,文字的选中态均发生丢失的问题。

此时可以通过创建一个 map 来存储不同文件在切换前的状态,核心代码如下:

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;
}

核心便是借助editor实例的 saveViewState 方法实现编辑器状态的存储,通过 restoreViewState 方法进行恢复。

monaco-editor 作为一款优秀的编辑器,其本身是能够感知到其他model的存在,并进行相关代码补全的提示。虽然 hover 上去能看到相关信息,但是我们最常用的 cmd + 点击,默认是不能够跳转的。

这一条也算是比较常见的问题了,详细的原因及解决方案可以查看此 issue

简单来说,库本身没有实现这个打开,是因为如果允许跳转,那么用户没有很明显的方法可以再跳转回去。

实际中,可以通过覆盖 openCodeEditor 的方式来解决,在没有找到跳转结果的情况下,自己实现 model 切换

    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
    };

在实际编写 react 组件中,往往还需要对文件内容进行受控的操作,这就需要编辑器在内容变化时通知外界,同时也允许外界直接修改文本内容。

先说内容变化的监听,monaco-editor 的每个 model 都提供了 onDidChangeContent 这样的方法来监听文件改变,可以继续改造我们的 openFile 函数。

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,
            })
        }
    })
}

解决了内部改动对外界的通知,外界想要直接修改文件的值,可以直接通过 model.setValue 进行修改,但是这样直接操作,就会丢失编辑器 undo 的堆栈,想要保留 undo,可以通过 model.pushEditOperations 来实现替换,具体代码如下:

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
                }
            ],
            () => {},
        )
    }
}

通过上述的 monaco-editor 提供的 api,基本就可以完成整个多文件的支持。

当然,具体到实现还有挺多的工作,文件树列表,顶部 tab,未保存态,文件的导航等等。不过这部分属于我们大部分前端的日常工作,工作量虽然不小但是实现起来并不复杂,此处不再赘述。

ESLint支持

monaco-editor 本身是有语法分析的,但是自带的仅仅只有语法错误的检查,并没有代码风格的检查,当然,也不应该有代码风格的检查。

作为一名现代的前端开发程序员,基本上每个项目都会有 ESLint 的配置,虽然 WebIDE 是一个精简版的,但是 ESLint 还是必不可少。

ESLint 的原理,是遍历语法树然后检验,其核心的 Linter,是不依赖 node 环境的,并且官方也进行了单独的打包输出,具体可以通过 clone官方代码 后,执行 npm run webpack 拿到核心的打包后的 ESLint.js。其本质是对 linter.js 文件的打包。

同时官方也基于该打包产物,提供了 ESLint 的官方 demo

该 linter 的使用方法如下:

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: {},
})

如果只使用上述 linter 提供的方法,存在几个问题:

  1. 规则太多,一一编写太累且不一定符合团队规范
  2. 一些插件的规则无法使用,比如 react 项目强依赖的 ESLint-plugin-react, react-hooks的规则。

故还需要进行一些针对性的定制。

定制浏览器版的eslint

在日常的 react 项目中,基本上团队都是基于 ESLint-config-airbnb 规则配置好大部分的 rules,然后再对部分规则根据团队进行适配。

通过阅读 ESLint-config-airbnb 的代码,其做了两部分的工作:

  1. 对 ESLint 的自带的大部分规则进行了配置
  2. 对 ESLint 的插件,ESLint-plugin-react, ESLint-plugin-react-hooks 的规则,也进行了配置。

而 ESLint-plugin-react, ESLint-plugin-react-hooks,核心是新增了一些针对 react 及 hooks 的规则。

那么其实解决方案如下:

  1. 使用打包后的 ESLint.js 导出的 linter 类
  2. 借助其 defineRule 的方法,对 react, react/hooks 的规则进行增加
  3. 合并 airbnb 的规则,作为各种规则的 config 合集备用
  4. 调用 linter.verify 方法,配合3生成的 airbnb 规则,即可实现完整的 ESLint 验证。

通过上述方法,可以生成一个满足日常使用的 linter 及满足 react 项目使用的 ruleConfig。这一部分由于相对独立,我将其单独放在了一个 github 仓库 yuzai/ESLint-browser,可以酌情参考使用,也可根据团队现状修改使用。

确定调用时机

解决了 eslint 的定制,下一步就是调用的时机,在每次代码变更时,频繁同步执行ESLint的verify可能会带来ui的卡顿,在此,我采取方案是:

  1. 通过 webworker 执行 linter.verify
  2. model.onDidChangeContent 中通知 worker 进行执行。并通过防抖来减少执行频率
  3. 通过 model.getVersionId,拿到当前 id,来避免延迟过久导致结果对不上的问题

主进程核心的代码如下:

// 监听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);
});

worker 内核心代码如下:

// 引入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 });
});

主进程监听文本变化,消抖后传递给 worker 进行 linter,同时携带 versionId 作为返回的比对标记,linter 验证后将 markers 返回给主进程,主进程设置 markers。

以上,便是整个 ESLint 的完整流程。

当然,由于时间关系,目前只处理了 js,jsx,并未对ts,tsx文件进行处理。支持 ts 需要调用 linter 的 defineParser 修改语法树的解析器,相对来讲稍微麻烦,目前还未做尝试,后续有动静会在 github 仓库 yuzai/ESLint-browser 进行修改同步。

Prettier支持

相比于 ESLint, Prettier 官方支持浏览器,其用法见此官方页面, 支持 amd, commonjs, es modules 的用法,非常方便。

其使用方法的核心就是调用不同的 parser,去解析不同的文件,在我当前的场景下,使用到了以下几个 parser:

  1. babel: 处理 js
  2. html: 处理 html
  3. postcss: 用来处理 css, less, scss
  4. typescript: 处理 ts

其区别可以参考官方文档,此处不赘述。一个非常简单的使用代码如下:

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,
});

在上述配置中,有一个配置需要注意:filepath。

该配置是用来来告知 Prettier 当前是哪种文件,需要调用什么解析器进行处理。在当前WebIDE场景下,将文件路径传递即可,当然,也可以自行根据文件后缀计算后使用 parser 字段指定用哪个解析器。

在和 monaco-editor 结合时,需要监听 cmd + s 快捷键来实现保存时,便进行格式化代码。

考虑到 monaco-editor 本身也提供了格式化的指令,可以通过⇧ + ⌥ + F进行格式化。

故相比于 cmd + s 时,执行自定义的函数,不如直接覆盖掉自带的格式化指令,在 cmd + s 时直接执行指令来完成格式化来的优雅。

覆盖主要通过 languages.registerDocumentFormattingEditProvider 方法,具体用法如下:

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
});

上述代码中 window.require,是 amd 的方式,由于本文在选择引入 monaco-editor 时,采用了 amd 的方式,所以此处 Prettier 也顺带采用了 amd 的方式,并从 cdn 引入来减少包的体积,具体代码如下:

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

在完成 Prettier 的引入,提供格式化的 provider 之后,此时,执行⇧ + ⌥ + F即可实现格式化,最后一步便是在用户 cmd + s 时执行该指令即可,使用 editor.getAction 方法即可,伪代码如下:

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

至此,整个 Prettier 的流程便已完成,整理如下:

  1. amd 方式引入
  2. monaco.languages.registerDocumentFormattingEditProvider 修改 monaco 默认的格式化代码方法
  3. editor.getAction('editor.action.formatDocument').run() 执行格式化

monaco-editor 本身已经具备了常见的代码补全,比如 window 变量,dom,css 属性等。但是并未提供 node_modules 中的代码补全,比如最常见的 react,没有提示,体验会差很多。

经过调研,monaco-editor 可以提供代码提示的入口至少有两个 api:

  1. registerCompletionItemProvider,需要自定义触发规则及内容
  2. addExtraLib,通过添加 index.d.ts,使得在自动输入的时候,提供由 index.d.ts 解析出来的变量进行自动补全。

第一种方案网上的文章较多,但是对于实际的需求,导入 react, react-dom,如果采用此种方案,就需要自行完成对 index.d.ts 的解析,同时输出类型定义方案,在实际使用时非常繁琐,不利于后期维护。

第二种方案比较隐蔽,也是偶然发现的,经过验证,stackbliz 就是用的这种方案。但是 stackbliz 只支持 ts 的跳转及代码补全。

经过测试,只需要同时在 ts 中的 javascriptDefaults 及 typescriptDefaults 中使用 addExtraLib 即可实现代码补全。

体验及成本远远优于方案一。

方案二的问题在于未知第三方包的解析,目前看,stackbliz 也仅仅只是对直系 npm 依赖进行了 .d.ts 的解析。相关依赖并无后续进行。实际也能理解,在不经过二次解析 .d.ts 的情况下,是不会对二次引入的依赖进行解析。故当前版本也不做 index.d.ts 的解析,仅提供直接依赖的代码补全及跳转。不过 ts 本身提供了 types分析 的能力,后期接入会在 github 中同步。

故最终使用方案二,内置 react, react-dom 的类型定义,暂不做二次依赖的包解析。相关伪代码如下:

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

同时,通过 addExtraLib 增加的 .d.ts 的定义,本身也会自动创建一个 model,借助前文所描述的 openCodeEditor 的覆盖方案,可以顺带实现 cmd + click 打开 index.d.ts 的需求,体验更佳。

此处由于 monaco-editor 同 vscode 使用的解析器不同,导致无法直接使用 vscode 自带的主题,当然也有办法,具体可以参考手把手教你实现在 Monaco Editor 中使用 VSCode 主题文章,可以直接使用 vscode 主题,我采取的也是这篇文章的方案,本身已经很详细了,我就不在这里做重复劳动了。

这一部分由于公司内有基于 codesandbox 的沙箱方案,故实际在公司内部落地时,本文所述的 WebIDE 仅仅作为一个代码编辑与展示的方案,实际预览时走的是基于 codesandbox 的沙箱渲染方案。

除此之外,得益于浏览器对 modules 的天然支持,也尝试过不打包,直接借助浏览器的 modules 支持,通过 service worker 中对 jsx, less文件处理后做预览的方案。该方案应对简单场景可以直接使用,但是实际场景中,存在对 node_modules 的文件需要特殊处理的情况,没有做更深入的尝试。

这一部分我也没有做更多深入尝试,故不做赘述。

本文详细介绍了基于 monaco-editor 打造一款轻量级的 WebIDE 的必备环节。

整体来讲,monaco-editor 本身的能力比较完善,借助其基础 api 再加上适当的 ui 代码,可以非常快速的构建出一款可用的 WebIDE。但是要做好,并不是那么容易。

本文在介绍了相关 api 的基础上,对多文件的细节处理、ESLint 浏览器化方案、Prettier 与 monaco 的贴合及代码补全的支持上做了更为详细的介绍。希望能够对有同样需求的同学起到帮助的作用。

最后,源代码奉上,觉得不错的话,帮忙点个赞 or 小星星就更好啦。

本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK