使用成熟的 UI 模式模块化 React 应用程序

成熟的 UI 模式在前端开发领域经常被低估,尽管它们在解决 UI 设计中的复杂问题方面已被证明是有效的。本文探讨了将成熟的 UI 构建模式应用于 React 世界,并通过重构旅程代码示例来展示其优势。重点放在分层架构如何帮助组织 React 应用程序以提高响应能力和未来的更改。

2023 年 2 月 16 日



虽然我提到了 React 应用程序,但实际上并没有所谓的 React 应用程序。我的意思是,有一些用 JavaScript 或 TypeScript 编写的,恰好使用 React 作为视图的前端应用程序。但是,我认为称它们为 React 应用程序并不公平,就像我们不会称 Java EE 应用程序为 JSP 应用程序一样。

通常,人们会将不同的东西塞进 React 组件或钩子中以使应用程序正常工作。如果应用程序很小或基本上没有太多业务逻辑,这种不太组织化的结构不会成为问题。但是,随着许多情况下更多业务逻辑转移到前端,这种“组件中的一切”就会出现问题。更具体地说,理解这种类型的代码的难度相对较高,代码修改的风险也随之增加。

在本文中,我想讨论一些模式和技术,您可以使用它们将您的“React 应用程序”重塑为一个常规应用程序,并且只使用 React 作为其视图(您甚至可以将这些视图交换到另一个视图库中,而不会付出太多努力)。

这里的关键点是您应该分析代码的每个部分在应用程序中扮演什么角色(即使在表面上,它们可能被打包在同一个文件中)。将视图与非视图逻辑分开,根据其职责进一步拆分非视图逻辑,并将它们放置在正确的位置。

这种分离的好处是,它允许您更改底层域逻辑,而无需过多担心表面视图,反之亦然。此外,它可以提高域逻辑在其他地方的可重用性,因为它们没有与任何其他部分耦合。

React 是一个用于构建视图的简单库

很容易忘记,React 的核心是一个库(而不是框架),它可以帮助您构建用户界面。

在这种情况下,强调 React 是一个 JavaScript 库,它专注于 Web 开发的特定方面,即 UI 组件,并在应用程序设计及其整体结构方面提供了充分的自由。

一个用于构建用户界面的 JavaScript 库

-- React 主页

这听起来可能很简单。但我已经看到很多案例,人们在使用的地方直接编写数据获取、重塑逻辑。例如,在 React 组件中获取数据,在useEffect块中,就在渲染之上,或者在从服务器端获得响应后执行数据映射/转换。

useEffect(() => {
  fetch("https://address.service/api")
    .then((res) => res.json())
    .then((data) => {
      const addresses = data.map((item) => ({
        street: item.streetName,
        address: item.streetAddress,
        postcode: item.postCode,
      }));

      setAddresses(addresses);
    });
}, []);

// the actual rendering...

也许是因为前端世界还没有一个通用的标准,或者这只是一个糟糕的编程习惯。前端应用程序不应该与常规软件应用程序有太大区别。在前端世界中,您通常仍然使用关注点分离来安排代码结构。所有经过验证的有用设计模式仍然适用。

欢迎来到真实的 React 应用程序世界

大多数开发人员对 React 的简单性和将用户界面表示为将数据映射到 DOM 的纯函数的想法印象深刻。在某种程度上,它确实如此

但是,当开发人员需要向后端发送网络请求或执行页面导航时,他们就开始苦苦挣扎,因为这些副作用使组件不那么“纯粹”。一旦您考虑这些不同的状态(全局状态或本地状态),事情就会迅速变得复杂,用户界面的阴暗面就会出现。

除了用户界面

React 本身并不关心将计算或业务逻辑放在哪里,这很公平,因为它只是一个用于构建用户界面的库。除此之外的视图层,前端应用程序还有其他部分。为了使应用程序正常工作,您将需要路由器、本地存储、不同级别的缓存、网络请求、第三方集成、第三方登录、安全、日志记录、性能优化等。

有了所有这些额外的上下文,试图将所有内容都塞进 React 组件或钩子中通常不是一个好主意。原因是在一个地方混合概念通常会导致更多混乱。首先,组件设置一些网络请求以获取订单状态,然后有一些逻辑来修剪字符串开头的空格,然后导航到其他地方。读者必须不断重置他们的逻辑流程,并在不同的细节级别之间来回跳转。

将所有代码打包到组件中可能适用于像 Todo 或单表单应用程序这样的小型应用程序。但是,一旦应用程序达到一定程度,理解这种应用程序的努力将是巨大的。更不用说添加新功能或修复现有缺陷了。

如果我们可以将不同的关注点分离到具有结构的文件或文件夹中,理解应用程序所需的脑力负担将大大降低。而且您一次只需要专注于一件事。幸运的是,在 Web 时代之前就已经有一些行之有效的模式。这些设计原则和模式经过充分探索和讨论,以解决常见的用户界面问题——但在桌面 GUI 应用程序的上下文中。

Martin Fowler 对视图-模型-数据分层概念有一个很好的总结。

总的来说,我发现这对于许多应用程序来说是一种有效的模块化形式,也是我经常使用和鼓励的一种形式。它最大的优势在于它允许我通过允许我相对独立地思考这三个主题(即视图、模型、数据)来提高我的专注力。

-- Martin Fowler

分层架构已被用于应对大型 GUI 应用程序中的挑战,当然我们可以在我们的“React 应用程序”中使用这些成熟的前端组织模式。

React 应用程序的演变

对于小型或一次性项目,您可能会发现所有逻辑都只是写在 React 组件中。您可能会看到一个或只有几个组件。代码看起来很像 HTML,只有一些变量或状态用于使页面“动态”。有些人可能会在组件渲染后在useEffect上发送请求以获取数据。

随着应用程序的增长,越来越多的代码被添加到代码库中。如果没有适当的方法来组织它们,代码库很快就会变成不可维护的状态,这意味着即使添加小功能也会很耗时,因为开发人员需要更多时间来阅读代码。

因此,我将列出一些可以帮助缓解可维护性问题的步骤。它通常需要付出更多努力,但拥有应用程序中的结构将得到回报。让我们快速回顾一下这些步骤,以构建可扩展的前端应用程序。

单组件应用程序

它几乎可以被称为单组件应用程序

图 1:单组件应用程序

但很快,您就会意识到一个单一组件需要大量时间才能阅读正在发生的事情。例如,有一些逻辑用于遍历列表并生成每个项目。此外,除了其他逻辑之外,还有一些逻辑用于使用只有少量配置代码的第三方组件。

多组件应用程序

您决定将组件拆分为多个组件,这些结构反映了结果 HTML 中发生的事情,这是一个好主意,它可以帮助您一次专注于一个组件。

图 2:多组件应用程序

随着应用程序的增长,除了视图之外,还有发送网络请求、将数据转换为视图可以使用的不同形状以及收集数据以发送回服务器等操作。将此代码放在组件中感觉不对,因为它们实际上与用户界面无关。此外,一些组件有太多内部状态。

使用钩子进行状态管理

将此逻辑拆分到一个单独的地方是一个更好的主意。幸运的是,在 React 中,您可以定义自己的钩子。这是共享这些状态以及状态更改时逻辑的绝佳方法。

图 3:使用钩子进行状态管理

太棒了!您从单组件应用程序中提取了一堆元素,并且您有一些纯演示组件和一些可重用的钩子,这些钩子使其他组件具有状态。唯一的问题是,在钩子中,除了副作用和状态管理之外,一些逻辑似乎不属于状态管理,而是纯计算。

业务模型出现

因此,您开始意识到将此逻辑提取到另一个地方可以为您带来很多好处。例如,通过这种拆分,逻辑可以是内聚的,并且独立于任何视图。然后,您提取一些域对象。

这些简单的对象可以处理数据映射(从一种格式到另一种格式),根据需要检查空值并使用回退值。此外,随着这些域对象的数量增加,您会发现您需要一些继承或多态性来使事情更简洁。因此,您将从其他地方找到的有用的许多设计模式应用到了这里的前端应用程序中。

图 4:业务模型

分层前端应用程序

应用程序不断发展,然后您会发现一些模式出现。有一堆对象不属于任何用户界面,而且它们也不关心底层数据是来自远程服务、本地存储还是缓存。然后,您想将它们拆分为不同的层。这里是对层拆分的详细解释 演示域数据分层.

图 5:分层前端应用程序

上述演变过程是一个高级概述,您应该了解如何构建代码或至少应该是什么方向。但是,在将理论应用到您的应用程序之前,您需要考虑许多细节。

在接下来的部分中,我将带您逐步完成从实际项目中提取的功能,以演示我认为对大型前端应用程序有用的所有模式和设计原则。

引入支付功能

我使用一个过于简化的在线订购应用程序作为起点。在这个应用程序中,客户可以选择一些产品并将其添加到订单中,然后他们需要选择一种付款方式才能继续。

图 6:支付部分

这些支付方式选项是在服务器端配置的,不同国家/地区的客户可能会看到其他选项。例如,Apple Pay 可能只在某些国家/地区流行。单选按钮是数据驱动的 - 从后端服务获取的内容将被显示。唯一的例外是,当没有返回配置的支付方式时,我们不会显示任何内容,默认情况下将其视为“现金支付”。

为了简单起见,我将跳过实际的支付流程,重点关注Payment组件。假设在阅读了 React hello world 文档和一些 stackoverflow 搜索后,你得到了类似这样的代码

src/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      const fetchPaymentMethods = async () => {
        const url = "https://online-ordering.com/api/payment-methods";
  
        const response = await fetch(url);
        const methods: RemotePaymentMethod[] = await response.json();
  
        if (methods.length > 0) {
          const extended: LocalPaymentMethod[] = methods.map((method) => ({
            provider: method.name,
            label: `Pay with ${method.name}`,
          }));
          extended.push({ provider: "cash", label: "Pay in cash" });
          setPaymentMethods(extended);
        } else {
          setPaymentMethods([]);
        }
      };
  
      fetchPaymentMethods();
    }, []);
  
    return (
      <div>
        <h3>Payment</h3>
        <div>
          {paymentMethods.map((method) => (
            <label key={method.provider}>
              <input
                type="radio"
                name="payment"
                value={method.provider}
                defaultChecked={method.provider === "cash"}
              />
              <span>{method.label}</span>
            </label>
          ))}
        </div>
        <button>${amount}</button>
      </div>
    );
  };

上面的代码非常典型。你可能在某个入门教程中见过它。它并不一定不好。但是,正如我们上面提到的,代码将不同的关注点混合在一个组件中,这使得代码难以阅读。

初始实现的问题

我想解决的第一个问题是该组件有多“忙碌”。我的意思是,Payment处理不同的内容,这使得代码难以阅读,因为你必须在阅读时切换思维上下文。

为了进行任何更改,你必须理解如何初始化网络请求如何将数据映射到组件可以理解的本地格式如何渲染每个支付方式以及Payment组件本身的渲染逻辑

src/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      const fetchPaymentMethods = async () => {
        const url = "https://online-ordering.com/api/payment-methods";
  
        const response = await fetch(url);
        const methods: RemotePaymentMethod[] = await response.json();
  
        if (methods.length > 0) {
          const extended: LocalPaymentMethod[] = methods.map((method) => ({
            provider: method.name,
            label: `Pay with ${method.name}`,
          }));
          extended.push({ provider: "cash", label: "Pay in cash" });
          setPaymentMethods(extended);
        } else {
          setPaymentMethods([]);
        }
      };
  
      fetchPaymentMethods();
    }, []);
  
    return (
      <div>
        <h3>Payment</h3>
        <div>
          {paymentMethods.map((method) => (
            <label key={method.provider}>
              <input
                type="radio"
                name="payment"
                value={method.provider}
                defaultChecked={method.provider === "cash"}
              />
              <span>{method.label}</span>
            </label>
          ))}
        </div>
        <button>${amount}</button>
      </div>
    );
  };

对于这个简单的示例,在当前阶段这不是一个大问题。但是,随着代码变得越来越大、越来越复杂,我们将需要对其进行一些重构。

将视图和非视图代码拆分成不同的位置是一个好习惯。原因是,通常情况下,视图比非视图逻辑更频繁地发生变化。此外,由于它们处理应用程序的不同方面,将它们分开可以让你专注于特定的自包含模块,这在实现新功能时更容易管理。

视图和非视图代码的分离

在 React 中,我们可以使用自定义钩子来维护组件的状态,同时使组件本身保持无状态。我们可以使用提取函数创建一个名为usePaymentMethods的函数(前缀use是 React 中的约定,表示该函数是一个钩子,并在其中处理一些状态)

src/Payment.tsx…

  const usePaymentMethods = () => {
    const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      const fetchPaymentMethods = async () => {
        const url = "https://online-ordering.com/api/payment-methods";
  
        const response = await fetch(url);
        const methods: RemotePaymentMethod[] = await response.json();
  
        if (methods.length > 0) {
          const extended: LocalPaymentMethod[] = methods.map((method) => ({
            provider: method.name,
            label: `Pay with ${method.name}`,
          }));
          extended.push({ provider: "cash", label: "Pay in cash" });
          setPaymentMethods(extended);
        } else {
          setPaymentMethods([]);
        }
      };
  
      fetchPaymentMethods();
    }, []);
  
    return {
      paymentMethods,
    };
  };

这将返回一个paymentMethods数组(类型为LocalPaymentMethod)作为内部状态,并准备在渲染中使用。因此,Payment中的逻辑可以简化为

src/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const { paymentMethods } = usePaymentMethods();
  
    return (
      <div>
        <h3>Payment</h3>
        <div>
          {paymentMethods.map((method) => (
            <label key={method.provider}>
              <input
                type="radio"
                name="payment"
                value={method.provider}
                defaultChecked={method.provider === "cash"}
              />
              <span>{method.label}</span>
            </label>
          ))}
        </div>
        <button>${amount}</button>
      </div>
    );
  };

这有助于缓解Payment组件中的痛苦。但是,如果你查看遍历paymentMethods的代码块,你会发现这里缺少一个概念。换句话说,这个代码块应该有自己的组件。理想情况下,我们希望每个组件只专注于一件事。

通过提取子组件来拆分视图

此外,如果我们可以使组件成为一个纯函数 - 意味着给定任何输入,输出都是确定的 - 这将非常有助于我们编写测试、理解代码甚至在其他地方重用组件。毕竟,组件越小,它被重用的可能性就越大。

我们可以再次使用提取函数(也许我们应该称之为“提取组件”,但在 React 中,组件本身就是一个函数)。

src/Payment.tsx…

  const PaymentMethods = ({
    paymentMethods,
  }: {
    paymentMethods: LocalPaymentMethod[];
  }) => (
    <>
      {paymentMethods.map((method) => (
        <label key={method.provider}>
          <input
            type="radio"
            name="payment"
            value={method.provider}
            defaultChecked={method.provider === "cash"}
          />
          <span>{method.label}</span>
        </label>
      ))}
    </>
  );

Payment组件可以直接使用PaymentMethods,因此可以简化为如下所示

src/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const { paymentMethods } = usePaymentMethods();
  
    return (
      <div>
        <h3>Payment</h3>
        <PaymentMethods paymentMethods={paymentMethods} />
        <button>${amount}</button>
      </div>
    );
  };

请注意,PaymentMethods是一个纯函数(一个纯组件),它没有任何状态。它基本上是一个字符串格式化函数。

数据建模以封装逻辑

到目前为止,我们所做的更改都是关于将视图和非视图代码拆分成不同的位置。它运行良好。钩子处理数据获取和重塑。PaymentPaymentMethods都相对较小,易于理解。

但是,如果你仔细观察,仍然有改进的空间。首先,在纯函数组件PaymentMethods中,我们有一些逻辑来检查是否应该默认选中支付方式

src/Payment.tsx…

  const PaymentMethods = ({
    paymentMethods,
  }: {
    paymentMethods: LocalPaymentMethod[];
  }) => (
    <>
      {paymentMethods.map((method) => (
        <label key={method.provider}>
          <input
            type="radio"
            name="payment"
            value={method.provider}
            defaultChecked={method.provider === "cash"}
          />
          <span>{method.label}</span>
        </label>
      ))}
    </>
  );

视图中的这些测试语句可以被认为是逻辑泄漏,并且随着时间的推移,它们可能会散布在不同的位置,从而使修改变得更加困难。

另一个潜在的逻辑泄漏点是在我们获取数据的转换中

src/Payment.tsx…

  const usePaymentMethods = () => {
    const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      const fetchPaymentMethods = async () => {
        const url = "https://online-ordering.com/api/payment-methods";
  
        const response = await fetch(url);
        const methods: RemotePaymentMethod[] = await response.json();
  
        if (methods.length > 0) {
          const extended: LocalPaymentMethod[] = methods.map((method) => ({
            provider: method.name,
            label: `Pay with ${method.name}`,
          }));
          extended.push({ provider: "cash", label: "Pay in cash" });
          setPaymentMethods(extended);
        } else {
          setPaymentMethods([]);
        }
      };
  
      fetchPaymentMethods();
    }, []);
  
    return {
      paymentMethods,
    };
  };

请注意methods.map内部的匿名函数会静默地进行转换,并且这个逻辑以及上面的method.provider === "cash"可以提取到一个类中。

我们可以有一个类PaymentMethod,将数据和行为集中到一个地方

src/PaymentMethod.ts…

  class PaymentMethod {
    private remotePaymentMethod: RemotePaymentMethod;
  
    constructor(remotePaymentMethod: RemotePaymentMethod) {
      this.remotePaymentMethod = remotePaymentMethod;
    }
  
    get provider() {
      return this.remotePaymentMethod.name;
    }
  
    get label() {
      if(this.provider === 'cash') {
        return `Pay in ${this.provider}`
      }
      return `Pay with ${this.provider}`;
    }
  
    get isDefaultMethod() {
      return this.provider === "cash";
    }
  }

使用这个类,我可以定义默认的现金支付方式

const payInCash = new PaymentMethod({ name: "cash" });

在转换期间 - 从远程服务获取支付方式后 - 我可以在适当的位置构造PaymentMethod对象。甚至可以提取一个名为convertPaymentMethods的小函数

src/usePaymentMethods.ts…

  const convertPaymentMethods = (methods: RemotePaymentMethod[]) => {
    if (methods.length === 0) {
      return [];
    }
  
    const extended: PaymentMethod[] = methods.map(
      (method) => new PaymentMethod(method)
    );
    extended.push(payInCash);
  
    return extended;
  };

此外,在PaymentMethods组件中,我们不再使用method.provider === "cash"进行检查,而是调用getter

src/PaymentMethods.tsx…

  export const PaymentMethods = ({ options }: { options: PaymentMethod[] }) => (
    <>
      {options.map((method) => (
        <label key={method.provider}>
          <input
            type="radio"
            name="payment"
            value={method.provider}
            defaultChecked={method.isDefaultMethod}
          />
          <span>{method.label}</span>
        </label>
      ))}
    </>
  );

现在,我们正在将Payment组件重构为一堆更小的部分,这些部分协同工作以完成工作。

图 7:重构后的 Payment,包含更多可以轻松组合的部分

新结构的优势

  • 拥有一个类可以封装围绕支付方式的所有逻辑。它是一个领域对象,没有任何与 UI 相关的 信息。因此,测试和修改此处的逻辑比嵌入视图中要容易得多。
  • 新提取的组件PaymentMethods是一个纯函数,只依赖于一个领域对象数组,这使得它非常容易测试并在其他地方重用。我们可能需要向它传递一个onSelect回调,但即使在这种情况下,它也是一个纯函数,不需要触碰任何外部状态。
  • 功能的每个部分都很清楚。如果出现新的需求,我们可以直接导航到正确的位置,而无需阅读所有代码。

我必须使本文中的示例足够复杂,以便可以提取出许多模式。所有这些模式和原则都是为了帮助简化我们代码的修改。

新需求:捐赠给慈善机构

让我们通过对应用程序进行一些进一步的更改来检验这里的理论。新的需求是,我们希望为客户提供一个选项,让他们在订单中向慈善机构捐赠少量资金作为小费。

例如,如果订单金额为 19.80 美元,我们会询问他们是否愿意捐赠 0.20 美元。如果用户同意捐赠,我们将在按钮上显示总金额。

图 8:向慈善机构捐赠

在我们进行任何更改之前,让我们快速看一下当前的代码结构。我更喜欢将不同的部分放在它们自己的文件夹中,这样当它变得更大时,我更容易导航。

      src
      ├── App.tsx
      ├── components
      │   ├── Payment.tsx
      │   └── PaymentMethods.tsx
      ├── hooks
      │   └── usePaymentMethods.ts
      ├── models
      │   └── PaymentMethod.ts
      └── types.ts
      

App.tsx是主入口,它使用Payment组件,而Payment使用PaymentMethods来渲染不同的支付选项。钩子usePaymentMethods负责从远程服务获取数据,然后将其转换为PaymentMethod领域对象,该对象用于保存labelisDefaultChecked标志。

内部状态:同意捐赠

为了在Payment中进行这些更改,我们需要一个布尔状态agreeToDonate来指示用户是否选中了页面上的复选框。

src/Payment.tsx…

  const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);

  const { total, tip } = useMemo(
    () => ({
      total: agreeToDonate ? Math.floor(amount + 1) : amount,
      tip: parseFloat((Math.floor(amount + 1) - amount).toPrecision(10)),
    }),
    [amount, agreeToDonate]
  );

函数Math.floor将向下舍入数字,这样我们就可以在用户选择agreeToDonate时获得正确的金额,而舍入后的值与原始金额之间的差值将被分配给tip

对于视图,JSX 将是一个复选框加上简短的描述

src/Payment.tsx…

  return (
    <div>
      <h3>Payment</h3>
      <PaymentMethods options={paymentMethods} />
      <div>
        <label>
          <input
            type="checkbox"
            onChange={handleChange}
            checked={agreeToDonate}
          />
          <p>
            {agreeToDonate
              ? "Thanks for your donation."
              : `I would like to donate $${tip} to charity.`}
          </p>
        </label>
      </div>
      <button>${total}</button>
    </div>
  );

有了这些新的更改,我们的代码开始再次处理多个内容。务必注意潜在的视图和非视图代码混合。如果你发现任何不必要的混合,请寻找方法将它们拆分。

请注意,这不是一个一成不变的规则。对于小型且内聚的组件,将所有内容保持整洁和井井有条,这样你就不必在多个地方查找以了解整体行为。通常,你应该注意避免组件文件变得太大而无法理解。

提取一个钩子来救援

这里我们需要一个对象来计算小费和金额,并且每当用户改变主意时,该对象应该返回更新后的金额和小费。

所以听起来我们需要一个对象,它

  • 将原始金额作为输入
  • 每当agreeToDonate发生变化时,返回totaltip

听起来很适合再次使用自定义钩子,对吧?

src/hooks/useRoundUp.ts…

  export const useRoundUp = (amount: number) => {
    const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);
  
    const {total, tip} = useMemo(
      () => ({
        total: agreeToDonate ? Math.floor(amount + 1) : amount,
        tip: parseFloat((Math.floor(amount + 1) - amount).toPrecision(10)),
      }),
      [amount, agreeToDonate]
    );
  
    const updateAgreeToDonate = () => {
      setAgreeToDonate((agreeToDonate) => !agreeToDonate);
    };
  
    return {
      total,
      tip,
      agreeToDonate,
      updateAgreeToDonate,
    };
  };

在视图中,我们可以使用初始amount调用这个钩子,并将所有这些状态定义在外部。updateAgreeToDonate函数可以更新钩子中的值并触发重新渲染。

src/components/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const { paymentMethods } = usePaymentMethods();
  
    const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(amount);
  
    return (
      <div>
        <h3>Payment</h3>
        <PaymentMethods options={paymentMethods} />
        <div>
          <label>
            <input
              type="checkbox"
              onChange={updateAgreeToDonate}
              checked={agreeToDonate}
            />
            <p>{formatCheckboxLabel(agreeToDonate, tip)}</p>
          </label>
        </div>
        <button>${total}</button>
      </div>
    );
  };

请注意,我们还可以将消息格式化部分提取到辅助函数formatCheckboxLabel中,以简化组件中的代码。

const formatCheckboxLabel = (agreeToDonate: boolean, tip: number) => {
  return agreeToDonate
    ? "Thanks for your donation."
    : `I would like to donate $${tip} to charity.`;
};

Payment组件可以简化很多 - 状态现在完全在钩子useRoundUp中管理。

你可以将钩子想象成视图背后的一个状态机,每当 UI 中发生一些变化时,例如复选框更改事件。该事件将被发送到状态机以生成一个新状态,而新状态将触发重新渲染。

因此,这里的模式是,我们应该将状态管理从组件中移开,并尝试将其变成一个表现性函数(这样它就可以像这些简单的实用程序函数一样轻松地测试和重用)。React 钩子旨在从不同的组件中共享可重用逻辑,但我发现即使只有一个用途,它也很有用,因为它可以帮助你专注于组件中的渲染,并将状态和数据保留在钩子中。

随着捐赠复选框变得更加独立,我们可以将其移到自己的纯函数组件中。

src/components/DonationCheckbox.tsx…

  const DonationCheckbox = ({
    onChange,
    checked,
    content,
  }: DonationCheckboxProps) => {
    return (
      <div>
        <label>
          <input type="checkbox" onChange={onChange} checked={checked} />
          <p>{content}</p>
        </label>
      </div>
    );
  };

而在Payment中,由于 React 中的声明式 UI,阅读代码就像阅读一段简单的 HTML 代码一样简单。

src/components/Payment.tsx…

  export const Payment = ({ amount }: { amount: number }) => {
    const { paymentMethods } = usePaymentMethods();
  
    const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(amount);
  
    return (
      <div>
        <h3>Payment</h3>
        <PaymentMethods options={paymentMethods} />
        <DonationCheckbox
          onChange={updateAgreeToDonate}
          checked={agreeToDonate}
          content={formatCheckboxLabel(agreeToDonate, tip)}
        />
        <button>${total}</button>
      </div>
    );
  };

此时,我们的代码结构开始类似于下面的图表。请注意,不同的部分如何专注于自己的任务并协同工作以使流程正常运行。

图 9:重构后的 Payment,包含捐赠功能

关于四舍五入逻辑的更多更改

四舍五入到目前为止看起来不错,并且随着业务扩展到其他国家/地区,它带来了新的需求。相同的逻辑在日本市场不起作用,因为 0.1 日元作为捐赠太小了,它需要四舍五入到最接近的百位数才能用于日元。而对于丹麦,它需要四舍五入到最接近的十位数。

听起来很容易修复。我只需要将countryCode传递到Payment组件中,对吧?

<Payment amount={3312} countryCode="JP" />;

并且由于所有逻辑现在都在useRoundUp钩子中定义,我也可以将countryCode传递到钩子中。

const useRoundUp = (amount: number, countryCode: string) => {
  //...

  const { total, tip } = useMemo(
    () => ({
      total: agreeToDonate
        ? countryCode === "JP"
          ? Math.floor(amount / 100 + 1) * 100
          : Math.floor(amount + 1)
        : amount,
      //...
    }),
    [amount, agreeToDonate, countryCode]
  );
  //...
};

你会注意到,随着在useEffect块中添加新的countryCode,if-else 语句会不断增加。对于getTipMessage,我们需要相同的 if-else 检查,因为不同的国家/地区可能会使用其他货币符号(而不是默认的美元符号)

const formatCheckboxLabel = (
  agreeToDonate: boolean,
  tip: number,
  countryCode: string
) => {
  const currencySign = countryCode === "JP" ? "¥" : "$";

  return agreeToDonate
    ? "Thanks for your donation."
    : `I would like to donate ${currencySign}${tip} to charity.`;
};

最后,我们还需要更改按钮上的货币符号

<button>
  {countryCode === "JP" ? "¥" : "$"}
  {total}
</button>;

散弹枪手术问题

这种情况是我们经常在许多地方看到的著名的“散弹枪手术”气味(尤其是在 React 应用程序中)。这本质上意味着,每当我们需要修改代码以进行错误修复或添加新功能时,我们都必须触碰多个模块。事实上,进行如此多的更改更容易出错,尤其是在测试不足的情况下。

图 10:散弹枪手术气味

如上图所示,彩色线条表示跨越多个文件的国家/地区代码检查分支。在视图中,我们需要为不同的国家/地区代码执行不同的操作,而在钩子中,我们需要类似的分支。并且每当我们需要添加新的国家/地区代码时,我们都必须触碰所有这些部分。

例如,如果我们将丹麦视为业务正在扩展到的一个新国家/地区,最终将在许多地方得到类似这样的代码

const currencySignMap = {
  JP: "¥",
  DK: "Kr.",
  AU: "$",
};

const getCurrencySign = (countryCode: CountryCode) =>
  currencySignMap[countryCode];

解决分支散落在不同位置的问题的一种可能解决方案是使用多态性来替换这些 switch case 或表查找逻辑。我们可以使用 提取类 对这些属性进行操作,然后使用 用多态性替换条件语句

多态性来救援

首先,我们可以检查所有变体,看看需要提取到类中的内容。例如,不同的国家有不同的货币符号,因此 getCurrencySign 可以提取到一个公共接口中。此外,国家可能具有不同的舍入算法,因此 getRoundUpAmountgetTip 可以放到接口中。

export interface PaymentStrategy {
  getRoundUpAmount(amount: number): number;

  getTip(amount: number): number;
}

策略接口的具体实现类似于以下代码片段:PaymentStrategyAU

export class PaymentStrategyAU implements PaymentStrategy {
  get currencySign(): string {
    return "$";
  }

  getRoundUpAmount(amount: number): number {
    return Math.floor(amount + 1);
  }

  getTip(amount: number): number {
    return parseFloat((this.getRoundUpAmount(amount) - amount).toPrecision(10));
  }
}

请注意,这里接口和类与 UI 无直接关系。此逻辑可以在应用程序中的其他地方共享,甚至可以移动到后端服务(例如,如果后端是用 Node 编写的)。

我们可以为每个国家创建子类,每个子类都有特定于该国家的舍入逻辑。但是,由于函数在 JavaScript 中是一等公民,我们可以将舍入算法传递到策略实现中,以使代码在没有子类的情况下减少开销。由于我们只有一个接口实现,我们可以使用 内联类 来减少单一实现接口。

src/models/CountryPayment.ts…

  export class CountryPayment {
    private readonly _currencySign: string;
    private readonly algorithm: RoundUpStrategy;
  
    public constructor(currencySign: string, roundUpAlgorithm: RoundUpStrategy) {
      this._currencySign = currencySign;
      this.algorithm = roundUpAlgorithm;
    }
  
    get currencySign(): string {
      return this._currencySign;
    }
  
    getRoundUpAmount(amount: number): number {
      return this.algorithm(amount);
    }
  
    getTip(amount: number): number {
      return calculateTipFor(this.getRoundUpAmount.bind(this))(amount);
    }
  }

如以下所示,组件和钩子不再依赖于分散的逻辑,而是仅依赖于单个类 PaymentStrategy。在运行时,我们可以轻松地用另一个 PaymentStrategy 实例替换一个实例(红色、绿色和蓝色方块表示 PaymentStrategy 类的不同实例)。

图 11:提取类以封装逻辑

useRoundUp 钩子的代码可以简化为

src/hooks/useRoundUp.ts…

  export const useRoundUp = (amount: number, strategy: PaymentStrategy) => {
    const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);
  
    const { total, tip } = useMemo(
      () => ({
        total: agreeToDonate ? strategy.getRoundUpAmount(amount) : amount,
        tip: strategy.getTip(amount),
      }),
      [agreeToDonate, amount, strategy]
    );
  
    const updateAgreeToDonate = () => {
      setAgreeToDonate((agreeToDonate) => !agreeToDonate);
    };
  
    return {
      total,
      tip,
      agreeToDonate,
      updateAgreeToDonate,
    };
  };

Payment 组件中,我们将策略从 props 传递到钩子中

src/components/Payment.tsx…

  export const Payment = ({
    amount,
    strategy = new PaymentStrategy("$", roundUpToNearestInteger),
  }: {
    amount: number;
    strategy?: PaymentStrategy;
  }) => {
    const { paymentMethods } = usePaymentMethods();
  
    const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(
      amount,
      strategy
    );
  
    return (
      <div>
        <h3>Payment</h3>
        <PaymentMethods options={paymentMethods} />
        <DonationCheckbox
          onChange={updateAgreeToDonate}
          checked={agreeToDonate}
          content={formatCheckboxLabel(agreeToDonate, tip, strategy)}
        />
        <button>{formatButtonLabel(strategy, total)}</button>
      </div>
    );
  };

然后我进行了一些清理,提取了一些用于生成标签的辅助函数

src/utils.ts…

  export const formatCheckboxLabel = (
    agreeToDonate: boolean,
    tip: number,
    strategy: CountryPayment
  ) => {
    return agreeToDonate
      ? "Thanks for your donation."
      : `I would like to donate ${strategy.currencySign}${tip} to charity.`;
  };

我希望您已经注意到,我们正在尝试将非视图代码直接提取到单独的位置,或者抽象出新的机制来将其重构为更模块化的形式。

您可以这样想:React 视图只是非视图代码的消费者之一。例如,如果您要构建一个新的界面 - 也许使用 Vue 甚至是一个命令行工具 - 您可以在当前实现中重用多少代码?

进一步推动设计:提取网络客户端

如果我保持这种“关注点分离”的心态(用于分离视图和非视图逻辑,或者更广泛地将不同的职责分离到自己的函数/类/对象中),下一步就是做一些事情来缓解 usePaymentMethods 钩子中的混合。

目前,该钩子没有太多代码。如果我添加诸如错误处理和重试之类的东西,它很容易膨胀。此外,钩子是 React 的概念,您无法在下一个花哨的 Vue 视图中直接重用它,对吧?

src/hooks/usePaymentMethods.ts…

  export const usePaymentMethods = () => {
    const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      const fetchPaymentMethods = async () => {
        const url = "https://online-ordering.com/api/payment-methods";
  
        const response = await fetch(url);
        const methods: RemotePaymentMethod[] = await response.json();
  
        setPaymentMethods(convertPaymentMethods(methods));
      };
  
      fetchPaymentMethods();
    }, []);
  
    return {
      paymentMethods,
    };
  };

我已经将 convertPaymentMethods 提取到这里作为全局函数。我想将获取逻辑移到一个单独的函数中,这样我就可以使用像 React Query 这样的库来为我处理所有与网络相关的麻烦。

src/hooks/usePaymentMethods.ts…

  const fetchPaymentMethods = async () => {
    const response = await fetch("https://5a2f495fa871f00012678d70.mockapi.io/api/payment-methods?countryCode=AU");
    const methods: RemotePaymentMethod[] = await response.json();
  
    return convertPaymentMethods(methods)
  }

这个小类做了两件事,获取和转换。它充当 反腐败层(或网关 [1]),可以确保我们对 PaymentMethod 结构的更改仅限于单个文件。这种拆分的优势在于,同样,该类可以在需要时使用,即使是在后端服务中,就像我们上面看到的 策略 对象一样。

对于 usePaymentMethods 钩子,代码现在非常简单

src/hooks/usePaymentMethods.ts…

  export const usePaymentMethods = () => {
    const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>(
      []
    );
  
    useEffect(() => {
      fetchPaymentMethods().then(methods => setPaymentMethods(methods))
    }, []);
  
    return {
      paymentMethods,
    };
  };

我们的类图变成了下面的样子。我们已经将大部分代码移到了与视图无关的文件中,这些文件可以在其他地方使用。

图 12:更细粒度的拆分使每个部分的职责更清晰

拥有这些层的优势

如上所示,这些层为我们带来了许多优势

  1. 增强可维护性:通过将组件分离成不同的部分,更容易定位和修复代码特定部分中的缺陷。这可以节省时间并降低在进行更改时引入新错误的风险。
  2. 提高模块化:分层结构更加模块化,这可以更容易地重用代码和构建新功能。即使在每一层,以视图为例,也往往更具可组合性。
  3. 增强可读性:更容易理解和遵循代码的逻辑。这对于阅读和使用代码的其他开发人员来说尤其有用。这是对代码库进行更改的核心。
  4. 提高可扩展性:由于每个模块的复杂性降低,应用程序通常更具可扩展性,因为更容易添加新功能或进行更改而不会影响整个系统。这对于预计会随着时间推移而发展的大型复杂应用程序来说尤其重要。
  5. 迁移到其他技术栈:如果我们必须(即使在大多数项目中不太可能),我们可以替换视图层而不更改底层模型和逻辑。所有这一切都是因为领域逻辑封装在纯 JavaScript(或 TypeScript)代码中,并且不知道视图的存在。

结论

构建 React 应用程序或以 React 作为视图的前端应用程序不应被视为一种新型软件。构建传统用户界面的大多数模式和原则仍然适用。即使在后端构建无头服务的模式在前端领域也同样有效。我们可以在前端使用层,并将用户界面尽可能地薄,将逻辑沉入支持模型层,并将数据访问沉入另一个层。

在前端应用程序中拥有这些层的好处是,您只需要理解一个部分,而不必担心其他部分。此外,随着可重用性的提高,对现有代码进行更改将比以前更容易管理。


致谢

感谢 Andy Marks 和 Hannah Bourke 审阅了草稿版本并纠正了我的语法和语言问题。

感谢 Cam Jackson 对技术细节的详细审查以及对文章结构的宝贵建议。

感谢 Martin Fowler,我的榜样,指导我完成所有技术细节,并使我能够在本网站上发布这篇文章。

脚注

1: 网关 是一个封装对外部系统或资源访问的对象。当您不想将所有采用逻辑分散到代码库中时,它很有用,并且当外部系统发生变化时,更容易在一个地方进行更改。

重大修订

2023 年 2 月 16 日:发布文章的剩余部分

2023 年 2 月 14 日:发布“新需求”部分的第一部分

2023 年 2 月 8 日:发布第二部分:介绍支付功能。

2023 年 2 月 7 日:发布到“React 应用程序的演变”