思路

我们最终的成果:左边写代码,右边实时预览
右边还能看到编译后的代码
编译思路
包安装
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!");`;
const blob = new Blob([code], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
console.log(blobUrl);
|
这个 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 有很多参数,等用到的时候再展开看。