Redux 是模式的一半(1/2)

2025-05-24

Redux 是模式的一半(1/2)

Redux 非常棒。

你们中的一些人可能不同意,所以让我告诉你们原因。

在过去的几年中,Redux 推广了使用消息传递(也称为事件驱动编程)来管理应用程序状态的理念。现在,我们可以将状态视为一个“可预测的容器”,该容器仅根据这些“事件”做出响应而发生变化,而不是对各种类实例进行任意方法调用或修改数据结构。

这个简单的想法和实现足够通用,可以与任何框架(或根本没有框架)一起使用,并且启发了其他流行框架的库,例如:

然而,Redux 最近受到了 Web 社区一些知名开发人员的审查:

如果你不认识这些开发者,他们其实就是 Redux 的共同创造者。那么,为什么 Dan、Andrew 以及其他许多开发者几乎放弃了在应用程序中使用 Redux 呢?

Redux 中的理念和模式看似合理,如今仍在许多大型生产应用中使用。然而,它强制应用采用某种架构:

事实证明,这种单原子不可变架构并不自然,也不代表任何软件应用程序在现实世界中的工作方式(也不应该工作)。

Redux 是 Facebook Flux “模式”的替代实现。Facebook Flux 实现中的诸多症结和困难促使开发人员寻求其他更优秀、更方便开发人员使用的 API,例如 Redux、Alt、Reflux、Flummox等等。Redux最终脱颖而出,成为赢家。据称,Redux 融合了以下理念:

然而,Elm 架构甚至不是一个独立的架构/模式,因为它基于基本模式,无论开发人员是否知道:

早期的 Elm 程序员并非发明了它,而是在他们的代码中不断发现相同的基本模式。看到人们在没有提前规划的情况下最终写出架构良好的代码,真是令人毛骨悚然!

在本文中,我将通过将 Redux 与一个基础且成熟的模式——有限状态机——进行比较,来强调 Redux并非一个独立模式的一些原因。这并非随意的选择;我们编写的每个应用程序本质上都是一个状态机,无论我们是否意识到这一点。不同之处在于,我们编写的状态机是隐式定义的。

我希望这些比较和差异能够帮助您认识到 Redux 驱动的应用程序中的一些常见痛点是如何实现的,以及如何使用这种现有模式来帮助您构建更好的状态管理架构,无论您使用的是 Redux、其他库还是根本没有库。

什么是有限状态机?

(摘自我写的另一篇文章《FaceTime 漏洞和隐式状态机的危险》):

维基百科对有限状态机有一个实用但专业的描述。本质上,有限状态机是一个以状态、事件以及状态间转换为中心的计算模型。为了更简单起见,可以这样理解:

  • 你开发的任何软件都可以用有限数量的状态来描述例如idle,,,,loadingsuccesserror
  • 在任何给定时间,您只能处于其中一种状态(例如,您不能同时处于success和状态)error
  • 你总是从某个初始状态开始(例如idle
  • 根据事件,您可以从一个状态移动到另一个状态,或者说转换idle(例如,从某个状态,当LOAD事件发生时,您立即转换到该loading状态)

它就像你习惯编写的软件,但规则更明确。你以前可能习惯将isLoadingisSuccess写成布尔标志,但状态机不允许你isLoading === true && isSuccess === true同时使用 或 。

这也直观地表明了事件处理程序只能做一件主要的事情:将其事件转发到状态机。它们不能像现实世界中的物理设备一样“脱离”状态机并执行业务逻辑:计算器或 ATM 上的按钮实际上并不执行操作;相反,它们将“信号”发送到某个管理(或协调)状态的中央单元,该单元决定在收到该“信号”时应该做什么。

那么非有限的状态又如何呢?

对于状态机,尤其是UML 状态机(又名状态图),“状态”指的是与不能完全适合有限状态的数据不同的东西,但“状态”和所谓的“扩展状态”可以协同工作。

例如,让我们考虑水🚰。它可以分为四个阶段之一,我们将这些阶段视为水的状态

  • liquid
  • solid(例如冰、霜)
  • gas(例如,蒸汽、水蒸气)
  • plasma

水相UML状态机图

水相 UML 状态机图来自uml-diagrams.com

然而,水温是一个连续测量值,而非离散测量值,无法用有限的方式来表示。尽管如此,水温可以用水的有限状态来表示,例如:

  • liquid其中temperature === 90(摄氏度)
  • solid在哪里temperature === -5
  • gas在哪里temperature === 500

在应用程序中,有很多方法可以表示有限状态和扩展状态的组合。以水为例,我个人会将有限状态value(即“有限状态值”)和扩展状态context(即“上下文数据”)分别称为:

const waterState = {
  value: 'liquid', // finite state 
  context: {       // extended state
    temperature: 90
  }
}
Enter fullscreen mode Exit fullscreen mode

但您可以自由地以其他方式表示它:

const waterState = {
  phase: 'liquid', // finite state
  data: {          // extended state
    temperature: 90
  }
}

// or...

const waterState = {
  status: 'liquid', // finite state
  temperature: 90   // anything not 'status' is extended state
}
Enter fullscreen mode Exit fullscreen mode

关键点在于有限状态和扩展状态之间有明确的区别,并且有逻辑可以防止应用程序达到不可能的状态,例如:

const waterState = {
  isLiquid: true,
  isGas: true, // 🚱 Water can't be both liquid and gas simultaneously!
  temperature: -50 // ❄️ This is ice!! What's going on??
}
Enter fullscreen mode Exit fullscreen mode

我们可以将这些示例扩展到实际代码,例如更改如下内容:

const userState = {
  isLoading: true,
  isSuccess: false,
  user: null,
  error: null
}
Enter fullscreen mode Exit fullscreen mode

像这样:

const userState = {
  status: 'loading', // or 'idle' or 'error' or 'success'
  user: null,
  error: null
}
Enter fullscreen mode Exit fullscreen mode

这可以防止userState.isLoading === true和等不可能状态userState.isSuccess === true同时发生。

Redux 与有限状态机相比如何?

我之所以将 Redux 与状态机进行比较,是因为从总体上看,它们的状态管理模型非常相似。对于 Redux 来说:

state+ action=newState

对于状态机:

state+ event= newState+effects

在代码中,甚至可以通过使用Reducer以相同的方式表示这些

function userReducer(state, event) {
  // Return the next state, which is
  // determined based on the current `state`
  // and the received `event` object

  // This nextState may contain a "finite"
  // state value, as well as "extended"
  // state values.

  // It may also contain side-effects
  // to be executed by some interpreter.
  return nextState;
}
Enter fullscreen mode Exit fullscreen mode

已经存在一些细微的差别,例如“动作”与“事件”,或者扩展状态机如何模拟副作用(它们确实如此)。Dan Abramov 甚至认识到了其中的一些区别:

Reducer 可以用来实现有限状态机,但大多数 Reducer并非以有限状态机建模。让我们来了解一下 Redux 与状态机之间的一些区别,从而改变这种现状。

区别:有限状态和扩展状态

通常,Redux Reducer 的状态不会明确区分“有限”状态和“扩展”状态,正如上文所述。这是状态机中的一个重要概念:应用程序始终处于有限个“状态”中的某个状态,其余数据则表示为其扩展状态。

可以通过创建一个明确的属性来将有限状态引入到 reducer 中,该属性可以精确地表示众多可能状态之一:

const initialUserState = {
  status: 'idle', // explicit finite state
  user: null,
  error: null
}
Enter fullscreen mode Exit fullscreen mode

这样做的好处是,如果您使用 TypeScript,您可以利用可区分联合来使不可能的状态变得不可能:

interface User {
  name: string;
  avatar: string;
}

type UserState = 
  | { status: 'idle'; user: null; error: null; }
  | { status: 'loading'; user: null; error: null; }
  | { status: 'success'; user: User; error: null; }
  | { status: 'failure'; user: null; error: string; }
Enter fullscreen mode Exit fullscreen mode

区别:事件与动作

状态机术语中,“动作”是由于转换而产生的副作用:

当分派事件实例时,状态机通过执行操作来响应,例如更改变量、执行 I/O、调用函数、生成另一个事件实例或更改为另一个状态。

这并不是使用术语“动作”来描述导致状态转换的事物令人困惑的唯一原因;“动作”还表明需要做的事情(即命令),而不是刚刚发生的事情(即事件)。

因此,当我们谈论状态机时,请记住以下术语:

  • 事件描述发生的事情。事件触发状态转换
  • 动作描述了响应状态转换而发生副作用。

Redux 风格指南直接建议将动作建模为事件:

但是,我们建议尝试将操作视为“描述已发生的事件”,而不是“setter”。将操作视为“事件”通常会使操作名称更有意义,调度的操作总数更少,并且操作日志历史记录更有意义。

来源:Redux 风格指南:将动作建模为事件,而不是设置器

本文中使用的“事件”一词与传统的 Redux Action 对象含义相同。对于副作用,将使用“效果”一词。

区别:显式转换

状态机工作原理的另一个基本部分是转换。转换描述了一个有限状态如何由于事件而转换到另一个有限状态。这可以用方框和箭头来表示:

描述登录流程的状态机

该图清楚地表明,从 到 或从 到 直接转换是不可能的idlesuccesssuccess一个error状态转换到另一个状态,需要发生一系列明确的事件。

然而,开发人员建模 Reducer 的方式往往是仅根据接收到的事件来确定下一个状态:

function userReducer(state, event) {
  switch (event.type) {
    case 'FETCH':
      // go to some 'loading' state
    case 'RESOLVE':
      // go to some 'success' state
    case 'REJECT':
      // go to some 'error' state
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

这种方式管理状态的问题在于,它无法阻止不可能的转换。你见过这样的屏幕:先短暂地显示错误,然后又显示成功视图?如果没有,请浏览Reddit,并执行以下步骤:

  1. 搜索任何内容。
  2. 搜索时单击“帖子”选项卡。
  3. 说“啊哈!”然后等待几秒钟。

在步骤 3 中,您可能会看到类似这样的内容(在发布本文时可见):

Reddit 出现错误,显示没有搜索结果

几秒钟后,这个意外的视图就会消失,你最终会看到搜索结果。这个 bug 已经存在一段时间了,虽然它无害,但它确实影响了用户体验,而且绝对可以算作逻辑错误。

不管它是如何实现的(Reddit确实使用了 Redux……),肯定出了点问题:发生了不可能的状态转换。直接从“错误”视图转换到“成功”视图完全没有意义,而且在这种情况下,用户不应该看到“错误”视图,因为它根本不是错误;它还在加载!

你可能正在检查现有的 Redux Reducer,并意识到这个潜在的 bug 可能出现在何处,因为状态转换仅基于事件,使得这些不可能的转换成为可能。在 Reducer 中引入 if 语句或许可以缓解这个问题:

function userReducer(state, event) {
  switch (event.type) {
    case 'FETCH':
      if (state.status !== 'loading') {
        // go to some 'loading' state...
        // but ONLY if we're not already loading
      }

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

但这只会使你的状态逻辑更难理解,因为状态转换并不明确。尽管可能有点冗长,但最好根据当前有限状态和事件来确定下一个状态,而不是仅仅根据事件:

function userReducer(state, event) {
  switch (state.status) {
    case 'idle':
      switch (event.type) {
        case 'FETCH':
          // go to some 'loading' state

        // ...
      }

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

您甚至可以将其拆分为单独的“有限状态”减速器,以使事情变得更清晰:

function idleUserReducer(state, event) {
  switch (event.type) {
    case 'FETCH':
      // go to some 'loading' state

      // ...
    }
    default:
      return state;
  }
}

function userReducer(state, event) {
  switch (state.status) {
    case 'idle':
      return idleUserReducer(state, event);
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

但不要只听我说。Redux 风格指南也强烈建议将 Reducer 视为状态机:

[...] 将减速器视为“状态机”,其中当前状态和分派的动作的组合决定是否实际计算新的状态值,而不仅仅是无条件的动作本身。

来源:Redux 风格指南:将 Reducer 视为状态机

我在我的帖子中也详细讨论了这个想法:不,禁用按钮不是应用程序逻辑。

区别:声明性效果

如果单独来看 Redux,其管理和执行副作用的策略是这样的:

˙\_(ツ)_/˙

没错,Redux 没有内置处理副作用的方法。在任何复杂的应用程序中,如果你想做任何有用的事情,比如发起网络请求或启动某种异步进程,都会产生副作用。重要的是,副作用应该被视为事后诸葛亮;它们应该被视为一等公民,并在你的应用程序逻辑中毫不妥协地体现出来。

不幸的是,使用 Redux 时,它们确实存在,唯一的解决方案是使用中间件,尽管对于任何非平凡的应用程序逻辑来说都是必需的,但这是一个令人费解的高级主题:

如果没有中间件,Redux store 仅支持同步数据流。

来源:Redux 文档:Async Flow

在扩展/UML状态机(也称为状态图)中,这些副作用被称为动作(本文的其余部分将简称为动作),并以声明式的方式进行建模。动作是转换的直接结果:

当分派事件实例时,状态机通过执行操作来响应,例如更改变量、执行 I/O、调用函数、生成另一个事件实例或更改为另一个状态。

_来源:(维基百科)UML 状态机:动作和转换

这意味着,当事件改变状态时,即使状态保持不变,也可能执行相应的动作(效果)(称为“自转换”)。正如牛顿所说:

每一个作用力都会有一个大小相等、方向相反的反作用力。

来源:牛顿第三运动定律

动作永远不会无缘无故地自发发生,无论是在软件中,还是在硬件中,还是在现实生活中,都永远不会。动作的发生有一个原因,而对于状态机来说,这个原因就是由于接收到的事件而引起的状态转换。

状态图通过三种可能的方式区分如何确定动作:

  • 进入动作是每当进入特定有限状态时执行的效果
  • 退出操作是每当退出特定有限状态时执行的效果
  • 转换动作是在两个有限状态之间进行特定转换时执行的效果。

有趣的事实:这就是为什么状态图被认为具有Mealy 机Moore 机的特征

  • 对于 Mealy 机,“输出”(动作)取决于状态和事件(转换动作)
  • 对于 Moore 机器,“输出”(动作)仅取决于状态(进入和退出动作)

Redux 最初的理念是,它不想对这些副作用的执行方式发表意见,因此存在诸如redux-thunkredux-promise之类的中间件。这些库通过提供第三方、针对特定用例的“解决方案”来处理不同类型的副作用,从而解决了 Redux 副作用无关的问题。

那么该如何解决这个问题呢?这可能看起来很奇怪,但是就像可以使用属性来指定有限状态一样,也可以使用属性来以声明的方式指定应执行的操作:

// ...
case 'FETCH':
  return {
    ...state,

    // finite state
    status: 'loading',

    // actions (effects) to execute
    actions: [
      { type: 'fetchUser', id: 42 }
    ]
  }
// ...
Enter fullscreen mode Exit fullscreen mode

现在,你的 Reducer 将返回有用的信息来回答这个问题:“状态转换后应该执行哪些副作用(操作)?” 答案很明确,并且就在你的应用状态中:读取actions属性以获取要执行的操作的声明性描述,然后执行它们:

// pretend the state came from a Redux React hook
const { actions } = state;

useEffect(() => {
  actions.forEach(action => {
    if (action.type === 'fetchUser') {
      fetch(`/api/user/${action.id}`)
        .then(res => res.json())
        .then(data => {
           dispatch({ type: 'RESOLVE', user: data });
        })
    }
    // ... etc. for other action implementations
  });
}, [actions]);
Enter fullscreen mode Exit fullscreen mode

在某些属性(或类似属性)中以声明方式建模副作用state.actions具有诸多优势,例如可以预测/测试或追踪操作何时执行或已执行,以及自定义执行这些操作的实现细节。例如,可以fetchUser将操作更改为从缓存中读取数据,而无需更改 Reducer 中的任何逻辑。

区别:同步与异步数据流

事实上,中间件是一种间接的机制。它将应用程序逻辑分散在多个位置(reducer 和中间件),导致对它们如何协同工作缺乏清晰、统一的理解。此外,它使某些用例更容易实现,而另一些用例则更加困难。例如:以Redux 高级教程中的这个示例为例,它redux-thunk允许调度一个“thunk”来发起异步请求:

function fetchPosts(subreddit) {
  return dispatch => {
    dispatch(requestPosts(subreddit))
    return fetch(`https://www.reddit.com/r/${subreddit}.json`)
      .then(response => response.json())
      .then(json => dispatch(receivePosts(subreddit, json)))
  }
}
Enter fullscreen mode Exit fullscreen mode

现在问问自己:我该如何取消这个请求?有了redux-thunk,这根本不可能。如果你的答案是“选择其他中间件”,那么你刚才的观点就得到了验证。逻辑建模不应该是选择哪个中间件的问题,中间件甚至不应该成为状态建模过程的一部分。

如前所述,使用 Redux 建模异步数据流的唯一方法是使用中间件。对于所有可能的用例,从 thunk 到 Promises,从 sagas(生成器)到 epics(可观察对象)等等,生态系统针对这些用例提供了大量不同的解决方案。但理想的解决方案数量只有一个:即由正在使用的模式提供的解决方案。

好吧,那么状态机如何解决异步数据流问题?

他们没有。

需要澄清的是,状态机不区分同步和异步数据流,因为它们之间没有区别。这一点非常重要,因为它不仅简化了数据流的概念,而且还模拟了现实生活中事物的工作方式:

  • 状态转换(由接收事件触发)总是在“零时间”发生;也就是说,状态同步转换。
  • 可以随时接收事件。

异步转换根本不存在。例如,数据获取的建模看起来不应该像这样:

idle . . . . . . . . . . . . success
Enter fullscreen mode Exit fullscreen mode

相反,它看起来像这样:

idle --(FETCH)--> loading --(RESOLVE)--> success
Enter fullscreen mode Exit fullscreen mode

一切都是某个事件触发状态转换的结果。中间件掩盖了这一事实。如果您好奇如何以同步状态转换的方式处理异步取消,以下是一些潜在实现的指导要点:

  • 取消意图是一个事件(例如{ type: 'CANCEL' }
  • 取消正在进行的请求是一种行为(即副作用)
  • “已取消”是一种状态,无论它是特定状态(例如canceled)还是请求不应处于活动状态(例如idle

待续

在 Redux 中,可以将应用程序状态建模得更像有限状态机,这样做有很多好处。我们编写的应用程序具有不同的模式,或者说“行为”,它们会根据所处的“状态”而变化。以前,这种状态可能是隐式的。但现在有了有限状态,您可以根据这些有限状态(例如idleloadingsuccess等)对行为进行分组,这使得整体应用程序逻辑更加清晰,并防止应用程序陷入不可能的状态。

有限状态还能明确事件在特定状态下可以执行的操作,以及应用程序中所有可能的状态。此外,它们可以与用户界面中的视图一一对应。

但最重要的是,状态机存在于你编写的所有软件中,而且已经存在了半个多世纪。将有限状态机显式化可以为复杂的应用程序逻辑带来清晰度和健壮性,并且可以在你使用的任何库中实现它们(甚至不需要任何库)。

在下一篇文章中,我们将讨论 Redux 原子全局存储如何成为一种模式的一半,它所带来的挑战,以及它与另一个众所周知的计算模型(Actor 模型)的比较。

封面照片由Joseph Greve 在 Unsplash 上拍摄

文章来源:https://dev.to/davidkpiano/redux-is-half-of-a-pattern-1-2-1hd7
PREV
你不需要状态机库
NEXT
不,禁用按钮不是应用程序逻辑。