React、Redux 和 Immutable.js:高效 Web 应用程序的组成部分
已发表: 2022-03-11React、Redux 和 Immutable.js 是目前最流行的 JavaScript 库之一,并且正迅速成为开发人员在前端开发方面的首选。 在我参与过的几个 React 和 Redux 项目中,我意识到很多刚开始使用 React 的开发人员并不完全理解 React 以及如何编写高效的代码来充分利用它的潜力。
在这个 Immutable.js 教程中,我们将使用 React 和 Redux 构建一个简单的应用程序,并确定 React 的一些最常见的误用以及避免它们的方法。
数据引用问题
React 是关于性能的。 它是从头开始构建的,具有极高的性能,仅重新渲染 DOM 的最小部分以满足新的数据更改。 任何 React 应用程序都应该主要由小的简单(或无状态函数)组件组成。 它们很容易推理,并且大多数都可以让shouldComponentUpdate函数返回false 。
shouldComponentUpdate(nextProps, nextState) { return false; }
性能方面,最重要的组件生命周期函数是shouldComponentUpdate ,如果可能,它应该总是返回false 。 这确保了该组件永远不会重新渲染(初始渲染除外),从而有效地使 React 应用程序感觉非常快。
如果不是这种情况,我们的目标是对旧 props/state 与新 props/state 进行廉价的相等性检查,如果数据未更改,则跳过重新渲染。
让我们退后一步,回顾一下 JavaScript 如何对不同的数据类型执行相等检查。
对boolean 、 string和integer等原始数据类型的相等性检查非常简单,因为它们总是通过它们的实际值进行比较:
1 === 1 'string' === 'string' true === true
另一方面,对象、数组和函数等复杂类型的相等性检查则完全不同。 如果两个对象具有相同的引用(指向内存中的相同对象),则它们是相同的。
const obj1 = { prop: 'someValue' }; const obj2 = { prop: 'someValue' }; console.log(obj1 === obj2); // false
尽管 obj1 和 obj2 看起来相同,但它们的引用是不同的。 由于它们不同,在shouldComponentUpdate函数中天真地比较它们会导致我们的组件不必要地重新渲染。
需要注意的重要一点是,来自 Redux reducer 的数据,如果设置不正确,将始终使用不同的引用提供服务,这将导致组件每次重新渲染。
这是我们寻求避免组件重新渲染的核心问题。
处理引用
让我们举一个例子,其中我们有深度嵌套的对象,我们想将它与以前的版本进行比较。 我们可以递归循环遍历嵌套的对象 props 并比较每一个,但显然这将非常昂贵并且是不可能的。
这让我们只有一个解决方案,那就是检查参考资料,但很快就会出现新问题:
- 如果没有任何变化,则保留参考
- 如果任何嵌套对象/数组道具值发生更改,则更改引用
如果我们想以一种漂亮、干净和性能优化的方式来做这件事,这不是一件容易的事。 Facebook 很久以前就意识到了这个问题,并调用了 Immutable.js 来解决这个问题。
import { Map } from 'immutable'; // transform object into immutable map let obj1 = Map({ prop: 'someValue' }); const obj2 = obj1; console.log(obj1 === obj2); // true obj1 = obj1.set('prop', 'someValue'); // set same old value console.log(obj1 === obj2); // true | does not break reference because nothing has changed obj1 = obj1.set('prop', 'someNewValue'); // set new value console.log(obj1 === obj2); // false | breaks reference
没有一个 Immutable.js 函数对给定数据执行直接突变。 相反,数据在内部被克隆、变异,如果有任何更改,则返回新的引用。 否则返回初始引用。 必须明确设置新引用,例如obj1 = obj1.set(...);
.
React、Redux 和 Immutable.js 示例
展示这些库的强大功能的最佳方式是构建一个简单的应用程序。 还有什么比待办事项应用程序更简单的呢?
为简洁起见,在本文中,我们将仅介绍对这些概念至关重要的应用程序部分。 应用程序代码的完整源代码可以在 GitHub 上找到。
当应用程序启动时,您会注意到对console.log的调用方便地放置在关键区域,以清楚地显示 DOM 重新渲染的数量,这是最小的。
与任何其他待办事项应用程序一样,我们希望显示待办事项列表。 当用户单击待办事项时,我们会将其标记为已完成。 此外,我们需要在顶部有一个小的输入字段来添加新的待办事项,并在底部的 3 个过滤器上允许用户在以下之间切换:
- 全部
- 完全的
- 积极的
Redux 减速器
Redux 应用程序中的所有数据都存在于单个 store 对象中,我们可以将 reducer 视为一种将 store 拆分为更易于推理的小块的便捷方式。 由于 reducer 也是一个函数,它也可以拆分成更小的部分。
我们的减速器将由 2 个小部件组成:
- 待办事项列表
- 主动过滤器
// reducers/todos.js import * as types from 'constants/ActionTypes'; // we can look at List/Map as immutable representation of JS Array/Object import { List, Map } from 'immutable'; import { combineReducers } from 'redux'; function todoList(state = List(), action) { // default state is empty List() switch (action.type) { case types.ADD_TODO: return state.push(Map({ // Every switch/case must always return either immutable id: action.id, // or primitive (like in activeFilter) state data text: action.text, // We let Immutable decide if data has changed or not isCompleted: false, })); // other cases... default: return state; } } function activeFilter(state = 'all', action) { switch (action.type) { case types.CHANGE_FILTER: return action.filter; // This is primitive data so there's no need to worry default: return state; } } // combineReducers combines reducers into a single object // it lets us create any number or combination of reducers to fit our case export default combineReducers({ activeFilter, todoList, });
与 Redux 连接
现在我们已经使用 Immutable.js 数据设置了一个 Redux reducer,让我们将它与 React 组件连接以传递数据。
// components/App.js import { connect } from 'react-redux'; // ….component code const mapStateToProps = state => ({ activeFilter: state.todos.activeFilter, todoList: state.todos.todoList, }); export default connect(mapStateToProps)(App);
在一个完美的世界里,connect 应该只在顶级路由组件上执行,提取 mapStateToProps 中的数据,其余的是基本的 React 将 props 传递给孩子。 在大型应用程序中,跟踪所有连接往往会变得很困难,因此我们希望将它们保持在最低限度。
需要注意的是 state.todos 是一个从 Redux combineReducers函数返回的常规 JavaScript 对象(todos 是 reducer 的名称),但 state.todos.todoList 是一个不可变列表,它保持在这样一个非常重要的直到它通过shouldComponentUpdate检查。
避免组件重新渲染
在我们深入挖掘之前,重要的是要了解必须向组件提供什么类型的数据:
- 任何类型的原始类型
- 对象/数组仅以不可变形式
拥有这些类型的数据可以让我们对 React 组件中的 props 进行浅显的比较。
下一个示例展示了如何以最简单的方式区分 props:
$ npm install react-pure-render
import shallowEqual from 'react-pure-render/shallowEqual'; shouldComponentUpdate(nextProps, nextState) { return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState); }
函数shallowEqual将仅检查 1 级深度的道具/状态差异。 它的运行速度非常快,并且与我们的不可变数据完美协同。 必须在每个组件中编写这个shouldComponentUpdate会很不方便,但幸运的是有一个简单的解决方案。

将 shouldComponentUpdate提取到一个特殊的单独组件中:
// components/PureComponent.js import React from 'react'; import shallowEqual from 'react-pure-render/shallowEqual'; export default class PureComponent extends React.Component { shouldComponentUpdate(nextProps, nextState) { return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState); } }
然后只需扩展需要此 shouldComponentUpdate 逻辑的任何组件:
// components/Todo.js export default class Todo extends PureComponent { // Component code }
这是在大多数情况下避免组件重新渲染的一种非常干净和有效的方法,以后如果应用程序变得更复杂并且突然需要自定义解决方案,它可以轻松更改。
在将函数作为道具传递时使用PureComponent时会出现一个小问题。 由于带有 ES6类的 React 不会自动将this绑定到我们必须手动执行的函数。 我们可以通过执行以下操作之一来实现这一点:
- 使用 ES6 箭头函数绑定:
<Component onClick={() => this.handleClick()} />
- 使用绑定:
<Component onClick={this.handleClick.bind(this)} />
这两种方法都会导致Component重新渲染,因为每次都向onClick传递了不同的引用。
为了解决这个问题,我们可以在构造方法中预先绑定函数,如下所示:
constructor() { super(); this.handleClick = this.handleClick.bind(this); } // Then simply pass the function render() { return <Component onClick={this.handleClick} /> }
如果你发现自己大部分时间都预先绑定了多个函数,我们可以导出和重用小的辅助函数:
// utils/bind-functions.js export default function bindFunctions(functions) { functions.forEach(f => this[f] = this[f].bind(this)); } // some component constructor() { super(); bindFunctions.call(this, ['handleClick']); // Second argument is array of function names }
如果没有一个解决方案适合您,您始终可以手动编写shouldComponentUpdate条件。
处理组件内的不可变数据
使用当前的不可变数据设置,避免了重新渲染,并且我们在组件的 props 中留下了不可变数据。 使用这种不可变数据的方法有很多,但最常见的错误是使用不可变的 toJS函数立即将数据转换为纯 JS。
使用toJS将不可变数据深度转换为普通 JS 否定了避免重新渲染的整个目的,因为正如预期的那样,它非常慢,因此应该避免。 那么我们如何处理不可变数据呢?
它需要按原样使用,这就是为什么 Immutable API 提供了各种各样的函数, map和get在 React 组件中最常用。 来自 Redux Reducer 的todoList数据结构是一个不可变形式的对象数组,每个对象代表一个单独的 todo 项:
[{ id: 1, text: 'todo1', isCompleted: false, }, { id: 2, text: 'todo2', isCompleted: false, }]
Immutable.js API 与常规 JavaScript 非常相似,因此我们可以像使用任何其他对象数组一样使用 todoList。 在大多数情况下,地图功能证明是最好的。
在 map 回调中,我们得到todo ,它是一个仍然处于不可变形式的对象,我们可以安全地将它传递给Todo组件。
// components/TodoList.js render() { return ( // …. {todoList.map(todo => { return ( <Todo key={todo.get('id')} todo={todo}/> ); })} // …. ); }
如果您计划对不可变数据执行多个链式迭代,例如:
myMap.filter(somePred).sort(someComp)
…那么首先使用toSeq将其转换为Seq并在迭代后将其转换回所需的形式非常重要,例如:
myMap.toSeq().filter(somePred).sort(someComp).toOrderedMap()
由于 Immutable.js 从不直接改变给定数据,它总是需要制作另一个副本,像这样执行多次迭代可能非常昂贵。 Seq 是惰性的不可变数据序列,这意味着它将执行尽可能少的操作来完成其任务,同时跳过创建中间副本。 Seq 就是为这种方式而构建的。
在Todo组件内部使用get或getIn来获取道具。
够简单吧?
好吧,我意识到,很多时候它会变得非常难以理解,因为它有大量的get()
,尤其是getIn()
。 所以我决定在性能和可读性之间找到一个最佳点,经过一些简单的实验后,我发现 Immutable.js的 toObject和toArray函数工作得很好。
这些函数将(1 级深度)Immutable.js 对象/数组浅转换为纯 JavaScript 对象/数组。 如果我们有任何数据深深嵌套在里面,它们将保持不可变的形式,准备好传递给
它比get()
慢了可以忽略不计,但看起来更干净:
// components/Todo.js render() { const { id, text, isCompleted } = this.props.todo.toObject(); // ….. }
让我们看看这一切在行动
如果您还没有从 GitHub 克隆代码,现在是这样做的好时机:
git clone https://github.com/rogic89/ToDo-react-redux-immutable.git cd ToDo-react-redux-immutable
启动服务器很简单(确保安装了 Node.js 和 NPM):
npm install npm start
在 Web 浏览器中导航到 http://localhost:3000。 打开开发者控制台,在添加一些待办事项时观察日志,将它们标记为完成并更改过滤器:
- 添加 5 个待办事项
- 将过滤器从“全部”更改为“活动”,然后返回“全部”
- 无需重新渲染,只需过滤器更改
- 将 2 个待办事项标记为已完成
- 重新渲染了两个 todo,但一次只渲染一个
- 将过滤器从“全部”更改为“活动”,然后返回“全部”
- 仅安装/卸载了 2 个已完成的待办事项
- 活动的没有重新渲染
- 从列表中间删除单个待办事项
- 只有移除的待办事项受到影响,其他没有重新渲染
包起来
如果使用得当,React、Redux 和 Immutable.js 的协同作用可以为大型 Web 应用程序中经常遇到的许多性能问题提供一些优雅的解决方案。
Immutable.js 允许我们检测 JavaScript 对象/数组的变化,而无需求助于深度相等检查的低效率,这反过来又允许 React 在不需要时避免昂贵的重新渲染操作。 这意味着 Immutable.js 的性能在大多数情况下往往都很好。
我希望你喜欢这篇文章,并发现它对在你未来的项目中构建 React 创新解决方案很有用。