一、前言
最近AI在线生成前端代码特别火,很多公司的PD都学会使用这类平台开始生成原型图了。例如blot.new、1D等一系列P2C、D2C的场景。
但这些应用都有一个特性,在页面上脱离不开几个核心组件:
- 文件列表+代码编辑器;
- 预览的页面;
- 提示词对话框;
那这里核心的问题是,在浏览器怎么跑node从而生成本地前端服务呢?这里核心是依赖@webcontainer/api
二、@webcontainer/api
简单介绍一下:
- 由 StackBlitz 开发
- 运行在浏览器中的 WebContainers 技术
- 它让浏览器拥有一个原生 Node.js 运行时容器
- 可以直接在浏览器内执行
npm install
、跑 Express、Vite、Next.js 等服务 - 不需要后端服务器,全部计算和运行都在本地浏览器完成
- 非常适合 在线 IDE (CodeSandbox、StackBlitz)、AI 生成代码直接运行 、文档里的可运行示例
简而言之就是你在电脑终端能做的事情,基本都能在@webcontainer/api中实现,它在页面中提供了一个类似终端的容器给你。
三、0~1实现一个雏形应用
3.1、新建前端应用
新建一个前端项目,用于演示交互。
bash
# 新建目录
mkdir ai-webcontainer-demo
cd ai-webcontainer-demo
# 初始化 package.json
npm init -y
# 安装依赖
npm install react react-dom @webcontainer/api cross-fetch @monaco-editor/react
npm install webpack webpack-cli webpack-dev-server ts-loader typescript @types/react @types/react-dom --save-dev
3.2、新建webpack.config.js
js
const path = require("path");
module.exports = {
mode: "development",
entry: "./src/index.tsx",
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.js",
publicPath: "/",
},
resolve: {
extensions: [".ts", ".tsx", ".js"],
},
module: {
rules: [{ test: /\.tsx?$/, loader: "ts-loader", exclude: /node_modules/ }],
},
devServer: {
static: {
directory: path.join(__dirname, "public"),
},
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
hot: true,
port: 3000,
},
};
3.3、新建tsconfig.json
js
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"jsx": "react-jsx",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
3.4、新建src/index.tsx
ts
import { createRoot } from "react-dom/client";
import App from "./App";
const container = document.getElementById("root")!;
const root = createRoot(container);
root.render(<App />);
3.6、新建files.ts
ts
export const files = {
"package.json": {
file: {
contents: `
{
"name": "vite-react-hello",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "latest",
"vite": "latest"
}
}
`.trim(),
},
},
"vite.config.js": {
file: {
contents: `
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173
}
});
`.trim(),
},
},
"index.html": {
file: {
contents: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + WebContainer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
`.trim(),
},
},
src: {
directory: {
"main.jsx": {
file: {
contents: `
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
`.trim(),
},
},
"App.jsx": {
file: {
contents: `
import React, { useState } from 'react';
import Hello from './components/Hello.jsx';
import Counter from './components/Counter.jsx';
export default function App() {
const [showCounter, setShowCounter] = useState(true);
return (
<div className="app-container">
<h1>🚀 Hello from Vite + React in WebContainer!</h1>
<Hello name="WebContainer User" />
<button onClick={() => setShowCounter(!showCounter)}>
{showCounter ? 'Hide' : 'Show'} Counter
</button>
{showCounter && <Counter />}
</div>
);
}
`.trim(),
},
},
components: {
directory: {
"Hello.jsx": {
file: {
contents: `
import React from 'react';
export default function Hello({ name }) {
return <p className="hello">Hello, {name}! 👋</p>;
}
`.trim(),
},
},
"Counter.jsx": {
file: {
contents: `
import React, { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div className="counter">
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
`.trim(),
},
},
},
},
"index.css": {
file: {
contents: `
body {
font-family: sans-serif;
margin: 0;
padding: 0;
background: #f6f6f6;
}
.app-container {
padding: 2rem;
}
button {
margin: 0.25rem;
padding: 0.5rem 1rem;
}
.hello {
color: #0070f3;
}
.counter {
margin-top: 1rem;
}
`.trim(),
},
},
},
},
};
3.5、新建App.tsx
这是页面组件,包含了最早说的几个部分。
代码编辑器基于@monaco-editor/react
实现,文件列表基于webcontainer
初始化文件后读取实现。
预览的页面基于webcontainer
跑完vite dev server
后返回容器内存虚拟链接,然后在iframe中渲染实现。
提示词对话框很简单,就暂时用了原生的textarea
。
代码如下:
ts
import { Editor } from "@monaco-editor/react";
import { WebContainer } from "@webcontainer/api";
import React, { useRef, useState } from "react";
import { files } from "./files"; // 这里可以换成 AI 动态生成
const App: React.FC = () => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const webcontainerRef = useRef<any>(null);
const [loading, setLoading] = useState(false);
const [prompt, setPrompt] = useState("创建一个最简单的vite+vanilla js项目");
const [iframeSrc, setIframeSrc] = useState("");
// 文件列表和编辑
const [fileList, setFileList] = useState<string[]>([]);
const [selectedFile, setSelectedFile] = useState<string>("");
const [fileContent, setFileContent] = useState<string>("");
/** 安装依赖 */
async function installDependencies() {
const installProcess = await webcontainerRef.current.spawn("npm", [
"install",
]);
installProcess.output.pipeTo(
new WritableStream({
write(data) {
console.log(data);
},
})
);
return installProcess.exit;
}
/** 启动 Dev Server */
async function startDevServer() {
await webcontainerRef.current.spawn("npm", ["run", "dev"]);
webcontainerRef.current.on("server-ready", (_port: any, url: any) => {
console.log("Server ready:", url);
setIframeSrc(url);
});
}
/** 从容器读取文件列表 */
async function loadFileList(dir: string = "/") {
const entries = await webcontainerRef.current.fs.readdir(dir, {
withFileTypes: true,
});
const files: string[] = [];
for (const entry of entries) {
if (entry.isFile()) {
files.push(entry.name);
}
if (entry.isDirectory()) {
const subEntries = await webcontainerRef.current.fs.readdir(
`${dir}${entry.name}`,
{ withFileTypes: true }
);
subEntries.forEach((se: any) => {
if (se.isFile()) {
files.push(`${entry.name}/${se.name}`);
}
});
}
}
setFileList(files);
}
/** 选择文件并读取内容 */
async function handleSelectFile(fileName: string) {
setSelectedFile(fileName);
const content = await webcontainerRef.current.fs.readFile(
"/" + fileName,
"utf-8"
);
setFileContent(content as string);
}
/** 保存文件并触发热更新(HMR) */
async function handleSaveFile() {
if (!selectedFile) return;
await webcontainerRef.current.fs.writeFile("/" + selectedFile, fileContent);
console.log(`${selectedFile} 已保存`);
}
/** 启动容器并运行项目 */
const handleGenerate = async () => {
setLoading(true);
if (!webcontainerRef.current) {
webcontainerRef.current = await WebContainer.boot();
}
// AI模式:把files换成AI返回的对象
await webcontainerRef.current.mount(files);
const exitCode = await installDependencies();
if (exitCode !== 0) {
throw new Error("Installation failed");
}
await startDevServer();
await loadFileList("/");
setLoading(false);
};
return (
<div style={{ display: "flex", height: "100vh" }}>
{/* 左侧:文件列表和编辑区 */}
<div
style={{
flex: "0 0 700px",
borderRight: "1px solid #ccc",
display: "flex",
flexDirection: "column",
}}
>
<div style={{ padding: 8 }}>
<h3>文件列表</h3>
{fileList.map((name) => (
<div
key={name}
style={{
cursor: "pointer",
background: name === selectedFile ? "#eee" : "transparent",
padding: "4px 8px",
}}
onClick={() => handleSelectFile(name)}
>
{name}
</div>
))}
</div>
{selectedFile && (
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
<div
style={{
fontWeight: "bold",
borderTop: "1px solid #ccc",
padding: 4,
}}
>
正在编辑: {selectedFile}
</div>
<Editor
height="calc(100% - 40px)" // 编辑器高度
defaultLanguage={
selectedFile.endsWith(".js") ? "javascript" : "plaintext"
}
value={fileContent}
theme="vs-dark" // 主题,可换成 "light"
onChange={(value) => setFileContent(value ?? "")}
options={{
fontSize: 14,
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
/>
<button onClick={handleSaveFile} style={{ padding: 8 }}>
保存
</button>
</div>
)}
</div>
{/* 右侧:启动按钮 + iframe 预览 */}
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
<div style={{ padding: 8, borderBottom: "1px solid #ccc" }}>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
style={{ width: "70%", height: "40px" }}
/>
<button onClick={handleGenerate} disabled={loading}>
{loading ? "生成中..." : "启动项目"}
</button>
</div>
<div style={{ flex: 1 }}>
<iframe
src={iframeSrc}
ref={iframeRef}
style={{ width: "100%", height: "100%", border: "none" }}
/>
</div>
</div>
</div>
);
};
export default App;
这里有几个关键点。
- 当用户点击生成项目时,会先初始化webcontainer容器,对应
WebContainer.boot()
。 - 将项目所有文件注入到
webcontainer
中(真实应用这里应该先从AI的响应取,再序列化组装),这里作为demo直接mock了一份webcontainer
格式的files,然后调用await webcontainerRef.current.mount(files)
。 - 执行
installDependencies
容器安装依赖。 - 执行
startDevServer
运行vite dev server。 - 接收虚拟内存链接,在demo iframe中渲染。
效果:

四、未来的思考
本demo其实没有涉及到AI交互的部分,实现了核心的文件交互+项目预览+容器渲染,如果加了AI,这个Demo还需要做哪些事情?
- AI部分,基于prompt约束大模型返回的files遵循
webcontainer
的要求结构,在启动项目之前,注入AI响应的交互和技术链路; - 序列化组装files,后续和本demo的交互保持一致。
也就是说,只需要接入AI,这就是个mini blot.new
,是否感觉并没有很复杂呢?如果本文给你带来灵感,欢迎在评论区讨论。