如何使用 React 和 Monaco Editor 實現 Web 版 VSCode?

破曉L發表於2022-11-24


本專案是 React 基於 Monaco Editor 實現的 Web VSCode Demo,它的主要功能是允許在瀏覽器中編寫 TypeScript/JavaScript 並直接執行,除此之外,它還包含如下功能:

  1. 支援部分語言服務,例如 TS 型別檢查、程式碼補全、程式碼錯誤檢查、程式碼格式化等;
  2. 編輯器支援 ES6 模組語法 import/export
  3. 多個 Tab 項,可以新增和刪除;
  4. Tab 頁拖拽排序;
  5. 控制檯輸出與顯示;
  6. 編輯歷史回退等。

接下來讓我們一起來了解下它是如何工作的吧。

使用 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;

image.png

程式碼的執行與輸出

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 不附帶選項卡,這裡增加了選項卡功能,並實現了選項卡的建立和刪除。

當點選 「+」 按鈕時,會彈出輸入框和一個帶有檔案型別的下拉框,下拉框預設了兩種檔案型別 tsjs ,我們可以選擇什麼編輯什麼型別的檔案。

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 將目標元件進行包裹,即可實現拖動或接受拖動元素的功能。

專案中使用了 useDraguseDrop 兩個 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)的相關專案(下圖),後續會對這方面的知識會進行一個總結,期待關注。

原文參考:How To Embed VSCode Into A Browser With React

相關文章