前端工程与应用设计
前端工程的核心问题不是“选哪个框架”,而是怎样把界面、状态、数据和用户操作组织成可维护的系统。页面从浏览器渲染模型开始,往上叠加组件、路由、请求、缓存、构建和部署。框架会变化,分层和边界不会。
1. 框架到底解决了什么
跳转到“1. 框架到底解决了什么”原生 DOM 当然可以写应用,但当状态越来越多时,手动同步数据和界面容易失控:
用户操作 → 修改数据 → 更新 DOM ↑ │ └── 其他操作 ┘同一份数据可能在多个位置显示,网络返回和本地操作又可能交错。框架主要提供三件事:
- 声明式渲染:描述“界面应该是什么样”,而不是逐步修改 DOM。
- 组件化:把结构、样式和行为封装成有边界的单元。
- 状态到视图的同步:状态变化后,以可控方式重新计算和渲染。
React、Vue、Svelte、Solid 等实现不同,但都要处理组件、属性、状态、生命周期、事件和渲染调度。
2. 组件、属性与组合
跳转到“2. 组件、属性与组合”组件可以理解为接收输入并返回界面描述的函数:
type ButtonProps = { label: string; disabled?: boolean; onClick: () => void;};
function Button({ label, disabled = false, onClick }: ButtonProps) { return ( <button disabled={disabled} onClick={onClick}> {label} </button> );}**属性(props)**是从父级传入的输入,**状态(state)**是组件内部会变化的数据。两者边界不清时,组件会出现大量同步逻辑。
好的组件边界通常满足:
- 输入和输出清楚。
- 不直接依赖外部环境,或把依赖通过参数传入。
- 只负责一个可解释的界面职责。
- 可单独测试,不要求启动整个应用。
- 样式和可访问性在同一层解决,不把行为留给调用者猜测。
组件不是越小越好。把一个按钮拆成七个文件,如果它们总是一起变化,反而增加跳转成本。拆分依据是职责、复用和变化频率,不是行数。
3. 渲染模型:从声明到屏幕
跳转到“3. 渲染模型:从声明到屏幕”不同框架的更新方式不同:
- 虚拟 DOM先构造轻量的界面树,再与上一棵树比较,最后应用差异。
- 细粒度响应式跟踪哪些状态被哪些视图读取,状态变化时只更新相关部分。
- 编译式更新在构建阶段分析模板,把更新逻辑编译成更直接的代码。
这些方式都试图减少无效工作。真正影响性能的是:更新频率、每次更新涉及多少组件、是否触发同步布局,以及状态是否放置得过高层。
3.1 受控与非受控
跳转到“3.1 受控与非受控”表单输入可以由应用状态控制,也可以由 DOM 自己维护。受控输入便于校验和联动,但每次输入都可能触发渲染;非受控输入更接近原生表单,适合简单或高性能场景。选择取决于是否需要即时同步,而不是框架偏好。
3.2 服务端渲染与客户端渲染
跳转到“3.2 服务端渲染与客户端渲染”| 模式 | HTML 从哪来 | 优势 | 注意 |
|---|---|---|---|
| CSR | 浏览器执行脚本生成 | 交互丰富、部署简单 | 首屏依赖 JS,SEO 和弱网体验可能差 |
| SSR | 服务端按请求生成 | 首屏内容更快可读 | 服务端负载、流式渲染、状态同步 |
| SSG | 构建时生成静态页面 | 便宜、稳定、适合内容站 | 内容更新需要重新构建 |
| 混合渲染 | 页面或组件分别选择 | 兼顾静态与动态 | 缓存、边界和调试更复杂 |
SSR 返回 HTML 后,客户端还需要“水合”(hydration),把事件和状态接上。水合不匹配可能导致闪烁、重复渲染或事件异常。不要默认 SSR 一定更快;它增加的是服务端与缓存复杂度,换来首屏和可索引性。
4. 路由与页面边界
跳转到“4. 路由与页面边界”路由把 URL 映射到页面和状态。单页应用常用 History API 修改地址,不重新加载整个文档;服务端路由则由服务器根据路径返回不同响应。
路由设计要回答:
- 哪些状态应放进 URL,能通过刷新、分享和后退恢复?
- 哪些页面需要登录、权限或数据预加载?
- 页面切换时哪些请求应取消,哪些缓存可复用?
- 404、空状态和错误状态分别怎样展示?
- 深层链接和浏览器前进后退是否正常工作?
/orders 订单列表/orders/:id 订单详情/orders/:id?tab=items 详情中的页签筛选条件、分页和页签通常适合放进 URL;临时弹窗和输入焦点未必需要。嵌套路由能表达布局复用,但不要把整个应用嵌套成几十层不可理解的树。
4.1 代码分割与懒加载
跳转到“4.1 代码分割与懒加载”按路由或大型功能拆分代码,可以减少首屏下载。懒加载不是免费午餐:用户第一次进入页面时会额外等待,预加载策略、加载占位和失败重试都要设计。
5. 状态管理:先分类,再选工具
跳转到“5. 状态管理:先分类,再选工具”“全局状态”常被滥用。实际状态可以分成几类:
| 状态类型 | 例子 | 适合放在哪里 |
|---|---|---|
| 局部 UI 状态 | 弹窗开关、输入值 | 组件内部 |
| 跨组件 UI 状态 | 主题、侧边栏、选中项 | 局部共享上下文或小型 store |
| URL 状态 | 页面、筛选、分页、页签 | 路由 |
| 服务端数据 | 用户、订单、列表 | 请求层缓存与 query 管理 |
| 表单状态 | 字段值、错误、脏状态 | 表单库或组件内 |
| 派生状态 | 总价、过滤结果、是否为空 | 根据源状态计算,不重复存储 |
最危险的是把同一事实存多份:例如用户资料同时存在全局 store、局部 state 和 URL 中,更新时只改了一处。优先确定单一事实来源,再决定缓存和派生。
5.1 服务端状态不是全局变量
跳转到“5.1 服务端状态不是全局变量”服务端数据会过期、可能被其他客户端修改,也需要分页、重试和失效策略。它与“弹窗是否打开”不是同类问题。请求层通常需要:
- 请求去重与缓存键。
- 加载、成功、空数据和错误状态。
- 过期时间与重新获取。
- 用户操作后的局部失效。
- 取消、超时和竞态处理。
两个请求乱序返回时,旧请求可能覆盖新结果。用请求 ID、取消信号或版本比较保证只有当前结果能更新界面。
6. 前端项目结构
跳转到“6. 前端项目结构”不要只按“components / services / utils”切分所有代码。随着项目变大,一个业务功能会同时包含页面、组件、请求、类型和测试。更耐用的结构是按领域或功能聚合:
src/ app/ router.ts providers.tsx features/ orders/ api.ts model.ts OrderList.tsx OrderDetail.tsx OrderList.test.tsx users/ ... shared/ ui/ lib/ config/原则:
- 业务功能内部可以高内聚,对外暴露少量入口。
shared只放真正跨功能复用的代码,避免变成杂物间。- API 类型、校验和错误结构靠近调用方。
- 页面负责组合,不负责所有业务细节。
- 依赖方向尽量单向,低层工具不要反过来导入页面。
7. 构建工具:转译、打包与产物
跳转到“7. 构建工具:转译、打包与产物”浏览器只需要能执行的 HTML、CSS、JavaScript 和资源文件。现代前端源码可能包含 TypeScript、JSX、CSS Modules、图片导入和新语法,因此需要构建链。
| 阶段 | 做什么 |
|---|---|
| 转译 | TypeScript/JSX/新语法变成目标浏览器能执行的代码 |
| 解析依赖 | 找出模块之间谁依赖谁 |
| 打包/拆包 | 合并或拆分产物,控制加载体积 |
| 资源处理 | 图片、字体、CSS、哈希文件名 |
| 优化 | 压缩、Tree Shaking、代码分割 |
| 生成产物 | 输出可直接部署的文件和 manifest |
Vite 在开发时利用浏览器原生模块和快速转换,生产构建通常交给更完整的打包流程;Webpack 的 loader 负责转换文件,plugin 负责扩展构建生命周期。工具不同,但都要回答同样的问题:入口是什么,输出在哪里,环境变量怎样注入,缓存怎样失效。
7.1 环境变量不是秘密
跳转到“7.1 环境变量不是秘密”构建时注入到前端的变量会进入浏览器产物,任何人都能看到。API 密钥、数据库密码和服务端令牌不能放在前端环境变量里。公开配置可以注入,秘密必须留在服务端或受控代理。
7.2 Source Map
跳转到“7.2 Source Map”Source Map 把压缩后的代码映射回源码,方便调试。公开生产 Source Map 可能泄露源码结构和内部路径;可以只上传到错误监控服务,或限制访问。
8. 性能:先测再优化
跳转到“8. 性能:先测再优化”常见指标:
- LCP(Largest Contentful Paint):最大内容元素何时可见,反映加载体验。
- INP(Interaction to Next Paint):交互后界面多久响应。
- CLS(Cumulative Layout Shift):页面内容是否突然跳动。
优化按阶段拆:
| 阶段 | 问题 | 常用手段 |
|---|---|---|
| 加载 | 资源太多、太慢、阻塞渲染 | 压缩、拆包、懒加载、缓存、CDN |
| 渲染 | DOM 太大、布局复杂、图片未定尺寸 | 虚拟列表、占位尺寸、减少嵌套 |
| 交互 | 主线程长任务、重复计算 | 拆分任务、Worker、缓存派生值 |
| 动画 | 每帧触发布局或重绘 | transform/opacity、requestAnimationFrame |
图片要设置宽高或比例,避免加载后推倒布局;字体要考虑 font-display、子集化和布局差异。性能指标要分设备、网络和地区观察,不能只测开发机。
8.1 虚拟列表
跳转到“8.1 虚拟列表”上千条记录全部渲染成 DOM 会消耗内存和布局时间。虚拟列表只渲染视口附近的项目,但会增加滚动、可变高度和焦点管理的复杂度。数据量不大时,普通列表更简单可靠。
9. 实时通信
跳转到“9. 实时通信”| 方式 | 方向 | 适合 |
|---|---|---|
| 轮询 | 客户端定时请求 | 简单、低频、容忍延迟 |
| 长轮询 | 服务端保持请求直到有数据 | 兼容性好,但连接管理复杂 |
| SSE | 服务端单向推送 | 通知、进度、流式文本 |
| WebSocket | 双向长连接 | 聊天、协作、游戏 |
| WebRTC | 点对点音视频和数据 | 实时媒体,需要信令与网络协商 |
实时连接必须处理断线重连、重复消息、顺序、心跳、认证过期和页面隐藏。SSE/WebSocket 只负责传输,业务仍需消息 ID、幂等和恢复策略。
流式结果要能区分“部分成功”和“最终成功”。用户关闭页面或取消操作时,客户端应发送取消信号,服务端也应停止无意义的后台工作。
10. 可访问性与国际化
跳转到“10. 可访问性与国际化”可访问性不是额外功能,而是正确使用 Web 平台:
- 用语义元素表达标题、按钮、链接和表单。
- 所有交互都能用键盘完成,并有清楚的焦点样式。
- 图片有合适的替代文本,装饰图可标记为空。
- 表单错误同时提供文字说明,不只依赖颜色。
- 弹窗打开后管理焦点,关闭后把焦点还给触发元素。
- 动态内容更新时,用合适的 live region 告诉辅助技术。
ARIA 可以在原生语义不足时补充信息,但“加了很多 aria-*”不等于可访问。错误的 ARIA 比没有更糟。
国际化包括翻译、日期/数字格式、复数规则、文字方向、长度变化和时区。使用 Intl API 处理本地化格式,不要手写所有语言规则。布局要允许德语长词、阿拉伯语从右到左和中文无空格断行。
11. 前端安全边界
跳转到“11. 前端安全边界”- 不把未转义的用户内容插入
innerHTML。 - 对 URL、重定向和第三方脚本做白名单限制。
- 敏感操作在服务端鉴权,前端隐藏按钮不算权限。
- Cookie 根据场景设置
HttpOnly、Secure和SameSite。 - 使用 CSP、依赖审计和最小权限降低 XSS 与供应链风险。
- 第三方组件和统计脚本可能读取页面数据,必须评估来源与必要性。
12. 测试与上线
跳转到“12. 测试与上线”前端测试大致分三层:
- 单元测试:格式化、计算、状态转换、校验函数。
- 组件/集成测试:用户操作、表单、请求 mock、错误状态。
- 端到端测试:真实浏览器中走关键路径、登录、支付、权限。
不要只断言 DOM class 名,用户能看到的行为更值得测试。视觉回归可以发现布局问题,但截图差异噪声较大,应选择稳定页面和关键断点。
上线前确认:
- 路由刷新和 404 回退是否适配部署平台。
- 静态资源是否使用正确 base path 和缓存头。
- 环境变量是否只包含公开值。
- 错误监控能否定位到版本和 Source Map。
- 首屏、弱网、移动端和键盘操作是否验证。
- 旧版本缓存的 Service Worker 是否能安全更新。
检查清单
跳转到“检查清单”- 组件输入输出是否清楚,是否避免重复状态。
- 路由是否覆盖刷新、深链、权限和错误页。
- 服务端数据是否有缓存、失效、取消和竞态处理。
- 项目结构是否按变化边界组织,而不是只有技术层目录。
- 构建产物是否可复现,前端环境变量是否全部视为公开。
- 性能是否由真实指标驱动,是否检查了主线程和布局。
- 实时连接是否处理断线、重复、顺序和取消。
- 语义、键盘、焦点、对比度和替代文本是否检查。
- 安全边界是否在服务端,而不是只靠前端隐藏。
- 是否测试了正常、空、加载、错误和权限不足状态。