跳到主要内容

第 9 章 前端框架:为什么需要声明式 UI

副题:状态是唯一真相——当"改界面"变成"同步状态",命令式就撑不住了

第 8 章我们解决了白屏,页面能打开了。但内容流只是开始:要滚动加载、评论要展开收起、登录态要变、下单流程要分步——页面交互越来越复杂,代码里到处是"找到这个节点、改它的样式、再找那个节点、换它的内容"。改着改着,你撞上了一个绕不过去的墙:手动操作 DOM 的代码,变成了状态同步的泥潭。本章回答:为什么页面复杂度一上来,业界就转向了"声明式 UI"——以及框架到底帮你做了什么。

本章要建立的认知

  1. 命令式"改界面"的困境:状态散落在变量里,视图要靠代码手动同步——状态一变,漏同步就出 bug;
  2. 声明式换了个思路:你描述"界面应该是什么样"(状态是唯一真相),框架负责"怎么变成那样"——复杂度从"每一步怎么改"转移到"每一处怎么描述";
  3. 框架的三件套:组件化(组织)、声明式渲染(描述)、虚拟 DOM(高效地变)——每件都对应一个具体的痛点。

9.1 命令式泥潭

老周的白屏解决后,内容流页面开始加功能。

第一个功能:滚动加载。滚到底部,调 GET /v1/contents?page=2(第 7 章的契约),把新卡片插进列表。命令式写法很直接:

scrollContainer.addEventListener('scroll', () => {
if (到达底部) {
const cards = await fetchContents(page + 1);
cards.forEach(card => {
const el = document.createElement('article'); // 手动建 DOM
el.className = 'post';
el.innerHTML = renderCard(card);
list.appendChild(el); // 手动插进列表
});
page++;
}
});

第二个功能:评论展开。点击卡片上的评论数,展开评论列表。又是"找到节点、填充内容、切换显示"。

第三个功能:登录态。用户登录后,页面右上角要从"登录"变成"用户名 + 退出",内容流里要出现"发布内容"按钮。又是"找到节点、改文本、切换显示"三连——而且登录态是全局的:右上角要改、内容流要改、评论区要改,登录这个动作要同步三个地方。

滚动加载还有一个更隐蔽的泥潭:分页状态不止 currentPage 一个。滚到底部要不要继续加载(loading 状态)、还有没有下一页(hasMore 状态)、滚动位置在哪(scroll 状态)——三个状态要同步:加载中要禁用重复触发、没更多要显示"到底了"、回到页面要恢复滚动位置。手动同步这三个,每个都是"改 A 忘 B"的候选。

三个功能叠加,代码开始不对劲了。最典型的一天:用户小明登录后,发了一条评论——然后你收到 bug 报告:"评论数还是 3,但明明有 4 条了。"

排查:评论数那个 <span> 是在发布评论的接口成功回调里更新的;但小明登录后,页面重新拉取了内容列表(登录态变化触发的刷新),列表重新渲染时,评论数是从列表数据里取的——但那条新评论还没同步进列表数据。两个地方更新同一个数字,一个忘了另一个。修好之后你发现:类似的"两个地方改同一个东西"的代码,页面里还有七八处。

这个 bug 不是粗心,是命令式 DOM 编程的结构性缺陷状态(数据)和视图(DOM)是两套东西,代码负责在它们之间手动同步。每加一个功能,就多几条"状态变了 → 手动改 DOM"的代码;每多几条这样的代码,就多几个"改了 A 忘了 B"的漏网之鱼。第 8 章说过 DOM 操作是昂贵的(reflow 是全局的),现在还要加上:DOM 操作不只是慢,还是错的温床——手动同步的每一行,都是潜在的"不同步"。

状态散落还有三种典型形态,先认识(后面判断"要不要框架"要用):全局变量let currentPage = 1 谁都能改);DOM 属性(把状态藏在 data-* 属性里,读取靠 dataset);URL 参数(分页状态藏在地址栏里)。三种形态的共同问题:状态没有一个"唯一真相"——同一个数字,在变量里有一份、在 DOM 里有一份、在 URL 里有一份,改的时候要记得同步三份。

怎么判断自己已经陷进泥潭了?一个简单的度量:**改一个功能,要动几个地方?**改"评论数"要动接口回调、列表渲染、登录刷新三处——你已经陷进去了;改"评论数"只动一处——你还在岸上。泥潭不是一晚上陷进去的,是每个功能加一处"同步",慢慢陷进去的——没有任何一个时刻你"决定"进入泥潭,但回头一看,泥潭已经在脚下(这也是"改一个功能要动几个地方"这个度量存在的意义:它是渐进恶化的早期警报)。这本身就是第 2 章演进循环的前端实例:页面复杂度上升(功能叠加)→ 原方案无法承受(手动同步开始出错)→ 新技术出现(框架)→ 解决(声明式)→ 新代价(框架的抽象层,本章后半段)。前端框架不是"潮流",它是被"评论数 bug 们"逼出来的。

泥潭的"体感"是这样的:第一个功能(滚动加载)用了 30 行命令式代码,感觉良好;第二个功能(评论展开)又加 30 行,还能忍;第三个功能(登录态)再加 30 行——三个功能各自都合理,但代码里"找节点、改样式、插内容"的模式重复出现了十几次。泥潭的可怕之处不是代码多,是每一行单独看都正常——没有一行是"写错了"的,组合起来却让人改不动。

泥潭还有一层和第 8 章叠加的代价:手动操作多 = reflow 多。第 8 章说过 DOM 操作昂贵(reflow 是全局的),命令式代码里"插一条卡片就 appendChild 一次、改一个数字就 textContent 一次"——操作又多又碎,页面的布局重算也跟着多。所以泥潭是双重问题:正确性上状态乱(改 A 忘 B),性能上操作碎(reflow 频繁)——第 16 章性能优化时,"批量 DOM 操作"就是给命令式擦屁股;而声明式从机制上就绕开了。

命令式的正面也要说一句,保持平衡:简单交互(点击弹个提示)命令式三行搞定,引入框架反而重;性能极致场景(手写 canvas 游戏)命令式是唯一选择——框架的边界在第 9.5 划。泥潭说的不是"命令式不能用",是"状态一多,命令式撑不住"。

这种状态,业界给它起了个名字:命令式泥潭(imperative mess)。泥潭里的开发者每天的工作不是"写功能",是"追状态"——这个变量在哪儿改的?那个 DOM 什么时候同步的?改了这个,那个会不会不同步?

泥潭还有一个心理学层面的特征:每个功能单独做的时候都感觉正常——滚动加载 30 行,正常;评论展开 30 行,正常;等发现不对的时候,代码已经散在几十处了。

为什么 1-3 年的开发者特别容易陷进泥潭?两个原因:先写功能后想结构——功能是从"加一个滚动加载"这种小需求长起来的,每个小需求单独看都合理,叠加起来才失控;以及"手动操作 DOM"是入门教程的第一课——jQuery 时代(第 9.5 会讲它和框架的区别)教的就是"找到节点改它",这个习惯根深蒂固。泥潭不是能力问题,是思路问题——而思路问题,靠更小心是解不了的。

泥潭的解法,不是"写得更小心",而是换一个思路。预告一下答案:这个 bug 在声明式里的修法是结构性的——把评论数提升为状态(而不是两个地方的 DOM 文本),发布评论成功后只更新状态,显示自然跟着变,不存在"第二个地方"。修 bug 从"找漏同步的代码"变成"删掉一个手动同步"——这正是声明式值得的理由:它消灭的不是 bug,是产生 bug 的整类机制。下一节,正式讲这个思路。

9.2 声明式:换个思路

泥潭的解法,不是"写得更小心",是换一个思路

命令式的思路是:我要一步一步告诉浏览器怎么改——找到节点、改样式、插内容。声明式的思路是:我告诉框架"界面应该长什么样",框架负责"怎么变成那样"

// 命令式:一步一步改
function updateCommentCount(count) {
document.querySelector('.comment-count').textContent = count;
}

// 声明式:描述"界面 = 状态的函数"
function CommentCount({ count }) {
return <span className="comment-count">{count}</span>;
}

第二段代码里没有"改"——它只是说"评论数这个界面,等于 count 这个状态"。状态变了,框架重新计算界面,界面就变了;开发者不再需要"找到节点 → 改"这个动作,因为界面是状态的一个函数界面 = f(状态)

这个思路的核心是一条纪律:状态是唯一真相。评论数显示多少,只取决于 count 这一个状态;count 在哪儿更新,显示就跟着变——不会出现"两个地方改同一个数字"的问题,因为数字只有一个来源。命令式泥潭里"改了 A 忘了 B"的 bug,在声明式里结构性地不可能发生:你不需要同步,因为根本没有"第二个地方"。(这一条纪律是本章一切机制的出发点:界面、组件、虚拟 DOM,都是为了让这句话成立;它也是第 16 章性能优化的坐标——所有优化都是"让 f 算得更快",而不是"绕过 f 手动改"。)

声明式的开发者体验也描述一下:改界面 = 改状态。想在评论数旁边加个"已读"标记?在状态里加一个字段,界面描述里加一行——不用想"哪个节点、哪个时机、哪段代码"。调试时,状态是可打印的(console.log 一个对象就知道界面为什么长这样);命令式里,界面是"一系列操作的累积结果",要复现操作序列才能知道为什么长这样。声明式把'界面为什么是这样'从'考古'变成'查状态'——这个体验差异,是用过框架就回不去的。

"界面 = f(状态)"还有一个隐藏收益:可测试性。f 是纯函数——同样的状态输入,同样的界面输出,没有"当时谁改过它"的隐式依赖。测试一个组件,就是测"状态 → 界面"的映射:给状态,断言界面(第 18 章的测试体系会看到组件测试就是这么写的)。命令式的界面没法这么测——每个界面都是"一系列操作的累积结果",要复现操作序列才能复现界面。

这和第 4 章的验收标准是同一个思想:第 4 章说"验收标准 = 可度量的句子",声明式把它变成了"可断言的函数"——给状态、断言界面,验收标准从文档变成了测试代码(第 17 章会看到这个转化)。

图9-1 图稿占位
声明式 vs 命令式
描述"要什么" vs 一步步"怎么做"

代价先说清楚:声明式不是魔法。状态变了,框架要重新计算整个界面(而不是只改一个数字)——这就是 9.4 的虚拟 DOM 要解决的效率问题。声明式把"同步的复杂度"换成了"重算的复杂度",前者靠人脑,后者靠框架——人脑不可靠,框架可以优化,这是这笔交换划算的根本原因。

声明式也有它的边界,必须诚实:不是所有界面都能纯函数化。动画、焦点、滚动位置、剪贴板——这些是"副作用",f(状态) 管不了它们(同一个状态,动画进度不同,界面不同)。框架的答案是给副作用留"后门"(React 的 useEffect 就是干这个的):副作用是例外,要显式声明、在渲染之后执行。这个边界理解到位,就不会出现"声明式怎么连个动画都做不好"的困惑——不是做不好,是副作用本来就不该塞进 f。

"把怎么做交给引擎"还有第二层代价:你失去一部分控制。最典型的是渲染时机——"我没改这个组件,它为什么重渲染了?"(9.4 说过:父组件重渲染,子组件默认跟着重算)。命令式里渲染时机完全由你控制(不会"莫名其妙多渲染一次");声明式里时机由框架的规则决定,你得理解规则(useMemo 就是"告诉框架别重算")。这是"把怎么做交给引擎"的完整代价:引擎的决策不一定最优,你得学会和引擎沟通——但"和引擎沟通"(学几条规则)比"自己管所有同步"(记几百个地方)便宜得多。

声明式的思路在计算机世界里并不陌生——数据库的 SQL 就是声明式的SELECT * FROM contents WHERE author_id = 3 描述"我要什么数据",数据库负责"怎么查"(走索引还是全表扫,是查询优化器的事,第 14 章会看到)。命令式写法要自己写循环、自己判断、自己拼结果;声明式写法只描述目标。第 9 章讲的"界面 = f(状态)"和 SQL 的"结果 = f(查询)"是同一个思想:把'怎么做'的复杂度交给引擎,把'要什么'的表达留给开发者。理解了这一层,框架就不再是"一个工具",而是一种思维方式——第 14 章讲 SQL 时,你会发现"又是声明式"。

9.3 组件:把界面组织成树

声明式解决了"状态不同步",但还有一个问题:页面越来越大,界面代码怎么组织?第 8 章说过"三层分工的代价是要协同"——HTML 结构、CSS 样式、JS 行为分开维护,改结构要想着样式、改行为要想着结构。组件化是答案:把一个界面单元的结构、样式、行为绑在一起,做成一个"组件"

// 一个帖子卡片组件:结构 + 样式 + 行为绑在一起
function PostCard({ post, onComment }) {
return (
<article className="post"> {/* 结构 */}
<h2>{post.title}</h2> {/* 数据 */}
<button onClick={onComment}> {/* 行为 */}
评论 ({post.commentCount})
</button>
</article>
);
}

组件的好处是第 6 章"实体边界"在界面层的复刻:独立的生命周期(组件有自己的状态)、独立的复用单元(一个组件到处用)。内容流的帖子卡片是一个组件,评论区是一个组件,登录框是一个组件——页面 = 组件的嵌套,组件的嵌套就是一棵组件树

组件不是框架发明的——它是模板(MVC 时代的 "view")演进来的。模板是'半声明式':它声明了结构(<div>{{ title }}</div>),但状态同步还要手动(数据变了,要手动重新渲染或手动更新节点)——模板解决了"结构怎么描述",没解决"状态怎么同步"。组件在模板的基础上加了两样东西:行为绑定(结构旁边就是事件处理,不用到处 querySelector 找节点)和状态封装(组件自己的 state,变了自动重渲染)。从模板到组件,是"声明式从结构扩展到状态"的演进。

第 8 章说过:渲染树是"物理层"的树,组件树是"逻辑层"的树——现在可以兑现了。开发者按业务组织组件树(帖子卡片 → 评论区 → 评论项),浏览器按渲染规则组织渲染树(每个组件拆成几百个盒子和文本节点)。组件树和渲染树之间的翻译,是框架做的:你只管组件树(业务逻辑),框架管渲染树(浏览器细节)——这就是"框架帮你做什么"的第一个答案。

把第 8 章和第 9 章合起来看,界面有三棵树组件树(你写的,逻辑层)→ 虚拟 DOM 树(框架维护的,纯 JS)→ 真实 DOM 树(浏览器的,物理层,再变成第 8 章的渲染树)。每两层之间的翻译:组件树 → 虚拟树是"渲染"(运行你的组件函数),虚拟树 → 真实 DOM 是"patch"(9.4),真实 DOM → 渲染树是第 8 章的管线。三棵树的链条,就是"你的代码"到"屏幕像素"的完整路径——第 16 章优化时,每一层翻译都是候选的优化点。

对应地,调试工具也分两层:**React DevTools(Vue DevTools 同理)**是浏览器开发者工具的扩展,直接显示组件树、每个组件的 props 和 state——第 8 章用 Elements 面板看"物理层"的 DOM 树,DevTools 看"逻辑层"的组件树。两棵树都能看了,"界面为什么长这样"就完全可视化了:组件树看逻辑、Elements 看结果、Network 看数据(第 8 章)。

组件之间怎么通信?两条通道,先认识名字(第 12 章服务端分层时会看到同款结构):props(父组件传给子组件的数据,单向)和state(组件自己的状态,变了组件就重渲染)。父组件把数据通过 props 传下去,子组件把事件通过回调传上来(onComment)——数据向下流,事件向上报。这个"单向数据流"是声明式纪律的组织化形态:状态从哪来、往哪去,在组件树里是一目了然的,而不是散落在全局变量里。

评论区组件的完整形态看一眼(示意):

function CommentSection({ postId }) { // props:从父组件来
const [comments, setComments] = useState([]); // state:组件自己的

async function load() {
const data = await fetch(`/v1/contents/${postId}/comments`); // 第 7 章的契约
setComments(data); // 状态变了 → 自动重渲染
}

return (
<div>
{comments.map(c => <CommentItem key={c.id} comment={c} />)}
<button onClick={load}>加载评论</button>
</div>
);
}

注意三件事:props 单向(postId 从外面进来,组件不修改它);state 是私有(comments 只有这个组件能改);状态变了界面自动变(setComments 之后,框架负责重新渲染,没有一行"手动改 DOM")。

如果两个组件要共享同一个状态(比如登录态:右上角头像和内容流的"发布按钮"都要知道"登录了没有"),怎么办?答案是状态提升:把共享的状态从两个子组件里拿出来,放到它们的共同父组件,通过 props 传下去——"状态在哪儿,谁就拥有它"。状态提升是声明式里最常见的"重构"动作,它和"状态散落在全局变量里"的区别是:提升是有路径的(沿着组件树),散落是没路径的(到处都能改)。

props 还有一个隐含纪律:只读。子组件收到 props 只能读,不能改——改状态是父组件的事(通过回调上报,第 9.3 的 onComment)。这个"单向"设计是刻意的:如果子组件能直接改 props,"状态从哪来"就又不清楚了,声明式的可预测性会被钻出洞。单向数据流的"单向"两个字,就是防这个洞的。

组件边界的设计也是一个真实决策:拆太粗(一个巨型组件管整个页面),状态又混在一起;拆太细(每个按钮一个组件),props 传递变成穿针引线。判断标准和第 6 章实体边界同款:有没有独立的状态、会不会被复用——有独立状态或被多处复用,拆;否则,先不拆。

组件复用还有一个和第 6 章同构的视角:它是"代码复用"在界面层的形态——后端有函数、有服务(第 12 章),前端有组件。复用的判断标准也一致:被两个以上地方需要,才值得抽出来——为一个地方抽组件,是提前设计(第 6 章的"过早细化");为三个地方抽组件,是消除重复(第 6 章的"无意识的冗余")。组件树太深还有一个现实问题:props 要一层层传("穿针引线"),状态库(Redux/Pinia 一类)就是为"跨层共享状态"而生的——但那是生态话题(9.5),机制上记住"状态提升"就够起步。

组件的本质还可以用一句话说清:组件是函数——props 是输入,界面是输出PostCard(props) → 界面)。这个视角和第 9.2 的"界面 = f(状态)"完全一致:组件就是 f 的组织化形态。理解了"组件 = 函数",就理解了为什么组件要"纯"(同样的 props 出同样的界面——测试才好写,第 17 章会看到)——和 9.2 的可测试性论证是同一件事。

9.4 虚拟 DOM:高效地"变"

声明式 + 组件化的代价兑现了:状态一变,框架要重新计算界面。如果每次重算都直接操作真实 DOM,第 8 章说过的"DOM 操作昂贵"(reflow 全局性)会让页面卡成幻灯片——状态每变一次,整棵 DOM 重建一次

框架的答案是虚拟 DOM(Virtual DOM):在真实 DOM 和状态之间,加一棵"虚拟树"(用 JS 对象模拟的 DOM 结构)。

先感受一下"没有虚拟 DOM"的代价:内容流 100 条卡片,小明发了一条评论,评论数要 +1。没有虚拟 DOM,框架只能"重新渲染整个列表"——100 条卡片全部重建,每条都要重新走第 8 章的管线(创建节点、插入、布局、绘制),浏览器要白忙一大圈。有虚拟 DOM:重算一棵虚拟树(纯 JS,快),diff 发现"只有第 3 条卡片的评论数变了",patch 只改那一个 <span> 的文本——真实 DOM 操作从 100 次变成 1 次。这个对比就是虚拟 DOM 存在的全部理由:把'重建整页'的代价,降成'只改差异'

// 真实 DOM:浏览器的东西,操作贵
document.querySelector('.post-title').textContent = '新的标题';

// 虚拟 DOM:普通 JS 对象,操作便宜
const vnode = { tag: 'h2', props: {}, children: ['新的标题'] };

状态变化后的流程是四步:

  1. 重算:状态变了,框架用新的状态重新生成一棵虚拟 DOM 树(纯 JS 计算,便宜);
  2. 对比(diff):新旧两棵虚拟树逐节点对比,找出"哪里变了"(标题变了、评论数变了、其他没变);
  3. 打补丁(patch):只把"变了的地方"翻译成真实 DOM 操作——只改那个 <span> 的文本,不碰其他节点;
  4. 真实渲染:浏览器只对改动过的节点走布局和绘制。

patch 的具体操作其实很朴素:diff 发现"文本变了"→ node.textContent = 新文本;"新增了节点"→ parent.appendChild(新节点);"删除了节点"→ parent.removeChild(旧节点)——都是第 9.1 命令式时代的老朋友,只是由框架来决定做哪些。框架没有发明新的 DOM 操作,它发明的是"决策":做哪些、不做哪些,由 diff 说了算,而不是靠开发者记性。

diff 的粒度也说清:它对比的是节点级别——文本变了(textContent)、属性变了(classstyle)、子节点增删(appendChild/removeChild)。三种差异对应三种 patch 操作,其余没变的节点一概不碰。所以"状态变了一次,diff 只发现一处差异"是常态——框架的性能收益来自"只改该改的",而不是"重算得有多快"。

图9-2 图稿占位
虚拟 DOM diff
状态变化→diff→最小 patch

第 8 章的"DOM 昂贵"在这里兑现了:虚拟 DOM 把'直接操作真实 DOM'变成'先在虚拟层算出最小改动,再精准操作'——真实 DOM 操作从"重建整页"变成"只改差异处",reflow/repaint 的范围从"全局"缩到"局部"。

把虚拟 DOM 放回第 8 章的管线:它不是在管线里加了一段,而是管线的"前置优化"——patch 之前先算出"最少操作",让进入管线的操作次数从 N 次降到 1 次;浏览器真正走的还是第 8 章那五步(解析不用了,布局/绘制要),只是走的次数少了、范围小了。第 8 章讲管线、第 9 章讲怎么少走管线——两章合起来,才是"页面性能"的完整认知。

虚拟 DOM 还有两个顺带的收益:跨端——虚拟树是纯 JS 结构,不依赖浏览器 DOM,所以同一套组件可以渲染成 Web(DOM)、App(原生组件)甚至测试环境(内存树)——"一次编写,多处渲染"的底层机制就是虚拟树;心智模型统一——开发者永远在操作"普通 JS 对象",不用碰 DOM API 的细节。

还有一个"什么时候重渲染"的问题,现在就认识(第 16 章的优化就从这里开始):框架在三种情况下重渲染组件——state 变了、props 变了、父组件重渲染了。前两种是"数据真的变了",第三种是"父变了我可能也变"(框架不知道子组件会不会受影响,默认重渲染)。第三种是性能优化的主要战场:"父组件重渲染导致整棵子树白算一遍"——useMemo/useCallback 这类优化(第 16 章)就是告诉框架"这个子组件不用跟着重算"。

diff 算法有一个关键细节要认识:列表为什么要 key。diff 对比新旧虚拟树时,遇到列表(多个同类型节点),它怎么知道"第 3 条卡片对应旧树的第几条"?靠 key(<CommentItem key={c.id} />)——key 是节点的"身份证",diff 按 key 配对,才知道哪条是新增、哪条是移动、哪条是更新。列表不写 key,框架只能按位置猜——猜错的结果是:本该复用的节点被重建,本该更新的节点被误判(性能 bug 的头号来源,第 16 章会看到)。

key 还有一个和第 6 章的呼应:它就是虚拟 DOM 树的主键。第 6 章讲表结构时说过主键的职责(唯一标识一行、让数据库知道"这条就是那条")——key 在虚拟树里干的是同一件事(唯一标识一个节点、让 diff 知道"这个就是那个")。同一个思想,在数据层和界面层各出现一次——这也是本书反复强调的:机制是相通的,换的是场景

虚拟 DOM 的代价也要诚实说:diff 本身有成本。状态频繁变化、组件树巨大时,diff 的开销会显现;列表很长时,框架的 diff 算法也可能"看不出"该复用哪个节点(React 靠 key 提示)。这些是第 16 章性能优化的具体战场——现在只需要知道:虚拟 DOM 不是"不用优化",是把优化的战场从"手动同步"搬到了"diff 效率"

还有一个必须说破的真相:虚拟 DOM 不是永远比手动操作快。一个前端高手把手动 DOM 优化到极致(只改该改的节点、批量操作、避开 reflow),可能比框架更快——虚拟 DOM 的真正价值不是"最快",是"不用手动优化也能达到可接受的性能"。框架买的是"性能的下限":默认实现已经足够快,把省下来的心智留给业务。第 16 章会看到,当性能真的成为瓶颈时,优化手段依然是"手动干预渲染"(跳过 diff、直接操作节点)——虚拟 DOM 是默认值,不是天花板。

虚拟 DOM 还有一个"为什么叫虚拟"的深层理由:虚拟层是'计划',真实层是'执行'。先在内存里模拟一遍(虚拟树、diff、算出最小操作),再对真实 DOM 执行计划——和第 8 章的关键路径同构:先想清楚最短路径,再动手。"先计划后执行"是本书反复出现的模式(第 5 章的决策、第 12 章的中间件、第 14 章的查询计划)——虚拟 DOM 是它在界面层的又一次实例。(还有一层深意:计划错了可以重来——diff 重新算一遍就行;执行错了要付真实代价——DOM 操作不可撤销。所以先在虚拟层把计划做对,是成本最低的纠错时机。)

虚拟树还有一个顺带的形态:服务端渲染(SSR)——服务器上把组件渲染成虚拟树,再输出成 HTML 字符串(而不是 DOM 操作),浏览器拿到 HTML 直接显示(第 8.2 的 SSR 模式),之后"激活"(hydration)让页面恢复交互。SSR 能存在,正是因为虚拟树是纯 JS——不依赖浏览器也能"渲染"。这个形态在第 12 章服务端会再见到。

9.5 框架的代价与边界

框架解决了很多问题,但它不是免费的。第 5 章的决策模型在这里做一次完整的回顾——要不要用框架,本身就是一次技术选型,只是大多数项目在"默认用 React/Vue"时没意识到自己做了选择。

走一遍九步(轻量版):业务目标:页面交互复杂后,状态与视图的同步可控;业务约束:阶段 1 团队 1-2 人、前端同事是兼职、页面复杂度正在爬升(滚动加载/评论/登录态);技术问题:要不要引入前端框架?候选方案:A 继续纯手动 DOM(第 9.1 的泥潭);B 引入框架(React/Vue);C 轻量方案(自己写一个"迷你声明式"——最不划算,等于自研框架);评价维度:状态管理、团队上手、生态、包体积;方案选择:B——页面复杂度已经到了"泥潭"阶段,而框架的生态(组件库、工具链)省下的时间远超学习成本;收益:状态可控、组件复用、生态成熟;代价:抽象层(要理解渲染时机)、包体积(几百 KB)、学习成本;演进条件:页面回归简单(不太可能)或性能瓶颈出现在框架本身(第 16 章再评估)。

九步走完,和第 5 章的决策记录对上了:当初选 React 的理由(前后端同语言、生态大),在这里变成了"泥潭的解药"——选型的收益,总是在后来的章节里兑现(第 5 章选 JSONB,第 6 章兑现;选 React,本章兑现;这样的跨章兑现,后面章节还会有)。

压缩成决策记录(第 5 章的习惯):要不要框架——要(React)。目标:状态同步可控;约束:1-2 人、兼职前端、复杂度爬升;候选:手动 DOM/框架/自研;维度:状态管理+生态;选择:React(决策记录见第 5 章);收益:状态可控、组件复用;代价:抽象层/包体积/学习成本;演进条件:性能瓶颈在框架本身(第 16 章)。

框架的生态交代一句:React/Vue 不只是"一个渲染库",它周围有一圈配套(组件库、路由、状态管理、构建工具链)——这些生态是"选型时评价维度里的'生态'"的真实含义。本书只讲核心机制(组件/声明式/虚拟 DOM),生态的使用是查文档的事(第 1 章"为什么 vs 怎么点":机制讲为什么,生态自己点)。

框架的维护成本也要诚实:依赖要升级(安全补丁、版本跟进)、生态在变(库的维护者可能弃坑)——这些是"用框架"的持续成本,属于团队日常(第 20 章代码质量会看到依赖管理)。但对比命令式泥潭的"持续出错",框架的"持续维护"是明确划算的——泥潭的成本是隐性的(bug 在线上),框架的成本是显性的(升级在日程上)。

框架和第 27 章还有一个呼应:它让迭代更快。第 27 章的迭代循环里,"加新功能"是常态——声明式下加功能 = 加状态 + 加界面描述,不用在几十处手动同步里穿针引线;框架把"加功能"的成本从"全链路排查"降成"局部修改"。这也是为什么选框架的收益要到迭代多了才完全显现——阶段 1 的功能少,框架的优势不明显;阶段 3 之后,每加一个功能省下的时间,都是当初选框架的回报。

框架的边界也要划清:**什么时候不需要框架?**三个信号——页面几乎无交互(纯展示页,静态 HTML 就够);状态极少(两三个变量,手动同步的成本可忽略);团队完全不会且没有学习预算。判断标准和第 4 章砍需求同一个逻辑:复杂度没到,就别引入——框架是被"泥潭"逼出来的,不是被"潮流"逼出来的。

一个具体的反例:如果案例产品只是个"内容展示站"(只读、无评论、无登录),那 React 就是过度设计——静态 HTML + 一点点 JS 就够了。但案例有评论、有登录、有交易,交互复杂度摆在那里——框架不是"锦上添花",是"雪中送炭"。要不要框架,答案永远取决于"你的页面有没有陷进泥潭",不取决于"别人都在用什么"。

还有一个经常被问的对比:框架和"库"(如 jQuery)的区别。jQuery 是"让手动操作 DOM 更顺手"(还是命令式,只是语法糖);React/Vue 是"换一种思路"(声明式,结构性地消灭手动同步)。两者的差别不是 API 多少,是范式——这也是为什么"学完 jQuery 再用 React 觉得别扭":不是 API 不会,是思路要换。

把前端框架放回本书更大的语境:前端框架和后端框架(第 12 章)是同一个思路的两端——都是"把一类系统的常见问题答案内建":前端框架内建了"状态→界面"的答案,后端框架内建了"请求→响应"的答案(路由、中间件、模板)。理解了这个共性,学任何框架(前端的、后端的)都更快:先问"它内建了哪类问题的答案",再学它的 API。

React 和 Vue 的对比也常被问:选哪个?本书不站队(第 5 章决策记录写过)——两者的机制(声明式/组件/虚拟 DOM 或响应式)同构,差异在 API 风格和生态。React 的"UI = f(state)"(2013 年的口号)和 Vue 的"响应式"(状态变了自动追踪依赖)是同一个思想的两种实现;选型维度(团队技能、生态)走第 5 章的九步。本章讲机制不讲 API,就是因为:机制学会了,React 和 Vue 都只是同一个机制的两个方言

对读者来说,本章的"机制先行"也是一种学习路径的建议:先理解声明式/组件/虚拟 DOM 的机制,再学框架的 API——机制是十年后还有效的(第 1 章"为什么"),API 是查文档就行的。先读本章再学 React/Vue,你会发现自己学的不是"一个新工具",而是"同一个机制的实现"——学习成本降一半。

框架自身的版本演进也印证了这一点(第 2 章的演进循环在框架身上同样成立):React 从 class 组件到 hooks、Vue 从选项式到组合式——框架频繁发版不是"业界浮躁",是它在回应生态的压力(新需求、新性能问题)。这带来一个现实:框架的 API 会变,机制不会变——理解机制的人,版本升级是"换 API 名";只背 API 的人,版本升级是"重学一遍"。

最后给第 16 章埋一个接口:框架的渲染时机(什么时候重渲染)、useMemo/useCallback(跳过不必要的重算)、大列表的 key 与虚拟化——这些性能话题的机制都在本章(状态变→重算→diff→patch),第 16 章会沿着这条线展开"怎么让这条链更短"。本章和第 16 章的边界,和第 8 章同款一句话:第 9 章回答'框架为什么这样做',第 16 章回答'怎么让框架做得更快'

还有一个第 1 章的回扣:框架知识是最容易"名词地图化"的领域——hooks、虚拟 DOM、SSR、hydration、状态库……一堆名词,每个都能背定义,但连不成地图。本章的"机制先行"就是防名词地图:先把"状态→界面"这条主线立住,所有名词(组件、props、state、diff、patch、key)都是这条主线上的站点——你记住的不是名词,是名词之间的路。

9.6 本章对应表

业务诉求技术选择为什么代价/取舍
状态同步代码失控(改 A 忘 B)声明式 UI(状态是唯一真相)界面 = f(状态),同步结构性消失状态变了要重算界面,框架要接住效率
界面代码难以组织组件化(结构+样式+行为绑定)独立生命周期/复用单元,页面 = 组件树组件边界要设计,过度拆分也乱
DOM 操作昂贵虚拟 DOM(diff + patch)先把最小改动算出来,再精准操作真实 DOMdiff 本身有成本,大列表要 key/优化
组件间通信props(下)+ 回调(上)单向数据流状态来源一目了然,不散落全局深层次传递要"提升状态"等模式
要不要框架九步决策(阶段 1 要,被泥潭逼出)复杂度到了,生态收益超学习成本包体积/学习成本/抽象层(9.5)

每一行都在本章正文里有完整的论证:声明式在 9.2,组件化在 9.3,虚拟 DOM 在 9.4,九步决策在 9.5。和前面章节的对应表一样,先看"业务诉求"列——这一章的每一行,都是从"评论数 bug"和"滚动加载"里长出来的。

把本章放回旅程图(图 8-1):还在浏览器这一站——第 8 章回答"页面怎么画出来"(管线的形状),第 9 章回答"页面怎么变"(状态→界面的机制)。浏览器站的两章讲完了:,是浏览器里发生的两件大事。下一站离开浏览器——请求上路,第 10 章的网络段。对两种读者,本章的收益也呼应第 8 章:前端读者得到了"为什么用框架"的完整论证(泥潭→声明式→组件→虚拟 DOM 的因果链);后端读者得到了"前端不是黑魔法"——框架的每个机制,都是前面章节思想的复用(实体边界、单向数据流、先计划后执行)。全栈的"全",在框架这里又补了一块拼图。

本章小结

框架速查(第三部分回指本章时翻回这里):

  1. 命令式泥潭:状态散落、视图手动同步——"改一个功能要动几个地方"是泥潭度量;
  2. 声明式:界面 = f(状态),状态是唯一真相——SQL 是同一个思想(把怎么做交给引擎);
  3. 组件化:结构+样式+行为绑成单元;props 下、回调上(单向数据流);页面 = 组件树;
  4. 虚拟 DOM:状态变 → 重算虚拟树 → diff(靠 key 配对)→ 只 patch 差异——把"重建整页"降成"改差异";
  5. 要不要框架:九步决策(复杂度没到别用)——框架买的是性能下限,不是最快。

(五条速查对应本章的认知结构:1-2 是"为什么要框架"(泥潭与声明式),3 是"框架怎么组织"(组件),4 是"框架怎么变"(虚拟 DOM),5 是"什么时候用"(九步决策)——五条串起来,就是前端框架的完整决策地图,也是第 16 章性能优化的出发点。)


  • 命令式泥潭:状态散落、视图手动同步——"两个地方改同一个数字"是结构性缺陷,不是粗心;
  • 声明式换思路:你描述界面(状态是唯一真相),框架负责怎么变成那样——同步的复杂度从"人脑"转移到"框架",人脑不可靠,框架可以优化;
  • 组件化:结构+样式+行为绑成单元,页面 = 组件树——第 8 章"物理层/逻辑层树"的翻译是框架做的;
  • 虚拟 DOM:重算虚拟树 → diff → 只 patch 差异——把"重建整页"变成"精准改动",第 8 章"DOM 昂贵"的结构性解药;
  • 框架有代价(抽象层/包体积/学习成本)也有边界(没到泥潭别用)——要不要框架是九步决策,不是潮流跟随。

下一章

页面能渲染了,交互有框架管着了。但还有一个问题悬着:**用户在浏览器里输入网址、点击按钮——这些动作怎么到达服务器?**浏览器和服务器之间,隔着什么?

下一章开始,请求离开浏览器,走上网络——第 10 章,请求的上行:HTTP 与 HTTPS——一次请求的报文长什么样、连接怎么建立、数据怎么加密。浏览器这一站(第 8、9 章)到此结束:都讲完了,接下来看请求怎么走完剩下的路——但别忘了,请求的终点,还是回到这个页面,等着被画、被变。