原文地址:Idiomatic Redux: The History and Implementation of React-Redux
本文将介绍 React-Redux API 的由来及其内部工作原理。
简介
React-Redux 的概念非常简单。它订阅 Redux store,检查组件所需的数据是否发生变化,然后重新渲染组件。
然而,要实现这一点,内部结构非常复杂,大多数人并不了解 React-Redux 内部所做的所有工作。我想深入探讨一下 React-Redux 的一些设计决策和实现细节,以及这些实现细节是如何随着时间推移而变化的。
注:本文最初写于 React-Redux v6 发布之前。此后已更新,涵盖了 v6 的最终版本以及 v7.0 和 v7.1 的开发和发布。
目录
将 Redux 与 UI 集成
了解 Redux Store 订阅
有人说"Redux 只是一个(简单的)事件发射器"。这种说法其实不无道理。早期的 MVC 框架,例如 Backbone,允许将任何字符串作为事件触发,并像"change:firstName"在模型中一样自动触发事件。而 Redux 则只有一种事件类型:"某个操作已分发"。
作为提醒,以下是 Redux store 的一个精简版(但有效)实现示例:
function createStore(reducer) {
var state;
var listeners = []
function getState() {
return state
}
function subscribe(listener) {
listeners.push(listener)
return function unsubscribe() {
var index = listeners.indexOf(listener)
listeners.splice(index, 1)
}
}
function dispatch(action) {
state = reducer(state, action)
listeners.forEach(listener => listener())
}
dispatch({})
return { dispatch, subscribe, getState }
}
让我们重点关注一下dispatch()实现方式。请注意,它并没有检查根状态是否真的发生了变化------它会在*每次分发操作后运行每个*订阅者的回调函数,而不管状态是否发生了任何有意义的变化**。
此外,store 状态不会传递给订阅者的回调函数------每个订阅者需要自行调用回调函数store.getState()来获取最新状态。(更多详情请参阅Redux 常见问题解答,了解为什么状态不传递给订阅者。)
标准 UI 更新周期
将 Redux 与任何UI 层一起使用都需要遵循相同的步骤:
- 创建 Redux store
- 订阅更新
- 在订阅回调函数中:
- 获取当前商店状态
- 提取此用户界面所需的数据。
- 使用数据更新用户界面
- 如有必要,使用初始状态渲染 UI
- 通过分发 Redux action 来响应 UI 输入
以下是一个使用纯 JavaScript 编写的"计数器"应用程序的示例,其中"状态"是一个单独的数字:
// 1) Create a store
const store = createStore(counter)
// 2) Subscribe to store updates
store.subscribe(render);
const valueEl = document.getElementById('value');
// 3. When the subscription callback runs:
function render() {
// 3.1) Get the current store state
const state = store.getState();
// 3.2) Extract the data you want
const newValue = state.toString();
// 3.3) Update the UI with the new value
valueEl.innerHTML = newValue;
}
// 4) Display the UI with the initial store state
render();
// 5) Dispatch actions based on UI inputs
document.getElementById("increment")
.addEventListener('click', () => {
store.dispatch({type : "INCREMENT"});
})
每个 Redux UI 集成层都只是这些步骤的更高级版本。
你可以手动在每个 React 组件中执行此操作,但这很快就会失控,尤其是在你开始尝试减少不必要的 UI 更新时。
显然,订阅 store、检查数据更新以及触发重新渲染的过程可以变得更加通用和可复用。这就是 React-Redux 和connectAPI 的用武之地。
connect简而言之
Redux 发布大约一年后,Dan Abramov 写了一篇名为《connect.js Explained》的 gist 。其中包含一个精简版的 connect.js 示例,connect用以阐释其概念上的工作原理。为了强调这一点,值得将示例内容粘贴在此:
// connect() is a function that injects Redux-related props into your component.
// You can inject data and callbacks that change that data by dispatching actions.
function connect(mapStateToProps, mapDispatchToProps) {
// It lets us inject component as the last step so people can use it as a decorator.
// Generally you don't need to worry about it.
return function (WrappedComponent) {
// It returns a component
return class extends React.Component {
render() {
return (
// that renders your component
<WrappedComponent
{/* with its props */}
{...this.props}
{/* and additional props calculated from Redux store */}
{...mapStateToProps(store.getState(), this.props)}
{...mapDispatchToProps(store.dispatch, this.props)}
/>
)
}
componentDidMount() {
// it remembers to subscribe to the store so it doesn't miss updates
this.unsubscribe = store.subscribe(this.handleChange.bind(this))
}
componentWillUnmount() {
// and unsubscribe later
this.unsubscribe()
}
handleChange() {
// and whenever the store state changes, it re-renders.
this.forceUpdate()
}
}
}
}
// This is not the real implementation but a mental model.
// It skips the question of where we get the "store" from (answer: `<Provider>` puts it in React context)
// and it skips any performance optimizations (real connect() makes sure we don't re-render in vain).
// The purpose of connect() is that you don't have to think about
// subscribing to the store or perf optimizations yourself, and
// instead you can specify how to get props based on Redux store state
我们可以看到API的关键方面:
connect是一个返回函数的函数,该函数又返回一个包装组件。
mapState被包装组件的 props 是包装组件的 props、来自 的值和来自 的值的组合mapDispatch。
- 每个包装组件都是 Redux store 的一个独立订阅者。
- 包装组件会抽象掉你正在使用哪个存储、它**如何与存储交互以及如何优化性能等细节,以便你自己的组件只在需要时才重新渲染。
- 您只需指定如何根据 store 状态提取组件所需的数据,以及它可以调用哪些函数来向 store 分发 action 即可。
但是,这个微缩模型忽略了很多细节。特别是,正如评论所指出的那样:
- 这家店是从哪里来的?
- 如何
connect检查组件是否需要更新?
- 它是如何实现这些优化的?
除此之外,我们当初又是怎么得到这样一款 API 的呢?
React-Redux API 的发展历程
早期迭代
Redux 的最初几个版本将 React 绑定作为主包的一部分包含在内。我不会详细介绍这些版本,但您可以在这些早期版本的发行说明和 README 文件中查看更改:
有趣的是:令人惊讶的是,所有这些迭代竟然只用了一周时间就完成了!
又过了三周才达到v1.0.0-rc 版本,而真正有意义的历史也由此开始。
原始 API 设计约束
React-Redux 于 2015 年 7 月被拆分为一个单独的仓库,就在 Redux v1.0.0-rc 发布之前。
Dan 提交了React-Redux 问题 #1:替代 API 提案,讨论最终 API 的设计方向。在该问题中,他列出了一些设计约束,这些约束将指导最终 API 的运行方式:
常见痛点:\
- 区分智能组件和非智能组件的方式不够直观
<Connector>-@connect
必须手动将操作创建器与bindActionCreators辅助函数绑定,有些人不喜欢
这样 做 - 对于小型示例来说嵌套过多(<Provider>两者<Connector>都需要函数子组件)
让我们尽情发挥想象吧!请提出你认为可行的 API 方案。
它们应该满足以下标准:\
- 根组件必须持有
store实例。(类似于<Provider>)\
- 无论树的深度如何,都应该可以连接到状态。- 应该可以使用函数
选择感兴趣的状态 。- 需要鼓励智能组件/哑组件的分离 。- 应该有一种显而易见的方法来分离智能组件/哑组件 。- 应该很容易将函数转换为 action 创建器 。- 智能组件应该能够响应状态的更新。- 智能组件的函数需要能够考虑它们的 props。- 智能组件应该能够在哑组件分发 action 之前/之后执行某些操作 。- 我们应该尽可能地使用 @State。select
componentDidUpdate
select
shouldComponentUpdate
这些标准构成了我们今天所拥有的 React-Redux API 的基础,并有助于解释它为什么以这种方式工作。
那次讨论得出的最大结论是,应该使用connect函数而不是装饰器,以及如何处理绑定操作创建器。
还有一个有趣的题外话:绑定动作创建器的"对象简写"是 Dan 最早提出的 API 建议之一:
或许我们还可以更进一步,如果传入一个对象,就自动绑定。
API 最终定稿
接下来的几个版本继续迭代改进connectAPI。主要变化包括:
- v0.5.0:引入了
mapState、mapDispatch和mergeProps参数
- v0.6.0:使用不可变性和引用检查来确定是否需要重新渲染。
- v0.8.0
store :新增了以属性形式传递的功能
- v0.9.0:添加了
ownProps参数mapState/mapDispatch
- v1.0.0:
<Provider>单个子元素必须是一个返回元素的函数
- v2.0.0:不再支持"神奇"的热重载减量器
- v2.1.0:添加了一个
options参数
- v3.0.0:将初始
mapState/mapDispatch调用移至首次渲染中,以避免状态过时问题。
继续观察,丹曾警告说不要连接叶子组件:
我们在文档中确实警告说,我们鼓励您遵循 React 流程,避免使用 connect() 连接叶子组件。
考虑到我们现在建议将组件连接到树中任何您认为有用的位置,这一点尤其令人感到好笑。
这就引出了我所说的 React-Redux API 的"现代时期",从版本 4 开始。
v4.x
**React-Redux v4.0.0**于 2015 年 10 月发布,主要有四项变更:
- 最低要求 React 版本为 0.14,并且需要依赖其他组件。
- 不再需要 React Native 特有的入口点
<Provider>不再接受函数作为子元素,而是接受标准的 React 元素。
- 现在对子组件的引用需要启用该
withRef选项,而不是始终默认启用。
此外,v4.3.0还添加了"工厂函数"语法,mapState/mapDispatch允许对每个组件实例进行选择器记忆化。
我认为 4.x 版本是 React-Redux API 的第一个"完整"版本。因此,值得深入研究它的一些实现细节,以及定义该 API 各版本整体行为的一些共同点。
API行为
每个包装组件实例都是一个独立的商店订阅者
这个很简单。如果我有一个连通列表组件,它渲染了 10 个连通列表项子项,那就需要 11 次单独的调用store.subscribe()。这也意味着,如果树中有 N 个连通组件,那么每次分发操作都会运行 N 个订阅者回调!
每个订阅者回调函数都会根据 store 的状态和 props 自行检查该特定组件是否真的需要更新。
UI 更新需要数据存储不可变性
我们已经确定,无论状态是否实际改变,Redux store 都会在每次分发操作后运行**所有订阅者回调。
为了实现高效的 UI 更新,React-Redux 假定您已不可变地更新了 store 状态,因此它可以使用引用比较来确定状态是否已更改。
这一过程分三个阶段进行:
-
当connect包装组件的订阅者回调函数运行时,它首先调用 getState() 方法store.getState(),并检查 store 的状态是否发生变化。如果通过引用没有prevStoreState !== storeState改变store 的状态,那么回调函数就会立即停止,不再执行任何更新操作,因为它假定 store 状态的其他部分没有发生变化。
-
如果根状态发生了变化,包装组件就会运行你的mapState函数,并对当前结果和上次结果进行"浅相等"比较。如果任何字段的引用发生了变化,那么你的组件可能需要更新。
-
mapState假设or 的结果发生了变化mapDispatch,mergeProps()则运行该函数来合并来自stateProps``from mapState、dispatchProps``from``mapDispatch和ownProps包装组件本身的 props。最后检查合并后的 props 结果自上次以来是否发生变化,如果没有变化,则不会重新渲染被包装的组件。
注意:根状态比较依赖于一个特定的优化combineReducers,该优化会检查在处理操作期间是否有任何状态切片发生更改,如果没有,则返回之前的状态对象而不是新的状态对象。 这正是状态变更会导致 React UI 组件无法更新的关键原因!
商店实例通过旧版上下文向下传递
在 v4 版本中,渲染<Provider store={store}>会将该 store 实例放入旧版上下文 API 中context.store。任何嵌套的类组件都可以请求将该实例context.store附加到组件上。
整个 store 被置于 context 中的最大原因在于 context 本身存在缺陷。如果在父组件中将初始值放入 context,而某个子组件请求该值,它将收到正确的值。然而,如果父组件将同名更新后的值放入 context,并且中间有一个组件使用 nil 跳过渲染shouldComponentUpdate -> false,那么嵌套组件将永远无法看到更新后的值。这也是 React 团队一直不鼓励用户直接使用旧版 context 的原因之一,他们建议任何使用都应该封装在一个抽象层中,以便在"未来 context 的替代方案"发布时更容易迁移。(更多详情请参阅React 的"旧版 Context"文档页面以及 Michel Westrate 的文章《如何安全地使用 React Context》 。)
因此,一种变通方法是将事件发射器实例放入上下文中,而不是实际值。嵌套组件可以在首次渲染时获取发射器实例并直接订阅,从而绕过sCU接收更新的"障碍"。React-Redux 以及其他一些库都采用了这种方法react-broadcast。
互联组件接受商店作为属性
除了在上下文中检查 store 实例之外,包装组件还可以选择接受一个名为 store 实例的 prop store,例如 @store <MyConnectedComponent store={store}>。这样做的好处是每个组件都单独订阅 store,因此该组件只是以不同的方式获取了 store。
这主要用于在测试中渲染连通组件而无需将<Provider>其包裹起来,但这确实意味着你可以在树的中间有一个连通组件,它使用的存储与周围所有其他组件都不同。
实现细节
更新逻辑直接位于包装组件中
在 v4 版本之前,所有逻辑都包含在connect组件类本身中,包括处理 store 更新、调用mapState以及确定包装的组件是否需要重新渲染。
从中可以发现几个有趣的现象。首先,包装组件总是setState()在根存储状态发生变化时调用该方法,然后再尝试执行其他任何操作。
其次,因此,运行和确定是否有任何变化的真正工作实际上是在方法中直接完成的。mapStaterender
这意味着每次 Redux store 更新后,所有被包裹的组件都需要重新渲染自身,才能确定它们是否真的需要更新。这会导致大量组件每次都要重新渲染,也意味着每次 store 发生实质性更新时,React 都会被调用。
通过记忆化的 React 元素优化了子组件渲染
虽然相关文档不多,但 React 内置了一种特殊的性能优化机制。通常情况下,组件渲染时,每次都会创建新的子元素:
render() {
// Turns into: React.createElement(ChildComponent, {a : 42})
return <ChildComponent a={42} />;
}
每次调用都会React.createElement()返回一个新的元素对象,例如{type : ChildComponent, props : {a : 42}, children : []}。因此,通常每次重新渲染都会导致创建全新的元素对象。
但是,如果您返回与之前完全相同的元素对象,React 将跳过更新这些特定元素以进行优化。(这是@babel/plugin-transform-react-constant-elements的基础,此行为在React issue #3226中有讨论)。
v4 版本利用了这一点,通过缓存子组件的元素,在不需要更新时跳过更新操作。这是必要的,因为外层组件本身已经重新渲染,所以需要某种方法来避免子组件重新渲染。
v5.x
在 v4 版本之前,React-Redux 主要还是由 Dan Abramov 负责,尽管也有一些外部贡献。在我写完 Redux FAQ 之后,Dan 给了我提交权限。我花了很多时间处理问题和查看代码,因此我觉得除了 Redux 的使用方法之外,我还可以提出更多反馈意见。然而,到了 2016 年年中,Dan 加入了 Facebook 的 React 团队,工作也越来越忙,所以他告诉我和 Tim Dorr,我们现在是主要的维护者。
大约在那时,一位名叫Jim Bolla的用户提交了一个问题,询问关于的一个不寻常的用法connect。在讨论中,Jim评论说他正在开发" 的一个替代版本connect",我当时并没有在意。
不过几天后,吉姆提交了一个后续问题,征求大家对他提出的替代方案的反馈。我们讨论了该方案实现中的一些复杂性connect,以及这些复杂性与它试图解决的用例之间的关系,但除此之外,我并没有觉得它有多好。
令我惊讶的是,几天后,Jim 创建了**issue #407:完全重写 connect() 函数,以提供更高级的 API、分离关注点,并(可能)解决许多棘手的边界情况,**作为提交正式 PR 的先决条件。我当时仍然持怀疑态度,并开始指出一些问题和边界情况,但令我(惊喜地)的是,Jim 不断采纳我的反馈并改进他的 WIP 分支。这包括生成一些基准测试,结果表明在某些特定情况下,他的版本明显比 v4 版本快。
Jim 的努力最终打动了我,我们开始认真合作推进他的重写工作。这最终促成了**PR #416:重写 connect() 以提高性能和可扩展性**。
重写后的版本于 2016 年 12 月发布,版本号为**v5.0.0**。主要改动如下:
- 逻辑已从包装组件移至记忆化选择器中。
- 强制自上而下的订阅更新
- 新增
connectAdvancedAPI
- 更多自定义比较选项
- 整体性能提升
所有这些都是在保持connect与 v4 兼容的相同公共 API 的前提下完成的。
v5版本还解决了大量现有问题。
截至v5.0.7 ,进行了各种错误修复,而v5.1.0最近增加了对将 React 的新内置组件类型(如memo和)传递到lazy的支持connect。
让我们深入了解一下细节。
API行为
自上而下的更新
包装connect组件会订阅 store componentDidMount。然而,由于该生命周期在新组件树中自下而上触发,子组件有可能在父组件之前订阅 store。在 v4 版本之前,这导致了一些棘手的重复性 bug。
举例来说,假设有一个包含 10 个子列表项的连接列表。如果它们全部立即渲染,则子列表项的订阅时间会早于父列表。如果您随后从 store 中删除其中一个列表项的数据,则子列表项组件的mapState执行时间会早于父列表组件。这通常意味着子列表项组件mapState会抛出错误,从而破坏组件树。
v5 版本强化了自上而下的更新机制。组件树中位于更高层级的组件总是先于其子组件订阅 store。这样,在类似连接列表的场景中,从 store 中删除一个项目会导致父组件首先更新,并在子组件有机会执行自身更新之前重新渲染,从而移除该子组件mapState。这带来了更可预测的行为,并且与 React 本身的运行机制相一致。
我们将另行讨论具体实施方案。
connectAdvanced
connect它相当固执己见。它允许你通过 getData() 从 store 中提取数据mapState,并通过 preparedFunctions() 准备分发 action 的函数mapDispatch,但它不允许你在 getData() 中使用 store 状态数据,mapDispatch以防止性能问题。它确实提供了mergeProps一个 getState() 参数作为例外情况,但这与 getState() 是两个独立的功能。
然而,对于需要更大灵活性的用户(例如 Jim 本人),v5 版本新增了一个connectAdvancedAPI。它不再接受 <property> (mapState, mapDispatch),而是要求传入一个"选择器工厂"。系统会为每个组件创建一个选择器实例,并赋予其对 <property> 的引用。之后,在 store 或包装组件的所有更新中dispatch,都会调用这些选择器(state, ownProps)。这样,你就可以根据这些输入精确地自定义派生 props 的处理方式。
原始connectAPI 现在实际上已实现为一组特定的选择器函数和选项connectAdvanced。
实施说明
逻辑在记忆化选择器中实现
v5 版本将所有状态派生逻辑从包装组件中移出,并放入一组独立的、自研的记忆化选择器函数中。这些选择器专门实现了所有connectAPI 行为,例如:
- 检查根状态是否已更改
- 处理各种形式的
mapStateand mapDispatch((state)例如(state, ownProps),mapDispatch对象与函数等)
- 呼叫
mapState,,mapDispatch和mergeProps
- 计算新的子属性并确定是否真的需要重新渲染
因此,订阅者的回调函数可以运行得非常快,完全不需要 React 的参与。实际上,React 只会在包装组件知道子组件需要重新渲染时才会介入,并且它会使用一个虚拟this.setState({})调用来将重新渲染加入队列。(我们或许也可以使用forceUpdate()其他方法,但我认为在这种情况下没有任何区别。)
这就是v5通常比v4速度更快的最大原因。
自定义自上而下的订阅逻辑
为了强制执行自顶向下的订阅,v5 引入了一个自定义Subscription类。实际上,connect该类内部会将 store 实例和另一个实例都Subscription放入旧版上下文中。如果上下文中不存在订阅,则该组件将直接订阅 store,因为它必须位于组件树的较高层级。否则,它将订阅该Subscription实例。这意味着每个连接的组件实际上都在订阅其最近的连接的祖先组件。
当一个 action 被分发时,最上层的连接组件的回调函数会立即被触发。如果需要重新渲染,它们会调用回调函数setState(),并等待直到componentDidUpdate触发通知下一层连接组件。如果不需要更新,则会立即通知下一层连接组件。
这样做是可行的,但这也需要在Subscription类和包装组件本身中进行一些非常巧妙的逻辑运算(包括动态地添加和删除componentDidUpdate函数以进行微优化性能)。
v6.0
动机
v5版本很棒。在我们看到的几乎所有场景中,它的性能都比v4版本更快,而且增加了更多的灵活性。
然而,React 团队一直在不断创新。特别是,React 16.3 引入了新的React.createContext()API,它是对旧版 context API 的官方支持替代方案,并鼓励在生产环境中使用。随着createContext新 API 的推出,他们一直在鼓励社区从旧版 context 迁移出来。
他们还在研究"并发 React",这是一个涵盖"时间切片"和"Suspense"等未来功能的统称。从长远来看,当 React 以并发模式运行时,像 Redux 这样的同步外部存储如何正常工作仍然是个问题。
考虑到这一点,我们针对 React-Redux 应该如何与并发 React 协同工作(#890,#950)以及如何在并发 React 中使用时处理弃用警告等问题展开了多次讨论。<StrictMode>
我们最初计划发布 5.1.0 版本来修复<StrictMode>一些问题,但这个测试版本却存在很多问题。当我们尝试修复这些问题时,我们的努力不仅严重影响了性能,还增加了过多的复杂性。
我们最终决定不直接修复<StrictMode>5.x 中的警告,而是继续开发 v6。
推动v6版本开发的主要因素有:
- 使用
createContext替代旧版上下文
- 修复
<StrictMode>警告
- 未来要更加兼容 React 的并发行为。
我们尝试了几个实验性的 PR(特别是#898和#995),最终确定使用**PR #1000:使用 React.createContext()**作为最佳方案。另一位名叫Greg Beaver的贡献者也一直在和我们一起处理这些<StrictMode>问题,我和他各自提交了针对 v6 的"竞争性"候选 PR,内部实现方式有所不同。他的方法速度略快于我的,所以我们最终采用了他的 PR,之后我又对该 PR 进行了进一步优化。
API变更
我们在 2018 年 12 月初发布了**React-Redux v6.0 版本。**主要变化如下:
- 内部:
createContext在内部使用,而不是使用旧版上下文
- 内部更改:组件订阅和接收来自 store 的更新状态的方式发生了变化。
- 重要通知:该
withRef选项已被移除,取而代之的是使用 React 的forwardRef功能。
- 重大变更:不再支持将 store 作为 prop 传递。
请注意,公共 API 仅有两处细微的重大变更! React-Redux为 <script>和<script>标签提供了一套相当全面的单元测试connect``<Provider,v6 通过了与 v5 相同的单元测试(测试中已根据部分实现变更进行了相应的修改)。v6 在 <script>``<StrictMode>标签内也能安全运行,没有任何警告。
因此,对于大多数应用来说,React-Redux v6 的升级几乎是即插即用的!虽然我们要求 React 版本至少为 16.4,因为使用了 React createContext,但除此之外,许多应用都能直接升级到新版本。最大的问题来自一些依赖于直接从旧版上下文访问 store 实例的社区库,这些库的兼容性出现了问题。
然而,这些实现方式的改变确实导致了不同的行为权衡。让我们详细了解一下这些改变。
实施说明
传递。createContext
在 v5.x 及之前的每个版本中,Redux store 实例本身都被置于上下文中,每个连接的组件都会直接订阅它。但在 v6 版本中,这种情况发生了巨大变化。
在 v6 中:
- Redux store 的状态被放入新
createContextAPI的一个实例中。
- 只有一个商店订阅者:
<Provider>组件
这在整个实施过程中产生了各种连锁反应。
我们有理由问,为什么选择更改这一方面。我们当然可以将 store 实例放入 <div> 中,但将 store状态放入上下文中createContext有几个更合理的理由。
最主要的原因是提高与"并发 React"的兼容性,因为整个组件树将看到一个一致的状态值。简单来说,React 的"时间切片"和"Suspense"特性在使用外部同步状态管理工具时可能会出现问题。例如,Andrew Clark 曾描述过"撕裂"问题,即组件树的不同部分在同一次组件树重新渲染过程中看到不同的状态值。通过 context 传递当前状态,我们可以确保整个组件树看到相同的状态值,因为 React 会自动处理这个问题。
长远目标是希望能够避免在使用 React-Redux 和并发模式 React 时出现奇怪的 bug。(关于如何充分利用 Suspense,我们还有其他一些问题需要解决------我写了一篇很长的 Reddit 评论,详细描述了我们可能需要解决的问题。)
与此相关,React-Redux 之前在构造函数中分发状态时遇到了很多问题componentWillMount(参见一些相关问题)。改用通过 context 传递状态旨在消除这些特殊情况。
另一个重要原因是,我们免费获得了"自上而下的更新"功能! 上下文本身就具有自上而下的传播特性,并与渲染过程紧密相关。因此,如果列表项的数据被删除,列表父级自然会在列表项之前重新渲染。正因如此,在 v6 版本中,我们得以移除那段自定义Subscription逻辑------它不再需要了!这减少了我们需要维护的代码量,也使得软件包体积略微减小。
此外,传递 store 实例的原始原因已不再存在,因为**createContext它能正确地将值更改传播到shouldComponentUpdate阻塞者**之外。
最后,尽管无论如何我们都会处理状态与存储的问题,但我们还是切换到了createContext修复混合使用新旧上下文时出现的错误。已经有一些错误报告表明,如果在同一个组件中使用两种上下文形式,就会出现奇怪的问题。Dan 也提到,在组件树的任何位置使用旧上下文都会降低性能。
将商店状态置于上下文中确实对性能产生了一些有趣的影响,我们稍后会谈到这一点。
更新逻辑是选择器,用于渲染
新的上下文 API 依赖于"渲染属性"方法来接收放入上下文中的值,例如:
<MyContext.Consumer>
{ (contextValue) => {
// render something with the new context value here
}}
</MyContext.Consumer>
这意味着上下文更新与包装组件的功能直接相关render。
v6 仍然沿用了与 v5 完全相同的选择器函数集connect。但是,包装组件本身也内置了一些额外的记忆化逻辑,以辅助渲染过程。(我最初尝试添加第二个内部包装组件并使用一些技巧getDerivedStateFromProps,但事实证明,在一个包装组件中添加额外的选择器效率更高。)
为此,v6 版本重用了"记忆化的 React 子元素"技巧,以表明被包裹的组件不应该重新渲染。与 v4 版本一样,这是因为更新与包裹组件的重新渲染密切相关,因此我们需要一种方法来避免子元素不需要更新的情况。(实际上,v6 版本甚至没有真正实现 memoized shouldComponentUpdate,因为就子元素何时更新而言,这种技巧是等效的。)
该withRef选项已替换为forwardRef
高阶组件的一个公认缺点是,它们不允许用户轻易访问其内部被包裹的组件。React 16.3 引入了一个新的React.forwardRefAPI来解决这个问题。库可以使用这个 API,让最终用户能够ref对高阶组件进行自定义,但实际上却能获取到被包裹的真实组件实例。
我们在 v6 版本中添加了该功能,这意味着withRef不再需要旧的选项。由于这确实增加了一层额外的封装(因此 React 需要处理更多工作),所以仍然需要通过新选项启用{forwardRef : true}。
不再store是道具
这是由于从每个组件单独订阅改为在主目录中进行单个订阅所致<Provider>。由于组件不再订阅,直接将 store 作为 prop 传递毫无意义,因此将其移除。
如前所述,此功能主要有两个用途:一是避免<Provider>在测试中渲染组件,二是允许组件树的部分组件从另一个 store 读取数据。单元测试用途确实需要修改代码库,因为有些开发者需要在单元测试中渲染连接组件。对于"备用 store"用途,我们添加了将自定义上下文对象作为 prop 传递给<Provider>连接组件的功能,允许它们根据需要从不同的 store 读取数据,希望这能成为一个有效的替代方案。
(我最初的设想是,API 需要将一个对象Context.Provider作为 prop 传递给一个组件,<Provider>然后再将另一个对象Context.Consumer作为 prop 传递给一个连接的组件。然而,useContext()尽管我曾请求 React 团队允许它只使用一个消费者,但该 hook 仍然需要一个完整的上下文对象,而不仅仅是一个消费者。因此,我的想法是,如果我们将来要在内部使用 hook 来读取上下文,我们就需要在包装组件中提供完整的上下文对象,所以现在最好直接将其作为 prop 传入。)
通过上下文访问商店的方式已更改
虽然它从未成为我们公共 API 的一部分,但众所周知,任何组件都可以通过声明适当的 restore``contextTypes并使用 restore.getResources() 来获取 Redux store 的引用this.context.store。许多社区库都利用了这一点。例如, restore.getResources() connected-react-router [ 会添加一个额外的订阅来处理位置变化](https://github.com/supasate/connected-react-router/blob/b197430be6315eb8c70f98be96ff67825653add5/src/ConnectedRouter.js#L23-L47),而`restore.getResources()` 则会react-redux-subspace 拦截 store 并传递一个包装后的版本,该版本呈现了状态的修改视图。
显然,这种做法不受支持,任何这样做的库都存在导致功能崩溃的风险......在 v6 版本中,由于我们不再使用旧版上下文,所有功能都崩溃了。但是,我们希望允许社区根据需要基于 React-Redux 构建自定义解决方案。
每个连接的组件都需要当前 store 的状态以及对 storedispatch函数的引用,以便mapDispatch正确实现。在早期的一个 PR 中,我曾尝试<Provider>将其{storeState, dispatch}置于上下文中来处理这个问题。
然而,在 v6 正式版中,我们实际上将store 的状态和store 实例都放入了 context 中,因此 context 的值实际上看起来像这样{storeState, store}。这样,组件就可以引用它了store.dispatch。此外,我们还导出了默认的实例ReactReduxContext。 如果有人需要,他们可以渲染该 context 使用者,获取 store 实例,并对其进行一些操作。
再次强调,这不是官方 API,但其目的是为了让人们在需要时能够在此基础上进行开发。
性能影响
当我们尝试修复最初失败的 5.1.0 版本时,我们运行了一些基准测试,以查看修改后的版本与 5.0.7 的比较情况。性能大幅下降是我们放弃该尝试的主要原因。
为此,我建立了一个基准测试仓库,可以对多个版本的 React-Redux 进行比较。我们在 v6 的整个开发过程中都使用了该仓库,将我们各个在研版本与 v6 版本进行对比。
根据这些基准测试,我们预期 React-Redux v6 的速度对于几乎所有实际应用来说都足够快。
话虽如此,但也有一些需要注意的地方。
最初设想切换到新方案时createContext,我希望它能提升性能。毕竟,每次操作都会产生 N 个订阅者调用,而现在只需要 1 个。可惜的是,事实并非如此。
在人工压力测试基准测试中,v6 通常比 v5 慢......但慢的程度不一,原因也很复杂。
了解性能差异
在 v5 版本中,一个分发的操作会导致 N 个订阅者回调函数执行。但是,由于使用了高度记忆化的选择器函数connect,只有数据发生变化的包装组件才会真正调用this.setState()回调函数来触发重新渲染。这意味着 React 只在需要更新时才会介入。
在 v6 版本中,<Provider>它只有一个订阅者回调函数。但是,为了安全地处理状态更改,它会立即setState()使用函数式更新器形式进行调用:
this.unsubscribe = store.subscribe(() => {
const newStoreState = store.getState()
if (!this._isMounted) {
return
}
this.setState(providerState => {
// If the value is the same, skip the unnecessary state update.
if (providerState.storeState === newStoreState) {
return null
}
return { storeState: newStoreState }
})
})
如果 store 状态没有改变,它会尝试跳过一些后续工作来优化性能,但这意味React 会在每次分发 action 后立即介入。
下一个问题是,React 需要遍历组件树来找到所有匹配的上下文使用者。在简单的应用结构中,React 会自动完成这项工作,因为setState()在根组件中调用会递归地导致整个组件树重新渲染。
然而,组件树中的许多组件可能会阻塞更新,无论是手动实现的 on shouldComponentUpdate -> false、PureComponent``on 或on 的实例React.memo(),还是connect跳过子组件重新渲染的包装器。为了便于举例,我们假设最顶层的<App>组件只是简单地调用了 on ,shouldComponentUpdate -> false从而阻塞了后续组件的更新。在这种情况下,React 仍然需要遍历整个已渲染的组件树才能找到所有调用 on 的组件。<Provider>``setState()
React 速度很快,但这项工作确实需要时间。上下文更新的速度不仅仅影响 React-Redux。维护者react-beautiful-dnd创建了**React issue #13739:React Context value propagation performance**,讨论了一些性能方面的问题。在该讨论串中,Dan 和 Dominic 指出,当前对嵌套上下文更新的处理方式略显简单,未来或许可以进一步优化。
性能基准测试
当我完成对最终成为 v6 beta 版的 PR 的清理和优化工作后,我针对我们的基准测试进行了最后一轮运行。 您可以在这里查看这些基准测试结果。总结如下:
- 无论是早期的 v6 开发迭代版本还是最终的 v6 PR 版本,在所有基准测试场景中,速度都比 v5 慢。
- 也就是说,最终的v6版本是所有v6版本中最快的。
- 相对性能下降的程度取决于基准测试场景。在完全扁平且组件快速更新的树状结构中,性能下降最为明显(速度降低约 20%),而在更深的树状结构和其他更新模式下,性能下降则要小得多(速度降低 2%)。
我想再次强调,**这些完全是人为设定的压力测试基准!**我们需要一种方法来客观地比较不同构建版本的性能,因此我们设置了一些场景,故意增加组件数量和调度操作的频率,直到所有构建版本都开始变慢。
(注:我非常欢迎社区提供更多帮助,以完善基准测试套件,帮助我们构建更贴近实际的应用场景。此外,任何人都可以克隆基准测试代码库,替换为特定版本的 React-Redux,并在自己的机器上复现大致的结果。)
v7.0
动机
我多次观察到,获取软件反馈的最佳方法就是发布最终版本。无论你如何宣传 alpha 版和 beta 版,如何恳求人们试用,A) 大多数人都不会试用,B) 即使少数试用者也无法涵盖人们使用代码的方方面面。
v6 版本完全符合这种模式。发布后不久,用户就开始提交各种问题报告,列举他们在升级过程中遇到的各种问题。最常见的问题实际上是第三方库尝试(现在都失败了)直接访问存储库。值得注意的例子包括 <library>、<library> 、 connected-react-router<library> ,甚至还有 <library> 。除了联系维护人员并提供一些升级建议之外,我们对此无能为力。react-redux-firebase``react-redux-subspace``redux-form
然而,其他问题更令人担忧。其中最大的问题是性能。尽管我希望 v6 在实际应用中能够"足够快",但不少用户反映在各种情况下都出现了明显的卡顿。
另一个主要问题是,我们对上下文的使用最终被证明不利于构建基于 Hooks 的 API。当时 React 团队就如何避免函数组件中由上下文引起的更新展开了一场激烈的讨论。尽管早期有一些很有希望的评论,但最终 React 团队表示他们近期不会处理这种特定用例。此外,Sebastian Markbage 还明确指出,新的上下文"不适用于类似 Flux 的状态传播"。
最后,一些特定(但声音很大的)用户对移除 as``store作为属性提出了担忧。Enzyme 的实现方式和局限性,以及我们对上下文的使用,实际上使得他们无法继续对连通组件进行浅层测试。
作为回应,我在 2019 年 2 月初提交了**issue #1177:React-Redux 路线图:v6、Context、订阅和 Hooks**。这是一篇篇幅很长的文章,更详细地阐述了这些问题,并尝试为可能解决这些问题的 v7 版本设定一个方向。为了避免在此重复,请阅读原文以了解其中的挑战以及整个过程的演变。
特别是 React 团队(尤其是 Dan Abramov 和 Sebastian Markbage)鼓励我们回归在组件中使用直接 store 订阅,以提升性能。Dan 还鼓励我们充分利用 React 的unstable_batchedUpdates()API。
发展
当我提交那份路线图提案时,我并没有任何具体的想法,不知道该如何重新connect实现这些目标。幸运的是,我当时正好有一些空闲时间,于是便立即投入到各种想法的试验中。
鉴于我们在类组件生命周期中访问上下文时遇到的限制,我选择尝试使用 React 新的 Hooks API 进行全新的实现。最终我重新引入了Subscriptionv5 中的自定义类,但最初的基准测试结果并不理想。
然而,一天后,我取得了突破性进展:封装connect后的React.memo()性能大幅提升!对比测试表明,它的速度至少与v5 版本一样快,在某些情况下甚至更快。
我开始发布 alpha 版本供大家体验。在接下来的几周里,社区成员不断试探和反馈,发现了一些问题,我们一一修复。期间,我还编写了一份极其详尽的数据流分析报告,逐一比较了 v5、v6、v7-alpha.1 和 v7-alpha.2 版本在更新和重新渲染方面的处理方式。
这条长达一英里的问题讨论串中出现了许多离题的讨论和辩论,涉及各种主题,包括是否需要分层订阅、类组件方法是否仍然可行、对等依赖项的升级是否需要此软件包的新主要版本等等。
最终,我们在四月初发布了**React-Redux v7.0**正式版。用户反响普遍积极。之前使用 v6 版本时遇到性能下降问题的用户反馈,v7 版本在各方面都显著提升了速度。
实施说明
connect使用 Hooks 实现
包装connect组件之前一直是一个类组件。从 v7 版本开始,connect它变成了一个函数组件,并在其中使用了 hooks。这简化了一些方面(例如从上下文访问值、轻松缓存子元素),但也使另一些方面变得复杂(例如 effect 回调的时序控制)。
我们最初在 <script> 标签中执行订阅useEffect(),但后来发现需要使用 <script> 标签useLayoutEffect()来确保订阅是同步添加的。不幸的是,React 团队选择在 SSR 环境中使用 <script> 标签时打印警告useLayoutEffect()。我们不得不进行一些变通的环境检测,并useEffect()在 SSR 中改用 <script> 标签。虽然这两种方法都无法运行,但至少 <script> 标签useEffect()不会发出警告。
直接组件订阅回归
与 v5 及更早版本一样,v7 的所有包装组件都直接订阅 store,只有当选择器逻辑确定包装组件需要重新渲染时,React 才会介入。这是将性能恢复到 v5 水平的第一步关键举措。
使用 React 的批量更新 API
React 一直都有一个名为 Event 的 API unstable_batchedUpdates()。在内部,React 会将所有事件处理程序封装在其中,这使得 React 可以将一次事件触发中的多个状态更新批量处理到单个渲染过程中。
React 团队敦促我们直接在 React-Redux 中使用它unstable_batchedUpdates()。但这很棘手,因为它实际上是从 ReactDOM 和 React Native 等渲染器导出的,而不是从 React 核心包导出的。React-Redux应该与任何 React 渲染器兼容,因此我们不能直接依赖它们。我们不得不编写一些不同的包装文件,以便"react-dom"在 Web 环境中加载导入,并"react-native"在与 React Native 一起使用时加载导入。对于可能使用 React-Redux 和其他渲染器的应用程序,我们添加了一个额外的入口点,该入口点会回退到虚拟的批处理实现。
之前也有其他 Redux 插件利用了批量更新。我不想对所需的增强器和 store 设置做任何规定。因此,我选择 在自定义类中使用批量更新Subscription,并更新代码<Provider>以创建根订阅。
用于React.memo()道具优化
connect它一直以来都实现了与现在类似的优化React.PureComponent,但更加全面。它会检查来自父组件的传入 props,但最终只有在合并后的 stateProps + dispatchProps + ownPropsprops发生变化时才会渲染。
React 16.6 引入了 Component 作为 Component或 ComponentReact.memo()的替代方案。与 Component 类似,它通过对先前和当前 props 进行浅比较来检查是否需要更新。与作为替代基类组件的 Component 不同, Component 是一种新的组件类型,可以封装类组件或函数组件。此外,它还会返回一个非常特殊的对象,其结构类似于Component (参见实现参考)。这很有意思,因为在此之前,所有 React 组件都是某种函数(因为 JS 类实际上就是函数)。现在,React 组件类型首次可以是一个对象,因此,之前试图通过检查值是否为函数来判断其是否为组件的代码现在是错误的。shouldComponentUpdate``PureComponent``PureComponent``PureComponent``React.memo()``{$$typeof: REACT_MEMO_TYPE, type : WrappedComponent, compareFunction}
当然,这意味着一旦我们发布了 v7,我们就开始收到问题,说人们的代码出现了问题,因为其中的检查要求所有组件都是函数。
API变更
回归store道具
现在组件又可以自行订阅 store 了,所以很容易就能重新添加让连接组件store再次接受 prop 的功能。这样就解决了之前移除该功能后出现的问题。
全新batch()API
由于我们已经费尽心思确保可以unstable_batchedUpdates()在 Web 和 RN 环境中导入,因此我们决定将其重新导出为名为 <public API_name> 的公共 API batch()。这样,最终用户可以将触发多次状态更新的代码部分(例如异步函数或 thunk)封装在 React 的事件处理程序之外,从而最大限度地减少重新渲染的次数。
v7.1:钩子?!
动机
React Hooks发布后,人们就开始询问React-Redux何时会包含基于Hooks的公共API。(React Hooks常见问题解答甚至提到了"useRedux()"作为一种假设的Hook "。)
在 React Hooks 正式发布时,已经有很多第三方 Redux Hooks 库了。(后来我整理了一份表格,比较了各种库的 API 及其受欢迎程度。)
显然,问题在于我们何时开发和发布 hooks API,而不是"是否"开发和发布。
发展
v7.0 的开发工作一度让 Hooks API 的讨论陷入停滞。正如我们在React #14110:在 Hooks 中提供更多退出机制时发现的那样,目前没有办法阻止由更新引起的更新useContext()。那场旷日持久的讨论最终导致 React 团队建议我们切换回直接订阅,这意味着发布 v7.0 是实现任何 Hooks API 的先决条件。
最初的钩子讨论帖跑题后,我在二月份根据一些关于 v7 的讨论内容,创建了一个新的 API 讨论帖。讨论在那里持续了一段时间,但我并没有太在意。事实上,在 2019 年 3 月下旬,我回复了一个关于"预计何时发布?"的问题,说我很忙,而且短期内不太可能发布。
但是,随着 v7 版本进入 beta 测试阶段并即将发布,我的脑海中开始浮现出 hooks 的问题。大约在那时,我在Twitter 上发起了一个投票,询问我接下来应该把时间花在哪里,结果 82% 的人选择了"Hooks"。显然,大家的意愿已经表达出来了 :)
我开始积极参与讨论,很快我们就发现需要解决一些重大问题。特别是,我们无法在钩子环境中强制执行自上而下的更新,因为 v7 依赖于重写上下文值来传递嵌套Subscription实例,而钩子无法渲染上下文值。这意味着用户可能会再次遇到"僵尸子实例"的问题。
我最终花了几天时间分析了我所见过的所有第三方钩子库以及所提出的各种方法,并写了一份总结,阐述了我认为我们可能如何能够继续前进。
随后,我们又就一些其他话题展开了讨论,例如使用代理来跟踪更新,以及我们是否可以并行使用 v6 和 v7 的方案。经过长时间的辩论,我们最终得出结论:我们基本上只能放弃用技术手段解决"僵尸子进程"的问题,记录下潜在的问题,然后继续推进。
一位名叫 Jonathan Ziller 的用户编写了一个软件包,实现了他提出的钩子 API 集,我最终建议我们应该将该实现作为 PR 提交。在就钩子名称进行了一番争论之后,我们最终发布了第一个 alpha 版本,其中包含五个钩子:
useSelector
useActions
useDispatch
useRedux
useStore
Alpha 测试周期引发了另一场热烈的讨论(250 条评论)。在此过程中,我们做出了三项重大改动:
经过几个月的 alpha 测试,我们终于快速完成了 RC 阶段,并在 6 月初发布了**React-Redux v7.1.0** 。(我当时正准备在 ReactNext 大会上就这篇文章发表演讲,所以我们最终在演讲前一天晚上发布了 v7.1,这样我就可以宣布它上线了。)
API变更
钩子!
如前所述,我们最终发货了三个挂钩:
useSelector订阅商店并返回所选值
useDispatch返回商店的dispatch功能
useStore返回存储实例本身
由于缺乏自上而下的订阅强制执行,我们确保记录潜在的极端情况,以便人们了解这些问题。
我们最终还在文档中添加了可复制粘贴的配方。useActions``useShallowEqualSelector
未来
现有的connectAPI 总体上非常成功,已有数十万个应用程序在使用它。我们的 hooks API 是全新的,当然还没有经过充分的实战检验,但我们对其进行了足够的迭代,所以我对它的运行方式很有信心。
我真心希望 React-Redux 在经历了 v6/v7 版本频繁更迭之后已经稳定下来了。我很庆幸我们成功地保持了公共 API 的一致性,这意味着大多数用户都能顺利地从 v5 升级到 v6 再到 v7,而不会遇到任何重大变更。不过,我仍然不喜欢我们不得不经历这么几个主要版本,希望它能就此稳定一段时间。
但是,维护者的工作永无止境。以下是一些未来可能需要考虑的问题:
备用 API
在 Hooks 功能发布之前,我们经常被要求提供 "渲染属性"形式的功能connect。现在有了 Hooks,这种情况不太可能再发生了。
除此之外,或许还有一些替代的 API 方法更容易使用,并且将来能更好地与并发 React 配合使用,只是我们还没有想到而已。
并发 React
自 Dan 在2018 年冰岛 JSConf大会上发表题为"超越 React 16"的演讲,并大力宣传 React 的"并发模式"以来,React 社区就一直翘首以盼它的发布。React 团队在 2018 年底发布了一份路线图,表示希望在 2019 年年中推出该功能,但截至 6 月,该功能仍未发布,而且最近的评论表明,距离发布可能还需要一段时间。
Flarnie Marchan(曾是 React 核心团队成员)在六月份的 ReactNext 大会上发表了一场精彩的演讲,题为"准备好迎接并发模式了吗?"。她在演讲中概述了并发模式的工作原理,并指出了现有代码中可能存在的一些问题。非常值得一看。
从长远来看,我们尚不清楚 React-Redux 将如何与并发模式完全兼容,主要是因为并发模式尚未正式发布,也没有相关的文档供我们理解。有人刚刚提交了一个 issue 询问我们与并发模式的兼容性,而我们的回答是:"我们也不知道,以后再研究吧。" 请关注该 issue 以便后续讨论。
未来上下文改进?
v6 最令人失望的地方在于它虽然能用,但速度不够快,无法满足实际应用的需求。或许 React 未来会对 Context API 进行一些改动,让我们能够再次考虑将基于 Context 的状态传播作为一种可行的方案。
举个例子,Josh Story 最近提交了 React RFC,描述了两种可能的上下文重写方案:一种 是延迟上下文传播,用于以更低的开销实现更快的更新;另一种是使用上下文选择器来判断上下文是否应该更新,作为现有方案的替代方案observedBits。他还提交了一个 React-Redux 的概念验证 PR,旨在重写上下文connect以使用这种上下文选择器实现。显然,在真正能够使用这项技术之前,还有很多工作要做(例如 RFC 被接受、更改合并到 React 中、发布新版本),而且还需要 React-Redux 的一个新主版本,但这其中蕴藏着巨大的潜力。
魔法?
在 v6 开发期间,我写了一篇长文,探讨了我们可以使用 Proxies 来跟踪状态依赖关系并根据该信息优化上下文更新的方法:React-Redux 问题 #1018:研究使用 context + observedBits 进行性能优化。
从那时起,加藤大石一直在尝试各种类似的方法,目前他开发了一个名为 Proxy 的小型库,reactive-react-redux该库实现了一个基于代理的useTrackedState()钩子,作为 React-Redux 的替代方案。从长远来看,这种方法非常有趣。
我非常希望听到社区对哪些形式的"魔法"是可以接受的反馈,尤其是在优化组件更新方面。
最后的一些想法
希望这段回顾 React-Redux 开发历程和版本说明的旅程对您有所帮助。正如您所见,React-Redux 从来都不是"魔法",它只是巧妙地实现了各种优化,让您无需为此操心。尽管内部机制复杂,但本质上仍然只是订阅 store,检查组件需要哪些数据,并在必要时重新渲染。实现方式有所改变,但目标始终如一。
这也有助于解释为什么你应该使用 React-Redux 而不是在组件中自己编写订阅逻辑。Redux 团队投入了无数时间来优化性能、处理各种极端情况以及应对生态系统的变化。你应该充分利用这个 API 所凝聚的大量心血!:)
如有任何疑问,请留言、提交问题或在 Reactiflux 和 Twitter 上联系我**@acemarke**。
更多信息
这是"惯用语重述"系列文章之一。本系列其他文章:
本文将介绍 React-Redux API 的由来及其内部工作原理。
简介
React-Redux 的概念非常简单。它订阅 Redux store,检查组件所需的数据是否发生变化,然后重新渲染组件。
然而,要实现这一点,内部结构非常复杂,大多数人并不了解 React-Redux 内部所做的所有工作。我想深入探讨一下 React-Redux 的一些设计决策和实现细节,以及这些实现细节是如何随着时间推移而变化的。
目录
connect简而言之将 Redux 与 UI 集成
了解 Redux Store 订阅
有人说"Redux 只是一个(简单的)事件发射器"。这种说法其实不无道理。早期的 MVC 框架,例如 Backbone,允许将任何字符串作为事件触发,并像
"change:firstName"在模型中一样自动触发事件。而 Redux 则只有一种事件类型:"某个操作已分发"。作为提醒,以下是 Redux store 的一个精简版(但有效)实现示例:
让我们重点关注一下
dispatch()实现方式。请注意,它并没有检查根状态是否真的发生了变化------它会在*每次分发操作后运行每个*订阅者的回调函数,而不管状态是否发生了任何有意义的变化**。此外,store 状态不会传递给订阅者的回调函数------每个订阅者需要自行调用回调函数
store.getState()来获取最新状态。(更多详情请参阅Redux 常见问题解答,了解为什么状态不传递给订阅者。)标准 UI 更新周期
将 Redux 与任何UI 层一起使用都需要遵循相同的步骤:
以下是一个使用纯 JavaScript 编写的"计数器"应用程序的示例,其中"状态"是一个单独的数字:
每个 Redux UI 集成层都只是这些步骤的更高级版本。
你可以手动在每个 React 组件中执行此操作,但这很快就会失控,尤其是在你开始尝试减少不必要的 UI 更新时。
显然,订阅 store、检查数据更新以及触发重新渲染的过程可以变得更加通用和可复用。这就是 React-Redux 和
connectAPI 的用武之地。connect简而言之Redux 发布大约一年后,Dan Abramov 写了一篇名为《connect.js Explained》的 gist 。其中包含一个精简版的 connect.js 示例,
connect用以阐释其概念上的工作原理。为了强调这一点,值得将示例内容粘贴在此:我们可以看到API的关键方面:
connect是一个返回函数的函数,该函数又返回一个包装组件。mapState被包装组件的 props 是包装组件的 props、来自 的值和来自 的值的组合mapDispatch。但是,这个微缩模型忽略了很多细节。特别是,正如评论所指出的那样:
connect检查组件是否需要更新?除此之外,我们当初又是怎么得到这样一款 API 的呢?
React-Redux API 的发展历程
早期迭代
Redux 的最初几个版本将 React 绑定作为主包的一部分包含在内。我不会详细介绍这些版本,但您可以在这些早期版本的发行说明和 README 文件中查看更改:
有趣的是:令人惊讶的是,所有这些迭代竟然只用了一周时间就完成了!
又过了三周才达到v1.0.0-rc 版本,而真正有意义的历史也由此开始。
原始 API 设计约束
React-Redux 于 2015 年 7 月被拆分为一个单独的仓库,就在 Redux v1.0.0-rc 发布之前。
Dan 提交了React-Redux 问题 #1:替代 API 提案,讨论最终 API 的设计方向。在该问题中,他列出了一些设计约束,这些约束将指导最终 API 的运行方式:
这些标准构成了我们今天所拥有的 React-Redux API 的基础,并有助于解释它为什么以这种方式工作。
那次讨论得出的最大结论是,应该使用
connect函数而不是装饰器,以及如何处理绑定操作创建器。还有一个有趣的题外话:绑定动作创建器的"对象简写"是 Dan 最早提出的 API 建议之一:
API 最终定稿
接下来的几个版本继续迭代改进
connectAPI。主要变化包括:mapState、mapDispatch和mergeProps参数store:新增了以属性形式传递的功能ownProps参数mapState/mapDispatch<Provider>单个子元素必须是一个返回元素的函数options参数mapState/mapDispatch调用移至首次渲染中,以避免状态过时问题。继续观察,丹曾警告说不要连接叶子组件:
考虑到我们现在建议将组件连接到树中任何您认为有用的位置,这一点尤其令人感到好笑。
这就引出了我所说的 React-Redux API 的"现代时期",从版本 4 开始。
v4.x
**React-Redux v4.0.0**于 2015 年 10 月发布,主要有四项变更:
<Provider>不再接受函数作为子元素,而是接受标准的 React 元素。withRef选项,而不是始终默认启用。此外,v4.3.0还添加了"工厂函数"语法,
mapState/mapDispatch允许对每个组件实例进行选择器记忆化。我认为 4.x 版本是 React-Redux API 的第一个"完整"版本。因此,值得深入研究它的一些实现细节,以及定义该 API 各版本整体行为的一些共同点。
API行为
每个包装组件实例都是一个独立的商店订阅者
这个很简单。如果我有一个连通列表组件,它渲染了 10 个连通列表项子项,那就需要 11 次单独的调用
store.subscribe()。这也意味着,如果树中有 N 个连通组件,那么每次分发操作都会运行 N 个订阅者回调!每个订阅者回调函数都会根据 store 的状态和 props 自行检查该特定组件是否真的需要更新。
UI 更新需要数据存储不可变性
我们已经确定,无论状态是否实际改变,Redux store 都会在每次分发操作后运行**所有订阅者回调。
为了实现高效的 UI 更新,React-Redux 假定您已不可变地更新了 store 状态,因此它可以使用引用比较来确定状态是否已更改。
这一过程分三个阶段进行:
当
connect包装组件的订阅者回调函数运行时,它首先调用getState()方法store.getState(),并检查store的状态是否发生变化。如果通过引用没有prevStoreState !== storeState改变store 的状态,那么回调函数就会立即停止,不再执行任何更新操作,因为它假定 store 状态的其他部分没有发生变化。如果根状态发生了变化,包装组件就会运行你的
mapState函数,并对当前结果和上次结果进行"浅相等"比较。如果任何字段的引用发生了变化,那么你的组件可能需要更新。mapState假设or的结果发生了变化mapDispatch,mergeProps()则运行该函数来合并来自stateProps``frommapState、dispatchProps``from``mapDispatch和ownProps包装组件本身的props。最后检查合并后的 props 结果自上次以来是否发生变化,如果没有变化,则不会重新渲染被包装的组件。商店实例通过旧版上下文向下传递
在 v4 版本中,渲染
<Provider store={store}>会将该 store 实例放入旧版上下文 API 中context.store。任何嵌套的类组件都可以请求将该实例context.store附加到组件上。整个 store 被置于 context 中的最大原因在于 context 本身存在缺陷。如果在父组件中将初始值放入 context,而某个子组件请求该值,它将收到正确的值。然而,如果父组件将同名更新后的值放入 context,并且中间有一个组件使用
nil跳过渲染shouldComponentUpdate -> false,那么嵌套组件将永远无法看到更新后的值。这也是 React 团队一直不鼓励用户直接使用旧版 context 的原因之一,他们建议任何使用都应该封装在一个抽象层中,以便在"未来 context 的替代方案"发布时更容易迁移。(更多详情请参阅React 的"旧版 Context"文档页面以及 Michel Westrate 的文章《如何安全地使用 React Context》 。)因此,一种变通方法是将事件发射器实例放入上下文中,而不是实际值。嵌套组件可以在首次渲染时获取发射器实例并直接订阅,从而绕过
sCU接收更新的"障碍"。React-Redux 以及其他一些库都采用了这种方法react-broadcast。互联组件接受商店作为属性
除了在上下文中检查 store 实例之外,包装组件还可以选择接受一个名为 store 实例的 prop
store,例如@store<MyConnectedComponent store={store}>。这样做的好处是每个组件都单独订阅 store,因此该组件只是以不同的方式获取了 store。这主要用于在测试中渲染连通组件而无需将
<Provider>其包裹起来,但这确实意味着你可以在树的中间有一个连通组件,它使用的存储与周围所有其他组件都不同。实现细节
更新逻辑直接位于包装组件中
在 v4 版本之前,所有逻辑都包含在
connect组件类本身中,包括处理 store 更新、调用mapState以及确定包装的组件是否需要重新渲染。从中可以发现几个有趣的现象。首先,包装组件总是
setState()在根存储状态发生变化时调用该方法,然后再尝试执行其他任何操作。其次,因此,运行和确定是否有任何变化的真正工作实际上是在方法中直接完成的。
mapStaterender这意味着每次 Redux store 更新后,所有被包裹的组件都需要重新渲染自身,才能确定它们是否真的需要更新。这会导致大量组件每次都要重新渲染,也意味着每次 store 发生实质性更新时,React 都会被调用。
通过记忆化的 React 元素优化了子组件渲染
虽然相关文档不多,但 React 内置了一种特殊的性能优化机制。通常情况下,组件渲染时,每次都会创建新的子元素:
每次调用都会
React.createElement()返回一个新的元素对象,例如{type : ChildComponent, props : {a : 42}, children : []}。因此,通常每次重新渲染都会导致创建全新的元素对象。但是,如果您返回与之前完全相同的元素对象,React 将跳过更新这些特定元素以进行优化。(这是@babel/plugin-transform-react-constant-elements的基础,此行为在React issue #3226中有讨论)。
v4 版本利用了这一点,通过缓存子组件的元素,在不需要更新时跳过更新操作。这是必要的,因为外层组件本身已经重新渲染,所以需要某种方法来避免子组件重新渲染。
v5.x
在 v4 版本之前,React-Redux 主要还是由 Dan Abramov 负责,尽管也有一些外部贡献。在我写完 Redux FAQ 之后,Dan 给了我提交权限。我花了很多时间处理问题和查看代码,因此我觉得除了 Redux 的使用方法之外,我还可以提出更多反馈意见。然而,到了 2016 年年中,Dan 加入了 Facebook 的 React 团队,工作也越来越忙,所以他告诉我和 Tim Dorr,我们现在是主要的维护者。
大约在那时,一位名叫Jim Bolla的用户提交了一个问题,询问关于的一个不寻常的用法
connect。在讨论中,Jim评论说他正在开发" 的一个替代版本connect",我当时并没有在意。不过几天后,吉姆提交了一个后续问题,征求大家对他提出的替代方案的反馈。我们讨论了该方案实现中的一些复杂性
connect,以及这些复杂性与它试图解决的用例之间的关系,但除此之外,我并没有觉得它有多好。令我惊讶的是,几天后,Jim 创建了**issue #407:完全重写 connect() 函数,以提供更高级的 API、分离关注点,并(可能)解决许多棘手的边界情况,**作为提交正式 PR 的先决条件。我当时仍然持怀疑态度,并开始指出一些问题和边界情况,但令我(惊喜地)的是,Jim 不断采纳我的反馈并改进他的 WIP 分支。这包括生成一些基准测试,结果表明在某些特定情况下,他的版本明显比 v4 版本快。
Jim 的努力最终打动了我,我们开始认真合作推进他的重写工作。这最终促成了**PR #416:重写 connect() 以提高性能和可扩展性**。
重写后的版本于 2016 年 12 月发布,版本号为**v5.0.0**。主要改动如下:
connectAdvancedAPI所有这些都是在保持
connect与 v4 兼容的相同公共 API 的前提下完成的。v5版本还解决了大量现有问题。
截至v5.0.7 ,进行了各种错误修复,而v5.1.0最近增加了对将 React 的新内置组件类型(如
memo和)传递到lazy的支持connect。让我们深入了解一下细节。
API行为
自上而下的更新
包装
connect组件会订阅 storecomponentDidMount。然而,由于该生命周期在新组件树中自下而上触发,子组件有可能在父组件之前订阅 store。在 v4 版本之前,这导致了一些棘手的重复性 bug。举例来说,假设有一个包含 10 个子列表项的连接列表。如果它们全部立即渲染,则子列表项的订阅时间会早于父列表。如果您随后从 store 中删除其中一个列表项的数据,则子列表项组件的
mapState执行时间会早于父列表组件。这通常意味着子列表项组件mapState会抛出错误,从而破坏组件树。v5 版本强化了自上而下的更新机制。组件树中位于更高层级的组件总是先于其子组件订阅 store。这样,在类似连接列表的场景中,从 store 中删除一个项目会导致父组件首先更新,并在子组件有机会执行自身更新之前重新渲染,从而移除该子组件
mapState。这带来了更可预测的行为,并且与 React 本身的运行机制相一致。我们将另行讨论具体实施方案。
connectAdvancedconnect它相当固执己见。它允许你通过getData()从 store 中提取数据mapState,并通过preparedFunctions()准备分发 action 的函数mapDispatch,但它不允许你在getData()中使用 store 状态数据,mapDispatch以防止性能问题。它确实提供了mergeProps一个getState()参数作为例外情况,但这与getState()是两个独立的功能。然而,对于需要更大灵活性的用户(例如 Jim 本人),v5 版本新增了一个
connectAdvancedAPI。它不再接受<property>(mapState, mapDispatch),而是要求传入一个"选择器工厂"。系统会为每个组件创建一个选择器实例,并赋予其对<property>的引用。之后,在 store 或包装组件的所有更新中dispatch,都会调用这些选择器(state, ownProps)。这样,你就可以根据这些输入精确地自定义派生 props 的处理方式。原始
connectAPI 现在实际上已实现为一组特定的选择器函数和选项connectAdvanced。实施说明
逻辑在记忆化选择器中实现
v5 版本将所有状态派生逻辑从包装组件中移出,并放入一组独立的、自研的记忆化选择器函数中。这些选择器专门实现了所有
connectAPI 行为,例如:mapStateandmapDispatch((state)例如(state, ownProps),mapDispatch对象与函数等)mapState,,mapDispatch和mergeProps因此,订阅者的回调函数可以运行得非常快,完全不需要 React 的参与。实际上,React 只会在包装组件知道子组件需要重新渲染时才会介入,并且它会使用一个虚拟
this.setState({})调用来将重新渲染加入队列。(我们或许也可以使用forceUpdate()其他方法,但我认为在这种情况下没有任何区别。)这就是v5通常比v4速度更快的最大原因。
自定义自上而下的订阅逻辑
为了强制执行自顶向下的订阅,v5 引入了一个自定义
Subscription类。实际上,connect该类内部会将 store 实例和另一个实例都Subscription放入旧版上下文中。如果上下文中不存在订阅,则该组件将直接订阅 store,因为它必须位于组件树的较高层级。否则,它将订阅该Subscription实例。这意味着每个连接的组件实际上都在订阅其最近的连接的祖先组件。当一个 action 被分发时,最上层的连接组件的回调函数会立即被触发。如果需要重新渲染,它们会调用回调函数
setState(),并等待直到componentDidUpdate触发通知下一层连接组件。如果不需要更新,则会立即通知下一层连接组件。这样做是可行的,但这也需要在
Subscription类和包装组件本身中进行一些非常巧妙的逻辑运算(包括动态地添加和删除componentDidUpdate函数以进行微优化性能)。v6.0
动机
v5版本很棒。在我们看到的几乎所有场景中,它的性能都比v4版本更快,而且增加了更多的灵活性。
然而,React 团队一直在不断创新。特别是,React 16.3 引入了新的
React.createContext()API,它是对旧版 context API 的官方支持替代方案,并鼓励在生产环境中使用。随着createContext新 API 的推出,他们一直在鼓励社区从旧版 context 迁移出来。他们还在研究"并发 React",这是一个涵盖"时间切片"和"Suspense"等未来功能的统称。从长远来看,当 React 以并发模式运行时,像 Redux 这样的同步外部存储如何正常工作仍然是个问题。
考虑到这一点,我们针对 React-Redux 应该如何与并发 React 协同工作(#890,#950)以及如何在并发 React 中使用时处理弃用警告等问题展开了多次讨论。
<StrictMode>我们最初计划发布 5.1.0 版本来修复
<StrictMode>一些问题,但这个测试版本却存在很多问题。当我们尝试修复这些问题时,我们的努力不仅严重影响了性能,还增加了过多的复杂性。我们最终决定不直接修复
<StrictMode>5.x 中的警告,而是继续开发 v6。推动v6版本开发的主要因素有:
createContext替代旧版上下文<StrictMode>警告我们尝试了几个实验性的 PR(特别是#898和#995),最终确定使用**PR #1000:使用 React.createContext()**作为最佳方案。另一位名叫Greg Beaver的贡献者也一直在和我们一起处理这些
<StrictMode>问题,我和他各自提交了针对 v6 的"竞争性"候选 PR,内部实现方式有所不同。他的方法速度略快于我的,所以我们最终采用了他的 PR,之后我又对该 PR 进行了进一步优化。API变更
我们在 2018 年 12 月初发布了**React-Redux v6.0 版本。**主要变化如下:
createContext在内部使用,而不是使用旧版上下文withRef选项已被移除,取而代之的是使用 React 的forwardRef功能。请注意,公共 API 仅有两处细微的重大变更! React-Redux为
<script>和<script>标签提供了一套相当全面的单元测试connect``<Provider,v6 通过了与 v5 相同的单元测试(测试中已根据部分实现变更进行了相应的修改)。v6 在<script>``<StrictMode>标签内也能安全运行,没有任何警告。因此,对于大多数应用来说,React-Redux v6 的升级几乎是即插即用的!虽然我们要求 React 版本至少为 16.4,因为使用了 React
createContext,但除此之外,许多应用都能直接升级到新版本。最大的问题来自一些依赖于直接从旧版上下文访问 store 实例的社区库,这些库的兼容性出现了问题。然而,这些实现方式的改变确实导致了不同的行为权衡。让我们详细了解一下这些改变。
实施说明
传递。
createContext在 v5.x 及之前的每个版本中,Redux store 实例本身都被置于上下文中,每个连接的组件都会直接订阅它。但在 v6 版本中,这种情况发生了巨大变化。
在 v6 中:
createContextAPI的一个实例中。<Provider>组件这在整个实施过程中产生了各种连锁反应。
我们有理由问,为什么选择更改这一方面。我们当然可以将 store 实例放入
<div>中,但将 store状态放入上下文中createContext有几个更合理的理由。最主要的原因是提高与"并发 React"的兼容性,因为整个组件树将看到一个一致的状态值。简单来说,React 的"时间切片"和"Suspense"特性在使用外部同步状态管理工具时可能会出现问题。例如,Andrew Clark 曾描述过"撕裂"问题,即组件树的不同部分在同一次组件树重新渲染过程中看到不同的状态值。通过 context 传递当前状态,我们可以确保整个组件树看到相同的状态值,因为 React 会自动处理这个问题。
长远目标是希望能够避免在使用 React-Redux 和并发模式 React 时出现奇怪的 bug。(关于如何充分利用 Suspense,我们还有其他一些问题需要解决------我写了一篇很长的 Reddit 评论,详细描述了我们可能需要解决的问题。)
与此相关,React-Redux 之前在构造函数中分发状态时遇到了很多问题
componentWillMount(参见一些相关问题)。改用通过 context 传递状态旨在消除这些特殊情况。另一个重要原因是,我们免费获得了"自上而下的更新"功能! 上下文本身就具有自上而下的传播特性,并与渲染过程紧密相关。因此,如果列表项的数据被删除,列表父级自然会在列表项之前重新渲染。正因如此,在 v6 版本中,我们得以移除那段自定义
Subscription逻辑------它不再需要了!这减少了我们需要维护的代码量,也使得软件包体积略微减小。此外,传递 store 实例的原始原因已不再存在,因为**
createContext它能正确地将值更改传播到shouldComponentUpdate阻塞者**之外。最后,尽管无论如何我们都会处理状态与存储的问题,但我们还是切换到了
createContext修复混合使用新旧上下文时出现的错误。已经有一些错误报告表明,如果在同一个组件中使用两种上下文形式,就会出现奇怪的问题。Dan 也提到,在组件树的任何位置使用旧上下文都会降低性能。将商店状态置于上下文中确实对性能产生了一些有趣的影响,我们稍后会谈到这一点。
更新逻辑是选择器,用于渲染
新的上下文 API 依赖于"渲染属性"方法来接收放入上下文中的值,例如:
这意味着上下文更新与包装组件的功能直接相关
render。v6 仍然沿用了与 v5 完全相同的选择器函数集
connect。但是,包装组件本身也内置了一些额外的记忆化逻辑,以辅助渲染过程。(我最初尝试添加第二个内部包装组件并使用一些技巧getDerivedStateFromProps,但事实证明,在一个包装组件中添加额外的选择器效率更高。)为此,v6 版本重用了"记忆化的 React 子元素"技巧,以表明被包裹的组件不应该重新渲染。与 v4 版本一样,这是因为更新与包裹组件的重新渲染密切相关,因此我们需要一种方法来避免子元素不需要更新的情况。(实际上,v6 版本甚至没有真正实现
memoizedshouldComponentUpdate,因为就子元素何时更新而言,这种技巧是等效的。)该
withRef选项已替换为forwardRef高阶组件的一个公认缺点是,它们不允许用户轻易访问其内部被包裹的组件。React 16.3 引入了一个新的
React.forwardRefAPI来解决这个问题。库可以使用这个 API,让最终用户能够ref对高阶组件进行自定义,但实际上却能获取到被包裹的真实组件实例。我们在 v6 版本中添加了该功能,这意味着
withRef不再需要旧的选项。由于这确实增加了一层额外的封装(因此 React 需要处理更多工作),所以仍然需要通过新选项启用{forwardRef : true}。不再
store是道具这是由于从每个组件单独订阅改为在主目录中进行单个订阅所致
<Provider>。由于组件不再订阅,直接将 store 作为 prop 传递毫无意义,因此将其移除。如前所述,此功能主要有两个用途:一是避免
<Provider>在测试中渲染组件,二是允许组件树的部分组件从另一个 store 读取数据。单元测试用途确实需要修改代码库,因为有些开发者需要在单元测试中渲染连接组件。对于"备用 store"用途,我们添加了将自定义上下文对象作为 prop 传递给<Provider>连接组件的功能,允许它们根据需要从不同的 store 读取数据,希望这能成为一个有效的替代方案。(我最初的设想是,API 需要将一个对象
Context.Provider作为 prop 传递给一个组件,<Provider>然后再将另一个对象Context.Consumer作为 prop 传递给一个连接的组件。然而,useContext()尽管我曾请求 React 团队允许它只使用一个消费者,但该 hook 仍然需要一个完整的上下文对象,而不仅仅是一个消费者。因此,我的想法是,如果我们将来要在内部使用 hook 来读取上下文,我们就需要在包装组件中提供完整的上下文对象,所以现在最好直接将其作为 prop 传入。)通过上下文访问商店的方式已更改
虽然它从未成为我们公共 API 的一部分,但众所周知,任何组件都可以通过声明适当的
restore``contextTypes并使用restore.getResources()来获取 Redux store 的引用this.context.store。许多社区库都利用了这一点。例如,restore.getResources()connected-react-router[会添加一个额外的订阅来处理位置变化](https://github.com/supasate/connected-react-router/blob/b197430be6315eb8c70f98be96ff67825653add5/src/ConnectedRouter.js#L23-L47),而`restore.getResources()` 则会react-redux-subspace拦截 store 并传递一个包装后的版本,该版本呈现了状态的修改视图。显然,这种做法不受支持,任何这样做的库都存在导致功能崩溃的风险......在 v6 版本中,由于我们不再使用旧版上下文,所有功能都崩溃了。但是,我们希望允许社区根据需要基于 React-Redux 构建自定义解决方案。
每个连接的组件都需要当前 store 的状态以及对 store
dispatch函数的引用,以便mapDispatch正确实现。在早期的一个 PR 中,我曾尝试<Provider>将其{storeState, dispatch}置于上下文中来处理这个问题。然而,在 v6 正式版中,我们实际上将store 的状态和store 实例都放入了 context 中,因此 context 的值实际上看起来像这样
{storeState, store}。这样,组件就可以引用它了store.dispatch。此外,我们还导出了默认的实例ReactReduxContext。 如果有人需要,他们可以渲染该 context 使用者,获取 store 实例,并对其进行一些操作。再次强调,这不是官方 API,但其目的是为了让人们在需要时能够在此基础上进行开发。
性能影响
当我们尝试修复最初失败的 5.1.0 版本时,我们运行了一些基准测试,以查看修改后的版本与 5.0.7 的比较情况。性能大幅下降是我们放弃该尝试的主要原因。
为此,我建立了一个基准测试仓库,可以对多个版本的 React-Redux 进行比较。我们在 v6 的整个开发过程中都使用了该仓库,将我们各个在研版本与 v6 版本进行对比。
根据这些基准测试,我们预期 React-Redux v6 的速度对于几乎所有实际应用来说都足够快。
话虽如此,但也有一些需要注意的地方。
最初设想切换到新方案时
createContext,我希望它能提升性能。毕竟,每次操作都会产生 N 个订阅者调用,而现在只需要 1 个。可惜的是,事实并非如此。在人工压力测试基准测试中,v6 通常比 v5 慢......但慢的程度不一,原因也很复杂。
了解性能差异
在 v5 版本中,一个分发的操作会导致 N 个订阅者回调函数执行。但是,由于使用了高度记忆化的选择器函数
connect,只有数据发生变化的包装组件才会真正调用this.setState()回调函数来触发重新渲染。这意味着 React 只在需要更新时才会介入。在 v6 版本中,
<Provider>它只有一个订阅者回调函数。但是,为了安全地处理状态更改,它会立即setState()使用函数式更新器形式进行调用:如果 store 状态没有改变,它会尝试跳过一些后续工作来优化性能,但这意味React 会在每次分发 action 后立即介入。
下一个问题是,React 需要遍历组件树来找到所有匹配的上下文使用者。在简单的应用结构中,React 会自动完成这项工作,因为
setState()在根组件中调用会递归地导致整个组件树重新渲染。然而,组件树中的许多组件可能会阻塞更新,无论是手动实现的
onshouldComponentUpdate -> false、PureComponent``on或on的实例React.memo(),还是connect跳过子组件重新渲染的包装器。为了便于举例,我们假设最顶层的<App>组件只是简单地调用了on,shouldComponentUpdate -> false从而阻塞了后续组件的更新。在这种情况下,React 仍然需要遍历整个已渲染的组件树才能找到所有调用on的组件。<Provider>``setState()React 速度很快,但这项工作确实需要时间。上下文更新的速度不仅仅影响 React-Redux。维护者
react-beautiful-dnd创建了**React issue #13739:React Context value propagation performance**,讨论了一些性能方面的问题。在该讨论串中,Dan 和 Dominic 指出,当前对嵌套上下文更新的处理方式略显简单,未来或许可以进一步优化。性能基准测试
当我完成对最终成为 v6 beta 版的 PR 的清理和优化工作后,我针对我们的基准测试进行了最后一轮运行。 您可以在这里查看这些基准测试结果。总结如下:
我想再次强调,**这些完全是人为设定的压力测试基准!**我们需要一种方法来客观地比较不同构建版本的性能,因此我们设置了一些场景,故意增加组件数量和调度操作的频率,直到所有构建版本都开始变慢。
(注:我非常欢迎社区提供更多帮助,以完善基准测试套件,帮助我们构建更贴近实际的应用场景。此外,任何人都可以克隆基准测试代码库,替换为特定版本的 React-Redux,并在自己的机器上复现大致的结果。)
v7.0
动机
我多次观察到,获取软件反馈的最佳方法就是发布最终版本。无论你如何宣传 alpha 版和 beta 版,如何恳求人们试用,A) 大多数人都不会试用,B) 即使少数试用者也无法涵盖人们使用代码的方方面面。
v6 版本完全符合这种模式。发布后不久,用户就开始提交各种问题报告,列举他们在升级过程中遇到的各种问题。最常见的问题实际上是第三方库尝试(现在都失败了)直接访问存储库。值得注意的例子包括
<library>、<library>、connected-react-router<library>,甚至还有<library>。除了联系维护人员并提供一些升级建议之外,我们对此无能为力。react-redux-firebase``react-redux-subspace``redux-form然而,其他问题更令人担忧。其中最大的问题是性能。尽管我希望 v6 在实际应用中能够"足够快",但不少用户反映在各种情况下都出现了明显的卡顿。
另一个主要问题是,我们对上下文的使用最终被证明不利于构建基于 Hooks 的 API。当时 React 团队就如何避免函数组件中由上下文引起的更新展开了一场激烈的讨论。尽管早期有一些很有希望的评论,但最终 React 团队表示他们近期不会处理这种特定用例。此外,Sebastian Markbage 还明确指出,新的上下文"不适用于类似 Flux 的状态传播"。
最后,一些特定(但声音很大的)用户对移除
as``store作为属性提出了担忧。Enzyme 的实现方式和局限性,以及我们对上下文的使用,实际上使得他们无法继续对连通组件进行浅层测试。作为回应,我在 2019 年 2 月初提交了**issue #1177:React-Redux 路线图:v6、Context、订阅和 Hooks**。这是一篇篇幅很长的文章,更详细地阐述了这些问题,并尝试为可能解决这些问题的 v7 版本设定一个方向。为了避免在此重复,请阅读原文以了解其中的挑战以及整个过程的演变。
特别是 React 团队(尤其是 Dan Abramov 和 Sebastian Markbage)鼓励我们回归在组件中使用直接 store 订阅,以提升性能。Dan 还鼓励我们充分利用 React 的
unstable_batchedUpdates()API。发展
当我提交那份路线图提案时,我并没有任何具体的想法,不知道该如何重新
connect实现这些目标。幸运的是,我当时正好有一些空闲时间,于是便立即投入到各种想法的试验中。鉴于我们在类组件生命周期中访问上下文时遇到的限制,我选择尝试使用 React 新的 Hooks API 进行全新的实现。最终我重新引入了
Subscriptionv5 中的自定义类,但最初的基准测试结果并不理想。然而,一天后,我取得了突破性进展:封装
connect后的React.memo()性能大幅提升!对比测试表明,它的速度至少与v5 版本一样快,在某些情况下甚至更快。我开始发布 alpha 版本供大家体验。在接下来的几周里,社区成员不断试探和反馈,发现了一些问题,我们一一修复。期间,我还编写了一份极其详尽的数据流分析报告,逐一比较了 v5、v6、v7-alpha.1 和 v7-alpha.2 版本在更新和重新渲染方面的处理方式。
这条长达一英里的问题讨论串中出现了许多离题的讨论和辩论,涉及各种主题,包括是否需要分层订阅、类组件方法是否仍然可行、对等依赖项的升级是否需要此软件包的新主要版本等等。
最终,我们在四月初发布了**React-Redux v7.0**正式版。用户反响普遍积极。之前使用 v6 版本时遇到性能下降问题的用户反馈,v7 版本在各方面都显著提升了速度。
实施说明
connect使用 Hooks 实现包装
connect组件之前一直是一个类组件。从 v7 版本开始,connect它变成了一个函数组件,并在其中使用了 hooks。这简化了一些方面(例如从上下文访问值、轻松缓存子元素),但也使另一些方面变得复杂(例如 effect 回调的时序控制)。我们最初在
<script>标签中执行订阅useEffect(),但后来发现需要使用<script>标签useLayoutEffect()来确保订阅是同步添加的。不幸的是,React 团队选择在 SSR 环境中使用<script>标签时打印警告useLayoutEffect()。我们不得不进行一些变通的环境检测,并useEffect()在 SSR 中改用<script>标签。虽然这两种方法都无法运行,但至少<script>标签useEffect()不会发出警告。直接组件订阅回归
与 v5 及更早版本一样,v7 的所有包装组件都直接订阅 store,只有当选择器逻辑确定包装组件需要重新渲染时,React 才会介入。这是将性能恢复到 v5 水平的第一步关键举措。
使用 React 的批量更新 API
React 一直都有一个名为
Event的 APIunstable_batchedUpdates()。在内部,React 会将所有事件处理程序封装在其中,这使得 React 可以将一次事件触发中的多个状态更新批量处理到单个渲染过程中。React 团队敦促我们直接在 React-Redux 中使用它
unstable_batchedUpdates()。但这很棘手,因为它实际上是从 ReactDOM 和 React Native 等渲染器导出的,而不是从 React 核心包导出的。React-Redux应该与任何 React 渲染器兼容,因此我们不能直接依赖它们。我们不得不编写一些不同的包装文件,以便"react-dom"在 Web 环境中加载导入,并"react-native"在与 React Native 一起使用时加载导入。对于可能使用 React-Redux 和其他渲染器的应用程序,我们添加了一个额外的入口点,该入口点会回退到虚拟的批处理实现。之前也有其他 Redux 插件利用了批量更新。我不想对所需的增强器和 store 设置做任何规定。因此,我选择 在自定义类中使用批量更新
Subscription,并更新代码<Provider>以创建根订阅。用于
React.memo()道具优化connect它一直以来都实现了与现在类似的优化React.PureComponent,但更加全面。它会检查来自父组件的传入 props,但最终只有在合并后的stateProps + dispatchProps + ownPropsprops发生变化时才会渲染。React 16.6 引入了
Component作为Component或ComponentReact.memo()的替代方案。与Component类似,它通过对先前和当前 props 进行浅比较来检查是否需要更新。与作为替代基类组件的Component不同,Component是一种新的组件类型,可以封装类组件或函数组件。此外,它还会返回一个非常特殊的对象,其结构类似于Component(参见实现参考)。这很有意思,因为在此之前,所有 React 组件都是某种函数(因为 JS 类实际上就是函数)。现在,React 组件类型首次可以是一个对象,因此,之前试图通过检查值是否为函数来判断其是否为组件的代码现在是错误的。shouldComponentUpdate``PureComponent``PureComponent``PureComponent``React.memo()``{$$typeof: REACT_MEMO_TYPE, type : WrappedComponent, compareFunction}当然,这意味着一旦我们发布了 v7,我们就开始收到问题,说人们的代码出现了问题,因为其中的检查要求所有组件都是函数。
API变更
回归
store道具现在组件又可以自行订阅 store 了,所以很容易就能重新添加让连接组件
store再次接受 prop 的功能。这样就解决了之前移除该功能后出现的问题。全新
batch()API由于我们已经费尽心思确保可以
unstable_batchedUpdates()在 Web 和 RN 环境中导入,因此我们决定将其重新导出为名为<public API_name>的公共 APIbatch()。这样,最终用户可以将触发多次状态更新的代码部分(例如异步函数或 thunk)封装在 React 的事件处理程序之外,从而最大限度地减少重新渲染的次数。v7.1:钩子?!
动机
React Hooks发布后,人们就开始询问React-Redux何时会包含基于Hooks的公共API。(React Hooks常见问题解答甚至提到了"
useRedux()"作为一种假设的Hook "。)在 React Hooks 正式发布时,已经有很多第三方 Redux Hooks 库了。(后来我整理了一份表格,比较了各种库的 API 及其受欢迎程度。)
显然,问题在于我们何时开发和发布 hooks API,而不是"是否"开发和发布。
发展
v7.0 的开发工作一度让 Hooks API 的讨论陷入停滞。正如我们在React #14110:在 Hooks 中提供更多退出机制时发现的那样,目前没有办法阻止由更新引起的更新
useContext()。那场旷日持久的讨论最终导致 React 团队建议我们切换回直接订阅,这意味着发布 v7.0 是实现任何 Hooks API 的先决条件。最初的钩子讨论帖跑题后,我在二月份根据一些关于 v7 的讨论内容,创建了一个新的 API 讨论帖。讨论在那里持续了一段时间,但我并没有太在意。事实上,在 2019 年 3 月下旬,我回复了一个关于"预计何时发布?"的问题,说我很忙,而且短期内不太可能发布。
但是,随着 v7 版本进入 beta 测试阶段并即将发布,我的脑海中开始浮现出 hooks 的问题。大约在那时,我在Twitter 上发起了一个投票,询问我接下来应该把时间花在哪里,结果 82% 的人选择了"Hooks"。显然,大家的意愿已经表达出来了 :)
我开始积极参与讨论,很快我们就发现需要解决一些重大问题。特别是,我们无法在钩子环境中强制执行自上而下的更新,因为 v7 依赖于重写上下文值来传递嵌套
Subscription实例,而钩子无法渲染上下文值。这意味着用户可能会再次遇到"僵尸子实例"的问题。我最终花了几天时间分析了我所见过的所有第三方钩子库以及所提出的各种方法,并写了一份总结,阐述了我认为我们可能如何能够继续前进。
随后,我们又就一些其他话题展开了讨论,例如使用代理来跟踪更新,以及我们是否可以并行使用 v6 和 v7 的方案。经过长时间的辩论,我们最终得出结论:我们基本上只能放弃用技术手段解决"僵尸子进程"的问题,记录下潜在的问题,然后继续推进。
一位名叫 Jonathan Ziller 的用户编写了一个软件包,实现了他提出的钩子 API 集,我最终建议我们应该将该实现作为 PR 提交。在就钩子名称进行了一番争论之后,我们最终发布了第一个 alpha 版本,其中包含五个钩子:
useSelectoruseActionsuseDispatchuseReduxuseStoreAlpha 测试周期引发了另一场热烈的讨论(250 条评论)。在此过程中,我们做出了三项重大改动:
useRedux,理由是它没有提供任何有用的功能。useActions,此前 Dan Abramov 强烈认为该版本增加了过多的复杂性(包括命名和变量作用域冲突)。经过几个月的 alpha 测试,我们终于快速完成了 RC 阶段,并在 6 月初发布了**React-Redux v7.1.0** 。(我当时正准备在 ReactNext 大会上就这篇文章发表演讲,所以我们最终在演讲前一天晚上发布了 v7.1,这样我就可以宣布它上线了。)
API变更
钩子!
如前所述,我们最终发货了三个挂钩:
useSelector订阅商店并返回所选值useDispatch返回商店的dispatch功能useStore返回存储实例本身由于缺乏自上而下的订阅强制执行,我们确保记录潜在的极端情况,以便人们了解这些问题。
我们最终还在文档中添加了可复制粘贴的配方。
useActions``useShallowEqualSelector未来
现有的
connectAPI 总体上非常成功,已有数十万个应用程序在使用它。我们的 hooks API 是全新的,当然还没有经过充分的实战检验,但我们对其进行了足够的迭代,所以我对它的运行方式很有信心。我真心希望 React-Redux 在经历了 v6/v7 版本频繁更迭之后已经稳定下来了。我很庆幸我们成功地保持了公共 API 的一致性,这意味着大多数用户都能顺利地从 v5 升级到 v6 再到 v7,而不会遇到任何重大变更。不过,我仍然不喜欢我们不得不经历这么几个主要版本,希望它能就此稳定一段时间。
但是,维护者的工作永无止境。以下是一些未来可能需要考虑的问题:
备用 API
在 Hooks 功能发布之前,我们经常被要求提供 "渲染属性"形式的功能
connect。现在有了 Hooks,这种情况不太可能再发生了。除此之外,或许还有一些替代的 API 方法更容易使用,并且将来能更好地与并发 React 配合使用,只是我们还没有想到而已。
并发 React
自 Dan 在2018 年冰岛 JSConf大会上发表题为"超越 React 16"的演讲,并大力宣传 React 的"并发模式"以来,React 社区就一直翘首以盼它的发布。React 团队在 2018 年底发布了一份路线图,表示希望在 2019 年年中推出该功能,但截至 6 月,该功能仍未发布,而且最近的评论表明,距离发布可能还需要一段时间。
Flarnie Marchan(曾是 React 核心团队成员)在六月份的 ReactNext 大会上发表了一场精彩的演讲,题为"准备好迎接并发模式了吗?"。她在演讲中概述了并发模式的工作原理,并指出了现有代码中可能存在的一些问题。非常值得一看。
从长远来看,我们尚不清楚 React-Redux 将如何与并发模式完全兼容,主要是因为并发模式尚未正式发布,也没有相关的文档供我们理解。有人刚刚提交了一个 issue 询问我们与并发模式的兼容性,而我们的回答是:"我们也不知道,以后再研究吧。" 请关注该 issue 以便后续讨论。
未来上下文改进?
v6 最令人失望的地方在于它虽然能用,但速度不够快,无法满足实际应用的需求。或许 React 未来会对 Context API 进行一些改动,让我们能够再次考虑将基于 Context 的状态传播作为一种可行的方案。
举个例子,Josh Story 最近提交了 React RFC,描述了两种可能的上下文重写方案:一种 是延迟上下文传播,用于以更低的开销实现更快的更新;另一种是使用上下文选择器来判断上下文是否应该更新,作为现有方案的替代方案
observedBits。他还提交了一个 React-Redux 的概念验证 PR,旨在重写上下文connect以使用这种上下文选择器实现。显然,在真正能够使用这项技术之前,还有很多工作要做(例如 RFC 被接受、更改合并到 React 中、发布新版本),而且还需要 React-Redux 的一个新主版本,但这其中蕴藏着巨大的潜力。魔法?
在 v6 开发期间,我写了一篇长文,探讨了我们可以使用 Proxies 来跟踪状态依赖关系并根据该信息优化上下文更新的方法:React-Redux 问题 #1018:研究使用 context + observedBits 进行性能优化。
从那时起,加藤大石一直在尝试各种类似的方法,目前他开发了一个名为
Proxy的小型库,reactive-react-redux该库实现了一个基于代理的useTrackedState()钩子,作为 React-Redux 的替代方案。从长远来看,这种方法非常有趣。我非常希望听到社区对哪些形式的"魔法"是可以接受的反馈,尤其是在优化组件更新方面。
最后的一些想法
希望这段回顾 React-Redux 开发历程和版本说明的旅程对您有所帮助。正如您所见,React-Redux 从来都不是"魔法",它只是巧妙地实现了各种优化,让您无需为此操心。尽管内部机制复杂,但本质上仍然只是订阅 store,检查组件需要哪些数据,并在必要时重新渲染。实现方式有所改变,但目标始终如一。
这也有助于解释为什么你应该使用 React-Redux 而不是在组件中自己编写订阅逻辑。Redux 团队投入了无数时间来优化性能、处理各种极端情况以及应对生态系统的变化。你应该充分利用这个 API 所凝聚的大量心血!:)
如有任何疑问,请留言、提交问题或在 Reactiflux 和 Twitter 上联系我**@acemarke**。
更多信息
这是"惯用语重述"系列文章之一。本系列其他文章: