React App 如何实现按需加载

目的

随着生产项目的持续功能迭代,前端代码体积会不断增加,导致加载时间越来越长。为了减少加载时间,我们可以拆分代码,让浏览器只加载当前功能所需要的代码(load on demand / lazy load)。这样在前端初始请求的时候,只发送部分代码,不需要长时间等待所有代码加载完成。在后续使用中,只有在需要某个功能模块的时候,才发出一个网络请求,把这部分需要的功能代码拉过来。

实现

为了实现动态加载,首先需要有模块系统的支持。虽然 ES6 语法支持动态加载模块,但是因为后续打包的时候会使用 babel 来处理代码,所以我们还需要引入一个插件来处理动态引入,也就是 @babel/plugin-syntax-dynamic-import

接下来我们需要在功能代码中动态地引入组件。这里是一个简单的 demo,当用户按下按钮的时候,发出一个请求,获取所需组件,然后渲染出来。

// Hello.js
import React from 'react';

export default (props) => {
  return (
    <div>
    hello
    </div>
  );
}

// App.js
class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
    };

    this.loadHello = () => {
      import('./Hello').then((Hello) => {
        this.setState({
          Hello: Hello.default,
        });
      });
    };
  }

  render() {
    const { Hello } = this.state;

    return (
      <div>
        <button onClick={this.loadHello}> load hello </button>
        { Hello && <Hello /> }
      </div>
    );
  }
}
beforeHello.png
afterHello.png

在源码中可以看到 Hello 组件是在按下按钮之后才被引入的。所以 webpack 打包的时候会把这个组件单独打包为一个chunk,只有当需要的时候才会发出网络请求被加载。

SplitChunksPlugin 基本配置

Webpack 4 里面有很多开箱即用的配置和优化,SplitChunksPlugin 也是其中默认的一个optimization plugin。这个 plugin 可以提取出公共模块,避免重复加载代码。

首先了解一下这个插件的默认配置:

官方文档

  • New chunk can be shared OR modules are from the node_modules folder
  • New chunk would be bigger than 30kb (before min+gz)
  • Maximum number of parallel requests when loading chunks on demand would be lower or equal to 5
  • Maximum number of parallel requests at initial page load would be lower or equal to 3

或者直接参见配置项

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 30000,
      maxSize: 0,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: '~',
      name: true,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

可以看到默认配置只作用于 async 模块,也就是只从动态加载的模块中提取公共代码。chunks 有几种配置:

  • async,默认配置,只提取动态加载模块的公共部分
  • initial,分离出同步加载模块的公共部分,要注意如果这里的设置是 initial 或者 all,再去 设置 filename 会报错。
  • all,提取出所有模块的公共部分

举个例子,在 App 组件中动态加载 Foo 和 Bar 两个组件,而 Foo 和 Bar 共享一些第三方依赖。

// lifeLession.js
export const sentence1 = '你们还是还要多学习一个';

// Foo.js
import React from 'react';
import moment from 'moment';

import { sentence1 } from './lifeLesson';

class Foo extends React.Component {
  constructor(props) {
    super(props);
    this.state = {};
  }

  componentDidMount() {
    this.setState({
      now: moment().format('YYYY-MM-DD hh:mm:ss'),
    });
  }

  render() {
    return (
      <div>
        Foo { this.state.now }
        { sentence1 }
      </div>
    );
  }
}

export default Foo;

// Bar.js
import React from 'react';
import moment from 'moment';

import { sentence1 } from './lifeLesson';

class Foo extends React.Component {
  constructor(props) {
    super(props);
    this.state = {};
  }

  componentDidMount() {
    this.setState({
      now: moment().format('YYYY-MM-DD hh:mm:ss'),
    });
  }

  render() {
    const { now } = this.state;
    return (
      <div>
        Bar { now }
        { sentence1 }
      </div>
    );
  }
}

export default Foo;

// App.js
class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
    };

    this.loadFoo = () => {
      import('./Foo').then((Foo) => {
        this.setState({
          Foo: Foo.default,
        });
      });
    };

    this.loadBar = () => {
      import('./Bar').then((Bar) => {
        this.setState({
          Bar: Bar.default,
        });
      });
    };
  }

  render() {
    const { Foo, Bar } = this.state;

    return (
      <div>
        <button onClick={this.loadFoo}> load foo </button>
        <button onClick={this.loadBar}> load bar </button>

        { Foo && <Foo /> }
        { Bar && <Bar /> }
      </div>
    );
  }
}

首先我们可以通过如下设置关闭默认优化,看看没有 splitChunksPlugin 优化的时候是什么效果。

optimization: {
  splitChunks: {
    cacheGroups: {
      default: false,
      vendors: false,
    },
  },
},

因为 Foo 和 Bar 里面都引入了 moment 这个依赖,所以会被打包进两个chunk。产生了两个非常大的chunk。

      Asset       Size  Chunks             Chunk Names
       1.js    228 KiB       1  [emitted]  
   1.js.map    749 KiB       1  [emitted]  
       2.js    228 KiB       2  [emitted]  
   2.js.map    749 KiB       2  [emitted]  
 index.html  202 bytes          [emitted]  
    main.js    112 KiB       0  [emitted]  main
main.js.map    267 KiB       0  [emitted]  main

如果打开默认的 splitChunksPlugin 默认优化,共享的依赖会被打包成为一个独立的 chunk,无论是 Foo 被加载还是 Bar 被加载的时候,这个依赖chunk都会被一起引入。

      Asset       Size  Chunks             Chunk Names
       0.js    223 KiB       0  [emitted]  
   0.js.map    737 KiB       0  [emitted]  
       2.js   5.08 KiB       2  [emitted]  
   2.js.map     12 KiB       2  [emitted]  
       3.js   5.07 KiB       3  [emitted]  
   3.js.map     12 KiB       3  [emitted]  
 index.html  202 bytes          [emitted]  
    main.js    112 KiB       1  [emitted]  main
main.js.map    267 KiB       1  [emitted]  main

0.js 是依赖chunk,2 和 3 分别是 Foo 和 Bar 两个组件,依赖会随着模块一同动态加载,依赖 chunk 只会被加载一次。

上面是默认配置的打包结果,可以看到第三方依赖被分离出来了。但是 Foo 和 Bar 组件除了共享了 moment 这个依赖之外,还共享了我们自己定义的 lifeLesson 里面的内容,而这部分仍然被分别打包进入了两个 chunk。

这里我们可以通过配置把这部分代码拆分出来,公用的第三方依赖放在叫做 vendor 的 chunk 里面,我们自己的公用代码放在 common 里面。

optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          chunks: 'async',
          minChunks: 2,
          name: 'vendor',
          test: /node_modules/,
        },
        common: {
          chunks: 'async',
          minChunks: 2,
          name: 'common',
          reuseExistingChunk: true,
          enforce: true, // 我们的公用代码小于 30kb,这里强制分离
        }
      },
    },
  },

结果如下,vendor 里面都是第三方依赖,common 是引入的自己开发模块的内容。

        Asset       Size  Chunks             Chunk Names
         3.js   1.65 KiB       3  [emitted]  
     3.js.map    1.1 KiB       3  [emitted]  
         4.js   1.64 KiB       4  [emitted]  
     4.js.map   1.06 KiB       4  [emitted]  
    common.js   3.52 KiB       0  [emitted]  common
common.js.map     11 KiB       0  [emitted]  common
   index.html  202 bytes          [emitted]  
      main.js    112 KiB       2  [emitted]  main
  main.js.map    267 KiB       2  [emitted]  main
    vendor.js    223 KiB       1  [emitted]  vendor
vendor.js.map    737 KiB       1  [emitted]  vendor

Bundle Analysis

在上面我们可以看到生成了各种 chunk 文件,我们可以使用 source-map-explorer 这个工具分析各个 chunk 里面到底打包了什么内容,有没有重复打包组件,也可以看到到底是哪些依赖占用了大部分体积。为了分析包体积,我们需要生成 source map 文件,然后进行分析。

通过路径动态加载组件

在上面的例子里面,我们通过按钮 onClick 事件的 callback 发出加载请求。但是对于一个大型单页应用来说,我们并不能通过按钮去一个一个地加载组件。这时候我们可以利用前端路由,当路由需要某个前端组件的时候,发出请求加载组件。

// loadable.js
import React from 'react';

class LoadableComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {};
  }

  componentDidMount() {
    const { loader } = this.props;

    if (loader) {
      return loader().then((LoadedComponent) => {
        this.setState({ LoadedComponent: LoadedComponent.default });
      });
    }
  }

  render() {
    const { LoadedComponent } = this.state;
    const { Loading } = this.props;

    return (
      <div>
      {
        LoadedComponent ? <LoadedComponent /> : <Loading />
      }
      </div>
    );
  }
};

export function loadable({ loader, Loading }) {
  return (props) => <LoadableComponent {...props} loader={loader} Loading={Loading} />;
}

// App.js

import React from 'react';
import { BrowserRouter, NavLink as Link, Route, Switch } from 'react-router-dom';

import { loadable } from './loadable';

const loadFoo = () => import('./Foo');
const loadBar = () => import('./Bar');
const Loading = () => <div>Loading...</div>;

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
    };
  }

  render() {
    return (
      <BrowserRouter>
        <div>
          <Link to='/foo'> to foo </Link>
          <Link to='/bar'> to bar </Link>

          <Switch>
            <Route path='/foo' component={loadable({ loader: loadFoo, Loading })} />
            <Route path='/bar' component={loadable({ loader: loadBar, Loading })} />
          </Switch>
        </div>
      </BrowserRouter>
    );
  }
}

动态加载样式文件

通过 MiniCssExtractPlugin 我们也可以分离出样式文件,和 js 模块一同动态加载,

New Kid in Town

React 16.6.0 为我们带来了更方便的 lazy 和 Syspense,进一步简化了写法。

import React, { lazy, Suspense } from 'react';
...


const Foo = lazy(() => import('./Foo'));
const Bar = lazy(() => import('./Bar'));
const Loading = <div>Loading...</div>;

const ReadyFoo = () => (
  <Suspense fallback={Loading}>
    <Foo />
  </Suspense>
);

const ReadyBar = () => (
  <Suspense fallback={Loading}>
    <Bar />
  </Suspense>
);


class App extends React.Component {

  ...
  render() {
    return (
      <BrowserRouter>
        <div>
          <Link to='/foo'> to foo </Link>
          <Link to='/bar'> to bar </Link>

          <Switch>
            <Route path='/foo' component={ReadyFoo} />
            <Route path='/bar' component={ReadyBar} />
          </Switch>
        </div>
      </BrowserRouter>
    );
  }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,194评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,058评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,780评论 0 346
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,388评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,430评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,764评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,907评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,679评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,122评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,459评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,605评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,270评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,867评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,734评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,961评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,297评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,472评论 2 348

推荐阅读更多精彩内容