a single-volume edition · 单页全本
框架的名字每三年换一茬——jQuery、Angular、React、Vue、Svelte、Next、TanStack……如果只记名字,就像只认识汽车的车标,却从没打开过引擎盖。这篇全本要做的事只有一件:打开引擎盖,从最底层的物理定律开始,一层一层向上盖,直到"前端框架"这四个字在你脑中变成一座结构清晰的建筑。读完之后,任何新框架、新缩写出现时,你都能立刻看出它住在这座建筑的哪一层、在重复哪个古老的权衡。
prologue · 开演之前
七幕不是并列的七个话题,而是一座有承重关系的建筑
在出发前先把地图钉在墙上。下面这张图是全篇的骨架:下层解释上层为什么长成那样。第一幕给出物理定律,第二幕在定律之上确立编程范式;范式是一个等式 UI = f(state),第三幕展开等式左边的 state,第七幕展开等式的执行过程;第四幕是当代框架竞争的主战场——计算的时空分布;第五、六幕是横贯所有楼层的两根钢梁。
act i · the laws of physics
框架是糖,这一幕是糖底下的东西——十年不变,再十年也不变
故事从一个最日常的动作开始:你在地址栏敲下回车。在你看到页面之前的那几百毫秒里,发生了一连串每一步都有真实耗时的事件。所有框架吹嘘的"快",所有性能指标的缩写(TTFB、FCP、TTI),全都对应这条链路上的某一段。不理解这条链路,"优化"就只是咒语;理解了,它就是会计学。
响应到达后,浏览器手里只有三种纯文本:HTML、CSS、JS。它们要变成屏幕上的像素,必须走一条固定的流水线——这条线是整个前端世界的物理基础,值得逐字读懂:
关于 DOM 还有两个认知值得钉死:第一,DOM 是唯一的真相——无论框架多么花哨,最后一步都必须落到 DOM 操作上,没有例外;框架之争只是"谁能用更少、更聪明的 DOM 操作达到目标"之争。第二,DOM 节点是重对象——每个节点背后挂着几百个属性、关联着样式和布局信息,创建一万个 DOM 节点远贵于创建一万个普通 JS 对象。把这句话存好,第七幕讲虚拟 DOM 时它就是全部的物理基础。
最后一块地基关于 JS 本身。JS 是单线程的——同一时刻只能做一件事,而且这唯一的线程和页面渲染共用。一段 JS 跑 200 毫秒,页面就冻结 200 毫秒:点击没反应,动画停帧。那么"异步"是怎么回事?协调一切的机制叫事件循环。
到此为止,我们什么框架都没讲,却已经能推导出三条框架界的"物理定律"——后面六幕的所有设计,都是在这三条定律下求生:
浏览器只有一条渲染管线和一个 JS 线程。框架的一切聪明,都是在替你更省地使用这两样稀缺资源。
act ii · the paradigm shift
声明式 vs 命令式——理解了这组对立,就理解了 React 为什么长这样
2010 年前后的前端日常是这样的:页面右上角有个消息图标,要求"未读数大于零时显示红点和数字"。jQuery 时代的你会写:
// 收到新消息时
function onNewMessage() {
unread++;
const badge = $('#badge');
if (unread > 0) {
badge.text(unread);
if (!badge.is(':visible')) badge.show();
}
}
// 已读时——还要再写一份反向逻辑
function onRead() {
unread = 0;
$('#badge').hide();
}
这叫命令式(imperative):你告诉机器每一步怎么做——找到那个元素、改它的文字、判断要不要显示。像打电话给朋友报路:"前面路口左转,过两个红绿灯,看到加油站右转……"你必须时刻知道他现在在哪,才能给出下一步指令。
而声明式(declarative)是把目的地直接发给导航:
// 任何时刻,UI 都应该是这个样子:
function Badge({ unread }) {
return unread > 0 ? <span class="badge">{unread}</span> : null;
}
仔细看这段代码——里面没有"变化"的概念。没有 show 和 hide,没有"从上一个状态怎么走到下一个状态"。你只写了一个映射关系:UI = f(state)。状态变了?框架重新调用 f,自己想办法让真实 DOM 追上来。无论用户从哪条路走来,导航都能重新算出抵达路径。
命令式真正的死因不是代码丑,而是复杂度的增长方式。命令式代码维护的是"状态之间的转换路径":一个数据列表有"空、加载中、有数据、出错"四个状态,你就要手写"加载中→有数据""加载中→出错""出错→重试回到加载中"……N 个状态,最多 N×(N−1) 条路径。平方增长。而真实应用有几十个状态维度。
jQuery 时代那些经典 bug——"loading 转圈永远不消失""删除后列表残留一行幽灵数据"——本质全是某条转换路径忘了写。声明式直接取消了这类 bug 的存在条件:UI 不是被一步步改出来的,是每次从状态重新推导出来的。N 个状态,只需写 N 个"长什么样",复杂度回到线性。
但等一下——"状态变了就重新调 f",听起来美好,可第一幕刚讲过 DOM 有多贵。真把整棵 DOM 树推倒重建,每敲一个字符重盖一次大堂?性能是灾难。"如何最小代价地更新 DOM"这个难题并没有消失,它只是从你手里转移给了框架。
于是每个声明式框架都必须回答同一道题:声明式的写法,如何翻译成命令式的、最小化的 DOM 操作?对这道题的两种回答,分出了当今框架的两大技术路线——运行时比对(React 的虚拟 DOM),和编译时分析(Svelte / Solid 的 signals)。我们把这道题悬挂在这里,第七幕回来收口。此刻只需记住:两条路线殊途同归,都是"声明式外表 + 命令式引擎"。
声明式的本质是一笔交易:你交出对 DOM 的直接控制权,换来"UI 永远等于状态的函数"这条不变量。框架的全部职责,就是高效兑现这条不变量。
act iii · the memory of an app
应用的记忆——十年演进史,归根结底是三个问题
先把"状态"这个被说滥的词擦干净:状态 = 随时间变化、且 UI 需要反映的数据。输入框里的字、购物车内容、当前登录的用户、一个下拉菜单开没开——全是状态。它是应用的记忆。既然第二幕确立了 UI = f(state),那么整部状态管理史,就是在反复回答 state 的三个问题:
想知道复选框选没选?去问 DOM:$('#agree').is(':checked')。状态没有自己的家,界面本身就是数据库——像一家不记账的餐厅,想知道 3 号桌点了什么,只能跑去看桌上摆着什么菜。小店还行;一旦多处 UI 依赖同一份数据(顶部红点、侧栏计数、标签页标题都要显示未读数),就得手动让它们互相同步——第二幕的组合爆炸正是从这里点燃的。
React / Vue 把状态请进了 JS(useState、data),与组件共生。新问题随之而来:两个相距很远的组件要共享一份状态怎么办?只能把状态提升到共同祖先,再一层层通过 props 传下去——著名的 props drilling,像救火时的人链传水桶:中间的人自己根本不喝水,却必须站在那里传桶。组件树一深,处处是无辜的传桶人。
解法看似简单粗暴:全应用设一个全局 store,谁都能读;但修改必须走规定流程——发出一张说明意图的单据(action),由纯函数会计(reducer)算出下一个状态。换来的是可预测性:每次变化都有名有姓、可回放、甚至可以"时间旅行"调试。代价是仪式感过重:改一个布尔值要填三份表格、跑三个文件。但 Redux 真正的问题不在繁琐,而在它看错了自己仓库里装的是什么——这就是第四站。
这是整条主线最重要的认知跃迁,值得放慢呼吸读。人们盯着自己的 Redux store 看,发现里面 80% 的内容是从服务器 fetch 回来的数据——用户列表、订单、文章。而这类数据和"下拉菜单开没开"有本质区别:菜单状态是你的,你不改它就永远不变;服务器数据只是别人家真相在某一刻的快照——像从图书馆借回来的书,原书随时可能再版,你手里这本会悄悄过期。
| 客户端状态(你的笔记本) | 服务端状态(借来的书) | |
|---|---|---|
| 所有权 | 你的应用独占 | 服务器才是真相,你拿到的只是快照 |
| 会过期吗 | 不会 | 随时可能被别人改掉(缓存一致性问题) |
| 核心难题 | 共享与同步 | 缓存、失效、重新获取、去重、乐观更新 |
| 合适的工具 | useState / Zustand / signals | React Query / SWR / 框架 loader |
React Query / SWR 的革命不在 API,在分类学:它们把"管理别人数据的快照"识别为一个独立的问题域,并打包了整套解法——按 key 缓存、窗口重新聚焦时自动重取、过期标记、乐观更新失败自动回滚。分流之后人们惊讶地发现:剥掉服务端状态,真正属于客户端的状态少得可怜——重型集中式方案随之退潮。
前四站解决了"住哪、谁能改",最后一站解决"怎么通知"。React 的通知方式是粗粒度广播:状态一变,整个组件函数重跑一遍,再去找哪里变了。Signal 反过来——状态本身是一个可订阅的盒子,读取即订阅:哪段 UI 读过这个 signal,更新就像神经冲动一样精确送达那段 UI。组件函数只在出生时跑一次。通知粒度的演进至此走完全程:刷新整页 → 重渲染组件子树 → 精确到单个 DOM 绑定。这条路线的完整机制与代价,留给第七幕。
遇到任何状态先问一句"这是谁的"——是你的(客户端),还是服务器的快照(服务端)?分清这一刀,90% 的状态管理选型问题会自动消失。
act iv · space and time
渲染策略全谱——把所有缩写放进同一个坐标系,它们会自己显形
这一幕是全篇的制高点,也是现代框架真正的主战场。CSR、SSR、SSG、ISR、streaming、islands、RSC……这些缩写像一锅字母汤,但只要建立一个坐标系,每一个都会乖乖落座。坐标系只有一个问题:把"HTML + 数据"拼成一道完整的菜,这件事发生在哪里、发生在什么时候?可选的厨房只有三个:
| 策略 | HTML 何时何地生成 | 强项 | 弱项 | 典型场景 |
|---|---|---|---|---|
| CSR | 客户端 · 用户打开时 | 交互自由,服务器最省 | 白屏、SEO 弱、吃设备性能 | 登录后的后台 / 工具 |
| SSR | 服务端 · 每次请求 | 首屏有内容、SEO 好、可个性化 | TTFB 受服务器拖累、需 hydration | 内容 + 个性化混合页 |
| SSG | 构建时 · 一次 | 最快、最稳、CDN 直出 | 内容更新要重新构建 | 博客 / 文档 / 营销页 |
| ISR | 构建时 + 过期后服务端重建 | 静态的速度 + 准实时内容 | 缓存一致性的心智负担 | 商品页 / 新闻列表 |
SSR / SSG 有一个被低估的隐形成本。服务器发来的 HTML 是"死"的——看得见,但按钮没有灵魂。要让它活过来,浏览器必须:下载全部组件 JS → 把渲染逻辑从头到尾重新执行一遍(服务器明明刚执行过!)→ 把事件监听一个个挂回 DOM。这个过程叫注水(hydration)。
近五年所有的新策略,本质都是对这笔税的不同逃法:
这些策略不是互斥的单选题,而是逐页、甚至逐组件的决策。同一个应用完全可以:营销页走 SSG、商品页走 ISR、登录后的控制台走 CSR、信息流走 streaming SSR。"选框架"在很大程度上是在选——它让你做这些混搭有多容易、默认值有多聪明。
没有最好的渲染策略,只有"这块内容的变化频率 × 交互密度,配哪个厨房最便宜"。策略全谱是一张成本地图,不是一张技术鄙视链。
act v · the invisible factory
构建工具——开发时你等多久,生产时用户等多久,都由它决定
一个朴素的疑问:浏览器明明能直接运行 JS,为什么前端需要"构建"这道工序?因为你写的代码和浏览器能吃的代码之间,隔着四层转换需求——这就是构建器的四件本职工作:
看清这四件事就会明白:构建器不是"工程脚手架"这种边缘角色。tree-shaking 和切分的质量,就是用户实际感受到的加载速度;框架的性能口碑,相当一部分是它绑定的构建器挣来的。
还有两条同样重要的暗线:
构建器同时握着两个咽喉:开发时的反馈循环速度,和生产时的JS 包形态。评估框架时看一眼它底下是什么构建器,能预知一半的体验。
act vi · the contract
类型系统——把一整类 bug 的死亡时刻,沿时间轴整体左移
TypeScript 的本质常被说成"给 JS 加类型注解"——这是对它最浅的理解。更准确的说法是:同一个 bug 有四个可能的死亡时刻,TS 做的事是把一整类 bug 的死亡时刻整体搬到最早的那一格。而 bug 死得越早,埋葬它的成本越低:
JS 是动态类型:user.naem(拼错的 name)这种错误,代码照常加载,直到那一行真的执行才爆出 undefined——可能在你测不到的分支里,在用户的手机上,在凌晨三点。TS 让这类错误根本无法通过编译,而且大多数时候你刚敲完编辑器就画了红线——bug 死于出生前。被整类消灭的包括:属性拼写、参数顺序、null/undefined 访问、重构后漏改的调用点、接口字段变更没同步。两个常被低估的副产品也值得记:类型是不会撒谎的文档(注释会过时,类型错了编译不过),以及类型给了机器理解代码的能力——自动补全、安全的全局重命名都靠它撑着;顺带一提,这也是 AI 编码工具在 TS 项目里明显更准的原因之一:类型就是机器可读的意图。
很多人对 TS 的印象停在"到处写注解,啰嗦"。现代玩法恰恰相反——尽量不写,让类型自己流动:
const user = { name: 'Carrey', age: 30 }; // 没写任何类型
user.naem; // ← 编译器自己推断出形状,直接报错
// 泛型推断:类型穿过函数继续流动
const first = (arr) => arr[0];
const n = first([1, 2, 3]); // n 自动是 number,无需声明
"类型能自己穿过函数边界流动"——抓住这个画面,下一节就是它的放大版。
单文件内的类型安全早已是标配,真正的难题在边界上。类型历来会在两个地方"断流":
实现机制主要是两招:共享推断——前后端同仓,客户端直接 import 服务端函数的类型签名做推断,运行时由构建器换成 RPC 调用(tRPC 把这招发扬光大,TanStack Start 的 server functions、Next 的 server actions 同属一脉);把约定变成类型——路由历来是"字符串约定",TanStack Router 把整棵路由树建成类型结构,于是 params、searchParams 全部可推断、可校验。所以"端到端类型安全是 TanStack 的卖点"翻译成人话就是:在前后端边界和路由边界这两个历来最容易烂掉的地方,把运行时炸弹换成编译期红线——对快速迭代的团队,这直接等于重构的胆量。
TS 类型在编译后会被完全擦除——它是给开发期的合同,管不了运行时闯进门的陌生人(用户提交的表单、第三方 API 的响应)。所以边界上还需要运行时校验(Zod / Valibot 这类 schema 库)站岗。类型 + 运行时校验双保险,才是完整的工程答案。
类型系统的价值 = 错误发现时刻的左移量 × 边界覆盖范围。框架竞争的新前线,就是把类型推到更多的边界之外。
act vii · the last mile
虚拟 DOM vs 编译时——框架性能差异的根源,第二幕悬题在此收口
把问题重新摆上桌:状态变了,框架重新算 f 就知道"应该变成什么样",但它不知道哪里变了。整棵 DOM 推倒重建不可接受(第一幕:节点贵、布局更贵)。于是核心难题是——
如何用最小的代价,找出新 UI 与旧 UI 的差异,并只更新差异部分?
两条路线的全部分歧浓缩在一个问题上:关于"哪里会变"的知识,是运行时每次现场勘查,还是编译时一次性固化?
React 的回答(2013)有一个常被忽略的真相:VDOM 当年解决的不是性能问题,而是编程模型问题。它让"每次都重新渲染一切"这种最简单、最不会出错的心智模型变得性能上可行——便宜的 JS 对象树先排练,贵的真实 DOM 只挨补丁。"性能够好"是手段,"可以无脑重渲染"才是目的。
但它的开销是结构性的:哪怕只改了一个数字,整个组件函数照样重跑、整棵子树照样 diff。React 生态里的 memo、useMemo、依赖数组,本质全是在手动给这台机器踩刹车;而 React Compiler 试图让编译器自动踩——注意这个动向:连 React 自己也在往编译时挪。
Svelte / Solid 的反问(2016 起)直指要害:框架在编译时明明看得见你的代码——"这个 <span> 只显示 count"是静态可知的事实,为什么要等到运行时去 diff 着找?核心机制就是第三幕结尾出场的 signal:
const [count, setCount] = createSignal(0);
// 这段 JSX 只在创建时执行一次。
// 框架记下:这个文本节点 读取了 count → 订阅成立
<button onClick={() => setCount(count() + 1)}>
点击了 {count()} 次
</button>
// 之后 setCount 触发时:直接更新那一个文本节点。
// 没有组件重跑,没有 diff,没有虚拟树。
关键词是读取即订阅(自动依赖追踪):谁在渲染中读了这个 signal,谁就上了它的通知名单。更新从"全城广播 + 挨家排查"变成"点对点快递"。Vue 的 ref/reactive、Svelte 5 的 runes、Angular 的 signals、Solid——殊途同归,这就是"signals 正在成为行业新共识"的含义。代价同样真实:魔法依赖纪律,比如 Solid 里解构 props 会剪断电线(订阅丢失)——你必须按它的规矩写。而 React"每次重跑一切"的模型里没有这种隐形电线,笨,但直白。
| 虚拟 DOM(React) | 编译时 + signals(Solid / Svelte / Vue) | |
|---|---|---|
| 找差异的时机 | 运行时,每次更新现场 diff | 编译期/创建期接好订阅,更新直达 |
| 组件函数 | 每次更新重新执行 | 只执行一次(设置阶段) |
| 更新粒度 | 组件子树 | 单个 DOM 绑定 |
| 运行时体积 | 较大(diff 引擎随包发货) | 较小(很多工作编译期做完) |
| 心智模型 | "一切皆重渲染",简单一致 | "哪里读哪里更新",精确但有纪律 |
| 性能优化方式 | 手动 memo / 依赖数组(或交给 React Compiler) | 默认即最优,少有手动调优 |
| 生态与可雇佣性 | 压倒性优势 | 增长中 |
务实的结论:对绝大多数应用,两条路线的性能差异根本感知不到——瓶颈通常在网络和包体积(第四、五幕的领域),不在 diff。路线差异真正显形的场景只有几个:高频更新(实时仪表盘、协作编辑、动画密集)、低端设备、对包体积极端敏感的页面。选型时把它放在第三优先级——排在渲染策略与生态之后。
两条路线的分歧本质是:关于"哪里会变"的知识,是运行时每次重新发现,还是编译时一次性固化。整个行业正缓慢而一致地滑向后者。
finale · confluence
七幕走完,回望来路——以及带走的三件武器
把整段旅程压缩成一段楼梯,自下而上再走一遍:
UI = f(state):交出 DOM 控制权,换取复杂度从平方降到线性。如果把七幕再压缩到极限,剩下三句话。它们不只是总结——以后任何新框架、新缩写、新范式出现时,拿这三个问题过一遍,新东西会自动在你脑中归位:
前端框架 = 在浏览器的物理定律之上,用声明式范式管理"状态 → 像素"的同步,并对"计算发生在哪里、何时发生"做出一整套默认决策的系统。
最后回到那家餐厅。七幕之后它完整地开起来了:大堂有不可更改的店规(浏览器),菜单是一纸映射而非操作手册(声明式),账本分清了自家的账和供应商的货(状态),菜在三个厨房之间被精确调度(渲染策略),后场有一座决定一切节奏的中央工厂(构建),每张单据从农场到餐桌全程不换格式(类型),而厨房感知"客人改了单"的方式,正从挨桌巡查进化成每张桌子直通后厨的铃(两条路线)。
框架的名字是时尚,这七个概念是语法。掌握语法的人不追时尚——他们读得懂每一季时装在重复什么。■