•
阅读时长 3 分钟

用 Babel 和 iframe 实现 React 单文件组件预览

目录

代码生成页面中,如果生成的是 React 组件,希望旁边可以直接运行它,光把代码放进 iframe 还不够。浏览器不认识 JSX,还需要提供 React、挂载节点以及报错信息。

下面做一个单文件预览器:输入定义 App 函数,点击运行后在 iframe 中显示组件。只支持这一种输入,不支持 import/export、独立 CSS 文件和 npm 模块导入。

安装和页面结构

新建目录,安装三个依赖:

npm init -y
npm install react@18.3.1 react-dom@18.3.1 @babel/standalone@7.26.10

创建 preview.html:

<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>React 单文件预览</title>
<textarea id="source" rows="10" style="width:95%">function App() {
  const [count, setCount] = React.useState(0);
  return &lt;button onClick={() =&gt; setCount(count + 1)}&gt;计数 {count}&lt;/button&gt;;
}</textarea>
<p><button id="run">运行</button></p>
<p id="status" role="status"></p>
<iframe id="frame" title="组件预览" sandbox="allow-scripts"
  style="width:95%;height:240px;border:1px solid #ccc"></iframe>
<script src="/node_modules/@babel/standalone/babel.min.js"></script>
<script src="/preview.js"></script>
</html>

React 作为全局变量提供给生成的代码,所以组件中使用 React.useState,不需要 import。iframe 只允许脚本,没有 allow-same-origin。

转换代码并构建子页面

创建 preview.js:

const frame = document.querySelector('#frame');
const status = document.querySelector('#status');
let token = '';

function makeDocument(code, executionToken) {
  const origin = location.origin;
  const encoded = JSON.stringify(code + '; return App;').replace(/</g, '\\u003c');
  const messageToken = JSON.stringify(executionToken);
  return `<!doctype html><meta charset="utf-8">
    <meta http-equiv="Content-Security-Policy" content="default-src 'none';
      script-src 'unsafe-inline' 'unsafe-eval' ${origin};
      style-src 'unsafe-inline'; img-src data:; connect-src 'none'">
    <div id="root"></div>
    <script src="${origin}/node_modules/react/umd/react.development.js"><\/script>
    <script src="${origin}/node_modules/react-dom/umd/react-dom.development.js"><\/script>
    <script>
      const report = status => parent.postMessage({token: ${messageToken}, status}, '*');
      window.addEventListener('error', event => report(event.message));
      window.addEventListener('unhandledrejection', event =>
        report(String(event.reason?.message || event.reason)));
      try {
        const App = new Function(${encoded})();
        function Ready() {
          React.useEffect(() => report('已执行'), []);
          return React.createElement(App);
        }
        ReactDOM.createRoot(document.querySelector('#root'))
          .render(React.createElement(Ready));
      } catch (error) {
        report(error.message);
      }
    <\/script>`;
}

function run() {
  try {
    const source = document.querySelector('#source').value;
    if (/\b(import|export)\b/.test(source)) {
      throw new Error('仅支持无 import/export 的 App 函数');
    }
    const code = Babel.transform(source, { presets: ['react'] }).code;
    token = crypto.randomUUID();
    status.textContent = '编译成功,等待执行';
    frame.srcdoc = makeDocument(code, token);
  } catch (error) {
    status.textContent = error.message;
  }
}

document.querySelector('#run').addEventListener('click', run);
window.addEventListener('message', event => {
  if (event.source !== frame.contentWindow || event.data?.token !== token) return;
  if (typeof event.data.status === 'string') status.textContent = event.data.status;
});

Babel 将 JSX 转换为 JavaScript,API 见 Babel Standalone 文档。编译成功不代表组件能运行:App 可能没有定义,组件执行时也可能抛错。

代码执行放在 iframe 内,父页面不调用生成的 App。Ready 的 effect 用来报告挂载后的状态;之后的错误仍然可能发生,所以“已执行”不等于以后不会报错。

字符串中出现 script 结束标签

这里将编译结果放进另一份 HTML 的 script 中。如果代码字符串包含 </script>,HTML 解析器可能提前结束外层脚本,即使它在 JavaScript 字符串里。

所以要使用 JSON.stringify 生成 JavaScript 字符串字面量,再把 < 替换成 \u003c。上面代码中 <\/script> 则用于父页面拼接子页面的脚本标签。

这是 HTML 和 JavaScript 嵌套的问题,不能只靠 JSX 编译处理。用户代码也不能直接拼进 HTML 后就当成安全文本。

启动本地服务

为了让依赖脚本通过 HTTP 加载,创建 server.mjs。只暴露预览需要的五个文件:

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';

const files = new Map([
  ['/', ['preview.html', 'text/html; charset=utf-8']],
  ['/preview.js', ['preview.js', 'text/javascript']],
  ['/node_modules/@babel/standalone/babel.min.js',
    ['node_modules/@babel/standalone/babel.min.js', 'text/javascript']],
  ['/node_modules/react/umd/react.development.js',
    ['node_modules/react/umd/react.development.js', 'text/javascript']],
  ['/node_modules/react-dom/umd/react-dom.development.js',
    ['node_modules/react-dom/umd/react-dom.development.js', 'text/javascript']],
]);
createServer(async (req, res) => {
  const entry = files.get(new URL(req.url, 'http://localhost').pathname);
  if (!entry) { res.writeHead(404); res.end(); return; }
  try {
    const bytes = await readFile(fileURLToPath(new URL(entry[0], import.meta.url)));
    res.writeHead(200, { 'Content-Type': entry[1], 'Cache-Control': 'no-store' });
    res.end(bytes);
  } catch {
    res.writeHead(404);
    res.end('文件不存在');
  }
}).listen(3000, '127.0.0.1');

执行 node server.mjs,打开 http://127.0.0.1:3000,点运行后再点计数按钮,计数应增加。依赖从本地加载,不需要在执行时请求 CDN。

还可以分别检查:删掉 JSX 闭合标签,观察编译错误;把 App 改成其他名字,观察执行错误;在组件内抛出异常,观察状态信息。每次点运行都会替换整个 iframe 文档,组件状态随之重置。

消息和权限限制

sandbox 子页面没有普通同源身份,不能用 event.origin === location.origin 校验它。这里检查来源窗口和本次 token,避免其他窗口或上一次运行的消息修改状态。token 只是区分执行批次,不是认证凭据,也不能证明生成代码诚实地报告了结果。

状态通过 textContent 展示,不把子页面返回的文字作为 HTML 插入。

CSP 禁止 fetch 等连接,图片只允许 data URL,脚本来源限制在这个演示服务器。但该来源仍允许加载其他可访问脚本;生产方案需要进一步收紧资源暴露和执行环境。拒绝 import/export 的正则只是输入提示,注释或字符串也可能触发它,不是安全检查。

无限循环仍然可能阻塞页面。父页面的计时器不一定能在主线程被阻塞时执行,删除 iframe 也不能作为可靠的执行预算。这个实现适合观察已知的单文件组件,不能直接作为接受任意代码的生产沙箱。