目的
随着生产项目的持续功能迭代,前端代码体积会不断增加,导致加载时间越来越长。为了减少加载时间,我们可以拆分代码,让浏览器只加载当前功能所需要的代码(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>
);
}
}
在源码中可以看到 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>
);
}
}