「性能优化」之 React Loadable 上篇| 8月更文挑战

1,095 阅读10分钟

这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战

react-lodable.png

A higher order component for loading components with dynamic imports.

用于加载具有动态导入的组件的高阶组件。

React Loadable 使「以组件为中心的代码分割」变得非常容易,通常用来做动态加载。

「动态加载」这个话题其实诞生已久,现在的日常开发中,也是必不可少的一环,它对线上应用的性能有着至关重要的作用!

通常 React 应用会使用 Webpack 进行构建,但随着项目内容的丰富我们会发现项目包越来越大,项目运行越来越慢,这个时候就需要对代码进行拆分了。

为了更好地了解 React Loadable,我们会先从Webpack聊起。

从Webpack说起

我们知道,webpack 内置了对动态 import 的支持,下面是一个最基础的 demo:

// a.js
import('./b')

// b.js
console.log('i am b')

// webpack.config.js
const path = require('path')
const resolve = p => path.resolve(__dirname, p)

module.exports = {
    mode: 'development',
    entry: resolve('a.js'),
    output: {
        filename: 'bundle.js',
        path: resolve('dist')
    }
}

上述代码在执行 webpack 打包后,在 dist 文件夹中生成的目录树如下:

image.png

从打包结果可以看到:webpack 会对动态引入的文件单独打成一个新的子包。

这样就实现了Webpack中最基本的「组件动态加载」。

接下来进入今天的主题 ---- React Loadable是如何实现组件动态加载的?

React Loadable的使用

深入学习这个库之前,我们需要先了解它的用法,因为你可能还没有在项目中用 React Loadable 做过动态加载。

官方文档中给出了一个比较简单易懂的 DEMO :

import React from 'react'
import Lodable from 'react-lodable'
import Loading from '@common/loading' // Loading 组件

const Footer = Loadable({
    // 当前业务中期望被懒加载的组件
    loader: () => import('@service/Footer'),
    loading: Loading,
})

export const App = () => {
    return (
        <div>
            <Footer />
        <div>
    )
}

上面短短几行代码就完成了一个最简单的 React Loadable 的应用。

大家有没有发现原本我们是可以用webpack的动态 import 特性import('@service/Footer') 就可以实现动态加载,而现在使用 React Loadable 实现的动态加载还需要把 import('@service/Footer') 嵌套在一个对象中

看到这里,大家可能会猜 React Loadable 应该是做了什么”手脚“,封装了一些 Loading 之类的功能。

想要知道它到底做了哪些”手脚“,带着我们的推测往下看看它的源码中都做了什么?

先提问题

解析 React Loadable 源码之前我们提两个问题,带着问题去理解代码。

  • 前面我们了解到 webpack 已经提供了成熟的动态加载方案,那 React Loadable 存在的意义又是什么呢?

  • 在服务端渲染(SSR)的场景下做动态加载有什么需要注意的吗?React Loadable 在这其中会做些什么呢?

后文中我们会按照使用场景「CSR & SSR」逐个分析

前言

写在前面:React Loadable 内部使用了一个封装的小套路。

为了便于大家更好地理解这个所谓的「小套路」是什么,我们先来举个栗子🌰:

function load(delayTask) {
    const state = {
        loaded: false,
        result: null,
        promise: null,
    }
    
    const Defer = delayTask()
        .then(value => {
            state.loaded = true
            state.result = value
        }).catch(err => {
            state.loaded = true
            state.result = value
        })
        
    state.promise = Defer
    
    return state
}

你看出这段代码中有什么「小套路」了吗?

load 函数接收了一个异步任务「delayTask」参数,在函数内部定义了一个 对象 state,最终返回了这个 state 对象。

  • 在 state 上,绑定了 delayTask 任务的一些相关信息,包括加载状态、执行结果、异步执行的 promise。
  • 因为 load 函数最终会返回 state 对象,所以我们只需要在某个时刻去看返回的 promise 状态就可以判断任务是否完成。
  • 另外,我们可以直接获取到任务的执行结果及其他信息。
const state = load(() => {/* do something */})

function check() {
    const { loaded, result } = state
    if (loaded) {
        console.log(result)
    }
}

// 时刻 A
check()

// 时刻 B
check()

在 state.promise「即Defer」 中会在某个时刻修改 state.loaded,因此我们每次通过 state.loaded 获取的都是最新的状态值。

这其中利用了 JS 中传递的对象是引用类型的值,始终可以通过这个引用获取到最新的值的原理。

React Loadable中的「小套路」也是同样的:把异步任务对应的Promise、执行结果、加载状态等封装在一个对象中,通过这个对象就可以获取到异步任务的所有信息

回归主题,下面我们就接着看看 React Loadable 在两种不同的场景下分别是如何实现动态加载的?

CSR场景

CSR,即客户端渲染。

还记得文章开头提供的官方demo吗?那其实就是一个典型的 CSR 使用场景下的例子。

下面我们一起看看在 CSR 场景下 React Loadable 的一些核心代码吧⤵️

❓❓❓React Loadable 是怎么应用这个套路的呢?

先看一段源码👇

function Loadable(options) {
    let res = null
    
    function init() {
        if (!res) {
            // loadFn() 将加载「动态引入模块」的结果 赋给 res
            res = loadFn(options.loader)
        }
        
        return res.promise
    }

    ALL_INITIALIZERS.push(init)

    if (typeof options.webpack === 'function') {
        READY_INITIALIZERS.push(() => {
            if (isWebpackReady(options.webpack)) {
                return init()
            }
        })
    }

    return class LoadableComponent extends React.Component {
        // ...
    }
}

TIPS:代码中 ALL_INITIALIZERS 和 READY_INITIALIZERS 的部分我们会在后面讲解😏

  • 这段代码正是套用了前面说的「小套路」:先定义一个 res 变量,在 init 函数的内部把 loadFn 的结果赋值给 res ,然后返回 res 上的 promise 。

    因此我们在后文就可以这么去做:

const promise = init()
promise.then(() => {
    // ...
})
  • Loadable 函数最终返回了一个 React 组件「LoadableComponent」,因此在第一个 React Loadable 应用的 DEMO 中我们可以直接将传入 react-loadable 函数的结果作为一个组件去使用。
const Footer = Loadable({
    loader: () => import('@service/Footer'),
    loading: Loading,
})

export const App = () => {
    return (
        <div>
            <Footer />
        </div>
    )
}

❓❓❓那返回的这个 LoadableComponent 组件内部都做了什么呢?

我们把这个组件一一展开:

  • 首先,constructor 部分👇
constructor(props) {
      super(props);
      init();  // 还记得前面我们定义过的 init 函数吗?是的,就是它!

      this.state = {
        error: res.error,
        pastDelay: false,
        timedOut: false,
        loading: res.loading,
        loaded: res.loaded
      };
}
  • 在 constructor  中会执行前面提到过的 init 函数,然后会在 state 上绑定一些参数,包括 error、timedOut、loading 等。

  • 接下来,看看 init 函数里都干了些什么?(O_O)

    init 函数的本质是一个 load 函数,接收的参数是传入 Loadable 中的 loader(也是一个函数),并且这个函数可以返回动态引入的 Footer 组件(即需要被动态加载的组件)。

function load(loader) {
  let promise = loader();

  let state = {
    loading: true,
    loaded: null,
    error: null
  };

  state.promise = promise
    .then(loaded => {
      state.loading = false;
      state.loaded = loaded;
      return loaded;
    })
    .catch(err => {
      state.loading = false;
      state.error = err;
      throw err;
    });

  return state;
}

上面的 load 函数是不是看起来非常熟悉?

没错!这里也用到了「小套路」:先创建一个对象,然后把加载的状态、promise 都添加到该对象上。

  • 代码中首先执行了 loader 函数,并把执行结果赋值给 promise 变量,也就是说 这里的 promise 其实就是动态引入的 Footer 组件 。
  • 然后创建一个 state 对象,并给 state 对象绑定了一个 promise 参数,后面会在 promise 回调中修改 state 中 loading、loaded、error 等相关参数的值。
  • 最后,返回这个 state 对象。

由此得出结论:在 LoadableComponent 组件初始化(即constructor)时就会去加载 Footer 组件。

❓❓❓ LoadableComponent 除了上面这些,还做了什么呢?

继续看源码👇

componentWillMount() {
  this._loadModule()
}

render() {
  if (this.state.loading || this.state.error) {
    return React.createElement(opts.loading, {
      isLoading: this.state.loading,
      pastDelay: this.state.pastDelay,
      timedOut: this.state.timedOut,
      error: this.state.error,
      retry: this.retry
    });
  } else if (this.state.loaded) {
    return opts.render(this.state.loaded, this.props);
  } else {
    return null;
  }
}
  • 首先,组件渲染前,先执行了this._loadModule

    这里从函数名可以猜到:这应该是用来加载传入的动态加载组件的。详情我们后文也会讲到😛

  • 然后,看render  部分,代码分成了三个岔路:

    • 加载中或加载失败时,渲染传入到 React Loadable 的 Loading 组件,并将 isLoading、error、retry 等作为参数传入
    • 加载完毕,渲染真正需要动态加载的组件
    • 其他情况,返回 null

这里返回 null 的目的是,我们可能在某些场景下不需要显示 Loading,也不需要显示真正的组件。

⚠️ 敲黑板啦 ⚠️

思考🤔一下:加载中或加载失败的状态时,传入了一个 retry 参数 ,那这个 retry 是做什么用的呢?

retry = () => {
  this.setState({ error: null, loading: true, timedOut: false });
  res = loadFn(opts.loader);
  this._loadModule();
};
  • retry 是一个方法,用来做重新加载的。
  • 执行这个方法:
    • 首先会将 error、loading、timedOut 的值重置

    • 然后会重新加载真正的组件

    • 最后执行 this._loadModule 函数。

到这里,我们已经知道了 在 componentWillMount 和 retry 中都会执行this._loadModule()。

❓❓❓ this._loadModule() 这个方法中都做了什么?它和 loadFn(opts.loader) 做的事情有什么关系呢?

这个部分,我们来看看 this._loadModule() 的源码👇

  • 首先对 res.loading 做判断:如果已经加载完毕,直接退出。
_loadModule() {
  if (!res.loading) {  // 如果已经加载完毕,直接退出。
    return;
  }
}
  • 接着定义一个 setStateWithMountCheck 方法,如果已经渲染完毕,就更新传入的值。
_loadModule() {
  // 检查 loading 状态

  let setStateWithMountCheck = (newState) => {
    if (!this._mounted) {  // 如果已经渲染完毕,就更新传入的值。
      return;
    }

    this.setState(newState);
  }
}
  • 然后在哪会调用这个 setStateWithMountCheck 方法呢?
_loadModule() {
  // 检查 loading 状态
  // 定义 setStateWithCheck 方法
  if (typeof opts.delay === 'number') {
        if (opts.delay === 0) {
          this.setState({ pastDelay: true });
        } else {
          this._delay = setTimeout(() => {
            setStateWithMountCheck({ pastDelay: true });
          }, opts.delay);
        }
  }

  if (typeof opts.timeout === "number") {
      this._timeout = setTimeout(() => {
          setStateWithMountCheck({ timedOut: true });
      }, opts.timeout);
  }
}

这里主要判断是否传入了 delay 、timeout 参数

这两个参数最终都会被传入到 Loading 组件,如果我们传入的 Loading 组件只是单纯的菊花图,那么这些参数可能都用不到。

所以,它们的作用就是对 Loading 组件作更细分的判断场景的渲染。

  • 最后更新状态
_loadModule() {
  // 检查 loading 状态
  // 定义 setStateWithCheck 方法
  // 判断 delay、timeout 参数

  let update = () => {
    setStateWithMountCheck({
      error: res.error,
      loaded: res.loaded,
      loading: res.loading
    });

    this._clearTimeouts();
  };

  res.promise
      .then(() => {
          update();
          return null;
      })
      .catch(err => {
          update();
          return null;
      });
}
  • 这一段代码首先定义了 update 方法,目的是将 res 对象的各种状态更新到 state 中,最终触发 React 渲染。

    因为 res 只是普通的 JS 对象,更改 res 的属性不会触发页面更新。

我们已经看完了_loadModule() 的全部源码,那我们一起回顾下前面提的两个问题:

  1. _loadModule 这个方法里做了什么?
  2. _loadModule 和 loadFn(opts.loader)有什么关系?
  • 首先,loadFn(opts.loader) 会先检查是否已经存在 res,不存在则去加载真正的组件,并返回一个包含加载状态及结果的 res 对象。

  • 然后,_loadModule 会去判断 res 是否存在 delay、timeout 等参数,并且会在组件加载完毕(res.promise.then)时去将 res 的结果赋值给 state ,同时触发渲染。

  • 所以二者的关系是:先通过 loadFn 去创建加载组件的对象,然后 _loadModule 根据 loadFn 返回的对象判断加载结果,最终触发渲染。

CSR场景下的动态加载部分至此就全部讲完了,我们再回过头看一下前面开始那一段代码:

function Loadable(options) {
  let res = null

  function init() {
      if (!res) {
        res = loadFn(opts.loader);
      }
      return res.promise;
  }

  ALL_INITIALIZERS.push(init);

  // ...

  return class LoadableComponent exends React.Component {
    // ...
  }
}

前面我们说 ALL_INITIALIZERS 和 READY_INITIALIZERS 会在后面讲到,但直到 CSR 场景全部讲完,也没有解析到这一部分。

因为我们的示例代码是 CSR (客户端渲染)的场景,所以有些代码没有用到也实属正常。

所以,我们自然也能想到一定在另一种场景 SSR 中起到了一定的作用。

老规矩,我们还是先思考两个问题,带着问题看源码:

  1. 每个动态加载组件对应的 init 函数都会 push 到 ALL_INITIALIZERS 队列中,那么 ALL_INITIALIZERS 什么时候被调用的呢?
  2. 我们只插入到 ALL_INITIALIZERS 队列,但是似乎动态加载的工作一直也没有 ALL_INITIALIZERS 的参与。它都做了些什么呢?

我们在下篇中继续回答这个问题。