React Loadable - 以组件为中心的代码分割和懒加载

#1

by James Kyle
翻译:荆雷(下文括号里是译者的注释)

原文地址 https://medium.com/@thejameskyle/react-loadable-2674c59de178
译文地址 http://www.jianshu.com/p/697669781276

当你有一个很大的应用,把所有代码打成一个单一的包那么应用的启动时间会成问题(现在的前端打包工具在生产环境一般都是把所有 js 拼接压缩成一个长文件,css 拼接压缩成一个长文件,配合 source map 发到用户的浏览器。如果项目使用到的包很多,这个单一的 js 文件会很大,严重延迟网页的首次加载时间)。你需要开始分拆你的应用到分离的包,然后在需要的时候动态加载。

(上图注意深色阴影部分,左边的阴影是连成一块,右边的是分成了小块。作者作图的时候估计没有考虑到很多红绿色盲的人。不过红绿色盲的话估计也不好做前端)

如何分割一个包到多个是打包工具诸如 Browserify 和 Webpack 等早就很好解决了的问题。

但是现在你需要找出你的应用里可以分割到其他包并动态加载的部分。你也需要在你的应用里找到一个当有组件在加载时的交流方式(估计指 loading 组件)。

基于路由的分割 vs 基于组件的分割

你会遇到的一个常规建议是把你的应用分成多个路由,然后一个个异步加载。这种方式似乎对大多数应用有作用,点击一个链接然后加载一页新的页面并不是一个太差的体验。

但是我们可以做得比这个更好。

React 的大多数路由工具都是一个简单的组件。没有什么特别的。所以如果我们优化组件而不是把责任交给路由会给我们带来什么?(指在组件层动态优化而不是传统的动态加载路由)

(上图可以看到,路由分割的粒度还是比较大,一个路由就是一条系列组件,而组件的分割更细,在路由里还可以细分)

效果是显著的。除了路由之外,有太多的地方你可以轻松地分割你的应用。例如 modals、tabs,还有其他更多的隐藏 UI 组件,这些都是直到用户的某些交互完成后才展现的(所以适合延迟加载)。

更别提其他所有在更高优先级内容加载完成后才会延迟加载的部分。例如在你的页面最底部有的组件需要加载一堆包(虽然组件本身可能不大,但是这些底部组件可能引入一些很大的第三方包):为什么这些需要和顶部的组件一起加载呢?

你也可以继续轻松地分割路由,因为路由也仅仅是组件而已。怎么对你的应用最好怎么做。

但是我们需要使在组件层面分割和路由层面分割一样容易。分割一块新位置应该简单到改变应用的几行代码一样容易,其他剩余的工作应该是自动化的。

React Loadable 介绍

React Loadable 是一个我受够了人们说这很难办到之后写的小库。

Loadable 是一个高阶组件(简单来说,就是把组件作为输入的组件。高阶函数就是把函数作为输入的函数。在 React 里,函数和组件有时是一回事),一个可以构建组件的函数(函数可以是组件),它可以很容易的在组件层面分割代码包。

让我们想象两个组件,一个引入另外一个并渲染。

import AnotherComponent from './another-component';
class MyComponent extends React.Component {
  render() {
    return <AnotherComponent/>;
  }
}

现在我们依赖由 import 同步引入的 AnotherComponent。我们需要一种使它异步加载的方法。

使用 dynamic import,一种 tc39 标准的提案,能够修改我们的组件来异步加载 AnotherComponent

class MyComponent extends React.Component {
  state = {
    AnotherComponent: null
  };

  componentWillMount() {
    import('./another-component').then(AnotherComponent => {
      this.setState({ AnotherComponent });
    });
  }
  
  render() {
    let {AnotherComponent} = this.state;
    if (!AnotherComponent) {
      return <div>Loading...</div>;
    } else {
      return <AnotherComponent/>;
    };
  }
}

然而,这种方式有很多手动工作,而且它并不能处理很多不同的场景。例如如果 import() 失败怎么办?服务器端渲染怎么处理?

你可以使用 Loadable 来抽象地解决这个问题。使用 Loadable 很简单。所有你需要做的就是传入一个加载组件的函数,和一个当你的组件在加载时提示用户来占位显示 “Loading” 状态的组件

import Loadable from 'react-loadable';

function MyLoadingComponent() {
  return <div>Loading...</div>;
}

const LoadableAnotherComponent = Loadable({
  loader: () => import('./another-component'),
  LoadingComponent: MyLoadingComponent
});

class MyComponent extends React.Component {
  render() {
    return <LoadableAnotherComponent/>;
  }
}

但是如果组件加载失败怎么办?我们还需要一个错误状态。

为了让你最大限度控制显示什么内容,error 会作为一个 prop 传入你的 LoadingComponent

function MyLoadingComponent({ error }) {
  if (error) {
    return <div>Error!</div>;
  } else {
    return <div>Loading...</div>;
  }
}

import() 自动代码分割

关于 import() 最好的事情就是 Webpack 2 能够在你引入了一个新的模块之后为你自动分割你的代码,而不需要任何额外的工作。

这意味只需要通过切换到 import() 并使用 React Loadable,你可以很容易实验新的代码分割点,来弄清楚在你的应用上怎么处理表现最好。

你可以查看这个例子项目,或者阅读 Webpack 2 的文档。注意一些相关的文档也在 require.ensure() 部分

避免加载组件时的闪烁

有时组件加载非常快,小于 200ms,提示加载的组件会在界面上一闪而过。

一些用户调研表明这会导致用户感知事情发生(组件加载)的时间比真实的更长。如果你什么都不显示,那么用户对加载的感知反而觉得更快。

所以你的 loading 组件(就是在真正要用的组件加载完成之前显示的提示组件)有一个 pastDelay prop,只有在真正用到的组件花了比设定的 delay更长的时间加载的时候,它的值才会是 true (才会显示提示的 loading 组件)。

export default function MyLoadingComponent({ error, pastDelay }) {
  if (error) {
    return <div>Error!</div>;
  } else if (pastDelay) {
    return <div>Loading...</div>;
  } else {
    return null;
  }
}

delay 的默认值是 200ms,但你也可以使用第三个参数来设置 delay 时长 。

Loadable({
  loader: () => import('./another-component'),
  LoadingComponent: MyLoadingComponent,
  delay: 300
});

Preloading

作为一种优化手段,你也可以在一个组件被渲染之前预加载它。

例如,如果你需要一个按钮被点击时加载一个新的组件,你可以在这个用户把鼠标 hover 到这个按钮之上时就开始预加载这个组件。

Loadable 构建的组件会开放一个 preload 静态方法刚好做到这点(指预加载)。

let LoadableMyComponent = Loadable({
  loader: () => import('./another-component'),
  LoadingComponent: MyLoadingComponent,
});

class MyComponent extends React.Component {
  state = { showComponent: false };

  onClick = () => {
    this.setState({ showComponent: true });
  };

  onMouseOver = () => {
    LoadableMyComponent.preload();
  };

  render() {
    return (
      <div>
        <button onClick={this.onClick} onMouseOver={this.onMouseOver}>
          Show loadable component
        </button>
        {this.state.showComponent && <LoadableMyComponent/>}
      </div>
    )
  }
}

服务端渲染

Loader 也可以通过最后一个参数来支持服务端渲染。

运行在服务器上时,把需要动态加载的模块的确切地址传入,可以让 Loader 同步 require() 这个模块。

import path from 'path';

const LoadableAnotherComponent = Loadable({
  loader: () => import('./another-component'),
  LoadingComponent: MyLoadingComponent,
  delay: 200,
  serverSideRequirePath: path.join(__dirname, './another-component')
});

这意味着你的异步加载代码分割包可以在服务端同步渲染。(服务端不需要异步,因为都是提前在服务器上渲染完成)

现在问题回到了客户端接收加载的包。我们可以在服务端把应用全部渲染好(估计这里指所有分割的包),然后在客户端我们按需加载一个一个包。

但是如果我们能找出那些包需要在服务端事先打包好,然后我们可以一次性发送这些包到客户端,客户端得到和服务端渲染一样的状态。(这里是直译。我认为主要是指把前端需要的分割好的包一次性发过去。这里表面上和动态异步加载矛盾,因为是打成一个整体的包发送到客户端,但是这个包不是客户端渲染时用到的 js 包,而是渲染好的
html string,这样可以节省客户端和服务器来回发送消息的次数,还有前端加载 js 再渲染的时间。服务器端渲染的好处是相比较客户端渲染不需要加载庞大的 js 包,下载的 js 文件会小很多。但是服务端渲染实现起来比较麻烦)

事实上今天你几乎可以做到这点。

因为在 Loadable 中我们拥有服务器端 requires 的组件的所有路径,所以我们可以添加一个新的 flushServerSideRequires 函数,返回服务器端渲染的所有组件路径。然后使用webpack --json,我们可以将文件与分割的包匹配在一起。你可以在这里看到我的代码

现在唯一剩下的问题就是需要 Webpack 在客户端上好好表现了。发布这个之后,我会等待你的消息的,Sean。(应该是隔空喊话 Webpack 核心开发者之一 Sean Larkin)

一旦这一切都很好地整合,我们可以开发各种各样吊炸天的东西。 React Fiber 将使我们能够更加智能地分清哪些是我们想要立即发送的包,那些是我们直到更高优先级的工作完成之后推迟发送的包。

最后,请安装这货,再给我的 github repo 一颗星星。

#2

webpack2结合react-router4已经可以很好的支持组件分割加载了,router4官网也有代码展示。

#3

我好像没找到啊