11.2 MVC、MVP 与 MVVM:谁拥有交互状态
MVC、MVP 和 MVVM 都在回答同一组问题:输入由谁解释,界面状态由谁持有,业务动作由谁触发,视图如何得到更新。
它们主要作用于展示边界。把 Spring MVC 控制器、领域模型和数据库仓储放在一张“MVC 三角形”里,会把两种不同尺度的架构混为一谈。
MVC:输入、状态与呈现分开
在服务端 Web MVC 中:
- Controller 把 HTTP 请求翻译成应用调用;
- Model 是提供给页面渲染的数据,不等于完整领域模型;
- View 根据 Model 生成 HTML。
@Controller
final class RankingPageController {
private final LoadRanking loadRanking;
@GetMapping("/rankings/{season}")
String show(@PathVariable String season, Model model) {
RankingViewData ranking = loadRanking.forSeason(season);
model.addAttribute("ranking", ranking);
return "ranking";
}
}这里的 Controller 不计算排名;它选择用例和视图。RankingViewData 可以专门为页面裁剪,不必把领域对象暴露给模板。
客户端 MVC 的事件循环和状态同步方式可能不同,因此不要仅凭类名判断模式。应画出真实控制流和状态所有权。
MVP:Presenter 主动驱动一个被动 View
MVP 常把 View 抽象成接口。Presenter 接收用户意图、调用用例,再显式更新 View:
interface RankingView {
void showLoading();
void showRanking(List<RankingRow> rows);
void showError(String message);
}
final class RankingPresenter {
private final RankingView view;
private final LoadRanking loadRanking;
void onSeasonSelected(String season) {
view.showLoading();
try {
view.showRanking(loadRanking.forSeason(season).rows());
} catch (RankingUnavailable ex) {
view.showError("排名暂时不可用");
}
}
}被动 View 容易用测试替身验证,适合 UI 框架难以直接测试、交互流程需要集中编排的场景。代价是 Presenter 容易长成包含大量视图细节的“上帝对象”。
MVVM:View 观察可绑定状态
MVVM 的 ViewModel 暴露界面所需的状态和命令,View 通过绑定或声明式渲染反映状态:
type RankingState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'ready'; rows: RankingRow[] }
| { kind: 'failed'; message: string }
class RankingViewModel {
state: RankingState = { kind: 'idle' }
async load(season: string) {
this.state = { kind: 'loading' }
try {
this.state = { kind: 'ready', rows: await api.loadRanking(season) }
} catch {
this.state = { kind: 'failed', message: '排名暂时不可用' }
}
}
}显式的联合状态避免 loading=true、error!=null、rows!=null 同时出现的矛盾组合。ViewModel 不应持有具体 DOM 节点,也不应把远程 DTO 原封不动地扩散到所有组件。
选择依据
| 问题 | MVC | MVP | MVVM / 声明式状态 |
|---|---|---|---|
| 谁解释输入 | Controller | Presenter | View 调用命令 |
| 谁主动更新视图 | Controller/View 协作 | Presenter | 绑定或渲染系统 |
| 状态主要放哪里 | Model 或会话 | Presenter | ViewModel / Store |
| 主要优势 | 请求—响应清晰 | 被动 View 易替换、易测 | 状态到界面的映射直接 |
| 常见风险 | Controller 变胖 | Presenter 变胖 | 隐式响应链和全局状态失控 |
框架名称不能替代选择。一个 React 页面可以采用单向数据流,也可以写成难以追踪的双向同步;一个 Spring MVC 项目也可以把全部业务塞进 Controller。
把副作用推到边缘
展示逻辑最难测试的部分通常不是条件分支,而是计时器、网络、导航和本地存储。把这些副作用封装成依赖,状态转换就可以作为纯逻辑测试:
当前状态 + 用户事件 + 用例结果 → 新状态 + 待执行效果重点测试:
- 加载、空结果、失败和重试状态;
- 重复点击与过期响应;
- 取消或离开页面后的结果处理;
- 错误是否被翻译成适合用户的消息;
- 展示模型是否稳定,不泄露后端内部字段。
与应用分层衔接
无论使用哪种展示模式,边界都应在调用应用用例处收口:
View / HTTP
↓
Controller / Presenter / ViewModel
↓
Application Use Case
↓
Domain + Ports展示模式可以随客户端技术变化;用例和领域规则不应因此重写。下一章会进一步反转外部依赖,让数据库、消息和 Web 框架成为核心之外的可替换适配器。
参考资料
- Martin Fowler, GUI Architectures
- Martin Fowler, Presentation Model
- Microsoft, The Model-View-ViewModel Pattern