React Playground 實現原理揭祕
大家應該都用過在線寫代碼的工具,比如 vue 的 playground:
左邊寫代碼,右邊實時預覽。
右邊還可以看到編譯後的代碼:
這是一個純前端項目。
類似的,也有 React Playground。
那它是怎麼實現的呢?我們自己能實現一個麼?
可以的,今天我們來分析下實現思路。
首先是編譯:
編譯用的 @babel/standalone,這個是 babel 的瀏覽器版本。
可以用它實時把 tsx 代碼編譯爲 js。
試一下:
npx create-vite
進入項目安裝 @babel/standalone 和它的 ts 類型:
npm install
npm i --save @babel/standalone
npm i --save-dev @types/babel__standalone
去掉 index.css 和 StrictMode:
改下 App.tsx
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。
在 public 目錄下添加 test.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta >
<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>
瀏覽器訪問下:
這裏用的就是 blob url:
我們可以把一段 JS 代碼,用 URL.createObjectURL 和 new Blob 的方式變爲一個 url:
URL.createObjectURL(new Blob([code], { type: 'application/javascript' }))
那接下來的問題就簡單了,左側寫的所有代碼都是有文件名的。
我們只需要根據文件名替換下 import 的 url 就好了。
比如 App.tsx 引入了 ./Aaa.tsx
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 就行了。
比如這樣寫:
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/core 包的類型,安裝下:
npm i --save-dev @types/babel__core
我們用 babel 插件的方式對 import 的 source 做了替換。
把 ImportDeclaration 的 soure 的值改爲了 blob url。
這樣,瀏覽器裏就能直接跑這段代碼。
那如果是引入 react 和 react-dom 的包呢?這些也不是在左側寫的代碼呀
這種可以用 import maps 的機制:
在 public 下新建 test2.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta >
<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 這種模塊我們就都清楚了。
分別用 Blob + URL.createBlobURL 和 import maps + esm.sh 來做。
那編輯器部分如何做呢?
這個用 @monaco-editor/react
安裝下:
npm install @monaco-editor/react
試一下:
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 有很多參數,等用到的時候再展開看。
接下來看下預覽部分:
這部分就是 iframe,然後加一個通信機制,左邊編輯器的結果,編譯之後傳到 iframe 裏渲染就好了。
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:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta />
<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 就好了。
至此,從編輯器到編譯到預覽的流程就理清了。
案例代碼上傳了 react 小冊倉庫。
總結
我們分析了下 react playground 的實現思路。
編輯器部分用 @monaco-editor/react 實現,然後用 @babel/standalone 在瀏覽器裏編譯。
編譯過程中用自己寫的 babel 插件實現 import 的 source 的修改,變爲 URL.createObjectURL + Blob 生成的 blob url,把模塊內容內聯進去。
對於 react、react-dom 這種包,用 import maps 配合 esm.sh 網站來引入。
然後用 iframe 預覽生成的內容,url 同樣是把內容內聯到 src 裏,生成 blob url。
這樣,react playground 整個流程的思路就理清了。
什麼?光思路不過癮,你想實現一個完整版?
這是我小冊 《React 通關祕籍》的一個項目,感興趣的話可以上車一起做。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/VlHWy_3mSOUkSzVsZYIemA