乐趣区

关于前端:如何使用-React-实现-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;

代码的执行与输入

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

退出移动版