为什么需要按需加载
随着互联网的发展,一个网页需要承载的功能越来越多。 对于采用单页应用作为前端架构的网站来说,会面临着一个网页需要加载的代码量很大的问题,因为许多功能都集中的做到了一个HTML里。 这会导致网页加载缓慢、交互卡顿,用户体验将非常糟糕。
导致这个问题的根本原因在于一次性的加载所有功能对应的代码,但其实用户每一阶段只可能使用其中一部分功能。 所以解决以上问题的方法就是用户当前需要用什么功能就只加载这个功能对应的代码,也就是所谓的按需加载。
如何使用按需加载
在给单页应用做按需加载优化时,一般采用以下原则:
- 把整个网站划分成一个个小功能,再按照每个功能的相关程度把它们分成几类。
- 把每一类合并为一个
Chunk
,按需加载对应的Chunk
。 - 对于用户首次打开你的网站时需要看到的画面所对应的功能,不要对它们做按需加载,而是放到执行入口所在的
Chunk
中,以降低用户能感知的网页加载时间。 - 对于个别依赖大量代码的功能点,例如依赖Chart.js去画图表、依赖flv.js去播放视频的功能点,可再对其进行按需加载。
被分割出去的代码的加载需要一定的时机去触发,也就是当用户操作到了或者即将操作到对应的功能时再去加载对应的代码。 被分割出去的代码的加载时机需要开发者自己去根据网页的需求去衡量和确定。
由于被分割出去进行按需加载的代码在加载的过程中也需要耗时,你可以预言用户接下来可能会进行的操作,并提前加载好对应的代码,从而让用户感知不到网络加载时间。
用Webpack实现按需加载
Webpack内置了强大的分割代码的功能去实现按需加载,实现起来非常简单。
举个例子,现在需要做这样一个进行了按需加载优化的网页:
- 网页首次加载时只加载
main.js
文件,网页会展示一个按钮,main.js
文件中只包含监听按钮事件和加载按需加载的代码。 - 当按钮被点击时才去加载被分割出去的
show.js
文件,加载成功后再执行show.js
里的函数。
其中main.js
文件内容如下:
window.document.getElementById('btn').addEventListener('click', function () {
// 当按钮被点击后才去加载 show.js 文件,文件加载成功后执行文件导出的函数
import(/* webpackChunkName: "show" */ './show').then((show) => {
show('Webpack');
})
});
show.js
文件内容如下:
module.exports = function (content) {
window.alert('Hello ' + content);
};
代码中最关键的一句是import(/* webpackChunkName: "show" */ './show')
,Webpack内置了对import(*)
语句的支持,当Webpack遇到了类似的语句时会这样处理:
- 以
./show.js
为入口新生成一个Chunk
; - 当代码执行到
import
所在语句时才会去加载由Chunk
对应生成的文件。 -
import
返回一个Promise
,当文件加载成功时可以在Promise
的then
方法中获取到show.js
导出的内容。
在使用import()
分割代码后,你的浏览器并且要支持Promise API
才能让代码正常运行, 因为import()
返回一个Promise
,它依赖Promise
。对于不原生支持Promise
的浏览器,你可以注入Promise polyfill
。
/* webpackChunkName: "show" */
的含义是为动态生成的Chunk赋予一个名称,以方便我们追踪和调试代码。 如果不指定动态生成的 Chunk 的名称,默认名称将会是[id].js
。/* webpackChunkName: "show" */
是在Webpack3中引入的新特性,在Webpack3之前是无法为动态生成的Chunk
赋予名称的。
为了正确的输出在/* webpackChunkName: "show" */
中配置的ChunkName
,还需要配置下Webpack,配置如下:
module.exports = {
// JS 执行入口文件
entry: {
main: './main.js',
},
output: {
// 为从 entry 中配置生成的 Chunk 配置输出文件的名称
filename: '[name].js',
// 为动态加载的 Chunk 配置输出文件的名称
chunkFilename: '[name].js',
}
};
其中最关键的一行是chunkFilename: '[name].js',
,它专门指定动态生成的Chunk
在输出时的文件名称。 如果没有这行,分割出的代码的文件名称将会是[id].js
。
按需加载与ReactRouter
在实战中,不可能会有上面那么简单的场景,接下来举一个实战中的例子:对采用了ReactRouter的应用进行按需加载优化。 这个例子由一个单页应用构成,这个单页应用由两个子页面构成,通过ReactRouter在两个子页面之间切换和管理路由。
这个单页应用的入口文件main.js
如下:
import React, {PureComponent, createElement} from 'react';
import {render} from 'react-dom';
import {HashRouter, Route, Link} from 'react-router-dom';
import PageHome from './pages/home';
/**
* 异步加载组件
* @param load 组件加载函数,load 函数会返回一个 Promise,在文件加载完成时 resolve
* @returns {AsyncComponent} 返回一个高阶组件用于封装需要异步加载的组件
*/
function getAsyncComponent(load) {
return class AsyncComponent extends PureComponent {
componentDidMount() {
// 在高阶组件 DidMount 时才去执行网络加载步骤
load().then(({default: component}) => {
// 代码加载成功,获取到了代码导出的值,调用 setState通知高阶组件重新渲染子组件
this.setState({
component,
})
});
}
render() {
const {component} = this.state || {};
// component 是 React.Component 类型,需要通过React.createElement生产一个组件实例
return component ? createElement(component) : null;
}
}
}
// 根组件
function App() {
return (
import(/* webpackChunkName: 'page-about' */'./pages/about')
)}
/>
import(/* webpackChunkName: 'page-login' */'./pages/login')
)}
/>
)
}
// 渲染根组件
render( , window.document.getElementById('app'));
以上代码中最关键的部分是getAsyncComponent
函数,它的作用是配合ReactRouter去按需加载组件,具体含义请看代码中的注释。
由于以上源码需要通过Babel去转换后才能在浏览器中正常运行,需要在 Webpack 中配置好对应的babel-loader
,源码先交给babel-loader
处理后再交给 Webpack 去处理其中的 import(*)
语句。 但这样做后你很快会发现一个问题:Babel报出错误说不认识import(*)
语法。 导致这个问题的原因是import(*)
语法还没有被加入到ECMAScript标准中去, 为此我们需要安装一个Babel插件babel-plugin-syntax-dynamic-import
,并且将其加入到.babelrc
中去:
{
"presets": [
"env",
"react"
],
"plugins": [
"syntax-dynamic-import"
]
}
执行Webpack构建后,你会发现输出了三个文件:
-
main.js
:执行入口所在的代码块,同时还包括PageHome所需的代码,因为用户首次打开网页时就需要看到 PageHome 的内容,所以不对其进行按需加载,以降低用户能感知到的加载时间; -
page-about.js
:当用户访问/about
时才会加载的代码块; -
page-login.js
:当用户访问/login
时才会加载的代码块。
同时你还会发现page-about.js
和page-login.js
这两个文件在首页是不会加载的,而是会当你切换到了对应的子页面后文件才会开始加载。