本專案是 React 基於 Monaco Editor 實現的 Web VSCode Demo,它的主要功能是允許在瀏覽器中編寫 TypeScript/JavaScript 並直接執行,除此之外,它還包含如下功能:
- 支援部分語言服務,例如 TS 型別檢查、程式碼補全、程式碼錯誤檢查、程式碼格式化等;
- 編輯器支援 ES6 模組語法 import/export;
- 多個 Tab 項,可以新增和刪除;
- Tab 頁拖拽排序;
- 控制檯輸出與顯示;
- 編輯歷史回退等。
接下來讓我們一起來了解下它是如何工作的吧。
使用 Monaco Editor
Monaco Editor 是一個 Web 編譯器,由 Erich Gamma 帶領的團隊所開發,關於 Monaco Editor 可以追溯到 2011 年,最早的 Monaco 是被廣泛用於微軟內部及外部一些 Web 產品的編輯器控制元件,為人所熟知的是早期的 Visual Studio Online 。VS Online 是 2013 年就已經上線運營的產品,介面與較老版本的 VS Code 非常類似,可以說 VS Code 是將 VS Online 搬到了桌面端,而新的 Github Codespaces 又將其搬到了 Web 端。
在 React 專案中使用 Monaco Editor,有兩個比較成熟的元件庫 react-monaco-editor 和 @monaco-editor/react 可供選擇。
這裡推薦使用 @monaco-editor/react
,因為它無需額外的 webpack(rollup/parcel/etc)配置或外掛。
# yarn install
yarn add @monaco-editor/react
import React from 'react';
import Editor, { monaco } from '@monaco-editor/react';
function MonacoEditor() {
return (
<Editor /**props**/ />
)
}
export default MonacoEditor;
程式碼的執行與輸出
Monaco Editor 是一個文字編輯器(支援語法高亮、自動完成、懸停提示等)不具有程式碼執行的功能,我們可以透過 Function 函式模擬程式碼執行的效果。
let userCode = 'console.log("hello world")'
try {
Function(userCode)()
} catch(e) {
console.log(e)
}
直接呼叫 Function([functionBody])
可以動態建立函式,返回的是為 functionBody
建立的匿名函式。
執行 TypeScript
TypeScript 是不能直接在瀏覽器中執行的,它需要編譯器將其編譯為 JavaScirpt 後執行。所幸 Monaco Editor 提供了一個 API ,它可以將 TypeScript 程式碼編譯為 JavaScript,透過獲取編譯後的程式碼可以達到執行的目的。
const tsClient = await monaco.languages.typescript
.getTypeScriptWorker()
.then(worker => worker(runnerModel.uri));
這將編譯當前 model
中的程式碼(在 VSCode 中,一個模型基本上就是一個檔案),然後獲取返回的 JavaScript 並執行。
注:每個編輯器的程式碼內容等資訊都儲存在 ITextModel 中,model 儲存了文件內容、文件語言、文件路徑等一系列資訊,當 editor 關閉後 model 仍保留在記憶體中。
const tsClient = await monaco.languages.typescript
.getTypeScriptWorker()
.then(worker => worker(runnerModel.uri));
const emittedJS = (
await tsClient.getEmitOutput(runnerModel.uri.toString())
)
try {
Function(emittedJS)();
} catch (e) {
...
}
控制檯顯示
到這裡,我們可以將編輯器中的 TypeScirpt 或 JavaScript 程式碼進行線上執行,現在需要將執行後的結果進行顯示,我們需要實現控制檯元件,用來顯示輸出的結果。
專案中使用的是React 元件 console-feed ,它可以顯示來自當前頁面、iframe 或跨伺服器傳輸的控制檯日誌。
import React, { useState, useEffect } from 'react'
import { Console, Hook, Unhook } from 'console-feed'
const LogsContainer = () => {
const [logs, setLogs] = useState([])
// run once!
useEffect(() => {
Hook(
window.console,
(log) => setLogs((currLogs) => [...currLogs, log]),
false
)
return () => Unhook(window.console)
}, [])
return <Console logs={logs} variant="dark" />
}
export { LogsContainer }
支援多個控制檯
我們希望每頁有多個編輯器,預設情況下,它們的控制檯都會列印相同的訊息,因為我們是從同一個控制檯讀取日誌。我們如何透過傳送訊息的編輯器隔離控制檯訊息呢?
我們讓每個編輯器輸出唯一的編輯器 ID 作為覆蓋訊息源的最後一個引數,以區分 console.log
訊息來源。
let consoleOverride = `let console = (function (oldCons) {
return {
...oldCons,
log: function (...args) {
args.push("${editorId}");
oldCons.log.apply(oldCons, args);
},
warn: function (...args) {
args.push("${editorId}");
oldCons.warn.apply(oldCons, args);
},
error: function (...args) {
args.push("${editorId}");
oldCons.error.apply(oldCons, args);
},
};
})(window.console);`;
try {
Function(consoleOverride + emittedJS)();
} catch (e) {
...
支援多個檔案
選項卡
Monaco Editor 不附帶選項卡,這裡增加了選項卡功能,並實現了選項卡的建立和刪除。
當點選 「+」 按鈕時,會彈出輸入框和一個帶有檔案型別的下拉框,下拉框預設了兩種檔案型別 ts
和 js
,我們可以選擇什麼編輯什麼型別的檔案。
export default function NewFileButton({ plusModel }: newFileButtonProps) {
return (
<div>
<IconButton size="small" onClick={() => setOpenMenu(true)}>
<AddIcon style={{ color: '#787777' }}></AddIcon>
</IconButton>
{openMenu && (
<div className={classes.dropdownContent}>
<input
...
onKeyDown={e => {
if (e.key === 'Enter') {
createModelOnEnter();
setOpenMenu(false);
}
}}
></input>
<option value="typescript">.ts</option>
<option value="javascript">.js</option>
</select>
</div>
)}
</div>
)
}
當按回車時,會呼叫 addNewModel
函式, 此時頁面會新增一個 Tab 頁,每個 Tab 頁會對應一個新的 model
。
export default function TopBar({ editorId, modelsInfo }: TopBarProps) {
...
const [models, setModels] = useModels();
const plusModel = (
filename: string,
language: 'javascript' | 'typescript' | 'json'
) => addNewModel(setModels);
return (
<div className={classes.bar}>
{models &&
models
.filter(model => !model.shown)
.map((model, index) => (
<Tab
key={index}
model={model}
index={index}
dragTabMove={dragTabMove}
deleteTab={deleteTab}
/>
))}
<NewFileButton plusModel={plusModel} />
</div>
);
}
拖拽排序
對選項卡進行拖拽佈局使用了 react-dnd,效果就像 VSCode 中的一樣。
react-dnd 是一組 React 高階元件,使用的時候只需要使用對應的 API 將目標元件進行包裹,即可實現拖動或接受拖動元素的功能。
專案中使用了 useDrag
和 useDrop
兩個 Hook 組合的方式達到拖拽排序的目的。
// 以下只展核心程式碼
import { useDrag, useDrop } from 'react-dnd';
export default function Tab({
model,
index,
dragTabMove,
deleteTab,
}: TabProps) {
// useDrag 提供了一種將元件作為拖動源連線到 DnD 系統的方法。
const [{ isDragging }, drag] = useDrag({
item: { type: 'moveIdx', index },
collect: monitor => ({
isDragging: !!monitor.isDragging(),
}),
});
// useDrop 提供了一種將元件作為放置目標連線到 DnD 系統的方法。
const [{ isOver }, drop] = useDrop({
accept: 'moveIdx',
drop: (item: DragTabItem) => {
dragTabMove(item.index, index);
},
collect: monitor => ({
isOver: !!monitor.isOver(),
}),
});
return (
<span ref={drag}>
<span ref={drop}>
<LanguageIcon language={model.language} />
<span>{model.model.uri.path.substring(1)}</span>
<span onClick={() => deleteTab(index)}>x</span>
</span>
</span>
);
}
拖拽的同時也會更新對應 model
的選中狀態。
function dragTabMove(draggedIdx: number, draggedToIdx: number) {
if (models) {
let newModels = [...models];
//drag left
if (draggedIdx > draggedToIdx) {
newModels.splice(draggedToIdx, 0, models[draggedIdx]);
newModels.splice(draggedIdx + 1, 1);
} else {
//drag right
newModels.splice(draggedToIdx + 1, 0, models[draggedIdx]);
newModels.splice(draggedIdx, 1);
}
setModels(newModels);
setSelectedIdx(draggedToIdx);
}
}
支援 ES6 模組
編輯器還支援 ES6 模組語法,可以使用 import/export
匯入/匯出模組。
首先我們獲取所有 Tab 頁對應的 model
,從所選模型開始進行深度優先遍歷(DFS),使用正規表示式將各個 model
的關聯關係生成依賴關係圖。
export default function getModelsInOrder(currentModel, monaco) {
const allModels = monaco.editor.getModels();
// 從所選模型開始,執行 DFS(深度優先遍歷)分析匯入語句
const graph = allModels.map((model) => {
let importRegex = /(from|import)\s+["']([^"']*)["']/gm;
let importIndices = (model.getValue().match(importRegex) ?? []) //Get import strings
.map((s) => s.match(/["']([^"']*)["']/)![1]) //find name
.map((s) =>
allModels.findIndex(
(findImportModel) =>
s === findImportModel.uri.path.substring(1).replace(/\.[^.]*$/, "") // 將格式化的匯入與格式化的檔名進行比較
)
)
.filter((index) => index !== -1);
return importIndices;
});
然後將生成的依賴關係再進行拓撲排序(這裡使用了 LeetCode 中經過了良好測試的程式碼),將檔案堆疊在一起。
// https://leetcode.com/problems/course-schedule-ii/discuss/146326/JavaScript-DFS
const TopoSort = function (ranFile: number, deps: number[][]) {
const res: number[] = [];
const seeing = new Set<number>();
const seen = new Set<number>();
if (!dfs(ranFile)) {
return [];
}
return res;
function dfs(v: number) {
if (seen.has(v)) {
return true;
}
if (seeing.has(v)) {
return false;
}
seeing.add(v);
for (let nv of deps[v]) {
if (!dfs(nv)) {
return false;
}
}
seeing.delete(v);
seen.add(v);
res.push(v);
return true;
}
};
export default TopoSort;
Monaco 的 model
在同一個視窗中共享,因此可以匯入來自同一頁面不同編輯器中的程式碼。
粗暴的做事方式並不總是有效的,如果開啟名為「0.ts」的檔案,它將顯示生成後的程式碼,以便您診斷問題(在這裡,我們會遇到被重複的宣告的錯誤提示)。
自定義檔案
我為檔案提供了一些不同的選項,您可以自定義以確定最初選擇的選項卡、檔案是否應為只讀、檔案是否應該顯示等等。
export type modelInfoType = {
notInitial?: boolean;
shown?: boolean;
readOnly?: boolean;
tested?: boolean;
filename: string;
value: string;
language: "typescript" | "javascript" | "json";
};
快速編寫互動式內容
要為編輯器建立初始狀態,您可以建立一個空編輯器,建立一個新檔案,然後點選右上方的 <> 按鈕,這將會把 modelsInfo
的配置複製到剪貼簿。
import React from "react";
import Editor from "react-run-code";
function App() {
return <Editor id="10" modelsInfo={[]} />;
}
export default App;
現在,您可以貼上 [{“value”:“console.log(\”make a new file\“)”,“filename”:“new.ts”,“language”:“typescript”}]
來替換原始碼中 modelsInfo={[]}
的 []
。(如上圖)
最後
最近在做一個關於瀏覽器支援 C/C++ 的語言服務(遵循 LSP)的相關專案(下圖),後續會對這方面的知識會進行一個總結,期待關注。