
在瀏覽器裡跑 Node.js:認識 WebContainers
這篇整理自 2026 年 3 月在前端讀書會的分享,投影片是公開的。
為了這次分享,我照著 WebContainers 官方教學做了一個極簡的瀏覽器內 IDE:左邊編輯 Express 的程式碼,右邊即時預覽,下面是終端機。可以直接打開 demo 試試看,原始碼在 GitHub。
什麼是 WebContainers
WebContainers 是 StackBlitz 開發的瀏覽器執行環境,可以直接在瀏覽器分頁裡執行 Node.js、npm 和 shell 指令,不需要後端伺服器。StackBlitz.com 就是用它做的。
它能做到的事情包括:
- 執行 Node.js,支援
npm install、ESM 和 CommonJS - 虛擬檔案系統,可以掛載、讀寫和監聽檔案
- 類 POSIX 的 shell(jsh),輸入輸出都是串流
- 在容器裡啟動的伺服器,會透過 Service Worker 拿到一個代理網址,外層頁面可以直接存取
在 demo 的終端機裡,可以像在本機一樣操作:
$ node --version
$ npm install express
$ node index.js
傳統做法的問題
要讓使用者在網頁上執行程式碼,傳統做法是在雲端開一台 VM 當沙盒。這可行,但有幾個代價:
- 延遲:每個操作都要經過網路往返。
- 成本:伺服器要隨使用者數量擴充,很貴。
- 安全:使用者的程式碼跑在共用的伺服器上。
- 啟動慢:開一台 VM 要好幾秒。
WebContainers 把這些事情都搬到使用者的瀏覽器裡:運算在本機完成,沒有網路延遲;不需要伺服器,靜態檔案透過 CDN 發送就好;執行環境關在瀏覽器的沙盒裡;啟動只要幾毫秒,不必等 VM。
這套做法已經被大規模使用,除了 StackBlitz 本身,Svelte、Angular、Nuxt 的互動式官方教學也都是用 WebContainers 做的。
底層架構
WebContainers 建立在四項瀏覽器技術之上。
每個 process 各有一個 Web Worker,共用同一塊記憶體;網路請求由 Service Worker 處理,見後文
WebAssembly
Node.js 的核心是用 C/C++ 寫的,瀏覽器只能執行 JavaScript 和 WebAssembly,沒辦法直接跑。WebContainers 的做法是把需要的部分編譯成 WebAssembly,Node.js 內建的 JavaScript 模組則直接交給瀏覽器本身的 V8 執行,不用在虛擬機裡再跑一個虛擬機。
open、fork 這類作業系統層級的呼叫,會被攔截下來,改用瀏覽器的 API 重新實作。
SharedArrayBuffer 與 Atomics
SharedArrayBuffer 是一塊可以在多個 Worker 之間共用的記憶體,每個 Worker 都能直接讀寫,不必透過 postMessage 傳訊息。
Atomics 在這之上提供同步機制:Atomics.wait() 讓 Worker 停下來等,Atomics.notify() 再把它叫醒。
Node.js 有很多同步 API,例如 fs.readFileSync,呼叫後要等結果回來才往下執行。瀏覽器裡的跨 Worker 溝通原本都是非同步的,靠這兩者搭配,WebContainers 才能模擬出同步、會阻塞的呼叫。
Service Worker
容器裡的 HTTP 伺服器並沒有真正的 TCP 連接埠,它跑在 Worker 裡。
當伺服器開始監聽某個 port,WebContainers 會替它產生一個專屬的 HTTPS 代理網址,例如 *.webcontainer-api.io。Service Worker 會攔截送往這個網址的請求,轉給容器裡的伺服器,再把回應傳回去。對 <iframe> 來說,就像在跟一台真的伺服器溝通。
Web Workers
瀏覽器的 JavaScript 預設只有一條主執行緒,任何阻塞的操作都會讓畫面卡住。
WebContainers 替每個 process 配一個獨立的 Web Worker,所以可以真的平行執行,例如一邊跑 dev server、一邊 npm install。另外,Atomics.wait() 不能在主執行緒呼叫,所以執行環境本身也一定要跑在 Worker 裡。
從啟動到預覽
用 @webcontainer/api 在瀏覽器裡跑起一台伺服器,有四個步驟:
- Boot:
WebContainer.boot()初始化執行環境,每次載入頁面只呼叫一次。 - Mount:
container.mount(tree)把專案的檔案寫進虛擬檔案系統。 - Spawn:
container.spawn('jsh')啟動 shell 或 Node.js process,輸入輸出以串流傳遞。 - Preview:伺服器開始監聽後,會觸發
server-ready事件,拿到可以放進<iframe>的網址。
虛擬檔案系統
檔案系統存在執行環境的記憶體裡,外層頁面的 JavaScript 和容器裡的 Node.js process 共用同一份,完全不會碰到使用者的硬碟。
- 任何一邊寫入,另一邊馬上看得到
- 任何一邊改動,都會觸發 file watcher
- 只存在記憶體裡,關掉分頁就消失
外層頁面和容器裡的 process 讀寫的是同一份檔案
外層頁面透過 container.fs 操作檔案:
| API | 用途 |
|---|---|
container.mount(tree) |
用 FileSystemTree 物件初始化整個檔案系統 |
container.fs.readFile(path) |
讀檔,回傳 Uint8Array 或字串 |
container.fs.writeFile(path, data) |
寫入或覆寫檔案 |
container.fs.mkdir(path) |
建立目錄 |
container.fs.rm(path) |
刪除檔案或目錄 |
container.fs.watch(path, cb) |
監聽變更,用法和 Node.js 的 fs.watch 一樣 |
要掛載的檔案有三種來源:
// 1. 直接寫在程式裡
await container.mount({ 'index.js': { file: { contents: src } } });
// 2. 從 API 取得,伺服器回傳 FileSystemTree 格式的 JSON
const tree = await fetch('/api/project').then((r) => r.json());
await container.mount(tree);
// 3. 二進位快照(官方推薦)
// 伺服器端:res.send(await snapshot('./my-project')),snapshot 來自 @webcontainer/snapshot
const buf = await fetch('/snapshot').then((r) => r.arrayBuffer());
await container.mount(buf);
Process 與終端機
spawn() 會在容器裡啟動一個 process,它的輸入輸出以 Web Streams 的形式傳回頁面。spawn() 回傳的物件有這些屬性和方法:
| API | 用途 |
|---|---|
output |
stdout 和 stderr 的 ReadableStream |
input.getWriter() |
寫入 stdin 的 WritableStream writer |
exit |
process 結束時 resolve,值是結束代碼 |
kill() |
終止 process |
resize({ cols, rows }) |
更新終端機的尺寸 |
demo 用 xterm.js 當終端機介面,把 shell 的輸出接到畫面,再把鍵盤輸入接回 stdin:
const shell = await container.spawn('jsh', {
terminal: { cols, rows },
});
// shell 的輸出 → xterm.js
shell.output.pipeTo(
new WritableStream({
write(data) {
terminal.write(data);
},
}),
);
// xterm.js 的鍵盤輸入 → shell 的 stdin
const input = shell.input.getWriter();
terminal.onData((data) => {
input.write(data);
});
預覽伺服器
容器裡的 process 開始監聽某個 port 時,會觸發 server-ready 事件,並帶上代理網址。把網址放進 <iframe>,就能在同一頁即時預覽:
container.on('server-ready', (port, url) => {
iframe.src = url;
});
server-ready 帶回代理網址,iframe 對它發出的請求由 Service Worker 攔截、轉給容器裡的伺服器
可以同時監聽多個 port,每個 port 都有自己的代理網址。
跨來源隔離:一定要設定的 header
WebContainers 依賴 SharedArrayBuffer,但這個 API 不是隨時都能用。
2018 年 Spectre 和 Meltdown 漏洞公開後,瀏覽器為了防範旁路攻擊(side-channel attack),預設停用了 SharedArrayBuffer。網頁要先進入 cross-origin isolated 狀態,瀏覽器才會重新開放。做法是在回應加上這兩個 header:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
少了這兩個 header,WebContainer.boot() 會直接拋出錯誤。我部署 demo 到 Vercel 時就遇到這個問題,本機的 Vite 有設定,部署環境卻忘了加。
要注意的是,設定 COEP 後,所有跨來源的子資源,像是圖片、字型、script,都必須在回應中帶上 Cross-Origin-Resource-Policy: cross-origin,否則會被擋下來。沒有這個 header 的 CDN 資源都會載入失敗。
COOP、COEP 和 CORP 的來龍去脈,在〈從 CORS 淺談瀏覽器安全模型〉有更多說明。
可以用在哪裡
凡是需要隔離、免安裝的 Node.js 執行環境,都可以考慮 WebContainers:
- 線上 IDE:不需要後端,每個分頁都是獨立的執行環境。
- 互動式文件:在文件裡嵌入可以執行的範例,讀者不用離開頁面就能修改、執行。
- 程式碼沙盒:程式競賽、技術面試,支援真正的 npm。
- 可分享的 demo:一個網址就是一個正在執行的應用程式,不需要部署流程,也沒有伺服器費用。
- AI coding agent:讓 LLM 在沙盒裡寫程式、執行、再修改。
- 瀏覽器端的 CI:在瀏覽器裡跑測試和建置,不用排隊等 CI 伺服器。