React 应用的 CLEAN 架构

2025-06-08

React 应用的 CLEAN 架构

我最初在我的网站上发布了这篇文章的西班牙语版本:
Arquitectura CLEAN para el frontend

关于分层架构

分层并非新概念。它在业界已经存在了好几年(阅读本文的各位可能比分层概念还年轻),并且是最早出现的架构风格之一。简而言之,分层无非就是将应用程序的关注点划分到不同的层级,就像蛋糕一样,上层可以与底层通信,但底层不能与上层通信。

层通过外观进行交互,因此只要遵守公共 API,层就不必了解其他层的内部实现细节。

我们来看看下面的图:

分层架构,一张图

最典型的分层架构有三层:UI 层Domain 层Infrastructure 层。我们的系统可以根据需要包含任意多层,不一定只有 3 层。这只是最典型的一种。

将其转化为 React 应用,我们需要将视图组件放在顶层。然后,状态管理解决方案放在下一层。最后,同样重要的是,我们需要一个基础设施层,用于与外部资源通信,例如后端、Firebase 数据库、推送器、本地存储以及任何其他外部信息源。

对于小型应用来说,这已经足够好了,而且我们长期以来编写 React 应用的方式可能也是如此。但随着应用规模的扩大,这些层级变得越来越臃肿,开始承担过多的任务,这使得它们的推理变得更加困难。

在讨论这些胡言乱语之前,让我们先快速谈谈分层的好处以及为什么我们要探索实现分层架构。

推理的简易性

分而治之:解决大问题的最佳方法是将其分解成更容易解决的小问题。我们可以独立地推理某一层,而不必担心其他层的实现。

替代

层可以轻松地用其他实现替换。我们并非每天都要切换 http 库,但当需要时,更改会自成一体,不会泄漏到层边界之外。重构将变得更容易,干扰性也更低。

进化

可扩展的架构必须具备随着软件成熟和需求变化而不断演进的能力。虽然我们倾向于提前进行一些设计,但有些事情只有在开发开始后才会显现出来。使用分层架构时,我们可以推迟关于实现细节的决策,直到掌握足够的信息来做出合理的选择。

解耦

由于层与层之间的依赖关系是单向的,因此它们之间是可控的。追求低耦合(同时保持高内聚性,或者说是共置)是避免应用程序变成一团乱麻的好方法。

可测试性

分层架构使得单独测试每个组件变得容易。虽然这很好,但在我看来,这在可测试性方面并非最大的优势。对我来说,分层架构最大的好处是更容易在代码编写过程中编写测试。由于每一层都应该有明确定义的职责,因此在实现过程中更容易思考哪些部分值得测试。

以上所有因素都有助于我们编写更易于维护的代码。可维护的代码库可以提高我们的生产力,因为我们可以减少与技术债务作斗争的时间,而将更多时间投入到新功能的开发中。它还可以降低引入变更时的风险。最后,同样重要的是,它使我们的代码更易于测试,最终使我们在开发和重构过程中更有信心。

现在我们知道了分层和分层架构的好处,让我们来讨论一下我们为大型 React 应用程序提出什么类型的分层架构。

CLEAN架构

CLEAN 架构是一种分层架构,由来自其他分层架构的各种思想组成,例如洋葱架构、六边形架构和端口和适配器架构等。

CLEAN 的核心思想是将业务和业务实体置于软件系统的中心,并以其他层级包裹这些实体。外层与业务的关联性较低,而内层则完全围绕业务展开。

我们将简要描述 CLEAN 架构中每一层的作用,以便了解如何在我们的 React 应用程序中利用其中的一些概念。

CLEAN 架构,图表

实体

图的中心是实体。在经典的 CLEAN 架构中,实体是包含与业务规则相关的状态的一种方式。实体应该是简单的数据结构,并且不了解我们的应用程序框架或 UI 框架。

对于前端应用程序,这里存放的是与系统实体相关的逻辑。我们通常将这些实体放入状态管理库中。稍后我们将更详细地讨论这一点。

用例

用例类似于敏捷术语中的用户故事。这是应用程序业务规则的所在。用例应该代表用户想要实现的目标。用例应该包含以应用程序可理解的方式实现该目标所需的所有代码。需要注意的是,用例只能依赖于内部层,因此,为了使用例内部能够执行某些操作(例如,发出一个 http 请求),我们必须将依赖项注入到用例中并应用控制反转。

控制器/演示器/网关

这一层包含实现用例的框架代码。通常,UI 层会调用控制器或演示器公开的方法。

框架和驱动程序

最外层包含所有 IO 操作。用户输入、http 连接、从 Web 存储读取数据等等。我们的 UI 框架就位于此处。

值得注意的是,与任何其他分层架构一样,我们可以根据需要添加任意数量的层。话虽如此,让我们看看这些概念如何与我们通常使用 React 在一个玩具应用程序上实现此架构的操作相匹配。

一个非常复杂的计数器应用程序

我们将通过以下方式讨论 CLEAN 架构的每个概念 真的很复杂简单的计数器应用程序。我们的应用程序看起来会像这样:

一个非常复杂的计数器应用程序

让我们描述一下我们的应用程序的一些要求。

  • 初始值应该来自远程数据源
  • 当计数器值为 0 时,计数器不能减少
  • 我们应该将计数器值保存回我们的远程数据源

我们将讨论计数器应用程序的每一层:

实体

在宇宙的中心,我们有领域实体。在本例中,我们将定义一个Counter只包含 value 属性的接口。这也可以只是 number ( type Counter = number;) 的一个普通类型别名。

重要的是,这就是我们Counter在应用程序的其余部分中理解实体的方式,因此这个定义就计数器而言是一种“真相来源”。



// domain/counterEntity.ts
export interface Counter {
  value: number;
}


Enter fullscreen mode Exit fullscreen mode

虽然我们可以使用类来表示数据模型,但是interface这样就很好了。

领域模型

根据马丁·福勒的说法:

包含行为和数据的领域对象模型。

在领域模型中,我们可以定义针对实体的操作。在本例中,一个简单的增量和减量函数就可以了。

请注意,计数器值永远不能低于零的业务规则在这里定义,就在实体定义旁边。



// domain/counterModel.ts
import type { Counter } from "./counterEntity";

const create = (count: Counter["value"]) => ({ value: count });
const decrement = (counter: Counter) => ({
  value: Math.max(counter.value - 1, 0)
});
const increment = (counter: Counter) => ({ value: counter.value + 1 });

export { create, decrement, increment };


Enter fullscreen mode Exit fullscreen mode

我们可以将实体接口和域模型放在同一个文件中,这样就完全没问题了。

数据存储(又称存储库)

这一层通常用于状态管理。然而,我们在这里只定义数据访问层的形式,而不是具体实现。为此,我们可以使用接口。



// domain/counterStore.ts
import type { Counter } from "./counterEntity";

interface CounterStore {
  // State
  counter: Counter | undefined;
  isLoading: boolean;
  isUpdating: boolean;

  // Actions
  loadInitialCounter(): Promise<Counter>;
  setCounter(counter: Counter): void;
  updateCounter(counter: Counter): Promise<Counter | undefined>;
}

export type { CounterStore };


Enter fullscreen mode Exit fullscreen mode

用例

如前所述,用例可以定义为用户故事,或者用户(或任何其他外部系统)可以使用我们的系统做的事情。

我们的应用程序有 3 个用例

  • 从数据源获取计数器初始值
  • 增加计数器值
  • 减少计数器值

请注意,更新远程数据源中的计数器值不属于用例。这是增加或减少计数器的副作用。对于此层来说,数据源是否远程甚至无关紧要。

获取计数器用例



// useCases/getCounterUseCase.ts
import type { CounterStore } from "../domain/counterStore";

type GetCounterStore = Pick<CounterStore, "loadInitialCounter">;

const getCounterUseCase = (store: GetCounterStore) => {
  store.loadInitialCounter();
};

export { getCounterUseCase };


Enter fullscreen mode Exit fullscreen mode

对于这个特殊情况,我们Store为数据存储(又称存储库)定义了一个接口,该接口只需要一个getCounter方法。我们实际的Store实现可能会有更多方法,但这是我们在这一层唯一关心的。

增量计数器用例



// useCases/incrementCounterUseCase.ts
import { updateCounterUseCase } from "./updateCounterUseCase";
import type { UpdateCounterStore } from "./updateCounterUseCase";
import { increment } from "../domain/counterModel";

const incrementCounterUseCase = (store: UpdateCounterStore) => {
  return updateCounterUseCase(store, increment);
};

export { incrementCounterUseCase };


Enter fullscreen mode Exit fullscreen mode

减量计数器用例



// useCases/decrementCounterUseCase.ts
import { updateCounterUseCase } from "./updateCounterUseCase";
import type { UpdateCounterStore } from "./updateCounterUseCase";
import { decrement } from "../domain/counterModel";

const decrementCounterUseCase = (store: UpdateCounterStore) => {
  return updateCounterUseCase(store, decrement);
};

export { decrementCounterUseCase };


Enter fullscreen mode Exit fullscreen mode

更新计数器用例

前面两个用例都使用它updateCounterUseCase来在底层更新计数器值。正如你所见,用例可以组合。



// useCases/updateCounterUseCase.ts
import debounce from "lodash.debounce";

import type { Counter } from "../domain/counterEntity";
import type { CounterStore } from "../domain/counterStore";

type UpdateCounterStore = Pick<
  CounterStore,
  "counter" | "updateCounter" | "setCounter"
>;

const debouncedTask = debounce((task) => Promise.resolve(task()), 500);

const updateCounterUseCase = (
  store: UpdateCounterStore,
  updateBy: (counter: Counter) => Counter
) => {
  const updatedCounter = store.counter
    ? updateBy(store.counter)
    : store.counter;

  // Early return so we only persist the data when it makes sense
  if (!updatedCounter || store.counter?.value === updatedCounter?.value) return;

  store.setCounter(updatedCounter);

  return debouncedTask(() => store.updateCounter(updatedCounter));
};

export { updateCounterUseCase };
export type { UpdateCounterStore };


Enter fullscreen mode Exit fullscreen mode

注意我们如何对此处的调用进行去抖动处理store.updateCounter,以便仅在用户停止点击后更新远程源计数器(我还实现了乐观更新)。而不是对按钮点击进行去抖动处理。将此逻辑放在这里乍一看可能有点违反直觉,但现在应用程序逻辑被放在一个地方,而不是分散在视图层和数据层之间。

实际上,这个实现确实应该放在存储库中,但说实话,目前还没有明确的共识。有些文章建议你应该在这里注入服务,而其他实现则像我一样,将异步操作隐藏在存储库中。我还没有对这两种方法进行广泛的测试来下定决心。如果你问我,这取决于你是否希望这个库与外界无关。

控制器/演示器/网关

你可能已经注意到了,到目前为止我们还没有编写任何针对 React 的代码:只是一些普通的 TypeScript。这是我们要使用 React 代码的第一层。

这一层的作用是封装用例,以便可以从 UI 调用它们。为此,我们可以使用简单的 React Hooks。

我们将在这里使用一种 ViewModel 模式(稍后我们将更深入地阐述该组件的作用):



// controller/counterViewModel.ts
import React from "react";

import type { CounterStore } from "../domain/counterStore";
import { getCounterUseCase } from "../useCases/getCounterUseCase";
import { incrementCounterUseCase } from "../useCases/incrementCounterUseCase";
import { decrementCounterUseCase } from "../useCases/decrementCounterUseCase";

function useCounterViewModel(store: CounterStore) {
  const getCounter = React.useCallback(
    function () {
      getCounterUseCase({
        loadInitialCounter: store.loadInitialCounter
      });
    },
    [store.loadInitialCounter]
  );

  const incrementCounter = React.useCallback(
    function () {
      incrementCounterUseCase({
        counter: store.counter,
        updateCounter: store.updateCounter,
        setCounter: store.setCounter
      });
    },
    [store.counter, store.updateCounter, store.setCounter]
  );

  const decrementCounter = React.useCallback(
    function () {
      decrementCounterUseCase({
        counter: store.counter,
        updateCounter: store.updateCounter,
        setCounter: store.setCounter
      });
    },
    [store.counter, store.updateCounter, store.setCounter]
  );

  return {
    count: store.counter?.value,
    isLoading: typeof store.counter === "undefined" || store.isLoading,
    canDecrement: store.counter?.value === 0,
    getCounter,
    incrementCounter,
    decrementCounter
  };
}

export { useCounterViewModel };


Enter fullscreen mode Exit fullscreen mode

视图模型不仅将用例绑定到框架特定的功能,而且还将数据格式化为语义变量,因此表示逻辑包含在一个地方,而不是分散在整个视图中。

框架和驱动程序

好的,这是最外层,这里我们可以拥有所有特定的库代码,对于这个特定的例子,它意味着:

  • React 组件
  • 状态管理库存储实现
  • 计数器 API 服务,以便我们可以将数据持久保存到数据源
  • 用于与远程数据源通信的 HTTP 客户端
  • 国际化
  • 以及更多

我们将开始创建 API 服务:

计数器API服务



// data/counterAPIService.ts
import httpClient from '../../shared/httpClient'; // Esto puede ser una instancia de axios, para este caso es irrelevante
import type { Counter } from '../domain/counterEntity';
import { create } from '../domain/counterModel';

const BASE_URL = 'counter';

function getCounter(): Promise<Counter> {
  return httpClient.get<number>(BASE_URL).then(res => create(res.data));
}

function updateCounter(counter: Counter): Promise<Counter> {
  return httpClient.put<number>(BASE_URL, { count: counter.value }).then(res => create(res.data));
}

export { getCounter, updateCounter };


Enter fullscreen mode Exit fullscreen mode

数据存储实现(又称存储库实现)

分层架构的美妙之处在于我们无需关心各个层级的内部实现方式。CounterStoreImplementation我们可以使用任何东西:mobx一个简单的 React 组件,无论什么,都无所谓。reduxzustandrecoilreact-query

我们将redux在这里使用良好的措施,只是为了证明实现细节不会泄露到其他层:



// data/counterActionTypes.ts
export const SET_COUNTER = "SET_COUNTER";
export const GET_COUNTER = "GET_COUNTER";
export const GET_COUNTER_SUCCESS = "GET_COUNTER_SUCCESS";
export const UPDATE_COUNTER = "UPDATE_COUNTER";
export const UPDATE_COUNTER_SUCCESS = "UPDATE_COUNTER_SUCCESS";


Enter fullscreen mode Exit fullscreen mode



// data/counterActions.ts
import type { Counter } from "../domain/counterEntity";
import { getCounter, updateCounter } from "./counterService";
import * as actionTypes from "./counterActionTypes";

const setCounterAction = (counter: Counter) => (dispatch: any) =>
  dispatch({ type: actionTypes.SET_COUNTER, counter });

const getCounterAction = () => (dispatch: any) => {
  dispatch({ type: actionTypes.GET_COUNTER });

  return getCounter().then((counter) => {
    dispatch({ type: actionTypes.GET_COUNTER_SUCCESS, counter });

    return counter;
  });
};

const updateCounterAction = (counter: Counter) => (dispatch: any) => {
  dispatch({ type: actionTypes.UPDATE_COUNTER });

  return updateCounter(counter).then((counter) => {
    dispatch({ type: actionTypes.UPDATE_COUNTER_SUCCESS });

    return counter;
  });
};

export { setCounterAction, getCounterAction, updateCounterAction };


Enter fullscreen mode Exit fullscreen mode



// data/counterReducer.ts
import type { AnyAction } from "redux";
import type { CounterStore } from "../domain/counterStore";
import * as actionTypes from "./counterActionTypes";

type CounterStoreState = Omit<CounterStore, "loadInitialCounter" | "setCounter" | "updateCounter">;

const INITIAL_STATE: CounterStoreState = {
  counter: undefined,
  isLoading: false,
  isUpdating: false
};

const counterReducer = (state: CounterStoreState = INITIAL_STATE, action: AnyAction) => {
  switch (action.type) {
    case actionTypes.SET_COUNTER:
      return { ...state, counter: action.counter };
    case actionTypes.GET_COUNTER:
      return { ...state, isLoading: true };
    case actionTypes.GET_COUNTER_SUCCESS:
      return { ...state, isLoading: false, counter: action.counter };
    case actionTypes.UPDATE_COUNTER:
      return { ...state, isUpdating: true };
    case actionTypes.UPDATE_COUNTER_SUCCESS:
      return { ...state, isUpdating: false };
    default:
      return state;
  }
};

export { counterReducer };
export type { CounterStoreState };


Enter fullscreen mode Exit fullscreen mode

有了所有典型的 redux 代码,现在我们就可以为CounterStore接口创建一个计数器存储实现:



// data/counterStoreImplementation.ts
import React from "react";
import { useDispatch, useSelector } from "react-redux";

import type { AppRootState } from "../../main/data/appStoreImplementation";
import type { CounterStore } from "../domain/counterStore";
import type { Counter } from "../domain/counterEntity";

import type { CounterStoreState } from "./counterReducer";
import {
  getCounterAction,
  setCounterAction,
  updateCounterAction
} from "./counterActions";

const counterSelector = (state: AppRootState) => state.counter;

const useCounterStoreImplementation = (): CounterStore => {
  const { counter, isLoading, isUpdating } = useSelector<
    AppRootState,
    CounterStoreState
  >(counterSelector);
  const dispatch = useDispatch();

  const setCounter = React.useCallback(
    (counter: Counter) => setCounterAction(counter)(dispatch),
    [dispatch]
  );

  const loadInitialCounter = React.useCallback(
    () => getCounterAction()(dispatch),
    [dispatch]
  );

  const updateCounter = React.useCallback(
    (counter: Counter) => updateCounterAction(counter)(dispatch),
    [dispatch]
  );

  return {
    counter,
    isLoading,
    isUpdating,
    setCounter,
    loadInitialCounter,
    updateCounter
  };
};

export { useCounterStoreImplementation };


Enter fullscreen mode Exit fullscreen mode

看法

我们这里展示的最后一层是 UI 层,也就是视图层。这是我们所有组件的集成点:



// view/AppView.tsx
import React from "react";

import Button from "../../shared/ui/Button";
import Count from "../../shared/ui/Count";
import Spinner from "../../shared/ui/Spinner";

import { useCounterViewModel } from "../controller/counterViewModel";
import { useCounterStoreImplementation } from "../data/counterStoreImplementation";

const CounterView = () => {
  const store = useCounterStoreImplementation();
  const {
    count,
    canDecrement,
    isLoading,
    getCounter,
    incrementCounter,
    decrementCounter
  } = useCounterViewModel(store);

  React.useEffect(() => {
    getCounter();
  }, [getCounter]);

  return (
    <div className="App">
      {isLoading ? (
        <Spinner />
      ) : (
        <>
          <Button onClick={decrementCounter} disabled={!canDecrement}>
            dec
          </Button>
          <Count>{count}</Count>
          <Button onClick={incrementCounter}>inc</Button>
        </>
      )}
    </div>
  );
};

export default CounterView;


Enter fullscreen mode Exit fullscreen mode

关于这一层没有太多要说的,而是从我们的视图状态到屏幕上显示的任何 UI 元素的映射非常简单,因为我们将业务规则映射到视图模型上的语义变量。

回顾

就是这样,这是在 React 应用程序上实现 CLEAN 架构的好方法。总结一下,像 CLEAN 这样的架构带来的好处是:

  • 它使我们的代码更容易推理,因为每一层都有明确定义的角色,我们可以专注于单个层,而无需了解其他层的实现细节
  • 这也使得任何层都可以替换。有了明确定义的表面和层与层之间的界限,我们更容易尝试新技术等等。
  • 通过遵守依赖规则,我们能够将业务与框架特定的代码隔离,从而更易于描述、实现和测试
  • 每一层在实现时都可以独立测试,这比一切就绪后再编写测试方便得多。

现在,最大的问题是:你应该在当前/下一个项目中使用 CLEAN 吗?简单的回答是,没有万能药。CLEAN 架构有其优点和缺点;虽然它使我们的代码更加结构化,但也带来了大量的样板代码。正如我之前提到的,为我们在这里展示的应用程序实现 CLEAN 是过度的。我们将在下一篇文章中讨论更多权衡。

最后但同样重要的一点是,您可以在此处找到源代码

如果您喜欢这些内容,请不要忘记在 Twitter 上分享并关注

注意:我主要用西班牙语发布有关 JavaScript 的推文。

参考

鏂囩珷鏉ユ簮锛�https://dev.to/daslaf/clean-architecture-for-react-apps-3g3m
PREV
Javascript 中的堆栈数据结构 编程中堆栈的用例 - 基本操作 在 Javascript 中创建堆栈数据结构的方向 示例 用法
NEXT
利用 React 的页面可见性 API