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