React 0.14:揭秘局部组件状态陷阱

3 年前

本文于 2015 年 10 月 29 日 发表在 Safari 在线书店的 Content - Highlights and ReviewsjavascriptprogrammingProgramming & DevelopmentTechWeb Development 类目下。

撰稿人 Richard Feldman

Richard Feldman 酷爱函数式编程,专注于突破浏览器端 UI 的限制。他就职于 NoRedInk,并且使用 Elm 和 React 打造了该教育系统。他同样是即将由 Bleeding Edge Press出版的第二版「Developing a React Edge: The Javascript Library for User Interfaces」的作者之一。该书的第二版很快就会出现在 Safari 上。

一直以来,在 JavaScript UI 编程世界中,在多个组件中切分应用的状态都是很寻常的事情,也就是说每个组件拥有自己的状态。在过去的架构体系中这也基本是不可避免的,不过 React 和声明式渲染的出现带来了一个更加健壮的范式:单一原子状态

现在越来越多的 JavaScript 库抛弃了局部组件状态转而偏爱单一原子状态,当然也随之带来了很多问题。我们如何控制嵌套组件让其只能访问单一原子状态的特定部分?是否可以在不借助组件内部状态的情况下重用组件?我们能不能通过 React 0.14 的无状态函数式组件独立完成这样的一个应用?

或许最重要的是:现在已经有足够健康的生态来支撑单一原子状态应用,是否局部组件状态真的从一个最佳实践沦落为一个诱人的陷阱了呢?

单一数据源

一个常规的 Web 应用到底需要多少个数据库来存储它的状态?答案几乎总是『仅需一个』。很多特殊用途的数据库用于消息队列和缓存(比如,Redis、Memcache),不过几乎很少有 Web 应用庞大到需要切分应用的状态跨数据库存储的。

切分数据库并没有什么技术上的障碍,如果我们做了会怎样呢?假设我们把用户账号信息存储在一个 MySQL 数据库中,他们的角色和权限存储在另一个 MySQL 数据库,『最近动态』又存储在一个单独的 MySQL 数据库。

这样做有一个很明显的好处。我们可以保证每一部分的数据都不会与其他部分混在一起。对每一个进行查询操作,都很轻量和简洁,因为不需要在其他的数据中花费时间索引。在某种概念下,这种严格的隔离很受推崇。

然而,它的弊端也很明显。其一,它会引出同步性的问题。比如,在我们删除一个用户账号的同时有人在另一个数据库中查询该用户的『最近动态』会发生什么?他很可能得到一些很糟糕的数据,除非我们能够做到原子事务更新。如果这些值都在同一个数据库中,这可能并没有什么,不过当状态如此分布的时候会更加的困难并且容易出错。

另外一个弊端是备份变得更难。不仅仅是备份一个单独的数据库,在发生故障时直接恢复它。取而代之的是需要备份和恢复很多数据库。如果我们几乎恢复了所有的数据库,而仅有一个没有恢复又回怎样呢?那么应用的状态将会混乱,并且可能直到它宕机的时间足够长并且产生了很多糟糕的数据污染了其他的数据时,我们才会意识到。

这些问题来源于我们的数据在业务上是天然地耦合在一起的:用户账号、他们的权限以及他们的『最近动态』,而我们又需要在架构上将它们解耦。你喜欢或不喜欢,那些数据都依赖于另一个,而将它们分开存储只会增加串联它们的难度。

我们在编写前端代码的过程中,当每个组件都有自己的局部状态时,遇到了类似的的挑战。我们都知道『撤销/恢复』成就了很棒的用户体验,就像数据『备份/恢复』一样,想要在应用状态在多个数据源中传递是很难的而且很容易出错。也很容易出现组件之间错误地传递,导致应用状态的混乱。比如一个很臭名昭著的案例,当一个消息通知组件显示『一个未读消息』时,而实际上消息列表组件认为所有的消息都是已读状态。

单一的数据来源 完美解决了数据源上的同步问题。伴随 React 而来的『不可变数据』和『声明式渲染』从根本上解决了这些问题。越来越多的前端架构正在拥抱使用单一数据来源来表示应用的状态,并且得到了如后端数据库一样的待遇。

来自 Mozilla 的 James Long 已经使用 React 借助 Dan Abramov 的 Redux 架构在开发 Firefox 开发者工具中拥抱单一原子状态,实现了按照时序热加载的特性。David Nolen 为 ClojureScript 而写的 Om 库借助单一原子状态让逻辑像『撤销/恢复』一样简单,Sean Grove 也尝到了甜头,在 Glint 中很容易地添加了『撤销/恢复』功能。在 NoRedInk 我们高兴地用着 Elm 架构,在这些单一原子状态的基础上 Laszlo Pandy 构建了最原始的 Time-Traveling 调试器

尽管在前端架构的动向上看单一原子状态的好处越来越清晰,但社区中关于这一新领域中存在问题的讨论一直没有停止过。我们如何维持局部组件状态的好处,精确地控制那一段代码可以访问哪个状态?以及我们该如何在不让每个组件拥有自身状态的情况下创建可复用的组件?

随着新技术的到来,这些问题也变得出奇的简单。

可复用的无状态组件

考虑一个折叠组件。当用户点击某一特定的区域,如果该区域处于折叠状态它应该展开,反之亦然。

很明显,这里包含了状态。我们需要记录哪些区域处于展开以及折叠状态,以便渲染组件,以及当用户点击指定区域时应该如何做。如果折叠组件不能调用自身的状态,我们该如何做呢?

在后端,经常会有很多可复用的库用于基于状态的任务,比如用户授权。然而,这些库并不会与一个自己的 MySQL 数据库打包在一起;相反,应用通常会结合现有的数据库通过一个 API 来使用它们,因为用于授权的库通常被设计为委托状态存储而不是将状态打包起来。

在前端,这一思想同样适用。

我们的折叠组件需要状态做两件事情:渲染以及处理用户的动作。它需要知道当前状态(即哪部分被折叠和展开)以便渲染,并且在用户点击时它需要改变状态(即改变展开的和折叠的部分)。

过去,处理这类需求最常见的方式就是给它一系列的局部状态,然后在渲染时去读取它,在用户点击时修改它。

在单一原子状态中,存在委托机制:折叠组件的 API 接收用于渲染的所必须的展开或折叠数据作为参数,以及状态更新函数(expandSection()collapseSection()等)用于响应用户的输入。所以当用户点击折叠区域并且该区域要展开时,它会调用给它传递的 expandSection() 函数,随之更新折叠组件的单一原子状态原子。

不仅仅是这种委派的方式,它还提供了更加强大的方式用于控制哪些组件能够影响状态的哪个部分。父组件可以为子组件提供『读权限』(提供当前状态的一部分作为参数)而不提供『写权限』(为子组件提供了函数可用于更新单一原子状态的指定部分),反过来也一样。你甚至可以根据当前状态选择性地提供访问权限。

这种架构会为我们的终端用户带来哪些不一样的东西?

假设我们想要实现一个用户体验提升:当用户刷新页面时,折叠组件依旧处于用户操作后的展开或折叠状态。我们可以轻易地使用 localStorage 来持久化必要的信息。不过相对于单一原子状态而言,使用局部状态来实现的难点又在哪里呢?

使用局部组件状态时,每次用户点击折叠组件时,我们一定要去更新 localStorage 以持久化数据。如果我们忘记了,即便只有一次,状态就会不同步。最后我们会得到一个混乱的状态,页面所展示的东西和用户的交互不一致。而且这种问题很难追踪到。

一旦有了单一原子状态,一个折叠动作可以从状态的拥有者委派给它,这是很简单的事情。每次状态发生改变,我们将整个原子都存到 localStorage 中。这样绝不会出现不同步的问题,并且我们的应用架构仅需要维护一个原子,所有的 UI 组件都会工作,包括可复用的折叠组件等。

这也的确是我们在 NoRedInk 使用 Elm 创建可复用的折叠组件所使用的方式。

Example: Stateless Functional Components in React 0.14

示例:React 0.14 中的无状态函数式组件

最新发布的 React 0.14 提出了无状态函数式组件。它指出:

在通常的 React 代码中,绝大多数组件都被写成无状态、简单地组合成其他的组件等等。这种通过创建多个简单组件然后组合成一个大应用的设计模式被提倡。

下面是那篇文章中的一个类似的例子:

// A functional component using an ES2015 (ES6) arrow function:
// 一个使用 ES2015 (ES6)箭头函数写成的函数式组件
var Aquarium = (props) => {
  var fish = getFish(props.species);
  return {fish};
};

我们可以运行一下这个示例。用户打开或关闭鱼缸灯光的功能该如何实现呢?我们可以使用局部组件状态,每一个鱼缸都会有一个状态来表示灯的开关状态,但是让我们用委派单一组件状态来试试。

下面在 jsFiddle 中展示了该示例:

示例中有一个典型的 React 组件位于组件树的根部,我们称之为 App。它的职责仅仅是控制应用的单一原子状态,它的子组件负责按需更新状态。

注意,Aquarium 和它的子组件 Tank 都是无状态的;我们可以明确这一点,因为它们使用了 0.14 的无状态函数式组件的方式,甚至不支持局部组件状态!不要灰心,鱼缸这个类在需要的时候更新整个应用的状态并没有问题,因为它的父组件提供了 setLightOn 方法用于选择性地更新。

这里我们可以看到局部状态的概念上的好处,那就是可以精细地控制哪些组件可以访问哪个状态。而这一点可以通过简单的函数传递来实现,而不必去牺牲单一原子状态的好处。Tank 不需要访问整个应用的状态;仅仅需要知道它有一个鱼缸,能对鱼缸做的就是打开或关闭它的照明。

简而言之,我们简单地通过选择性地传递参数避免了将组件的内容与应用状态的其他部分混合在一起!

这可能事一种不寻常的做事方式,不过请记住这给我们带来了什么:如果我们想要序列化整个应用的状态并将它存到某个地方,或者切换到前一个状态,都将是不费吹灰之力。跨应用状态的不同部分来更新事务也就变得微不足道了,因而争用状态也很容易避免。

对大多数无状态组件来讲,第二版的『Developing a React Edge』会详细的讨论,当然还包括 React 0.14 中的新特性。我也同样希望单一原子状态会成为在布拉迪斯拉发市举办的 Reactive2015 中的一个议题,会有很多关于单一原子状态的演讲,包括 ReduxOm 以及 Elm

总结

尽管局部组件状态很诱人,而单一原子状态的好处绝对强大到不可拒绝:

  • 单一数据源使得操作事务更新更加简单并且避开了争用状态
  • 快速持久化和检索状态的序列,不再需要大量的判断逻辑
  • 调试器能够可靠地跨整个 UI 状态按照时序调试
  • 『撤销/恢复』成为一个可以轻易实现的特性

我们能够使用委派和传递函数来创建一个可复用的无状态组件,可以像有状态的组件那样精细地控制状态的访问权限,即便是局部组件状态的好处也可以轻易地移植到单一原子状态的领域中。

所以,它很可能就是前端开发界的下一次革命,我们避免了局部组建状态的陷阱并且尝到了后端开发者一直在享用的甜头。

0
推荐阅读