21 项优化 React App 性能的技术

115 阅读12分钟

image.png

介绍

在 React 内部,React 会使用几项巧妙的小技术,来优化计算更新 UI 时,所需要的最少的更新 DOM 的操作。在大多数情况下,即使你没有针对性能进行专项优化,React 依然很快,但是仍有一些方法可以加速 React 应用程序。本文将介绍一些可用于改进 React 代码的有效技巧。

1.使用不可变数据结构

数据不变性不是一种架构或者设计模式,它是一种编程思想。它会强制您考虑如何构建应用程序的数据流。在我看来,数据不变性是一种符合严格单项数据流的实践。

数据不变性,这一来自函数式编程的概念,可应用于前端应用程序的设计。它会带来很多好处,例如:

  • 零副作用
  • 不可变的数据对象更易于创建,测试,和使用;
  • 利于解耦;
  • 更加利于追踪变化;

在 React 环境中,我们使用 Component 的概念来维护组件内部的状态,对状态的更改可以导致组建的重新渲染。

React 构建并在内部维护呈现的UI(Virtual DOM)。当组件的 props 或者 state 发生改变时,React 会将新返回的元素与先前呈现的元素进行比较。当两者不相等时,React 将更新 DOM。因此,在改变状态时,我们必须要小心。

让我们考虑一个用户列表组件:

state = {
   users: []
}

addNewUser = () =>{
   /**
    *  OfCourse not correct way to insert
    *  new user in user list
    */
   const users = this.state.users;
   users.push({
       userName: "robin",
       email: "email@email.com"
   });
   this.setState({users: users});
}

这里的关注点是,我们正在将新的用户添加到变量 users ,这里它对应的引用是 this.state.users

专业提示 : 应该将 React 的状态视为不可变。我们不应该直接修改 this.state,因为 setState() 之后的调用可能会覆盖你之前的修改。

那么,如果我们直接修改 state 会产生什么问题呢?比方说,我们添加 shouldComponentUpdate ,并对比 nextState 和 this.state 来确保只有当数据改变时,才会重新渲染组件。

shouldComponentUpdate(nextProps, nextState) {
    if (this.state.users !== nextState.users) {
        return true;
    }
    return false;
}

即使用户的数组发生了改变,React 也不会重新渲染UI了,因为他们的引用是相同的。

避免此类问题最简单的方法,就是避免直接修改 props 和 state。所以,我们可以使用 concat 来重写 addNewUser 方法:

addNewUser = () => {
   this.setState(state => ({
     users: state.users.concat({
       timeStamp: new Date(),
       userName: "robin",
       email: "email@email.com"
     })
   }));
};

为了处理 React 组件中 props 或者 state 的改变,我们可以考虑一下几种处理不可变的方法:

  • 对于数组:使用 [].concat 或es6的 [ ...params]
  • 对象:使用 Object.assign({}, ...) 或 es6的 {...params}

在向代码库引入不变性时,这两种方法有很长的路要走。

但是,最好使用一个提供不可变数据结构的优化库。以下是您可以使用的一些库:

  • Immutability Helper:这是一个很好的库,他可以在不改变源的情况下,提供修改后的数据。
  • Immutable.js :这是我最喜欢的库,因为它提供了许多持久不可变的数据,包括:ListStackMapOrderedMapSetOrderedSet 和 Record
  • Seamless-immutable:一个用于提供不可变 JavaScript 数据结构的库,他与普通的数组和对象向后兼容。
  • React-copy-write:一个不可变的React状态管理库,带有一个简单的可变API,memoized选择器和结构共享。

专业提示: React setState 方法是异步的。这意味着,setState() 方法会创建一个带转换的 state, 而不是立即修改 this.state。如果在调用setState() 方法之后去访问 this.state ,则可能会返回现有值。为防止这种情况,请setState 在调用完成后使用回调函数运行代码。

其他资源:

2.函数/无状态组件和 React.PureComponent

在 React 中,函数组件和 PureComponent 提供了两种不同级别的组件优化方案。

函数组件防止了构造类实例,
同时函数组件可以减少整体包的大小,因为它比类组件的的体积更小。

另一方面,为了优化UI更新,我们可以考虑将函数组件转换为 PureComponent 类组件(或使用自定义 shouldComponentUpdate 方法的类)。但是,如果组件不使用状态和其他生命周期方法,为了达到更快的的更新,首次渲染相比函数组件会更加复杂一些。

译注:函数组件也可以做纯组件的优化:React.memo(...) 是 React v16.6 中引入的新功能。 它与 React.PureComponent 类似,它有助于控制 函数组件 的重新渲染。 React.memo(...) 对应的是函数组件,React.PureComponent 对应的是类组件。React Hooks 也提供了许多处理这种情况的方法:useCallback, useMemo。推荐两个延伸阅读:
A Closer Look at React Memoize Hooks: useRef, useCallback, and useMemo ,React Hooks API ⎯ 不只是 useState 或 useEffect

我们应该何时使用 React.PureComponent

React.PureComponent 对状态变化进行浅层比较(shallow comparison)。这意味着它在比较时,会比较原始数据类型的值,并比较对象的引用。因此,我们必须确保在使用 React.PureComponent 时符合两个标准:

  • 组件 State / Props 是一个不可变对象;
  • State / Props 不应该有多级嵌套对象。

专业提示:  所有使用 React.PureComponent 的子组件,也应该是纯组件或函数组件。

3.生成多个块文件

Multiple Chunk Files

您的应用程序始终以一些组件开始。您开始添加新功能和依赖项,最终您会得到一个巨大的生产文件。

您可以考虑通过利用 CommonsChunkPlugin for webpack 将供应商或第三方库代码与应用程序代码分开,生成两个单独的文件。你最终会得到 vendor.bundle.js 和 app.bundle.js 。通过拆分文件,您的浏览器会缓存较少的资源,同时并行下载资源以减少等待的加载时间。

注意:  如果您使用的是最新版本的webpack,还可以考虑使用 SplitChunksPlugin

4.在 Webpack 中使用 Production 标识生产环境

如果您使用 webpack 4 作为应用程序的模块捆绑程序,则可以考虑将 mode 选项设置为 production 。这基本上告诉 webpack 使用内置优化:

module.exports = {
    mode: 'production'
};

或者,您可以将其作为 CLI 参数传递:

webpack --mode=production

这样做会限制对库的优化,例如缩小或删除开发代码。它不会公开源代码,文件路径等等

5.依赖优化

在考虑优化程序包大小的时候,检查您的依赖项中实际有多少代码被使用了,会很有价值。例如,如果您使用 Moment.js 会包含本地化文件的多语言支持。如果您不需要支持多种语言,那么您可以考虑使用 moment-locales-webpack-plugin 来删除不需要的语言环境。

另一个例子是使用 lodash 。假设你使用了 100 多种方法的 20 种,那么你最终打包时其他额外的方法都是不需要的。因此,可以使用 lodash-webpack-plugin 来删除未使用的函数。

以下是一些使用 Webpack 打包时可选的依赖项优化列表

6. React.Fragments 用于避免额外的 HTML 元素包裹

React.fragments 允许您在不添加额外节点的情况下对子列表进行分组。

class Comments extends React.PureComponent{
    render() {
        return (
            <React.Fragment>
                <h1>Comment Title</h1>
                <p>comments</p>
                <p>comment time</p>
            </React.Fragment>
        );
    } 
}

等等!我们还可以使用更加简洁的语法代替 React.fragments :

class Comments extends React.PureComponent{
    render() {
        return (
            <>
                <h1>Comment Title</h1>
                <p>comments</p>
                <p>comment time</p>
            </>
        );
    } 
}

7.避免在渲染函数中使用内联函数定义

由于在 JavaScript 中函数就是对象({} !== {}),因此当 React 进行差异检查时,内联函数将始终使 prop diff 失败。此外,如果在JSX属性中使用箭头函数,它将在每次渲染时创建新的函数实例。这可能会为垃圾收集器带来很多工作。

default class CommentList extends React.Component {
    state = {
        comments: [],
        selectedCommentId: null
    }

    render(){
        const { comments } = this.state;
        return (
           comments.map((comment)=>{
               return <Comment onClick={(e)=>{
                    this.setState({selectedCommentId:comment.commentId})
               }} comment={comment} key={comment.id}/>
           }) 
        )
    }
}

您可以定义箭头函数,而不是为 props 定义内联函数。

default class CommentList extends React.Component {
    state = {
        comments: [],
        selectedCommentId: null
    }

    onCommentClick = (commentId)=>{
        this.setState({selectedCommentId:commentId})
    }

    render(){
        const { comments } = this.state;
        return (
           comments.map((comment)=>{
               return <Comment onClick={this.onCommentClick} 
                comment={comment} key={comment.id}/>
           }) 
        )
    }
}

8. JavaScript 中事件的防抖和节流

事件触发率代表事件处理程序在给定时间内调用的次数。

通常,与滚动和鼠标悬停相比,鼠标点击具有较低的事件触发率。较高的事件触发率有时会使应用程序崩溃,但可以对其进行控制。

我们来讨论一些技巧。

首先,明确事件处理会带来一些昂贵的操作。例如,执行UI更新,处理大量数据或执行计算昂贵任务的XHR请求或DOM操作。在这些情况下,防抖和节流技术可以成为救世主,而不会对事件监听器进行任何更改。

节流

简而言之,节流意味着延迟功能执行。因此,不是立即执行事件处理程序/函数,而是在触发事件时添加几毫秒的延迟。例如,这可以在实现无限滚动时使用。您可以延迟 XHR 调用,而不是在用户滚动时获取下一个结果集。

另一个很好的例子是基于 Ajax 的即时搜索。您可能不希望每次按键时,都会请求服务器获取新的数据,因此最好节流直到输入字段处于休眠状态几毫秒之后,再请求数据。

节流可以通过多种方式实现。您可以限制触发的事件的次数或延迟正在执行的事件来限制程序执行一些昂贵的操作。

防抖

与节流不同,防抖是一种防止事件触发器过于频繁触发的技术。如果您正在使用 lodash ,则可以使用 lodash’s debounce function 来包装你的方法。

这是一个搜索评论的演示代码:

import debouce from 'lodash.debounce';

class SearchComments extends React.Component {
 constructor(props) {
   super(props);
   this.state = { searchQuery: “” };
 }

 setSearchQuery = debounce(e => {
   this.setState({ searchQuery: e.target.value });

   // Fire API call or Comments manipulation on client end side
 }, 1000);

 render() {
   return (
     <div>
       <h1>Search Comments</h1>
       <input type="text" onChange={this.setSearchQuery} />
     </div>
   );
 }
}

如果您不使用 lodash,可以使用简单版的防抖函数。

function debounce(a,b,c){var d,e;return function(){function h(){d=null,c||(e=a.apply(f,g))}var f=this,g=arguments;return clearTimeout(d),d=setTimeout(h,b),c&&!d&&(e=a.apply(f,g)),e}}

9.避免在 map 方法中使用 Index 作为组件的 Key

在渲染列表时,您经常会看到索引被用作键。

{
    comments.map((comment, index) => {
        <Comment 
            {..comment}
            key={index} />
    })
}

但是使用 index
作为 key, 被用在React虚拟DOM元素的时候,会使你的应用可能出现错误的数据 。当您从列表中添加或删除元素时,如果该 key 与以前相同,则 React虚拟DOM元素表示相同的组件。

始终建议使用唯一属性作为 key,或者如果您的数据没有任何唯一属性,那么您可以考虑使用shortid module 生成唯一 key 的属性。

import shortid from  "shortid";
{
   comments.map((comment, index) => {
       <Comment 
           {..comment}
           key={shortid.generate()} />
   })
}

但是,如果数据具有唯一属性(例如ID),则最好使用该属性。

{
   comments.map((comment, index) => {
       <Comment 
           {..comment}
           key={comment.id} />
   })
}

在某些情况下,将 index 用作 key 是完全可以的,但仅限于以下条件成立时:

  • 列表和子元素是静态的
  • 列表中的子元素没有ID,列表永远不会被重新排序或过滤
  • 列表是不可变的

10.避免使用 props 来初始化 state (直接赋值)

image.png

20.考虑服务端渲染

服务端渲染的好处之一是为用户提供更好的体验,相比客户端渲染,用户会更快接受到可查看的内容。

近年来,像沃尔玛和Airbnb会使用 React 服务端渲染来为用户提供更好的用户体验。然而,在服务器上呈现拥有大数据,密集型应用程序很快就会成为性能瓶颈。

服务器端渲染提供了性能优势和一致的SEO表现。现在,如果您在没有服务器端渲染的情况下检查React应用程序页面源,它将如下所示:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <link rel="shortcut icon" href="/favicon.ico">
    <title>React App</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="/app.js"></script>
  </body>
</html>

浏览器还将获取app.js包含应用程序代码的包,并在一两秒后呈现整个页面。

image.png 我们可以看到客户端渲染,在到达服务器之前有两次往返,用户可以看到内容。现在,如果应用程序包含API驱动的数据呈现,那么流程中会有一个暂停。

让我们考虑用服务器端渲染来处理的同一个应用程序:

image.png 我们看到在用户获取内容之前,只有一次访问服务器。那么服务器究竟发生了什么?当浏览器请求页面时,服务器会在内存中加载React并获取呈现应用程序所需的数据。之后,服务器将生成的HTML发送到浏览器,立即向用户显示内容。

以下是一些为React应用程序提供SSR的流行解决方案:

  • Next.js
  • Gatsby

21.在Web服务器上启用Gzip压缩

Gzip 压缩允许 Web 服务器提供更小的文件大小,这意味着您的网站加载速度更快。gzip 运行良好的原因是因为JavaScriptCSSHTML文件使用了大量具有大量空白的重复文本。由于gzip压缩常见字符串,因此可以将页面和样式表的大小减少多达70%,从而缩短网站的首次渲染时间。

如果您使用的是 Node / Express 后端,则可以使用 Gzipping 来解决这个问题。

const express = require('express');
const compression = require('compression');
const app = express();

// Pass `compression` as a middleware!
app.use(compression());

结论

有许多方法可以优化React应用程序,例如延迟加载组件,使用 ServiceWorkers 缓存应用程序状态,考虑SSR,避免不必要的渲染等等。也就是说,在考虑优化之前,值得了解React组件如何工作,理解 diff 算法,以及在React 中 render 的工作原理。这些都是优化应用程序时需要考虑的重要概念。

我认为没有测量的优化几乎都是为时过早的,这就是为什么我建议首先对性能进行基准测试和测量。您可以考虑使用 Chrome 时间线分析和可视化组件。这使您可以查看卸载,装载,更新哪些组件以及它们相对于彼此的时间。它将帮助您开始性能优化之旅。