乐趣区

关于前端:打造轻量级-WebIDE看这一篇文章就够啦

本文作者:芋仔

目前团队正在着手搭建低代码平台,该平台将反对 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 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/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!

退出移动版