如何使用 React 和 Monaco Editor 实现 Web 版 VSCode?


本项目是 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


前端 1943
前端技术文章

智慧之子 总以智慧为是

2k 声望
3.6k 粉丝
0 条评论
推荐阅读
单文件组件下的vue,可以擦出怎样的火花
与时俱进吧,看着 vue3 和 vite,虽然不会用,但还是心痒痒,然后就把原先基于 vue@2 的实现做了重构。不周之处,大家见谅!下面关于过期的内容,我就用删除线标记了。

leftstick64阅读 45.1k评论 18

从零搭建 Node.js 企业级 Web 服务器(零):静态服务
过去 5 年,我前后在菜鸟网络和蚂蚁金服做开发工作,一方面支撑业务团队开发各类业务系统,另一方面在自己的技术团队做基础技术建设。期间借着 Node.js 的锋芒做了不少 Web 系统,有的至今生气蓬勃、有的早已夭折...

乌柏木141阅读 11.9k评论 10

从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望
总结截止到本章 “从零搭建 Node.js 企业级 Web 服务器” 主题共计 16 章内容就更新完毕了,回顾第零章曾写道:搭建一个 Node.js 企业级 Web 服务器并非难事,只是必须做好几个关键事项这几件必须做好的关键事项就...

乌柏木60阅读 6k评论 16

再也不学AJAX了!(二)使用AJAX ① XMLHttpRequest
「再也不学 AJAX 了」是一个以 AJAX 为主题的系列文章,希望读者通过阅读本系列文章,能够对 AJAX 技术有更加深入的认识和理解,从此能够再也不用专门学习 AJAX。本篇文章为该系列的第二篇,最近更新于 2023 年 1...

libinfs39阅读 6.2k评论 12

封面图
从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
分层规范从本章起,正式进入企业级 Web 服务器核心内容。通常,一块完整的业务逻辑是由视图层、控制层、服务层、模型层共同定义与实现的,如下图:从上至下,抽象层次逐渐加深。从下至上,业务细节逐渐清晰。视图...

乌柏木39阅读 7.1k评论 6

CSS 绘制一只思否猫
欢迎关注我的公众号:前端侦探练习 CSS 有一个比较有趣的方式,就是发挥想象,绘制各式各样的图案,比如来绘制一只思否猫?思否猫,SegmentFault 思否的吉祥物,是一只独一无二、特立独行、热爱自由的(&gt;^ω^&lt...

XboxYan42阅读 2.8k评论 14

封面图
还在用 JS 做节流吗?CSS 也可以防止按钮重复点击
举个例子:一个保存按钮,为了避免重复提交或者服务器考虑,往往需要对点击行为做一定的限制,比如只允许每300ms提交一次,这时候我想大部分同学都会到网上直接拷贝一段throttle函数,或者直接引用lodash工具库

XboxYan34阅读 2.2k评论 2

封面图

智慧之子 总以智慧为是

2k 声望
3.6k 粉丝
宣传栏