webpack从基础配置到性能提升的进阶之路

3,488 阅读10分钟

本文首发掘金,未经允许,不得转载

cli工程师,顾名思义就是使用现成的脚手架来实现开发。通过直接拉取现成的脚手架工程固然可以免去项目起始阶段的基础框架配置,便于开发人员快速入手开发,但作为一名合格的前端开发工程师,我们也要具备一定的搭建脚手架的基本能力,所谓知其然也要知其所以然,这也有利于我们日后对于项目进行性能提升和维护。本文整理了一些基于webpack搭建前端工程,并实现一定的性能优化的方法。

一、构建流程

学习Webpack,首先我们得先了解它的整体运行流程,其实webpack的运行是一个串行的过程,从启动到结束会依次执行以下流程:

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,并得出最终的参数;
  2. 开始编译:用上一步得到的参数初始化Compiler对象,加载所有配置的插件,执行对象的run方法开始执行编译;
  3. 确定入口:根据配置中的entry找出所有的入口文件;
  4. 编译模块:从入口文件触发,调用所有配置的Loader对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  5. 完成模块的编译:在经过第四部使用Loader翻译完所有模块后,得到了每个模块被编译后的最终内容以及它们之间的依赖关系;
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的Chunk,再把每个Chunk转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  7. 输出完成,在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,Webpack会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用Webpack提供的API改变Webpack的运行结果。那了解了基本流程后就可以开展后面的作业了。

二、常规配置

1. 起始配置

最基本配置无非就是安装npm环境、webpackindex.html以及引入一些js文件:

npm init -y
npm i -D webpack webpack-cli

文件目录如下:

src
 |-- index.js
 |-- index.html
package.json
webpack.config.js

其中webpack.config.jswebpack默认的配置文件名,只有这个文件名下写入配置代码,才可以在后面的script脚本命令中直接通过"webpack"打包项目。现做一下入口和出口文件配置:

// webpack.config.js
const path = require('path');

module.exports = {
  entry: './src/index.js', // 设置入口文
  output: {
    path: path.resolve(__dirname, 'dist'), // 设置出口文件的位置为根目录下的dist文件夹
    filename: 'js/[name].[hash].bundle.js' // 设置出口文件名称规则
  }
};

写入一些测试js代码,我们写一些ES6的语法,比如promise:

// src/index.js
handleEvent(); // 函数提升

console.log('哦?是吗?好可惜~') 

function handleEvent() {
  console.log('梅西要离开巴萨了');
}

new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('不走了')
  }, 1000)
}).then(res => {
  console.log('哦不对,最新的消息是他决定',res)
})

修改package.json,做以下操作:

  1. 去除main属性;
  2. 添加"private": true,设置为私有库,防止npm意外发布代码到远程仓库;
  3. scripts添加打包命令:
// package.json
"scripts": {
  "build": "webpack", // 因为我们的配置文件名就是webpack.config.js,所以命令就这么简单
}

现在初期配置完毕,执行脚本命令。会看到dist文件夹已在根路径下生成,打开dist/index.html,打开控制台,会发现一切都很顺利,包括promise。现在我们的初创配置就基本完成了。

2. Babel预处理ES6语法

相信很多朋友都知道ES6在浏览器环境下是需要转换为ES5才能正常运行的,那为什么我们现在就可以正常打包成功呢?其实这是chrome浏览器太过牛逼罢了。你去IE上试试就不起作用了。所以出于兼容性的目的我们要通过babel工具对ES6做个降级处理。

2.1 babel核心库的配置

这里我们用到的库是babel-loader,配置过程为:

  1. 安装相关核心库
npm i -D babel-loader @babel/core @babel/preset-env
  1. webpack配置
module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: 'js/[name].[hash].bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules|browser_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
            babelrc: false, // 我们这里简单一些,不采用.babelrc的配置了
          }
        }
      }
    ]
  }
}

这里解释一些基础内容供初学朋友了解:

  • 引入loader都是在module属性的rules数组中进行相关配置;
  • test表示当前适用该loader的文件的正则表达式;
  • exclude表示排除项目中的文件的正则表达式,这里可以看出我们要排除的文件是引入的资源库和浏览器组件库,根本不需要我们去给人家做处理;
  • use中设置该loaderoptions是传入给该loader的预制参数,这是强规范。

好了,现在我们就配置好了babel核心库,打开IE10,发现还是不行:

IE 不识别promise

难道是我们配置出错了吗?不是的,babel-loaderbabel-core等只能为我们处理一些相对简单的ES6语法,对于promise这种高级货它们也显得有些棘手。大家可以配置webpack的mode为development后,查看打包后的js结果。因此,道高一尺魔高一丈,我们还需要借助babel/polyfill去做一个更深入的降级处理。

2.2 babel-polify核心库配置

关于babel-polify,官网解释的很明确,该库会模拟初一个完全的ES2015+环境,倾向于在应用程序中使用,这意味着我们可以放心地使用像PromiseWeakMapgenerator function这样的新内置工具,Array.fromObject.assign等静态方法以及Array.prototype.includes等新的原型方法。配置过程如下:

  1. 安装babel-polify库
npm install --save @babel/polyfill
  1. webpack相关配置 关于babel-polify的配置方式有好几种,这里我们选用通过在入口文件设置的方式:
module.exports = {
  entry: ["@babel/polyfill", "./src/index.js"],
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'js/[name].[hash].bundle.js'
  }
// ...
}

好了,现在再看一下,会发现已经控制台已经可以正常打印出来了:

IE 11识别promise

3. CSS文件打包

3.1 问题描述

现在我们简单做一点前期准备,创建index.css文件,并在入口文件index.js中直接以ES6 module的方式静态引入:

// src/assets/index.css
body {
  color: green;
  font-size: 20px
}

// src/index.js
import './style/index.css'

现在执行build命令后,会看到提示构建失败。所以这里要引出一个概念:**webpack默认只能打包JS文件。**注意这里我们一定要明确一点的是,这次打包失败不是因为webpack检测到index.css的类型是.css所以报错,而是因为它的代码不是JS而报错,不信你可以修改index.css内容,只打印一个简单日志,会发现build仍然成功,而且日志成功展示。

好了,问题找到了,那怎么解决呢?这就需要我们按需引入相关的loader去帮助我们处理非JS文件了。loader也可以理解为是一个导出为function的node模块。可以将匹配到的文件进行一次转换,且支持链式传递。,通过相关loader我们可以处理.css.vue.jsx以及图片等资源的打包。

3.2 css-loader 和 style-loader

回到当前问题,我们要让index.css中的css语句正常参与到打包结果中,这里我们引入css-loader帮助我们。先安装一下:

npm i -D css-loader

在module中添加.css文件的处理规则:

{
  test: /\.css$/,
  use: [
    {
      loader: 'css-loader',
      exclude: /(node_modules|bower_components)/
      options: {
        url: true,
        modules: 'global'
      }
    }
  ]
}

现在打包就成功了,但是我们会看到样式并没有生效,这是因为css-loader仅仅只是帮我们解析了css文件中的css代码,而webpack默认只是解析js代码的,所以现在我们可以打印一下引入结果日志看一下那是什么东西:

css-loader打包结果

所以,现在自己可以自己动手,通过document.createElement创建一个<style/>标签,把这些解析出来的内容写入后,再插入到body中。这样就会出现样式效果。但我们可以直接通过style-loader帮助我们解决上面的步骤。 安装style-loader并做配置:

{
  test: /\.css$/,
  use: [
    {
      loader: 'style-loader'
    },
    {
      loader: 'css-loader',
      exclude: /(node_modules|bower_components)/
      options: {
        url: true,
        modules: 'global'
      }
    }
  ]
}

这里需要注意一个很重要的点:use中的链式loader的配置是按照从下到上(或从右到左)的顺序执行,所以这里style-loader一定得在css-loader上面写入。现在重新build一下,样式效果就出来了。

3.3 mini-css-extract-plugin

好了,但现在我们又发现了一个问题,打包结果还是只有js文件,并没有为我们单独输出一个css文件。针对这个问题,我们可以使用mini-css-extract-plugin这个插件帮我们解决。该插件的机理为将CSS提取为独立的文件的插件,对每个包含css的js文件都会创建一个CSS文件。这里要预备一些关于插件的知识点:

  1. Plugin可以在webpack运行到所指定的某个阶段去做一些事情。其作用要比loader强大,通过钩子可以涉及整个构建流程,可以做一些在构建范围内的事情;
  2. 对于这个问题,因为是要调整打包后的文件结构,输出新的非JS文件,其已经超出了loader编译JS语法的层面了,所以我们一般就可以通过这种方式判定问题的解决办法是用loader还是plugin

好了现在开始配置,首先安装

npm i mini-css-extract-plugin

配置mini-css-extract-plugin

// webpack.config.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: {
    index: ['@babel/polyfill', './src/index.js']
    index: ['./src/index.js']
  },
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: 'js/[name].[hash].bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              // 这里可以指定一个 publicPath
              // 默认使用 webpackOptions.output中的publicPath
              publicPath: '../'
            },
          },
          // {
          //   loader: 'style-loader'
          // },
          {
            loader: 'css-loader',
            options: {
              url: true,
              modules: 'global'
            }
          } 
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[hash].css'
    })
  ]
}

注意这里注意两点:

  1. 我们除了在plugins数组中配置mini-css-extract-plugin外,还需要loader中配置MiniCssExtractPlugin.loader,且一定要把之前配置的style-loader去除;
  2. 一些老文章说使用mini-css-extract-plugin一定得配置mode为生产模式production,但这块最新的包是没有这个限制的; 好了,现在重新打包,就会看到该文件:dist/css/index.5b771014804342c27941.css,问题解决。

4. 打包HTML文件

现在项目进行到这里,如果有小伙伴真的跟着我的节奏走了下来,估计骂街的心都有了。因为每次打包前都要先手动干掉上一次的dist,打包后再手动把js文件引入到src/index.html<body>中。这样太痛苦了,那既然我们在上一节已经介绍了loader和plugin,现在我们就可以简单判定,要让webpack自动帮我们替换上一次的dist,并生成一个已经引入JS和CSS打包结果的index.html,这无疑需要plugin的支持。这里我们需要的是html-webpack-plugin和是clean-webpack-plugin这两个插件。

4.1 html-webpack-plugin和clean-webpack-plugin

先安装一下:

npm i -D mini-css-extract-plugin html-webpack-plugin

先配置一下这两个插件:

// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
  entry: {
    index: ['@babel/polyfill', './src/index.js']
  },
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: 'js/[name].[hash].bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin()
    new HtmlWebpackPlugin({
      minify: {
        collapseWhitespace: true
      },
      chunks: ['index'],
      filename: 'index.html',
      template: './src/index.html'
    })
  ]
}

这里面又涉及到了下面几个知识点:

  1. 配置html-webpack-plugin时,增加了一个chunks属性,chunk(块)是webpack的一个很重要的概念,表示多个模块(module)的集合。我们都知道webpack中一切皆模块。比如我们例子中的index.jsindex.css,甚至是一些图片,这些都是模块。因此在entry配置中,作为key值输出的内容都可以被看成是一个chunk。。在本项目配置中,我们的entry是通过key-value的形式,其实就相当于显式地声明了一个chunk。这里我们设置html-webpack-plugin的chunk包含index,即指定了对名为index的入口文件内容进行打包生成一个chunk,插入到生成的html中。所以这里切记一定要和entry的key一一对应;
  2. minify表示压缩html的相关设置(这里设置为消去html里标签之间的空白字符),filename设置html页面名称,template设置输出的html页面的模板地址,即我们要解析的原html页面;
  3. 配置插件时不必考虑遵循自下而上(或自右向左)的先后顺序,插件是通过获取指定的编译输出钩子执行的,和配置顺序无关。

现在我们再重新打包一下,可以看到打包结果包含一个新的index.html,且整个包已经实现了更新。

4. url-loader处理图

既然我们现在已经实现了对CSS文件的打包处理,在打包结果中以CSS文件的形式输出。那么对于图片的处理又是怎样的呢?先做一下前期准备,根路径下创建一个images文件夹,放入一个图片。并在index.js中写入代码:

// index.js
import novanber from './images/6_novanber.jpg'; 
import logo from './images/logo.jpg';

var img1 = new Image();
img1.src = novanber;
img1.classList.add('jay-chou');

var img2 = new Image();
img2.src = logo;
img2.classList.add('logo');

var root = document.getElementById('root'); // 这里确保index.html中含有id为root的div
root.append(img1)
root.append(img2)

其中6_novanber.jpg大小50kb,logo.jpg为6kb。此时执行打包会失败,且ERROR提示需相关loader实现。此时我们就可以引入url-loader帮助我们编译图片文件。url-loader是一款针对图片、视频、字体等多类文件资源进行打包优化的插件。

先安装一下:

npm i url-loader

配置url-loader

{
  test: /\.(png|jpg|jpeg|gif|svg)$/,
  use: {
    loader: 'url-loader',
    options: {
      name: '[name].[hash:8].[ext]',
      outputPath: 'images/',
      limit: 10 * 1024,
    }
  }
}

这里有一个知识点,关于options中的limit,该配置参数是url-loader最强大的功能之一,也是一种经常用来提升前端图片加载性能的方法。它用来设定图片转换的最小值阈值,单位为字节。也可以设置为Boolean类型false去除压缩。在该阈值下的文件会被转为base64编码格式,否则原样复制到输出目录。

好了,现在再build一下,查看一下输出文件,结构如下:

dist
  |--css
    |--index.c4578273aesdeedsrl.css
  |--images
    |--6_novanber.04048ebf.jpg
  |--js
    |--index.674623746234shfgf.bundle.js
  |--index.html

再看一下index.html的页面元素:

1

可以看出小图没有输出到打包文件中,并且在页面元素中的src引入路径是base64编码。其优点有如下:

  1. 对于比较小的图片,使用base64编码可以减少一次图片的网络请求;
  2. base64编码的图片不饿能像正常图片可以进行缓存,即使是引入在css文件中,且该文件做了缓存都没用;

但是这里要注意一点,我们在设置limit时也不能太大,否则对于较大图片的base64转换会和html页面混在一起,不知不觉加大了html页面的大小,延长了页面加载的时间。

5. sourceMap定位错误

现在做一件事,我们在index.js刻意去写一个错误:

new Promise((resolve, reject) => {
  setTimeout(() => {
    reject('不走了')
  }, 1000)
}).then(res => {
  console.log('哦不对,最新的消息是他决定:',res)
}, err => {
  throw new Error('呜呜呜~~~', err)
})

现在重新打包,看一下控制台结果:

error

确实报错了,并且它也告诉我错误来自于index.js文件的第1行,点击一看,index.js就一行代码,密密麻麻的,完全找不到错误位置。针对这个现象,我们就要引入sourceMap的概念了。什么是sourceMap,直译过来就是源地址,即代码的源地址。通过该配置我们可以精准定位到错误的位置。在开发环境下,默认是开启sourceMap的,所以我们这里设置mode为production:

// webpack.config.js
module.exports = {
  devtool: 'source-map',
  mode: 'production',
  entry: {...},
  output: {...},
  module: {...}
}

现在重新打包,查看结果,就会发现控制台已经告知了你错误发生的行数,并且点击后就会定位到该位置下。 2

6. devServer配置

现在又要开始构思一件事了,目前来看虽然每次执行构建命令后,dist中可以生成一个已经替换了旧有结果的包,且为我们生成了相应的html页面,还通过sourceMap便利了我们去发现报错点,但是如果每次做一些小的修改,而后期项目越来越大,那每次build的时间必然会很长,这样会严重降低开发效率。**最严重的一点是!我们不可能在这个打包结果中去调测一些ajax请求。**所以,我们还得自己搭建一个小型服务器,每次把打包结果配置为该服务器的根路径,这样才能解决上述所有问题。但是这样做太繁琐了。

针对上述问题,我们就可以在开发阶段引入webpack-dev-server。它能够帮助我们在开发阶段起一个小型服务器,且可设置为热更新模式(hot),便于我们快速开发。 下载webpack-dev-server

npm install webpack-dev-server -D

直接在webpack-dev-server中通过devServer配置即可。

// webpack.config.js
module.exports = {
  devtool: 'source-map',
  entry: {...},
  output: {...},
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    hot: trueopen: true
  }
}

其中contentBase用于配置服务器的文件根路径,hot为启动热更新,open为在devServer启动且第一次构建完成时,自动用我们的系统的默认浏览器去打开要开发的页面。

现在添加一个脚本命令dev

// package.json
"scripts": {
  "build": "webpack",
  "dev": "webpack-dev-server",
},

此时执行dev,会发现自动打开了一个服务器页面,但此时并没有打包出任何结果。因为我们说过,devServer只是用来为开发阶段服务的,它不会生成dist目录下的打包文件,而是直接打包在了内存上,提升运行和打包速度。所以,我们在Vue-Cli或者React脚手架上可以直接通过Proxy实现正向代理,其核心原来就是内置了Webpack-dev-server

现在我们可以说本地开发阶段配置完善了吗?并没有,当我们只是修改了当前页面上的一个标签,比如背景色,你会看到服务器页面进行了整体刷新,这不是很好的体验。那如何能让它局部变化呢?Webpack内置的HotModuleReplacementPlugin可以帮我们:

// webpack.config.js
module.exports = {
  devtool: 'source-map',
  entry: {...},
  output: {...},
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    hot: true
    open: true
  },
  plugins: [ 
    new webpack.HotModuleReplacementPlugin(); 
  ],
}

现在就相对完善啦。

7. 环境拆分

7.1 为什么要拆分环境

首先项目进行到这里,在开发阶段,我们实现了开启小型服务器,助力快速开发和调测。在生产阶段,我们通过build命令构建对js、css以及图片等进行了打包后的结果输出(dist),且通过设置sourceMap定位报错点。但我们还要思考一个问题:现阶段我们针对开发和生产阶段的配置都是共有配置,然而开发阶段不需要CleanWebpackPlugin帮助我们清除上一次的打包结果,也不需要设置sourceMap定位错误;而生产阶段只关注打包结果,完全不需要webpack-dev-server搭建小型服务器。综上所述,如在不同阶段都实现一样的配置,是对内存资源的浪费。因此,上述这些就是生产环境(production,简称prod)和开发环境(development,简称dev)的最基本区别,总结起来就是:

  1. dev环境下会创建一个专门的小型服务器,供开发阶段调测,且代码定位机制更加全面;
  2. prod环境下打包后代码经过压缩,节约部署服务器空间,且通过sourceMap定位错误。

7.2 拆分webpack配置

综上所述,我们将对webpack.config.js做拆分,使不同环境下的配置各取所得。在根路径下创建如下文件:

config
  |-- webpack.dev.js
  |-- webpack.prod.js
  |-- webpack.com.js

看名字就知道都是干啥的,先看一下webpack.config.js的内容。不难发现当前CleanWebpackPlugin插件因为是用于替换build后的结果,所以它是只用在生产环境的。其他配置目前都是两个环境共需的。好现在做下面步骤的操作:

  1. 去除CleanWebpackPluginmode配置后,复制webpack.config.js内容到webpack.com.js,亲吻屏幕与webpack.config.js做最后的告别;
  2. 安装webpack-merge插件:
npm i -D webpack-merge

CleanWebpackPlugin配置内容放入到webpack.prod.js中,并在生产环境配置中开启sourceMap。这里需要注意一点:当设置环境模式mode为development时,它是默认开启sourceMap的。最后webpack.prod.jswebpack.dev.js都通过webpack-merge合并webpack.com.js部分:

// webpack.com.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: {
    index: ['@babel/polyfill', './src/index.js']
  },
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: 'js/[name].[hash].bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        test: /\.js$/,
        exclude: /(node_modules|browser_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
            babelrc: false, // 不采用.babelrc的配置
          }
        }
      },
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              publicPath: '../'
            },
          },
          {
            loader: 'css-loader',
            options: {
              url: true,
              modules: 'global'
            }
          }
        ]
      },
      {
        test: /\.(png|jpg|jpeg|gif|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            name: '[name].[hash:8].[ext]',
            outputPath: 'images/',
            limit: 10 * 1024,
          }
        }
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      minify: {
        collapseWhitespace: true
      },
      chunks: ['index'],
      filename: 'index.html',
      template: './src/index.html'
    })
  ]
}


// webpack.dev.js
const path = require('path');
const { merge } = require('webpack-merge');
const common = require('./webpack.com.js');
const webpack = require("webpack");

module.exports = merge(common, {
  mode: 'development',
  devtool: 'inline-source-map',
  plugins: [
    new webpack.HotModuleReplacementPlugin(),  // 开启全局的模块热替换
    new webpack.NamedModulesPlugin()  // 当模块热替换时在浏览器控制台输出对用户更友好的模块名信息
  ],
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    hot: true // 开启热更新
  }
})


// webpack.prod.js
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

const { merge } = require('webpack-merge');
const common = require('./webpack.com.js');

module.exports = merge(common, {
  mode: 'production',
  devtool: 'source-map',
  plugins: [
    new CleanWebpackPlugin()
  ]
})

好了,现在修改package.json,分别添加基于不同环境下的脚本命令:

"scripts": {
  "build": "webpack --config config/webpack.prod.js",
  "dev": "webpack-dev-server --open --port 3100 --config config/webpack.dev.js",
  "start": "npm run dev"
}

说明:

  1. dev命令中--open为默认初始化打开页面;
  2. --port设置webpack-dev-server端口为3100;
  3. start命令和dev命令作用一致,区别在于start命令会更简化,直接npm start即可。

好了,现在我们就可以分别通过npm start(或npm run dev)和npm run build构建项目开发和生产环境,并各取所需实现了最基本的配置拆分。

三、性能优化配置

现在我们通过对一个项目从初创阶段开始,到实现相对完善的功能配置,但webpack的强大之处绝不仅仅在于这些。其最强大也是最被人问起的是性能优化这部分。其实,我们在上文中也偶然间做了不少性能优化方面的工作,比如:

  1. 本地创建小型服务器后通过在脚本命令中加入--open自动打开浏览器;
  2. 通过url-loader将阈值下的小图转换成base64格式,从而减少无关图片的网络请求次数;
  3. 通过sourceMap定位打包结果的报错点,提升开发效率;
  4. 通过HotModuleReplacementPlugin实现开发阶段服务器页面的热替换,避免了整体页面刷新,从而提升开发效率;
  5. 通过对webpack配置做拆分,划分开发阶段和生产阶段,避免内存资源的浪费。

上述这些知识一些较基本的性能优化内容,现在将主要讲几个常用的优化方式,也欢迎大家评论补充。

1. 垫片(shimming)策略

首先我觉得我们得纠正一次误区,所谓性能优化,我认为并非一定是要从构建速度、构建结果的大小,以及渲染速度等维度上评定,也应当包含你当前项目的健壮性、稳定性。如果今天项目一切顺利,明天加了一个依赖包或者写了几行代码后突然崩了而你还不知所措,那这也属于性能问题。现在看一个经典的例子,首先引入lodash

npm i lodash -S

创建2个子库:child1.jschild2.js,分别使用lodash做一些计算:

// src/assets/child1.js
import _ from 'lodash'

let arrayList = [ 
  { id: 1, name: '那撸多'},
  { id: 2, name: '啥是gay'},
  { id: 3, name: '卡卡西' },
  { id: 4, name: '继来依' }
]

let aimObj = _.findIndex(arrayList, { id: 3 })

export default arrayList[aimObj]


// src/assets/child2.js
import _ from 'lodash'

let arrayListTest = [
  1,
  2,
  [3, 4 , 5],
  [6, 7, 
    [ 8, 9, 
      [ 10, 11]
    ]
  ] 
]
  
let resultFlatten = _.flattenDeep(arrayListTest)
  
export default resultFlatten

之后在index.js中分别引入该两个包,打开控制台查看结果:

// index.js
import result1 from './assets/child1'
import result2 from './assets/child2'

console.log('result1 is:', result1)
console.log('result1 is:', result2)

console

嗯,现在两个子文件都能正常运行并打印结果,但如果我们把child1.js中的lodash引入代码注释掉,就会出现报错:

error

这个结果貌似并不出乎意料,因为我们都知道:**在webpack的编译模块阶段,从入口文件出发,找出所有模块,并通过递归方式找出其依赖的模块,直到所有入口文件依赖的文件都被找到后执行编译。**所以,当前在child1.js引入时,因为此前并没有任何文件引入过lodash,故_从未被定义,从而出现报错。反之,如果我们保留child1.jslodash引入,去除child2.js的引入,则不会报错。

但是这样的话,我们如果要避免出现上述意外错误,就应当在每次调用的文件中去手动引入,那这样是否太繁琐?如何能在一处地方全局定义一个常量,然后在项目任何地方直接使用呢?我们可以通过shimming(垫片)解决。

shimming中文名为垫片,即全局起一个临时的常量,用来代码一个固定的模块。配置方式为:

// config/webpack.common.js
const webpack = require('webpack');
module.exports = {
  entry: {...},
  output: {...},
  plugins: [
    new webpack.ProvidePlugin({
      _: 'lodash'
    })
  ]
}

此时重新运行,会发现一切正常,此时我们可以把child1.jschild2.js的引入代码都干掉了。 那么它的作用原理是怎样的呢?其实很简单,就是当在全局定义好这个临时产量后,如果在项目全局中发现该常量的调用,则在当前文件的第一行处默认加上该常量的模块引入。这样就避免了意外报错。从而提升了代码的健壮性和稳定性。

2. Tree-Shaking

说起tree-shaking,至少我面试的大部分小伙伴都能说上了是什么东东,但是要是再深入问点儿原理就不知所云了,真可谓都是背面试题背出来的。首先一定一定要明确这么个概念:在webpack4中及其以上版本,tree-shaking是在生产环境下默认的,不需要你再做什么,你只需要确保生产环境的配置模式mode为production即可。那现在我们要给开发环境下配置tree-shaking,之后看一下效果。下面我用个例子说明一下,并用实际结果验证。

引入一个我们项目中自己写入的一个公共方法文件common.js,里面包含的方法变幻莫测,层出不穷,而且很多方法的作用还基本一致,总之这种多人协同编码而又没有统一整合管理,会造就代码量巨大、冗余度巨高,正好适合这个例子~~~

// src/assets/common.js

export const hideMobile4 = (mobile) => {
  return String(mobile).replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}

export const buildTagsInfo = () => {}
export const ......
// ( 30多个 )

index.js中只引入上面那个隐藏手机号码的方法,并使用它:

// index.js
import { hideMobile4 } from './assets/common'

console.log(hideMobile4(13674813961))

执行npm start,查看js文件的请求:

before tree

before tree 2

可以看到当前该文件的Waiting和Content Download时长分别为 337.49ms和75.87ms,

现在我们对开发环境做tree-shaking配置,首先在开发环境配置文件中配置optimization:

// config/webpack.dev.js
module.exports = { 
    mode: 'development',
    ...
    plugins: [...],
    
    optimization: {
      usedExports: true 
    },
    devServer: {
      contentBase: path.join(__dirname, 'dist'),
      hot: true 
    }
}

因为正常情况下,webpack对所有模块都做了tree-shaking,为方便调测,这里我们需要在package.json中配置"sideEffects":false来表示模块是安全的,没有副作用的。

打印结果: after tree

after tree 2

配置了tree-shaking后,Waiting和Content Download时长分别为 310.55ms和67.82ms,且response内容中明确了exports used只有hideMobile4这个方法,即告知虽然最后的打包结果还是提供了common.js的所有方法,但是对外只暴漏hideMobile4这一个。而如果当前是生产环境的话,则只会打包这个方法。

3. DllPlugin & DllReferencePlugin

说起DllPluginDllReferencePlugin的优化,我们得结合实际生活来看。举个我个人认为生动的例子:作为一名孤独的深漂者,周末偶尔自己做饭,每次吃饭前的初始步骤我都是先从碗柜里取一双筷子,然后拿到饭桌上开吃。一日三天,但后来我发现家里只有我一个人,不会有别人来,我也不会用过别人的筷子。所以,既然每次筷子都是我用的,而且屋里也没有灰尘,那我没必要每次吃饭前都要走去碗柜拿筷子。于是我干脆就第一遍的时候拿出来洗干净,吃完后就直接扔桌子上了,以后每次再吃饭就直接拿起来用,省的走去碗柜和洗了。所以,这个社会的很多优化内容,其实都是懒蛋提出来的。而回归知识本身,DllPlugin就是解决第一次吃饭时候的取筷子问题,DllReferencePlugin就是解决以后每次都从桌子上直接拿筷子的问题。二者缺一不可。

还有一点需要明确的是,上述所有例子都是为了解决我们开发环境的优化,即提升每次本地起开发调测的构建速度的优化,防止在开发热更新时重复打包第三方模块,而不是针对生产环境的。因为在绝大多数场景下,本地开发环境的构建频率是远高于生产环境的。

再次回归目前项目本身,现在我们的项目还缺少一些很明显直观的稳定因素。所以为了学习和演示DllPlugin,我们以Vue为例,安装Vue和Element-ui。我们通常认为它们基本是稳定的,不需要每次构建的时候重新构建它们,只需要构建一次即可,之后的构建直接调用对于它们之前的构建结果即可:

npm i vue 
npm i webpack vue vue-loader vue-template-compiler -D

npm i element-ui -D

配置.vue文件的loader处理:

// config/webpack.common.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        test: /\.(eot|svg|ttf|woff|woff2)(\?\S*)?$/,
        loader: 'file-loader'
      }, // 这个必须安,否则element-ui的样式会报错
    ]
  }
}

创建src/components/App.vue,并在index.js中引入Vue和Element-ui,

// index.js
import Vue from 'vue';
import App from './components/app.vue'
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css'
Vue.use(ElementUI)

const root = document.createElement('div');
document.body.appendChild(root);

new Vue({
  render: (h) => h(App)
}).$mount(root)

现在就把Vue环境搭建好了,就可以开展DllPlugin了。

首先先创建webpack.dll.js脚本文件

const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');
const { srcPath, distPath } = require('./paths')

module.exports = {
  mode: 'development',

  // JS 执行入口文件
  entry: {
    vendor: ['vue', 'element-ui']
  },
  output: {
    // 输出的动态链接库的文件名称,[name]代表当前动态链接库的名称,也就是entry中配置的vendor
    filename: '[name].dll.js',
    // 输出的文件都放到dist目录下
    path: distPath,

    // 存放动态链接库的全局变量名,例如对应vue来说就是_dll_vue,之所以在前面加上_dll_是为了防止全局变量冲突
    library: '_dll_[name]'
  },
  plugins: [
    // 接入 DllPlugin
    new DllPlugin({
      // 动态链接库的全局变量名称,需要和output.library中保持一致
      name: '_dll_[name]',
      // 描述动态链接库的manifest.json文件输出时的文件名称
      path: path.join(distPath, '[name].manifest.json')
    })
  ]
}

此时就可以运行该文件以获取dll构建结果。添加新的脚本命令:

// package.json
"scripts": {
  "dll": "webpack --config config/webpack.dll.js"
}

执行该命令,此时就会在根目录下生成dist文件中包含vendor.dll.jsvendor.manifest.json两个文件。查看vendor.dll.js的内容,会看到它已经创建了一个全局对象_dll_vendor

// dist/vendor.dll.js
var _dll_vendor =
    /******/ (function(modules) { // webpackBootstrap
    /******/ 	// The module cache
    /******/ 	var installedModules = {};
    /******/
    /******/ 	// The require function
    /******/ 	function __webpack_require__(moduleId) {
    /******/
    ...
}

此时在src/index.html下引入该文件:

// src/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="root"></div>
  <script src="./vendor.dll.js"></script>
</body>
</html>

此时我们就已经提前把vue、element-ui打包完成了,现在就需要给开发环境提前配置好了:

// config/webpack.dev.js
const path = require('path');
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const webpack = require("webpack");
const { srcPath, distPath } = require('./paths')

module.exports = merge(common, {
  mode: 'development',
  devtool: 'inline-source-map',
  plugins: [
  ...
    new webpack.DllReferencePlugin({
      manifest: require(path.join(distPath, 'vendor.manifest.json'))
    })
  ],
  optimization: {
    usedExports: true
  },
  devServer: {
    contentBase: distPath, // 注意这里必须要做根目录的配置,否则上面index.html中根本不会找到vendor.dll.js
    hot: true // 开启热更新
  }
})

大功告成,此时只需要重新执行开发构建命令即可,运行npm run dev,此时运行成功,会发现本地构建快了非常多,非常有利于提升开发速度。

四、总结

其实,对于webpack的性能提升,还有一些内容,比如针对比较大的项目,使用happypack开启多线程打包。通过thread-loader将loader放置在一个worker池里以达到多线程构建等。但基于篇幅和当前的项目太小,实现这些优化后本身效果不显著,故不再赘述。而随着webpack的不断壮大,其优化内容更会不断壮大,我们需要持之以恒。那故事即将画上不太完整和圆满的句号了,我也需要腾出精力做一些其他点的总结了。谢谢大家的支持,欢迎点赞留言。