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

803 阅读6分钟

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

react-lodable.png

A higher order component for loading components with dynamic imports.

上篇中我们已经介绍了 React Loadable 在 「CSR」客户端渲染场景下的源码部分,这一篇我们继续来讲在 SSR 场景下的源码部分,同时待着上篇中提出的问题来阅读 SSR 的部分:

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

SSR场景

SSR ,即服务端渲染。可以理解为:是在 CSR 的基础上加了一层服务端。

❓❓❓在 SSR 场景下,React Loadable 为什么就需要用到 ALL_INITIALIZERS 队列了呢?

讲 ALL_INITIALIZERS 的用处之前,我们先想想:SSR 的场景下直接使用前文中的示例代码会有什么问题吗?

const Footer = Loadable({
  loader: () => import('@service/Footer')
  loading: Loading,
})

export const App = () => {
  return (
    <div>
      <Footer />
    </div>
  )
}
  • 首先,我们需要将这个 App 渲染成 string。在渲染的过程中是同步的,所以 Footer 组件注定是还没有真实 DOM 的。

    为什么同步状态时 Footer 没有真实 DOM 呢?

    因为在同步状态时,Loadable 的返回值只是一个 LoadableComponent 组件,真正需要动态加载的组件 @service/Footer 是动态引入的,而动态引入的组件本身就需要在 promise 回调里才能获取到,所以 React 在 renderToString 的时候,Footer 没有真实的 DOM,只有 loading 组件的 DOM。

  • 另外,在这种情况下如果什么都不处理的话,返回到前端的首屏 HTML 中 Footer 的部分当然就只有一个 loading 的效果了,这显然不是我们想要的。

SSR 中 直接使用 CSR 中一样的动态加载的方式出现问题了,所以就需要解决这些问题。

我们试想:在 SSR 场景下,提前把组件渲染完毕,或者等待组件渲染完毕后再返回给前端是不是就可以了?

其实这句话里包含了两个方案:

  1. 当请求对应的懒加载组件时等待真实 DOM 渲染完毕再返回这个组件
  2. 在服务启动时把组件渲染完毕

   第一种方案其实存在两个问题:

  • 如果请求对应的组件时再去等待加载,就必定有一个等待的时间。
  • 其次如果有 n 个用户访问的页面都需要这个组件,那么每个用户都需要等待。

所以,我们排除了第一种方案。

React Loadable 也是采用了第二种方案的 😬

那它是怎么做的呢?

这里也会揭晓 ALL_INITIALIZERS 队列和 READY_INITIALIZERS 的作用。

我们先改造一下 客户端的代码:

window.main = () => {
  Loadable.preloadReady().then(() => {
    ReactDOM.hydrate(<App/>, document.getElementById('app'));
  });
};

原本我们直接 ReactDOM.hydrate 渲染 App 就可以了,但现在我们做了两件事:

  1. 将其封装在 main 函数中

    因为通过 React Loadable 把很多组件都做了动态加载,所以肯定需要等所有动态加载的组件都加载完毕后才能开始渲染,不然肯定会报错:'找不到 xx 组件'。

  2. 需要等待 Loadable.preloadReady() 执行完成后才能渲染

    我们参照上面两种 SSR 渲染方案,选择的第二种

服务启动时就需要把组件加载完毕,所以 preloadReady 就是这个作用。我们看一下代码:

Loadable.preloadReady = () => {
  return new Promise((resolve, reject) => {
    flushInitializers(READY_INITIALIZERS).then(resolve, resolve); 
  });
};
  • flushInitializers 方法的作用:遍历传入的任务队列并通过 Promise.all 批量执行,具体的下文会继续分析。
  • 可以看到 preloadReady 返回了一个 Promise,并且将 READY_INITIALIZERS队列传入。
  • 最后无论成功或失败,都会 resolve。

这里面我们从三个点去分析:

  1. flushInitializers 做了什么?

    我们看看 flushInitializers 的源码👇

    function flushInitializers(initializers) { 
      let promises = [];
    
      while (initializers.length) {
        let init = initializers.pop();
        promises.push(init());
      }
    
      return Promise.all(promises).then(() => {
        if (initializers.length) {
          return flushInitializers(initializers);
        }
      });
    }
    
    • 入参 initializers 是一个队列

    • 遍历整个队列,并依次添加到 promises 数组中

    • 通过 Promise.all 执行该数组,最终 resolve

但这里有一个问题,在执行 Promise.all() 前,看起来 initializers 一定为空。那为什么 Promise.all() 的回调里仍要判断 initializers.length 呢?

我们通过一个🌰 来解释一下吧:

function test(array) {
  console.log(array.length)
  setTimeout(() => {
    console.log(array.length)
  })
}

const arr = []
test(arr)
arr.push(1)  // 0 1
  • 为什么打印结果是0 、1呢?

    因为这个例子中传递的数组只是一个引用,当 arr.push(1) 的时候,test 函数内的 array 只是 arr 的一个引用,执行到 setTimeout 内部的时候,取 array 的值就等价于取 arr 的值,所以第二个打印的就是 1 了。

  • 回到问题本身,可以发现虽然 flushInitializers 在 while 循环时已经清空了 initializers ,但是 Promise.all()  执行过程中,可能又往 READY_INITIALIZERS 里 push 了内容。所以在回调之后仍然需要再次判断。

  • 和刚才的例子对比:initializers 就相当于 array,而 READY_INITIALIZERS 就相当于 arr。

    形参 initializers 其实指向的是 READY_INITIALIZERS,所以  Promise.all()   执行完任务时仍需要判断 initializers 是否为空。

  1. READY_INITIALIZERS 是什么?

    讲 CSR 的场景时,我们提到了 READY_INITIALIZERS:

    if (typeof opts.webpack === "function") {
        READY_INITIALIZERS.push(() => {
          if (isWebpackReady(opts.webpack)) {
            return init();
          }
        });
    }
    

    如果配置参数传入了 webpack,就会向队列中添加一个任务,当 WebpackReady 的时候就执行 init 函数进行加载组件。

那这个 WebpackReady 判断了什么呢?

function isWebpackReady(getModuleIds) {
  if (typeof __webpack_modules__ !== "object") {
    return false;
  }

  return getModuleIds().every(moduleId => {
    return (
      typeof moduleId !== "undefined" &&
      typeof __webpack_modules__[moduleId] !== "undefined"
    );
  });
}
  • webpack_modules  是 webpack 打包后产生的一个变量,上面绑定了很多方法。所以 isWebpackReady 先判断是否处于 webpack 环境。
  • getModuleIds 的返回值就是传入 React Loadable 时的 webpack 参数值,它需要是一个包含 moduleId 的数组,这些 moduleId 最终会被定义在  **webpack_modules ** 对象中。
  • 整体来看,isWebpackReady方法就是判断 webpack 是否加载完毕、指定的懒加载模块是否已被正确的定义了。

总结来说:READY_INITIALIZERS 就是存储在 ReactDOM render 前就必须加载好的任务。

为什么 preloadReady 无论成功还是失败都会 resolve(视为成功)呢?

  • 首先,大家要知道 preloadReady 的本质就是懒加载模块的动态引入是否成功:
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;
}
  • 另外,我们在使用 rl  时候传入了一个 Loading 组件,关于动态引入懒加载模块报错的逻辑,在 LoadableComponent 中也提到过了,再回顾一下:
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;
      }
}

从 render 函数的第一个判断条件,可以看到如果引入懒加载模块报错时,会进入渲染 Loading 组件的逻辑。

所以关于为什么 preloadReady 里成功或者失败都是 resolve 的原因就可以揭晓了:

因为加载失败会进入 Loading 组件的渲染,所以可以理解为这也是一种正常情况。

总结

React Loadable 这个库的设计还是非常巧妙的。

  • 利用了 webpack 支持动态 import 的特性
  • 把异步任务对应的Promise、执行结果、加载状态等封装在一个对象中,通过这个对象获取到异步任务的所有信息
  • 既支持 CSR ,也支持 SSR

欢迎大家交流分享!