打造輕量級 WebIDE,看這一篇文章就夠啦

雲音樂技術團隊發表於2022-04-13
本文作者:芋仔

目前團隊正在著手搭建低程式碼平臺,該平臺將支援 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/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!

相關文章