11.2 MVC、MVP 与 MVVM:谁拥有交互状态
MVC、MVP 和 MVVM 都在回答同一组问题:输入由谁解释,界面状态由谁持有,业务动作由谁触发,视图如何得到更新。
它们主要作用于展示边界。把 Spring MVC 控制器、领域模型和数据库仓储放在一张“MVC 三角形”里,会把两种不同尺度的架构混为一谈。
MVC:输入、状态与呈现分开
下面 Java 21 / Spring MVC 项目片段假设已有 LoadRanking 用例、RankingViewData、RankingRow 和 RankingUnavailable;控制器通过组件扫描注册,项目还需模板与视图解析器。服务端 Web MVC 中:
- Controller 把 HTTP 请求翻译成应用调用;
- Model 是提供给页面渲染的数据,不等于完整领域模型;
- View 根据 Model 生成 HTML。
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Controller
final class RankingPageController {
private final LoadRanking loadRanking;
RankingPageController(LoadRanking loadRanking) {
this.loadRanking = java.util.Objects.requireNonNull(loadRanking);
}
@GetMapping("/rankings/{season}")
String show(@PathVariable("season") 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:
import java.util.List;
interface RankingView {
void showLoading();
void showRanking(List<RankingRow> rows);
void showError(String message);
}
final class RankingPresenter {
private final RankingView view;
private final LoadRanking loadRanking;
RankingPresenter(RankingView view, LoadRanking loadRanking) {
this.view = java.util.Objects.requireNonNull(view);
this.loadRanking = java.util.Objects.requireNonNull(loadRanking);
}
void onSeasonSelected(String season) {
view.showLoading();
try {
view.showRanking(loadRanking.forSeason(season).rows());
} catch (RankingUnavailable ex) {
view.showError("排名暂时不可用");
}
}
}这是 MVP 的 Passive View 变体,并非所有 MVP 都要求 View 完全被动。例子同步加载,showRanking、showError 应结束加载提示;耗时 I/O 不能直接阻塞桌面 UI 线程,改成异步后还需调度回 UI 线程并处理过期响应。
被动 View 容易用测试替身验证,适合 UI 框架难以直接测试、交互流程需要集中编排的场景。代价是 Presenter 容易长成包含大量视图细节的“上帝对象”。
MVVM:View 观察可绑定状态
MVVM 的 ViewModel 暴露界面所需的状态和命令,View 通过绑定或声明式渲染反映状态:
type RankingRow = Readonly<{ playerId: string; points: number }>
type RankingState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'ready'; rows: ReadonlyArray<RankingRow> }
| { kind: 'failed'; message: string }
type RankingApi = {
loadRanking(season: string): Promise<ReadonlyArray<RankingRow>>
}
class RankingViewModel {
private current: RankingState = { kind: 'idle' }
private generation = 0
private readonly api: RankingApi
private readonly onChange: (state: RankingState) => void
constructor(api: RankingApi, onChange: (state: RankingState) => void) {
this.api = api
this.onChange = onChange
}
get state(): RankingState { return this.current }
private publish(state: RankingState): void {
this.current = state
this.onChange(state)
}
async load(season: string): Promise<void> {
const request = ++this.generation
this.publish({ kind: 'loading' })
let rows: ReadonlyArray<RankingRow>
try {
rows = await this.api.loadRanking(season)
} catch {
if (request === this.generation) {
this.publish({ kind: 'failed', message: '排名暂时不可用' })
}
return
}
if (request !== this.generation) return
const snapshot = Object.freeze(rows.map(row => Object.freeze({ ...row })))
this.publish({ kind: 'ready', rows: snapshot })
}
cancel(): void {
++this.generation
this.publish({ kind: 'idle' })
}
}示例用 onChange 明确通知绑定层;普通 class 字段赋值本身不会触发 React 或其他框架渲染。装配时要把该回调接到框架的状态机制。api 是注入的边界,负责验证和映射远程 DTO,示例状态没有 DOM 引用。
每次加载取得一个递增序号,只有最新请求能发布结果。先选 A 再选 B,即使 A 最后返回也不会覆盖 B;cancel() 同样使旧结果失效,但不终止网络请求。离开页面时可先取消,再解除 UI 订阅;需要释放网络资源时,应另外接入 AbortSignal 等取消机制。这里复制并冻结平坦的排名行,避免 API 持有的数组事后改写已显示数据。
显式的判别联合状态避免 loading=true、error!=null、rows!=null 同时出现的矛盾组合。kind 描述互斥状态,但不会自动解决并发或运行时数据校验。
选择依据
| 问题 | MVC | MVP | MVVM / 声明式状态 |
|---|---|---|---|
| 谁解释输入 | Controller | Presenter | View 调用命令 |
| 谁主动更新视图 | Controller/View 协作 | Presenter | 绑定或渲染系统 |
| 状态主要放哪里 | Model 或会话 | Presenter | ViewModel / Store |
| 主要优势 | 请求与响应清晰 | 被动 View 易替换、易测 | 状态到界面的映射直接 |
| 常见风险 | Controller 变胖 | Presenter 变胖 | 隐式响应链和全局状态失控 |
框架名称不能替代选择。一个 React 页面可以采用单向数据流,也可以写成难以追踪的双向同步;一个 Spring MVC 项目也可以把全部业务塞进 Controller。
把副作用推到边缘
展示逻辑最难测试的部分通常不是条件分支,而是计时器、网络、导航和本地存储。把这些副作用封装成依赖,可以把状态转换另行提取为纯函数,并用 Fake 测试副作用编排;上面的异步 load() 本身不是纯函数:
当前状态 + 用户事件 + 用例结果 → 新状态 + 待执行效果重点测试:
- 加载、空结果、失败和重试状态;
- 重复点击与过期响应;
- 取消或离开页面后的结果处理;
- 错误是否被翻译成适合用户的消息;
- 展示模型是否稳定,不泄露后端内部字段。
与应用分层衔接
无论使用哪种展示模式,边界都应在调用应用用例处收口:
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