React APIs 演进和代码复用

187 阅读16分钟

React APIs 简史

让我们从面向对象设计模式在 JS 生态系统中被广泛使用的时候开始。早期的 React APIs 也受到了这种影响。

混入(Mixins)

React.createClass API 是创建组件的原始方式。在 JavaScript 支持 class 语法之前,React 有自己的 class 实现方式。混入是一种通用的 OOP 模式,用于代码重用,以下是一个简化的示例:

function ShoppingCart() {
   this.items = [];
 }
 var orderMixin = {
   calculateTotal() {
     // calculate from this.items
   }
   // .. other methods
 }
 // mix that bad boy in like it's 2014
 Object.assign(ShoppingCart.prototype, orderMixin)
 var cart = new ShoppingCart()
 cart.calculateTotal()

JavaScript 不支持多重继承,因此混入是一种实现共享行为和增强类的方法。问题是,React 如何使用 createClass 实现组件之间共享逻辑呢?

混入是一种常用的模式,因此它看起来是个不错的方案。混入可以访问组件的生命周期方法,允许我们组合逻辑、状态和副作用:

var SubscriptionMixin = {
   // multiple mixins could contribute to
   // the end getInitialState result
   getInitialState: function() {
     return {
       comments: DataSource.getComments()
     };
   },
   // when a component used multiple mixins
   // React would try to be smart and merge the lifecycle
   // methods of multiple mixins, so each would be called
   componentDidMount: function() {
     console.log('do something on mount')
   },
   componentWillUnmount: function() {
     console.log('do something on unmount')
   },
 }
 // pass our object to createClass
 var CommentList = React.createClass({
   // define them under the mixins property
   mixins: [SubscriptionMixin, AnotherMixin, SomeOtherMixin],
   render: function() {
     // comments in this.state coming from the first mixin
     // (!) hard to tell where all the other state
     //     comes from with multiple mixins
     var { comments, ...otherStuff } = this.state
     return (
       <div>
         {comments.map(function(comment) {
           return <Comment key={comment.id} comment={comment} />
         })}
       </div>
     )
   }
 })

对于足够小的例子,这种方式是有效的。但是,当混入应用到更大规模时,它们有一些缺点:

  • 名称冲突(Name collisions):混入的命名空间是共享的。当多个混入存在同名的方法或状态时,就会发生冲突。
  • 隐式依赖关系(Implicit dependencies):需要额外的工作才能确定某个混入提供了什么功能或状态。它们引用共享的属性键,导致了相互之间的耦合。
  • 降低本地推理的能力:混入通常使组件更难以推理和调试。例如,几个混入可能会对 getInitialState 的结果产生影响,难于调试和追踪。

感受到了这些问题的痛苦后,React 团队发表了 “混入被认为是有害的”(Mixins Considered Harmful)的文章,不再鼓励继续使用这种模式。

高阶组件(Higher-order components)

最终,JavaScript 中支持了 class 语法。React 团队在 v15.5 中弃用了 createClass API,倾向于使用原生 class。

在这个转换过程中,我们仍然按照类和生命周期来思考,因此没有进行重大的心智模型转变。现在我们可以扩展 React 的 Component 类,其中包括生命周期方法:

class MyComponent extends React.Component {
   constructor(props) {
     // runs before the component mounts to the DOM
     // super refers to the parent Component constructor
     super(props)
     // calling it allows us to access
     // this.state and this.props in here
   }
   // life cycle methods related to mounting and unmounting
   componentWillMount() {}
   componentDidMount(){}
   componentWillUnmount() {}
   // component update life cycle methods
   // some now prefixed with UNSAFE_
   componentWillUpdate() {}
   shouldComponentUpdate() {}
   componentWillReceiveProps() {}
   getSnapshotBeforeUpdate() {}
   componentDidUpdate() {}
   // .. and more methods
   render() {}
 }

在考虑了混入的缺陷后,问题是如何在这种编写 React 组件的新方式中共享逻辑和效果?

高阶组件(Higher Order Components,HOC)通过一个早期的 gist 进入了我们的视线。它得名于函数式编程概念中的高阶函数。

它们成为一种流行的替代混入的方式,在诸如 Redux 这类库中大放异彩,例如它的 connect 函数将组件连接到 Redux store 中,以及 React Router 的 withRouter 等。

// a function that creates enhanced components
 // that have some extra state, behavior, or props
 const EnhancedComponent = myHoc(MyComponent);
 // simplified example of a HOC
 function myHoc(Component) {
   return class extends React.Component {
     componentDidMount() {
       console.log('do stuff')
     }
     render() {
       // render the original component with some injected props
       return <Component {...this.props} extraProps={42} />
     }
   }
 }

HOC 对于在多个组件之间共享通用行为非常有用。它们允许包装的组件保持解耦,以便可重用。

抽象是强大的,因为一旦我们掌握了它们,就会用它们来处理所有事情。结果发现 HOC 遇到了与混入类似的问题:

  • 名称冲突:因为 HOC 需要将 ...this.props 转发和扩展到包装的组件中,所以嵌套的 HOC 可能会发生冲突并相互覆盖。
  • 难以静态类型化:这大约是静态类型检查器真正普及的时候。当多个嵌套的 HOC 向包装的组件注入新的 props 时,正确地进行类型定义是一件困难的事情。
  • 数据流不明确:对于混入而言,“这个状态从哪里来?”;对于 HOC 而言,则是 “这些 props 是从哪里来的?” 因为它们在模块级别静态组合,所以很难追踪数据流。

除了这些缺陷外,过度使用 HOC 还会导致深度嵌套和复杂的组件层次结构以及难以调试的性能问题。

渲染属性(Render props)

渲染属性模式开始作为 HOC 的替代方案出现,由开源 API 如 React-Motion 和 downshift 以及构建 React Router 的人们推广。

这个想法是将一个函数作为 prop 传递给组件。然后组件内部调用该函数,传递任何数据和方法,并将控制权反转回函数以继续渲染它们想要的内容。

与 HOC 相比,组合发生在 JSX 内运行时,而不是在模块范围内静态地进行组合。它们没有遭受名称冲突,因为可以明确地了解事物来自哪里。它们也更容易进行静态类型定义。

一个笨重的方面是,当视为数据提供者使用时,它们可能很快导致深度嵌套的金字塔形,从而创建一个虚假的组件层次结构。

<UserProvider>
   {user => (
     <UserPreferences user={user}>
       {userPreferences => (
         <Project user={user}>
           {project => (
             <IssueTracker project={project}>
               {issues => (
                 <Notification user={user}>
                   {notifications => (
                     <TimeTracker user={user}>
                       {timeData => (
                         <TeamMembers project={project}>
                           {teamMembers => (
                             <RenderThangs renderItem={item => (
                                 // do stuff
                                 // what was i doing again?
                             )}/>
                           )}
                         </TeamMembers>
                       )}
                     </TimeTracker>
                   )}
                 </Notification>
               )}
             </IssueTracker>
           )}
         </Project>
       )}
     </UserPreferences>
   )}
 </UserProvider>

在这个时候,通常会将管理状态的组件和渲染 UI 的组件分开考虑。

“容器” 和 “展示” 组件模式随着 Hooks 的出现而不再流行。但是在这里提到这个模式仍然很值得,以了解它们如何在服务器组件中有所复兴。

无论如何,渲染属性仍然是一种有效的创建可组合组件 API 的模式。

Hooks

Hooks 成为了在 React 16.8 中重用逻辑和效果的官方方法。这巩固了函数组件作为编写组件的推荐方式。

Hooks 使得重用效果和在组件中组合逻辑变得更加简单。与类不同,类中封装和共享逻辑和效果更加棘手,散落在各种生命周期方法中。

可以简化和扁平化深度嵌套的结构。随着 TypeScript 的流行,它们也很容易进行类型定义。

 // flattened our contrived example above
 function Example() {
   const user = useUser();
   const userPreferences = useUserPreferences(user);
   const project = useProject(user);
   const issues = useIssueTracker(project);
   const notifications = useNotification(user);
   const timeData = useTimeTracker(user);
   const teamMembers = useTeamMembers(project);
   return (
     <div>
       {/* render stuff */}
     </div>
   );
 }

理解权衡

它们带来了许多好处,解决了类组件中的一些问题。但是它们不是没有一些权衡,现在我们来深入了解一下这些权衡。

类和函数之间的划分

从组件的使用者角度来看,在这种转换中没有改变任何东西;我们继续以相同的方式呈现 JSX。但是,现在类和函数之间的范式出现了划分,尤其是对于同时学习两者的人来说。

类伴随着面向对象编程和有状态类的关联。而函数则与函数式编程和纯函数等概念有关。每个模型都有有用的类比,但只能部分地捕捉到完整的图景。

类组件从可变的 this 中读取状态和 props ,并考虑如何响应生命周期事件。函数组件利用闭包思想,以声明同步和副作用为重点。

像 “组件是函数,其参数是‘props’” 和 “函数应该是纯的” 这样的常见类比,无法与基于类的心智模型匹配。

另一方面,在函数式编程模型中保持函数 “纯洁” 并不能完全考虑到本地状态和效果,这些是 React 组件的关键要素。在其中,对象并不直观,其返回值形成了关于状态、副作用和 JSX 的声明式描述。

在 React 中,组件的概念、其使用 JavaScript 实现以及我们试图使用现有术语来解释它所带来的困难都会导致学习 React 的人构建一个准确的心智模型变得困难。

我们对理解的空缺会导致出现错误的代码。在这种转换中,一些常见的罪魁祸首是当设置状态或获取数据时产生无限循环。或读取过时的 props 和状态。考虑响应事件和生命周期时往往引入了不必要的状态和效果,而您可能并不需要它们。

开发者体验

类在 componentDid、componentWill、shouldComponent? 和将方法绑定到实例方面具有不同的术语。

函数和 Hooks 通过去除外部类壳来简化了这个问题,让我们可以专注于 render 函数。在每次渲染时都会重新创建所有内容,因此我们重新发现需要能够在渲染周期之间保存东西。

对于熟悉类的人来说,这揭示了 React 中从一开始就存在的新视角。引入了像 useCallback 和 useMemo 这样的 API,以便我们定义应在重新渲染之间保留什么。

需要明确地管理依赖项数组,并且始终考虑对象标识符,加上 hooks API 的语法噪音,对某些人来说感觉更糟糕的是开发者体验。对其他人而言,hooks 大大简化了他们对 React 的心理模型和代码。

实验性的 React forget 旨在通过预编译 React 组件来改进开发者体验,消除手动记忆和管理依赖项数组的需要。突显了明确留下事物或试图在幕后处理事物之间的权衡。

将状态和逻辑耦合到 React 上

许多使用状态管理库(如 Redux 或 MobX)的 React 应用程序将状态和视图分开。这符合 React 最初作为 MVC 中 “视图” 的标语。

随着时间的推移,从全局单块存储向更多的共位迁移,特别是在渲染属性中,“一切都是组件” 的想法得到了巩固。这一点在转向 Hooks 时也得以证实。

“应用中心” 和 “组件中心” 模型都有其权衡。将管理状态与 React 分离使您在应该重新渲染时具有更多控制权,允许独立开发存储和组件,允许您单独运行和测试所有逻辑而不涉及 UI。

另一方面,可以由多个组件使用的 hooks 的共存和可组合性可以改善本地推理、可移植性等其他优势,我们将在接下来详细介绍。

React 演变背后的原则

那么,我们可以从这些模式的演变中学到什么呢?有哪些启发性的方法可以指导我们做出有价值的权衡呢?

用户体验优于 API

框架和库必须考虑开发者体验和最终用户体验。以用户体验为代价换取开发者体验是一种错误的二分法,但有时一个会优先考虑另一个。

例如,运行时 CSS in JS 库(如 styled-components)在具有大量动态样式时使用非常不错,但可能会以最终用户体验为代价。需要平衡的是一个光谱。与其他更快的框架相比,React 作为库落在了这个光谱上。

我们可以将 React 18 中的并发功能和 RSC 视为追求更好的最终用户体验的创新。

追求这些意味着更新我们用来实现组件的 API 和模式。函数的 “快照” 属性(闭包)使得编写在并发模式下正确工作的代码变得更容易,在服务器上使用异步函数是表示服务器组件的好方法。

API 胜过实现

我们讨论的 API 和模式是从实现组件的内部角度来看的。

虽然实现细节已经从 createClass、ES6 类、有状态函数等不同的方式进行了演变,但更高级别的 API 概念 - 可以具有状态和效果的 “组件” - 在这个演变中保持稳定:

 return (
   <ImplementedWithMixins>
     <ComponentUsingHOCs>
       <ThisUsesHooks>
         <ServerComponentWoah />
       </ThisUsesHooks>
     </ComponentUsingHOCs>
   </ImplementedWithMixins>
 )

专注于正确的原语

换句话说,建立在坚实的基础上。在 React 中,这是组件模型,它使我们可以以声明性方式思考并进行本地推理。

这使它们具有可移植性,我们可以更轻松地删除、移动、复制和粘贴代码,而不会意外地拆除任何隐藏的连接来保持事物的联系。

与该模型一致的体系结构和模式提供了更好的可组合性,通常需要将事物局部化,其中组件捕获关注点的共同位置,并接受随之而来的权衡。

反过来的抽象模型会使数据流变得模糊,使追踪和调试变得难以跟踪,从而增加隐式耦合。

一个例子是从类转向 hooks,其中分散在多个生命周期事件中的逻辑现在打包在一个可组合的函数中,直接放置在组件中。

一个思考 React 的好方法是将其视为提供一组低级别的原语供我们在其基础上构建的库。它具有机动性,可以按您想要的方式架构事物,这既是一种福音也是一种诅咒。

这与高级应用程序级框架(如 Remix 和 Next)的流行有关,这些框架提供了更强的观点和抽象层。

React 的扩展心智模型

随着 React 扩展到服务端,它为开发人员提供了构建全栈应用程序的原语,支持前端编写后端代码,开启了一系列新的模式和权衡。

与以前的转变相比,这次转变更多地是对我们已有心智模型的扩展,而不是需要我们放弃之前模型。

想要深入了解这种演变,可以查看我所撰写的 “重新思考 React 最佳实践”,其中我谈到了围绕数据加载和数据变异的新模式浪潮,以及我们如何思考客户端缓存。

那么这与 PHP 有什么不同呢?

在像 PHP 这样完全由服务器驱动的状态模型中,客户端的角色更多地是 HTML 的接收方。计算集中在服务器上,模板被渲染,路由变化时刷新页面并清除所有客户端状态。

在混合模型中,客户端和服务器组件都对整个前端页面渲染做出贡献。具体规模可以根据您提供的体验类型来决定。

对于很多网络体验来说,在服务器上进行更多操作是有意义的,它可以让我们避免在网络中传输庞大的包。但是,如果我们需要快速交互而且延迟要比完整的服务器往返低得多,那么采用客户端驱动的方法更好。

理解全栈 React

混合客户端和服务器需要我们知道模块的边界。这是必要的,以便推理出代码在何处、何时和如何运行。

为此,我们开始在 React 中看到一种新的模式,即指令(或 pragma,类似于 "use strict" 和 "use asm",或 React Native 中的 "worklet"),可以更改其后面代码的含义。

1、理解 "use client"

"use client" 是放置在文件顶部导入语句之上的标识符,用于指示以下代码是 “客户端代码”,从而将其与仅在服务器端运行的代码区分开来。

其中导入到该文件的其他模块(以及它们的依赖项)被认为是发送到客户端的客户端包的一部分。

“客户端” 和 “服务端” 等术语只是粗略的近似,因为它们并不确定代码运行的环境。

带有 "use client" 的组件也可以在服务器端运行。例如,作为生成初始 HTML 或静态站点生成过程的一部分。换句话说,这些就是我们今天所熟知和喜爱的 React 组件。

2、"use server" 指令

动作函数是客户端调用位于服务器上的函数的一种方式 - 远程过程调用模式。

在服务器组件中,可以将 "use server" 放置在动作函数顶部,以告诉编译器它应该保留在服务器端。

 // inside a server component
 // allows the client to reference and call this function
 // without sending it down to the client
 // server (RSC) -> client (RPC) -> server (Action)
 async function update(formData: FormData) {
   'use server'
   await db.post.update({
     content: formData.get('content'),
   })
 }

在 Next.js 中,当 "use server" 放置在文件的顶部时,会告诉打包工具所有导出都是服务器动作函数。这确保该函数不会包含在客户端 bundle 中。

当我们的后端和前端共享同一模块视图时,可能会意外地发送一堆你不想要的客户端代码,或者更糟糕的是,在客户端 bundle 中意外导入敏感数据。

为了确保不会发生这种情况,还有 "server-only" 包作为标记边界的一种方式,以确保其后面的代码仅在服务器组件中使用。

这些实验性指令和模式也正在其他超出 React 范畴的框架中进行探索,并且用类似于 server$ 的语法来标记此区别。

全栈组合

在这个过渡中,组件的抽象被提升到更高的层次,包括服务器和客户端元素。这使得可以重复使用和组合整个全栈垂直功能切片的可能性增强了。

 // we can imagine sharable fullstack components
 // that encapsulate both server and client details
 <Suspense fallback={<LoadingSkelly />}>
   <AIPoweredRecommendationThing
     apiKey={proccess.env.AI_KEY}
     promptContext={getPromptContext(user)}
   />
 </Suspense>

这种能力的代价来自于构建在 React 之上的元框架中发现的高级打包工具、编译器和路由器的底层复杂性。作为前端开发人员,我们需要扩展自己的思维模型,以理解在与前端相同的模块图中编写后端代码的影响。

结论

我们已经涵盖了很多内容,从 mixins 到服务器组件,探索了 React 的演变和每种范例的权衡。

理解这些变化及其基本原理是构建清晰的 React 思维模型的好方法。拥有准确的思维模型使我们能够高效地构建,并快速定位错误和性能瓶颈。

在大型项目中,许多复杂性来自于未完成迁移和想法的拼凑。这通常发生在没有一致的愿景或一致的结构来对齐的情况下。共享的理解有助于我们一起连贯地沟通和构建,创建可重用的抽象,可以随着时间的推移进行适应和演变。

正如我们所看到的,建立准确的思维模型的一个棘手之处是现有术语和语言与概念的实际实现之间的阻抗不匹配。

建立思维模型的好方法是欣赏每种方法的权衡和优点,这是为了能够选择适合特定任务的正确方法而必需的,而不会陷入对特定方法或模式的教条主义坚持。