实现 React 的 Playground(2)

302 阅读7分钟

# 实现 React 的 Playground(1)

说了半天,playGround的原理其实就是,你动态在body标签里面加入一个script标签,当前script标签内容执行完成以后,浏览器会自动创建一个新的task,来执行这个新的script标签里面的内容。在这个基础上,我们就可以实现一个左边编辑器,右边预览器的面板出来。编辑器和预览器的上面有一个context,来接受当前用户输入的代码,这个代码是以字符串的形式存放在context里面的。预览器拿到新代码以后,对他们进行编译,编译完以后把他塞入iframe里面。这样页面上相当于出现了一个新窗口,用来展示我们的成果。

在这里面的难点有3个:

1.在页面编辑器里面敲得代码,是以字符串的形式存放在浏览器内存中的,我们拿到这个字符串以后,会动态的塞到iframescript标签里面,来实现了页面展示。

2.playGround的编辑区是支持多文件的,但是他们的本质就是一个个字符串,如何在iframescript标签里面引用这些字符串,主要利用的就是blob转化,然后获取她的内存地址。

3.playGround的编辑区里面的文件是不是也需要导入react,使用的是 esm.sh 而不是用CDN导入,<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>。用CDN导入以后,编译就是个大问题,为了快速处理我们只能选择 esm.sh

4.我们从页面上拿到的html文件也是个字符串,但是iframe标签要的是url,这和2一样,把这个html字符串用blob转化成html二进制,然后用window.URL.createObjectURL()获取内存地址。

上节已经初步把整个原理过了一遍,现在我们来布局页面,看看样例

image.png

1.自己写个左右拖动的布局

playground的左右是可以移动的,我们就做这个布局,大概如下:

import { useRef, useState } from 'react';
import './index.scss';

export default function ReactPlayground() {
  const editorRef = useRef<HTMLDivElement>(null);
  const readerRef = useRef<HTMLDivElement>(null);
  const [startSite, setStartSite] = useState(0);


  const onDragStart = (event: any) => {
    setStartSite(event.pageX);
  };
  const onDragEnd = (event: any) => {
    const editorDiv = editorRef.current;
    const readerDiv = readerRef.current;
    const endSite = event.pageX;


    if (editorDiv && readerDiv) {
      const editorWidth = editorDiv.offsetWidth;
      const readerWidth = readerDiv.offsetWidth;

      editorDiv.style.width = `${editorWidth - (startSite - endSite)}px`;
      readerDiv.style.width = `${readerWidth + (startSite - endSite)}px`;
    }
  };

  return <div className="content">
    <div className="editor" ref={editorRef}>编辑区</div>
    <div className="moveBox"
      draggable
      onDragStart={onDragStart}
      onDragEnd={onDragEnd}></div>
    <div className="reader" ref={readerRef}>预览区</div>
  </div >;
}

样式:

.content {
  display: flex;

  .editor,
  .reader {
    border: 1px solid #ccc;
    height: 500px;
    width: 50%;
  }

  .moveBox {
    width: 6px;
  }
}

效果:

image.png

2.利用工具组件做左右拖动

因为我们自己写的左右拖动很粗糙,细节还得自己打磨,咱们就用一个组件: allotment他是一个专门做左右拖动的组件,而且用起来也比较方便。

import { Allotment } from "allotment";
import 'allotment/dist/style.css';

export default function ReactPlayground() {
  return <div style={{ height: '100vh' }}>
    <Allotment defaultSizes={[100, 100]}>
      <Allotment.Pane minSize={500}>
        <div>
          111
        </div>
      </Allotment.Pane>
      <Allotment.Pane minSize={0}>
        <div>
          222
        </div>
      </Allotment.Pane>
    </Allotment>
  </div>;
}

整个页面由三个部分组成,1.Header,2.编辑区,3.预览区

2.1 Header

image.png

实现结果

image.png

2.2 编辑区

playground是多文件的,文件之间可以用import相互导入导出的,所有编辑区由两部分组成,一个是fileList还有一个是编辑面板。

image.png

image.png

引入编辑器@monaco-editor/react

如果你想要你的编辑器指出ts,那就给editor配置一下:

import React from 'react';
import MonacoEditor, { OnMount } from '@monaco-editor/react';
import './index.scss';

export default function Editor() {
  const code = `export default function App() {
    return <div>hello play ground</div>
  }`;

  const handleEditorMount: OnMount = (editor, monaco) => {
    monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
      jsx: monaco.languages.typescript.JsxEmit.Preserve,
      esModuleInterop: true,
    });
  };

  return <div className='editor-content'>
    <MonacoEditor
      height='100%'
      path={'guang.tsx'}
      language={"typescript"}
      onMount={handleEditorMount}
      value={code}
    />
  </div>;

}

主要就是这个:

image.png

在编辑器加载的时候就去配置ts,ts 默认的编辑器的默认 compilerOptions。

在commonjs里面没有默认的导出,如果我们要在文件里面使用node的fs就必须用import * as fs from 'fs'

但是现在配置了compilerOptions以后,咱们可以直接 import fs from 'fs'

配置编辑器样式不要滚动条

@monaco-editor/react这个编辑器的好处就你可以通过配置不但可以编译jsx,还能编译ts,同时也可以配置快捷键。同时你可以在option里面配置他们的显示样式

<MonacoEditor
      height={'100%'}
      path={'guang.tsx'}
      language={"typescript"}
      onMount={handleEditorMount}
      value={code}
      options={
        {
          fontSize: 14,
          scrollBeyondLastLine: false,
          minimap: {
            enabled: false,
          },
          scrollbar: {
            verticalScrollbarSize: 6,
            horizontalScrollbarSize: 6,
          },
        }
      }

配置ts的类型检测

现在我们的编辑器值支持ts,当然再写代码的时候,也应该由type提示,我们可以把@typescript/ata包给他配到编辑器里面去。

npm install --save @typescript/ata -f

image.png

image.png 上面这个说明他已经支持了

完整代码 editor.tsx

import React from 'react';
import MonacoEditor, { OnMount } from '@monaco-editor/react';
import { createATA } from './ata';
import './index.scss';

export default function Editor() {
  const code = `export default function App() {
    return <div>hello play ground</div>
  }`;

  const handleEditorMount: OnMount = (editor, monaco) => {
    monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
      jsx: monaco.languages.typescript.JsxEmit.Preserve,
      esModuleInterop: true,
    });

    const ata = createATA((code, path) => {
      monaco.languages.typescript.typescriptDefaults.addExtraLib(code, `file://${path}`);
    });

    editor.onDidChangeModelContent(() => {
      ata(editor.getValue());
    });

    ata(editor.getValue());
  };

  return <div className='editor-content'>
    <MonacoEditor
      height={'100%'}
      path={'guang.tsx'}
      language={"typescript"}
      onMount={handleEditorMount}
      value={code}
      options={
        {
          fontSize: 14,
          scrollBeyondLastLine: false,
          minimap: {
            enabled: false,
          },
          scrollbar: {
            verticalScrollbarSize: 6,
            horizontalScrollbarSize: 6,
          },
        }
      }
    />
  </div>;

}

ata.tsx

import { setupTypeAcquisition } from '@typescript/ata';
import typescriprt from 'typescript';

export function createATA(onDownloadFile: (code: string, path: string) => void) {
  const ata = setupTypeAcquisition({
    projectName: 'my-ata',
    typescript: typescriprt,
    logger: console,
    delegate: {
      receivedFile: (code: any, path: any) => {
        console.log('自动下载的包', path);
        onDownloadFile(code, path);
      }
    },
  });

  return ata;
}

总结:

用@monaco-editor/react编辑器最大的好处就是,不但帮我们解决了编译的问题,还帮我解决了样式问题,如果自己写写个编辑器,那还是又很大难度了。通过配置,就可以完成对jsx,ts的编译,还能用@typescript/ata来配置type的检测功能。

2.3 多文件切换

在fileList里面又很多文件,你可以所以切换文件就和vscode一样

image.png

之前就说了,在编辑器里面的代码对我们的项目而言,他就是个字符串,一个文件是一个字符串,如何把这些字符串全部收集起来,然后在editor里面共用呢?就要看context了

创建数据中心context

所以先创建一个fileContext出来,把context的创建和派发都封装在一起。

创建: image.png

虽然在这里,file是File类型,但最终加入iframe以后都会处理成字符串,在我们的代码里面他的本质就是个字符串。这一点一定要明确,不然很难理解后面的原理。

派发:操作files里面的方法是增删改,就是操作object

image.png

在index.tsx里面使用看看

image.png

创建模板文件

首先从vue的playground里面看,fileList就是一个tab,来切换面板。而且文件都应该有一些模板的,就是实现这个功能

image.png 首先先创造出文件模板出来

image.png

有了这个基础文件,当我们的playground一旦启动,他就会在预览区出现基础画面了,和我们启动脚手架搭建的项目一样。

定义初始数据

接下来我们要写一个tsb页,把这些文件渲染到页面上去,渲染之前,要定义好初始数据。

image.png

有了这一份数据就难呢过很快的实现当我切换tab的时候,编辑器的数据发生变化。所以先把这份初始数据放到数据中心context里面去,这样,不管是fileList还是editor,我们都能快速的获取到动态数据

image.png

创建文件tab

首先看看files的类型

image.png

files是一个对象,每个文件名对应一个文件对象。所以在tab.tsx里面,我们直接拿到files的keys然后直接遍历出文件名就好了。

image.png 但是传入onchange就是为了实现,当我点击文件的时候,修改当前选中文件,然后把对应文件名传给setSelectedFileName。

编辑器关联文件数据

有了这些数据以后,你会发现是不是edotor编辑器也得变化了?

首先要把当前选中的文件传递给Editor编辑器,然后当编辑器里面的内容发生变化的时候,我们要把变化值同步给context数据中心去。

image.png

编辑器使用这个onchang起到实时监控的效果。

image.png

测试结果。

image.png

总结

最原始的一个playground就处理好了。

  1. 我们使用allotment组件来实现左右拖动,
  2. 我们选用@monaco-editor/react做react的编辑器,它最大的好处是你可以直接配置就难呢过帮你编译jsx和ts了,不用你再用babel处理。 3.文件模型用vite的静态方式导入成字符串:import AppCss from './template/App.css?raw';这样做的好处是浏览器不会执行它,可以直接丢进编辑器以值的形式显示出来。 4.文件切换器就是个tab,咱们自己做的时候可以借助antd的组件,也可以自己写。 5.文件和编辑区和预览区的数据中心只有一个,就是context里面存储的文件。 6.ts类型检测使用的包是: @typescript/ata

难点: 1.左右拖动布局, 2.编辑器如何做jsx和ts编译 3.如何做ts类型检测,如果有错就给出提示。

参考文件:

教你做个在线代码编辑器,体验堪比 VSCode!

感谢光哥的react通关秘籍