前端在線(xiàn)代碼編輯器技術(shù)雜談
01、背景
這段時(shí)間在搞一個(gè)出碼項(xiàng)目,出碼后支持編輯代碼并可以在右側(cè)實(shí)時(shí)預(yù)覽,其ui如下,在調(diào)研了幾個(gè)開(kāi)源(react-playground,react-live,minisandbox)的在線(xiàn)編輯運(yùn)行react代碼的庫(kù)后,把所得所想分享給大家。
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 編輯器部分
開(kāi)源的使用最多的編輯器:Monaco Editor、Ace 和 Code Mirror。
Monaco Editor生態(tài)豐富功能強(qiáng)大,還是vscode同款編輯器,帶代碼提示等功能,開(kāi)發(fā)友好。
react-playgound是用的就是 @monaco-editor/react
<MonacoEditor
width='600px'
height='800px'
notallow={(newCode) => setCode(newCode)}
defaultValue={code}
defaultLanguage='javascript'
/>
(比較簡(jiǎn)單,也不是本次講解重點(diǎn))
2.2 代碼預(yù)覽部分
項(xiàng)目中如果有全局樣式,全局變量,會(huì)影響到實(shí)時(shí)代碼運(yùn)行效果,所以運(yùn)行時(shí)需要一個(gè)干凈的環(huán)境去運(yùn)行代碼,react-playground 選用的方案是iframe。
- 瀏覽器運(yùn)行jsx
瀏覽器不認(rèn)識(shí)jsx,所以我們需要把jsx轉(zhuǎn)為js,這個(gè)轉(zhuǎn)換通常是用babel實(shí)現(xiàn)的,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)
經(jīng)過(guò)編譯,我們的代碼會(huì)變成
- 代碼編譯的任務(wù)交給誰(shuí)處理
交給主線(xiàn)程?可能會(huì)導(dǎo)致頁(yè)面阻塞,編輯時(shí)感到卡頓,所以好的解決方案是 新開(kāi)一個(gè)web worker用于處理代碼編譯操作。
// compiler.worker.ts
self.addEventListener('message', async ({ data }) => {
// 2. 接收到源代碼后編譯
const compiledCode = babelTransform(jsxCode)
//3. 編譯完成后,發(fā)送數(shù)據(jù)給index.tsx
self.postMessage(compiledCode)
})
// index.tsx
compilerRef.current = new CompilerWorker()
useEffect(()=>{
// 1. 源代碼變更后,發(fā)送給worker去編譯
compilerRef.current?.postMessage(jsxCode)
},[jsxCode])
compilerRef.current.addEventListener('message', ({ data }) => {
// 4. 接收到web worker編譯后的代碼,發(fā)送到iframe中
iframeRef.current?.contentWindow?.postMessage(data)
})
// iframe.html
window.addEventListener('message', ({data}) => {
// 6. 接收到編譯后的代碼執(zhí)行
// 代碼插入script標(biāo)簽中或者轉(zhuǎn)為臨時(shí)文件地址賦值給script標(biāo)簽
})
支持第三方模塊包引入
由于瀏覽器原生支持esm,我們選擇使用esm格式的第三方依賴(lài)包。有一些非esm的包,可以通過(guò)esm.sh 找到其對(duì)應(yīng)的的esm格式文件地址。
然后通過(guò)importmap 映射模塊地址。
importmap 允許您在瀏覽器環(huán)境中指定模塊路徑映射,import "react" 時(shí),會(huì)按importmap 中指定的 URL 來(lái)加載 react 模塊。
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.2.0",
"react-dom/client": "https://esm.sh/react-dom@18.2.0",
}
}
</script>
引入本地模塊
我們?cè)诒镜仨?xiàng)目中執(zhí)行這么一行代碼 import a from './A',這樣的導(dǎo)入語(yǔ)句通常由構(gòu)建工具和模塊打包器處理的(如webpack,vite等)
- esm 模塊
依賴(lài)編譯時(shí)生成的樹(shù)形結(jié)構(gòu),尋找到對(duì)應(yīng)路徑。會(huì)以此尋找A.js,A.jsx,A.ts… 。然后打包到一個(gè)文件或獨(dú)立文件中。
- commonjs模塊
Node.js 提供了一個(gè)文件系統(tǒng)模塊,它允許你在服務(wù)器端環(huán)境中訪(fǎng)問(wèn)和操作本地文件系統(tǒng)。
那么在瀏覽器中,我們?nèi)绾谓馕?import a from './A' 對(duì)應(yīng)的模塊?
瀏覽器絕大部分支持esm模塊,支持的是URL文件,所以我們可以把'./A'替換成url地址,瀏覽器就可以使用了
import a from './A' --> import a from 'http://xxx/A'
這由于我們沒(méi)有一個(gè)服務(wù)來(lái)提供文件,可以使用 URL.createObjectURL 生成臨時(shí)URL文件。
// 把代碼編譯后轉(zhuǎn)換成url文件
const A = URL.createObjectURL(
new Blob([babelTransform(code)], {
type: 'application/javascript'
})
)
// 轉(zhuǎn)換后的地址
// blob:https://localhost:3000/e4ef352f-1c5f-414e-8009-33514b300842
// 替換 './A'
import a from 'blob:https://localhost:3000/e4ef352f-1c5f-414e-8009-33514b300842'
按照這個(gè)思路,在引入本次文件時(shí),我們只要分析import語(yǔ)句,把對(duì)應(yīng)模塊代碼在編譯時(shí)替換為本地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就對(duì)應(yīng)ast樹(shù)的如下值
至此,react-playground 的原理已經(jīng)分析清楚了。分別用 Blob + URL.createBlobURL 和 import maps 來(lái)做。
引入樣式文件
(() => {
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
我們?cè)賮?lái)看下react-live方案,在github上4.2k star
相比 react-playground,有兩點(diǎn)不同:
1)編譯后的js代碼,不是通過(guò)script標(biāo)簽插入的形式做的,而是通過(guò)new Function/eval 的方式變?yōu)榭蓤?zhí)行代碼。
2)依賴(lài)的處理不同。
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、
// 刪除末尾分號(hào),因?yàn)橄逻厱?huì)在 code 外包裝一個(gè) return (code) 的操作,有分號(hào)會(huì)導(dǎo)致語(yǔ)法錯(cuò)誤
const codeTrimmed = code.trim().replace(/;$/, '')
const opts = { transforms: ['jsx', 'imports'] as Transform[] }
// 前邊補(bǔ)上一個(gè) return,以便下邊 evalCode 后能正確拿到生成的組件
const transformed = transform(`return (${codeTrimmed})`, opts).code.trim()
// 編譯后只是一個(gè)字符串,我們通過(guò) evalCode 函數(shù)將它變成可執(zhí)行代碼
return evalCode(transformed, { React, ...scope })
}
重點(diǎn)說(shuō)下,依賴(lài)的處理
1)import-map方案
- 優(yōu)點(diǎn):簡(jiǎn)單易用。
- 缺點(diǎn):
- 只支持esm模塊地址;
- 如果一個(gè)A包內(nèi)部引了B包,只在importmap中定義A包的映射地址,在實(shí)際運(yùn)行A包邏輯時(shí),會(huì)報(bào)Uncaught TypeError: Failed to resolve module specifier “B” 這樣的錯(cuò)誤,所以 對(duì)于依賴(lài)復(fù)雜的包,使用importmap方案不夠友好,需要分析依賴(lài),然后每個(gè)都在importmap定義映射。
2)scope 方案
實(shí)際傳入的是一個(gè)上下文對(duì)象,它定義了當(dāng)前代碼可以訪(fǎng)問(wèn)的所有外部資源。react-live 會(huì)將代碼中的標(biāo)識(shí)符與 scope 進(jìn)行匹配,以找到這些標(biāo)識(shí)符的定義。
- 優(yōu)點(diǎn):直接提供變量和組件,簡(jiǎn)化了依賴(lài)管理
- 缺點(diǎn):不能自動(dòng)解析復(fù)雜的模塊依賴(lài),依賴(lài)關(guān)系需要手動(dòng)管理。
介紹和對(duì)比了react-playground和react-live兩種方案,他們主要的使用場(chǎng)景在 「代碼片段實(shí)時(shí)預(yù)覽」 。如組件文檔實(shí)例、在線(xiàn)調(diào)試代碼等。那有沒(méi)有更強(qiáng)大的前端編輯器和實(shí)時(shí)預(yù)覽方案,答案是肯定的。
比如codesandbox、stackblitz。
04、codesandbox
codesandbox 開(kāi)源了 @codesandbox/sandpack-react庫(kù),這個(gè)React庫(kù)提供了很多開(kāi)箱即用的codesandbox模塊。
對(duì)應(yīng)codesanbox的面板來(lái)看,分別是以下組件
各個(gè)組件通過(guò)postMessage與SandackPreview渲染的iframe交互。
codesandbox的兩種運(yùn)行環(huán)境:
1)純前端項(xiàng)目(比如React項(xiàng)目、純JS項(xiàng)目)使用Browser Sandbox
2)需要服務(wù)端運(yùn)行環(huán)境(比如Docker項(xiàng)目、全??蚣茼?xiàng)目)使用Cloud Sandbox(他底層使用的是MicroVM)
對(duì)于browser sandbox來(lái)說(shuō),由于瀏覽器端并沒(méi)有 Node 環(huán)境,所以 CodeSandbox 自己實(shí)現(xiàn)了一個(gè)可以跑在瀏覽器端的簡(jiǎn)化版 webpack。稱(chēng)為 mini webpack。
4.1 原理
關(guān)于codesandbox的原理,文章有很多。我這里不重點(diǎn)解釋了
CodeSandbox 構(gòu)建項(xiàng)目過(guò)程
構(gòu)建過(guò)程主要包括了三個(gè)步驟:
- Packager--npm 包加載階段:下載 npm 包并遞歸查找所有引用到的文件,然后提供給下個(gè)階段進(jìn)行編譯
- Transpilation--編譯階段:編譯所有代碼, 構(gòu)建模塊依賴(lài)圖
- Evaluation--執(zhí)行階段:使用 eval 運(yùn)行編譯后的代碼,實(shí)現(xiàn)項(xiàng)目預(yù)覽
Packager--npm包加載階段
codesandbox受WebpackDllPlugin啟發(fā)。DllPlugin 會(huì)將所有依賴(lài)都打包到一個(gè)dll文件中(存儲(chǔ)預(yù)打包模塊),并創(chuàng)建一個(gè) manifest 文件來(lái)描述dll的元數(shù)據(jù)(描述模塊映射)。
{
"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,
"..."
每一個(gè)路徑都映射一個(gè)模塊id。如果我想引入 React,我只需要調(diào)用 dll_bundle(3),然后我就得到了React。
基于這個(gè)思想, CodeSandbox 構(gòu)建了自己的在線(xiàn)打包服務(wù), 和WebpackDllPlugin不一樣的是,CodeSandbox是在服務(wù)端預(yù)先構(gòu)建Manifest文件的。
這個(gè)包叫,packager ,是基于 express框架提供的服務(wù),其流程是,比如我現(xiàn)在有一個(gè) react包16.8.0版本,首先 express 框架接收到請(qǐng)求中的包名以及包版本, react、16.8.0。然后通過(guò) yarn 下載 react 以及 react 的依賴(lài)包到磁盤(pán)上,通過(guò)讀取 npm 包的 package.json 文件中的 module、main 等字段找到 npm 包入口文件,然后解析 AST 中所有的 require 語(yǔ)句,將被 require 的文件內(nèi)容添加到 manifest 文件中,并且遞歸執(zhí)行剛才的步驟,最終形成依賴(lài)圖。
這樣就實(shí)現(xiàn)將 npm 包文件內(nèi)容轉(zhuǎn)移到 manifest.json 上的目的,同時(shí)也實(shí)現(xiàn)了剔除 npm 模塊中多余的文件的目的。最后返回給 Sandbox 進(jìn)行編譯。
manifest 長(zhǎng)這個(gè)樣子:
Transpilation--編譯階段
先從 Packager 服務(wù)下載 npm 依賴(lài)包對(duì)應(yīng)的 manifest 文件,接著前端項(xiàng)目的入口文件開(kāi)始對(duì)項(xiàng)目進(jìn)行編譯,并解析 AST 遞歸編譯被 require 的文件,形成依賴(lài)圖。原理同webpack。
Evaluation--執(zhí)行階段
項(xiàng)目入口文件對(duì)應(yīng)的編譯后的模塊開(kāi)始,遞歸調(diào)用 eval 執(zhí)行所有被引用到的模塊。
總結(jié)一下:Browser Sandbox 頁(yè)面通過(guò)內(nèi)置的mini webpack與其他工具(比如babel),編譯并執(zhí)行代碼。
代碼編譯、執(zhí)行的信息也會(huì)通過(guò)通信協(xié)議傳遞回各個(gè)需要的模塊。比如,控制臺(tái)模塊可以根據(jù)type為console的信息打印消息。
05、stackblitz
stackblitz 核心技術(shù)是webcontainers。
5.1 什么是webcontainers?
WebContainer 是由 StackBlitz 團(tuán)隊(duì)開(kāi)發(fā)的一項(xiàng)革命性技術(shù),它允許我們?cè)跒g覽器中運(yùn)行完整的 Node.js 環(huán)境。這使得我們可以在瀏覽器中運(yùn)行現(xiàn)代 JavaScript 開(kāi)發(fā)工具,如 Webpack、Vite 和各種 npm 包,而無(wú)需安裝任何本地依賴(lài)。
以前,我們?nèi)绻胍跒g覽器內(nèi)實(shí)現(xiàn)本地化,主流想法是以electron為例,把瀏覽器的內(nèi)核引擎與node環(huán)境進(jìn)行搭配,實(shí)現(xiàn)了web應(yīng)用的本地化,但是隨著wasm的技術(shù)的發(fā)展,瀏覽器的運(yùn)算能力已經(jīng)能夠比肩系統(tǒng)級(jí),因此,也就能夠支持起來(lái)將node移植到瀏覽器內(nèi)的可行性。這種技術(shù) 就是webcontainers。
以前需要云虛擬機(jī)來(lái)執(zhí)行用戶(hù)代碼的應(yīng)用程序,現(xiàn)在在 WebContainers 中可以完全在瀏覽器中運(yùn)行。
簡(jiǎn)而言之:webContainer 就是一個(gè)可以運(yùn)行在瀏覽器頁(yè)面中的微型操作系統(tǒng),提供了文件系統(tǒng)、運(yùn)行進(jìn)程的能力,同時(shí)內(nèi)置了 nodejs、npm/yarn/pnpm 等包管理器。
主要特性:
- 能夠在瀏覽器中運(yùn)行 node.js 及其工具鏈(如:webpack、vite 等)
- 靈活:在 WebContainers 支持下,編碼體驗(yàn)將會(huì)大幅提升
- 安全:所有內(nèi)容都運(yùn)行在瀏覽器頁(yè)面中,天然隔離,非常安全
- 快速:毫秒級(jí)啟動(dòng)整個(gè)開(kāi)發(fā)環(huán)境。
- 始終開(kāi)源免費(fèi)
- 零延時(shí)。離線(xiàn)工作。
熱更新從代碼編寫(xiě),到編譯打包,完全在瀏覽器中閉環(huán),只要打開(kāi)一個(gè)瀏覽器就完成所有的動(dòng)作。
5.2 了解 WebAssembly
WebAssembly 是一種運(yùn)行在現(xiàn)代網(wǎng)絡(luò)瀏覽器中的新型代碼,并且提供新的性能特性和效果。它設(shè)計(jì)的目的不是為了手寫(xiě)代碼而是為諸如 C、C++和 Rust 等低級(jí)源語(yǔ)言提供一個(gè)高效的編譯目標(biāo)。
WebAssembly 是一門(mén)低級(jí)的類(lèi)匯編語(yǔ)言。它有一種緊湊的二進(jìn)制格式,使其能夠以接近原生性能的速度運(yùn)行。
WebAssembly 是 WebContainers 能夠在瀏覽器中運(yùn)行的核心。通過(guò)將 Node.js 編譯為 WebAssembly,WebContainers 能夠在瀏覽器中提供一個(gè)完整的開(kāi)發(fā)環(huán)境,包括運(yùn)行 Node.js 代碼、安裝和管理 npm 包等功能。
5.3 案例:在瀏覽器中運(yùn)行一個(gè)簡(jiǎn)單的 Node.js 應(yīng)用
第一步:創(chuàng)建WebContainer實(shí)例,并啟動(dòng)
import { WebContainer } from '@webcontainer/api';
// 啟動(dòng) WebContainer 實(shí)例
const webContainerInstance = await WebContainer.boot();
第二步:創(chuàng)建文件系統(tǒng),并掛載到webcontainer實(shí)例上
await webContainerInstance.mount(projectFiles);
第三步:以編程方式下載依賴(lài)
const install = await webContainerInstance.spawn('npm', ['i']);
await install.exit;
第四步:?jiǎn)?dòng)服務(wù)
await webContainerInstance.spawn('npm', ['run', 'dev']);
這樣,我們就使用webcontainers開(kāi)發(fā)了一個(gè)簡(jiǎn)單的node應(yīng)用。
我們可以從 stackblitz體驗(yàn)webcontainers案例。
5.4 原理
一句話(huà)就是,在 service worker 中,借助 wasm 實(shí)現(xiàn)一個(gè) js runtime。
利用 WebAssembly 來(lái)實(shí)現(xiàn)一個(gè)瀏覽器環(huán)境中不存在的 API(如 Node.js 的 readFile),并將其功能注入到全局對(duì)象供 JavaScript 使用。
5.5 使用場(chǎng)景&支持度
1)webIDE,如stackblitz;
2)bug復(fù)現(xiàn)片段;
3、)實(shí)驗(yàn)功能,無(wú)需本地新啟一個(gè)項(xiàng)目,費(fèi)時(shí)費(fèi)力。
WebContainers 在基于 Chromium 的桌面瀏覽器上開(kāi)箱即用,在 Safari 16.4 和 Firefox 上也受支持。
參考鏈接
[01] React Playground在線(xiàn)Dome
[02] @babel/standalone( babel的瀏覽器版)
https://link.juejin.cn/?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40babel%2Fstandalone
[03] @codesandbox/sandpack-react庫(kù)
https://www.npmjs.com/package/@codesandbox/sandpack-react
[04] express
[05] mini webpack
https://juejin.cn/post/6844903880652750862?from=search-suggest
[06] 離線(xiàn)工作
[07] stackblitz
https://stackblitz.com/edit/stackblitz-webcontainer-api-starter-4vmxqn?file=main.js