• 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.小册总结

我们实现了多文件的切换、文件内容编辑:

左边部分可以告一段落。

这节我们开始写右边部分,也就是文件的编译,还有 iframe 预览。

编译前面讲过,用 @babel/standalone 这个包。

安装下:

npm install --save @babel/standalone

npm install --save-dev @types/babel__standalone

在 Preview 目录下新建 compiler.ts

import { transform } from "@babel/standalone";
import { Files } from "../../PlaygroundContext";
import { ENTRY_FILE_NAME } from "../../files";

export const babelTransform = (
    filename: string,
    code: string,
    files: Files
) => {
    let result = "";
    try {
        result = transform(code, {
            presets: ["react", "typescript"],
            filename,
            plugins: [],
            retainLines: true,
        }).code!;
    } catch (e) {
        console.error("编译出错", e);
    }
    return result;
};

export const compile = (files: Files) => {
    const main = files[ENTRY_FILE_NAME];
    return babelTransform(ENTRY_FILE_NAME, main.value, files);
};

调用 babel 的 transform 方法进行编译。

presets 指定 react 和 typescript,也就是对 jsx 和 ts 语法做处理。

retainLines 是编译后保持原有行列号不变。

在 compile 方法里,对 main.tsx 的内容做编译,返回编译后的代码。

在 Preview 组件里调用下:

import { useContext, useEffect, useState } from "react";
import { PlaygroundContext } from "../../PlaygroundContext";
import Editor from "../CodeEditor/Editor";
import { compile } from "./compiler";

export default function Preview() {
    const { files } = useContext(PlaygroundContext);
    const [compiledCode, setCompiledCode] = useState("");

    useEffect(() => {
        const res = compile(files);
        setCompiledCode(res);
    }, [files]);

    return (
        <div style={{ height: "100%" }}>
            <Editor
                file={{
                    name: "dist.js",
                    value: compiledCode,
                    language: "javascript",
                }}
            />
        </div>
    );
}

在 files 变化的时候,对 main.tsx 内容做编译,然后展示编译后的代码。

看下效果:

可以看到,右边展示了编译后的代码,并且左边编辑的时候,右边会实时展示编译的结果。

这样编译后的代码能直接放到 iframe 里跑么?

明显不能,我们只编译了 main.tsx,它引入的模块没有做处理。

前面讲过,可以通过 babel 插件来处理 import 语句,转换成 blob url 的方式。

我们来写下这个插件:

function customResolver(files: Files): PluginObj {
    return {
        visitor: {
            ImportDeclaration(path) {
                path.node.source.value = "23333";
            },
        },
    };
}

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

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

我们要改的就是 ImportDeclaration 节点的 source.value 的内容。

可以看到,确实被替换了。

那替换成什么样还不是我们说了算。

我们分别对 css、json 还有 tsx、ts 等后缀名的 import 做下替换:

首先,我们要对路径做下处理,比如 ./App.css 这种路径提取出 App.css 部分

万一输入的是 ./App 这种路径,也要能查找到对应的 App.tsx 模块:

如果去掉 ./ 之后,剩下的不包含 . 比如 ./App 这种,那就要补全 App 为 App.tsx 等。

过滤下 files 里的 js、jsx、ts、tsx 文件,如果包含这个名字的模块,那就按照补全后的模块名来查找 file。

之后把 file.value 也就是文件内容转成对应的 blob url:

ts 文件的处理就是用 babel 编译下,然后用 URL.createObjectURL 把编译后的文件内容作为 url。

而 css 和 json 文件则是要再做一下处理:

json 文件的处理比较简单,就是把 export 一下这个 json,然后作为 blob url 即可:

而 css 文件,则是要通过 js 代码把它添加到 head 里的 style 标签里:

全部代码如下:

import { transform } from "@babel/standalone";
import { File, Files } from "../../PlaygroundContext";
import { ENTRY_FILE_NAME } from "../../files";
import { PluginObj } from "@babel/core";

export const babelTransform = (
    filename: string,
    code: string,
    files: Files
) => {
    let result = "";
    try {
        result = transform(code, {
            presets: ["react", "typescript"],
            filename,
            plugins: [customResolver(files)],
            retainLines: true,
        }).code!;
    } catch (e) {
        console.error("编译出错", e);
    }
    return result;
};

const getModuleFile = (files: Files, modulePath: string) => {
    let moduleName = modulePath.split("./").pop() || "";
    if (!moduleName.includes(".")) {
        const realModuleName = Object.keys(files)
            .filter((key) => {
                return (
                    key.endsWith(".ts") ||
                    key.endsWith(".tsx") ||
                    key.endsWith(".js") ||
                    key.endsWith(".jsx")
                );
            })
            .find((key) => {
                return key.split(".").includes(moduleName);
            });
        if (realModuleName) {
            moduleName = realModuleName;
        }
    }
    return files[moduleName];
};

const json2Js = (file: File) => {
    const js = `export default ${file.value}`;
    return URL.createObjectURL(
        new Blob([js], { type: "application/javascript" })
    );
};

const css2Js = (file: File) => {
    const randomId = new Date().getTime();
    const js = `
(() => {
    const stylesheet = document.createElement('style')
    stylesheet.setAttribute('id', 'style_${randomId}_${file.name}')
    document.head.appendChild(stylesheet)

    const styles = document.createTextNode(\`${file.value}\`)
    stylesheet.innerHTML = ''
    stylesheet.appendChild(styles)
})()
    `;
    return URL.createObjectURL(
        new Blob([js], { type: "application/javascript" })
    );
};

function customResolver(files: Files): PluginObj {
    return {
        visitor: {
            ImportDeclaration(path) {
                const modulePath = path.node.source.value;
                if (modulePath.startsWith(".")) {
                    const file = getModuleFile(files, modulePath);
                    if (!file) return;

                    if (file.name.endsWith(".css")) {
                        path.node.source.value = css2Js(file);
                    } else if (file.name.endsWith(".json")) {
                        path.node.source.value = json2Js(file);
                    } else {
                        path.node.source.value = URL.createObjectURL(
                            new Blob(
                                [babelTransform(file.name, file.value, files)],
                                {
                                    type: "application/javascript",
                                }
                            )
                        );
                    }
                }
            },
        },
    };
}

export const compile = (files: Files) => {
    const main = files[ENTRY_FILE_NAME];
    return babelTransform(ENTRY_FILE_NAME, main.value, files);
};

看下效果:

可以看到,./App 的模块内容编译之后变为了 blob url。

我们引入 ./App.css 试下:

可以看到,css 模块也变为了 blob url。

我们在 devtools 里 fetch 下 blob url 可以看到它的内容:

fetch("blob:http://localhost:5173/xxxx")
    .then((response) => response.text())
    .then((text) => {
        console.log(text);
    });

可以看到,./App.tsx 的内容是 babel 编译过后的。

./App.css 的内容也是我们做的转换。

而上面的 react、react-dom/client 的包是通过 import maps 引入:

其实还有一个问题要处理:

比如 App.tsx 的 jsx 内容编译后变成了 React.createElement,但是我们并没有引入 React,这样运行会报错。

处理下:

babel 编译之前,判断下文件内容有没有 import React,没有就 import 一下:

export const beforeTransformCode = (filename: string, code: string) => {
    let _code = code;
    const regexReact = /import\s+React/g;
    if (
        (filename.endsWith(".jsx") || filename.endsWith(".tsx")) &&
        !regexReact.test(code)
    ) {
        _code = `import React from 'react';\n${code}`;
    }
    return _code;
};

export const babelTransform = (
    filename: string,
    code: string,
    files: Files
) => {
    let _code = beforeTransformCode(filename, code);
    let result = "";
    try {
        result = transform(_code, {
            presets: ["react", "typescript"],
            filename,
            plugins: [customResolver(files)],
            retainLines: true,
        }).code!;
    } catch (e) {
        console.error("编译出错", e);
    }
    return result;
};

现在,如果没引入 React 就会自动引入:

至此, main.tsx 的所有依赖都引入了:

  • react、react-dom/client 的包通过 import maps 引入
  • ./App.tsx、./App.css 或者 xx.json 之类的依赖通过 blob url 引入

这样,编译过后的这段代码就可以直接在浏览器里跑了:

我们加个 iframe 来跑下:

加一个 iframe 标签,src url 同样是用 blob url 的方式。

用 ?raw 的 import 引入 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>
    </head>
    <body>
        <script type="importmap"></script>
        <script type="module" id="appSrc"></script>
        <div id="root"></div>
    </body>
</html>

替换其中的 import maps 和 src 的内容。

之后创建 blob url 设置到 iframe 的 src。

当 import maps 的内容或者 compiledCode 的内容变化的时候,就重新生成 blob url。

import { useContext, useEffect, useState } from "react";
import { PlaygroundContext } from "../../PlaygroundContext";
import Editor from "../CodeEditor/Editor";
import { compile } from "./compiler";
import iframeRaw from "./iframe.html?raw";
import { IMPORT_MAP_FILE_NAME } from "../../files";

export default function Preview() {
    const { files } = useContext(PlaygroundContext);
    const [compiledCode, setCompiledCode] = useState("");
    const [iframeUrl, setIframeUrl] = useState(getIframeUrl());

    useEffect(() => {
        const res = compile(files);
        setCompiledCode(res);
    }, [files]);

    const getIframeUrl = () => {
        const res = iframeRaw
            .replace(
                '<script type="importmap"></script>',
                `<script type="importmap">${
                    files[IMPORT_MAP_FILE_NAME].value
                }</script>`
            )
            .replace(
                '<script type="module" id="appSrc"></script>',
                `<script type="module" id="appSrc">${compiledCode}</script>`
            );
        return URL.createObjectURL(new Blob([res], { type: "text/html" }));
    };

    useEffect(() => {
        setIframeUrl(getIframeUrl());
    }, [files[IMPORT_MAP_FILE_NAME].value, compiledCode]);

    return (
        <div style={{ height: "100%" }}>
            <iframe
                src={iframeUrl}
                style={{
                    width: "100%",
                    height: "100%",
                    padding: 0,
                    border: "none",
                }}
            />
            {/* <Editor file={{
            name: 'dist.js',
            value: compiledCode,
            language: 'javascript'
        }}/> */}
        </div>
    );
}

看下效果:

看下 iframe 的内容:

没啥问题。

预览功能完成!

案例代码上传了小册仓库,可以切换到这个 commit 查看:

git reset --hard a02195cfa12948e969bb9dc9cf01cdbe79331ab4

总结

前面章节实现了代码编辑,这节我们实现了编译以及在 iframe 里预览。

使用 @babel/standalone 做的 tsx 代码的编译,编译过程中需要对 .tsx、.css、.json 等模块的 import 做处理,变成 blob url 的方式。

tsx 模块直接用 babel 编译,css 模块包一层代码加到 head 的 style 标签里,json 包一层代码直接 export 即可。

对于 react、react-dom/client 这种,用浏览器的 import maps 来引入。

之后把 iframe.html 的内容替换 import maps 和 src 部分后,同样用 blob url 设置为 iframe 的 src 就可以了。

这样就能实现浏览器里的编译和预览。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
55.ReactPlayground项目实战:多文件切换
Next
57.ReactPlayground项目实战:文件增删改