web性能优化—— 打包构建优化

208 阅读3分钟

作者:麦乐

来源:恒生LIGHT云社区

压缩与合并

资源的合并与压缩所涉及的优化点包括两方面:一方面是减少HTTP的请求数量,另一方面是减少HTTP请求资源的大小。下面我们将详细探讨:HTML压缩、CSS压缩、JavaScript压缩与混淆及文件合并。

HTML压缩

什么是HTML压缩?

37024340-0E4D-45cf-AE84-CC40A7C8F9EE.png

准备一个html文件 未压缩前的大小6kb,这里使用node工具来压缩:

var fs = require('fs');
var minify = require('html-minifier').minify;
fs.readFile('./test.htm', 'utf8', function (err, data) {
    if (err) {
        throw err;
    }
    fs.writeFile('./test_result.html', 
      minify(data,{
        removeComments: true, // 删除注释
        collapseWhitespace: true, // 合并空格
        minifyJS:true, // 压缩文件中的css
        minifyCSS:true // 压缩文件中的js
      }),
      function(){
        console.log('success');
      }
    );
});

压缩后内容少了一半,大小为3kb,html中不再有空格和注释等。

<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Document</title><style>img{width:700px;height:200px;display:block}</style></head><body><ul id="uls"><li><a href="http://www.baidu.com">百度</a></li></ul><img loading="lazy" src="./image/home-1.png" alt="photo"> <img loading="lazy" src="./image/home-2.png" alt="photo"> <img loading="lazy" src="./image/home-3.png" alt="photo"> <img loading="lazy" src="./image/home-4.png" alt="photo"> <img loading="lazy" src="./image/home-5.png" alt="photo"> <img loading="lazy" src="./image/home-6.png" alt="photo"> <img loading="lazy" src="./image/home-7.png" alt="photo"> <img loading="lazy" src="./image/home-8.png" alt="photo"> <img loading="lazy" src="./image/home-9.png" alt="photo"> <img loading="lazy" src="./image/home-10.png" alt="photo"> <img loading="lazy" src="./image/home-11.png" alt="photo"> <img loading="lazy" src="./image/home-12.png" alt="photo"> <img loading="lazy" src="./image/home-13.png" alt="photo"> <img loading="lazy" src="./image/home-14.png" alt="photo"> <img loading="lazy" src="./image/home-15.png" alt="photo"> <img loading="lazy" src="./image/home-16.png" alt="photo"> <img loading="lazy" src="./image/home-17.png" alt="photo"> <img loading="lazy" src="./image/home-18.png" alt="photo"> <img loading="lazy" src="./image/home-19.png" alt="photo"> <img loading="lazy" src="./image/home-20.png" alt="photo"> <img loading="lazy" src="./image/home-21.png" alt="photo"> <img loading="lazy" src="./image/home-22.png" alt="photo"> <img loading="lazy" src="./image/home-23.png" alt="photo"> <img loading="lazy" src="./image/home-24.png" alt="photo"> <img loading="lazy" src="./image/home-25.png" alt="photo"> <img loading="lazy" src="./image/home-26.png" alt="photo"> <img loading="lazy" src="./image/home-27.png" alt="photo"> <img loading="lazy" src="./image/home-28.png" alt="photo"> <img loading="lazy" src="./image/home-29.png" alt="photo"> <img loading="lazy" src="./image/home-30.png" alt="photo"><script></script><script></script></body></html>

css的压缩

.box {
  background: red;
}
// mini.js
var CleanCSS = require('clean-css');
var fs = require('fs');
var minify = require('html-minifier').minify;

var options = { 

};
fs.readFile('./css/index.css', 'utf8', function (err, data) {
    if (err) {
        throw err;
    }
    const a =  new CleanCSS(options).minify(data)
    console.log(a)
    fs.writeFile('./css/test.css', 
      a.styles,
      function(err){
        if(err) {
          console.log(err)
        }
        console.log('success');
      }
    );
});

node mini.js

css被压缩一行:

.box{background:red}

压缩js

JavaScript部分的处理主要包括三个方面:无效字符和注释的删除、代码语义缩减和优化及代码混淆保护。无效字符和注释的删除原理与HTML和CSS的压缩类似,这里主要介绍代码语义缩减和优化及代码混淆保护。

代码语义缩减和优化

通过对JavaScript的压缩可以将一些变量的长度进行缩短,比如说原本一个很长的变量名经过压缩后,可以用很短的像a、b来代替,这样能进一步有效地缩减JavaScript的代码量。同样还可以针对一些重复代码进行优化,比如去除重复的变量赋值,将一些无效的代码进行缩减与合并的优化。

代码混淆保护

由于任何能够访问到网站页面的用户,都可以通过浏览器的开发者工具查看到前端的JavaScript代码,如果前端代码的语义非常明显,没进行压缩也没进行混淆,其格式还完整保留,那么理论上任何访问网站的人都可以轻易地窥探到我们代码中的逻辑,从而去做一些威胁系统安全的事情。所以进行JavaScript代码压缩和混淆的处理,也是对我们前端代码的一种保护。

如何压缩?

准备js文件

var progress = 0;
for(var i = 0; i < 10000; i ++) {
   progress = i + 9;
   console.log(progress)
}
var fs = require('fs');
var uglifyJs = require('uglify-js');

var options = { 
  toplevel: true 
};
fs.readFile('./js/file3.js', 'utf8', function (err, data) {
    if (err) {
        throw err;
    }
    const a =  uglifyJs.minify(data, options)
    console.log(a) 
    fs.writeFile('./js/test.js', 
      a.code,
      function(err){
        if(err) {
          console.log(err)
        }
        console.log('success');
      }
    );
});

压缩后内容,空格取消了,变量的命名也被替换为简单的:

{ code: 'for(var o=0;o<1e4;o++)console.log(o+9);' }

文件合并

假设我们有三个JavaScript文件,分别是a.js、b.js和c.js,当使用keep-alive模式未进行合并请求时,它的请求过程如图:

7.png

如果是合并请求,则只需要发出一个获取a-b-c.js的请求就可以接收到全部内容,如图:

F.png

好处:

合并文件带来的好处是显而易见的,减少了网络请求的次数,减少了等待的时间。

坏处:

但也并不能说合并文件就是万能的,也有不足之处。

第一是首屏渲染的问题,当进行了文件合并后,JavaScript文件尺寸肯定会比合并之前大,那么在进行HTML网页渲染时,如果这个渲染过程会依赖所要请求的JavaScript文件,就必须要等待文件请求加载完成后才能进行渲染。

第二是缓存失效的问题,因为目前大部分项目都有缓存策略,即每个请求的JavaScript文件都会加一个md5的戳,用来标识文件是否发生修改更新,当发现文件被修改时,就会让缓存失效重新请求文件。如果在源文件中只发生了一处很小的修改,则没进行文件合并时只有发生修改的文件失效,而若进行了文件合并,就会造成大面积的缓存失效。

合并建议:

合并公共库,通常我们的前端代码会包含自己的业务逻辑代码和引用的第三方公共库代码,业务逻辑代码的修改变动会比公共库代码频繁,所以可将公共库代码打包成一个文件,而对业务代码进行单独处理。

对业务代码按照不同页面进行合并。但是目前大部分前端都是单页面应用,如何按照页面合并呢?可以按照路由不同进行打包,只有访问相应路由的时候才去加载相应的文件。

结合webpack进行打包构建优化

减少loader的执行范围

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: 'node_modules', // 排除的模块
        include: '', // 包含的模块
        use: [
          // [style-loader](/loaders/style-loader)
          { loader: 'bable-loader' }
        ]
      }
    ]
  }
};

如果不加exclude字段,则webpack会对该配置文件所在路径下的所有JavaScript文件使用babel-loader,虽然babel-loader的功能强大,但执行起来很慢。

由于第三方库的文件在发布前本身已经执行过一次babel-loader,没必要再重复执行一次,增加不必要的打包构建耗时,所以exclude字段不可省略。

对于图片文件则没有必要通过include或exclude来降低loader的执行频率,因为无论哪里引入的图片,最后打包都需要通过url-loader对其进行处理,所以include或exclude的语法并不适用于所有loader类型,要根据具体的情况而定。

开启缓存

开启缓存将构建结果缓存到文件系统中,则可让babel-loader的工作效率得到成倍增加。

{ 
            loader: 'bable-loader' ,
            options: {
              cacheDirectory: true // 开启缓存
            }
          }

确保插件的精简和可靠

果在开发环境下,由于不需要考虑代码对用户的加载速率,并且压缩了反而会降低代码的可读性,增加开发成本,所以在开发环境下不用引入代码压缩插件。

对于有必要使用插件的情况,建议使用webpack官方网站上推荐的插件,因为该渠道的插件性能往往经过了官方测试,如果使用未经验证的第三方公司或个人开发的插件,虽然它们可能会帮助我们解决在打包构建过程中遇到相应的某个问题,但其性能没有保障,可能会导致整体打包的速度下降。

合理配置resolve参数

resolve.extensions配置优化

当我们引入JavaScript代码模块时,我们一般不会引入文件的后缀名,通常的写法如下:

import Foo from './components/Foo'

当项目规模比较大时,为方便代码的组织维护,会拆分出多个模块文件进行引用,可想而知,每次引入模块都填写文件后缀是一件很麻烦的事情,因为代码模块的文件后缀无非就是.js,或者是React中的.jsx、TypeScript中的.ts。

我们可以使用resolve中的extensions属性来申明这些后缀,让项目在构建打包时,由webpack帮我们查找并补全文件后缀。同时对组件路径的引用也可通过resolve的alias配置来进行简化,配置如下:

 resolve: {
    extensions: ['.js', '.json'],
}

当遇到 require('./data')这样的导入语句时,Webpack 会先去寻找./data.js文件,如果该文件不存在,就去寻找./data.json文 件,如果还是找不到就报错。

如果这个列表越长,或者正确的后缀越往后,就会造成尝试的次数 越多,所以resolve.extensions 的配置也会影响到构建的性能。在配置 resolve.extensions时需要遵守以下几点,以做到尽可能地优化构建性 能。

1 后缀尝试列表要尽可能小,不要将项目中不可能存在的情况写到 后缀尝试列表中。 2 频率出现最高的文件后缀要优先放在最前面,以做到尽快退出寻 找过程。 3 在源码中写导入语句时,要尽可能带上后缀,从而可以避免寻找 过程。例如在确定的情况下将require('./data')写成 require('./data.json')。

resolve.modules配置优化

resolve.modules的默认值是['node_modules'],含义是先去当前目录 的./node_modules 目录下去找我们想找的模块,如果没找到,就去上一 级目录../node_modules中找,再没有就去../../node_modules中找,以此类 推,这和Node.js的模块寻找机制很相似。

当安装的第三方模块都放在项目根目录的./node_modules 目录下 时,就没有必要按照默认的方式去一层层地寻找,可以指明存放第三方 模块的绝对路径,以减少寻找,配置如下:

  resolve: {
    ...
    modules: [path.resolve(__dirname, 'node_modules')],
  },

resolve.alias的配置优化

在实战项目中经常会依赖一些庞大的第三方模块,以 React库为 例,

可以看到在发布出去的React库中包含两套代码。

1 一套是采用 CommonJS 规范的模块化代码,这些文件都放在 lib目录下,以package.json中指定的入口文件react.js为模块的入口。 2 一套是将React的所有相关代码打包好的完整代码放到一个单独的 文件中,这些代码没有采用模块化,可以直接执行。其中dist/react.js 用 于开发环境,里面包含检查和警告的代码。dist/react.min.js用于线上环 境,被最小化了。

在默认情况下,Webpack会从入口文件./node_modules/react/react.js 开始递归解析和处理依赖的几十个文件,这会是一个很耗时的操作。通 过配置resolve.alias,可以让Webpack在处理React库时,直接使用单独、 完整的react.min.js文件,从而跳过耗时的递归解析操作。

resolve: {
    extensions: ['.js', '.jsx'],
    alias: {
      '@': path.resolve(__dirname, 'src'),
      react: path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
    },
    modules: [path.resolve(__dirname, 'node_modules')],
  },

根据环境引入:

      react: isDev ? path.resolve(__dirname, './node_modules/react/cjs/react.development.js') : path.resolve(__dirname, './node_modules/react/cjs/react.production.min.js'),

使用DllPlugin

每当发生修改需要重新进行打包时,webpack会默认去分析所有引用的第三方组件库,最后将其打包进我们的项目代码中。在通常情况下,第三方组件包的代码是稳定的,版本不变内容不会发生变化。

这便会用到DllPlugin,它是基于Windows动态链接库(DLL)的思想创建出来的,该插件会把第三方库单独打包到一个文件中,作为一个单纯的依赖库,它不会和我们的项目代码一起参与重新打包,只有当依赖自身发生版本变化时才会重新进行打包。

DllPlugin插件:用于打包出一个个单独的动态链接库文件。

DllReferencePlugin插件:用于在主要的配置文件中引入DllPlugin 插件打包好的动态链接库文件。

使用如下:

webpack.dll.js

const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
 
module.exports = {
  mode: "development",
  entry:{
    vue: ["vue"],
  },
  output: {
    path: __dirname + "/src/dll/",
    filename: '[name].dll.js',
    library: 'dll_[name]'
  },
  optimization: {
    minimize: true,
    // 压缩js
    minimizer: [
			new TerserPlugin({
        parallel: true, // 启动多进程压缩 官方建议
      })
		]
  },
  
  plugins:[
    new CleanWebpackPlugin(), // 清空得是output中得path
    new webpack.DllPlugin({
      path: __dirname+'/src/dll/[name].dll.json',
      name: 'dll_[name]',
    })
  ]
}

webpack配置如下:

new webpack.DllReferencePlugin({
     manifest:require('./src/dll/jquery.json')
   }),
   new webpack.DllReferencePlugin({
     manifest:require('./src/dll/loadsh.json')
   })

插入html

 // 将某个文件打包输出去,并在html中自动引入该资源
          new AddAssetHtmlPlugin([{
            outputPath: "js/",
            filepath: path.resolve(__dirname,'src/dll/vue.dll.js') // 文件路径
          }])

将单进程转化为多进程

我们都知道webpack是单进程的,就算有多个任务同时存在,它们也只能一个一个排队依次执行,这是nodejs的限制。

我们可以使用happypack充分释放CPU在多核并发方面的优势,帮助我们把打包构建任务分解成多个子任务去并发执行,这将大大提高打包的效率。

//建议当文件较多的时候再使用这个,如果只有一两个反而会拖慢。
const HappyPack=require('happypack');
const os=require('os'); // 拿到操作系统
const happyThreadPool=HappyPack.ThreadPool({size:os.cpus().length}) // 新建进程池
...
    {
        test: /\.js$/,
        loader: 'happypack/loader?id=happyBabel',  // 对js的处理使用happypack
        include: [resolve('src')]
      },
...

new HappyPack({
      id:'happyBabel',
      loaders:[
        {
          loader:'babel-loader?cacheDirectory=true' // 代替babel-loader
        }
      ],
      threadPool:happyThreadPool,
      verbose:true
    })
...

压缩打包结果的体积

删除冗余代码

webpack从2.0版本开始,便基于ES6推出了Tree-shaking。比如在某个组件中通过import引入了两个模块module1和module2,但只使用了module1并未使用module2,由于引用模块的使用情况,是可以在静态分析过程中识别出来的,所以当打包进行到该组件时,Tree-shaking便会直接帮我们将module2删除。

其余的删除代码主要发生在代码压缩阶段,会删除一些注释,空格,删除console等。

webpack4开始已经支持自动压缩代码,只需要吧mode模式设置为 production就可以开启代码压缩功能。

代码拆分按需加载

以react为例,用脚手架创建一个项目:

create-react-app test

cd test

npm install

npm run start

7F.png

Foo.js

// Foo.js
import React from 'react';
import moment from 'moment';
import { TEXT } from '../utils/index'


class Foo extends React.Component {
  constructor(props) {
    super(props);
    this.state = {};
  }

  componentDidMount() {
    this.setState({
      now: moment().format('YYYY-MM-DD hh:mm:ss'),
    });
  }

  render() {
    return (
      <div>
        Foo { this.state.now }
        {TEXT}
      </div>
    );
  }
}

export default Foo;

Bar.js

// Bar.js
import React from 'react';
import moment from 'moment';
import { TEXT } from '../utils/index'



class Bar extends React.Component {
  constructor(props) {
    super(props);
    this.state = {};
  }

  componentDidMount() {
    this.setState({
      now: moment().format('YYYY-MM-DD hh:mm:ss'),
    });
  }

  render() {
    const { now } = this.state;
    return (
      <div>
        Bar { now }
        { TEXT }
      </div>
    );
  }
}
export default Bar

App.js

foo组件和bar组件都是异步加载的,moment是两个组件公用的node_modules库,TEXT是两个组件公用的 utils文件中的文案。

npm run start 启动项目

D05.png

点击加载Foo:

F3.png

可以看到Foo组件的代码是在点击按钮后才加载的,这样就是实现了按需加载,可以结合路由优化首屏渲染。

另外还可以看到0.chunk.js是两个组件共同依赖的第三方,如果点击加载Bar,将不需要重复加载,只加载1.chunk.js就可以。Bar组件的内容:

FE.png

可视化分析

1 官方版本

(1)Mac webpack --profile --json > stats.json (2)Window: webpack --profile --json | Out-file 'stats.json' -Encoding OEM

然后将输出的json文件上传到如下网站进行分析

webpack.github.io/analyse/

选择stats.json文件可以看到详细的信息,生成了可视化的页面,然后打开 Modules页面,可以分析每一个文件的打包时间和大小

2 社区版本

npm i webpack-bundle-analyzer --save

const wba = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

plugin: [
    new wba()
]

可以更直观的看到每个模块的大小和所占有的比例,然后做相应的优化。

希望本篇文章的内容能够给大家在优化项目构建方面提供有益的帮助。