01
背景
這段時間在搞一個出碼項目,出碼後支援編輯代碼并可以在右側實時預覽,其ui如下,在調研了幾個開源(react-playground,react-live,minisandbox)的線上編輯運作react代碼的庫後,把所得所想分享給大家。
02
react-playground
使用方式:
const files = {
'App.tsx': `import {title} from './const'
function App() {
return <h1>this is {title}</h1>
}
export default App
`,
'const.ts': {
code: 'export const title = "demo2"',
},
}
<PlaygroundSandbox
width={700}
height={400}
files={files}
theme='dark'
/>
2.1 編輯器部分
開源的使用最多的編輯器:Monaco Editor、Ace 和 Code Mirror。
Monaco Editor生态豐富功能強大,還是vscode同款編輯器,帶代碼提示等功能,開發友好。
react-playgound是用的就是 @monaco-editor/react
<MonacoEditor
width='600px'
height='800px'
onChange={(newCode) => setCode(newCode)}
defaultValue={code}
defaultLanguage='javascript'
/>
(比較簡單,也不是本次講解重點)
2.2 代碼預覽部分
項目中如果有全局樣式,全局變量,會影響到實時代碼運作效果,是以運作時需要一個幹淨的環境去運作代碼,react-playground 選用的方案是iframe。
- 浏覽器運作jsx
浏覽器不認識jsx,是以我們需要把jsx轉為js,這個轉換通常是用babel實作的,react-playground采用的是 @babel/standalone( babel的浏覽器版)
import { transform } from '@babel/standalone'
const babelTransform = (code: string) => {
return transform(code, {
presets: ['react', 'typescript'],
}).code
}
const compliedCode = babelTransform(jsxCode)
經過編譯,我們的代碼會變成
- 代碼編譯的任務交給誰處理
交給主線程?可能會導緻頁面阻塞,編輯時感到卡頓,是以好的解決方案是 新開一個web worker用于處理代碼編譯操作。
// compiler.worker.ts
self.addEventListener('message', async ({ data }) => {
// 2. 接收到源代碼後編譯
const compiledCode = babelTransform(jsxCode)
//3. 編譯完成後,發送資料給index.tsx
self.postMessage(compiledCode)
})
// index.tsx
compilerRef.current = new CompilerWorker()
useEffect(()=>{
// 1. 源代碼變更後,發送給worker去編譯
compilerRef.current?.postMessage(jsxCode)
},[jsxCode])
compilerRef.current.addEventListener('message', ({ data }) => {
// 4. 接收到web worker編譯後的代碼,發送到iframe中
iframeRef.current?.contentWindow?.postMessage(data)
})
// iframe.html
window.addEventListener('message', ({data}) => {
// 6. 接收到編譯後的代碼執行
// 代碼插入script标簽中或者轉為臨時檔案位址指派給script标簽
})
支援第三方子產品包引入
由于浏覽器原生支援esm,我們選擇使用esm格式的第三方依賴包。有一些非esm的包,可以通過esm.sh 找到其對應的的esm格式檔案位址。
然後通過importmap 映射子產品位址。
importmap 允許您在浏覽器環境中指定子產品路徑映射,import "react" 時,會按importmap 中指定的 URL 來加載 react 子產品。
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/[email protected]",
"react-dom/client": "https://esm.sh/[email protected]",
}
}
</script>
引入本地子產品
我們在本地項目中執行這麼一行代碼 import a from './A',這樣的導入語句通常由建構工具和子產品打包器處理的(如webpack,vite等)
- esm 子產品
依賴編譯時生成的樹形結構,尋找到對應路徑。會以此尋找A.js,A.jsx,A.ts… 。然後打包到一個檔案或獨立檔案中。
- commonjs子產品
Node.js 提供了一個檔案系統子產品,它允許你在伺服器端環境中通路和操作本地檔案系統。
那麼在浏覽器中,我們如何解析 import a from './A' 對應的子產品?
浏覽器絕大部分支援esm子產品,支援的是URL檔案,是以我們可以把'./A'替換成url位址,浏覽器就可以使用了
import a from './A' --> import a from 'http://xxx/A'
這由于我們沒有一個服務來提供檔案,可以使用 URL.createObjectURL 生成臨時URL檔案。
// 把代碼編譯後轉換成url檔案
const A = URL.createObjectURL(
new Blob([babelTransform(code)], {
type: 'application/javascript'
})
)
// 轉換後的位址
// blob:https://localhost:3000/e4ef352f-1c5f-414e-8009-33514b300842
// 替換 './A'
import a from 'blob:https://localhost:3000/e4ef352f-1c5f-414e-8009-33514b300842'
按照這個思路,在引入本次檔案時,我們隻要分析import語句,把對應子產品代碼在編譯時替換為本地URL位址。
怎麼做呢?用babel插件。
babel 插件就是在 transform 的階段增删改 AST 的:
const transformImportSourcePlugin: PluginObj = {
visitor: {
ImportDeclaration(path) {
path.node.source.value = url;
}
},
}
const res = transform(code, {
presets: ['react', 'typescript'],
filename: 'test.ts',
plugins: [transformImportSourcePlugin]
}
path.node.source.value就對應ast樹的如下值
至此,react-playground 的原理已經分析清楚了。分别用 Blob + URL.createBlobURL 和 import maps 來做。
引入樣式檔案
(() => {
let stylesheet = document.getElementById('style');
if (!stylesheet) {
stylesheet = document.createElement('style')
stylesheet.setAttribute('id', 'style')
document.head.appendChild(stylesheet)
}
const styles = document.createTextNode(`${css}`)
stylesheet.innerHTML = ''
stylesheet.appendChild(styles)
})()
03
react-live
我們再來看下react-live方案,在github上4.2k star
相比 react-playground,有兩點不同:
1)編譯後的js代碼,不是通過script标簽插入的形式做的,而是通過new Function/eval 的方式變為可執行代碼。
2)依賴的處理不同。
function evalCode(code: string, scope: Record<string, any>) {
const scopeKeys = Object.keys(scope)
const scopeValues = Object.values(scope)
// eslint-disable-next-line no-new-func
return new Function(...scopeKeys, code)(...scopeValues)
}
function generateNode(props) {
const { code = '', scope = {} } = props、
// 删除末尾分号,因為下邊會在 code 外包裝一個 return (code) 的操作,有分号會導緻文法錯誤
const codeTrimmed = code.trim().replace(/;$/, '')
const opts = { transforms: ['jsx', 'imports'] as Transform[] }
// 前邊補上一個 return,以便下邊 evalCode 後能正确拿到生成的元件
const transformed = transform(`return (${codeTrimmed})`, opts).code.trim()
// 編譯後隻是一個字元串,我們通過 evalCode 函數将它變成可執行代碼
return evalCode(transformed, { React, ...scope })
}
重點說下,依賴的處理
1)import-map方案
- 優點:簡單易用。
- 缺點:
- 隻支援esm子產品位址;
- 如果一個A包内部引了B包,隻在importmap中定義A包的映射位址,在實際運作A包邏輯時,會報Uncaught TypeError: Failed to resolve module specifier “B” 這樣的錯誤,是以 對于依賴複雜的包,使用importmap方案不夠友好,需要分析依賴,然後每個都在importmap定義映射。
2)scope 方案
實際傳入的是一個上下文對象,它定義了目前代碼可以通路的所有外部資源。react-live 會将代碼中的辨別符與 scope 進行比對,以找到這些辨別符的定義。
- 優點:直接提供變量群組件,簡化了依賴管理
- 缺點:不能自動解析複雜的子產品依賴,依賴關系需要手動管理。
介紹和對比了react-playground和react-live兩種方案,他們主要的使用場景在 「代碼片段實時預覽」 。如元件文檔執行個體、線上調試代碼等。那有沒有更強大的前端編輯器和實時預覽方案,答案是肯定的。
比如codesandbox、stackblitz。
04
codesandbox
codesandbox 開源了 @codesandbox/sandpack-react庫,這個React庫提供了很多開箱即用的codesandbox子產品。
對應codesanbox的面闆來看,分别是以下元件
各個元件通過postMessage與SandackPreview渲染的iframe互動。
codesandbox的兩種運作環境:
1)純前端項目(比如React項目、純JS項目)使用Browser Sandbox
2)需要服務端運作環境(比如Docker項目、全棧架構項目)使用Cloud Sandbox(他底層使用的是MicroVM)
對于browser sandbox來說,由于浏覽器端并沒有 Node 環境,是以 CodeSandbox 自己實作了一個可以跑在浏覽器端的簡化版 webpack。稱為 mini webpack。
4.1 原理
關于codesandbox的原理,文章有很多。我這裡不重點解釋了
CodeSandbox 建構項目過程
建構過程主要包括了三個步驟:
- Packager--npm 包加載階段:下載下傳 npm 包并遞歸查找所有引用到的檔案,然後提供給下個階段進行編譯
- Transpilation--編譯階段:編譯所有代碼, 構模組化塊依賴圖
- Evaluation--執行階段:使用 eval 運作編譯後的代碼,實作項目預覽
Packager--npm包加載階段
codesandbox受WebpackDllPlugin啟發。DllPlugin 會将所有依賴都打包到一個dll檔案中(存儲預打包子產品),并建立一個 manifest 檔案來描述dll的中繼資料(描述子產品映射)。
{
"name": "dll_bundle",
"content": {
"./node_modules/fbjs/lib/emptyFunction.js": 0,
"./node_modules/fbjs/lib/invariant.js": 1,
"./node_modules/fbjs/lib/warning.js": 2,
"./node_modules/fbjs/lib/react.development.js": 3,
"..."
每一個路徑都映射一個子產品id。如果我想引入 React,我隻需要調用 dll_bundle(3),然後我就得到了React。
基于這個思想, CodeSandbox 建構了自己的線上打包服務, 和WebpackDllPlugin不一樣的是,CodeSandbox是在服務端預先建構Manifest檔案的。
這個包叫,packager ,是基于 express架構提供的服務,其流程是,比如我現在有一個 react包16.8.0版本,首先 express 架構接收到請求中的包名以及包版本, react、16.8.0。然後通過 yarn 下載下傳 react 以及 react 的依賴包到磁盤上,通過讀取 npm 包的 package.json 檔案中的 module、main 等字段找到 npm 包入口檔案,然後解析 AST 中所有的 require 語句,将被 require 的檔案内容添加到 manifest 檔案中,并且遞歸執行剛才的步驟,最終形成依賴圖。
這樣就實作将 npm 封包件内容轉移到 manifest.json 上的目的,同時也實作了剔除 npm 子產品中多餘的檔案的目的。最後傳回給 Sandbox 進行編譯。
manifest 長這個樣子:
Transpilation--編譯階段
先從 Packager 服務下載下傳 npm 依賴包對應的 manifest 檔案,接着前端項目的入口檔案開始對項目進行編譯,并解析 AST 遞歸編譯被 require 的檔案,形成依賴圖。原理同webpack。
Evaluation--執行階段
項目入口檔案對應的編譯後的子產品開始,遞歸調用 eval 執行所有被引用到的子產品。
總結一下:Browser Sandbox 頁面通過内置的mini webpack與其他工具(比如babel),編譯并執行代碼。
代碼編譯、執行的資訊也會通過通信協定傳遞回各個需要的子產品。比如,控制台子產品可以根據type為console的資訊列印消息。
05
stackblitz
stackblitz 核心技術是webcontainers。
5.1 什麼是webcontainers?
WebContainer 是由 StackBlitz 團隊開發的一項革命性技術,它允許我們在浏覽器中運作完整的 Node.js 環境。這使得我們可以在浏覽器中運作現代 JavaScript 開發工具,如 Webpack、Vite 和各種 npm 包,而無需安裝任何本地依賴。
以前,我們如果想要在浏覽器内實作本地化,主流想法是以electron為例,把浏覽器的核心引擎與node環境進行搭配,實作了web應用的本地化,但是随着wasm的技術的發展,浏覽器的運算能力已經能夠比肩系統級,是以,也就能夠支援起來将node移植到浏覽器内的可行性。這種技術 就是webcontainers。
以前需要雲虛拟機來執行使用者代碼的應用程式,現在在 WebContainers 中可以完全在浏覽器中運作。
簡而言之:webContainer 就是一個可以運作在浏覽器頁面中的微型作業系統,提供了檔案系統、運作程序的能力,同時内置了 nodejs、npm/yarn/pnpm 等包管理器。
主要特性:
- 能夠在浏覽器中運作 node.js 及其工具鍊(如:webpack、vite 等)
- 靈活:在 WebContainers 支援下,編碼體驗将會大幅提升
- 安全:所有内容都運作在浏覽器頁面中,天然隔離,非常安全
- 快速:毫秒級啟動整個開發環境。
- 始終開源免費
- 零延時。離線工作。
熱更新從代碼編寫,到編譯打包,完全在浏覽器中閉環,隻要打開一個浏覽器就完成所有的動作。
5.2 了解 WebAssembly
WebAssembly 是一種運作在現代網絡浏覽器中的新型代碼,并且提供新的性能特性和效果。它設計的目的不是為了手寫代碼而是為諸如 C、C++和 Rust 等低級源語言提供一個高效的編譯目标。
WebAssembly 是一門低級的類彙編語言。它有一種緊湊的二進制格式,使其能夠以接近原生性能的速度運作。
WebAssembly 是 WebContainers 能夠在浏覽器中運作的核心。通過将 Node.js 編譯為 WebAssembly,WebContainers 能夠在浏覽器中提供一個完整的開發環境,包括運作 Node.js 代碼、安裝和管理 npm 包等功能。
5.3 案例:在浏覽器中運作一個簡單的 Node.js 應用
第一步:建立WebContainer執行個體,并啟動
import { WebContainer } from '@webcontainer/api';
// 啟動 WebContainer 執行個體
const webContainerInstance = await WebContainer.boot();
第二步:建立檔案系統,并挂載到webcontainer執行個體上
await webContainerInstance.mount(projectFiles);
第三步:以程式設計方式下載下傳依賴
const install = await webContainerInstance.spawn('npm', ['i']);
await install.exit;
第四步:啟動服務
await webContainerInstance.spawn('npm', ['run', 'dev']);
這樣,我們就使用webcontainers開發了一個簡單的node應用。
我們可以從 stackblitz體驗webcontainers案例。
5.4 原理
一句話就是,在 service worker 中,借助 wasm 實作一個 js runtime。
利用 WebAssembly 來實作一個浏覽器環境中不存在的 API(如 Node.js 的 readFile),并将其功能注入到全局對象供 JavaScript 使用。
5.5 使用場景&支援度
1)webIDE,如stackblitz;
2)bug複現片段;
3、)實驗功能,無需本地新啟一個項目,費時費力。
WebContainers 在基于 Chromium 的桌面浏覽器上開箱即用,在 Safari 16.4 和 Firefox 上也受支援。
參考連結
[01] React Playground線上Dome
https://fewismuch.github.io/react-playground/#eNqNVd1u2zYUfhVOQWEbi37sxmmmJUG7bsB60Q1oL3Yx74ImjyQ2FCmQlGPX8LvvkJJsOXWBQjZAfec7
[02] @babel/standalone( babel的浏覽器版)
https://link.juejin.cn/?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40babel%2Fstandalone
[03] @codesandbox/sandpack-react庫
https://www.npmjs.com/package/@codesandbox/sandpack-react
[04] express
https://expressjs.com/
[05] mini webpack
https://juejin.cn/post/6844903880652750862?from=search-suggest
[06] 離線工作
https://mmbiz.qpic.cn/mmbiz_gif/meG6Vo0MevhvJfkvEYBMuCrCvjbDn6d37PcBH4tcP4c62kOhUfLHLQSOD8lhv8HR7SQDF9KA2JoIzpJkuNqgUQ/640?wx_fmt=gif&tp=webp&wxfrom=5&wx_lazy=1
[07] stackblitz
https://stackblitz.com/edit/stackblitz-webcontainer-api-starter-4vmxqn?file=main.js
作者:若笙
來源-微信公衆号:阿裡技術
出處:https://mp.weixin.qq.com/s/VEV6RmOdZpAg7RBQzDmfUA