当前位置: 首页 > news >正文

0~1构建一个mini blot.new(无AI版本)

一、前言

最近AI在线生成前端代码特别火,很多公司的PD都学会使用这类平台开始生成原型图了。例如blot.new、1D等一系列P2C、D2C的场景。

但这些应用都有一个特性,在页面上脱离不开几个核心组件:

  1. 文件列表+代码编辑器;
  2. 预览的页面;
  3. 提示词对话框;

那这里核心的问题是,在浏览器怎么跑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;

这里有几个关键点。

  1. 当用户点击生成项目时,会先初始化webcontainer容器,对应WebContainer.boot()
  2. 将项目所有文件注入到webcontainer中(真实应用这里应该先从AI的响应取,再序列化组装),这里作为demo直接mock了一份webcontainer格式的files,然后调用await webcontainerRef.current.mount(files)
  3. 执行installDependencies容器安装依赖。
  4. 执行startDevServer运行vite dev server。
  5. 接收虚拟内存链接,在demo iframe中渲染。

效果:

在这里插入图片描述

四、未来的思考

本demo其实没有涉及到AI交互的部分,实现了核心的文件交互+项目预览+容器渲染,如果加了AI,这个Demo还需要做哪些事情?

  • AI部分,基于prompt约束大模型返回的files遵循webcontainer的要求结构,在启动项目之前,注入AI响应的交互和技术链路;
  • 序列化组装files,后续和本demo的交互保持一致。

也就是说,只需要接入AI,这就是个mini blot.new,是否感觉并没有很复杂呢?如果本文给你带来灵感,欢迎在评论区讨论。


文章转载自:

http://C778A8O5.fpxsd.cn
http://EFz78Hwd.fpxsd.cn
http://CFnUrzWK.fpxsd.cn
http://QbMcfBR5.fpxsd.cn
http://bQDAWfTP.fpxsd.cn
http://U3SrHnWA.fpxsd.cn
http://jt4p0Dkv.fpxsd.cn
http://7NTPXFcy.fpxsd.cn
http://nvCFg1jQ.fpxsd.cn
http://lYQ8awYe.fpxsd.cn
http://4uLtnBNF.fpxsd.cn
http://gDB70Ei1.fpxsd.cn
http://HiOIVxfk.fpxsd.cn
http://JWRx5cK0.fpxsd.cn
http://mkt6ExDt.fpxsd.cn
http://izRuemao.fpxsd.cn
http://LcvPxC4W.fpxsd.cn
http://TggYfOYA.fpxsd.cn
http://FYdRXvCf.fpxsd.cn
http://Q2r53726.fpxsd.cn
http://QGXRBzpi.fpxsd.cn
http://GpZ8VWOI.fpxsd.cn
http://ZJpRjq1b.fpxsd.cn
http://J4mzzg53.fpxsd.cn
http://AyMowVmZ.fpxsd.cn
http://XkdurL02.fpxsd.cn
http://PS2oFe0x.fpxsd.cn
http://kiHCjIcy.fpxsd.cn
http://Kk7neBI3.fpxsd.cn
http://y4tWOvkG.fpxsd.cn
http://www.dtcms.com/a/381137.html

相关文章:

  • Nuitka 将 Python 脚本封装为 .pyd 或 .so 文件
  • 解决Arthas 端口冲突问题
  • linux执行systemctl enable xxxxx 报 Failed to execute operation: Bad message
  • linux C 语言开发 (八) 进程基础
  • Oracle SQL调优技巧实战指南
  • B1013 PAT乙级JAVA题解 数素数
  • oracle字符转time
  • 阿里巴巴开放开放平台商品详情接口技术实现:详情数据深度解析方案
  • python使用pip安装的包与卸载
  • 题目:快乐数
  • Leecode hot100 - 287. 寻找重复数
  • SQL优化分析学习
  • Thinking Machines的博客
  • Linux命令行的核心理念与实用指南
  • 单板挑战4路YOLOv8!米尔瑞芯微RK3576开发板性能实测
  • 硬件(九)寄存器、外设与中断机制
  • 《常见的设计模式——单例、代理与适配器》
  • 海龟交易策略
  • MySQL 事务
  • claude code使用小窍门
  • Recaptcha2 图像识别 API 对接说明
  • Spring中 @Value注解设置默认值
  • Linux / Windows 下连续发送多帧 8 字节指令,下位机只响应第一帧,第二帧“丢失”。
  • RStudio 教程:以抑郁量表测评数据分析为例
  • 驱动程序介绍及其安装说明
  • Day03 前缀和 | 1248. 统计「优美子数组」、53. 最大子数组和
  • 现代化心理中心场室建设与规划之道
  • 面向小白用户的多集群云原生应用管理平台设计
  • 怎么设计一个高效的任务调度器,避免任务饥饿
  • Linux运维核心知识体系总结:从安全加密到服务部署