不,禁用按钮不是应用程序逻辑。

2025-05-24

不,禁用按钮不是应用程序逻辑。

我将以 Ian Horrocks 于 1999 年撰写的《使用状态图构建用户界面》一书中的摘录开始这篇文章:

用户界面开发工具非常强大。它们可以用来构建大型复杂的用户界面,而应用程序开发人员只需编写相对较少的代码。然而,尽管这些工具功能强大且代码量相对较少,用户界面软件通常具有以下特点:

  • 代码可能难以理解和彻底审查:
  • 代码可能难以以系统和彻底的方式进行测试;
  • 即使经过大量测试和修复,代码仍可能包含错误;
  • 代码很难增强,而且不会引入不必要的副作用;
  • 随着代码的增强,其质量往往会下降。

尽管用户界面开发存在一些显而易见的问题,但人们却很少努力去改善这种情况。任何参与过大型用户界面项目的从业者都会熟悉上述许多特征,这些特征是软件构建方式的体现

如果你没算过,这篇文章写于20多年前,但它却反映了当今许多开发者对应用开发现状的感受。这是为什么呢?

我们将通过一个简单的示例来探讨这一点:在 React 组件中获取数据。请记住,本文中提出的想法并非针对特定库,也不是针对特定框架……事实上,它们甚至不是针对特定语言的!

努力fetch()实现

假设我们有一个DogFetcher组件,其中包含一个按钮,点击该按钮即可获取一只随机的狗。点击按钮时,会向Dog APIGET发出请求,获取到狗后,我们会在标签中展示它<img />

React Hooks的典型实现可能如下所示:

function DogFetcher() {
  const [isLoading, setIsLoading] = useState(false);
  const [dog, setDog] = useState(null);

  return (
    <div>
      <figure className="dog">{dog && <img src={dog} alt="doggo" />}</figure>

      <button
        onClick={() => {
          setIsLoading(true);
          fetch(`https://dog.ceo/api/breeds/image/random`)
            .then(data => data.json())
            .then(response => {
              setDog(response.message);
              setIsLoading(false);
            });
        }}
      >
        {isLoading ? "Fetching..." : "Fetch dog!"}
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

这样虽然可行,但有一个直接的问题:(在加载狗狗的过程中)多次点击按钮会短暂地显示一只狗狗,然后又用另一只狗狗替换掉它。这对第一只狗狗来说不太贴心。

典型的解决方案是disabled={isLoading}向按钮添加一个属性:

function DogFetcher() {
  // ...

  <button
    onClick={() => {
      // ... excessive amount of ad-hoc logic
    }}
    disabled={isLoading}
  >
    {isLoading ? "Fetching..." : "Fetch dog!"}
  </button>

  // ...
}
Enter fullscreen mode Exit fullscreen mode

这也行得通;你可能对这个解决方案很满意。请允许我打破这个幻想。

可能出现什么问题?

目前,逻辑如下:

单击按钮时,获取一只新的随机狗,并设置一个标志以确保在获取一只狗时不能再次单击按钮来获取另一只狗。

然而,你真正想要的逻辑是这样的:

当需要一只新狗时,请将其取来并确保不能同时取另一只狗。

发现区别了吗?所需的逻辑与被点击的按钮完全无关;请求如何发出并不重要;重要的是之后发生的逻辑。

假设你想添加双击图片加载新狗的功能。你需要怎么做?

很容易忘记添加相同的“保护”逻辑figure(毕竟,<figure disabled={isLoading}>行不通,想想看),但假设你是一位精明的开发人员,记得添加这个逻辑:

function DogFetcher() {
  // ...

  <figure
    onDoubleClick={() => {
      if (isLoading) return;

      // copy-paste the fetch logic from the button onClick handler
    }}
  >
    {/* ... */}
  </figure>

  // ...

  <button
    onClick={() => {
      // fetch logic
    }}
    disabled={isLoading}
  >
    {/* ... */}
  </button>

  // ...
}
Enter fullscreen mode Exit fullscreen mode

实际上,您可以将其视为可以从多个位置发生某种“触发器”的任何用例,例如:

  • 可以通过在输入框中按“Enter”键或单击“提交”按钮来提交表单
  • 由用户操作或超时触发的事件
  • 任何需要在不同平台之间共享、具有不同事件处理实现的应用程序逻辑(例如 React Native)

但这里有一个代码异味。我们相同的获取逻辑在多个地方实现,而要理解应用程序逻辑,开发人员需要跳转到代码库的多个部分,找到所有存在逻辑细节的事件处理程序,并在脑海中将它们连接起来。

消除逻辑的污点

好吧,把逻辑放在事件处理程序中可能不是一个好主意,但我们还不能确切地指出原因。让我们把获取逻辑移到一个函数中:

function DogFetcher() {
  const [isLoading, setIsLoading] = useState(false);
  const [dog, setDog] = useState(null);

  function fetchDog() {
    if (isLoading) return;

    setIsLoading(true);
    fetch(`https://dog.ceo/api/breeds/image/random`)
      .then(data => data.json())
      .then(response => {
        setDog(response.message);
        setIsLoading(false);
      });
  }

  return (
    <div>
      <figure className="dog" onDoubleClick={fetchDog}>
        {dog && <img src={dog} alt="doggo" />}
      </figure>

      <button onClick={fetchDog}>
        {isLoading ? "Fetching..." : "Fetch dog!"}
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

添加功能和复杂性

现在让我们看看当我们想要添加基本“功能”时会发生什么,例如:

  • 如果获取狗失败,则应显示错误。
  • 接狗应该是可以取消的。

我犹豫着是否将这些称为“特性”,因为这些类型的行为应该由所使用的编程模式自然地启用,但无论如何,让我们尝试添加它们:

function DogFetcher() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [canceled, setCanceled] = useState(false);
  const [dog, setDog] = useState(null);

  function fetchDog() {
    setCanceled(false);
    setError(null);
    setIsLoading(true);

    fetchRandomDog()
      .then(response => {
        // This should work... but it doesn't!
        if (canceled) return;

        setIsLoading(false);
        setDog(response.message);
      })
      .catch(error => {
        setIsLoading(false);
        setCanceled(false);
        setError(error);
      });
  }

  function cancel() {
    setIsLoading(false);
    setCanceled(true);
  }

  return (
    <div>
      {error && <span style={{ color: "red" }}>{error}</span>}
      <figure className="dog" onDoubleClick={fetchDog}>
        {dog && <img src={dog} alt="doggo" />}
      </figure>

      <button onClick={fetchDog}>
        {isLoading ? "Fetching..." : "Fetch dog!"}
      </button>
      <button onClick={cancel}>Cancel</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

看起来应该可以正常工作——所有布尔标志在事件发生时都会被设置为正确的值。然而,由于一个难以捕捉的 bug:过时的回调它无法正常工作。在这种情况下,回调中的标志将始终是先前的值而不是最新值,因此取消操作在我们下次尝试获取狗之前不会生效,这并非我们想要的结果。canceled.then(...)canceled

希望您能够看到,即使有了这些简单的用例,我们的逻辑也很快就失控了,而布尔标志的混乱使得逻辑变得更加错误和难以理解。

有效降低复杂性

与其到处乱加布尔标志,不如用useReduceranduseEffect钩子来清理一下。这些钩子很有用,因为它们表达了一些有助于更好地组织逻辑的概念:

  • useReducer钩子使用减速器,根据当前状态和刚刚发生的一些事件返回下一个状态。
  • useEffect钩子将效果与状态同步。

为了帮助我们组织各种应用程序状态,让我们定义一些状态并将它们放在一个status属性下:

  • 状态"idle"表示尚未发生任何事情。
  • 状态"loading"表示目前正在取狗。
  • 状态"success"表示狗已被成功取回。
  • 状态"failure"表示在尝试取回狗时发生了错误。

现在让我们定义一些可以在应用中发生的事件。请记住:这些事件可以在任何地方发生,无论是由用户发起还是其他地方:

  • 事件"FETCH"表明应该去抓一只狗。
  • "RESOLVE"具有属性的事件表示data已成功获取狗。
  • "REJECT"具有某个属性的事件表示error由于某种原因无法取回狗。
  • 事件"CANCEL"表明正在进行的提取应该被取消。

太棒了!现在我们来写一下 Reducer:

function dogReducer(state, event) {
  switch (event.type) {
    case "FETCH":
      return {
        ...state,
        status: "loading"
      };
    case "RESOLVE":
      return {
        ...state,
        status: "success",
        dog: event.data
      };
    case "REJECT":
      return {
        ...state,
        status: "failure",
        error: event.error
      };
    case "CANCEL":
      return {
        ...state,
        status: "idle"
      };
    default:
      return state;
  }
}

const initialState = {
  status: "idle",
  dog: null,
  error: null
};
Enter fullscreen mode Exit fullscreen mode

这个 Reducer 的优点就在于此。它完全与框架无关——我们可以在任何框架中使用它,或者根本不用任何框架。这也使得测试变得更容易。

而且,在框架中实现这一点也简化了(双关语),只需调度事件即可。事件处理程序中不再有逻辑:

function DogFetcher() {
  const [state, dispatch] = useReducer(dogReducer, initialState);
  const { error, dog, status } = state;

  useEffect(() => {
    // ... fetchDog?
  }, [state.status]);

  return (
    <div>
      {error && <span style={{ color: "red" }}>{error}</span>}
      <figure className="dog" onDoubleClick={() => dispatch({ type: "FETCH" })}>
        {dog && <img src={dog} alt="doggo" />}
      </figure>

      <button onClick={() => dispatch({ type: "FETCH" })}>
        {status === "loading" ? "Fetching..." : "Fetch dog!"}
      </button>
      <button onClick={() => dispatch({ type: "CANCEL" })}>Cancel</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

然而,问题依然存在:我们如何执行实际抓狗的副作用?好吧,由于useEffect钩子的目的是将效果与状态同步,我们可以将fetchDog()效果与同步status === 'loading',因为这'loading'意味着无论如何都会执行该副作用:

// ...
  useEffect(() => {
    if (state.status === "loading") {
      let canceled = false;

      fetchRandomDog()
        .then(data => {
          if (canceled) return;
          dispatch({ type: "RESOLVE", data });
        })
        .catch(error => {
          if (canceled) return;
          dispatch({ type: "REJECT", error });
        });

      return () => {
        canceled = true;
      };
    }
  }, [state.status]);
// ...
Enter fullscreen mode Exit fullscreen mode

传说中的“disabled”属性

上面的逻辑非常有效。我们可以:

  • 点击“获取狗”按钮来获取一只狗
  • 取回时显示一只随机的狗
  • 如果无法获取狗,则显示错误
  • 点击“取消”按钮取消正在进行的获取请求
  • 防止同时抓取多只狗

……所有这些都无需在<button disabled={...}>属性中添加任何逻辑。事实上,即使我们完全忘记了添加逻辑,逻辑仍然有效!

这样,无论 UI 如何,只要逻辑有效,就能确保其稳健运行。无论“抓狗”按钮是否禁用,连续多次点击它都不会出现任何意外行为。

此外,由于大多数逻辑都委托给组件外部dogReducer定义的函数,因此:

  • 易于制作成自定义钩子
  • 易于测试
  • 易于在其他组件中重用
  • 易于在其他框架中重用

最终结果

在选择下拉菜单中更改<DogFetcher />版本以查看我们在本教程中探讨过的每个版本(甚至是有缺陷的版本)。

将效果推到一边

然而,还有一个挥之不去的想法......useEffect()放置副作用(例如获取)的理想位置是哪里?

或许是,或许不是。

老实说,在大多数用例中,它都能正常工作。但是,很难测试或将这种效果与组件代码分离。随着 React 即将推出的 Suspense 和 Concurrent Mode 功能,建议在某些操作触发这些副作用时执行它们,而不是在 中执行useEffect()。这是因为 React 官方建议是:

如果你正在开发一个数据获取库,那么“边获取边渲染”这个关键特性一定不容错过。我们在渲染之前就开始获取数据。

https://reactjs.org/docs/concurrent-mode-suspense.html#start-fetching-early

这是个好建议。数据获取不应该和渲染耦合在一起。然而,他们也提到了这一点:

答案是,我们想开始在事件处理程序中进行获取。

这是误导性的建议。正确的做法是:

  1. 事件处理程序应该向“某事物”发送信号,表明某些动作刚刚发生(以事件的形式)
  2. 当“某物”接收到该事件时,它应该协调接下来发生的事情。

当某个协调器接收到事件时,可能会发生两种情况:

  • 状态可以改变
  • 可执行效果

所有这些都可以在组件渲染周期之外发生,因为它不一定与视图相关。遗憾的是,React 还没有内置方法(目前为止?)来处理组件外部的状态管理、副作用、数据获取、缓存等(我们都知道 Relay 并不常用),所以让我们探索一种完全在组件外部实现这些功能的方法。

使用状态机

在本例中,我们将使用状态机来管理和编排状态。如果您不熟悉状态机,只需知道它就像典型的 Redux Reducer,只是多了一些“规则”。这些规则有一些强大的优势,也是当今所有计算机运作的数学基础。所以它们值得学习。

我将使用XState@xstate/react创建机器:

import { Machine, assign } from "xstate";
import { useMachine } from "@xstate/react";

// ...

const dogFetcherMachine = Machine({
  id: "dog fetcher",
  initial: "idle",
  context: {
    dog: null,
    error: null
  },
  states: {
    idle: {
      on: { FETCH: "loading" }
    },
    loading: {
      invoke: {
        src: () => fetchRandomDog(),
        onDone: {
          target: "success",
          actions: assign({ dog: (_, event) => event.data.message })
        },
        onError: {
          target: "failure",
          actions: assign({ error: (_, event) => event.data })
        }
      },
      on: { CANCEL: "idle" }
    },
    success: {
      on: { FETCH: "loading" }
    },
    failure: {
      on: { FETCH: "loading" }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

请注意,该机器的外观与我们之前的减速器相似,但有几点不同之处:

  • 它看起来像某种配置对象,而不是 switch 语句
  • 我们首先匹配状态而不是首先匹配事件
  • 我们正在调用fetchRandomDog()机器内部的承诺!😱

别担心;我们实际上并没有在这个状态机内部执行任何副作用。事实上,dogFetcherMachine.transition(state, event)它是一个纯函数,它根据当前状态和事件告诉你下一个状态。看起来很熟悉,是吧?

此外,我可以复制粘贴这台机器并在 XState Viz 中将其可视化

取狗机的可视化

在 xstate.js.org/viz 上查看此可视化

那么我们的组件代码现在是什么样子的呢?看一看:

function DogFetcher() {
  const [current, send] = useMachine(dogFetcherMachine);
  const { error, dog } = current.context;

  return (
    <div>
      {error && <span style={{ color: "red" }}>{error}</span>}
      <figure className="dog" onDoubleClick={() => send("FETCH")}>
        {dog && <img src={dog} alt="doggo" />}
      </figure>

      <button onClick={() => send("FETCH")}>
        {current.matches("loading") && "Fetching..."}
        {current.matches("success") && "Fetch another dog!"}
        {current.matches("idle") && "Fetch dog"}
        {current.matches("failure") && "Try again"}
      </button>
      <button onClick={() => send("CANCEL")}>Cancel</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

以下是使用状态机和 Reducer 的区别:

  • 的钩子签名useMachine(...)看起来几乎与useReducer(...)
  • 组件内部不存在任何获取逻辑;全部都是外部的!
  • 有一个很好的current.matches(...)功能可以让我们自定义按钮文本
  • send(...)而不是dispatch(...)...它只需要一个简单的字符串!(或者一个对象,由你决定)。

状态机/状态图定义了从状态的转换,因为它回答了这个问题:“应该从这个状态处理哪些事件? ” 之所以<button disabled={isLoading}>脆弱,是因为我们承认无论我们处于哪种状态,某些“FETCH”事件都可能产生影响,所以我们必须通过阻止用户在加载时单击按钮来清理我们的~混乱~错误逻辑。

相反,最好主动地处理你的逻辑。只有当应用不处于某种"loading"状态时才应该进行获取,而状态机中明确定义了这种状态——"FETCH"事件在该状态下不会被处理"loading",这意味着它不会产生任何效果。完美。

最后要点

禁用按钮不符合逻辑。相反,这反而表明逻辑脆弱且容易出错。在我看来,禁用按钮应该只是给用户一个视觉提示,让他们知道点击按钮不会有任何效果

因此,当您在应用程序中创建获取逻辑(或任何其他类型的复杂逻辑)时,无论使用哪种框架,请问自己以下问题:

  • 这个应用/组件的具体、有限状态有哪些?例如,“加载中”、“成功”、“空闲”、“失败”等等。
  • 无论状态如何,都可能发生哪些事件?这包括非用户事件(例如,"RESOLVE""REJECT"来自 Promise 的事件)。
  • 哪一个有限状态应该处理这些事件?
  • 我该如何组织我的应用程序逻辑以便在这些状态下正确处理这些事件?

你不需要状态机库(比如 XState)来实现这一点。事实上,useReducer当你第一次采用这些原则时,你甚至可能不需要。即使像用状态变量表示有限状态这样简单的事情,也已经足够清晰你的逻辑了:

function DogFetcher() {
  // 'idle' or 'loading' or 'success' or 'error'
  const [status, setStatus] = useState('idle');
}
Enter fullscreen mode Exit fullscreen mode

就这样,你就消除了isLoadingisErrorisSuccessstartedLoading以及所有要创建的布尔标志。如果你真的开始怀念那个isLoading标志(无论出于什么原因),你仍然可以使用它,但前提是它必须源自你组织的有限状态。isLoading变量永远不应该成为状态的主要来源:

function DogFetcher() {
  // 'idle' or 'loading' or 'success' or 'error'
  const [status, setStatus] = useState('idle');

  const isLoading = status === 'loading';

  return (
    // ...
    <button disabled={isLoading}>
      {/* ... */}
    </button>
    // ...
  );
}
Enter fullscreen mode Exit fullscreen mode

好了,我们又回到了原点。感谢阅读。

封面照片由 Lucrezia Carnelos 在 Unsplash 上拍摄

文章来源:https://dev.to/davidkpiano/no-disabling-a-button-is-not-app-logic-598i
PREV
Redux 是模式的一半(1/2)
NEXT
如何使用 Material Angular 创建响应式侧边栏和迷你导航 简介 先决条件 步骤 1:设置 Angular 步骤 2:添加 Angular Material 步骤 3:导入所需组件 步骤 4:实现组件 步骤 5:添加响应性 步骤 6:切换菜单 步骤 7:折叠导航 奖励步骤:条件类