跳到内容

第 6 卷 / 第 11 章 / 第 2 课

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。
java
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:

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

ts
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 描述互斥状态,但不会自动解决并发或运行时数据校验。

选择依据 ​

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

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

把副作用推到边缘 ​

展示逻辑最难测试的部分通常不是条件分支,而是计时器、网络、导航和本地存储。把这些副作用封装成依赖,可以把状态转换另行提取为纯函数,并用 Fake 测试副作用编排;上面的异步 load() 本身不是纯函数:

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

重点测试:

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

与应用分层衔接 ​

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

text
View / HTTP
    ↓
Controller / Presenter / ViewModel
    ↓
Application Use Case
    ↓
Domain + Ports

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

参考资料 ​

Built with VitePress | Software Systems Atlas