基于webpack实现react组件的按需加载

Posted by fengmiaosen on 2017-01-08

随着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)拆分出来独立打包加载,通常这些模块改动频率很低,将其与业务功能模块拆分出来并行加载,一方面可以最大限度的利用浏览器缓存,另一方面也可以大大降低多页面系统的代码冗余度。

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

1、js公共资源分割

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

index.js

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

webpack.config.js

定义多个entry入口

  • main为主入口模块文件
  • vendor为公共基础库模块,名字可随意设定。称为initial chunk
1
2
3
4
5
6
7
8
9
10
11
12
var path = require('path');
module.exports = {
entry: {
main: './index.js',
vendor: ['moment']
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
}
}

执行webpack打包命令:

1
webpack --progress --hide-modules

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

vendo

使用CommonsChunkPlugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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字段支持以数组格式来指定基础库模块名称运行时代码模块名称
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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。

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

index.js

1
2
3
4
import moment from 'moment';
import './index.css';
console.log('moment:', moment().format());

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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()语法
1
2
3
4
5
6
7
8
9
10
11
12
// 空参数
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() 语法
1
2
3
4
5
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 expressions

    import()中的传参可支持部分表达式的写法了,如果之前有接触过CommonJS中require()表达式写法,应该不会对此感到陌生。

    它的操作其实和 CommonJS 类似,给所有可能的文件创建一个环境,当你传递那部分代码的模块还不确定的时候,webpack 会自动生成所有可能的模块,然后根据需求加载。这个特性在前端路由的时候很有用,可以实现按需加载资源。

    import()会针对每一个读取到的module创建独立的chunk

    1
    2
    3
    4
    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体积过大的问题。

  • 使用语法:
1
2
3
4
5
6
7
8
9
10
// 在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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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:

1
2
3
4
5
6
module.exports = function (cb) {
require.ensure([], function(require) {
var app = require('./file.js');
cb(app);
});
};

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

  • 支持自定义Chunk名称:
1
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库。

  • 使用语法:
1
2
3
4
5
6
7
8
9
// 使用Bluebird promise库
var load = require("promise?bluebird!./file.js");
// 使用全局Promise对象
var load = require("promise?global!./file.js");
load().then(function(file) {
});
  • wrapper函数:
1
2
3
4
5
6
7
8
9
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对象。

  • 使用语法:
1
2
3
4
5
var load = require("es6-promise!./file.js");
load(namespace).then(function(file) {
console.log(file);
});
  • wrapper函数:
1
2
3
4
5
6
7
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异步方法

使用语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<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对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
<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封装按需加载组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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属性。
1
2
3
export const importLazy = (promise) => (
promise.then((result) => result.default || result)
);

React高阶组件懒加载

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

可参考React官方文档说明。

a higher-order component is a function that takes a component and returns a new component

这种模式通常使用工厂函数来实现。

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

LazilyLoadFactory

1
2
3
4
5
6
7
export const LazilyLoadFactory = (Component, modules) => {
return (props) => (
<LazilyLoad modules={modules}>
{(mods) => <Component {...mods} {...props} />}
</LazilyLoad>
);
};

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

webpack 2.x

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 函数”修饰”成了一个新的类。

  • 使用方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 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>
);
}
};

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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

参考资料