本文将探究 React API 的演变及其背后的心智模型。从 mixins 到 hooks,再到 RSCs,了解整个过程中的权衡。我们将对 React 的过去、现在和未来有一个更清晰的了解,便于深入研究遗留代码库并评估其他技术如何采用不同的方法并做出不同的权衡。
React API 简史
我们从面向对象的设计模式在 JS 生态系统中流行的时候开始,可以在早期的 React API 中看到这种影响。
Mixins
React.createClass
API 是创建组件的原始方式。在 Javascript 支持原生类语法之前,React 就有自己的类表示。 Mixins 是一种用于代码重用的通用 OOP 模式,下面是一个简化的例子:
function ShoppingCart() { this.items = []; } var orderMixin = { calculateTotal() { // 从 this.items 计算 } // .. 其他方法 } Object.assign(ShoppingCart.prototype, orderMixin) var cart = new ShoppingCart() cart.calculateTotal()
Javascript 不支持多重继承,因此 mixin 是重用共享行为和扩充类的一种方式。那该如何在使用 createClass 创建的组件之间共享逻辑呢?Mixins 是一种常用的模式,它可以访问组件的生命周期方法,允许我们组合逻辑、状态等:
var SubscriptionMixin = { getInitialState: function() { return { comments: DataSource.getComments() }; }, // 当一个组件使用多个 mixin 时,React 会尝试合并多个mixin的生命周期方法,因此每个都会被调用 componentDidMount: function() { console.log('do something on mount') }, componentWillUnmount: function() { console.log('do something on unmount') }, } // 将对象传递给 createClass var CommentList = React.createClass({ // 在 mixins 属性下定义它们 mixins: [SubscriptionMixin, AnotherMixin, SomeOtherMixin], render: function() { var { comments, ...otherStuff } = this.state return ( <div> {comments.map(function(comment) { return <Comment key={comment.id} comment={comment} /> })} </div> ) } })
对于较小的应用,这种方式可以正常运行。但是,当 Mixin 应用到大型项目时,它们也有一些缺点:
- 名称冲突:Mixin 具有共享的命名空间,当多个 Mixin 使用同一个方法或状态的名称时,会发生冲突。
- 隐式依赖关系:确定哪个 Mixin 提供了哪些功能或状态比较麻烦。它们使用共享的属性键相互交互,从而创建隐式耦合。
- 难以理解和调试:Mixin 通常使组件更难以理解和调试。例如,上面多个 Mixin 都可以对
getInitialState
结果有影响,使得跟踪问题变得更加困难。
在感受到这些问题的痛苦之后,React 团队发布了“Mixins Considered Harmful”,不鼓励继续使用这种模式。
高阶组件
当 Javascript 中支持了原生类语法后,React 团队就在 v15.5 中弃用了 createClass
API,支持原生类。
在这个转变过程中,我们仍然按照类和生命周期的思路来思考,因此没有进行重大的心智模型转变。现在可以扩展包含生命周期方法的 Reacts Component 类:
class MyComponent extends React.Component { constructor(props) { // 在组件挂载到 DOM 之前运行 // super 指的是父 Component 的构造函数 super(props) } componentWillMount() {} componentDidMount(){} componentWillUnmount() {} componentWillUpdate() {} shouldComponentUpdate() {} componentWillReceiveProps() {} getSnapshotBeforeUpdate() {} componentDidUpdate() {} render() {} }
考虑到 mixin 的缺陷,我们该如何以这种编写 React 组件的新方式来共享逻辑和副作用呢?
这时候,高阶组件 (HOC) 就出现了,它的名字来源于高阶函数的函数式编程概念。它成为了替代 Mixin 的一种流行方式,并出现在像 Redux 这样的库的 API 中,例如它的 connect
函数,用于将组件连接到 Redux 存储。除此之外,还有 React Router 的 withRouter
。
// 一个创建增强组件的函数,有一些额外的状态、行为或 props const EnhancedComponent = myHoc(MyComponent); // HOC 的简化示例 function myHoc(Component) { return class extends React.Component { componentDidMount() { console.log('do stuff') } render() { // 使用一些注入的 props 渲染原始组件 return <Component {...this.props} extraProps={42} /> } } }
高阶组件对于在多个组件之间共享通用行为非常有用。它们使包装的组件保持解耦和通用性,以便可以重用。然而,HOC 遇到了与 mixin 类似的问题:
- 名称冲突:因为 HOC 需要转发和传播 .
..this.props
到包装的组件中,所以嵌套的 HOC 相互覆盖可能会发生冲突。 - 难以静态类型检查:当多个嵌套的 HOC 将新的 props 注入到包装的组件中时,正确的 props 输入很难保证。
- 数据流模糊:对于 mixins,问题是“这个状态从哪里来?”;对于 HOC,问题是“这些 props 从哪里来?”。 因为它们是在模块级别静态组合的,所以很难跟踪数据流。
除了这些陷阱之外,过度使用 HOC 还导致了深度嵌套和复杂的组件层次结构以及难以调试的性能问题。
Render props
render prop 模式作为 HOC 的替代品出现,这种模式由开源 API 如 React-Motion 和 downshift 以及构建 React Router 的开发人员推广普及。
<Motion style={{ x: 10 }}> {interpolatingStyle => <div style={interpolatingStyle} />} </Motion>
主要思想就是将一个函数作为 props
传递给组件。然后组件会在内部调用该函数,并传递数据和方法,将控制反转回函数以继续渲染它们想要的内容。
与 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 => ( // ... )}/> )} </TeamMembers> )} </TimeTracker> )} </Notification> )} </IssueTracker> )} </Project> )} </UserPreferences> )} </UserProvider>
这时,通常会将管理状态的组件与渲染 UI 的组件分开来处理。随着 Hooks 的出现,“容器”和“展示性”组件模式已经不再流行。但值得一提的是,这种模式在服务逇组件中有所复兴。
目前,render props 仍然是创建可组合组件 API 的有效模式。
Hooks
Hooks 在 React 16.8 版本中成为了官方的重用逻辑的方式,巩固了将函数组件作为编写组件的推荐方式。
Hooks 让在组件中重用和组合逻辑变得更加简单明了。相比于类组件,在其中封装并共享逻辑会更加棘手,因为它们可能分散在各种生命周期方法中的不同部分。
深度嵌套的结构可以被简化和扁平化。搭配 TypeScript,Hook 也很容易进行类型化。
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> {/* 渲染内容 */} </div> ); }
权衡利弊
使用 Hooks 带来了很多好处,它们解决了类中的一些问题,但也需要付出一定的代价,下面来深入了解一下。
类 vs 函数
从组件消费者的角度来看,类组件到函数组件的转变并没有改变渲染 JSX 的方式。不过两种方式的思想是不同的:
- 类与有状态类的面向对象编程有着紧密联系。
- 函数则与函数式编程以及纯函数等概念有关联。
React 中的组件概念,以及使用 JavaScript 实现它的方式,以及我们试图使用现有术语来解释它,都增加了学习 React 的开发人员建立准确思维模型的困难度。对理解的漏洞会导致代码出现 bug。在这个过渡阶段中,一些常见的问题包括设置状态或获取数据时的无限循环,以及读取过时的 props 和 state。指令式响应事件和生命周期常常引入了不必要的状态副作用,我们可能并不需要它们。
开发者体验
在使用类组件时,有一套不同的术语,如 componenDid、componentWill、shouldComponent和将方法绑定到实例中。函数和 Hooks 通过移除外部类简化了这一点,使我们能够专注于渲染函数。每次渲染都会重新创建所有内容,因此需要能够在渲染周期之间保留一些内容。useCallback 和 useMemo 这样的 API 被引入就方便定义哪些内容应该在重新渲染之间保留下来。
在 Hooks 中需要明确管理依赖数组,再加上 hooks API 的语法复杂,对一些人来说富有挑战性。对其他人来说,hooks 大大简化了他们对 React 的思维模型和代码的理解。
实验性 React forget 旨在通过预编译 React 组件来改善开发者体验,从而消除手动记忆和管理依赖项数组,强调将事情明确化或尝试在幕后处理事情之间的权衡。
将状态和逻辑耦合到 React 中
许多状态管理库,如 Redux 或 MobX 将 React 应用的状态和视图分开处理。这与 React 最初作为MVC 中的“视图”标语保持一致。随着时间的推移,从全局的单块式存储向更多的位置迁移,特别是使用 render props 的“一切皆为组件”的想法,这也随着转向 hooks 得到了巩固。
React 演进背后的原则
我们可以从这些模式的演变中学到什么呢? 哪些启发式可以指导我们做出有价值的权衡?
API 的用户体验
框架和库必须同时考虑开发者体验和最终用户体验。为开发者体验而牺牲用户体验是一种错误的做法,但有时候一个会优先于另一个。
例如,CSS in JS库 styled-components,在处理大量动态样式时使用起来非常棒,但它们可能以最终用户体验为代价,我们需要对此进行权衡。
我们可以将 React 18 和 RSC 中的并发特性视为追求更好的最终用户体验的创新。这些就意味着更新用来实现组件的 API 和模式。 函数的“snapshotting”属性(闭包)使得编写在并发模式下正常工作的代码变得更加容易,服务端的异步函数是表达服务端组件的好方法。
API 优于实现
上面讨论的 API 和模式都是从实现组件内部的角度出发的。虽然实现细节已经从 createClass
发展到了 ES6 类,再到有状态函数。但“组件”这个更高级别的API概念,它可以是有状态的并具有 effect,已经在整个演进过程中保持了稳定性:
return ( <ImplementedWithMixins> <ComponentUsingHOCs> <ThisUsesHooks> <ServerComponentWoah /> </ThisUsesHooks> </ComponentUsingHOCs> </ImplementedWithMixins> )
专注于正确的原语
在React中,组件模型让我们可以用声明式的方式来编写代码,并且可以方便地在本地进行处理。这使得代码更加易于移植,可以更轻松地删除、移动、复制和粘贴代码,而不会意外破坏其中的任何隐藏的连接。遵循这个模型的架构和模式可以提供更好的可组合性,通常需要保持局部化,让组件捕获相关的关注点,并接受由此带来的权衡。与这个模型不符的抽象化会使数据流变得模糊,并使跟踪和调试变得难以理解和处理,从而增加了隐含的耦合。一个例子就是从类到 hooks 的转换,将分布在多个生命周期事件中的逻辑打包成可组合的函数,可以直接放置在组件中的相应位置。
小结
考虑 React 的一个好方法是将其视为一个库,它提供了一组可在其上构建的低级原语。React非常灵活,可以按照自己的方式来设计架构,这既是一种福音,也可能带来一些问题。这也解释了为什么像 Remix 和 Next 这样的高级应用框架如此受欢迎,它们会在React基础之上添加更强烈的设计意图和抽象化。
React 的扩展心智模型
随着 React 将其范围扩展到客户端之外,它提供了允许开发人员构建全栈应用的原语。在前端编写后端代码开辟了一系列新的模式和权衡。与之前的转变相比,这些转变更多的是对现有心智模型的扩展,而不是需要忘记之前的范式转变。
在混合模型中,客户端和服务端组件都对整体计算架构有所贡献。在服务端做更多的事情有助于提高 web 体验,它允许卸载计算密集型任务并避免通过网络发送臃肿的包。 但是,如果我们需要比完整的服务端往返延迟少得多的快速交互,则客户端驱动的方法会更好。React 就是从该模型的仅客户端部分演变而来的,但可以想象 React 首先从服务器开始,然后再添加客户端部分。
了解全栈 React
混合客户端和服务端需要知道边界在模块依赖图中的位置。这样就能够更好地理解代码在何时、何地以及如何运行。
为此,我们开始看到一种新的React模式,即指令(或类似于“use strict”、“use asm”或React Native中的“worklet”的编译指示),它们可以改变其后代码的含义。
理解“use client”
将此代码放置在导入代码之前的文件顶部,可以表明以下的代码是“客户端代码”,标志着与仅在服务端上运行的代码进行区分。其中导入的其他模块(及其依赖项)被认为是客户端包,通过网络传输。
使用“use client”组件也可以在服务端运行。例如,作为生成初始 HTML 或作为静态网站生成过程的一部分。
“use server”指令
Action 函数是客户端调用在服务端存在的函数的方式。可以将“use server”放置在服务器组件的 Action 函数顶部,以告诉编译器应该在服务端保留它。
// 在服务器组件内部 // 允许客户端引用和调用这个函数 // 不发送给客户端 // 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”,它告诉打包工具所有导出都是服务端 Action 函数,这确保函数不会包含在客户端捆绑包中。
当后端和前端共享同一个模块依赖图时,有可能会意外地发送一堆不想要的客户端代码,或者更糟糕的是,意外将敏感数据导入到客户端捆绑包中。为了确保这种情况不会发生,还有“server-only”包作为标记边界的一种方式,以确保其后的代码仅在服务端组件上使用。这些实验性的指令和模式也正在其他框架中进行探索,超越了 React,并使用类似于server$
的语法来标记这种区别。
全栈组合
在这个转变中,组件的抽象被提升到一个更高的层次,包括服务端和客户端元素。这使得可以重用和组合整个全栈功能垂直切片的可能性。
// 可以想象可共享的全栈组件 // 封装了服务端和客户端的细节 <Suspense fallback={<LoadingSkelly />}> <AIPoweredRecommendationThing apiKey={proccess.env.AI_KEY} promptContext={getPromptContext(user)} /> </Suspense>
这种强大的能力是建立在 React 之上的元框架中使用的高级打包工具、编译器和路由器的基础上的,因此付出的代价来自于其底层的复杂性。同时,作为前端开发者,我们需要扩展自己的思维模型,以理解将后端代码与前端代码写在同一个模块依赖图中所带来的影响。
总结
本文探讨了很多内容,从mixin到服务端组件,探索了 React 的演变和每种范例的权衡。理解这些变化及其基础原则是构建一个清晰的 React 思维模型的好方法。准确的思维模型使我们能够高效地构建,并快速定位错误和性能瓶颈。