IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

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

    破晓L发表于 2022-11-24 12:17:44
    love 0


    本项目是 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 不附带选项卡,这里增加了选项卡功能,并实现了选项卡的创建和删除。

    当点击 「+」 按钮时,会弹出输入框和一个带有文件类型的下拉框,下拉框预设了两种文件类型 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)的相关项目(下图),后续会对这方面的知识会进行一个总结,期待关注。

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



沪ICP备19023445号-2号
友情链接