如何用Monaco和Babel打造高性能React-Playground

写在前面

今天在整理文章的时候发现去年实现过一个React在线编辑器,然后又搜了下发现神光的React通关秘籍里面的实战也有这个功能,索性就分享一下,文章是实现的时候写的大纲索性直接就粘贴过来分享,具体的实现代码可以去github仓库直接看代码。

直达链接

github.com/yinhw0210/r...

需求分析

需实现以下核心功能:

  1. 智能代码编辑器

    • 支持JSX/TSX语法高亮
    • 提供TypeScript类型提示
    • 支持模块导入校验
  2. 实时编译系统

    • 将JSX/TSX编译为浏览器可执行的ES模块
    • 支持本地文件(CSS/JSON/组件)的动态编译
    • 实现外部CDN依赖管理
  3. 沙箱化预览

    • 通过iframe隔离运行环境
    • 支持错误捕获与展示
    • 确保多实例安全隔离

编辑器

编辑器选用的是monaco-editor,使用syntax-highlighter来实现代码高亮。使用@typescript/ata来实现带有类型提示的编辑器。

jsx代码编译

如果在html中已经引入了esm的cdn模块,只需要把jsx编译成响应的js脚本既可运行。 jsx:

jsx 复制代码
import React from 'react'
import ReactDOM from 'react-dom/client'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <div>hello world!</div>
  </React.StrictMode>
)

html:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React 18 CDN Example</title>
  </head>
  <body>
    <script type="importmap">
      {
        "imports": {
          "react": "https://esm.sh/[email protected]",
          "react-dom/client": "https://esm.sh/[email protected]"
        }
      }
    </script>
    <div id="root"></div>

    <script type="module">
      import React from "react";
      import ReactDOM from "react-dom/client";
      import './app.css'
      ReactDOM.createRoot(document.getElementById("root")).render(
        /*#__PURE__*/ React.createElement(
          React.StrictMode,
          null,
          /*#__PURE__*/ React.createElement("div", null, "hello world!")
        )
      );
    </script>
  </body>
</html>

现在的demo支持的文件有jsx、json、css、mapjson文件。

jsx、tsx文件编译


文件编译使用的是babel的一个插件babel-standalone

javascript 复制代码
import { transform } from "@babel/standalone";
import mainCode from "../PlayGround/template/main?raw";

function Test() {
  // 编译传入的jsx代码
  const compileCode = (code: string) => {
    const babelFileResult = transform(code, {
      sourceType: "module", // 资源类型 es模块支持
      presets: ["react", "typescript"], // 预设语言为react和ts
    });
    return babelFileResult.code;
  };
  return <div>{compileCode(mainCode)}</div>;
}

export default Test;

引入本地文件的编译

jsx 复制代码
import React from "react";
import ReactDOM from "react-dom/client";
import "./App.css";
import App from "./App";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

引入的文件分为外部插件和本地文件,外部插件的话不需要做特殊处理,直接编译成js就可以了,本地引入的文件,如css、jsx组件等需要进行特殊处理,现在的思路是把对应的文件代码转成本地链接然后引入就可以了。 @babel/standalone的transform方法是可以自定义插件和自定义编译的,通过plugins自定义import的引入语句,把import "./App.css"转换成本地链接形式blob:http://localhost:5173/2b1581c0-3c05-4866-9f63-be740d1cbc50 。引入本地组件的话再走一遍编译,把组件内的代码也编译成本地文件的形式就行了。

jsx 复制代码
const babelFileResult = transform(_code, {
    sourceType: "module", // 资源类型 es模块支持
    presets: ["react", "typescript"], // 预设语言为react和ts
    filename: file.name,
    plugins: [
      // 自定义插件
      {
        visitor: {
          //自定义import语句
          ImportDeclaration: (path: any) => {
            // 获取当前import语句引入的name
            const moduleName: string = path.node.source.value;
            // 判断当前引入的是否是本地文件(以.开头的需要转换为本地资源在进行引入)不是本地资源则无需转换
            const isNative = moduleName.startsWith(".");
            if (!isNative) return;
            // 获取当前文件系统内的模块文件 没有创建此文件则不进行转换
            const module = getModuleFile(files, moduleName);
            if (!module) return;
            // 根据当前module的类型进行转换
            switch (module.type) {
              case "css":
                path.node.source.value = cssToJs(module);
                break;
              case "json":
                path.node.source.value = jsonToJs(module);
                break;
              default:
                // 如果不是css和json类型说明引入的是组件 则需要对组件的文件进行编译并且转换为本地资源
                path.node.source.value = URL.createObjectURL(
                  new Blob([babelTransform(module, files)!], {
                    type: "application/javascript",
                  })
                );
                break;
            }
          },
        },
      },
    ],
  });

// css转js
export const cssToJs = (file: IFile) => {
  const uuid = new Date().getTime();
  const renderJs = `
              let stylesheet = document.getElementById('style_${uuid}_${file.name}');
              if (!stylesheet) {
                stylesheet = document.createElement('style')
                stylesheet.setAttribute('id', 'style_${uuid}_${file.name}')
                document.head.appendChild(stylesheet)
              }
              const styles = document.createTextNode(\`${file.value}\`)
              stylesheet.innerHTML = ''
              stylesheet.appendChild(styles)
            `;
  return URL.createObjectURL(
    new Blob([renderJs], { type: "application/javascript" })
  );
};

css文件的编译则需要将css的内容转成一个立即立即执行的js文件

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Css To Js Example</title>
    <!-- 
    <style>
      body{
        color:red
      }
    </style> 
    -->
    <style id="stylesheet"></style>
  </head>
  <body>
    <script>
      const stylesheet = document.getElementById("stylesheet");
      const text = document.createTextNode(`
        body {
          color:red
        }
      `);
      stylesheet.appendChild(text);
    </script>
    <div id="root">
      <div class="node">hello word!</div>
    </div>
  </body>
</html>

引入cdn

对于外部插件的引用像vue sfc playground中一样,单独定义一个文件用来导入需要使用的外部插件,但需要是esm格式的外部链接。import 在线引包------ESM CDN

json 复制代码
{
  "imports": {
    "react": "https://esm.sh/[email protected]",
    "react-dom/client": "https://esm.sh/[email protected]"
  }
}

使用<script></script>标签的type = importmap导入cdn映射

渲染到页面上

相关格式的文件都已经转成可以直接在页面上运行的代码了,现在只需要在html中处理一下这些代码。 把我们提前创建好的html模板转成iframe的链接之后通过父子通信来处理页面渲染和页面报错等。。。

jsx 复制代码
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useEffect, useRef } from "react";
import srcdoc from "./srcdoc.html?raw";
import styles from "./index.module.less";
import { getIframeUrl } from "../../utils";
import { useCodeCompile } from "../../hooks";
import Container from "../../container";
import { debounce } from "lodash-es";
import ErrorMessage from "../ErrorMessage";
import { useMount, useUnmount } from "ahooks";
import { POST_MESSAGE_TYPE } from "../../enum";

const iframeUrl = getIframeUrl(srcdoc);

function Preview() {
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const [errorMessage, setErrorMessage] = React.useState<string>("");
  const { files, previewKey } = Container.useContainer();

  // 处理编译后的代码
  const { compiledCode } = useCodeCompile({
    files,
  });

  const updatePostMessage = useCallback(
    debounce(() => {
      iframeRef.current?.contentWindow?.postMessage(compiledCode);
    }, 50),
    [compiledCode, iframeRef]
  );

  // 代码更新后通知iframe更新
  useEffect(() => {
    if (compiledCode) {
      updatePostMessage();
    }
  }, [compiledCode, updatePostMessage]);

  // 处理报错信息
  useMount(() => {
    window.addEventListener("message", (e) => {
      if (e.data.type === POST_MESSAGE_TYPE.ERROR) {
        setErrorMessage(e.data.message);
      }
      if (e.data.type === POST_MESSAGE_TYPE.DONE) {
        setErrorMessage("");
      }
    });
  });

  // 组件卸载后移除监听
  useUnmount(() => {
    window.removeEventListener("message", () => {
      setErrorMessage("");
    });
  });

  return (
    <React.Fragment>
      <div className={styles.preview}>
        <ErrorMessage data={errorMessage} />
        <iframe
          key={previewKey}
          ref={iframeRef}
          src={iframeUrl}
          style={{
            width: "100%",
            height: "100%",
            padding: 0,
            border: "none",
          }}
          sandbox="allow-popups-to-escape-sandbox allow-scripts allow-popups allow-forms allow-pointer-lock allow-top-navigation allow-modals allow-same-origin"
        />
      </div>
    </React.Fragment>
  );
}

export default Preview;
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>
    <!-- es-module-shims -->
  </head>
  <body>
    <script>
      window.addEventListener("message", ({ data }) => {
        if (data?.type === "update") {
          const importmapTag = document.querySelector(
            'script[type="importmap"]'
          );
          if (data.data.importmap) importmapTag.innerHTML = data.data.importmap;

          // 获取样式代码块
          const appStyleTags =
            document.querySelectorAll('style[id^="style_"]') || [];
          // 获取脚本代码块
          const appSrcTag = document.querySelector("#appSrc");
          // 获取旧的脚本地址
          const oldSrc = appSrcTag.getAttribute("src");
          // 移除旧的脚本
          appSrcTag.remove();
          // 创建新的脚本
          const script = document.createElement("script");
          // 创建新的脚本地址
          const newSrc = URL.createObjectURL(
            new Blob([data.data.compileCode], {
              type: "application/javascript",
            })
          );
          // 设置新的脚本地址
          script.src = newSrc;
          script.id = "appSrc";
          script.type = "module";
          // 脚本加载完后移除旧的样式
          script.onload = () => {
            appStyleTags.forEach((div) => {
              div.remove();
            });
          };
          // 添加新的脚本
          document.body.appendChild(script);
          // 释放旧的脚本地址
          URL.revokeObjectURL(oldSrc);
          // 通知父窗口加载完成
          window.parent.postMessage({ type: "done", message: "" });
        }
      });
      // 捕获错误 通知父窗口
      window.addEventListener("error", (e) => {
        window.parent.postMessage({ type: "error", message: e.message });
      });

    </script>
    <script type="importmap"></script>
    <script type="module" id="appSrc"></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>

Web Worker


由于js的单线程设计,如果在主线程去执行这个编译,可能会阻塞主线程的任务执行,造成编辑卡顿,所以使用Web Work来执行编译任务,编译完成后通知主线程上的页面渲染即可。

jsx 复制代码
// 主线程(浏览器环境)
const worker = new Worker('babel-worker.js');

// 编辑器组件部分...
function Editor({ onCodeChange }) {
  // 当编辑器内容变化时
  const handleChange = (code) => {
    // 将JSX代码发送给Web Worker
    worker.postMessage(code);

    // 在主线程监听Worker返回的消息
    worker.onmessage = (event) => {
      if (event.data.type === 'compiled') {
        // 更新预览区域的组件
        onCodeChange(event.data.compiledCode);
      }
    };
  };

  // ... 其他编辑器相关的逻辑 ...
}

// Web Worker脚本(babel-worker.js)
self.addEventListener('message', (event) => {
  // 使用Babel Standalone进行编译
  try {
    const compiledCode = Babel.transform(event.data, {
      presets: ['@babel/preset-react'],
    }).code;

    // 将编译后的代码返回给主线程
    self.postMessage({
      type: 'compiled',
      compiledCode,
    });
  } catch (error) {
    // 处理编译错误
    console.error('Error compiling code:', error);
    self.postMessage({ type: 'error', errorMessage: error.message });
  }
});

importScripts('https://unpkg.com/@babel/standalone/babel.min.js');

本方案实现了:

  • 完整的React开发体验
  • 毫秒级响应速度(平均编译时间<200ms)
  • 支持多文件协作开发
  • 完善的错误提示机制
相关推荐
前端_yu小白几秒前
uniapp路由跳转导致页面堆积问题
前端·uni-app·页面跳转·返回
cong_11 分钟前
🌟 Cursor 帮我 2.5 天搞了一个摸 🐟 岛
前端·后端·github
MyhEhud1 小时前
Kotlin 中 also 方法的用法和使用场景
前端·kotlin
小莫爱编程1 小时前
HTML,CSS,JavaScript
前端·css·html
陈大鱼头2 小时前
AI驱动的前端革命:10项颠覆性技术如何在LibreChat中融为一体
前端·ai 编程
Gazer_S2 小时前
【解析 ECharts 图表样式继承与自定义】
前端·信息可视化·echarts
剪刀石头布啊2 小时前
视觉格式化模型
前端·css
一 乐2 小时前
招聘信息|基于SprinBoot+vue的招聘信息管理系统(源码+数据库+文档)
前端·javascript·数据库·vue.js·招聘系统
念九_ysl2 小时前
Vue3 + ECharts 数据可视化实战指南
前端·信息可视化·echarts
Gazer_S2 小时前
【Auto-Scroll-List 组件设计与实现分析】
前端·javascript·数据结构·vue.js