跳到内容

11.2 MVC、MVP 与 MVVM:谁拥有交互状态

MVC、MVP 和 MVVM 都在回答同一组问题:输入由谁解释,界面状态由谁持有,业务动作由谁触发,视图如何得到更新。

它们主要作用于展示边界。把 Spring MVC 控制器、领域模型和数据库仓储放在一张“MVC 三角形”里,会把两种不同尺度的架构混为一谈。

MVC:输入、状态与呈现分开

在服务端 Web MVC 中:

  • Controller 把 HTTP 请求翻译成应用调用;
  • Model 是提供给页面渲染的数据,不等于完整领域模型;
  • View 根据 Model 生成 HTML。
java
@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:

java
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 通过绑定或声明式渲染反映状态:

ts
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=trueerror!=nullrows!=null 同时出现的矛盾组合。ViewModel 不应持有具体 DOM 节点,也不应把远程 DTO 原封不动地扩散到所有组件。

选择依据

问题MVCMVPMVVM / 声明式状态
谁解释输入ControllerPresenterView 调用命令
谁主动更新视图Controller/View 协作Presenter绑定或渲染系统
状态主要放哪里Model 或会话PresenterViewModel / Store
主要优势请求—响应清晰被动 View 易替换、易测状态到界面的映射直接
常见风险Controller 变胖Presenter 变胖隐式响应链和全局状态失控

框架名称不能替代选择。一个 React 页面可以采用单向数据流,也可以写成难以追踪的双向同步;一个 Spring MVC 项目也可以把全部业务塞进 Controller。

把副作用推到边缘

展示逻辑最难测试的部分通常不是条件分支,而是计时器、网络、导航和本地存储。把这些副作用封装成依赖,状态转换就可以作为纯逻辑测试:

text
当前状态 + 用户事件 + 用例结果 → 新状态 + 待执行效果

重点测试:

  • 加载、空结果、失败和重试状态;
  • 重复点击与过期响应;
  • 取消或离开页面后的结果处理;
  • 错误是否被翻译成适合用户的消息;
  • 展示模型是否稳定,不泄露后端内部字段。

与应用分层衔接

无论使用哪种展示模式,边界都应在调用应用用例处收口:

text
View / HTTP

Controller / Presenter / ViewModel

Application Use Case

Domain + Ports

展示模式可以随客户端技术变化;用例和领域规则不应因此重写。下一章会进一步反转外部依赖,让数据库、消息和 Web 框架成为核心之外的可替换适配器。

参考资料

Built with VitePress | Software Systems Atlas