随着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.js
、vendor.js
,但如果检查者两个文件会发现moment
模块代码被重复打包到两个文件中,而这肯定不是我们想要的,这时候就需要 webpack的plugin发挥作用了。
使用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
中。
webpack运行时模块(manifest)
- 在前面的
步骤2
当中webpack在浏览器中加载js模块的运行时代码块也打包进了vendor.js
,如果为打包的js文件添加chunkhash
,则每次修改index.js
后再次编译打包,由于运行时代码需要重新编译生成,导致vendor.js
重新打包并生成新的chunkhash
。
webpack运行时代码块部分:
- 实际项目中我们希望修改业务功能后打包时只重新打包业务模块,而不打包第三方公共基础库。这里我们可以将webpack的
运行时代码
提取到独立的manifest
文件中,这样每次修改业务代码只重新打包生成业务代码模块main.js
和运行时代码模块manifest.js
,就实现了业务模块和公共基础库模块的分离。
-
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 [email protected] |
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 }) ] ...... |
二、按需加载代码分割
-
前面介绍的
静态资源分离打包
需要开发者在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打包为一个独立的chunk
。import()
以模块名称作为参数名并且返回一个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-loader是bundle-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-router
的getComponent
方法就可以实现React组件的按需加载,具体可参照以下文章:
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组件的懒加载示例
参考资料
- 基于Webpack 2的React组件懒加载
- react-router动态路由与Webpack分片thunks
- Lazy Loading - React
- es6-promise-loader
- promise-loader
- bundle-loader
- es6-modules-overview
- Implicit Code Splitting and Chunk Loading with React Router and Webpack
- 在Webpack中使用Code Splitting实现按需加载
- Code Splitting - Using require.ensure
- Code Splitting with ES2015
- 深入理解 React 高阶组件
- ES7 Decorator 装饰者模式
- ES Decorators简介
- Higher-Order Components
原文https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Using_promises