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、新建前端应用

新建一个前端项目,用于演示交互。

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;

这里有几个关键点。

  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,是否感觉并没有很复杂呢?如果本文给你带来灵感,欢迎在评论区讨论。

相关推荐
南方者2 小时前
文心文心,其利锻心!这个古风射覆,它帅到我了!文心快码 3.5S
前端·敏捷开发·文心快码
永日456702 小时前
学习日记-CSS-day53-9.11
前端·css·学习
云枫晖2 小时前
JS核心知识-this的指向
前端·javascript
magnet2 小时前
用img标签渲染的svg VS 直接使用svg标签,有什么区别?
前端·html
ze_juejin2 小时前
createComponent的environmentInjector详解
前端
云舟吖2 小时前
基于 electron-vite 从零到一搭建桌面端应用
前端·架构
ze_juejin2 小时前
CSS backdrop-filter 属性详解
前端
不爱编程的小方3 小时前
响应式布局
前端·css3