前端工程化:从页面开发到应用架构
两年前接手了一个老项目,一个页面五六千行代码,所有逻辑塞在 jQuery 的 `$(document).ready()` 里。后来经历 Vue、React 到企业级项目,回头才理解那些“屎山”不全是技术债,更多是工程化的缺失。本文从实际经历出发,聊聊构建、类型、状态、契约四个维度的工程化实践。
两年前接手了一个老项目。
打开代码仓库的时候,心里是有点准备的——毕竟是个维护了好几年的后台系统,代码不会太好看。但真正点开文件之后,还是倒吸了一口凉气。
一个页面,一个 HTML 文件,五千多行。里面混着 HTML 结构、内联 CSS、jQuery 选择器、$.ajax 请求、业务逻辑、还有不知道谁留下的 console.log。所有东西都在一个文件里,所有逻辑都塞在 $(document).ready() 那个大回调里。
改一个按钮的点击事件,得在五千行里搜索 #submitBtn 或者 click,运气好能找到,运气不好只能全文搜索猜。加一个新功能,不敢动已有的代码,只能在文件末尾再 append 一段新的 $.on('click')。
后来因为业务调整,这个项目交接了三四拨人。每一拨人都在原有的屎山上继续堆——不是大家不想好好写,是当时的架构和工具链根本没有给人“好好写”的空间。业务变动频繁、需求排期紧、接手的人不熟悉历史逻辑,最安全的方式就是在现有代码上打补丁,而不是重构。久而久之,一个文件五六千行就成了常态。
这不是谁写代码的水平问题。这是工程化缺失的必然结果。
后来陆续接触了 Vue 和 React 的项目,再到企业级后台,才逐渐理解前端工程化到底解决了什么问题。它不只是“用个新框架”或者“配个打包工具”,而是在项目一开始就建立起一套让代码持续可维护的机制。
这篇文章按四个维度聊聊这套机制:构建工具、类型系统、状态管理、API 协作。
Vite:构建工具不只是“能跑就行”
刚接手那个老项目的时候,没有构建工具。
代码直接在浏览器里跑,用 <script> 标签引入 jQuery 和第三方库。JavaScript 文件按依赖顺序手动排列,少了一个就报错,多了一个也没人知道。代码全在全局作用域里,一不小心就变量冲突。
后来改成了 Vue CLI,再后来换成了 Vite。每次迁移都能明显感受到生产力的变化。
Vite 带来的不只是“启动快了”,而是改变了开发的工作方式:
- HMR(热模块替换):改完代码页面自动更新,不需要手动刷新,状态还能保留。这在老项目里是不可想象的——每改一行代码,刷新页面、重新点击、重新填表单,一套操作花半分钟,一天下来浪费的时间比写代码还多
- 按需编译:只编译当前页面用到的模块,项目再大也不影响启动速度
- TypeScript 原生支持:零配置就能跑
.ts和.tsx文件
从老项目迁移到 Vite 的经验:先把老的 jQuery 代码用 Vite 跑起来,做最小化配置。然后逐步把页面拆成模块,一个文件一个文件地重构。不是一次性推倒重来,而是让老代码在新的工具链里跑通,再逐步优化。
// vite.config.js - 支持老项目迁移
import { defineConfig } from 'vite';
import legacy from '@vitejs/plugin-legacy';
export default defineConfig({
plugins: [
legacy({
targets: ['ie >= 11'],
additionalLegacyPolyfills: ['regenerator-runtime/runtime'],
}),
],
server: {
proxy: {
'/api': 'http://localhost:8080', // 代理后端接口
},
},
});关于框架迁移的思考: 接手老项目时,建议优先稳定现有业务,再逐步引入工程化工具。如果项目还在维护期,可以考虑先加构建工具和 TypeScript,让老代码能正常跑起来,再按模块逐步替换为 Vue/React。一次性重写的风险通常比想象中高,业务方不会给足够的时间让你推倒重来,而且老代码里藏着很多没人知道的业务逻辑。
TypeScript:类型即文档,也是边界
老项目里没有 TypeScript。函数传参靠看代码,返回值靠猜,对象结构靠 F12 看接口返回。
有一回改一个订单处理函数,看代码以为是传 orderId,结果实际传的是整个 order 对象。改完之后测试才发现问题,但已经浪费了半天时间。
TypeScript 解决的不只是“类型安全”,更是代码的可理解性。新人来了看代码,不需要运行就能知道函数的输入输出是什么,数据结构长什么样。
在实践中用了类型分层的做法:
types/
├── api/ # API 契约类型(自动生成,不手写)
├── domain/ # 业务领域类型(前端内部形态)
└── ui/ # UI 组件类型(Props、事件)API 类型从 OpenAPI 生成,不手写。Domain 类型是 API 类型的前端适配版——后端返回的时间戳转成 Date,金额单位从“分”转成“元”。UI 类型是组件之间的接口,只关心展示需要什么。
// api/order.ts - 从 OpenAPI 生成
export interface ApiOrder {
id: string;
amount: number; // 单位:分
status: 'PENDING' | 'PAID' | 'CANCELLED';
createdAt: number; // Unix 时间戳
}
// domain/order.ts - 前端业务层
export interface Order {
id: string;
amount: number; // 单位:元
status: OrderStatus;
createdAt: Date;
}
// ui/order.ts - 组件层
export interface OrderCardProps {
order: Order;
onStatusChange: (id: string, status: OrderStatus) => void;
}类型分层之后,每一层的改动不会波及其他层。API 变了只改生成逻辑,UI 不变。UI 重构也只影响自己这一层。
还有一个容易被忽略的点:运行时校验。TypeScript 只在编译时生效,运行时的数据是否合法需要额外处理。用 Zod 在运行时做校验,和数据流转的位置结合,比如 API 响应数据在进入应用的第一道关口就进行校验,确保后续代码处理的数据是符合预期的。
状态管理:从全局变量到可预测的数据流
那个五千行的老项目,状态管理的方式是:全局变量。
// 全局状态,任何地方都能改
var orderList = [];
var currentUser = {};
var selectedIds = [];然后在不同的函数里直接修改这些变量,没有任何约束。一个变量被十个函数修改,出了 bug 根本找不到是谁改的。
后来用 Vue 的 Vuex,再到 React 的 Redux,再到现在的 Zustand。这中间的理解变化是:状态管理的核心不是工具,而是数据流向的约束。
Zustand 是目前在用的方案,因为够简单:
import { create } from 'zustand';
interface OrderStore {
orders: Order[];
isLoading: boolean;
fetchOrders: (params: FetchParams) => Promise<void>;
updateOrder: (id: string, data: Partial<Order>) => void;
}
const useOrderStore = create<OrderStore>((set, get) => ({
orders: [],
isLoading: false,
fetchOrders: async (params) => {
set({ isLoading: true });
const data = await orderApi.list(params);
set({ orders: data, isLoading: false });
},
updateOrder: (id, data) => {
const { orders } = get();
const updated = orders.map(order =>
order.id === id ? { ...order, ...data } : order
);
set({ orders: updated });
},
}));Zustand 的几个好处在实际使用中感受明显:不需要 Provider 包裹、选择器粒度细、支持中间件扩展。但更重要的是它让数据流变得可追踪——知道数据从哪来、经过什么处理、最终到了哪里。
服务端状态交给专门的工具处理。
把服务端数据放在 Zustand 里管理曾经导致过问题:缓存策略需要自己实现、并发请求处理复杂、数据重新验证逻辑散落在各处。后来引入了 TanStack Query,把“服务端状态”和“客户端状态”分开:
- 服务端状态 → TanStack Query(缓存、重试、轮询、重新验证)
- 客户端状态 → Zustand(用户偏好、UI 状态、表单草稿)
function useOrders(params: FetchParams) {
return useQuery({
queryKey: ['orders', params],
queryFn: () => orderApi.list(params),
staleTime: 60 * 1000, // 一分钟内不重新请求
});
}这个分工让代码职责更清晰:TanStack Query 负责所有异步数据的获取和缓存,Zustand 只负责纯客户端状态。
API 契约:前后端的“共同语言”
老项目里,前后端接口靠口口相传。
后端开发口头说“接口好了,你调 /getOrder 就行”,前端去调,发现返回的数据结构和预期不一样。再问,后端说“哦我改了一下,加了两个字段”。前端代码就跟着改,改了还可能影响其他地方。
这种协作方式,在业务简单的时候还能应付。随着团队扩大、接口增多,矛盾越来越明显。
后来引入了 OpenAPI 契约优先的开发流程。
核心原则:先定契约,再写代码。
# openapi/order.yaml
openapi: 3.1.0
paths:
/api/orders:
get:
summary: 获取订单列表
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: status
in: query
schema:
$ref: '#/components/schemas/OrderStatus'
responses:
'200':
description: 成功
content:
application/json:
schema:
$ref: '#/components/schemas/OrderListResponse'契约定义了接口的路径、参数、响应结构,前后端基于同一份契约各自开发。
然后从契约生成前端代码:
"generate:api": "openapi-generator-cli generate -i openapi/order.yaml -g typescript-axios -o src/api"生成的代码包含完整的类型定义和请求函数,前端不需要手写任何 API 调用代码:
import { ordersApi, type Order } from '@/api';
// 直接使用生成的类型和函数
const { data } = await ordersApi.list({ page: 1, status: 'PAID' });契约先行的价值:
- 并行开发:前端不需要等后端接口开发完成,基于契约的 Mock Server 就可以开始联调和自测
- 类型安全:前端和后端的类型定义来自同一份契约,不会出现字段名不一致的问题
- API 变更可追踪:契约文件在 Git 里,谁改了接口、改了哪里、为什么改,都有记录
- 自动生成文档:契约文件可以直接生成 API 文档,不需要额外维护
在 CI 流程里加了契约检查:如果契约文件变更了但前端没有重新生成代码,PR 构建会失败并提示。这样确保类型定义和实际代码始终同步。
架构意识要从第一天建立
回到最开始那个老项目。
如果当时有 Vite 这样的构建工具,代码拆分就不会那么困难;如果有 TypeScript,函数之间传参就不会靠猜;如果有 Zustand 级别的状态管理,全局变量就不会失控;如果有 OpenAPI 契约,前后端联调就不会反复返工。
但这些工具不是关键。关键是架构意识。
那个老项目变成五千行屎山,不是因为写代码的人技术差,而是因为从一开始就没有建立起“代码会持续演进”的预期。所有人都把它当一次性页面来写,结果它活了五六年,经历了三四拨人,成了谁都不敢动的历史包袱。
工程化的本质,是承认代码会被长期维护、会被多人修改、会不断演进,然后围绕这个事实建立一套让这件事变得可持续的机制。
从第一天就建立好目录结构、类型边界、状态管理策略、API 协作方式——这些在一开始看起来“过度设计”的事情,会在项目生命周期的第二年、第三年变成救命的护栏。
工具选型会变,框架会过时,但工程化的思路不会。那个老项目后来也用 Vite 和 TypeScript 逐步重构了一部分,虽然不可能完全推翻重来,但至少新功能不再往里堆了,新的模块按新的方式组织,老的模块也有人在慢慢拆。
改一个五千行文件需要很大的勇气,但如果从一开始就把它拆成二十个文件,改的时候就没那么可怕了。工程化做的,就是把“拆”这件事前置到项目的第一天。