代码生成页面中,如果生成的是 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 <button onClick={() => setCount(count + 1)}>计数 {count}</button>;
}</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 也不能作为可靠的执行预算。这个实现适合观察已知的单文件组件,不能直接作为接受任意代码的生产沙箱。