之前一直讲的都是提升构建性能,那么如何提高生产环境下的访问性能呢?例如首页白屏,文件过大导致加载时间过长,文件太多细碎导致
http
通讯次数过多降低性能,http
缓存失效导致资源重新拉取等问题如何解决。
动态加载
动态加载也就是懒加载,这个在Vue
、React
等SPA
应用下面是很常见的优化技巧,例如Vue
通过路由实现页面按需加载,那这个在webbpack
中是怎么实现的呢?
由于我之前跳过了
splitChunks
的章节,这一部分会包含里面的内容,splitChunks
我花了很多时间学习,还没完全弄明白,后面会多花一些时间单独写,下面也会把这一块的知识简单的讲一下,为后面的做铺垫。
这里就有一个概念,叫做Async Chunk
,他的意思就是异步引用(使用import('./xxx.js')
或者require.ensure('./xxx.js')
)的模块会单独打包成一个文件,在Vue
的Route
文件中我们通常都是使用import('./xxx.vue')
来定义我们的component
,如下
import { createRouter } from "vue-router"; const router = createRouter({ route: [ { path: '/', component: () => import('./Home.vue'), // 这里的 home 就会打包成一个单独的文件 } ] }); export default router;
其实这是webpack
赋予的能力,在webpack
中,一般情况下,entry
定义的入口文件打包之后只会生成一个文件,如果使用到了import('./xxx.js')
或者require.ensure('./xxx.js')
,那么xxx.js
就会单独生成一个文件,来看示例代码:
// webpack.config.js const path = require('path'); module.exports = { mode: "development", entry: { main: './src/main', }, output: { path: path.resolve(__dirname, './dist'), filename: "[name].js", }, } // main.js import './common/common-sync'; // common-sync.js console.log('common-sync')
生成的文件内容如下:
如果common-sync.js
的内容过于庞大,又或者main.js
直接使用import './xxx.js'
同步引用的文件过多,同样也会造成最后生成的文件内容过大,这个时候如果将import './xxx.js'
改成import('./xxx.js')
,打包后的文件又会发生什么改变呢?来看看:
// main.js 修改 import('./common/common-sync');
构建之后的结果如下:
我们利用这一特性就可以实现按需加载,具体实现需要看业务,其实这个和懒加载图片是一个道理,在用户不使用的时候就不管他,在用户使用的时候就给用户,其实这个用Vue
的路由配置来讲就很容易理解了,这里我就简单的模拟一下:
- 配置文件先改一下,方便看效果,依赖和配置什么的之前都讲过,这里温习一下
const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin') module.exports = { mode: "development", entry: { main: './src/main', }, output: { path: path.resolve(__dirname, './dist'), filename: "[name].js", }, plugins: [ new HtmlWebpackPlugin({ template: "./src/index.html" }) ], devServer: { hot: true, open: true } }
main.js
内容如下
// Home要一进页面就看到,所以使用同步的方式加载,会和 main.js 打包到一起 import Home from "./Home"; Home(); document.getElementById('Home').addEventListener('click', () => { Home(); }) // Bar 和 Foo 使用异步的方式加载,会生成两个文件 document.getElementById('Bar').addEventListener('click', () => { // 这里是异步加载,只有点击对应的按钮才会请求资源 import('./Bar').then(bar => { bar.default() }); }) document.getElementById('Foo').addEventListener('click', () => { import('./Foo').then(bar => { bar.default() }); })
index.htm
页面会有对应的dom
信息,如下
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <button id="Home">Home</button> <button id="Bar">Bar</button> <button id="Foo">Foo</button> <div id="page"></div> </body> </html>
Bar.js
和Foo.js
如下:
function createPage() { const page = document.getElementById('page'); page.innerHTML = '<h1>Bar...</h1>'; // Foo.js就是文字不一样,就不贴代码了 } export default createPage;
上面的配置完了之后,可以执行npx webpack
查看构建出来的文件,也可以使用npx webpack server
来查看对应的页面效果,查看页面效果记得打开调试模式查看网络信息。
HTTP 缓存优化
还是接着上面的例子,但是需要修改一下配置,主要是修改output.filename
,完整配置如下:
const path = require('path'); module.exports = { mode: "development", entry: { main: './src/main', }, output: { path: path.resolve(__dirname, './dist'), filename: "[name]-[contenthash].js", } }
先来说一下这个配置的意思,当然还是要参考官网output.filename,我这里就简单的复制一下课程中的解释:
[fullhash]
:整个项目的内容 Hash 值,项目中任意模块变化都会产生新的fullhash
;[chunkhash]
:产物对应 Chunk 的 Hash,Chunk 中任意模块变化都会产生新的chunkhash
;[contenthash]
:产物内容 Hash 值,仅当产物内容发生变化时才会产生新的contenthash
,因此实用性较高。
然后我上面就使用[name]-[contenthash]
,这样只有文件内容发生改变,或者文件名发生改变,生成的资源文件名才会发生变化,现在npx webpack
看一下生成的产物,然后再随便修改一下Bar.js
或者Foo.js
再来看一下生成的产物。
- 第一次
- 第二次
结果是两次生成的文件,生成的资源对应main.js
的那个文件会发生改变,明明main.js
里面的东西是没有改过的(如果修改Home.js
,main.js
是一定会变的),这是因为Bar.js
产物的引用名称发生变化了。
就是Bar.js
的产物名称发生变化,对应的main.js
引用的名称也要改,所以main.js
的产物名称也就变了,其实main.js
并没有改。
这样就导致HTTP
缓存失效,因为名称改了,浏览器觉得是新的东西,就又重新加载了。
这里就又引出了一个概念叫做 运行时
,运行时
就是webpack
为了保证生成的产物能正常运行,就在生成的代码里面注入了一些代码,例如你在代码里面写了require
,你在浏览器里面敲敲看,这肯定是没有的呀,这就是webpack
给你提供的。
这里webpack
提供了一个配置可以将该代码单独剥离出来,形成一个文件,如下:
module.exports = { // 省略其他配置 optimization: { runtimeChunk: 'single', } }
使用外置依赖
首先要弄明白什么是外置依赖,外置依赖就是项目以外的依赖文件,不归webpack
管的依赖文件,就比如我有一个依赖包,他要走cdn
加速,我打到代码里面去就不能使用cdn
了,这个时候就可以使用外置依赖了(我可能说的不好,具体可以查看官网的解释:externals),webpack
中使用externals
来配置,如下:
module.exports = { // 省略其他配置 optimization: { runtimeChunk: 'single', } }
这个配置的实际作用目前我还没弄清楚,我能想到的就是我上面举的例子,或者说提升构建构建性能,利用
http
缓存机制,第三方库不管构建几次因为没有改动就可以一直有缓存。
使用 Tree-Shaking 删除多余模块导出
Tree-Shaking
老生常谈了,中文翻译就是树摇,解释一下就是把树摇一下,把上面没用的枯树叶,或者其他没动的东西摇下来;在代码中的意思就是把用不上的模块,用不上的代码给干掉。
在 Webpack 中,启动 Tree Shaking 功能必须同时满足两个条件:
- 配置
optimization.usedExports
为true
,标记模块导入导出列表; - 启动代码优化功能,可以通过如下方式实现:
- 配置
mode = production
- 配置
optimization.minimize = true
- 提供
optimization.minimizer
数组
简单来说就是下面这样配置就ok了:
module.exports = { // 省略其他配置 mode: "production", optimization: { usedExports: true, } }
还是使用上面的例子,直接在Home.js
中添加多个导出语句,然后在构建一下项目看看结果吧:
function createPage() { const page = document.getElementById('page'); page.innerHTML = '<h1>Home...</h1>' } export function func1() { console.log('func1') } export function func2() { console.log('func2') } export function func3() { console.log('func3') } export default createPage;
使用 Scope Hoisting 合并模块
合并模块是指在默认情况下,webpack
会将每次引用都包装成一个函数,引用多少个模块就包装多少个,这无疑是增加了很多代码量,还是拿之前的例子来做测试,将webpack
配置的mode
属性值先改成development
,然后修改一下main.js
里面的代码如下:
import Home from "./Home"; Home(); import Bar from "./Bar"; Bar();
接着就是构建看效果,一个模块对应着一个包装函数:
这里的模块合并方法也很简单,Webpack 提供了三种开启 Scope Hoisting 的方法:
- 使用
mode = 'production'
开启生产模式; - 使用
optimization.concatenateModules
配置项; - 直接使用
ModuleConcatenationPlugin
插件。
方法1:
const path = require('path'); module.exports = { mode: "production", entry: { main: './src/main', }, output: { path: path.resolve(__dirname, './dist'), filename: "[name]-[contenthash].js", }, }
方法2:
const path = require('path'); module.exports = { mode: "development", entry: { main: './src/main', }, output: { path: path.resolve(__dirname, './dist'), filename: "[name]-[contenthash].js", }, optimization: { concatenateModules: true, } }
方法3:
const path = require('path'); const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin'); module.exports = { mode: "development", entry: { main: './src/main', }, output: { path: path.resolve(__dirname, './dist'), filename: "[name]-[contenthash].js", }, plugins: [ new ModuleConcatenationPlugin() ] }
上面三种方式我都测试过有效,但是也是有一些限制的:
- 必须是
ESM
模块,原因就是其他,例如AMD
、CMD
导入导出的内容具有动态性,无法保证会不会出问题。 - 模块被其他模块引用,这是为了防止重复代码,避免A模块有使用打一次,B模块也有使用再打一次。
监控产物体积
在构建的时候,控制台都会输出产物的大小,如果太大了就会抛出警告,但是并不会影响构建。
这一块的监控也是可以配置的:
module.exports = { // ... performance: { // 设置所有产物体积阈值 maxAssetSize: 172 * 1024, // 设置 entry 产物体积阈值 maxEntrypointSize: 244 * 1024, // 报错方式,支持 `error` | `warning` | false hints: "error", // 过滤需要监控的文件类型 assetFilter: function (assetFilename) { return assetFilename.endsWith(".js"); }, }, };
上面的配置表示产物体积如果超过172KB则会报错,这里只是监控,报错并不会影响构建。
如果出现提示了,可以根据上面的方案进行优化处理。
总结
优化也要适可而止,适合自己的优化才是最好的优化,例如分包是优化,包的合并也是优化,但是这两种方案就原理是互斥的,所以要选择最合适的,过度优化可能会带来反效果。