React基于webpack做code splitting方法

随着web应用功能越来越复杂,模块打包后体积越来越大,如何实现静态资源的按需加载,最大程度的减小首页加载模块体积和首屏加载时间,成为模块打包工具的必备核心技能。

webpack作为当下最为流行的模块打包工具,成为了react、vue等众多热门框架的官方推荐打包工具。其提供的Code Splitting(代码分割)特性正是实现模块按需加载的关键方式。

什么是Code Splitting

官方定义

Code splitting is one of the most compelling features of webpack. It allows you to split your code into various bundles which you can then load on demand — like when a user navigates to a matching route, or on an event from the user. This allows for smaller bundles, and allows you to control resource load prioritization, which if used correctly, can have a major impact on your application load time

翻译过来大概意思就是:

代码分割功能允许将web应用代码分割为多个独立模块,当用户导航到一个匹配的路由或者派发某些特定事件时,来按需加载这些模块。这样就可以将大型的模块分割为多个小型的模块,实现系统资源加载的最大优化,如果使用得当,能够极大的减少我们的应用首屏加载时间。

Code splitting 分类

一、缓存和并行加载的资源分割

这种方法是将某些第三方基础框架模块(例如:moment、loadash)或者多个页面的公用模块拆分出来独立打包加载,通常这些模块改动频率很低,将其与业务功能模块拆分出来并行加载,一方面可以最大限度的利用浏览器缓存,另一方面也可以大大降低多页面系统的代码冗余度。

按照资源类型不同又可以分为js公共资源分割css资源分割两类。

1、js公共资源分割

例如:系统应用入口文件index.js中使用日期功能库 momentjs

index.js

var moment = require('moment');
console.log(moment().format());

webpack.config.js

定义多个entry入口

  • main为主入口模块文件
  • vendor为公共基础库模块,名字可随意设定。称为initial chunk
var path = require('path');

module.exports = {
   entry: {
       main: './index.js',
       vendor: ['moment']
   },
   output: {
       filename: '[name].js',
       path: path.resolve(__dirname, 'dist')
   }
}

执行webpack打包命令:

webpack --progress --hide-modules

可以看到最终打包为两个js文件 main.js 、vendor.js,但如果检查者两个文件会发现moment模块代码被重复打包到两个文件中,而这肯定不是我们想要的,这时候就需要 webpack的plugin发挥作用了。

vendo

使用CommonsChunkPlugin

var webpack = require('webpack');
var path = require('path');

module.exports = {
   entry: {
       main: './index.js',
       vendor: ['moment']
   },
   output: {
       filename: '[chunkhash:8].[name].js',
       path: path.resolve(__dirname, 'dist')
   },
   plugins: [
       new webpack.optimize.CommonsChunkPlugin({
            // vendor是包括公共的第三方代码,称为initial chunk
           name: 'vendor'
       })
   ]
}

执行webpack打包命令,我们发现moment只被打包进vendor.js中。

vendo

webpack运行时模块(manifest)

  • 在前面的步骤2当中webpack在浏览器中加载js模块的运行时代码块也打包进了vendor.js,如果为打包的js文件添加chunkhash,则每次修改 index.js后再次编译打包,由于运行时代码需要重新编译生成,导致vendor.js重新打包并生成新的chunkhash

webpack运行时代码块部分:

webpackjsonp

  • 实际项目中我们希望修改业务功能后打包时只重新打包业务模块,而不打包第三方公共基础库。这里我们可以将webpack的运行时代码提取到独立的manifest文件中,这样每次修改业务代码只重新打包生成业务代码模块main.js和运行时代码模块manifest.js,就实现了业务模块和公共基础库模块的分离。

manifest1

  • names字段支持以数组格式来指定基础库模块名称运行时代码模块名称
module.exports = {
   entry: {
       main: './index.js',
       vendor: 'moment'
   },
   output: {
       filename: '[chunkhash:8].[name].js',
       path: path.resolve(__dirname, 'dist')
   },
   plugins: [
       new webpack.optimize.CommonsChunkPlugin({
           // manifest是包括webpack运行时runtime的块,可以称为entry chunk
           names: ['vendor', 'manifest']
       })
   ]
}

2、CSS代码分割

  • 实际项目开发当中经常使用webpack的css-loader来将css样式导入到js模块中,再使用style-loader将css样式以<style>标签的形式插入到页面当中,但这种方法的缺点就是无法单独加载并缓存css样式文件,页面展现必须依赖于包含css样式的js模块,从而造成页面闪烁的不佳体验。
  • 因此有必要将js模块当中import的css模块提取出来,这时候就需要用到extract-text-webpack-plugin

注意webpack2.x需要使用相应版本的plugin。

npm i --save-dev extract-text-webpack-plugin@beta 

index.js

import moment from 'moment';
import './index.css';

console.log('moment:', moment().format());

webpack.config.js

var ExtractTextPlugin = require('extract-text-webpack-plugin');
......
   module: {
       rules: [{
           test: /\.css$/,
           exclude: /node_modules/,
           use: ExtractTextPlugin.extract({
               loader: 'css-loader',
               options: {}
           })
       }]
   },
   plugins: [
       new ExtractTextPlugin({ 
           filename: 'bundle.css', 
           disable: false, 
           allChunks: true 
       })
   ]
......

extract

二、按需加载代码分割

  • 前面介绍的静态资源分离打包需要开发者在webpack配置文件中明确分割点来提取独立的公共模块,这种方式适合提取第三方公共基础库(vue、react、moment等)以及webpack 的运行时代码模块
  • 除此之外webpack还提供了按需加载的代码分割功能,常用于在web应用路由或者用户行为事件逻辑中动态按需加载特定的功能模块chunk,这就是我们本文中后面要重点介绍的。

Code splitting with require.ensure

webpack1提供了CommonJS风格的 require.ensure()实现模块chunk的异步加载,通过require.ensure()在js代码中建立分割点,编译打包时webpack会将此分割点所指定的代码模块都打包为一个代码模块chunk,然后通过jsonp的方式来按需加载打包后的模块chunk

  • require.ensure()语法
// 空参数
require.ensure([], function(require){
    var = require('module-b');
});

// 依赖模块 "module-a", "module-b",会和'module-c'打包成一个chunk来加载
// 不同的分割点可以使用同一个chunkname,这样可以保证不同分割点的代码模块打包为一个chunk
require.ensure(["module-a", "module-b"], function(require) {
    var a = require("module-a");
    var b = require("module-b");
    var c = require('module-c');
},"custom-chunk-name");

Code Splitting with ES2015

webpack2 的ES2015 loader中提供了import()方法在运行时动态按需加载ES2015 Module

webpack将import()看做一个分割点并将其请求的module打包为一个独立的chunkimport()以模块名称作为参数名并且返回一个Promise对象。

  • import() 语法
import("./module").then(module => {
    return module.default;
}).catch(err => {
    console.log("Chunk loading failed");
});
  • import()使用须知
    • import()目前还是处于TC39 proposal阶段。
    • 在Babel中使用import()方法,需要安装 dynamic-import插件并选择使用babel-preset-stage-3处理解析错误。
  • 动态表达式 Dynamic expressionsimport()中的传参可支持部分表达式的写法了,如果之前有接触过CommonJS中require()表达式写法,应该不会对此感到陌生。它的操作其实和 CommonJS 类似,给所有可能的文件创建一个环境,当你传递那部分代码的模块还不确定的时候,webpack 会自动生成所有可能的模块,然后根据需求加载。这个特性在前端路由的时候很有用,可以实现按需加载资源。import() 会针对每一个读取到的module创建独立的chunk
    function route(path, query) {
      return import(`./routes/${path}/route`)
        .then(route => new route.Route(query));
    }

bundle-loader

bundle-loader 是webpack官方提供的loader,其作用就是对require.ensure的抽象封装为一个wrapper函数来动态加载模块代码,从而避免require.ensure将分割点所有模块代码打包为一个chunk体积过大的问题。

  • 使用语法:
// 在require bundle时,浏览器会立即加载
var waitForChunk = require("bundle!./file.js");
 
// 使用lazy模式,浏览器并不立即加载,只在调用wrapper函数才加载
var waitForChunk = require("bundle?lazy!./file.js");
 
// 等待加载,在回调中使用
waitForChunk(function(file) {
    var file = require("./file.js");
});
  • wrapper函数:

默认普通模式wrapper:


var cbs = [],data;
module.exports = function(cb) {
    if(cbs) cbs.push(cb);
    else cb(data);
},
require.ensure([], function(require) {
    data = require('./file.js');
    var callbacks = cbs;
    cbs = null;
    for(var i = 0, l = callbacks.length; i < l; i++) {
        callbacks[i](data);
      }
});

lazy模式wrapper:

module.exports = function (cb) {
  require.ensure([], function(require) {
    var app = require('./file.js');
    cb(app);
  });
};

使用bundle-loader在代码中require文件的时候只是引入了wrapper函数,而且因为每个文件都会产生一个分离点,导致产生了多个打包文件,而打包文件的载入只有在条件命中的情况下才产生,也就可以按需加载。

  • 支持自定义Chunk名称:
require("bundle-loader?lazy&name=my-chunk!./file.js");

promise-loader

promise-loaderbundle-loader的lazy模式的变种,其核心就是使用Promise语法来替代原先的callback回调机制。与bundle-loader类似,require模块的时候只是引入了wrapper函数,不同之处在于调用函数时得到的是一个对模块引用的promise对象,需要在then方法中获取模块对象,并可以使用catch方法来捕获模块加载中的错误。

promise-loader支持使用第三方Promise基础库(如:bluebird)或者使用global参数来指定使用运行环境已经存在的Promise库。

  • 使用语法:
// 使用Bluebird promise库
var load = require("promise?bluebird!./file.js");

// 使用全局Promise对象
var load = require("promise?global!./file.js");

load().then(function(file) {

});
  • wrapper函数:
var Promise = require('bluebird');

module.exports = function (namespace) {
  return new Promise(function (resolve) {
    require.ensure([], function (require) {
      resolve(require('./file.js')[namespace]));
    });
  });
}

es6-promise-loader

es6-promise-loader相比 promise-loader区别就在于使用原生的ES6 Promise对象。

  • 使用语法:
var load = require("es6-promise!./file.js");

load(namespace).then(function(file) {
    console.log(file);
});
  • wrapper函数:
module.exports = function (namespace) {
  return new Promise(function (resolve) {
    require.ensure([], function (require) {
      resolve(require('./file.js')[namespace]));
    });
  });
}

React按需加载实现方案

React router动态路由

react-router的 标签有一个叫做getComponent的异步的方法去获取组件。他是一个function接受两个参数,分别是location和callback。当react-router执行回调函数 callback(null, ourComponent)时,路由只渲染指定组件ourComponent

  • getComponent异步方法

使用语法:

<Router history={history}>
    <Route 
        path="/"
        getComponent={(nextState, callback) => {
            callback(null, HomePage)
        }}
    />
     <Route
        path="/faq"
        getComponent={(nextState, callback) => {
          callback(null, FAQPage);
        }}
    />
</Router>

这些组件会在需要的时候异步加载。这些组件仍然会在同一个文件中,并且你的应用看起来不会有任何不同。

  • require.ensure

webpack提供的require.ensure可以定义分割点来打包独立的chunk,再配合react-routergetComponent方法就可以实现React组件的按需加载,具体可参照以下文章:

  1. 基于Webpack 2的React组件懒加载
  2. react-router动态路由与Webpack分片thunks

React懒加载组件

文章前面提到使用React动态路由来按需加载react组件,但实际项目开发中很多时候不需要或者没法引入react-router,我们就可以使用webpack官方封装的React懒加载组件来动态加载指定的React组件,源代码见 LazilyLoad

LazilyLoad懒加载组件

  • LazilyLoad使用:

webpack2.x import方法异步加载ES2015模块文件,返回一个Promise对象。

<LazilyLoad modules={{
  TodoHandler: () => importLazy(import('./components/TodoHandler')),
  TodoMenuHandler: () => importLazy(import('./components/TodoMenuHandler')),
  TodoMenu: () => importLazy(import('./components/TodoMenu')),
}}>
{({TodoHandler, TodoMenuHandler, TodoMenu}) => (
  <TodoHandler>
    <TodoMenuHandler>
      <TodoMenu />
    </TodoMenuHandler>
  </TodoHandler>
)}
</LazilyLoad>

webpack 1.x 使用前文中提到的promise-loader或者es6-promise-loader封装按需加载组件。

class App extends React.Component {

   render() {
        return (
            <div>
                <LazilyLoad modules={{
                    LoadedLate: () => require('es6-promise!./lazy/LoadedLate')(),
                    LoadedLate2: () => require('es6-promise!./lazy/LoadedLate2')()
                }}>
                    {({LoadedLate,LoadedLate2}) => (
                        <div>
                            <LoadedLate />
                            <LoadedLate2 />
                        </div>
                    )}
                </LazilyLoad>
            </div>
        );
    }
  • importLazy方法是为了兼容Babel/ES2015模块,返回模块的default属性。
export const importLazy = (promise) => (
    promise.then((result) => result.default || result)
);

React高阶组件懒加载

高阶组件 (Higher Order Component)就是一个 React 组件包裹着另外一个 React 组件。

这种模式通常使用工厂函数来实现,基本上是一个类工厂。React高阶组件的调用方式与普通的React组件完全一致。

封装懒加载组件LazilyLoad的高阶组件工厂函数

LazilyLoadFactory

export const LazilyLoadFactory = (Component, modules) => {
    return (props) => (
        <LazilyLoad modules={modules}>
            {(mods) => <Component {...mods} {...props} />}
        </LazilyLoad>
    );
};

使用高阶组件实现按需加载

webpack 2.x

class Load_jQuery extends React.Component {

    componentDidMount() {
        console.log('Load_jQuery props:', this.props);
    }

    render() {
        return (
            <div ref={(ref) => this.props.$(ref).css('background-color', 'red')}>
                Hello jQuery
            </div>
        );
    }
};

// 使用工厂函数封装Load_jQuery为高阶组件,将异步加载的jQuery模块对象以props的形式来获取并使用
export default LazilyLoadFactory(Load_jQuery, {
    $: () => import('jquery')
});

webpack 1.x

class Load_jQuery extends React.Component {

   render() {
        return (
            <div ref={(ref) => this.props.$(ref).css('background-color', 'red')}>
                Hello jQuery
            </div>
        );
    }
};

export default LazilyLoadFactory(Load_jQuery, {
    $: () => require('es6-promise!jquery')()
});

ES Decorator

除了工厂函数方式扩展实现高阶组件,还可通过 ES草案中的 Decorator(https://medium.com/google-developers/exploring-es7-decorators-76ecb65fb841) 语法来实现。Decorator 可以通过返回特定的 descriptor 来”修饰” 类属性,也可以直接”修饰”一个类。即传入一个已有的类,通过 Decorator 函数”修饰”成了一个新的类。

  • 使用方法:
// ES Decorators函数实现高阶组件封装
// 参考 http://technologyadvice.github.io/es7-decorators-babel6/
const LazilyLoadDecorator = (Component) => {

    return LazilyLoadFactory(Component, {
        $: () => require('jquery')(),
    });
};

// ES Decorators语法
// 需要依赖babel-plugin-transform-decorators-legacy
// babel-loader配置使用plugins: ["transform-decorators-legacy"]
@LazilyLoadDecorator
export default class Load_jQuery extends React.Component {

    componentDidMount() {
        console.log('Load_jQuery props:', this.props);

    }

    render() {
        return (
            <div ref={(ref) => this.props.$(ref).css('background-color', 'red')}>
                Hello jQuery
            </div>
        );
    }

};

引用被高阶组件包裹的普通组件

import Load_jQuery from './js/Load_jQuery';

class App extends React.Component {
    constructor() {
        super(...arguments);
        this.state = {
            load: false,
        };
        this.handleClick = this.handleClick.bind(this);
    }

    handleClick() {
        this.setState({
            load: !this.state.load,
        });
    }

    render() {
        return (
            <div>
                <p>
                    <a
                        style={{ color: 'blue', cursor: 'pointer' }}
                        onClick={this.handleClick}>点击加载jQuery</a>
                </p>

                {this.state.load ?
                    <div><Load_jQuery /></div>
                    : null
                }
            </div>
        );
    }

基于webpack 1.x实现react组件的懒加载示例

lazyloaded

 

react-router4代码分割

react-router4官方文档: https://reacttraining.com/react-router/web/guides/code-splitting

对于SPA项目,出于加载速度的考虑,肯定不能在页面初始化的时候加载全部js文件,因此需要将代码分块,也就是所谓的code splitting,我这里基于react-router4.1.1展示一个code split的例子,当然,你也可以参考react官方文档

思路解析

react-router4是对之前react-router的一次大改,按照官方的说法,是将路由的问题转变成了react组件的问题,所以在react-router4里面,不同于以往使用require.ensure,我们使用一些其他办法异步的请求组件的js文件。

方案A 使用bundle-loader

bundle-loader git地址:https://github.com/webpack-contrib/bundle-loader

先仿着官方的示例先写一个Bundle组件,简化了一下,大概会是这样的


{/*
// 调用示例
<Bundle load={require('bundle-loader?lazy!./somefile.js')}>
    {(Cmp) => <Cmp></Cmp>}
</Bundle>
*/}

// Bundle.js
import React, { Component } from 'react'

class Bundle extends Component {
  constructor() {
    super()
    this.state = {
      mod: null
    }
  }

  componentDidMount() {
    this.props.load((mod) => {
      this.setState({
        mod: mod.default || mod
      })
    })
  }

  render() {
    return (
      this.state.mod ? this.props.children(this.state.mod) : null
    )
  }
}

export default Bundle

在被传入的load方法被调用的时候,相应的js文件才会被请求和加载

然后在入口的路由文件里面这样写(假设我们有两个组件,Cp1, Cp2)

import React from 'react'
import {
  BrowserRouter,
  Route
} from 'react-router-dom'
import Bundle from './bundle'

let CodeSplit = () => {
  return (
    <BrowserRouter>
      <div>
        <Route path={'/cp1'} render={() => {
          return (<Bundle load={require('bundle-loader?lazy!./cp1')}>
            {(Cp1) => <Cp1></Cp1>}
          </Bundle>)
        }
        }></Route>

        <Route path={'/cp2'} render={() => {
          return (<Bundle load={require('bundle-loader?lazy!./cp2')}>
            {(Cp2) => <Cp2></Cp2>}
          </Bundle>)
        }
        }></Route>
      </div>
    </BrowserRouter>
  )
}

export default CodeSplit

这样,代码分割就完成了。

注意

这里有一个小坑,如果你跟我一样使用的是create-react-app的话,你会发现,在运行代码的时候,会报这个错误

Line 35:  Unexpected '!' in 'bundle-loader?lazy!./cp1'. Do not use import syntax to configure webpack loaders  import/no-webpack-loader-syntax

Line 42:  Unexpected '!' in 'bundle-loader?lazy!./cp2'. Do not use import syntax to configure webpack loaders  import/no-webpack-loader-syntax

这是因为create-react-app不支持webpack-loader,具体的可以看看这个issue

why I can’t use bundle-loader like this: https://github.com/facebookincubator/create-react-app/issues/2477

解决办法也很简单,采用方案B

方案B 使用import()

import() 属于es的一个proposal,也就是提案,还没有正式立项,所以具体会有什么问题我这里也不清楚,不过babel已经支持,所以我们这里可以尝试使用,将之前使用bundle-loader的例子改造一下

因为import返回一个promise,所以我们这里将componentDidMount变成一个async函数

{/*
// 调用方法
<Bundle load={() => import(./somefile.js)}></Bundle>
*/}

import React, { Component } from 'react'

class Bundle extends Component {
  constructor() {
    super()
    this.state = {
      mod: null
    }
  }

  async componentDidMount() {
    const {default: mod} = await this.props.load()

    this.setState({
      mod: mod.default || mod
    })
  }

  render() {
    return (
      this.state.mod ? this.props.children(this.state.mod) : null
    )
  }
}

export default Bundle

然后在入口文件的路由里面这么用

import React from 'react'
import {
  BrowserRouter,
  Route
} from 'react-router-dom'
import Bundle from './bundle'

let CodeSplit = () => {
  return (
    <BrowserRouter>
      <div>
        <Route path={'/cp1'} render={() => {
          return (<Bundle load={() => import('./cp1')}>
            {(Cp1) => <Cp1></Cp1>}
          </Bundle>)
        }
        }></Route>

        <Route path={'/cp2'} render={() => {
          return (<Bundle load={() => import('./cp2')}>
            {(Cp2) => <Cp2></Cp2>}
          </Bundle>)
        }
        }></Route>
      </div>
    </BrowserRouter>
  )
}


export default CodeSplit

OK,一个大致的代码分割功能差不多就完成了

方案B改进版

方案B虽然实现了我们异步加载组件的需求,但是调用还是显得比较麻烦,我们需要一种更优雅的方式来实现异步加载,同时还希望能传递参数给组件和自定义组件在加载时候的显示效果,所以这里对方案B进一步进行封装

因为代码比较简单,所以我这里直接把我项目里的代码贴过来了

// async-component.js
/**
 * 用于react router4 code splitting
 */
import React, {Component} from 'react'

/**
 * @param {Function} loadComponent e.g: () => import('./component')
 * @param {ReactNode} placeholder  未加载前的占位
 */
export default (loadComponent, placeholder = null) => {
  class AsyncComponent extends Component {
    unmount = false

    constructor() {
      super()

      this.state = {
        component: null
      }
    }

    componentWillUnmount() {
      this.unmount = true
    }

    async componentDidMount() {
      const {default: component} = await loadComponent()

      if(this.unmount) return

      this.setState({
        component: component
      })
    }

    render() {
      const C = this.state.component

      return (
        C ? <C {...this.props}></C> : placeholder
      )
    }
  }

  return AsyncComponent
}

整体思路和之前的代码是一致的
然后调用的时候只需这么写

Demo组件,就是一个简单的无状态组件

// demo.jsx
import React from 'react'

const Demo = () => {
  return (
    <div>demo</div>
  )
}

export default Demo

调用示例

import asyncComponent from './async-component'

// 获取到异步组件
const AsyncDemo = asyncComponent(() => import('./demo'))

//...
render() {
  return (
    <Route path="/demo" component={AsyncDemo}></Route>
  )
}

// 如果要传参
render() {
  return (
    <Route path="/demo" render={() => {
        <AsyncComponent test="hello"></AsyncComponent>
    }}></Route>
  )
}

参数也可以通过asyncComponent函数进行传递,不过需要更改下async-component.js的代码,因为比较简单,所以这里就不展示了

参考资料

知识共享署名4.0国际许可协议,转载请保留出处; 部分内容来自网络,若有侵权请联系我:前端123学堂 » React基于webpack做code splitting方法

赞 (3) 打赏

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

如果对您有帮助,别忘了打赏一下宝宝哦!

支付宝扫一扫打赏

微信扫一扫打赏