本文作者:芋仔
目前团队正在着手搭建低代码平台,该平台将反对 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 提供的办法,存在几个问题:
- 规定太多,一一编写太累且不肯定合乎团队标准
- 一些插件的规定无奈应用,比方 react 我的项目强依赖的 ESLint-plugin-react, react-hooks的规定。
故还须要进行一些针对性的定制。
定制浏览器版的eslint
在日常的 react 我的项目中,基本上团队都是基于 ESLint-config-airbnb 规定配置好大部分的 rules,而后再对局部规定依据团队进行适配。
通过浏览 ESLint-config-airbnb 的代码,其做了两局部的工作:
- 对 ESLint 的自带的大部分规定进行了配置
- 对 ESLint 的插件,ESLint-plugin-react, ESLint-plugin-react-hooks 的规定,也进行了配置。
而 ESLint-plugin-react, ESLint-plugin-react-hooks,外围是新增了一些针对 react 及 hooks 的规定。
那么其实解决方案如下:
- 应用打包后的 ESLint.js 导出的 linter 类
- 借助其 defineRule 的办法,对 react, react/hooks 的规定进行减少
- 合并 airbnb 的规定,作为各种规定的 config 合集备用
- 调用 linter.verify 办法,配合3生成的 airbnb 规定,即可实现残缺的 ESLint 验证。
通过上述办法,能够生成一个满足日常应用的 linter 及满足 react 我的项目应用的 ruleConfig。这一部分因为绝对独立,我将其独自放在了一个 github 仓库 yuzai/ESLint-browser,能够酌情参考应用,也可依据团队现状批改应用。
确定调用机会
解决了 eslint 的定制,下一步就是调用的机会,在每次代码变更时,频繁同步执行ESLint的verify可能会带来ui的卡顿,在此,我采取计划是:
- 通过 webworker 执行 linter.verify
- 在 model.onDidChangeContent 中告诉 worker 进行执行。并通过防抖来缩小执行频率
- 通过 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:
- babel: 解决 js
- html: 解决 html
- postcss: 用来解决 css, less, scss
- 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 的流程便已实现,整顿如下:
- amd 形式引入
- monaco.languages.registerDocumentFormattingEditProvider 批改 monaco 默认的格式化代码办法
- editor.getAction('editor.action.formatDocument').run() 执行格式化
代码补全
monaco-editor 自身曾经具备了常见的代码补全,比方 window 变量,dom,css 属性等。然而并未提供 node_modules 中的代码补全,比方最常见的 react,没有提醒,体验会差很多。
通过调研,monaco-editor 能够提供代码提醒的入口至多有两个 api:
- registerCompletionItemProvider,须要自定义触发规定及内容
- 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 小星星就更好啦。
参考文章
- Building a code editor with Monaco
- 手把手教你实现在 Monaco Editor 中应用 VSCode 主题
本文公布自网易云音乐技术团队,文章未经受权禁止任何模式的转载。咱们长年招收各类技术岗位,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!