写在前面
今天在整理文章的时候发现去年实现过一个React在线编辑器,然后又搜了下发现神光的React通关秘籍里面的实战也有这个功能,索性就分享一下,文章是实现的时候写的大纲索性直接就粘贴过来分享,具体的实现代码可以去github仓库直接看代码。
直达链接
需求分析
需实现以下核心功能:
-
智能代码编辑器
- 支持JSX/TSX语法高亮
- 提供TypeScript类型提示
- 支持模块导入校验
-
实时编译系统
- 将JSX/TSX编译为浏览器可执行的ES模块
- 支持本地文件(CSS/JSON/组件)的动态编译
- 实现外部CDN依赖管理
-
沙箱化预览
- 通过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)
- 支持多文件协作开发
- 完善的错误提示机制