学习React Playground:实现原理

371 阅读3分钟

该文章是学习react小册的React Playground项目的总结

实现原理

image.png 怎么能实现一个类似Vue Playground呢
从布局来看,左边为代码输入区域,右边为预览区域,我们一个个来看

代码编写区域

借用了@monaco-editor/react,它把monaco editor 封装成了 react 组件,方便在react项目中使用

npm install @monaco-editor/react

导入Editor,并给defaultValue赋值代码数据,还需要设置代码的语言

import Editor from '@monaco-editor/react';

function App() {
  const code = `import { useState } from "react";

function App() {
    const [count, setCount] = useState(0);

    return (
        <div onClick={() => setCount(count + 1)}>{count}</div>
    );
}

export default App;
`;

  return (
    <Editor height="500px" defaultLanguage="javascript" defaultValue={code} />
  );
}

export default App;

运行项目可以看到显示正常,并有代码提示,但是这是JavaScript代码,要是typescript代码还会正常吗?

chrome-capture-2024-8-12.gif
替换成typescript后出现了两个问题

<Editor
  height="500px"
+  defaultLanguage="javascript"
-  defaultLanguage="typescript"
  defaultValue={code}
 />

第一个问题是jsx没有被ts编译 image.png 为了让ts能正确的编译,是要配置tsconfig.json文件,但是怎么修改编辑器的tsconfig呢?
onMount是编辑器加载完的回调里,在回调中设置对ts编译
更多的配置详情可以看此链接

+const handleEditorMount: OnMount = (editor, monaco) => {
+    monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
+      // 输入 <div> 输出 <div>,保留原样
+      jsx: monaco.languages.typescript.JsxEmit.Preserve,
+      // 编译的时候自动加上 default 属性
+      esModuleInterop: true,
+    });
+  };
  return (
    <Editor
      height="500px"
      defaultLanguage="typescript"
      defaultValue={code}
+      onMount={handleEditorMount}
      path="App.tsx"
    />
  );

editor是编辑器实例,提供了编辑器实例的创建、销毁、获取、设置等功能

image.png monaco是monaco-editor实例

image.png

image.png typescriptDefaults.setCompilerOptions可以设置ts相关的配置
image.png
第二个问题是react模块找不到 image.png 借助了@typescript/ata,这个库自动下载用到的 ts 类型的功能,它会扫描代码里的 import,然后自动下载类型
安装@typescript/ata

npm install --save @typescript/ata -f 

根据文档代码传入receivedFile函数拿到下载包的代码和路径 image.png 创建createATA函数得到ata

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, path) => {
        console.log('自动下载的包', path);
        onDownloadFile(code, path);
      },
    },
  });

  return ata;
}

image.png
ata的类型是一个函数,入参是代码字符串 image.png
解析到需要下载的包之后用addExtraLib添加到编辑器中,在初始化跟内容改变时再调用ata获取类型

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

getValue可以获取到编辑器值 image.png image.png onDidChangeModelContent是监听编辑器模型内容改变事件 image.png addExtraLib是添加类型 image.png

效果预览区域

一说到代码编译就会想到babel,但是在浏览器上怎么使用?
那就是@babel/standalone

image.png 使用transform来编译代码

image.png 传入之前的代码,通过.code获取到编译后的代码

import Editor from '@monaco-editor/react';
import { transform } from '@babel/standalone';

function App() {
  const code = `import { useState } from "react";

function App() {
    const [count, setCount] = useState(0);

    return (
        <div onClick={() => setCount(count + 1)}>{count}</div>
    );
}

export default App;
`;

+  const result = transform(code, {
+    presets: ['react', 'typescript'],
+    filename: 'test.tsx',
+  }).code!;
+  console.log(result);

  return (
    <Editor height="500px" defaultLanguage="javascript" defaultValue={code} />
  );
}

export default App;

可以看到div编译成React.createElement image.png
怎么把编译后的代码渲染出来呢
VuePlayground是采用iframe image.png image.png

添加html代码,将编译后的代码做为一个模块

const html = `
  <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Preview</title>
</head>
<body>
  <script type="module">${result}</script>
</body>
</html>
`;

iframe的src需要提供url,但是怎么把html的字符串转变成url呢?
可以用blob url,通过URL.createObjectURL()方法生成的,它允许你将二进制数据对象转换为一个可以在浏览器中使用的url

+ const src = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
return (
    <div style={{ display: 'flex' }}>
      <Editor
        height="500px"
        defaultLanguage="typescript"
        defaultValue={code}
        onMount={handleEditorMount}
        path="App.tsx"
      />
+      <iframe
+        src={src}
+        style={{
+          width: '500px',
+          height: '500px',
+          padding: 0,
+          border: 'none',
+        }}
+      />
    </div>
  );

但是界面没有显示

image.png 在编译后的代码中,React.createElement但是我们没有导入React,而且在react项目挂载是需要添加ReactDOM.createRoot(document.getElementById('root')) image.png
添加导入、挂载代码

  const code = `
+  import React from 'react';
  import { useState } from "react";
+  import ReactDOM from 'react-dom/client';

function App() {
    const [count, setCount] = useState(0);

    return (
        <div onClick={() => setCount(count + 1)}>{count}</div>
    );
}

export default App;

+ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
`;

const html = `
  <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Preview</title>
</head>
<body>
  <script type="module">${result}</script>
+  <div id="root"></div>
  </body>
</html>
`;

界面还是没有显示

image.png 虽然代码引入Reacthe和ReactDOM,但是模块不能识别,此时需要用到importmap

image.png 添加代码,导入esm.sh 就是提供 esm 模块的 CDN 服务

const html = `
  <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Preview</title>
</head>
<body>
+<script type="importmap">
+{
+    "imports": {
+        "react": "https://esm.sh/react@18.2.0",
+        "react-dom/client": "https://esm.sh/react-dom@18.2.0"
+    }
+}
+</script>
  <script type="module">${result}</script>
    <div id="root"></div>
    </body>
</html>
`;

修改code代码

  const code = `
  import React from 'react';
  import { useState } from "react";
  import ReactDOM from 'react-dom/client';

function App() {
    const [count, setCount] = useState(0);

    return (
-     <div onClick={() => setCount(count + 1)}>{count}</div>
+     <>
+     <span>count:{count}</span>
+     <button onClick={() => setCount(count + 1)}>+1</button>
+     </>
    );
}

export default App;

ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
`;

代码编写区域显示代码正常,效果预览区域渲染正常 chrome-capture-2024-8-18.gif

总结

  1. 代码编辑器使用@monaco-editor/react
  2. 效果预览采用了@babel/standalone编译代码,再用iframe+blob url+importmap渲染效果