性能优化

Redux 使数据流动变得清晰,但目前我们的项目中还存在一些不必要的状态重复计算和 UI 重复渲染。下面将对程序性能做进一步优化。

React Router引起的组件重复渲染问题

Redux 和 React Router 集成使用时,容易碰到一个很隐蔽的性能问题。先来看一下组件 app 的代码:

import React, { Component } from "react";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import asyncComponent from "../../utils/AsyncComponent";
import ModalDialog from "../../components/ModalDialog";
import Loading from "../../components/Loading";
import { actions as appActions, getError, getRequestQuantity } from "../../redux/modules/app";
import connectRoute from "../../utils/connectRoute";

// 异步加载 Home 组件
const AsyncHome = connectRoute(asyncComponent(() => import("../Home")));
// 异步加载 Login 组件
const AsyncLogin = connectRoute(asyncComponent(() => import("../Login")));

class App extends Component {
  render() {
    const { error, requestQuantity } = this.props;
    const errorDialog = error && (
      <ModalDialog onClose={this.props.removeError}>
        {error.message || error}
      </ModalDialog>
    );

    return (
      <div>
        <Router>
          <Switch>
            <Route exact path="/" component={AsyncHome} />
            <Route path="/login" component={AsyncLogin} />
            <Route path="/posts" component={AsyncHome} />
          </Switch>
        </Router>
        {errorDialog}
        {requestQuantity > 0 && <Loading />}
      </div>
    );
  }
}

const mapStateToProps = (state, props) => {
  return {
    error: getError(state),
    requestQuantity: getRequestQuantity(state)
  };
};

const mapDispatchToProps = dispatch => {
  return {
    ...bindActionCreators(appActions, dispatch)
  };
};

export default connect(mapStateToProps, mapDispatchToProps)(App);

app 中既使用到 React Router 的 Route,又使用到从 Redux store 中获取的 error 和 requestQuantity 两个状态。帖子列表组件 PostList 在发送获取帖子列表数据的 action 时,会改变 requestQuantity 的值,这时候 app 的 render 方法会再次被调用,这是正常的情况,但 Route 中定义的组件(比如 Home,实际上是组件 AsyncHome,AsyncHome 中渲染了组件 Home)的 render 方法也会被再次调用。再来看一下组件 Home 的代码:

import React, {Component} from "react";
import {Route} from "react-router-dom";
import {bindActionCreators} from "redux";
import {connect} from "react-redux";
import Header from "../../components/Header";
import asyncComponent from "../../utils/AsyncComponent";
import {actions as authActions, getLoggedUser} from "../../redux/modules/auth";
import connectRoute from "../../utils/connectRoute";

// 异步加载 Post 组件
const AsyncPost = connectRoute(asyncComponent(() => import("../Post")));
// 异步加载 PostList 组件
const AsyncPostList = connectRoute(asyncComponent(() => import("../PostList")));

class Home extends Component {
    // 省略其余代码
}

const mapStateToProps = (state, props) => {
    return {
        user: getLoggedUser(state)
    };
};

const mapDispatchToProps = dispatch => {
    return {
        ...bindActionCreators(authActions, dispatch)
    };
};

export default connect(mapStateToProps, mapDispatchToProps)(Home);

Home 也是一个容器组件,它使用到 Redux store 中的状态 user,当 requestQuantity 发生变化时,从 store 中获取的 user 还是原来的对象,也就是说 Home 的 mapStateToProps 新返回的对象和之前的对象符合浅比较(shallow comparison)相等的条件,根据 8.3.4 小节的介绍,Home 组件的 render 方法不应该被再次调用。

这个问题的根结在于 React Router 的 Route 组件。下面是 Route 的部分源码(注意注释部分):

image 2024 04 24 18 34 45 055

app render 方法的执行会导致 Route 的 componentWillReceiveProps 执行,componentWillReceiveProps 每次都会调用 setState 设置 match,match 由 computeMatch 计算而来,computeMatch 每次都会返回一个新的对象。这样,每次 Route 更新(componentWillReceiveProps 被调用)都将创建一个新的 match,而这个 match 又会作为 props 传递给 Route 中定义的组件。于是,Home 组件在更新阶段总会收到一个新的 match 属性,react-redux 既会比较组件依赖的 state 的变化,又会比较组件接收的 props 的变化,这种情况下,props 总是改变的,组件的 render 方法被重新调用。事实上,在上面的情况中,Route 传递给 Home 的其他属性 location、history、staticContext 都没有改变,match 虽然是一个新对象,但对象的内容并没有改变(一直处在同一页面,URL 并没有发生变化,match 的计算结果自然也没有变)。

我们可以通过创建一个高阶组件在高阶组件内重写组件的 shouldComponentUpdate 方法,如果 Route 传递的 location 属性没有发生变化(表示处于同一页面),就返回 false,从而阻止组件继续更新。然后使用这个高阶组件包裹每一个要在 Route 中使用的组件。

新建一个高阶组件 connectRoute:

import React from "react";

export default function connectRoute(WrappedComponent) {
    return class ConnectRoute extends React.Component {
        shouldComponentUpdate(nextProps) {
            return nextProps.location !== this.props.location;
        }

        render() {
            return <WrappedComponent {...this.props} />;
        }
    };
}

用 connectRoute 包裹 Home、Login:

import React, {Component} from "react";
import {BrowserRouter as Router, Route, Switch} from "react-router-dom";
import {bindActionCreators} from "redux";
import {connect} from "react-redux";
import asyncComponent from "../../utils/AsyncComponent";
import ModalDialog from "../../components/ModalDialog";
import Loading from "../../components/Loading";
import {actions as appActions, getError, getRequestQuantity} from "../../redux/modules/app";
import connectRoute from "../../utils/connectRoute";

// connectRoute包裹 Home 组件
const AsyncHome = connectRoute(asyncComponent(() => import("../Home")));
// connectRoute包裹 Login 组件
const AsyncLogin = connectRoute(asyncComponent(() => import("../Login")));

class App extends Component {
    // ...
}

const mapStateToProps = (state, props) => {
    return {
        error: getError(state),
        requestQuantity: getRequestQuantity(state)
    };
};

const mapDispatchToProps = dispatch => {
    return {
        ...bindActionCreators(appActions, dispatch)
    };
};

export default connect(mapStateToProps, mapDispatchToProps)(App);

这样,app 依赖的 store 中的 state 改变就不会再导致 Route 内组件的 render 方法的重新执行了。其他使用到 Route 的容器组件中也需要做同样的处理。

我们再来思考一种场景,如果 app 使用的 store 中的 state 同样会影响到 Route 的属性,比如 requestQuantity 大于 0 时,Route 的 path 会改变,假设变成 <Route path="/home/fetching"component={AsyncHome} />,而 Home 内部假设又需要用到 Route 传递的 path(通过 props.match.path 获取),这时候就需要 Home 组件重新 render。但因为在高阶组件 connectRoute 的 shouldComponentUpdate 中,我们只是根据 location 做判断,此时的 location 依然没有发生变化,导致 Home 并不会重新渲染。这是一种很特殊的场景,但是想通过这种场景告诉大家,高阶组件 connectRoute 中 shouldComponentUpdate 的判断条件需要根据实际业务场景做决策。绝大部分场景下,上面的高阶组件是足够使用的。

另外,React Router 的这个问题并不是只和 Redux 一起使用时才会遇到,当和 MobX 一起使用或 Route 内使用的组件继承自 React 的 PureComponent 时,也存在同样的问题。

Immutable.JS

Redux 的 state 必须是不可变对象,reducer 中每次返回的 state 都是一个新对象。为了保证这一点,我们需要写一些额外的处理逻辑,例如使用 Object.assign 方法或 ES6 的扩展运算符(…​)创建新的 state 对象。

Immutable.JS 的作用在于以更加高效的方式创建不可变对象,主要优点有 3 个:保证数据的不可变、丰富的 API 和优异的性能。

保证数据的不可变

通过 Immutable.JS 创建的对象在任何情况下都无法被修改,这样就可以防止由于开发者的粗心大意导致直接修改了 Redux 的 state。

丰富的API

Immutable.JS 提供了丰富的 API 创建不同类型的不可变对象,如 Map、List、Set、Record 等,它还提供了大量 API 用于操作这些不可变对象,如 get、set、sort、filter 等。

优异的性能

一般情况下,使用不可变对象会涉及大量的复制操作,给程序性能带来影响。Immutable.JS 在这方面做了大量优化,将使用不可变对象带来的性能损耗降低到可以忽略不计。

使用 Immutable.JS 前,需要先在项目根路径下安装这个依赖:

npm install immutable

然后,就可以在项目中使用 Immutable.JS 了,下面是一个简单示例:

import Immutalbe from "immutable";

const map1 = Immutalbe.Map({a:1,b:2,c:3});
const map2 = map1.set('b', 50);
const map3 = map1.merge({b: 100});
map1.get('b') + " vs. " + map2.get('b') + " vs. " + map3.get('b');  // 2 vs. 50 vs. 100

我们先使用 Immutable.JS 的 Map API 创建了一个 Map 结构的不可变对象 map1,然后分别使用 set、merge 方法修改 map1,最后通过 get 方法从不可变对象中取出 b 的值,输出表明,set、merge 并没有修改原有的 map1 对象,而是创建了一个新的对象。这就是 Immutable.JS 的最大特征,对象一旦创建,就无法再次修改。关于 Immutable.JS 完整的 API 介绍可参考官方文档: https://facebook.github.io/immutable-js/

当 Immutable.JS 和 Redux 一起使用时,需要通过 Immutable.JS 的 API 创建 Redux 的全局 state,reducer 中通过 Immutable.JS 的 API 修改 state。我们以 BBS 项目中的 posts 模块为例详细介绍如何引入 Immutable.JS。

  1. 用 Immutable.JS 创建模块使用的初始 state:

    import Immutable from 'immutable';
    
    const initialState = Immutable.fromJS({
        allIds: [],
        byId: {}
    });

    posts 模块中的每一个 reducer 设置 state 默认值的方式也需要修改:

    const allIds = (state = initialState.get("allIds"), action) => {
        // ...
    };
    
    const byId = (state = initialState.get("byId"), action) => {
        // ...
    };
  2. 当 reducer 接收到 action 时,reducer 内部也需要通过 Immutable.JS 的 API 来修改 state,代码如下:

    const allIds = (state = Immutable.fromJS([]), action) => {
      switch (action.type) {
        case types.FETCH_ALL_POSTS:
          // Immutable.List 创建一个 List 类型的不可变对象
          return Immutable.List(action.postIds);
        case types.CREATE_POST:
          // 使用 unshift 向 List 类型的 state 增加元素
          return state.unshift(action.post.id);
        default:
          return state;
      }
    };
    
    const byId = (state = Immutable.fromJS({}), action) => {
      switch (action.type) {
        case types.FETCH_ALL_POSTS:
          // 使用 merge 合并获取到 post 列表数据
          return state.merge(action.posts);
        case types.FETCH_POST:
        case types.CREATE_POST:
        case types.UPDATE_POST:
          // 使用 merge 修改对应 post 的数据
          return state.merge({ [action.post.id]: action.post });
        default:
          return state;
      }
    };
  3. 引入 redux-immutable。之前使用 Redux 提供的 combineReducers 函数合并 reducer,但 combineReducers 只能识别普通 JavaScript 对象组成的 state,无法识别 Immutable.JS 创建的对象组成的 state。我们可以使用 redux-immutable 这个库提供的 combineReducers 解决这个问题。先在项目中安装 redux-immutable:

    npm install redux-immutable

    使用 redux-immutable 的 combineReducers 合并 reducer:

    import { combineReducers } from "redux-immutable";
    
    const reducer = combineReducers({
        allIds,
        byId
    });
    
    export default reducer;
  4. 修改 selectors,让 selectors 返回 Immutable.JS 类型的不可变对象:

    // selectors
    export const getPostIds = state => state.getIn(["posts", "allIds"]);
    export const getPostList = state => state.getIn(["posts", "byId"]);
    export const getPostById = (state,id) => state.getIn(["posts", "byId", id]);

    修改 selectors 时,不要忘记 redux/module/index.js 中定义的复杂 selectors 同样需要修改,因为这部分修改相对复杂些,这里给出修改后的代码:

    import comments, { getCommentIdsByPost, getCommentById } from "./comments";
    import posts, { getPostIds, getPostById } from "./posts";
    import users, { getUserById } from "./users";
    
    // 获取包含完整作者信息的帖子列表
    export const getPostListWithAuthors = state => {
      const allIds = getPostIds(state);
      return allIds.map(id => {
        const post = getPostById(state, id);
        return post.merge({ author: getUserById(state, post.get("author")) });
      });
    };
    
    // 获取帖子详情
    export const getPostDetail = (state, id) => {
      const post = getPostById(state, id);
      return post
        ? post.merge({ author: getUserById(state, post.get("author")) })
        : null;
    };
    
    // 获取包含完整作者信息的评论列表
    export const getCommentsWithAuthors = (state, postId) => {
      const commentIds = getCommentIdsByPost(state, postId);
      if (commentIds) {
        return commentIds.map(id => {
          const comment = getCommentById(state, id);
          return comment.merge({ author: getUserById(state, comment.get("author")) });
        });
      } else {
        return Immutable.List();
      }
    };

    这里省略了 comments 和 users 模块中新版本 selectors 的定义。

  5. 在容器组件 PostList 中使用新版本的 selectors:

import { getLoggedUser } from "../../redux/modules/auth";
import { isAddDialogOpen } from "../../redux/modules/ui";
import { getPostListWithAuthors } from "../../redux/modules";

const mapStateToProps = (state, props) => {
    return {
        user: getLoggedUser(state),
        posts: getPostListWithAuthors(state),
        isAddDialogOpen: isAddDialogOpen(state)
    };
};

因为 mapStateToProps 返回的对象的属性是 Immutable.JS 类型的不可变对象,所以在容器组件中使用时,也需要通过 Immutable.JS 的 API 获取不可变对象中的属性值。但展示组件应该是对 Immutable.JS 的使用无感知的,也就是容器组件需要把 Immutable.JS 类型的不可变对象转换成普通 JavaScript 对象后,再传递给展示组件使用(否则展示组件复用时,还必须捆绑 Immutable.JS)。主要的变化发生在 containers/PostList/index.js 的 render 方法中:

render() {
    const {posts, user, isAddDialogOpen} = this.props;
    const rawPosts = posts.toJS(); // 转换成普通 JavaScript 对象
    return (
        <div className="postList">
            <div>
                <h2>话题列表</h2>
                {user.get("userId") ? (
                    <button onClick={this.handleNewPost}>发帖</button>
                ) : null}
            </div>
            {isAddDialogOpen ? (
                <PostEditor onSave={this.handleSave} onCancel={this.handleCancel}/>
            ) : null}
            <ul>
                {rawPosts.map(item => (
                    <Link key={item.id} to={`/posts/${item.id}`}>
                        <PostItem post={item} />
                    </Link>
                ))}
            </ul>
        </div>
    );
}

不可变对象 posts 先通过 toJS 方法转换成普通的 JavaScript 对象,然后才提供给展示组件 PostItem 使用。

不要在 mapStateToProps 中使用 toJS() 将 Immutable.JS 类型的不可变对象转换成普通 JavaScript 对象。因为 toJS() 每次返回的都是一个新对象,这将导致 Redux 每次使用浅比较判断 mapStateToProps 返回的对象是否改变时,都认为发生了修改,从而导致不必要的重复调用组件的 render 方法。

至此,我们就完成了 posts 模块使用 Immutable.JS 的重构,其他模块的修改可参考源代码:/chapter-09/bbs-redux-immutable。

通过这些修改可以发现使用 Immutable.JS 也有一些缺点,例如,Immutable.JS 创建的对象难以和普通 JavaScript 对象混合使用;操作 Immutable.JS 对象也不是很便捷,必须使用其提供的 API 完成;Immutable.JS 对象由于其特殊的结构,因此调试起来也更为麻烦。当项目的 state 数据结构并不是很复杂时,使用 Immutable.JS 带来的性能提升并不显著,所以读者可根据项目实际情况有选择性地使用 Immutable.JS。

Reselect

我们知道,Redux state 的任意改变都会导致所有容器组件的 mapStateToProps 的重新调用,进而导致使用到的 selectors 重新计算。但 state 的一次改变只会影响到部分 selectors 的计算值,只要这个 selector 使用到的 state 的部分未发生改变,selector 的计算值就不会发生改变,理论上这部分计算时间是可以被节省的。

先来看一下之前用来获取帖子列表的 selector:

export const getPostListWithAuthors = state => {
  const allIds = getPostIds(state);
  return allIds.map(id => {
    const post = getPostById(state, id);
    return post.merge({ author: getUserById(state, post.get("author")) });
  });
};

getPostListWithAuthors 需要根据 posts 模块和 users 模块的 state 计算出容器组件 PostList 所需格式的对象。当其他模块的 state 发生改变时,例如 ui 模块的编辑框状态发生变化,PostList 的 mapStateToProps 会被重新调用,getPostListWithAuthors 也就被重新调用,但这种场景下,getPostListWithAuthors 的计算结果并不会发生改变,完全可以不必重新计算,而是直接使用上次的计算结果即可。

Reselect 正是用来解决这类问题的。Reselect 可以创建具有记忆功能的 selectors,当 selectors 计算使用的参数未发生改变时,不会再次计算,而是直接使用上次缓存的计算结果。

现在,我们把 getPostListWithAuthors 改造成具有记忆功能的 selector。首先,需要安装 reselect 库:

npm install reselect

Reselect 提供了一个函数 createSelector 用来创建具有记忆功能的 selector。createSelector 的定义如下:

createSelector([inputSelectors], resultFunc)

它接收两个参数,第一个参数 [inputSelectors] 是数组类型,数组的元素是 selector,第二个参数 resultFunc 是一个转换函数,[inputSelectors] 中每一个 selector 的计算结果都会作为参数传递给 resultFunc。createSelector 的返回值是一个具有记忆功能的 selector,这个 selector 每次被调用时,使用运算符(===)判断 [inputSelectors] 中的 selector 计算结果相较前一次是否发生变化,如果所有的 selector 计算结果都没有变化,就直接返回前一次的计算结果。改造后的 getPostListWithAuthors 如下:

export const getPostListWithAuthors = createSelector(
  [getPostIds, getPostList, getUsers],
  (allIds, posts, users) => {
    return allIds.map(id => {
      let post = posts.get(id);
      return post.merge({ author: users.get(post.get("author")) });
    });
  }
);

现在,只要 getPostIds、getPostList 和 getUsers 的返回值不变(本质上是 posts 和 users 模块的 state 没有改变),getPostListWithAuthors 就不会重新计算。selector 的计算逻辑越复杂,Redux 全局 state 的改变频率越高,Reselect 带来的性能提升就越大。另外请注意,如果一个 selector 并不执行任何计算逻辑,只是单纯地从 state 中读取属性值,例如 posts 模块中的 getPostIds 和 getPostList,就没有必要使用 Reselect 进行改造。本节项目源代码的目录为 /chapter-09/bbs-redux-reselect。