思路

image-20250402234550892

我们最终的成果:左边写代码,右边实时预览

右边还能看到编译后的代码

编译思路

  • 用户输入代码

  • 使用 Babel 将 TSX/JSX 代码转换为 ES Modules

  • Blob URL 处理代码加载

  • babel插件替换import语句

  • import maps + esm.sh引用react库

  • 通过 iframe 运行代码并渲染

包安装

1
2
3
cnpm i --save @babel/standalone
cnpm i --save-dev @types/babel__standalone
cnpm i --save-dev @types/babel__core

编译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { useRef, useState } from 'react'
import { transform } from '@babel/standalone';

function App() {

const textareaRef = useRef<HTMLTextAreaElement>(null);

function onClick() {
if(!textareaRef.current) {
return ;
}

const res = transform(textareaRef.current.value, {
presets: ['react', 'typescript'],
filename: 'guang.tsx'
});
console.log(res.code);
}

const code = `import { useEffect, useState } from "react";

function App() {
const [num, setNum] = useState(() => {
const num1 = 1 + 2;
const num2 = 2 + 3;
return num1 + num2
});

return (
<div onClick={() => setNum((prevNum) => prevNum + 1)}>{num}</div>
);
}

export default App;
`
return (
<div>
<textarea ref={textareaRef} style={{ width: '500px', height: '300px'}} defaultValue={code}></textarea>
<button onClick={onClick}>编译</button>
</div>
)
}

export default App

在 textarea 输入内容,设置默认值 defaultValue,用 useRef 获取它的 value。

然后点击编译按钮的时候,拿到内容用 babel.transform 编译,指定 typescript 和 react 的 preset。

打印 res.code。

可以看到,打印了编译后的代码:

但现在编译后的代码也不能跑啊:

主要是 import 语句这里:

运行代码的时候,会引入 import 的模块,这时会找不到。

当然,我们可以像 vite 的 dev server 那样做一个根据 moduleId 返回编译后的模块内容的服务。

但这里是纯前端项目,显然不适合。

其实 import 的 url 可以用 blob url。

blob url

Blob URL 是一种临时的 URL,可以用来引用 Blob 对象。Blob(Binary Large Object)是一种表示二进制数据的对象,比如文本、图片、音频等。Blob URL 允许我们在浏览器中以 URL 形式访问这些数据,而不需要请求远程服务器。

如何创建 Blob URL

1
2
3
4
5
6
7
8
9
const code = `console.log("Hello, Blob URL!");`;

// 创建 Blob 对象
const blob = new Blob([code], { type: 'application/javascript' });

// 生成 Blob URL
const blobUrl = URL.createObjectURL(blob);

console.log(blobUrl); // 类似 blob:http://example.com/12345678-90ab-cdef-1234-567890abcdef

这个 blobUrl 现在可以作为 <script>src,或者用 import 语句加载。

<script> 中使用

1
2
3
4
<script type="module">
import myCode from "blob:http://example.com/12345678-90ab-cdef-1234-567890abcdef";
console.log(myCode);
</script>

Blob URL 是基于当前页面的生命周期,当页面关闭时,所有 Blob URL 都会失效。如果不再需要,可以手动释放:

1
URL.revokeObjectURL(blobUrl);

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>

<script>
const code1 =`
function add(a, b) {
return a + b;
}
export { add };
`;

const url = URL.createObjectURL(new Blob([code1], { type: 'application/javascript' }));
const code2 = `import { add } from "${url}";

console.log(add(2, 3));`;

const script = document.createElement('script');
script.type="module";
script.textContent = code2;
document.body.appendChild(script);
</script>
</body>
</html>

babel插件替换import

那接下来的问题就简单了,左侧写的所有代码都是有文件名的。

我们只需要根据文件名替换下 import 的 url 就好了。

比如 App.tsx 引入了 ./Aaa.tsx

1
2
3
4
5
import Aaa from './Aaa.tsx';

export default function App() {
return <Aaa></Aaa>
}

我们维护拿到 Aaa.tsx 的内容,然后通过 Bob 和 URL.createObjectURL 的方式把 Aaa.tsx 内容变为一个 blob url,替换 import 的路径就好了。

这样就可以直接跑。

那怎么替换呢?

babel 插件呀。

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

babel 插件就是在 transform 的阶段增删改 AST 的:

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

只要在对 ImportDeclaration 的 AST 做处理,把 source.value 替换为对应文件的 blob url 就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { transform } from '@babel/standalone';
import type { PluginObj } from '@babel/core';

function App() {

const code1 =`
function add(a, b) {
return a + b;
}
export { add };
`;

const url = URL.createObjectURL(new Blob([code1], { type: 'application/javascript' }));

const transformImportSourcePlugin: PluginObj = {
visitor: {
ImportDeclaration(path) {
path.node.source.value = url;
}
},
}


const code = `import { add } from './add.ts'; console.log(add(2, 3));`

function onClick() {
const res = transform(code, {
presets: ['react', 'typescript'],
filename: 'guang.ts',
plugins: [transformImportSourcePlugin]
});
console.log(res.code);
}

return (
<div>
<button onClick={onClick}>编译</button>
</div>
)
}

export default App

我们用 babel 插件的方式对 import 的 source 做了替换。

把 ImportDeclaration 的 soure 的值改为了 blob url。

这样,浏览器里就能直接跑这段代码。

那如果是引入 react 和 react-dom 的包呢?

import maps机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.2.0"
}
}
</script>
<script type="module">
import React from "react";

console.log(React);
</script>
</body>
</html>

访问下:

可以看到,import react 生效了。

为什么会生效呢?

你访问下可以看到,返回的内容也是 import url 的方式:

这里的 esm.sh 就是专门提供 esm 模块的 CDN 服务:

这是它们做的 react playground

这样,如何引入编辑器里写的 ./Aaa.tsx 这种模块,如何引入 react、react-dom 这种模块我们就都清楚了。

预览

接下来看下预览部分:

这部分就是 iframe,然后加一个通信机制,左边编辑器的结果,编译之后传到 iframe 里渲染就好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react'

import iframeRaw from './iframe.html?raw';

const iframeUrl = URL.createObjectURL(new Blob([iframeRaw], { type: 'text/html' }));

const Preview: React.FC = () => {

return (
<iframe
src={iframeUrl}
style={{
width: '100%',
height: '100%',
padding: 0,
border: 'none'
}}
/>
)
}

export default Preview;

iframe.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Preview</title>
<style>
* {
padding: 0;
margin: 0;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.2.0",
"react-dom/client": "https://esm.sh/react-dom@18.2.0"
}
}
</script>
<script>

</script>
<script type="module">
import React, {useState, useEffect} from 'react';
import ReactDOM from 'react-dom/client';

const App = () => {
return React.createElement('div', null, 'aaa');
};

window.addEventListener('load', () => {
const root = document.getElementById('root')
ReactDOM.createRoot(root).render(React.createElement(App, null))
})
</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>

这里路径后面加个 ?raw 是通过字符串引入(webpack 和 vite 都有这种功能),用 URL.createObjectURL + Blob 生成 blob url 设置到 iframe 的 src 就好了:

渲染的没问题:

这样,我们只需要内容变了之后生成新的 blob url 就好了。

编辑器

用 @monaco-editor/react

安装下:

1
npm install @monaco-editor/react

试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import Editor from '@monaco-editor/react';

function App() {

const code =`import { useEffect, useState } from "react";

function App() {
const [num, setNum] = useState(() => {
const num1 = 1 + 2;
const num2 = 2 + 3;
return num1 + num2
});

return (
<div onClick={() => setNum((prevNum) => prevNum + 1)}>{num}</div>
);
}

export default App;
`;

return <Editor height="500px" defaultLanguage="javascript" defaultValue={code} />;
}

export default App;

Editor 有很多参数,等用到的时候再展开看。