代码分割与异步加载
代码分割与异步加载
异步加载组件
- React 16 中的异常处理翻译自 React 官方文档,从属于笔者的 React 与前端工程化实践系列中的 React 组件分割与解耦章节;也可以使用 create-webpack-app 运行本部分示例。
为了方便进行代码分割与异步加载,Webpack 内置了 CommonsChunkPlugin 插件,可以帮我们自动提取出不同模块间的公共部分,从而避免冗余传输。另外,CommonsChunkPlugin 还允许我们近似无感地异步加载模块。先来看下 CommonsChunkPlugin 的基本用法:
new webpack.optimize.CommonsChunkPlugin(options)
options.name
oroptions.names
(string|string[]
): 默认的公共 Chunk 的名称,也可以传入一个已经在 entry 中设置过的 Chunk,这样就会默认把该 Chunk 作为公共 Chunk。如果留空,或者设置了options.async
或者options.children
那么默认所有的 Chunks 都会被抽取公共文件(即使用随机文件名),否则使用options.filename
作为这个公共 Chunk 的名称。options.filename
(string
): 输出的公共文件的文件名模板,可以使用output.filename
或者output.chunkFilename
来作为占位符。options.minChunks
(number|Infinity|function(module, count) -> boolean
): 当进行 Chunk 抽取时候的最小单元,这个值必须大于或者等于 2,或者不小于 Chunks 的数目。如果使用Infinity
即是自动创建 Commons Chunk,但是不会传入其他模块的内容,可以用于在设定 Vendor Chunks 的时候避免污染。options.chunks
(string[]`): 根据 Chunk 的名称选定需要处理的 Chunk 列表,这些 Chunk 必须是 Commons Chunk 的子 Chunk,如果忽略的话默认是全部的 Chunk。options.children
(boolean
): 如果为 true 则选定所有 Common Chunk 的子 Chunk。options.async
(boolean|string
): 设定为真之后默认开启异步加载模式。options.minSize
(number
): 在 Common Chunk 创建之前所需要的最小的大小,避免文件太小了还要创建 Common Chunk。
注意,code split
是不支持 ES6 的模块系统的,所以在导入和导出的时候千万要注意,特别是导出。如果你导出组件的时候用 ES6 的方式,这时候不管导入是用 CommomJs 还是 AMD,都会失败,而且还不会报错!
Multiple Entries Common Chunk(多个入口情况下公共 Chunk)
创建一个额外的公共模块,包含
name: "commons",
// (the commons chunk name)
filename: "commons.js",
// (the filename of the commons chunk)
// minChunks: 3,
// (Modules must be shared between 3 entries)
// chunks: ["pageA", "pageB"],
// (Only use these entries)
})
下面看一个复杂一点的例子:
var CommonsChunkPlugin = require("webpack/lib/optimize/CommonsChunkPlugin");
module.exports = {
entry: {
p1: "./page1",
p2: "./page2",
p3: "./page3",
},
output: {
filename: "[name].entry.chunk.js",
},
plugins: [new CommonsChunkPlugin("commons.chunk.js")],
};
这种配置下会编译出多个单独的入口块: p1.entry.chunk.js
, p2.entry.chunk.js
andp3.entry.chunk.js
, 加上commons.chunk.js
. 首先加载 commons.chunk.js
然后加载xx.entry.chunk.js
。也可以创建多个公共代码块:
var CommonsChunkPlugin = require("webpack/lib/optimize/CommonsChunkPlugin");
module.exports = {
entry: {
p1: "./page1",
p2: "./page2",
p3: "./page3",
ap1: "./admin/page1",
ap2: "./admin/page2"
},
output: {
filename: "[name].js"
},
plugins: [
new CommonsChunkPlugin("admin-commons.js", ["ap1", "ap2"]),
new CommonsChunkPlugin("commons.js", ["p1", "p2", "admin-commons.js"])
]
};
// <script>s required:
// page1.html: commons.js, p1.js
// page2.html: commons.js, p2.js
// page3.html: p3.js
// admin-page1.html: commons.js, admin-commons.js, ap1.js
// admin-page2.html: commons.js, admin-commons.js, ap2.js
### Explicit Vendor Chunk:分割第三方库代码当你的应用依赖其他库尤其是像 React JS 这种大型库的时候,你需要考虑把这些依赖分离出去,这样就能够让用户在你更新应用之后不需要再次下载第三方文件。当满足下面几个情况的时候你就需要这么做了:
- 当你的第三方的体积达到整个应用的 20% 或者更高的时候。 - 更新应用的时候只会更新很小的一部分 - 你没有那么关注初始加载时间,不过关注优化那些回访用户在你更新应用之后的体验。 - 有手机用户。
分割第三方库与分割公共模块的区别在与需要设置 minChunks 为 Infinity,来避免你把业务代码与第三方库混合了。
vendor: ["react", "other-lib"],
//或者 vendor:"./vendor.js",然后在vendor.js中使用require("react")来指定公共库
app: "./entry"
}
new CommonsChunkPlugin({
name: "vendor",
// filename: "vendor.js"
// (Give the chunk a different name)
minChunks: Infinity,
// (with more entries, this ensures that no other module
//goes into the vendor chunk)
})
这样打包之后就会多出一个 vendor.js
文件,之后在引入我们自己的代码之前,都要先引入这个文件。比如在 index.html
中
<script src="app.js" charset="utf-8"></script>
除了这种方式之外,还可以通过引用外部文件的方式引入第三方库,比如像下面的配置
{
externals: {
'react': 'React'
}
}
externals
对象的 key 是给 require
时用的,比如 require('react')
,对象的 value 表示的是如何在 global 中访问到该对象,这里是 window.React
。这时候 index.html
就变成下面这样
<script src="//cdn.bootcss.com/react/0.14.7/react.min.js"></script>
<script src="/build/bundle.js"></script>
Async Chunk(异步代码块)
一般加载一个网页都会把全部的 js 代码都加载下来。但是对于 web app 来说,我们更想要的是只加载当前 UI 的代码,没有点击的部分不加载。Quick Start 中的 HelloWorld 示例演示了 Webpack 最基本的用法,但是如果我们有一个相对复杂的 Button 组件定义:
- src/Components/Button.scss
.button {
background: tomato;
color: white;
}
- src/Components/Button.html
<a class="button" href="{{link}}">{{text}}</a>
- src/Components/Button.js
import $ from "jquery";
import template from "./Button.html";
import Mustache from "mustache";
import "./Button.scss";
export default class Button {
constructor(link) {
this.link = link;
}
onClick(event) {
event.preventDefault();
alert(this.link);
}
render(node) {
const text = $(node).text(); // Render our button
$(node).html(Mustache.render(template, { text })); // Attach our listeners
$(".button").click(this.onClick.bind(this));
}
}
按钮最终呈现的样式如下所示:
在主模块中,这个 Button 不一定在初始加载时就用到,可能在部分页面中需要用到该按钮,但是在其他页面中则根本不需要它们。在这种情况下,如果在一开始的时候就将所有 Button 的代码以及样式导入,无疑会加深初始时候的消耗。而 Webpack 的代码分割功能的提出正是为了解决这个问题,也就是所谓的"整体导入"与"无维持的手动导入"之间取得一个平衡。如果需要引入代码分割的功能,即是需要在代码中引入所谓的切割点,即动态地将整个代码切分为多个文件并且动态地按需加载。基本语法如下所示:
import $ from "jquery";
// This is a split point
require.ensure([], () => {
// All the code in here, and everything that is imported
// will be in a separate file
const library = require("some-big-library");
$("foo").click(() => library.doSomething());
});
所有在require.ensure
中定义的文件会被切分为多个大的独立分块,这些独立的分块会在需要被调用时被使用 Ajax 进行动态导入。使用 Code Splitting 之后整个文件目录的布局方式如下所示:
bundle.js
|- jquery.js
|- index.js // our main file
chunk1.js
|- some-big-libray.js
|- index-chunk.js // the code in the callback
当然,开发者并不需要手动导入 chunk1.js 文件,Webpack 打包的模块加载器会按需自动加载。这就意味着对于开发者而言并不需要使用复杂的逻辑去手动控制按需加载,而只需要使用 require.ensure 方法即可。
src/index.js
if (document.querySelectorAll('a').length) {
require.ensure([], () => {
const Button = require('./Components/Button');
const button = new Button('google.com');
button.render('a');
});
}
如果在编译时候使用如下参数:--display-chunks
,那么可以查看具体的被打包的情况:
$ webpack --display-modules --display-chunks
Hash: 432341dc518c06c9d8da
Version: webpack 1.12.2
Time: 952ms
Asset Size Chunks Chunk Names
bundle.js 3.88 kB 0 [emitted] main
1.bundle.js 287 kB 1 [emitted]
chunk {0} bundle.js (main) 294 bytes [rendered]
[0] ./src/index.js 294 bytes {0} [built]
chunk {1} 1.bundle.js 278 kB {0} [rendered]
[1] ./src/Components/Button.js 2.02 kB {1} [built]
[2] ./~/jquery/dist/jquery.js 248 kB {1} [built]
[3] ./src/Components/Button.html 72 bytes {1} [built]
[4] ./~/mustache/mustache.js 19.3 kB {1} [built]
[5] ./src/Components/Button.scss 1.05 kB {1} [built]
[6] ./~/css-loader!./~/sass-loader!./src/Components/Button.scss 212 bytes {1} [built]
[7] ./~/css-loader/lib/css-base.js 1.51 kB {1} [built]
[8] ./~/style-loader/addStyles.js 6.09 kB {1} [built]
如上所述,入口文件bundle.js
中只会包含部分 Webpack 的逻辑,其他的譬如 jQuery、Mustache、Button 这样的部分会被包含在 1.bundle.js 块中,这些块会在使用时被动态加载。
Chunk Limit
当编写代码时,我们可能会自己加入很多的代码分割点来实现这样一种代码的按需加载,每一个小的代码文件就会被称为一个 Chunks,在某些情况下可能会发现就是大量的这种小的 Chunks 可能会导致大量的 HTTP 负载。幸运的是,Webpack 可以自动合并处理这些小的代码块,可以使用以下的两个优化手段:
- 限制最大的代码块的数量--optimize-max-chunks 15
或者new webpack.optimize.LimitChunkCountPlugin({maxChunks: 15})
- 限制最小的代码块的大小--optimize-min-chunk-size 10000
或者 new webpack.optimize.MinChunkSizePlugin({minChunkSize: 10000})
异步加载模式
bundle-loader
bundle-loader 是 Webpack 官方出品的Loader之一,bundle-loader 可以用来加载异步代码块,基本的用法如下:
// 当请求某个Bundle时,Webpack会为我们自动加载
var waitForChunk = require("bundle-loader!./file.js");
//我们需要等待Chunk加载完成才能获取到文件详情
waitForChunk(function(file) {
// use file like is was required with
// var file = require("./file.js");
});
// wraps the require in a require.ensure block
我们同样可以自定义 Chunk 名:
require("bundle-loader?lazy&name=my-chunk!./file.js");
我们可以很方便地利用 bundle-loader 实现 React Router 中模块的懒加载,譬如如果我们的路由设置如下:
import HomePage from "./pages/HomePage";
import AdminPage from "./pages/admin/AdminPage";
import AdminPageSettings from "./pages/admin/AdminPageSettings";
export default function routes(fromServer) {
return (
<Router history={browserHistory}>
<Route path="/" component={HomePage}/>
<Route path="/admin" component={AdminPage}/>
<Route path="/admin/settings" component={AdminSettingsPage}/>
<Router/>
)
}
其中 AdminPage 可能非常笨重,我们希望只有当用户真实请求到/admin
这个地址时才会加载相关组件,此时我们就可以在 Webpack 配置中添加 bundle-loader 的支持:
{
...
module: {
loaders: [{
// use `test` to split a single file
// or `include` to split a whole folder
test: /.*/,
include: [path.resolve(__dirname, 'pages/admin')],
loader: 'bundle?lazy&name=admin'
}]
}
...
}
该配置会自动帮我们从主文件中移除 admin 相关的组件代码,然后将其移动到1.admin.js
文件中,然后在 React Router 中,我们同样需要冲定义组件加载函数:
import HomePage from "./pages/HomePage";
import AdminPage from "./pages/admin/AdminPage";
import AdminPageSettings from "./pages/admin/AdminPageSettings";
const isReactComponent = (obj) => Boolean(obj && obj.prototype && Boolean(obj.prototype.isReactComponent));
const component = (component) => {
return isReactComponent(component)
? {component}
: {getComponent: (loc, cb)=> component(
comp=> cb(null, comp.default || comp))}
};
export default function routes(fromServer) {
return (
<Router history={browserHistory}>
<Route path="/" {...component(HomePage)}/>
<Route path="/admin" {...component(AdminPage)}/>
<Route path="/admin/settings"
{...component(AdminSettingsPage)}/>
<Router/>
)
}
React 懒加载组件封装
有时候我们需要将某个厚重的组件设置为异步加载,这里我们将常见的懒加载操作封装为某个组件及其高阶组件接口,源代码参考LazilyLoad:
import React from "react";
/**
* @function 支持异步加载的封装组件
*/
class LazilyLoad extends React.Component {
constructor() {
super(...arguments);
this.state = {
isLoaded: false,
};
}
componentWillMount() {
this.load(this.props);
}
componentDidMount() {
this._isMounted = true;
}
componentWillReceiveProps(next) {
if (next.modules === this.props.modules) return null;
this.load(next);
}
componentWillUnmount() {
this._isMounted = false;
}
load(props) {
this.setState({
isLoaded: false,
});
const { modules } = props;
const keys = Object.keys(modules);
Promise.all(keys.map((key) => modules[key]()))
.then((values) =>
keys.reduce((agg, key, index) => {
agg[key] = values[index];
return agg;
}, {})
)
.then((result) => {
if (!this._isMounted) return null;
this.setState({ modules: result, isLoaded: true });
});
}
render() {
if (!this.state.isLoaded) return null;
return React.Children.only(this.props.children(this.state.modules));
}
}
LazilyLoad.propTypes = {
children: React.PropTypes.func.isRequired,
};
export const LazilyLoadFactory = (Component, modules) => {
return (props) => (
<LazilyLoad modules={modules}>
{(mods) => <Component {...mods} {...props} />}
</LazilyLoad>
);
};
export const importLazy = (promise) => promise.then((result) => result.default);
export default LazilyLoad;
回调方式懒加载
这里我们使用类似于 bundle-loader 中的回调方式进行懒加载,不过将其封装为了组件形式。其中的importLazy
主要是为了兼容 Babel/ES2015,其只是单纯的返回默认属性值,实例代码参考这里。
render(){
return ...
<LazilyLoad modules={{
LoadedLate: () => importLazy(System.import('../lazy/loaded_late.js'))
}}>
{
({LoadedLate}) => {
return <LoadedLate />
}
}
</LazilyLoad>
...
}
高阶组件方式懒加载
在入门介绍中我们讲过可以利用 external 属性来配置引入 jQuery,而这里我们也可以使用高阶组件方式进行异步加载:
// @flow
import React, { Component, PropTypes } from 'react';
import { LazilyLoadFactory } from '../../../common/utils/load/lazily_load';
/**
* 组件LoadedJquery
*/
export default class LoadedJQuery extends Component {
/**
* @function 默认渲染函数
*/
render() {
return (
<div
ref={(ref) => this.props.$(ref).css('background-color', 'red')}>
jQuery加载完毕
</div>
);
}
}
export default LazilyLoadFactory(
LoadedJQuery,
{
$: () => System.import('jquery'),
}
);
这里我们将加载完毕的 jQuery 作为组件的 Props 参数传入到组件中使用,同样我们也可以使用这种方式加载我们自定义的函数或者组件。上述两种的效果如下所示:
AsyncComponent
// Usage:
//
// function loader() {
// return new Promise((resolve) => {
// if (process.env.LAZY_LOAD) {
// require.ensure([], (require) => {
// resolve(require('./SomeComponent').default);
// });
// }
// });
// }
// ...
// <AsyncComponent loader={loader} />
//
// In the future, loader() could be:
// const loader = () => import('./SomeComponent');
import React, { PropTypes } from "react";
import Spinner from "./Spinner";
import { withStyles, css } from "../themes/withStyles";
function DefaultPlaceholder({ height = 300, styles }) {
return (
<div {...css(styles.container, { height })}>
<Spinner />
</div>
);
}
const WrappedPlaceholder = withStyles(({ color }) => ({
container: {
backgroundColor: color.white,
},
}))(DefaultPlaceholder);
DefaultPlaceholder.propTypes = {
height: PropTypes.number,
styles: PropTypes.shape({
backgroundColor: PropTypes.string,
}),
};
export { WrappedPlaceholder };
export default class AsyncComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
Component: null,
};
}
componentDidMount() {
this.props.loader().then((Component) => {
this.setState({ Component });
});
}
render() {
const { Component } = this.state;
const { renderPlaceholder, placeholderHeight } = this.props;
if (Component) {
return <Component {...this.props} />;
}
return renderPlaceholder ? (
renderPlaceholder()
) : (
<WrappedPlaceholder height={placeholderHeight} />
);
}
}
AsyncComponent.propTypes = {
// specifically loader is a function that returns a promise. The promise
// should resolve to a renderable React component.
loader: PropTypes.func.isRequired,
placeholderHeight: PropTypes.number,
renderPlaceholder: PropTypes.func,
};
加载事件与图片懒加载
在真实的应用开发中用户体验是我们不可忽略的重要因素,特别是对于包含大量图片的网页,受限于 HTTP/1.1 并发连接数与用户本地网速的限制,我们要避免同时加载多张大图;并且在大图加载的过程中需要以合适的占位符给用户友好地反馈,而不是直接留空或者将底部的元素上移。React 为图片添加了onLoad
事件,其会在图片加载完毕之后被触发;我们可以先插入隐藏的img
标签以向服务端或者 CDN 请求图片文件,待图片下载完毕之后再将其渲染到真实的 DOM 节点处。譬如在我们的Feed
组件中,其核心功能就是展示用户上传的大图,我们希望在图片加载时给予用户加载中的提示,加载完毕之后再将图片渲染到界面上:
export default class Feed extends Component {
constructor(props) {
super(props);
this.state = { loadedItems: [] };
}
onLoad(feedItem) {
let updatedItems = this.state.loadedItems;
updatedItems.push({
name: feedItem.name,
imgPath: feedItem.imgPath,
});
this.setState({ loadedItems: updatedItems });
}
render() {
return (
<div className="feed">
<h1 className="feed__h1">{this.props.name}</h1>
{this.state.loadedItems.map((item, i) => (
<FeedItem
imgPath={item.imgPath}
name={item.name}
renderModal={this.props.renderModal}
key={i}
/>
))}
{this.props.items.length > this.state.loadedItems.length && (
<LoadingItem />
)}
<div className="hidden">
{this.props.items.map((item, i) => (
<img
src={item.imgPath}
onLoad={this.onLoad.bind(this, item)}
key={i}
/>
))}
</div>
</div>
);
}
}