• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

这节我们开始做一个实战项目:React Playground。

类似 vue 的 playground:

左边写代码,右边实时预览。

右边还可以看到编译后的代码:

先来分析下实现思路。

首先是编译:

编译用的 @babel/standalone,这个是 babel 的浏览器版本。

可以用它实时把 tsx 代码编译为 js。

试一下:

npx create-vite

进入项目安装 @babel/standalone 和它的 ts 类型:

npm install
npm i --save @babel/standalone
npm i --save-dev @types/babel__standalone

去掉 index.css 和 StrictMode:

改下 App.tsx

import { useRef, useState } from "react";
import { transform } from "@babel/standalone";

function App() {
    const textareaRef = useRef < HTMLTextAreaElement > null;

    function onClick() {
        if (!textareaRef.current) {
            return;
        }

        const res = transform(textareaRef.current.value, {
            presets: ["react", "typescript"],
            filename: "guang.tsx",
        });
        console.log(res.code);
    }

    const code = `import { useEffect, useState } from "react";

  function App() {
    const [num, setNum] = useState(() => {
      const num1 = 1 + 2;
      const num2 = 2 + 3;
      return num1 + num2
    });

    return (
      <div onClick={() => setNum((prevNum) => prevNum + 1)}>{num}</div>
    );
  }

  export default App;
  `;
    return (
        <div>
            <textarea
                ref={textareaRef}
                style={{ width: "500px", height: "300px" }}
                defaultValue={code}></textarea>
            <button onClick={onClick}>编译</button>
        </div>
    );
}

export default App;

在 textarea 输入内容,设置默认值 defaultValue,用 useRef 获取它的 value。

然后点击编译按钮的时候,拿到内容用 babel.transform 编译,指定 typescript 和 react 的 preset。

打印 res.code。

可以看到,打印了编译后的代码:

但现在编译后的代码也不能跑啊:

主要是 import 语句这里:

运行代码的时候,会引入 import 的模块,这时会找不到。

当然,我们可以像 vite 的 dev server 那样做一个根据 moduleId 返回编译后的模块内容的服务。

但这里是纯前端项目,显然不适合。

其实 import 的 url 可以用 blob url。

在 public 目录下添加 test.html:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>
    <body>
        <script>
            const code1 = `
    function add(a, b) {
        return a + b;
    }
    export { add };
    `;

            const url = URL.createObjectURL(
                new Blob([code1], { type: "application/javascript" })
            );
            const code2 = `import { add } from "${url}";

    console.log(add(2, 3));`;

            const script = document.createElement("script");
            script.type = "module";
            script.textContent = code2;
            document.body.appendChild(script);
        </script>
    </body>
</html>

浏览器访问下:

这里用的就是 blob url:

我们可以把一段 JS 代码,用 URL.createObjectURL 和 new Blob 的方式变为一个 url:

URL.createObjectURL(new Blob([code], { type: "application/javascript" }));

那接下来的问题就简单了,左侧写的所有代码都是有文件名的。

我们只需要根据文件名替换下 import 的 url 就好了。

比如 App.tsx 引入了 ./Aaa.tsx

import Aaa from "./Aaa.tsx";

export default function App() {
    return <Aaa></Aaa>;
}

我们维护拿到 Aaa.tsx 的内容,然后通过 Bob 和 URL.createObjectURL 的方式把 Aaa.tsx 内容变为一个 blob url,替换 import 的路径就好了。

这样就可以直接跑。

那怎么替换呢?

babel 插件呀。

babel 编译流程分为 parse、transform、generate 三个阶段。

babel 插件就是在 transform 的阶段增删改 AST 的:

通过 astexplorer.net 看下对应的 AST:

只要在对 ImportDeclaration 的 AST 做处理,把 source.value 替换为对应文件的 blob url 就行了。

比如这样写:

import { transform } from "@babel/standalone";
import type { PluginObj } from "@babel/core";

function App() {
    const code1 = `
    function add(a, b) {
        return a + b;
    }
    export { add };
    `;

    const url = URL.createObjectURL(
        new Blob([code1], { type: "application/javascript" })
    );

    const transformImportSourcePlugin: PluginObj = {
        visitor: {
            ImportDeclaration(path) {
                path.node.source.value = url;
            },
        },
    };

    const code = `import { add } from './add.ts'; console.log(add(2, 3));`;

    function onClick() {
        const res = transform(code, {
            presets: ["react", "typescript"],
            filename: "guang.ts",
            plugins: [transformImportSourcePlugin],
        });
        console.log(res.code);
    }

    return (
        <div>
            <button onClick={onClick}>编译</button>
        </div>
    );
}

export default App;

这里插件的类型用到了 @babel/core 包的类型,安装下:

npm i --save-dev @types/babel__core

我们用 babel 插件的方式对 import 的 source 做了替换。

把 ImportDeclaration 的 soure 的值改为了 blob url。

这样,浏览器里就能直接跑这段代码。

那如果是引入 react 和 react-dom 的包呢?这些也不是在左侧写的代码呀

这种可以用 import maps 的机制:

在 public 下新建 test2.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>
    <body>
        <script type="importmap">
            {
                "imports": {
                    "react": "https://esm.sh/react@18.2.0"
                }
            }
        </script>
        <script type="module">
            import React from "react";

            console.log(React);
        </script>
    </body>
</html>

访问下:

可以看到,import react 生效了。

为什么会生效呢?

你访问下可以看到,返回的内容也是 import url 的方式:

这里的 esm.sh 就是专门提供 esm 模块的 CDN 服务:

这是它们做的 react playground:

这样,如何引入编辑器里写的 ./Aaa.tsx 这种模块,如何引入 react、react-dom 这种模块我们就都清楚了。

分别用 Blob + URL.createBlobURL 和 import maps + esm.sh 来做。

那编辑器部分如何做呢?

这个用 @monaco-editor/react

安装下:

npm install @monaco-editor/react

试一下:

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

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

function App() {
    const [num, setNum] = useState(() => {
        const num1 = 1 + 2;
        const num2 = 2 + 3;
        return num1 + num2
    });

    return (
        <div onClick={() => setNum((prevNum) => prevNum + 1)}>{num}</div>
    );
}

export default App;
`;

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

export default App;

Editor 有很多参数,等用到的时候再展开看。

接下来看下预览部分:

这部分就是 iframe,然后加一个通信机制,左边编辑器的结果,编译之后传到 iframe 里渲染就好了。

import React from "react";

import iframeRaw from "./iframe.html?raw";

const iframeUrl = URL.createObjectURL(
    new Blob([iframeRaw], { type: "text/html" })
);

const Preview: React.FC = () => {
    return (
        <iframe
            src={iframeUrl}
            style={{
                width: "100%",
                height: "100%",
                padding: 0,
                border: "none",
            }}
        />
    );
};

export default Preview;

iframe.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>
        <style>
            * {
                padding: 0;
                margin: 0;
            }
        </style>
    </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></script>
        <script type="module">
            import React, { useState, useEffect } from "react";
            import ReactDOM from "react-dom/client";

            const App = () => {
                return React.createElement("div", null, "aaa");
            };

            window.addEventListener("load", () => {
                const root = document.getElementById("root");
                ReactDOM.createRoot(root).render(
                    React.createElement(App, null)
                );
            });
        </script>

        <div id="root">
            <div
                style="position:absolute;top: 0;left:0;width:100%;height:100%;display: flex;justify-content: center;align-items: center;">
                Loading...
            </div>
        </div>
    </body>
</html>

这里路径后面加个 ?raw 是通过字符串引入(webpack 和 vite 都有这种功能),用 URL.createObjectURL + Blob 生成 blob url 设置到 iframe 的 src 就好了:

渲染的没问题:

这样,我们只需要内容变了之后生成新的 blob url 就好了。

至此,从编辑器到编译到预览的流程就理清了。

案例代码上传了小册仓库。

总结

我们分析了下 react playground 的实现思路。

编辑器部分用 @monaco-editor/react 实现,然后用 @babel/standalone 在浏览器里编译。

编译过程中用自己写的 babel 插件实现 import 的 source 的修改,变为 URL.createObjectURL + Blob 生成的 blob url,把模块内容内联进去。

对于 react、react-dom 这种包,用 import maps 配合 esm.sh 网站来引入。

然后用 iframe 预览生成的内容,url 同样是把内容内联到 src 里,生成 blob url。

这样,react playground 整个流程的思路就理清了。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
52.react-dnd实战:拖拽版TodoList
Next
54.ReactPlayground项目实战:布局、代码编辑器