React API 和代码重用的演变
追踪 React API 的演变及其背后的心智模型。从 mixins 到 hooks,再到 RSCs,了解整个过程中的权衡。
React改变了我们对构建用户界面(UI)的方式。随着React的不断发展,它正在改变我们对构建应用程序的思考方式。
我们对某个东西的工作原理或应该如何工作的理解与其实际工作方式之间的差距是错误和性能问题的温床。对技术有一个清晰而准确的心智模型对于掌握它至关重要。
软件开发也是一项团队运动,即使是与我们未来的自己(或人工智能)一起工作。对事物的工作原理以及如何构建和共享代码的共同理解有助于在代码库中创建一种连贯的愿景和一致的结构,这样我们就可以避免重复造轮子。
在本文中,我们将探索React的演变以及出现的各种代码重用模式。我们将深入探讨塑造这些模式的基本心智模型以及相关的权衡。
到最后,我们将对React的过去、现在和未来有一个更清晰的认识。您将能够深入研究遗留代码库,并评估其他技术采用不同方法和进行不同权衡的情况。
简要介绍React API的历史
从面向对象设计模式在JavaScript生态系统中更为普遍的时候开始。可以看到这种影响在早期的React API中。
混合(Mixins)
React.createClass API是创建组件的最初方式。在JavaScript支持原生的类语法之前,React有自己的类表示形式。混合是一种通用的面向对象编程模式,用于代码复用,下面是一个简化的示例:
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>
)
}
})
这对于足够小的示例效果很好。但是当推广到更大规模时,混合模式会有一些缺点:
-
名称冲突:混合具有共享的命名空间。当多个混合使用相同的方法或状态名称时会发生冲突。
-
隐式依赖关系:需要花费一些工作来确定哪个混合提供了哪些功能或状态。它们引用共享的属性键以相互交互,创建了一种隐式的耦合。
-
局部推理能力降低:通常使组件更难理解和调试。例如,几个混合可能会对getInitialState的结果产生影响,使得事情难以追踪。
在经历了这些问题后,React团队发表了“混合被认为是有害的”一文,不鼓励继续使用这种模式。
高阶组件
最终,我们在JavaScript中获得了原生的类语法。React团队在v15.5中废弃了createClass API,推荐使用原生类。
在这个过渡期间,我们仍然是以类和生命周期为思维方式,所以并没有发生重大的心智模型转变。现在我们可以扩展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,HOCs)通过一个早期的代码片段进入了舞台。它的命名来源于函数式编程中的高阶函数概念。
它们成为取代混合的一种流行方式,并在Redux等库的API中出现,例如Redux的connect函数,用于将组件连接到Redux存储库,以及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} />
}
}
}
HOCs对于在多个组件之间共享通用行为非常有用。它们允许被包装的组件保持解耦和通用性,以便可以重用。
抽象是强大的,因为一旦我们掌握了它们,我们倾向于运用到任何地方。事实证明,HOCs遇到了与混合类似的问题:
-
名称冲突:因为HOCs需要将...this.props传递和展开到被包装的组件中,嵌套的HOCs可能会相互覆盖,导致名称冲突。
-
难以静态类型化:这大致是在静态类型检查器真正流行的时候。当多个嵌套的HOCs向被包装的组件注入新的props时,正确地进行类型标注是很痛苦的。
-
数据流不明确:对于混合,问题是“这个状态是从哪里来的?”对于HOCs,问题是“这些props是从哪里来的?”因为它们在模块级别静态地进行组合,追踪数据流可能很困难。
除了这些问题,过度使用HOCs会导致深度嵌套和复杂的组件层次结构,以及难以调试的性能问题。
Render props
"Render props"模式开始作为HOCs的替代方案出现,由React-Motion和downshift等开源API以及构建React Router的开发人员推广。
<Motion style={{ x: 10 }}>
{interpolatingStyle => <div style={interpolatingStyle} />}
</Motion>
这个想法是将一个函数作为prop传递给组件。然后该组件会在内部调用该函数,并传递任何数据和方法,将控制权反转给函数,以便继续渲染他们想要的内容。
与HOCs相比,组合是在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的出现,“容器”和“展示”组件模式逐渐不受青睐。但是在这里提到这个模式是值得的,以便了解它们如何在服务器组件中得到一定程度的重生。
无论如何,render props仍然是创建可组合组件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渲染方式没有任何变化。但是,在类和函数这两种范式之间出现了一种分裂,特别是对那些同时学习两者的人来说。
类具有与有状态类的面向对象编程(OOP)相关的关联。而函数则与函数式编程以及纯函数等概念相关联。每种模型都有有用的类比,但只能部分地完整地描述整个情况。
类组件从可变的this中读取状态和属性,并考虑如何响应生命周期事件。函数组件利用闭包,并以声明性的同步和效果为思维方式。
常见的类比,比如组件是带有“props”参数的函数,函数应该是纯的,与基于类的心智模型并不相匹配。
另一方面,在函数式编程模型中保持函数的“纯粹性”并不能充分考虑到React组件中的本地状态和效果,而这些是React组件的关键要素。在这种模型中,想象将钩子(Hooks)作为组件的一部分并返回它们,形成了对状态、效果和JSX的声明式描述。
React中组件的概念、使用JavaScript实现以及我们尝试使用现有术语来解释它,都导致了学习React时建立准确心智模型的困难。
我们对React的理解有所欠缺会导致出现错误的代码。在这个过渡中,一些常见的问题是在设置状态或获取数据时出现无限循环,或者读取过时的属性和状态。在以命令式方式思考和响应事件和生命周期时,往往会引入不必要的状态和效果,而实际上可能并不需要它们。
开发者体验
在类组件中,存在一套不同的术语,如componentDid、componentWill、shouldComponent等,以及将方法绑定到实例上的操作。
函数组件和Hooks通过移除外部的类壳,简化了这个过程,使我们能够专注于render函数。在每次渲染时,所有内容都会重新创建,因此我们发现需要在渲染周期之间保留某些内容。
对于熟悉类组件的人来说,这揭示了React一开始就存在的新视角。为了能够定义应该在重新渲染之间保留的内容,引入了诸如useCallback和useMemo之类的API。
在处理依赖数组、考虑对象标识符的同时,还需要处理Hooks API的语法噪声,这对一些人来说可能不是很好的开发者体验。对于其他人来说,Hooks极大地简化了他们对React的理解和代码。
实验性的React forget旨在通过预编译React组件来改善开发者体验,消除了手动进行记忆和管理依赖数组的需求。这突显了明确留下事物或试图在内部处理事物之间的权衡。
将状态和逻辑耦合到React
许多使用Redux或MobX等状态管理库的React应用将状态和视图分开。这与React作为MVC中的“视图”最初的标语是一致的。
随着时间的推移,从全局的庞大存储向更多的位置共存转变,尤其是在使用渲染属性(render props)的情况下,认为“一切都是组件”。这一观点在引入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中,这就是组件模型,它使我们能够以声明性的方式思考并进行本地推理。
这使得组件具有可移植性,我们可以更轻松地删除、移动、复制和粘贴代码,而不会意外地破坏任何隐藏的连接。
与此模型相契合的架构和模式更易于组合,通常需要将事物局部化,其中组件捕获了关注点的共同位置,并接受这种方式带来的权衡。与此模型背道而驰的抽象会隐藏数据流,使得追踪和调试变得难以跟踪,增加了隐式的耦合。
一个例子是从类到钩子的过渡,其中在多个生命周期事件中分散的逻辑现在被打包到一个可组合的函数中,可以直接放置在组件中。
把React视为一个提供一组低级原语的库是一个很好的思路。它灵活地允许您按照自己的方式构建架构,这既是一种福音,也是一种诅咒。
这与更高级的应用级框架(如Remix和Next)的流行程度密切相关,这些框架在更强的观点和抽象层之上进行了扩展。
React不断扩展的心智模型
随着React超越客户端,为开发人员提供了构建全栈应用程序的原语。在前端编写后端代码为我们打开了一系列新的模式和权衡。
与以前的转变相比,这种转变更多地是我们现有心智模型的扩展,而不是需要我们放弃先前模式的范式转变。
如果您想深入了解这种演变,可以查看《重新思考React最佳实践》,其中我讨论了围绕数据加载和数据变异的新一波模式,以及我们如何思考客户端缓存。
- 与PHP有何不同?
在像PHP这样完全由服务器驱动的状态模型中,客户端的角色更多地是HTML的接收者。计算集中在服务器上,模板被渲染,而在路由更改之间的任何客户端状态都会在完整刷新页面时被清除。
在混合模型中,客户端和服务器组件都为整体计算架构做出贡献。具体取决于您提供的体验类型,规模可以在客户端和服务器之间来回切换。
对于Web上的许多体验来说,在服务器上执行更多操作是有意义的,它可以让我们卸载计算密集型任务,并避免将庞大的捆绑文件发送到网络。但是,如果我们需要快速交互且延迟较低,比起完全依赖服务器往返,使用客户端驱动的方法更好。
React起初是从此模型的仅客户端部分发展而来的,但我们可以想象React首先从服务器端开始,然后再添加客户端部分。
理解全栈React
将客户端和服务器结合起来需要我们知道模块图中的边界。这对于在何处、何时以及如何运行代码进行本地推理是必要的。
为此,我们开始在React中看到一种新的模式,即指令(或pragma,类似于"use strict"、"use asm"或React Native中的"worklet"),它们可以改变其后代码的含义。
- 理解 use strict
记了与仅限服务器的代码之间的边界。
被导入到此文件中的其他模块(以及它们的依赖项)被视为客户端捆绑包发送到网络。
像客户端和服务器这样的术语只是粗略的近似,因为它们并不确定代码运行的环境。
带有"use client"的组件也可以在服务器上运行。例如,作为生成初始HTML的一部分,或作为静态网站生成过程的一部分。换句话说,这些就是我们今天所熟悉和喜爱的React组件。
- use server命令
Action函数是客户端调用在服务器上运行的函数的一种方式,这是一种远程过程调用的模式。
在服务器组件的action函数顶部放置"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"指令时,它告诉打包工具所有的导出都是服务器端的action函数。这确保该函数不会包含在客户端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之上的元框架中的高级打包工具、编译器和路由器。作为前端开发者,我们需要扩展我们的思维模型,以理解在与前端代码相同的模块图中编写后端代码的影响。
结论
我们已经涵盖了很多内容,从混入到服务器组件,探索了React的演变和每种范式的权衡。
了解这些变化及其基本原则是构建清晰的React思维模型的一种好方法。拥有准确的思维模型使我们能够高效构建,并快速定位错误和性能瓶颈。
在大型项目中,复杂性往往来自于半成品迁移和从未完全完成的想法的拼凑。这往往发生在缺乏一致的愿景或一致的结构来对齐的情况下。共享的理解帮助我们进行沟通和协同构建,创建可重用的抽象,可以随着时间的推移进行调整和演化。
正如我们所看到的,建立一个准确的思维模型的一个棘手的方面是预先存在的术语和语言与我们用于表达概念以及这些概念在实践中的实现之间的不匹配。
建立一个思维模型的好方法是欣赏每种方法的权衡和优势,这对于能够为特定任务选择合适的方法而不陷入教条式的坚持特定的方法或模式非常必要。
一些参考资料和资源:
转载自:https://juejin.cn/post/7240117931887296569