本文作者:芋仔

目前团队正在着手搭建低代码平台,该平台将反对 LowCode/ProCode 双模式在线开发,而 ProCode 场景便须要一个性能绝对齐备的运行在浏览器的 WebIDE。同时思考到将来可能的一些区块代码平台的需要,将 WebIDE 模块独自抽离,以便应答前期更多的个性化需要。

得益于 monaco-editor 的弱小,应用 monaco-editor 去搭建一个简略的 WebIDE 非常容易,然而要把多文件反对、ESLint、Prettier、代码补全等性能加进去,并不是一件容易的事件。

本文意在分享在建设 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 workermodel.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#L1441const config = {    ...self.linter.config,    rules: {        ...self.linter.config.rules,        // 能够自定义笼罩本来的rules    },    settings: {},}// monaco的定义能够参考:https://microsoft.github.io/monaco-editor/api/enums/monaco.MarkerSeverity.htmlconst 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/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,    }});

在实现 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 小星星就更好啦。

参考文章

  1. Building a code editor with Monaco
  2. 手把手教你实现在 Monaco Editor 中应用 VSCode 主题
本文公布自网易云音乐技术团队,文章未经受权禁止任何模式的转载。咱们长年招收各类技术岗位,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!