本文作者:芋仔
目前團隊正在著手搭建低程式碼平臺,該平臺將支援 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 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:
- 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 小星星就更好啦。
參考文章
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!