背景
笔者最近接触了一个SPA项目,作为一个Web APP,在一个平台项目中使用。基本功能已经完整,使用过程发现,在网络比较慢的机器(尤其是wifi
信号不好),首次页面呈现特别慢。后期排查发现,主要是打包出来的js
过大(5~8mb
),页面等待脚本资源加载时间过长。简单截个图,显示下效果:
撸起袖子就是干,想尽了各种办法,本文就这个JS资源减肥
过程进行一个简单的回顾记录,以备后查。
常规处理过程
一般Spa项目资源优化都会基于Webpack
开始,本项目也不例外,主要包括了常规压缩混淆处理、动态异步加载、分包和动态链接等方式,下面简单介绍下这些机制。
常规压缩处理
在production
模式下webpack
提供了一套默认的混淆压缩配置minimizer
,当然如果嫌弃官方的不够强大,我们也可以使用一个插件terser-webpack-plugin
,可以帮我们进行进一步的js层面的压缩处理。
const TerserPlugin = require("terser-webpack-plugin");
exports.minifyJavaScript = () => ({
optimization: {
minimizer: [new TerserPlugin({ sourceMap: true })],
},
});
Lazy Load
React
16.6.3版本以后,提供了一个新的功能React.lazy()
,可以帮我们异步去加载组件,其实内部是使用了webpack
的动态导入功能。
import React, { Suspense } from 'react';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
Loading...
}>
);
}
打包好后,会生成一个单独的js组件文件,但是前端JS资源大的问题,往往不是业务单页面过大引起,本案例就是这样,过大的是一个地图组件,那就干脆把地图组件从集中文件中剥离出来。
SplitChunks
在研究splitChunks
之前,我们必须先弄明白这三个名词是什么意思,主要是chunk的含义,要不然你就不知道splitChunks
是在什么的基础上进行拆分。根据理解:
module
:就是js的模块化webpack
支持commonJS
、ES6
等模块化规范,简单来说就是你通过import语句引入的代码。
chunk
: chunk
是webpack
根据功能拆分出来的,包含三种情况:
你的项目入口(entry);通过import()动态引入的代码;通过splitChunks
拆分出来的代码,chunk
包含着module,可能是一对多也可能是一对一。
bundle
:bundle
是webpack
打包之后的各个文件,一般就是和chunk
是一对一的关系,bundle
就是对chunk
进行编译压缩打包等处理之后的产出。
webpack
内部SplitChunks
的配置大概如下:
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'async',
minSize: 30000,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
}
};
通过配置webpack-bundle-analyzer
插件,可以分析看到【背景】一节的分析图,可以发现vender.commons.js
这个JS有分包的空间,笔者把原先归属在vender.commons.js
内部的地图组件进行了分离,把原先vender.commons.js
从8MB
降到了5BM
(build后的大小)。当然commons内部还能继续拆。不过这个案例vendors.sesgis
拆出来后,还是太大,就不能靠webpack
了,后文会介绍其他方案。
sesgis: {
test: /[\\/]node_modules[\\/](ses-gis-api|cesium)[\\/]/,
name: 'vendors.sesgis',
chunks: 'all',
priority: -9,
},
commons: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors.commons',
chunks: 'all',
priority: -10,
},
Dll
SplitChunks
是每次打包的时候,入口文件或者动态加载的组件有公共依赖的场景,可以单独提取分包的过程。在实际的应用中,还有一项技术,可以处理类似的过程,叫做:动态链接
(听着有点像C# 的dll
,其实逻辑类似)。大致的过程就是,把一些公共的第三方依赖,提前抽取出一些vender
,然后形成一个映射文件,后期真正的业务打包过程,如果在映射文件中可以找的库,直接建立动态链接依赖即可,而不用把第三方库打包到自己的文件里面。
配置过程分两步,第一步抽包:
const webpack = require('webpack');
const fs = require('fs');
const cwd = process.cwd();
const appDirectory = fs.realpathSync(cwd);
const version = require('../config.js');
module.exports = (mode) => {
return {
name: "vendor",
mode,
entry: ['react','react-dom', 'react-router', 'axios', 'mobx'],
output: {
path: `${appDirectory}/dist`,
filename: `vendor_${version}.dll.js`,
library: "vendor_[hash]"
},
plugins: [
new webpack.DllPlugin({
name: "vendor_[hash]",
path: `${appDirectory}/dist/manifest.json`
})
]
}
}
通过webpack
,提前打包一次公共包,形成一个独立第三方包,上面代码打包后形成两个文件:vendor_1.0.0.dll.js
、manifest.json
。JS文件好理解,manifest.json
格式如下:
{
"name": "vendor_00e3a512503a6ed7ee92",
"content": {
"./node_modules/react/index.js": {
"id": 0,
"buildMeta": {
"providedExports": true
}
},
"./node_modules/@babel/runtime/helpers/esm/extends.js": {
"id": 1,
"buildMeta": {
"exportsType": "namespace",
"providedExports": [
"default"
]
}
},
........
}
vendor_1.0.0.dll.js
内部建立了一个var vendor_00e3a512503a6ed7ee92=function(e){}
,大概的意思就是根据manifest.json
可以找到模块化的第三方库代码。
第二步,真正打包配置引包:
new webpack.DllReferencePlugin({
manifest: `${appDirectory}/dist/manifest.json`
})
搞定!!!
主框架异步提前加载
在遇到SplitChunks
产生无法分包的5MB
脚本的时候,笔者还设想在该APP 运行的外围平台框架,添加异步请求脚本来预加载
的想法,不过后来没有使用,主要是不够优雅。大致思路也介绍下:
外围平台容器页面添加异步请求:
提前让浏览器缓存超大脚本文件,这样真正的app打开的时候,该文件不需要走网络了。
服务器调整
百般无奈的情况下,看到了analyzer
图,发现有个gzip
大小,几乎把5mb
的大小压缩到了1mb
。那么是不是可以从gzip
入手,看看有没有新突破。第一个映入眼帘的就是Nginx
。
Nginx
动态压缩
其实我们通过WebPack
配置,折腾了半天,并没有大幅度的优化前端资源的大小。反而又是超级牛逼的服务器软件Nginx
给了我一个启发,代码级优化不行,就靠服务器来处理呗。况且一般现在主流的前端部署都是通过Nginx
等静态资源服务器。
的确Nginx
和其他一些主流的web服务器软件都提供了优秀的gzip
功能,开启也方便,比如动态压缩常规配置如下:
location ^~/xxx/static/ {
# gzip config
gzip on;
gzip_min_length 1k;
gzip_comp_level 9;
gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
root /nginx/html/;
}
通过上面的压缩处理,笔者的站点前端JS资源明显变小,压缩幅度最大的到了80%
,优化惊人。
上一个压缩后的资源情况:
静态压缩
相对于开启动态压缩的机制,nginx
还提供了静态压缩的功能,也就是说,客户端请求a.js
时,nginx
优先查找当前文件的文件夹内是否包含a.js.gz
的文件,如果存在,直接返回该文件,避免了一次系统的gzip
过程,对服务器cup性能不是很理想的场景,很适合。
先讲下如何开启静态压缩功能,同样是nginx
的配置,移除动态压缩配置,设置如下:
location ^~/xxx/static/ {
# gzip_static config
gzip_static on;
root /nginx/html/;
}
如果打包出来就包含gz
那不是更好,减少Nginx压缩过程。那么如何自动把打包出来的js
文件进行gzip
呢,其实方法也很简单,nodejs
本身提供给我们了一个系统库zlib
,我们可以在postbuild
添加自己写的压缩脚本,把打包目录下面的文件进行gzip
压缩。
参考代码:
/* eslint-disable consistent-return */
const zlib = require('zlib');
const fs = require('fs');
const path = require('path');
// 压缩方式的一种
const { gzip } = zlib;
const dir = process.cwd();
// 需要压缩的文件夹
const distJsFolder = path.join(dir, 'dist/static/scripts/');
// 压缩输出
function gzipHandler(folder, fileName) {
fs.readFile(fileName, (err, data) => {
if (err) {
return err;
}
gzip(data.toString(), { level: 9 }, (error, result) => {
if (error) {
return error;
}
fs.writeFile(`${fileName}.gz`, result, e => {
if (e) {
return e;
}
console.log(`成功压缩文件:${fileName} !`);
});
});
});
}
function gzipJs(jsFolder) {
const fileNames = fs.readdirSync(jsFolder);
fileNames.forEach(fileName => {
const filePath = path.join(jsFolder, fileName);
const stat = fs.lstatSync(filePath);
if (stat && stat.isDirectory()) {
gzipJs(filePath);
} else if (filePath.lastIndexOf('.js')) {
gzipHandler(jsFolder, filePath);
}
console.dir(stat);
});
}
gzipJs(distJsFolder);
发布过程,把压缩出来的文件同原始的js
文件一起发布即可,高效而不失优雅。效果和上一节是一样的。
当然这个过程也可以通过webpack
的插件:compression-webpack-plugin
实现,具体功能也是压缩打包出来的js,具体过程不再累述,可以参考官方说明
Spring Boot
对于部分把前端资源寄宿在后端自服务器的场景,比如JAVA的Spring Boot,同样可以针对js资源开启gzip
选项,当然Spring Boot相对就简单了,如下修改:
application.properties
:
server.compression.enabled=true
server.compression.mime-types=application/javascript
server.compression.min-response-size=2048
备注
说了这么多服务器压缩处理,当然浏览器端必须要支持压缩,一般现在主流浏览器都是支持的,可以在httpRequst
的请求头上查看请求是否支持压缩:accept-encoding: gzip, deflate, br
。另外,浏览器本身是为了访问JS资源,从压缩的文件内获取文件内容,还是需要个解压过程,比较老旧的机器,还是要考虑浏览器的性能的。
后记
通过上面资源优化的历程,深深感觉方法还是比困难多的,实践出真知啊。