状态管理与架构演进
前端架构的本质问题是:随着应用变大,如何让状态的变化可控、让模块的边界清晰。本文沿着演进史理解"为什么现在的架构长这样",再深入 Flux/Redux 与当代状态库,最后延伸到模块化与微前端。
一、前端架构演进史
| 阶段 | 代表技术 | 状态管理方式 | 主要痛点 |
|---|---|---|---|
| 原生 JS | DOM API、var 全局变量 | 全局变量散落 | 状态乱、无组织 |
| jQuery 时代 | 链式 DOM 操作 | 直接读写 DOM 拿数据 | 逻辑与 DOM 强耦合 |
| MVC/MVVM | Backbone、AngularJS | 模型驱动视图 | 双向绑定易失控 |
| 组件化 | React、Vue 2/3 | 组件内部 state + 全局 store | 跨组件通信复杂 |
| 工程化 | 构建工具 + 类型系统 + 测试 | 分层清晰、可测试 | 学习成本高 |
这条演进线的主轴是状态从"散落"走向"收敛":全局变量 → 模型 → 组件 state → 单一 store → 明确边界的分层。
二、MVC 与 MVVM
1. MVC 三要素
| 角色 | 职责 |
|---|---|
| Model(模型) | 数据与业务规则 |
| View(视图) | 展示数据 |
| Controller(控制器) | 接收用户输入,调度 Model 与 View |
// 传统 MVC 风格:Controller 手动同步 View 与 Model
class TodoController {
constructor(model, view) {
this.model = model;
this.view = view;
view.on("add", text => { // 用户操作 → Controller
model.add(text); // 改 Model
view.render(model.list); // 手动刷新 View
});
}
}2. MVVM 与数据绑定
MVVM 用 ViewModel 替代 Controller,通过数据绑定自动同步视图,开发者不再手写 DOM 更新:
| 对比 | MVC | MVVM |
|---|---|---|
| 视图更新 | Controller 手动触发 | ViewModel 自动同步(双向绑定) |
| 测试性 | 需测三件套 | 主要测 ViewModel/状态层 |
| 代码量 | 更新逻辑手写 | 声明式,代码更少 |
| 典型代表 | Backbone | Vue、Angular、React(单向) |
// Vue 的 MVVM 体现:数据变,视图自动变
const vm = Vue.createApp({
data() { return { count: 0 }; },
template: `<button @click="count++">{{ count }}</button>`,
}).mount("#app");
// 无需任何手动 DOM 更新代码三、单向数据流思想
双向绑定在复杂场景下难以追踪"谁改了数据",于是主流框架转向单向数据流:
动作(用户交互)→ 修改状态 → 重新渲染视图 → 新动作...
↑ |
└────────────── 循环,方向单一 ─────────────┘单向数据流让变化路径唯一可预测:数据永远从"状态源"流向视图,视图不直接改状态,只能发动作。调试时沿着"动作 → 状态 → 视图"一条线就能定位问题。
四、Flux 架构
Facebook 为了解决 MVC 在大型应用中"状态难以追踪"的问题,提出 Flux:
View ──(用户操作)──▶ Action ──▶ Dispatcher ──▶ Store ──▶ View
▲ │
└────────────────── 数据单向流动 ─────────────────────┘| 角色 | 职责 |
|---|---|
| Action | 描述"发生了什么"的普通对象({ type, payload }) |
| Dispatcher | 唯一入口,把 Action 分发给所有 Store |
| Store | 保存状态并响应 Action 更新自身 |
| View | 订阅 Store,状态变化时重新渲染 |
Flux 的核心贡献是引入"动作"作为唯一改状态的途径,这个思想直接孕育了 Redux。
五、Redux 核心
1. 三原则
| 原则 | 含义 |
|---|---|
| 单一数据源 | 整个应用只有一棵 state 树,存于 store |
| 状态只读 | 只能通过 dispatch action 修改,不能直接改 |
| 纯函数修改 | 修改逻辑写在 reducer 中,reducer 必须纯 |
2. 完整流程
// action.js —— 动作:一个带 type 的普通对象
export const addTodo = (text) => ({ type: "todo/add", payload: text });
// reducer.js —— 纯函数:旧状态 + 动作 → 新状态
const initialState = { todos: [] };
export function todoReducer(state = initialState, action) {
switch (action.type) {
case "todo/add":
return { ...state, todos: [...state.todos, { text: action.payload, done: false }] };
default:
return state;
}
}
// store.js —— 唯一数据源
import { createStore } from "redux";
import { todoReducer } from "./reducer.js";
export const store = createStore(todoReducer);
// 使用:视图只能 dispatch,不能直接改状态
store.dispatch(addTodo("学习 Redux"));
console.log(store.getState().todos.length); // 13. 不可变数据
Redux 要求每次更新都产生新对象而非原地修改,这样便于 diff 与撤销/重放:
// 差:原地修改,旧引用也变了,无法追踪
state.todos.push(newTodo);
// 好:返回新数组
return { ...state, todos: [...state.todos, newTodo] };常用不可变更新工具:原生展开语法(如上)、structuredClone、Immer(produce 允许写"可变"代码,内部自动生成新对象)。
4. 中间件(Middleware)
在 dispatch 与 reducer 之间插入处理层,常用于异步逻辑与日志:
import { applyMiddleware, createStore } from "redux";
import { thunk } from "redux-thunk";
const store = createStore(todoReducer, applyMiddleware(thunk));
// thunk:让 action 可以是函数,在里面做异步再 dispatch
export const fetchTodos = () => async (dispatch) => {
dispatch({ type: "todo/loading" });
const todos = await api.getTodos(); // 异步请求
dispatch({ type: "todo/loaded", payload: todos });
};| 中间件 | 用途 |
|---|---|
| redux-thunk | 异步 action(最常用) |
| redux-saga | 复杂异步编排(Generator) |
| redux-logger | 开发期打印每个动作与状态 |
六、现代状态库对比:Zustand / Pinia
| 对比维度 | Redux | Zustand | Pinia |
|---|---|---|---|
| 学习曲线 | 陡(概念多) | 平缓 | 平缓 |
| 样板代码 | 多 | 极少 | 少 |
| 不可变要求 | 强制 | 推荐 | 自动(内部处理) |
| 异步处理 | 中间件 | 天然支持 | 天然支持(action 内 await) |
| TypeScript | 需额外工作 | 优秀 | 优秀 |
| 适用框架 | 任意(通用) | 任意(通用) | Vue 3 官方推荐 |
// Zustand:极简,一个 hook 即 store
import { create } from "zustand";
export const useCart = create((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
clear: () => set({ items: [] }),
}));
// 组件中直接使用
const { items, add } = useCart();// Pinia:Vue 3 官方推荐,options API 风格
import { defineStore } from "pinia";
export const useUserStore = defineStore("user", {
state: () => ({ name: "", token: null }),
getters: { isLoggedIn: (s) => !!s.token },
actions: {
async login(name, pwd) {
const { token } = await api.login(name, pwd); // action 内直接异步
this.token = token;
},
},
});选型建议:新 Vue 3 项目用 Pinia;需要跨框架或已有 Redux 体系用 Redux;追求最小样板、中型应用用 Zustand。三者都遵循"store 是唯一状态源"的同一思想。
七、组件状态与全局状态边界
| 状态类型 | 特征 | 存放位置 |
|---|---|---|
| 组件局部状态 | 只有本组件使用(表单输入、开关) | useState/ref 留在组件内 |
| 跨组件状态 | 兄弟/嵌套组件共享(筛选条件) | 提升到共同父组件或局部 store |
| 全局状态 | 多页面、多模块共享(用户信息、购物车) | 全局 store |
| 服务端状态 | 来自接口的数据(列表、详情) | 交给请求缓存层(TanStack Query) |
判断准则:先问"这个状态只在一个组件里用吗?"——是就留在组件内;再问"几个兄弟组件需要?"——是就提一级;最后才考虑全局 store。把一切塞进全局 store 是过度设计,会让状态依赖混乱。
// 组件局部状态:留在组件内即可
const [keyword, setKeyword] = useState(""); // 只本组件用
// 全局状态:用户信息、登录态
const user = useUserStore((s) => s.user); // 多模块共享才进 store八、组合式与模块化架构
1. 组合式 API:逻辑复用
Vue 3 组合式函数与 React Hooks 把"同一逻辑"从组件中抽离,跨组件复用:
// useCounter.js —— 逻辑单元,可任意组合
export function useCounter(initial = 0) {
const count = ref(initial);
const increment = () => count.value++;
const decrement = () => count.value--;
return { count, increment, decrement };
}
// 组件内组合使用,逻辑一目了然
const { count, increment } = useCounter(10);2. 模块化分层
典型前端分层(按依赖方向,上层依赖下层):
视图层(组件)→ 状态层(store)→ 服务层(API 请求)→ 数据层(DTO/类型)
↑
工具层(utils,被各层共享)| 层 | 职责 | 不应做的事 |
|---|---|---|
| 视图层 | 渲染与交互 | 直接发请求、写业务规则 |
| 状态层 | 状态与派生数据 | 拼接 UI 文案 |
| 服务层 | 网络请求与响应处理 | 持有 UI 状态 |
| 工具层 | 纯函数工具 | 依赖任何业务模块 |
约束手段:目录结构按层分(views/、stores/、api/、utils/),配合 ESLint 的 import 规则禁止跨层引用。
九、微前端架构简介
1. 为什么需要微前端
| 单体应用痛点 | 微前端方案 |
|---|---|
| 仓库与部署单一,牵一发动全身 | 子应用独立开发、独立部署 |
| 技术栈锁定 | 各子应用可用不同框架 |
| 团队协作冲突 | 按业务切分子应用团队自治 |
2. 核心机制(以 qiankun 为例)
// 主应用注册子应用
import { registerMicroApps, start } from "qiankun";
registerMicroApps([
{
name: "user-center",
entry: "//localhost:8081", // 子应用入口
container: "#subapp-container", // 挂载点
activeRule: "/user", // 路由匹配激活
},
{
name: "order-center",
entry: "//localhost:8082",
container: "#subapp-container",
activeRule: "/order",
},
]);
start();| 机制 | 作用 | 实现要点 |
|---|---|---|
| 沙箱 | 隔离全局变量与样式,防止子应用互相污染 | JS 沙箱(代理 window)+ CSS 沙箱(作用域) |
| 通信 | 主应用与子应用共享状态 | 全局状态 + 发布订阅;或 URL 参数/自定义事件 |
| 路由 | 按规则激活对应子应用 | 主应用基座路由 + 子应用子路由 |
// 沙箱:子应用里 window 上的全局变量被隔离
// 通信:qiankun 提供 actions 或直接走全局 EventBus
import { initGlobalState } from "qiankun";
const { onGlobalStateChange, setGlobalState } = initGlobalState({ user: null });
setGlobalState({ user: { name: "小明" } }); // 主应用发布
onGlobalStateChange((state) => console.log(state)); // 子应用订阅微前端的代价同样明显:架构复杂度、资源体积、联调成本都会上升。中小团队单仓库 + 模块化通常更划算——微前端解决的是组织问题,不是技术问题。
十、架构设计原则与分层
1. 核心原则
| 原则 | 落地建议 |
|---|---|
| 单一职责 | 每层/每模块只做一件事(见《代码规范与重构》) |
| 单向依赖 | 依赖箭头朝一个方向,禁止循环依赖 |
| 依赖抽象 | 面向接口编程,替换实现不动上层 |
| 可测试性 | 分层清晰后,每层都能独立 mock 测试 |
| 演进优先 | 先简单分层,复杂度真实出现再引入重型方案 |
2. 架构评审清单
- 状态变更是否路径唯一(store 是唯一修改入口)?
- 组件是否只依赖 store/服务层接口,不直接摸 DOM 兄弟?
- 模块之间是否有循环依赖(可用构建工具检测)?
- 新增一个页面,改动是否被限制在视图层与少量状态?
- 架构决策是否有文档记录理由(ADR),避免后人误改?
小结
从全局变量到 MVC/MVVM,从 Flux 到 Redux,再到 Zustand/Pinia 与微前端,架构演进始终在回答同一个问题:如何让状态变化可预测、让模块边界可维护。理解这条主线后你会发现,具体框架只是不同时期对同一问题的不同解法——掌握单向数据流、单一数据源、分层与边界这些底层思想,无论技术栈如何更迭,架构决策都不会走偏。