前端工程如何从样式处理发展到系统扩展
前端工程从HTML和CSS发展到了React组件、缓存技术、单仓库工具以及可观测性功能。每个阶段发生了什么变化,以及变化的原因是什么。
来源: I Thought Frontend Was Colors and Fonts. Then I Shipped to a Million Users.
工具在不断进步,但我们的思维方式进步得更快。
问别人前端开发是什么,通常会得到同样的答案:HTML、CSS、JavaScript。颜色、字体、图片、布局。
这确实是一个不错的起点,但故事并未到此结束。
最初只是简单的网页组合,如今已发展成需要构建完整系统的工程领域——在数千人同时使用的情况下,这些系统仍需保持快速、准确且可追溯。
工具沿着这条路径不断演变,但更为重要的是我们学会向自己提出的问题类型发生了变化。
以下是完整的演变历程。
1. 一切始于HTML、CSS和JavaScript
早期的网络设计非常简单。
HTML负责结构,CSS负责样式呈现,JavaScript则增添了部分交互功能。
这个任务可以用一句话概括:
打造一个既美观又功能正常的页面。
大多数页面都是静态的。服务器提供完整的HTML代码,浏览器负责渲染,而浏览器本身几乎不包含任何逻辑。
随着时间推移,页面承担的任务越来越多。用户在输入内容时,表单就会开始进行验证;内容也能在无需重新加载整个页面的情况下更新。
JavaScript不断承担更多职责,而且从未将任何任务交还给其他技术。
- 从页面到应用程序
网站逐渐演变成了应用程序。这意味着需要动态界面、API调用、多步骤表单、身份验证流程,以及在不同交互之间将数据保存在浏览器中。
这种转变暴露出了旧技术无法解决的问题。
当你直接操作DOM时,可见的屏幕就成了唯一的真实依据。同一份数据可能存在于变量中、DOM节点内,或是服务器返回的响应里。没有任何机制能保证这三者保持一致。
只更新其中一项而忽略其余部分,就会导致只有在特定点击顺序下才会出现的错误。
因此需要一种方式来声明“界面应该呈现的样子”,并将实际执行DOM操作的任务交给其他机制处理。
3. React:组件化思维
React真正的突破点并不在于其语法,而在于其背后的思维模型。
UI = f(state)
你只需描述给定状态下的界面,框架便会自动计算出实现该状态所需的最小DOM更新集。
不必编写 element.appendChild(...),只需写出应有的逻辑即可。
工作的基本单位已从整个页面转变为组件。可以想象一个单独的按钮、展示产品的卡片、弹出对话框,或是结账流程中的某一步。每个组件都有自己独立的标记语法、样式以及行为逻辑。
这种方式带来了以下优势:
可复用性。只需构建一次组件,即可在多个地方使用。
局部可理解性。无需通读整个应用,就能理解单个组件的功能。
可测试性。只需为组件提供属性并检查输出结果,无需浏览器即可完成测试。
可组合性。小型组件可以组合成更大的组件。
核心问题从“我该如何构建这个页面?”转变为“这个应用由哪些部分组成?”
4. Next.js:从用户界面到架构
纯客户端版的 React 存在一定的弊端。
浏览器首先下载一个空的 HTML 框架,接着下载体积较大的 JavaScript 包,然后执行该包,之后才获取实际数据。只有完成所有这些步骤后,用户才能看到内容。
在性能强大的笔记本电脑上这没问题,但在信号较弱的中端手机上,则意味着用户要盯着空白屏幕好几秒。此外,搜索爬虫也可能无法索引到任何有用的内容。
像 Next.js 这样的框架让开发者能够掌控 HTML 实际在“何时何地”生成。
SSG。 渲染在构建时完成,静态文件通过 CDN 提供。这是最快的方案,但内容直到下一次构建才会更新。
ISR。 内容保持静态,但在设定的时间间隔后会在后台重新生成。这样既能获得接近静态页面的速度,又能保证内容相对新鲜。
SSR。 每次请求都在服务器端进行渲染。内容始终是最新的,但每次都需要服务器重新处理。
CSR。 渲染完全在浏览器中完成。这种方式适用于需要登录才能访问的页面,因为此时无需考虑索引问题。
关键要点是:需为每条路由单独选择渲染方式,而非为整个应用统一决定。
营销页面适合使用 SSG,产品页面适合使用 ISR,而控制面板则适合使用 CSR。
这就是水合处理发挥作用的地方。服务器先发送HTML,然后客户端重新构建组件树以连接事件处理程序。在真正具备交互性之前,页面在视觉上看起来已经准备就绪。
React Server Components与流式渲染技术的出现主要是为了解决这一缺陷。一旦HTML准备好就会立即发送,而对于那些本来就不需要具备交互性的页面部分,则会减少JavaScript的加载量。
在这一阶段,前端工作不再仅仅是“UI层”层面的任务。关于渲染策略、路由和缓存的决策都变成了架构层面的决定,而由前端工程师来负责制定这些决策。
5. 接着是扩展性问题
构建一个应用程序本身就已经是一项挑战,而要构建一个能够服务数百万用户的应用,则是另一番完全不同的考验。
你需要考虑的问题也会随之发生变化:
当下游 API 变慢而非彻底失效时,应该怎么办?
六个独立的团队如何向同一个代码库提交代码而不互相干扰?
如何仅向1%的用户推送变更,一旦出问题就能在一分钟内撤回?
在那些从未测试过的、运行了三年旧版本的 Android 手机上,应用的表现如何?
昨天哪次部署导致了今天错误率的激增?
这些都是系统层面的问题,只不过恰好在浏览器中显现出来。
6. 速度并非可有可无的附加功能,而是基本要求
一个需要六秒才能渲染完成的精美界面,其实等同于已损坏。用户在还无法明确指出问题所在之前,就已经感受到了延迟。
如今的性能优化依赖于数据衡量而非直觉。Core Web Vitals为团队提供了讨论页面速度的统一标准。
LCP(最大内容绘制时间)指主要内容首次显示的瞬间,目标值应低于2.5秒。
INP(从交互到下一次绘制时间)衡量用户点击或触摸后界面响应的速度,目标值应低于200毫秒。
CLS(累积布局偏移)用于检测页面加载完成过程中各元素的位置变动程度,目标值应低于0.1。
每一种优化方法都与这些指标中的某一项相对应。
代码拆分与延迟加载:仅发送当前页面实际需要的JavaScript代码,而非整个应用包。这有助于提升LCP和INP指标。
优化图片。使用现代格式,正确设置尺寸,并始终提前声明宽度和高度,以避免内容跳动。这有助于提升LCP和CLS指标。
谨慎处理字体。预先加载字体,应用font-display: swap属性,并将字符集限制在实际使用的范围内。这有助于解决CLS问题。
通过CDN分发内容。从地理位置上靠近用户的服务器提供资源,几乎能同时提升所有性能指标。
避免在主线程上执行耗时操作。浏览器只有一个线程,同时负责渲染和脚本执行。如果某个任务运行300毫秒,那么在此期间所有点击操作都会被冻结。这就是INP优势的来源。
简而言之,其基本原则如下:
以最少的资源提供最佳体验。
需要注意一点:调整策略应基于实际测量数据,而非假设。在实验室环境中通过开发者的笔记本电脑获取的数值往往与真实用户的使用体验存在差异,而正是实地数据才能反映现实情况。
7. 缓存:避免重复计算已解决的内容
随着流量增加,反复生成相同的响应会造成浪费。缓存的存在就是为了避免为相同的工作付出双重成本。
每个请求都会经过多层处理,每一层都提供了简化工作的机会。
Browser → CDN → Application / ISR → Redis → Database
响应从离用户更近的位置提供,成本更低且速度更快。
不过,缓存内容距离用户越近,一旦出错就越难以修正。
这种矛盾关系决定了整个实践的走向。
什么应该放入缓存?应是稳定、可供多用户共享且生成成本较高的数据。除非缓存键能区分不同用户,否则绝不能存放针对特定用户的资料。
缓存的有效期应设多长?较短的过期时间更安全,但带来的好处较少;较长的有效期虽然速度更快,却风险更高。
如何清除缓存?方法包括基于时间的过期机制(TTL)、由事件触发的清理操作(在数据发布时触发),或是通过内容对文件名进行哈希处理,从而根本无需清除无效数据。
当查询失败时会发生什么?如果某个被广泛使用的键过期,同时有大量请求涌入后端,就会引发混乱。可以通过加锁来防止这种情况,或者在单个请求处理刷新时先返回过时的版本。
值得牢记的模式是stale-while-revalidate。立即返回缓存版本,同时在后台悄悄刷新它,这样下一个访问者就能看到更新后的内容。用户能体验到快速响应,而你也能在短暂延迟后保证数据的正确性。
要注意这里出现的错误会带来多大的代价。损坏的缓存不仅会导致系统变慢,还可能显示错误的价格、错误的名称,甚至完全是别人的数据。
缓存看似是一种性能优化技术,但实际上关乎数据的正确性。
8. 为发展而构建架构
当多个团队在同一个代码库中协作时,其组织结构的重要性与运行速度同样关键。
模块间的边界。明确哪些内容应公开,哪些需保留为内部信息,以及允许使用哪些依赖项。
API 的契约。需具备类型定义和版本控制,这样后端变更只会影响构建流程,而不会破坏在线站点。
状态存储的位置。服务器状态、URL 状态和本地客户端状态属于完全不同的类别。大多数与状态相关的错误都是因将不同类型的状态混为一谈造成的。
隔离故障。错误边界机制可确保某个出问题的组件不会导致整个页面崩溃。
逐步推出变更。通过功能开关、灰度发布以及即时回滚功能来实现渐进式更新。
思维方式从仅仅让某个功能正常运行,转变为确保该功能不会使未来的所有修改变得更加困难。
9. 单仓库中的代码共享
一旦多个应用程序需要使用相同的头部文件、设计规范以及认证辅助工具,复制粘贴代码的方式就不再可行。
单仓库架构让团队能够共享 UI 组件、设计系统、实用函数、配置文件以及类型定义——所有内容都基于同一个标准来源,更改会以原子化方式同步到各个包中。
不过共享也有其代价。每个被共享的包都会成为依赖项,而每一个依赖项实际上都相当于一个队列:某个团队的更新可能会延迟另一个团队的发布。
真正的目标是在避免紧密耦合的同时实现代码复用。
一个有用的经验法则:共享那些很少变化且无论在何处使用都含义相同的元素——比如设计令牌、按钮组件或类型定义。
避免共享任何包含某个团队特定业务逻辑的内容。复制代码通常比采用错误的共享抽象方案更经济。
微前端将这一理念进一步发展,让各个团队能够独立发布功能,但代价是运行时复杂性增加以及依赖项重复。对于那些团队间边界明确的大型组织来说,这种权衡可能是合理的;但在其他情况下则很少能带来好处。
10. 监控生产环境中的系统
在大规模系统中,“在我的机器上能运行”或“在预发布环境中运行正常”这类说法已不再有太大意义。
您真正需要的是能够实时查看应用程序在真实设备上的实际运行情况。
错误追踪功能结合上传的源代码映射,这样堆栈跟踪就能指向真实的源代码,而非压缩后的乱码。
真实用户监控可收集来自真实用户的Core Web Vitals数据,并按设备、国家和连接质量进行分类。
结构化日志与请求ID相关联,让您能够从浏览器一直追踪到其触发的所有后端服务。
基于症状的警报:在错误率或延迟超过阈值时发出通知,而不仅仅是服务器CPU使用率飙升时才提醒。
追踪版本发布情况。为每个错误标注导致它的具体部署版本,这样回答“发生了什么变化?”只需几秒钟,而非数小时。
这种机制能够帮助你回答以下问题:
用户究竟在何处遇到错误?是哪个API调用影响了使用体验?哪条路由速度较慢,又涉及哪些用户群体?最近的部署是否让情况变得更糟?
过去,前端代码往往是整个技术栈中最难监控的部分。但现在不必如此了。