跳到主要内容

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

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

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

本章要建立的认知

1. 命令式的困境:手动同步,处处是坑。 状态散落在各个变量里,视图靠代码一句句改 DOM——改文字、改样式、控制显隐。状态一多,漏同步是常态,bug 根本防不胜防。

2. 声明式的思路:状态是唯一真相,你只负责描述,框架负责变化。 你告诉框架"界面应该长什么样",数据变了,框架自动算出差异、更新视图。复杂度从"怎么一步步操作 DOM"转移到了"怎么准确描述状态与视图的关系"。

3. 框架的三件套,各解决一个痛点。 组件化解决"代码怎么组织",声明式渲染解决"状态怎么同步",虚拟 DOM 解决"更新怎么高效"。


9.1 命令式泥潭​

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

第一个是滚动加载:滚到底时,再去服务器要下一页的新数据,调 GET /v1/contents?page=2,把新卡片插进列表。命令式写法如下:

scrollContainer.addEventListener('scroll', async () => {
if (到达底部) {
const cards = await fetchContents(page + 1);
cards.forEach(card => {
const el = document.createElement('article');
el.className = 'post';
el.innerHTML = renderCard(card);
list.appendChild(el);
});
page++;
}
});

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

第三个是登录态:登录后,右上角从"登录"变成"用户名+退出",内容流要出现"发布"按钮。登录这一个动作,要同步改三个地方。

滚动加载还藏着更麻烦的状态:分页不止 currentPage 一个变量。有没有在加载(loading)、还有没有下一页(hasMore)、滚动位置在哪——三个状态要同步:加载中得禁用重复触发,到底了得显示"没有更多了",返回页面还得恢复位置。手动维护这些,"改 A 忘 B"是常态。

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

排查发现:评论数是在发布成功的回调里手动更新的;但登录态变化触发了列表刷新,列表重新渲染时,评论数从接口数据里取——新评论还没同步进去。同一个数字,两个地方在改,一个忘了另一个。修完后你发现,页面里还有七八处类似的"两个地方改同一个东西"。

这个 bug 的根源是命令式的结构性缺陷:状态和视图是两套东西,代码负责在它们之间手动同步。每加一个功能,就多几条"状态变了 → 手动改 DOM"的代码;每多几条,就多几个"改了 A 忘了 B"的漏网之鱼。

第 8 章说过 DOM 操作昂贵(reflow 是全局的),现在还要加上:手动同步又慢,又是错的温床。每行同步代码都是潜在的"不同步"。

状态散落还有三种典型形态,先认识一下:全局变量(谁都能改)、DOM 属性(藏在 data-* 里)、URL 参数(分页状态塞地址栏)。共同问题:没有"唯一真相"——同一个数字,变量里有一份、DOM 里有一份、URL 里有一份,改的时候得记得同步三份。

怎么判断自己已经陷进泥潭? 一个简单标准:改一个功能,要动几个地方? 改评论数要动接口回调、列表渲染、登录刷新三处——你已经陷进去了;只动一处——你还在岸上。

泥潭是慢慢陷进去的,没有某个时刻你"决定"进入。回头一看,脚下已是泥潭。这正是第 2 章演进循环的前端实例:复杂度上升(功能叠加)→ 原方案撑不住(手动同步开始出错)→ 新技术出现(框架)→ 解决(声明式)。前端框架是被"评论数 bug 们"逼出来的。

泥潭的可怕之处在于每一行单独看都正常——没有一行写错了,组合起来却改不动。第一个功能 30 行,感觉良好;第二个 30 行,还能忍;第三个 30 行——"找节点、改样式、插内容"的模式重复了十几次。正确性上状态乱(改 A 忘 B),性能上操作碎(reflow 频繁)。第 16 章性能优化里"批量 DOM 操作",本质上就是在给命令式擦屁股;声明式从机制上就绕开了这个问题。

当然,命令式并非一无是处。简单交互(点击弹个提示)三行搞定,上框架反而重;性能极致场景(手写 Canvas 游戏)命令式是唯一选择。泥潭指的是:状态一多,命令式撑不住。

为什么新手开发者特别容易陷进去?两个原因:一是先写功能后想结构,每个小需求单独看都合理,叠加起来才失控;二是**"手动操作 DOM"是入门第一课**——jQuery 时代教的就是"找到节点改它",习惯根深蒂固。问题出在思路上,跟能力关系不大;而思路问题,靠更小心是解不了的。

光"写得更仔细"解不了泥潭,得换一个思路。在声明式里,评论数是状态。发布成功后只更新状态,显示自然跟着变,不存在"第二个地方"。修 bug 从"找漏同步的代码"变成"删掉手动同步"——声明式消灭的是产生 bug 的整类机制。下一节,正式讲这个思路。

9.2 声明式:换个思路​

换什么思路?

命令式是"一步一步告诉浏览器怎么改":找到节点、改文字、切样式。声明式是"告诉框架界面应该长什么样",剩下的交给框架。

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

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

关键差别在于:声明式代码里没有"改"这个动作。它只是说"评论数等于 count"。count 变了,框架重新算一遍,界面自动跟着变。你不再需要"找到节点→改"这个动作,因为界面只是状态的一个函数:界面 = f(状态)。

这条纪律叫**"状态是唯一真相"。评论数显示多少,只取决于 count 这一个变量;count 在哪更新,显示就在哪更新。命令式里"改了 A 忘了 B" 的 bug,在声明式里结构性不可能发生**——根本没有第二个地方需要同步。

对开发者来说,改界面就是改状态。想在评论数旁加个"已读"标记?状态里加字段,描述里加一行,不用管"哪个节点、哪个时机、哪段代码"。调试时,命令式界面是一系列操作的累积结果,要复现整个操作序列才知道为什么长这样,像考古;声明式里状态是一个可打印的对象,console.log 一下就知道界面为什么长这样。查状态代替考古,这是用过就回不去的体验。

测试也更简单。f 是纯函数:同样的状态输入,同样的界面输出,没有隐式依赖。测试组件就是"给状态、断言界面"。命令式界面没法这么测——必须复现操作序列才能复现界面。

但声明式不是魔法。状态变了,框架要重新计算整个界面,而不是只改一个数字。这个效率问题需要靠虚拟 DOM 解决。本质上,声明式把**"手动同步的复杂度"换成了"重算的复杂度"**——前者靠人脑,后者靠框架优化。人脑不可靠,框架可以优化,这笔交换划算。

声明式有个前提:界面完全由状态决定。 但现实中有些情况,状态没法唯一决定界面长什么样。比如动画。按钮的状态是"展开",但动画播到第几帧、进度条走到百分之几,这些不是数据状态能描述的。再比如焦点——当前哪个输入框在闪烁光标?滚动位置——页面停在第几像素?这些取决于用户刚才做了什么,而不是当前的数据。这些"状态之外的因素",框架没法自动推导,得靠你手动干预。所以框架给它们开了后门(比如 React 的 useEffect):在声明式渲染完成后,单独执行一段命令式代码来处理这些特殊情况。这是合理的分工:数据展示交给声明式,用户交互的实时反馈交给命令式。 理解了这一点,就不会困惑"为什么声明式连动画都做不好"——动画本来就不该塞进"状态决定一切"的框架里。

另一层代价是失去部分控制。命令式里渲染时机完全由你决定;声明式里由框架规则决定,可能"我没改这个组件,它为什么重渲染了?"。你需要学习框架的规则来沟通。但"学几条规则"比"记几百个同步点"便宜得多。

这种思路在计算机世界里很常见——SQL 就是声明式。你写 SELECT 描述要什么,数据库负责怎么查。界面 = f(状态) 和 结果 = f(查询) 是同一个思想:把"怎么做"交给引擎,把"要什么"留给自己。

9.3 组件:把界面组织成树​

声明式解决了状态同步,但页面越来越大,代码怎么组织?

组件化的思路是:把一个界面单元(比如一张帖子卡片)的结构、样式、行为绑在一起,做成一个独立的"组件"。

function PostCard({ post, onComment }) {
return (
<article className="post">
<h2>{post.title}</h2>
<button onClick={onComment}>
评论 ({post.commentCount})
</button>
</article>
);
}

组件有两个直接好处:一是复用,帖子卡片在内容流里出现十次,只写一次;二是封装,每个组件管自己的状态,互不影响。整个页面就是一棵组件树:根是页面,枝干是区域,叶子是具体元素。

组件不是框架凭空发明的。更早的模板引擎(MVC 时代的 View)已经能声明结构,但状态同步还得手动处理。现代组件在模板基础上加了自动重渲染和行为绑定——数据变了框架自动更新,事件处理写在组件内部,不用到处 querySelector 找节点。

组件之间怎么传数据? 两条路:

  • props:父组件传给子组件,单向只读。子组件只能看,不能改。
  • state:组件自己的数据,变了就自动重渲染。

数据向下流,事件向上报。 用户点了评论按钮,子组件通过回调(onComment)告诉父组件"有人点了评论",父组件收到后改自己的 state,再重新渲染。

function CommentSection({ postId }) { // props:从外面进来
const [comments, setComments] = useState([]); // state:组件私有

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

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

注意三件事:postId 是 props,组件不修改它;comments 是 state,只有这个组件能改;setComments 之后,框架负责重新渲染,没有一行手动改 DOM。

如果两个组件要共享同一个状态怎么办? 比如右上角头像和内容流的"发布按钮"都要知道"登录了没有"。

答案是状态提升:把共享状态从子组件里抽出来,放到它们的共同父组件,再通过 props 分别传下去。状态放在哪,哪就是"真相源头"。所有子组件只读 props,需要改的时候发事件给父组件。

这和"全局变量满天飞"的区别在于:状态提升有明确的路径(沿着组件树),全局变量没有(到处都能改)。

组件拆多粗多细? 判断标准和第 6 章的实体边界一样:有没有独立的状态、会不会被多处复用。有就拆,没有就先放着。拆太粗,状态又混在一起;拆太细,props 一层层传,像穿针引线。

最后换个视角看组件:组件就是函数。props 是输入,界面是输出。同样的 props 进,同样的界面出——这样才好测试(第 17 章会讲)。这个视角和 9.2 的"界面 = f(状态)"完全一致:组件就是 f 的组织化形态。

把第 8 章和第 9 章连起来看,你的代码到屏幕像素之间有三层树:

  • 组件树:你写的,按业务逻辑组织(帖子 → 评论区 → 评论项)
  • 真实 DOM 树:浏览器里的物理节点
  • 渲染树:浏览器准备绘制时生成的,决定最终像素

框架坐在中间:你把组件树交给框架,框架翻译成 DOM,浏览器再翻译成渲染树。你只管业务逻辑,框架管浏览器细节——这就是框架帮你做的事。

9.4 虚拟 DOM:高效地"变"​

声明式加组件化有个代价:状态一变,框架就要重新计算界面。如果每次都直接重建真实 DOM,100 条卡片里只改一个数字,也要全部走一遍创建、插入、布局、绘制——页面会卡成幻灯片。

虚拟 DOM 的解决思路是:在真实 DOM 前面加一层"草稿纸"——用普通 JS 对象模拟的虚拟树。

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

  1. 重算:状态变了,框架用新状态重新生成一棵虚拟树(纯 JS 对象,计算很快)
  2. diff:新旧两棵虚拟树逐节点对比,找出哪里变了
  3. patch:只把"变了的地方"翻译成真实 DOM 操作——改那个文本、增那个节点、删那个节点
  4. 真实渲染:浏览器只对改动过的节点走布局和绘制

举个例子:内容流 100 条卡片,第 3 条评论数加 1。在声明式框架里,如果每次状态变化都重新生成整棵 DOM 树,代价极高;虚拟 DOM 通过在内存中对比新旧两棵虚拟树,只把"第 3 条评论数变了"这一处差异翻译成真实 DOM 操作,其余 99 条完全不动。

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

patch 本身就是改文本、增节点、删节点——都是命令式时代的老朋友。区别在于由框架根据 diff 结果来决定做哪些,而不是靠开发者自己记。

列表为什么要写 key?

diff 对比新旧虚拟树时,遇到列表(多个同类型节点),它怎么知道新树的第 3 条对应旧树的哪条?靠 key(<CommentItem key={c.id} />)。key 是节点的"身份证",diff 按 key 配对,才知道哪条是新增、哪条是移动、哪条该复用。不写 key,框架只能按位置猜,猜错的结果是该复用的节点被重建,白白浪费性能。

什么时候会触发重渲染? 三种情况:state 变了、props 变了、父组件重渲染了。前两种是数据真变了,第三种是"父变了我可能也变"——框架不知道子组件会不会受影响,默认重算一遍。第三种是性能优化的主要战场,第 16 章会讲怎么告诉框架"不用跟着重算"。

虚拟 DOM 不是万能药,代价要说清楚。

diff 本身有计算成本。状态变化太频繁、组件树太大时,diff 的开销也会显现。另外,虚拟 DOM 不是永远比手动操作快——顶尖高手把手动 DOM 优化到极致,只改该改的节点、批量操作、避开 reflow,可能比框架更快。

虚拟 DOM 的真正价值是"不用手动优化也能达到可接受的性能"。它买的是性能下限:默认实现已经足够快,把省下来的心智留给业务逻辑。第 16 章会看到,当性能真的成为瓶颈时,优化手段依然是手动干预——虚拟 DOM 是默认值,不是天花板。

最后顺带提一句:因为虚拟树是纯 JS 对象,不依赖浏览器 DOM,所以服务器上也能把组件渲染成 HTML 字符串——这就是**服务端渲染(SSR)**的底层机制。

9.5 框架的代价与边界​

框架解决了很多问题,但它不是免费的。要不要用框架,本身就是一次技术选型,只是大多数团队在"默认用 React/Vue"时,没意识到自己做了选择。

用第 5 章的决策模型过一遍。业务目标:让状态与视图的同步可控。当前约束:团队一两个人,前端是兼职,页面复杂度正在爬升(滚动加载、评论、登录态)。候选方案:A 继续手动 DOM(9.1 的泥潭),B 引入框架,C 自研轻量方案。评价维度:状态管理、上手成本、生态、包体积。选择 B——复杂度已到泥潭阶段,框架的生态(组件库、工具链)省下的时间远超学习成本。收益是状态可控、组件复用;代价是抽象层、包体积、学习成本。演进条件:性能瓶颈出现在框架本身时(第 16 章再评估)。

当初第 5 章选 React 的理由(前后端同语言、生态大),在这里变成了泥潭的解药。选型的收益,总是会在后面的章节里兑现的。

框架的生态不止"一个渲染库":React/Vue 周围有一圈配套——组件库、路由、状态管理、构建工具链。选型时评价的"生态",指的就是这些。本书只讲核心机制(组件、声明式、虚拟 DOM),生态的使用是查文档的事。

维护成本也要算进去: 依赖要升级(安全补丁)、生态可能变化(库被弃坑)。但这些成本是显性的、可计划的;对比命令式泥潭隐性的 bug 成本,框架的维护账是明确划算的。

框架还让迭代更快。 第 27 章的迭代循环里,"加新功能"是常态。声明式下加功能 = 加状态 + 加界面描述,不用在几十处手动同步里穿针引线。阶段 1 功能少,框架优势不明显;阶段 3 之后,每加一个功能省下的时间,都是当初选框架的回报。

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

具体反例:如果案例只是个只读的内容展示站,React 就是过度设计——静态 HTML 加一点 JS 就够了。但案例有评论、有登录、有交易,交互复杂度摆在那里,框架是雪中送炭。

框架和"库"(如 jQuery)的区别。 jQuery 是"让手动操作 DOM 更顺手",本质还是命令式,只是语法糖;React/Vue 是"换一种思路",声明式地消灭手动同步。差别在范式。学完 jQuery 再用 React 觉得别扭,是思路要换。

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

React 和 Vue 选哪个? 本书不站队。两者的机制同构(声明式、组件化),差异在 API 风格和生态。React 的"UI = f(state)"和 Vue 的"响应式"是同一个思想的两种实现。选型走第 5 章的九步,看团队技能和生态。本章讲机制不讲 API,就是因为机制学会了,React 和 Vue 只是同一个机制的两个方言。

对读者的建议:先理解声明式、组件、虚拟 DOM 的机制,再学框架的 API。 机制是十年后还有效的,API 是查文档就行的。先读本章再学 React/Vue,你会发现"一个新工具"的背后是"同一个机制的实现"——学习成本降一半。

框架自身也在演进(React 从 class 到 hooks,Vue 从选项式到组合式),频繁发版是在回应生态压力。这带来一个现实:API 会变,机制不会变。 理解机制的人,版本升级是"换 API 名",如果只关注 API,每次版本变化,都像重新再学习。

9.6 本章对应表​

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

第 8 章回答"页面怎么画出来"(管线的形状),第 9 章回答"页面怎么变"(状态到界面的机制)。浏览器站的两章讲完了:画和变,是浏览器里发生的两件大事。下一站离开浏览器,进入请求上路——第 10 章的网络段。

本章小结​

  • 命令式泥潭:状态散落、视图手动同步——"两个地方改同一个数字"是结构性缺陷;

  • 声明式换思路:你描述界面(状态是唯一真相),框架负责怎么变成那样——同步的复杂度从"人脑"转移到"框架",人脑不可靠,框架可以优化;

  • 组件化:结构+样式+行为绑成单元,页面 = 组件树——第 8 章"物理层/逻辑层树"的翻译是框架做的;

  • 虚拟 DOM:重算虚拟树 → diff → 只 patch 差异——把"重建整页"变成"精准改动",第 8 章"DOM 昂贵"的结构性解药;

  • 框架有代价(抽象层/包体积/学习成本)也有边界(没到泥潭别用)——要不要框架,走一遍九步决策。

下一章​

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

下一章开始,请求离开浏览器,走上网络——第 10 章,请求的上行:HTTP 与 HTTPS——一次请求的报文长什么样、连接怎么建立、数据怎么加密。