a single-volume edition · 单页全本

前端框架:
从地基到屋顶

框架的名字每三年换一茬——jQuery、Angular、React、Vue、Svelte、Next、TanStack……如果只记名字,就像只认识汽车的车标,却从没打开过引擎盖。这篇全本要做的事只有一件:打开引擎盖,从最底层的物理定律开始,一层一层向上盖,直到"前端框架"这四个字在你脑中变成一座结构清晰的建筑。读完之后,任何新框架、新缩写出现时,你都能立刻看出它住在这座建筑的哪一层、在重复哪个古老的权衡。

贯穿全篇的一家餐厅 为了让抽象的东西长出画面,我们会反复回到同一个比喻:一个网页,就是一道端给用户的菜。浏览器是有着严格店规的餐厅大堂,服务器是后厨,网络是送餐的路,框架则是这家餐厅的整套运营方法论——菜在哪做、什么时候做、谁来记客人的口味、单据怎么传、改单了怎么办。七幕走完,这家餐厅会完整地开起来。

prologue · 开演之前

先看一眼整座建筑

七幕不是并列的七个话题,而是一座有承重关系的建筑

在出发前先把地图钉在墙上。下面这张图是全篇的骨架:下层解释上层为什么长成那样。第一幕给出物理定律,第二幕在定律之上确立编程范式;范式是一个等式 UI = f(state),第三幕展开等式左边的 state,第七幕展开等式的执行过程;第四幕是当代框架竞争的主战场——计算的时空分布;第五、六幕是横贯所有楼层的两根钢梁。

知识结构:自下而上,每层回答上一层留下的问题 第一幕 · 物理定律:浏览器与网络 DOM · 渲染管线 · 事件循环 · HTTP 生命周期 —— 一切代价的来源 第二幕 · 编程范式:声明式 UI = f(state) 交出 DOM 控制权,换取"UI 永远等于状态的投影" 第三幕 · 等式左边:状态 状态归谁所有 · 变化通知给谁 第七幕 · 等式的执行:两条路线 运行时 VDOM diff vs 编译时 signals 第四幕 · 时空分布:渲染策略全谱 —— 计算发生在哪、何时发生 CSR · SSR · SSG · ISR · streaming · islands · RSC,现代框架的主战场 第五幕 · 看不见的推手:构建工具 第六幕 · 跨边界安全网:类型系统
把这张图记在心里——每读完一幕,回来看看自己站在哪一层。

act i · the laws of physics

第一幕 · 物理世界

框架是糖,这一幕是糖底下的东西——十年不变,再十年也不变

故事从一个最日常的动作开始:你在地址栏敲下回车。在你看到页面之前的那几百毫秒里,发生了一连串每一步都有真实耗时的事件。所有框架吹嘘的"快",所有性能指标的缩写(TTFB、FCP、TTI),全都对应这条链路上的某一段。不理解这条链路,"优化"就只是咒语;理解了,它就是会计学。

餐厅 · 第一幕 你走进一家陌生餐厅坐下点菜。先要有人查到这家店的后厨在哪(DNS),再派人跑过去敲门、对暗号(TCP/TLS 握手——好几个来回,店离得越远每个来回越慢),后厨现做这道菜(服务器处理),再端回来(响应)。菜上桌你才发现:酱料和餐具是分开送的,得再跑两趟(HTML 里引用的 CSS 和 JS)。这就是为什么"把后厨开到离客人近的地方"(CDN、edge)和"一趟把东西送齐"(减少请求瀑布)是永恒的优化主题。
一次请求的生命周期 每段都有真实网络/计算耗时 DNS 解析 域名 → IP TCP + TLS 握手 多次往返 (RTT) 服务器处理 查库 · 渲染 · 拼装 响应返回 首字节 TTFB 解析 HTML · 发现资源 CSS / JS / 图片再发请求 渲染管线 见下一节 关键认知:JS 包每大一点,"发现资源 → 再请求 → 下载 → 执行"这条尾巴就长一截。 SSR/SSG 的全部意义:让第一次响应里就带着可看的 HTML,不必等 JS。
请求瀑布每深一层嵌套,就是一轮完整往返。现代框架的 loader / RSC 设计,本质是把瀑布拍平成并行。

后厨流水线:文本如何变成像素

响应到达后,浏览器手里只有三种纯文本:HTML、CSS、JS。它们要变成屏幕上的像素,必须走一条固定的流水线——这条线是整个前端世界的物理基础,值得逐字读懂:

  1. 解析:HTML 被解析成 DOM 树,CSS 被解析成 CSSOM 树。DOM 不是 HTML 文件本身,而是浏览器在内存中维护的活的对象树——HTML 是图纸,DOM 是按图纸盖起来、可以随时改建的房子。
  2. 合成渲染树:两棵树合并,得出"哪些东西要显示、长什么样"。
  3. 布局(layout):计算每个元素的几何位置——它在哪、多大。这是全管线最贵的一步,因为元素之间互相牵连:一个 div 高了 10px,它下面所有东西都要重算。
  4. 绘制与合成:把几何变成像素,分层合到屏幕上。
为什么"操作 DOM 很贵" 不是 DOM 对象本身慢——而是每次修改都可能引爆后面整条流水线。改一个元素的宽度,相当于在坐满客人的大堂里挪一张桌子:周围的桌子都得跟着挪(重新布局),挪完还得重新摆台(重绘)。更糟的是"读写交替":改一下样式、读一次 offsetHeight、再改一下——每次"读"都强迫浏览器立刻把攒着的改动结算成一次完整布局,行话叫强制同步布局。一晚上把大堂翻来覆去挪几十次,餐厅就瘫了。
渲染管线 (critical rendering path) HTML → DOM 树 CSS → CSSOM 树 渲染树 布局 layout 绘制 → 合成 JS 修改 DOM / 样式 → 触发重排 (reflow) 与重绘 (repaint) 布局是全管线最贵的一步,频繁触发即卡顿 框架的批量更新、虚拟 DOM、细粒度响应,全部是为了少触发、晚触发右边这条红色路径。
记住这条红色回路——之后每一幕的"性能"二字,最终都落回这里。

关于 DOM 还有两个认知值得钉死:第一,DOM 是唯一的真相——无论框架多么花哨,最后一步都必须落到 DOM 操作上,没有例外;框架之争只是"谁能用更少、更聪明的 DOM 操作达到目标"之争。第二,DOM 节点是重对象——每个节点背后挂着几百个属性、关联着样式和布局信息,创建一万个 DOM 节点远贵于创建一万个普通 JS 对象。把这句话存好,第七幕讲虚拟 DOM 时它就是全部的物理基础。

独自打烊的服务员:事件循环

最后一块地基关于 JS 本身。JS 是单线程的——同一时刻只能做一件事,而且这唯一的线程和页面渲染共用。一段 JS 跑 200 毫秒,页面就冻结 200 毫秒:点击没反应,动画停帧。那么"异步"是怎么回事?协调一切的机制叫事件循环

餐厅 · 唯一的服务员 想象这家餐厅只有一个服务员,他还兼任布置大堂的工作(渲染)。他的工作方式是:手头的事必须一口气做完(调用栈清空),才去看留言板(任务队列)上的下一件事。"十分钟后提醒 3 号桌"这种事,他不会站在原地掐表——他写张条子交给后台的闹钟(浏览器的定时器线程),闹钟到点把条子放回留言板。所以 setTimeout(fn, 1000) 的真实含义不是"1 秒后执行",而是"至少 1 秒后、等服务员忙完手头事再执行"。留言板还分两块:便利贴(微任务,Promise.then)优先级更高——每做完一件事,他会先撕光所有便利贴,才看普通留言,才考虑要不要整理一次大堂(渲染一帧)。
事件循环 (event loop) JS 主线程 调用栈 渲染机会 栈空时才轮到画面 宏任务队列 setTimeout · 点击事件 · 网络回调 微任务队列 Promise.then · queueMicrotask 浏览器其他线程 网络 · 定时器在别处计时,到点投递 栈空后取一个执行 微任务优先:每次栈清空先全部排空 循环节奏:取一个宏任务 → 排空所有微任务 → (也许)渲染一帧 → 回到开头。
"异步"不是并行,是排队。框架的批量更新通常就藏在微任务里:本轮代码全跑完,再一次性结算 DOM。

到此为止,我们什么框架都没讲,却已经能推导出三条框架界的"物理定律"——后面六幕的所有设计,都是在这三条定律下求生:

第一幕 · 一句话

浏览器只有一条渲染管线和一个 JS 线程。框架的一切聪明,都是在替你更省地使用这两样稀缺资源

幕间 · 走到这里 我们有了一个物理世界:操作 DOM 很贵、线程只有一条、网络每个来回都要钱。在这个世界里,早期开发者是怎么写界面的?答案是"手动挡"——而手动挡撞上了一堵数学的墙。这就是第二幕。

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 个"长什么样",复杂度回到线性。

命令式:维护转换路径 声明式:维护一个映射 空状态 加载中 有数据 出错 N 个状态 → 最多 N×(N−1) 条路径要手写 漏写一条 = 一个"界面残留"bug state(唯一事实来源) UI = f(state) UI(永远是 state 的投影) N 个状态 → 只写 N 个"长什么样" 转换路径由框架推导,不可能漏
左边的乱麻,就是每个老前端的青春。React 的革命是把平方降回线性。

天下没有免费的声明式

但等一下——"状态变了就重新调 f",听起来美好,可第一幕刚讲过 DOM 有多贵。真把整棵 DOM 树推倒重建,每敲一个字符重盖一次大堂?性能是灾难。"如何最小代价地更新 DOM"这个难题并没有消失,它只是从你手里转移给了框架

于是每个声明式框架都必须回答同一道题:声明式的写法,如何翻译成命令式的、最小化的 DOM 操作?对这道题的两种回答,分出了当今框架的两大技术路线——运行时比对(React 的虚拟 DOM),和编译时分析(Svelte / Solid 的 signals)。我们把这道题悬挂在这里,第七幕回来收口。此刻只需记住:两条路线殊途同归,都是"声明式外表 + 命令式引擎"。

第二幕 · 一句话

声明式的本质是一笔交易:你交出对 DOM 的直接控制权,换来"UI 永远等于状态的函数"这条不变量。框架的全部职责,就是高效兑现这条不变量。

幕间 · 走到这里 等式 UI = f(state) 立起来了。但等式右边的 f 我们谈了很多,左边那个 state 呢——它住在哪?谁有权改它?改了之后谁该被通知?对这三个问题的回答换了五代,构成了前端十年的主线剧情。第三幕,状态。

act iii · the memory of an app

第三幕 · 状态

应用的记忆——十年演进史,归根结底是三个问题

先把"状态"这个被说滥的词擦干净:状态 = 随时间变化、且 UI 需要反映的数据。输入框里的字、购物车内容、当前登录的用户、一个下拉菜单开没开——全是状态。它是应用的记忆。既然第二幕确立了 UI = f(state),那么整部状态管理史,就是在反复回答 state 的三个问题:

十年主线:每一代解决上一代的核心痛点 ① jQuery:状态住在 DOM 里 界面本身就是数据库 痛:真相散落,无人知道全貌 ② 组件 state + props 状态搬进 JS,跟着组件走 痛:跨组件共享要层层传递 ③ Redux 单一全局 store 痛:样板代码沉重 ④ 大分流:服务端状态 ≠ 客户端状态 React Query / SWR 接管"别人的数据"(缓存·失效·重取) 真·客户端状态没剩多少,轻量方案即可(Zustand 等) ⑤ Signals 细粒度响应 状态自带订阅网络 Solid · Vue · Svelte 5 runes 不变的主轴:状态归谁所有 × 变化通知给谁 通知粒度一路变细:刷新整页 → 重渲染组件子树 → 精确到单个 DOM 绑定
这不是"工具迭代史",是同一个问题的五次重新回答。

① jQuery 时代:去问家具记得什么

想知道复选框选没选?去问 DOM:$('#agree').is(':checked')。状态没有自己的家,界面本身就是数据库——像一家不记账的餐厅,想知道 3 号桌点了什么,只能跑去看桌上摆着什么菜。小店还行;一旦多处 UI 依赖同一份数据(顶部红点、侧栏计数、标签页标题都要显示未读数),就得手动让它们互相同步——第二幕的组合爆炸正是从这里点燃的。

② 组件状态:记忆有了家,却被囚禁在房间里

React / Vue 把状态请进了 JS(useStatedata),与组件共生。新问题随之而来:两个相距很远的组件要共享一份状态怎么办?只能把状态提升到共同祖先,再一层层通过 props 传下去——著名的 props drilling,像救火时的人链传水桶:中间的人自己根本不喝水,却必须站在那里传桶。组件树一深,处处是无辜的传桶人。

③ Redux:中央银行与它的表格

解法看似简单粗暴:全应用设一个全局 store,谁都能读;但修改必须走规定流程——发出一张说明意图的单据(action),由纯函数会计(reducer)算出下一个状态。换来的是可预测性:每次变化都有名有姓、可回放、甚至可以"时间旅行"调试。代价是仪式感过重:改一个布尔值要填三份表格、跑三个文件。但 Redux 真正的问题不在繁琐,而在它看错了自己仓库里装的是什么——这就是第四站。

④ 大分流:你仓库里的大多数,根本不是你的货

这是整条主线最重要的认知跃迁,值得放慢呼吸读。人们盯着自己的 Redux store 看,发现里面 80% 的内容是从服务器 fetch 回来的数据——用户列表、订单、文章。而这类数据和"下拉菜单开没开"有本质区别:菜单状态是你的,你不改它就永远不变;服务器数据只是别人家真相在某一刻的快照——像从图书馆借回来的书,原书随时可能再版,你手里这本会悄悄过期。

客户端状态(你的笔记本)服务端状态(借来的书)
所有权你的应用独占服务器才是真相,你拿到的只是快照
会过期吗不会随时可能被别人改掉(缓存一致性问题)
核心难题共享与同步缓存、失效、重新获取、去重、乐观更新
合适的工具useState / Zustand / signalsReact Query / SWR / 框架 loader

React Query / SWR 的革命不在 API,在分类学:它们把"管理别人数据的快照"识别为一个独立的问题域,并打包了整套解法——按 key 缓存、窗口重新聚焦时自动重取、过期标记、乐观更新失败自动回滚。分流之后人们惊讶地发现:剥掉服务端状态,真正属于客户端的状态少得可怜——重型集中式方案随之退潮。

⑤ Signals:从广播站到神经系统

前四站解决了"住哪、谁能改",最后一站解决"怎么通知"。React 的通知方式是粗粒度广播:状态一变,整个组件函数重跑一遍,再去找哪里变了。Signal 反过来——状态本身是一个可订阅的盒子,读取即订阅:哪段 UI 读过这个 signal,更新就像神经冲动一样精确送达那段 UI。组件函数只在出生时跑一次。通知粒度的演进至此走完全程:刷新整页 → 重渲染组件子树 → 精确到单个 DOM 绑定。这条路线的完整机制与代价,留给第七幕。

第三幕 · 一句话

遇到任何状态先问一句"这是谁的"——是你的(客户端),还是服务器的快照(服务端)?分清这一刀,90% 的状态管理选型问题会自动消失。

幕间 · 走到这里 注意第四站埋下的引线:既然服务端状态的难题是"快照会过期",那有没有更釜底抽薪的办法——干脆把取数据这件事挪回服务端,让浏览器少管"别人的数据"?有。这正是接下来这场战役的核心:计算应该发生在哪里、什么时候发生。第四幕,现代框架的主战场。

act iv · space and time

第四幕 · 时空

渲染策略全谱——把所有缩写放进同一个坐标系,它们会自己显形

这一幕是全篇的制高点,也是现代框架真正的主战场。CSR、SSR、SSG、ISR、streaming、islands、RSC……这些缩写像一锅字母汤,但只要建立一个坐标系,每一个都会乖乖落座。坐标系只有一个问题:把"HTML + 数据"拼成一道完整的菜,这件事发生在哪里、发生在什么时候?可选的厨房只有三个:

餐厅 · 三个厨房 中央厨房预制(构建时)——开业前一次性做好一批,所有客人吃同一份。出菜最快(拿了就走),但内容对所有人一样、且不新鲜。
门店现炒(服务端请求时)——客人点单后厨现做。新鲜、可以按客人口味调整,代价是后厨的火力(服务器算力)和客人的等待(TTFB)。
食材包到桌,自己煮(客户端)——把生食材和炉子(JS)一起送到客人桌上。怎么煮全凭客人现场决定(交互最自由),但上菜最慢,而且很考验客人自带的炉子(设备性能)。
全部策略放进一个坐标系 HTML 在哪生成 → 三列;跨列的策略 = 混合打法 构建时(中央厨房) 服务端请求时(门店现炒) 客户端(桌上自煮) SSG 静态生成 SSR 服务端渲染 CSR 客户端渲染 Streaming SSR 边拼边发,不等全好 ISR 增量静态再生 构建时生成 + 过期后在服务端悄悄重建(介于两者之间) RSC · Islands · Resumability 服务端为主体,只把必要的交互部分交给客户端(见下文) 越靠左:首屏越快、越省用户设备 · 越靠右:交互越自由、内容越个性化 现代框架的方向:默认靠左,按需滑向右
横跨多列的策略不是骑墙,而是精确地把每块工作放在它最便宜的位置。

四个基础策略,先落座

策略HTML 何时何地生成强项弱项典型场景
CSR客户端 · 用户打开时交互自由,服务器最省白屏、SEO 弱、吃设备性能登录后的后台 / 工具
SSR服务端 · 每次请求首屏有内容、SEO 好、可个性化TTFB 受服务器拖累、需 hydration内容 + 个性化混合页
SSG构建时 · 一次最快、最稳、CDN 直出内容更新要重新构建博客 / 文档 / 营销页
ISR构建时 + 过期后服务端重建静态的速度 + 准实时内容缓存一致性的心智负担商品页 / 新闻列表

所有人都在逃的一笔税:hydration

SSR / SSG 有一个被低估的隐形成本。服务器发来的 HTML 是"死"的——看得见,但按钮没有灵魂。要让它活过来,浏览器必须:下载全部组件 JS → 把渲染逻辑从头到尾重新执行一遍(服务器明明刚执行过!)→ 把事件监听一个个挂回 DOM。这个过程叫注水(hydration)

餐厅 · 恐怖谷时刻 菜端上桌了,色香俱全——但餐具还没摆。你举着筷子戳了三下,菜纹丝不动。这就是 hydration 造成的著名"恐怖谷":页面看得见,但点不动。而且按第一幕的定律二,注水占用的正是那个唯一的服务员——页面越复杂,这段瘫痪期越长。

近五年所有的新策略,本质都是对这笔税的不同逃法:

一个常见误解

这些策略不是互斥的单选题,而是逐页、甚至逐组件的决策。同一个应用完全可以:营销页走 SSG、商品页走 ISR、登录后的控制台走 CSR、信息流走 streaming SSR。"选框架"在很大程度上是在选——它让你做这些混搭有多容易、默认值有多聪明

第四幕 · 一句话

没有最好的渲染策略,只有"这块内容的变化频率 × 交互密度,配哪个厨房最便宜"。策略全谱是一张成本地图,不是一张技术鄙视链。

幕间 · 走到这里 你可能已经注意到一件事:islands 要求"只给岛屿打包 JS",RSC 要求"服务端组件的代码不进客户端包"——这些花样全都要求有人在背后精确地切割和分发代码。这个幕后角色就是构建工具。它不写在任何框架的宣传页首屏上,却决定了框架能玩出什么花样。第五幕,进工厂。

act v · the invisible factory

第五幕 · 工厂

构建工具——开发时你等多久,生产时用户等多久,都由它决定

一个朴素的疑问:浏览器明明能直接运行 JS,为什么前端需要"构建"这道工序?因为你写的代码和浏览器能吃的代码之间,隔着四层转换需求——这就是构建器的四件本职工作:

看清这四件事就会明白:构建器不是"工程脚手架"这种边缘角色。tree-shaking 和切分的质量,就是用户实际感受到的加载速度;框架的性能口碑,相当一部分是它绑定的构建器挣来的。

Webpack → Vite:不是提速,是哲学换代

餐厅 · 两种开店方式 Webpack(2014 年的世界观):当年的浏览器不懂模块化,所以"先把所有东西打成包"是一切的前提——哪怕只是开发调试,也得先把整个仓库搬上货车、码放整齐,才能开门营业。项目越大,每天早上开门越慢,改一件商品也要重新理半个货车。

Vite(2020 年的世界观):现代浏览器已经原生支持 ESM——它自己就会顺着 import 来取货。那开发时根本不用装车:直接开门营业,客人(浏览器)要哪件,现场取哪件、现场加工哪件。启动时间从此与项目规模无关。
Webpack:先打包,再服务 Vite:先服务,按需编译 启动:扫描全部模块依赖图 全量打包成 bundle(JS 实现,慢) 才能开始服务浏览器 启动:立即开始服务(近乎零等待) 浏览器用原生 ESM 按 import 逐个要 要哪个文件,现场编译哪个 依赖预构建交给 esbuild(Go,快百倍) node_modules 只在首启时处理一次并缓存 项目越大,启动与热更新越慢(分钟级) 启动时间与项目大小近乎无关(毫秒级)
Vite 的洞察很简单:开发环境根本不需要 bundle——浏览器自己就会加载模块。

还有两条同样重要的暗线:

为什么说它是框架差异的"隐藏推手"

第五幕 · 一句话

构建器同时握着两个咽喉:开发时的反馈循环速度,和生产时的JS 包形态。评估框架时看一眼它底下是什么构建器,能预知一半的体验。

幕间 · 走到这里 工厂讲完,硬件齐了。但还有一种 bug 与渲染快慢无关,却日复一日消耗团队:后端改了一个字段名,前端编译照常通过,上线后用户的页面白屏。问题出在"边界"——代码与代码之间、前端与后端之间的约定,历来只靠口头。第六幕,把口头约定变成钢印合同。

act vi · the contract

第六幕 · 契约

类型系统——把一整类 bug 的死亡时刻,沿时间轴整体左移

TypeScript 的本质常被说成"给 JS 加类型注解"——这是对它最浅的理解。更准确的说法是:同一个 bug 有四个可能的死亡时刻,TS 做的事是把一整类 bug 的死亡时刻整体搬到最早的那一格。而 bug 死得越早,埋葬它的成本越低:

同一个 bug,四个可能的死亡时刻 输入时 编辑器红线 + 自动补全 编译时 tsc 报错,CI 挡下 测试时 要先想到才能测到 用户手里 undefined 崩溃 TypeScript 做的事:把右端的一整类错误,整体搬运到最左端 越靠左:发现成本越低、修复者就是写错的人、上下文还在脑子里
"编译期消灭"不是口号——是把错误的发现时刻沿这条轴整体左移。

JS 是动态类型:user.naem(拼错的 name)这种错误,代码照常加载,直到那一行真的执行才爆出 undefined——可能在你测不到的分支里,在用户的手机上,在凌晨三点。TS 让这类错误根本无法通过编译,而且大多数时候你刚敲完编辑器就画了红线——bug 死于出生前。被整类消灭的包括:属性拼写、参数顺序、null/undefined 访问、重构后漏改的调用点、接口字段变更没同步。两个常被低估的副产品也值得记:类型是不会撒谎的文档(注释会过时,类型错了编译不过),以及类型给了机器理解代码的能力——自动补全、安全的全局重命名都靠它撑着;顺带一提,这也是 AI 编码工具在 TS 项目里明显更准的原因之一:类型就是机器可读的意图。

现代 TS 的主角是推断,不是注解

很多人对 TS 的印象停在"到处写注解,啰嗦"。现代玩法恰恰相反——尽量不写,让类型自己流动

const user = { name: 'Carrey', age: 30 };   // 没写任何类型
user.naem;        // ← 编译器自己推断出形状,直接报错

// 泛型推断:类型穿过函数继续流动
const first = (arr) => arr[0];
const n = first([1, 2, 3]);   // n 自动是 number,无需声明

"类型能自己穿过函数边界流动"——抓住这个画面,下一节就是它的放大版。

端到端类型安全:让契约穿过两个传统断点

单文件内的类型安全早已是标配,真正的难题在边界上。类型历来会在两个地方"断流":

餐厅 · 单据的命运 后厨各工位之间传的是标准打印单据,谁都不会看错(同文件内的类型)。但单据一旦要传真到分店(网络边界:fetch 回来的数据是 any,后端改了字段前端毫不知情),或者口头喊单(URL 边界:路由参数本质是字符串拼接,改了路由定义没人提醒你),就退化成手写体——全靠运气。端到端类型安全,就是让同一张单据从农场到餐桌全程不换格式
端到端类型安全:让类型穿过两个传统断点 数据库 schema Drizzle / Prisma 生成类型 server function 返回值类型被推断 断点 ① 网络边界 loader / query 类型继续流动 组件 data 全类型 类型安全路由(TanStack Router) 路由参数 · search params 全部强类型,链接写错编译不过 断点 ② URL 边界 后端改一个字段名 → 前端所有用到它的地方瞬间全线标红,而不是上线后才发现
实现的关键魔术:客户端 import 的是服务端函数的"类型",构建器(第五幕!)把真实调用替换成网络请求——类型流过去了,代码没流过去。

实现机制主要是两招:共享推断——前后端同仓,客户端直接 import 服务端函数的类型签名做推断,运行时由构建器换成 RPC 调用(tRPC 把这招发扬光大,TanStack Start 的 server functions、Next 的 server actions 同属一脉);把约定变成类型——路由历来是"字符串约定",TanStack Router 把整棵路由树建成类型结构,于是 paramssearchParams 全部可推断、可校验。所以"端到端类型安全是 TanStack 的卖点"翻译成人话就是:在前后端边界和路由边界这两个历来最容易烂掉的地方,把运行时炸弹换成编译期红线——对快速迭代的团队,这直接等于重构的胆量。

一剂清醒针

TS 类型在编译后会被完全擦除——它是给开发期的合同,管不了运行时闯进门的陌生人(用户提交的表单、第三方 API 的响应)。所以边界上还需要运行时校验(Zod / Valibot 这类 schema 库)站岗。类型 + 运行时校验双保险,才是完整的工程答案。

第六幕 · 一句话

类型系统的价值 = 错误发现时刻的左移量 × 边界覆盖范围。框架竞争的新前线,就是把类型推到更多的边界之外。

幕间 · 走到这里 六根支柱立齐了。还记得第二幕悬挂的那道题吗——"声明式的写法,如何翻译成命令式的、最小化的 DOM 操作?"整座建筑只剩这最后一块拱顶石。第七幕,回到引擎舱最深处。

act vii · the last mile

第七幕 · 最后一公里

虚拟 DOM vs 编译时——框架性能差异的根源,第二幕悬题在此收口

把问题重新摆上桌:状态变了,框架重新算 f 就知道"应该变成什么样",但它不知道哪里变了。整棵 DOM 推倒重建不可接受(第一幕:节点贵、布局更贵)。于是核心难题是——

核心难题

如何用最小的代价,找出新 UI 与旧 UI 的差异,并只更新差异部分?

两条路线的全部分歧浓缩在一个问题上:关于"哪里会变"的知识,是运行时每次现场勘查,还是编译时一次性固化

两种装修队 路线一(VDOM)像一支带着相机的装修队:每次客户改需求,先在图纸上把整间房重画一遍(重新执行组件函数,生成新虚拟树),拿新旧两张图纸玩"找不同"(diff),最后只动真房子里有差异的地方。图纸是纸做的,画起来便宜——但每次都要整张重画、整张比对。

路线二(signals)像一支装修时就把电线埋好的队伍:开工那天(编译期/首次运行)就记下"这个开关接这盏灯"(这段 DOM 读了这个 signal → 订阅成立)。之后客户说"客厅灯换暖光",不画图、不比对——顺着电线直达那盏灯。
路线一:运行时找差异(VDOM) 路线二:编译时知道差异(signals) 状态变化 重新执行整个组件函数 新虚拟树 ↔ 旧虚拟树 diff 把差异补丁打到真实 DOM 编译期 / 首次运行:建立订阅 "这个文本节点读了 count" → 记下这根线 状态变化(signal 被写入) 沿订阅线直达 只更新订阅了它的 DOM 节点 组件函数只在创建时跑一次,无 diff、无重执行 代价:每次更新都有"重跑 + diff"开销 代价:依赖编译器/响应式原语的约束
左边每次更新都"现场勘查哪里变了";右边出生时就接好了电线,更新只是按下对应的开关。

路线一:虚拟 DOM——用算力换心智模型

React 的回答(2013)有一个常被忽略的真相:VDOM 当年解决的不是性能问题,而是编程模型问题。它让"每次都重新渲染一切"这种最简单、最不会出错的心智模型变得性能上可行——便宜的 JS 对象树先排练,贵的真实 DOM 只挨补丁。"性能够好"是手段,"可以无脑重渲染"才是目的。

但它的开销是结构性的:哪怕只改了一个数字,整个组件函数照样重跑、整棵子树照样 diff。React 生态里的 memouseMemo、依赖数组,本质全是在手动给这台机器踩刹车;而 React Compiler 试图让编译器自动踩——注意这个动向:连 React 自己也在往编译时挪

路线二:编译时 + signals——用信息换运行时

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

终幕 · 合流

七幕走完,回望来路——以及带走的三件武器

把整段旅程压缩成一段楼梯,自下而上再走一遍:

  1. 物理世界给出了不可谈判的定律:一条渲染管线、一个 JS 线程、每个网络往返都要付钱。
  2. 声明式范式在定律之上立了一个等式 UI = f(state):交出 DOM 控制权,换取复杂度从平方降到线性。
  3. 状态是等式的左操作数,十年五代,归根结底是"归谁所有、通知给谁"——其中最锋利的一刀是分清"你的状态"和"服务器的快照"。
  4. 渲染策略决定计算的时空分布——三个厨房、一笔人人想逃的 hydration 税,以及"逐页逐组件混搭"的现代答案。
  5. 构建工具是看不见的工厂,同时握着开发反馈速度和生产包形态两个咽喉。
  6. 类型系统是贯穿所有边界的契约,把 bug 的死亡时刻整体左移,并把"重构的胆量"还给团队。
  7. 两条渲染路线收口了第二幕的悬题:声明式如何落地为最小 DOM 操作——运行时勘查,或编译时埋线。

三条母题:以后消化任何新概念的滤网

如果把七幕再压缩到极限,剩下三句话。它们不只是总结——以后任何新框架、新缩写、新范式出现时,拿这三个问题过一遍,新东西会自动在你脑中归位:

全书一句话

前端框架 = 在浏览器的物理定律之上,用声明式范式管理"状态 → 像素"的同步,并对"计算发生在哪里、何时发生"做出一整套默认决策的系统。

最后回到那家餐厅。七幕之后它完整地开起来了:大堂有不可更改的店规(浏览器),菜单是一纸映射而非操作手册(声明式),账本分清了自家的账和供应商的货(状态),菜在三个厨房之间被精确调度(渲染策略),后场有一座决定一切节奏的中央工厂(构建),每张单据从农场到餐桌全程不换格式(类型),而厨房感知"客人改了单"的方式,正从挨桌巡查进化成每张桌子直通后厨的铃(两条路线)。

框架的名字是时尚,这七个概念是语法。掌握语法的人不追时尚——他们读得懂每一季时装在重复什么。■

↑ 顶部