前端资源瘦身的心历路程

背景

笔者最近接触了一个SPA项目,作为一个Web APP,在一个平台项目中使用。基本功能已经完整,使用过程发现,在网络比较慢的机器(尤其是wifi信号不好),首次页面呈现特别慢。后期排查发现,主要是打包出来的js过大(5~8mb),页面等待脚本资源加载时间过长。简单截个图,显示下效果:

优化前.png

撸起袖子就是干,想尽了各种办法,本文就这个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

React16.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支持commonJSES6等模块化规范,简单来说就是你通过import语句引入的代码。
chunk: chunkwebpack根据功能拆分出来的,包含三种情况:
你的项目入口(entry);通过import()动态引入的代码;通过splitChunks拆分出来的代码,chunk包含着module,可能是一对多也可能是一对一。
bundlebundlewebpack打包之后的各个文件,一般就是和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.js8MB降到了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,
    },
未压缩.jpg

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.jsmanifest.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%,优化惊人。

上一个压缩后的资源情况:

压缩后情况.png

静态压缩

相对于开启动态压缩的机制,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资源,从压缩的文件内获取文件内容,还是需要个解压过程,比较老旧的机器,还是要考虑浏览器的性能的。

后记

通过上面资源优化的历程,深深感觉方法还是比困难多的,实践出真知啊。

你可能感兴趣的:(前端资源瘦身的心历路程)