00教材提纲
每一章只回答你已经问出来的问题,并且用你本机真实存在的那个项目当教具。顺序可以改;标「待写」的章节随时挑一章让我展开。
第一部分 地形 东西都在哪、谁在运行
第二部分 界面 怎么把状态变成像素
第三部分 连接 前端和你的 Go 服务怎么对上
go:embed 单二进制、Docker 多阶段构建、Nginx 反代与 SPA 回退、Vercel / Cloudflare 这类平台替你做了什么;缓存与 hash 文件名的关系。第四部分 容器 把同一套前端装进别的壳
01一个全栈应用由什么组成
你现在的困惑不是「不会写」,而是「不知道这些东西各自是什么级别的存在」。所以第一章先不碰语法,先把这片地形的坐标系立起来:有几个程序在跑、各自跑在什么运行时上、边界在哪里。后面十三章都挂在这张图上。
1.1先数清楚:有几个东西在跑
在你熟悉的世界里,一个服务基本等于一个进程 + 一个二进制。编译出 ./server,扔到机器上,它自己跑。前端之所以让你觉得飘,是因为那边的产物不是可执行文件,而是一堆要被别的程序加载的资源。
把你本机四个项目摊开,一共只有四类「可执行物」:
| 产物 | 运行时 | 在谁的机器上 | 你项目里的实例 |
|---|---|---|---|
| 原生二进制 | 操作系统 | 服务器 | filetrans 的 Go 二进制,gen_note/server |
一堆 .js / .css / .html 静态文件 |
浏览器 | 用户的机器 | gen_note/web/dist/assets/index-CHPP6raO.js |
| Node 进程 | Node / Bun 运行时 | 开发期是你的笔记本;上线后只有 SSR 才需要 | vite dev 开发服务器;pi-web 的 Next.js |
| 带内嵌 WebView 的宿主程序 | 操作系统 + 系统 WebView | 用户的桌面 | llm-wiki(Tauri)、agent-archive(Wails) |
前端的「编译产物」是资源,不是程序。它必须由另一个程序(浏览器,或你自己嵌的 WebView)加载才会活。所以「前端框架」这个词里的「运行」,运行的位置永远是某个浏览器引擎内部。
这件事在你自己的 filetrans 里是字面意义上的真实:那堆前端文件最后变成了 Go 二进制里的一段字节数组。
//go:embed web/* var webFiles embed.FS
两行代码,边界就清楚了:构建期前端被压成文件,编译期这些文件被塞进二进制,运行期 Go 的 HTTP handler 把它们吐给浏览器,浏览器把它们跑起来。「全栈」在这里不是一个模糊的形容词,而是三个明确的时刻。
1.2浏览器是你的运行时,不是你的 UI 库
把浏览器放在和「操作系统 + 消息循环 + 渲染器」同一级去理解,前端的所有古怪规则都会立刻变得有道理。下面这张对照表是整本书里对你性价比最高的东西,值得单独记下来:
| 你熟悉的 | 浏览器里的对应物 | 关键差异 |
|---|---|---|
| 进程 | 一个标签页(独立渲染进程) | 崩一个不影响别的;内存和 CPU 配额由浏览器管 |
main() | HTML 解析到 <script> 的那一刻 | 没有入口函数,只有「文档解析到这里了」 |
epoll 事件循环 | 单线程事件循环 + 任务队列 | 同步阻塞就是界面冻死,所以一切 IO 都是回调 / Promise / async |
| Qt 的 widget 树、场景图 | DOM 树 | 保留模式:你改树,浏览器负责重排(layout)和重绘(paint) |
| 线程池 | Web Worker | 不共享内存,只能传消息;拿不到 DOM |
dlopen 动态库 | <script src> / ESM import | 能运行期加载,但实践中几乎都提前打包成少数几个文件 |
头文件 .h | .d.ts 类型声明 | 只存在于编译期,运行期被完全擦除,零开销 |
| RAII / 析构 | GC | 没有确定的释放时机,监听器、定时器要手动解绑 |
最后一行那个「保留模式」是理解 React 的前提。DOM 是一棵常驻的对象树,像 Qt 的控件树:你不是每帧重画,而是改这棵树的属性,浏览器自己决定什么时候重排重绘。这意味着界面的当前样子,是你历史上所有改动的累积结果,而不是某个状态的函数。所有的 UI bug,一大半来自这里。
1.3React 和 Vue 到底是什么关系
答案先给:它们是同一层的两个竞品,解决的是同一个问题,心智模型重合度大概九成,不是上下游、不是互补、不需要都学。它们共同对抗的敌人,是上一节最后那句话——界面状态要靠你手动同步。
下面这个计数器在这页上真的在跑,三个标签页是三种写法的同一个功能:数字、加减、以及一个「偶数时高亮」的小标记。注意第一种写法里,你必须记得每次改了数字,都要去改两个地方的 DOM。
let n = 0; const out = document.getElementById("d-out"); const badge = document.getElementById("d-parity"); function sync() { out.textContent = n; // 第 1 处同步 badge.textContent = n % 2 ? "奇数" : "偶数"; // 第 2 处同步 badge.dataset.even = n % 2 ? "no" : "yes"; // 第 3 处同步 } document.getElementById("d-inc").onclick = () => { n++; sync(); }; document.getElementById("d-dec").onclick = () => { n--; sync(); };
界面上每多一处依赖 n 的东西,sync() 就要多一行。漏写一行,界面和数据就不一致——这就是前端 bug 的主要来源,和你在后端见过的「缓存和数据库不一致」是同一种病。
function Counter() { const [n, setN] = React.useState(0); return ( <div> <button onClick={() => setN(n - 1)}>−</button> <output>{n}</output> <button onClick={() => setN(n + 1)}>+</button> <span data-even={n % 2 ? "no" : "yes"}>{n % 2 ? "奇数" : "偶数"}</span> </div> ); }
const h = React.createElement; function Counter() { const [n, setN] = React.useState(0); return h("div", null, h("button", { onClick: () => setN(n - 1) }, "−"), h("output", null, n), h("button", { onClick: () => setN(n + 1) }, "+"), h("span", { "data-even": n % 2 ? "no" : "yes" }, n % 2 ? "奇数" : "偶数") ); } ReactDOM.createRoot(mount).render(h(Counter));
没有一处「改 DOM」的代码。你只描述「当 n 是这个值时,界面该长什么样」,React 自己算出和上一次的差异并最小化修改真实 DOM。JSX 不是浏览器能懂的语法,它是语法糖,被构建工具编译成上面这些 createElement 调用——这就是你的项目里必须有构建步骤的原因之一。
.vue)的等价写法<script setup> import { ref, computed } from "vue"; const n = ref(0); const even = computed(() => n.value % 2 === 0); </script> <template> <div> <button @click="n--">−</button> <output>{{ n }}</output> <button @click="n++">+</button> <span :data-even="even ? 'yes' : 'no'">{{ even ? '偶数' : '奇数' }}</span> </div> </template>
同一个思路,不同的皮:Vue 把结构写在 <template> 里(像带表达式的 HTML 模板),React 把结构写成函数返回值。Vue 的 ref 会追踪谁读过它,只更新用到它的那一小块;React 的做法是把整个组件函数重跑一遍再 diff。性能上都够用,区别主要在手感。
所以该怎么选
| React | Vue | |
|---|---|---|
| 自我定位 | 一个库,只管「状态到界面」 | 渐进式框架,官方给全套 |
| 写界面 | JSX,本质是 JavaScript 表达式 | 模板 .vue,也支持 JSX |
| 路由 / 全局状态 | 自己选(react-router、zustand…) | 官方 vue-router、pinia |
| 更新粒度 | 组件级重跑 + diff | 依赖级精确更新 |
| 生态规模 | 最大,AI 训练语料也最多 | 很大,中文资料尤其多 |
| 你项目里的现状 | 几乎全部(llm-wiki、gen_note、agent-archive…) | 间接用到(cod 的 VitePress 底层) |
对你的建议很简单:继续用 React,不用纠结。不是因为它更好,而是因为你的仓库已经全在 React 上,而且让 AI 产出 React 代码时它的「见识」最广。学会之后看 Vue 代码不需要专门学习,一个下午就能读懂。
1.4名词层级表:哪些是同一层的,哪些根本不是
你列的那串名字(React、Vue、Next.js、图标库、动效库、Tauri、Wails)之所以搅在一起,是因为它们根本不在同一层。按层排一遍,整个混乱就散了:
| 名字 | 它是哪一层 | 一句话定位 | 怎么进到你的产物里 |
|---|---|---|---|
| HTML / CSS / JS | 平台本身 | 浏览器天然就懂的三样东西,所有上层最终都编译到这里 | 就是产物 |
| TypeScript | 语言层 | JS 加静态类型;类型在构建时被擦掉,运行期零开销 | 编译成 JS |
| React / Vue / Svelte / Solid | UI 库 / 框架 | 把「状态 → 界面」自动化 | npm 依赖,打进 bundle |
| Next.js / Nuxt / SvelteKit | 元框架(框架之上) | 在 UI 库之上补齐路由、服务端渲染、构建、部署约定 | npm 依赖 + 一个 Node 服务 |
| Vite / webpack / esbuild / Rollup | 构建工具 | 开发服务器 + 打包器,不参与运行期 | 只在构建期存在(devDependencies) |
| VitePress / Astro | 静态站点生成器 | 把 Markdown 编译成一堆 HTML | 构建期跑完就退场,产物是纯静态页面 |
| Tailwind CSS | CSS 方案 | 用类名拼样式,构建时只生成你用到的那些规则 | 构建期生成一个 .css 文件 |
| shadcn/ui | 组件「配方」 | 不是依赖,是把组件源码复制进你仓库,你自己维护 | 直接变成你的源文件 |
| Antd / Element Plus / MUI | 组件库 | 成套现成控件,换皮靠主题变量 | npm 依赖,打进 bundle |
| Radix / Base UI / Headless UI | 无样式组件库 | 只给行为和无障碍,样式你自己写(shadcn 建在它上面) | npm 依赖 |
| lucide / Font Awesome / iconfont | 图标库 | 一堆 SVG 路径数据(或图标字体)+ 一个渲染方式 | npm 依赖,按需打包;见 1.6 |
| CSS transition / GSAP / framer-motion | 动效 | 三层:声明式、命令式时间轴、组件化 | 前者零依赖,后两者 npm 依赖 |
| Electron / Tauri / Wails / MyGo | 桌面壳 | 把同一份前端产物装进自带 WebView 的本地程序 | 构建出安装包;见 1.7 |
| React Native / Flutter | 移动端 | 换一套渲染后端,不再是浏览器 | 构建出 APK / IPA |
「Vite 是不是一个框架?」不是,它是构建工具,等于你的 CMake + 链接器 + 本地调试服务器。「Next.js 和 React 要选哪个?」不选,Next.js 里面就跑着 React。「Tailwind 和 Antd 冲突吗?」不同层,能共存,但审美上通常只挑一条路走。
1.5Next.js 到底解决什么
一句话:Next.js 把「HTML 在哪台机器上生成」变成了一个可选项,顺带把路由、打包、部署约定一起包了。
你的 gen_note 是最朴素的那种前端,叫 CSR(客户端渲染)。看它构建出来的首页,<body> 里真的只有一个空壳:
<head> <title>gen_note</title> <script type="module" crossorigin src="/assets/index-CHPP6raO.js"></script> <link rel="stylesheet" crossorigin href="/assets/index-C2uaYuKR.css"> </head> <body> <div id="root"></div> <!-- 整个界面都是这个空 div 里由 JS 渲染出来的 --> </body>
两条请求路径对比,差别就在「谁生成 HTML」:
所以 Next.js 的代价很具体:线上多了一个必须常驻的 Node 进程(或者托管在 Vercel / Cloudflare 这类平台上)。而 CSR 的产物是纯静态文件,所以你的 Go 二进制 go:embed 一下就全搞定了,不需要 Node 出现在生产环境。
需要:要被搜索引擎收录、要分享链接有预览卡片、首屏速度是生意(落地页、文档站、内容站)、想用 RSC 把取数据这件事挪到服务端。
不需要:内部工具、登录后才有内容的后台、桌面客户端。这几类场合里,CSR + 你的 Go 服务是更省心的组合,少一个运行时就少一摊运维。你现在的几个项目基本都属于「不需要」。
1.6一个库到底以什么形式存在,最后怎么用
这是你问得最准的一个问题。完整链条有五段,而且全都能在你本机的文件里看到:
第 1 段:npm 包就是「带头文件的静态库」
一个 npm 包解包后是一个普通目录,里面是 JS 文件、一个描述自己的 package.json、以及给 TypeScript 看的 .d.ts 类型声明。对照你的世界:
node_modules/≈ vendor 目录,不进 git,可以随时删掉重装package.json≈ CMakeLists + 依赖清单(dependencies进产物,devDependencies只在构建期用)package-lock.json≈ 锁定整张依赖图的精确版本,保证两台机器装出一样的东西.d.ts≈ 头文件,只在编译期存在
第 2 段:一个图标库的真身
你的 llm-wiki 依赖 lucide-react。那个包里,每个图标就是这么一小段数据——下面是从 npm 上直接拉下来的真实文件:
const House = [ "svg", defaultAttributes, // viewBox、stroke: "currentColor"、宽高 24… [ ["path", { d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" }], ["path", { d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 ..." }] ] ]; export { House as default };
就这样。图标库 = 一堆描述 SVG 的数组 + 一个把数组变成 DOM 的渲染函数。不是图片,不是字体,没有网络请求。下面这个示例把真的 lucide 包从 CDN 载进本页,然后用我手写的十行渲染函数把那段数据画出来:
function renderIcon([tag, attrs, kids = []], size) { const el = document.createElementNS("http://www.w3.org/2000/svg", tag); for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v); if (size) { el.setAttribute("width", size); el.setAttribute("height", size); } kids.forEach(k => el.appendChild(renderIcon(k))); return el; }
图标颜色跟着文字走,是因为 stroke: "currentColor"。这也解释了为什么图标库给的都是描边图标、能随意换色换大小:它本质是矢量数据,不是位图资源。
第 3 段:打包器就是链接器
整包 lucide 有一千多个图标、三百多 KB。但你 import { House } from "lucide-react" 时,最终产物里只会留下 House 那一个数组。这个过程叫 tree-shaking,和你用 --gc-sections 做死代码消除是同一件事。
你的 gen_note 里有这件事最干净的证据。开发期的入口文件指向源码,构建后指向一个带内容 hash 的打包产物:
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<div id="root"></div>
<script type="module" crossorigin src="/assets/index-CHPP6raO.js"></script>
文件名里的 CHPP6raO 是内容哈希。内容变了名字就变,于是可以让浏览器永久缓存这个文件而不怕更新不生效。这是前端对「缓存失效」问题的标准答案,你在 CDN 层干过同样的事。
第 4 段:开发期的那个 Node 进程
开发时你是两个服务一起跑的:Vite 开发服务器(Node)伺候页面,Go 服务伺候接口。跨端口会撞上浏览器的同源策略,所以 Vite 在中间当反向代理——你项目里就是这么配的:
export default defineConfig({ plugins: [react()], server: { proxy: { "/api": { target: "http://127.0.0.1:8787", changeOrigin: false } } }, });
于是页面请求 /api/notes 时,Vite 把它转给 8787 的 Go 服务。上线后这个 Node 进程完全消失,由 Go 同时负责静态文件和接口,路径天然同源,代理也就不需要了。开发期和生产期的拓扑不一样,这件事值得记住,因为「本地好的、上线 404」十次有八次出在这里。
第 5 段:动效库的三层
动效没有「一个库」,是三层不同的东西。下面两条跑道并排对比,左边零依赖,右边是真的 GSAP:
/* 1 CSS:声明「变化要花多久」,由浏览器插值。零依赖,优先选它 */ .mover { transition: transform .5s cubic-bezier(.2,.9,.25,1.1); } .mover.is-out { transform: translateX(120px) rotate(45deg); } // 2 GSAP:命令式时间轴,能编排序列、暂停、倒放、物理缓动 gsap.timeline() .to(el, { x: 120, rotate: 45, duration: .5, ease: "back.out(1.7)" }) .to(el, { x: 0, rotate: 0, duration: .4 }, "+=0.15"); // 3 framer-motion:把动效包成 React 组件,适合「元素进出场」 <motion.div animate={{ x: 120 }} transition={{ type: "spring" }} />
选择顺序很简单:能用 CSS 就用 CSS;需要编排多步时间轴、或者要暂停倒放,上 GSAP;在 React 里做列表进出场动画,framer-motion 最省事。Tailwind 不在这一层,它只是帮你生成 CSS 类。
本页没有构建步骤,所以它用的是库的另一种分发形态:UMD 包通过 <script src> 挂到全局变量上(window.React、window.Vue、window.gsap、window.lucide)。你项目里用的是 ESM 形态 + 打包器。同一个库,两种分发形态,这就是为什么你有时在教程里看到 <script src>、有时看到 import ——它们不是两种技术,是同一个包的两种出口。
1.7桌面端:套壳派与自绘派
先说共同点,这点最重要:Electron、Tauri、Wails、MyGo 用的前端产物和网页版一模一样,还是那堆 dist/ 文件。变的只是宿主——从「用户的浏览器」变成「你自己的程序里嵌的 WebView」。你会写网页,就已经会写它们的界面部分了。
| Electron | Tauri | Wails | MyGo | |
|---|---|---|---|---|
| WebView 来源 | 自带整个 Chromium | 系统的(WKWebView / WebView2 / WebKitGTK) | 系统的,同左 | 系统的;原生 UI 模式下完全不用 |
| 主进程语言 | Node.js | Rust | Go | Go(纯 Go,不依赖 cgo) |
| 安装包量级 | 80–150 MB | 3–10 MB | 5–15 MB | 约 7 MB |
| 前端怎么调后端 | IPC 消息(ipcMain / ipcRenderer) | invoke("命令名", 参数) + 事件 | 自动生成 TS 绑定,像调本地函数一样调 Go 方法 | 同样生成 TS 客户端,带类型,还能用 channel 流式推送 |
| 渲染一致性 | 完全一致,内核你自己带 | 随用户系统版本浮动 | 随用户系统版本浮动 | WebView 模式随系统;原生 UI 自绘,完全一致 |
| 你的实例 | paseo/desktop-local | llm-wiki/src-tauri | agent-archive(Wails v3) | 还没用过 |
| 什么时候选它 | 要绝对一致的渲染、重度依赖 Node 生态 | 要小体积、愿意写点 Rust | 后端本来就是 Go | 后端是 Go,想要更小的包、更低的内存,或者干脆不要 WebView |
对你来说 Wails 的吸引力很直白:主进程是 Go,你那些现成的 Go 代码能直接变成桌面应用的后端,前端产物还是 React 那一套。agent-archive 用的就是这条路(它的 frontend/ 是 React + Vite,依赖里有 @wailsio/runtime)。
egoist/mygo,仓库 2026 年 9 月才建,现在 v0.7.3、三千多 star、MIT 许可。我上一版写「Go 里没有这个框架」是错的:它比我的知识截止日期还新。
它特别在一个框架同时提供两条路,而且同一个应用里可以混用:
① WebView 模式——和 Wails 同路,借系统 WebView,前端产物照旧是那堆 dist/。Go 这边 mygo.Bind(Greeter{}) 一句,框架自动生成 TypeScript 客户端,前端像调本地函数一样调 Go,还能用 channel 做流式推送。
② 原生 UI 模式——完全不要 WebView,用 package ui 在 Go 里写界面,MyGo 自己用 Metal / Direct3D / OpenGL 画。官方数字:二进制约 7 MB,原生 UI 内存约 44 MB、带 WebView 约 62 MB,空闲 CPU 0%。纯 Go 不依赖 cgo,一台机器就能交叉编译三个平台;打包、签名公证、带增量的自动更新也都在框架里。
第二种模式对你另有一层意义。看它官方的计数器例子,和本章 1.3 那个是同一个东西:
type counter struct{ n int } func (s *counter) view(c *ui.Context) { ui.Column(c).Fill().Center().Gap(12).Children(func() { ui.Text(c, fmt.Sprint(s.n)).FontSize(40).Bold() if ui.PrimaryButton(c, "Increment").Clicked() { s.n++ } }) }
没有 HTML,没有 JavaScript,没有 DOM,但思想和 React 一模一样:view 是应用状态的函数,框架调用它来构建每一帧,再画到 GPU 上。1.3 里那句「UI = f(state)」可以原样平移过来,换掉的只是渲染后端。这种做法在图形界面里叫即时模式(immediate mode),最有名的代表是 C++ 那边的 Dear ImGui——如果你见过它,MyGo 的原生 UI 会让你觉得很亲切。顺带说,这也正是理解 React 最快的一句话:React 相当于在保留模式的 DOM 上,盖了一层即时模式的壳。
它和 Fyne、Gio 属于同一派(自绘,不碰前端技术栈),区别是 MyGo 把两种模式放进了同一个框架,还把发布链路一起包了。
它太新了。API 还在变,生态、第三方控件和踩坑问答都远不如 Electron / Tauri / Wails。对你还有一条更现实的:多数 AI 模型的训练数据里还没有它(包括我),所以让 AI 写 MyGo 代码时,它大概率会编出不存在的 API。用它意味着你得自己读文档和源码——这恰好是第 14 章要讲的那种判断。第 11 章会把这四个框架连同签名、自动更新一起展开。
1.8五分钟读懂任何陌生前端仓库
这是本章能立刻用上的工具。拿到一个你没见过的仓库,按顺序做六件事,五分钟内就能说清它是什么、怎么跑、产物去哪:
这六步对你本机任意一个仓库都成立。不信随便挑一个试:先猜,再按这六步验证。
1.9自测
能把下面四题用自己的话讲清楚,第 1 章就算过了。
为什么 gen_note 上线后不需要在服务器上跑 Node,而 pi-web 需要?
因为 gen_note 是 CSR:构建产物是纯静态文件,Go 服务直接把它们吐给浏览器,渲染发生在用户机器上。Next.js 做 SSR / RSC 时,HTML 是在服务端由 Node 跑 React 生成的,所以线上必须有一个常驻的 JS 运行时(自己部署 Node,或交给 Vercel / Cloudflare 这类平台)。
JSX 是浏览器的特性吗?如果不是,谁把它变成浏览器能跑的东西?
不是。JSX 是语法糖,浏览器完全不认。构建期由 Vite 内部的 esbuild / SWC 这类编译器把它转成 React.createElement(...) 或新版的 jsx(...) 函数调用。本页 React 示例直接写的就是编译后的形态,因为本页没有构建步骤。
把 node_modules 整个删掉,线上的服务会挂吗?
不会。node_modules 只在开发和构建时需要,库的代码在打包那一刻就已经被复制进 dist/ 里了。删掉它只影响你下一次构建(跑一次 npm install 就恢复)。这和「删掉 vendor 目录不影响已编译的二进制」是一样的道理。
同一份 React 代码,怎么同时变成网页、桌面应用和一个 Go 单二进制?
因为它们消费的是同一份 dist/ 静态产物,只换宿主:浏览器直接访问;Tauri / Wails 把这堆文件装进程序内嵌的 WebView;Go 用 go:embed 把它们编译进二进制再由 HTTP handler 提供。前端部分不需要任何改动,唯一要动的是「怎么调后端」那一层——网页走 HTTP,桌面端走壳提供的 IPC。