0~1构建一个mini blot.new(无AI版本)
一、前言
最近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、新建前端应用
新建一个前端项目,用于演示交互。
# 新建目录
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
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
{"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
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
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
。
代码如下:
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" }}>{/* 左侧:文件列表和编辑区 */}<divstyle={{flex: "0 0 700px",borderRight: "1px solid #ccc",display: "flex",flexDirection: "column",}}><div style={{ padding: 8 }}><h3>文件列表</h3>{fileList.map((name) => (<divkey={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" }}><divstyle={{fontWeight: "bold",borderTop: "1px solid #ccc",padding: 4,}}>正在编辑: {selectedFile}</div><Editorheight="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" }}><textareavalue={prompt}onChange={(e) => setPrompt(e.target.value)}style={{ width: "70%", height: "40px" }}/><button onClick={handleGenerate} disabled={loading}>{loading ? "生成中..." : "启动项目"}</button></div><div style={{ flex: 1 }}><iframesrc={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
,是否感觉并没有很复杂呢?如果本文给你带来灵感,欢迎在评论区讨论。