【万字】优化Webpack?肘,跟我进屋聊聊

3,112 阅读18分钟

大家好我是来蹭饭,一个会点儿吉他和编曲,绞尽脑汁想傍个富婆的摸鱼大师。

咱们webpack大白话系列的第一篇文章《在?大白话跟你唠明白Webpack(基础篇)》带大家快速入门了webpack,今天我来填坑了。

本次给大家带来的是webpack的进阶玩法——优化webpack。本篇结束后,就只剩四个坑啦!

一. 前言

webpack官网介绍了很多优化它的方法,虽然量大管饱,但不够体系化。因此我打算将常用的方法归纳起来,从优化开发体验,提升构建速度,减少构建体积,优化应用性能这四个维度逐一介绍webpack的优化。也欢迎大家在评论区查漏补缺,介绍一些自己常用的优化技巧。

还是老样子,案例代码已放在git欢迎自取,点个star。

我们承接基础篇的仓库进行目录的改造(本篇内容未提出需要安装与配置的loader和plugin在上一篇文章中已经安装与配置好。若不清楚基础篇仓库内容的欢迎移步查看上一篇文章,有助于理解本篇内容)。

├─ src
│  ├─ components
│  │  ├─ Header
│  │  │  └── index.js
│  ├─ css
│  │  └── index.scss
│  ├─ index.html
│  └─ index.js
├─ .gitignore
└─ README.md
└─ webpack.config.js
└─ package.json

接下来正式开始进行优化,大家坐稳扶好我们发车。

二. 优化开发体验

本章主要介绍开发者可以通过webpack的哪些配置,实现定向搜索,源代码追踪,模块热更新,TS开发,代码校验以及省略文件后缀名等功能,提升自己在项目中的开发体验。

2.1 定向搜索——alias

我们在src/index.js中引入自己创建的组件,把它渲染出来以此讲解本案例。

import Header from './components/Header/index.js'

Header()

配置components/Header/index.js的内容如下:

import '../../css/index.scss'

const Header = () => {
  const body = document.body
  const div = document.createElement("div")
  div.setAttribute("class","cengfan")
  div.innerHTML = "<h2>我来组成头部</h2>"
  body.append(div)
}

export default Header

src/css/index.scss填写如下内容:

$baseCls:"cengfan";

.#{$baseCls} {
  background: #4285f4;
  color: #fff;
}

执行webpack serve查看结果渲染成功:

1.png

现在我们把注意力转移到components/Header/index.js中的import语句。这个组件引用了外部的样式,路径回退了2层。看似问题不大,但如果项目结构复杂,要回退更多层级的路径就很麻烦了。

举个例子,如果在Header文件夹下还有a/b/c/index.js这样的文件层级,我们引用css的方式会变成这样../../../../../css/index.scss

当项目结构如此复杂,我们还想达成引用的需求时。有什么方式能优雅地解决这个问题呢?接下来我们请出webpack的resolve配置。

官方对于resolve的解释非常简单:配置模块如何解析。下面我们看看如何配置。

  • 步骤1:   配置webpack.config.js
const path = require('path')
const resolvePath = _path => path.resolve(__dirname, _path)

module.exports = {
  // ...

  resolve:{
    alias: {
      '@': resolvePath('./src')
    },
  },

  //...
}

alias:创建 import 或 require 的别名,来确保模块引入变得更简单。

这里将@的寻址路径指定为src的根目录。

  • 步骤2:   更改Header组件的import语句
import '@/css/index.scss'

这里相当于在src路径下找寻后面的文件。配置好后重启服务,页面正常渲染。

即使项目层级复杂,你也可以指定任何你想寻址的目录。这就是alias的使用方法,Vue中@解析符的寻址也是这么回事。

2.2 源代码追踪——sourceMap

本小节开始学习前,我们先搞明白webapck中的module,chunk和bundle分别是什么。

名称定义
module开发者手写的代码模块
chunk输入module代码后,交由webpack正在打包的代码
bundle编译后输出的浏览器最终能直接识别的代码

简而言之,这三者是这样婶儿的关系:

module(手写的代码) => chunk(webpack处理中的代码) => bundle(webpack处理后的代码)

客户端在执行程序时,读取的是打包后的bundle文件。如果程序执行过程报错,报错信息是bundle的内容。无法溯源到源代码module中的错误,此时需要借助sourceMap来帮忙。

sourceMap(源代码映射)是一个用来生成源代码与构建后代码对应映射文件的方案。下面我们举个例子来看看它的使用:

我们在components/Header/index.js中添加依据错误的console信息:

console.lo('Halo Header')

运行webpack server查看页面。

2.png

点击第一行的main.js报错查看报错信息:

3.png

点击第二行的index.js查看报错:

4.png

可以看到无论是哪个报错信息,追溯的都是编译后的bundle报错,没有追溯到源码。下面我们给webpack.config.js配置sourceMap信息:

module.exports = {
  // ...

  devtool:'cheap-module-source-map',

  //...
}

再次查看报错信息,此时错误已经追踪到源码上了:

5.png

这就是sourceMap的作用,它的官方配置很多。但总结下就是将错误追踪分成不同的种类。如是否单独生成chunk与bundle相互映射的map文件;是否以内联形式追加sourceUrl信息等排列组合。这里整理了一份表格供参考。

模式含义
eval每个module会被封装到eval内执行,并且会在末尾追加注释 //sourceURL
source-map额外生成sourceMap文件
hidden-source-map上同,但是不会在bundle末尾追加注释
inline-source-map生成一个DataUrl形式的sourceMap文件,map文件不会被单独打包
eval-source-map上同,另外每个module会被封装到eval内执行
cheap-module-source-map生成一个没有列信息的sourceMap文件,map文件会被单独打包

但是我们的文章宗旨是通篇大白话,不讲八股文。这么些区别谁也懒得记。实际开发过程中我们只需要关注开发(development)生产(production)两个环境如何配置sourceMap信息即可,直接记结论如下表:

modedevtools优点缺点
developmentcheap-module-source-map打包编译速度快只包含行映射
productionsource-map/不配置包含行,列映射打包速度慢

提示:演示完毕后把代码改回不报错的状态,避免影响接下来的演示。

2.3 模块热更新-HMR

模块热替换(HMR - hot module replacement)功能会在应用程序运行过程中,替换、添加或删除 模块,而无需重新加载整个页面。主要是通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面期间丢失的应用程序状态。
  • 只更新变更内容,以节省宝贵的开发时间。
  • 在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。

webpack.config.js中的devServer配置hot: true即可开启:

module.exports = {
  // ...
  devServer: {
    host: 'localhost',
    port: 8080,
    open: true,
    hot: true,
  },

  mode: 'development'
}

2.4 TypeScript开发-babel-loader

开发ts需在安装对应依赖,并额外做一些配置,我们先做好前置工作,调整src目录,新增一个ts文件:

├─ src
│  ├─ components
│  │  ├─ Add
│  │  │  └── index.ts
│  │  ├─ Header
│  │  │  └── index.js
│  ├─ css
│  │  └── index.scss
│  ├─ index.html
│  └─ index.js

Add/index.ts文件中填充的内容如下:

const add = (a: number, b: number):number => a + b

const Hello = () => {
  const result = add(2,3)
  console.log(result)
}

export default Hello

src/index.js引入该组件:

import Hello from './components/Add/index.ts'

Hello()

到这里前置工作完成,接下来我们正式开始配置webpack使其能正常编译ts文件。

  • 步骤1:  安装bebal预设@babel/preset-typescript

yarn add @babel/preset-typescript -D

  • 步骤2:  配置webpack.config.js
//...

module.exports = {
  //...
  
  module: {
    rules: [
    //...
    {
      test: /\.(js|ts)$/,
      use: 'babel-loader',
    }]
  },

 //...
}
  • 步骤3:  项目根目录新建babel.config.json文件。
├─ src
├─ .gitignore
└─ babel.config.json
└─ README.md
└─ webpack.config.js
└─ package.json
  • 步骤4:  配置babel.config.json文件。
{
  "presets": ["@babel/preset-env","@babel/preset-typescript"]
}

到这里ts的前置工作就做完了,运行下webpack serve查看结果,Hello函数正确打印了结果。

6.png

2.5 校验代码

校验代码分常规校验ts校验两块内容讲解。

原因是ts校验涉及的历史背景较为复杂。早期涉及ts的编译,需要使用到ts-loader + babel-loader,先将ts代码编译成es代码,再通过babel-loader编译成es5代码。后来ts团队与babel团队合作带来了全新的babel 7。使得babel-loader能直接编译ts代码,但是babel官网有如下提示:

务必牢记 Babel 不做类型检查,你仍然需要安装 Flow 或 TypeScript 来执行类型检查的工作。

也就是说虽然配置和编译变得更快更简单,但由于不依赖tsc编译代码,编译过程中丢失了ts语法的校验功能。

那如何去做ts语法的校验呢,后续我们进行详细地讲解,让我们先关注如何使用eslint在webpack中进行代码的常规校验。

2.5.1 常规校验-eslint

常规校验es代码需要在webpack中引入eslint并做好配置,配置步骤如下:

  • 步骤1:  安装eslint依赖

yarn add eslint eslint-webpack-plugin -D

  • 步骤2:  配置webpack.config.js
// ...
const ESLintWebpackPlugin = require("eslint-webpack-plugin");

const resolvePath = _path => path.resolve(__dirname, _path)

module.exports = {
  // ...

  plugins: [
    new ESLintWebpackPlugin({
      // 指定检查文件的根目录
      context: resolvePath('./src'),
    }),
    // ...
  ]
  // ...
}
  • 步骤3:  根目录新增.eslintrc.js.eslintignore文件。
├─ src
├─ .gitignore
└─ README.md
└─ .eslintignore
└─ .eslintrc.js
└─ webpack.config.js
└─ package.json

这里讲解下这两个文件的作用:

  • .eslintrc.js: 配置eslint校验规则的文件。

  • .eslintignore: 哪些文件需要忽略校验规则。

eslint的使用,本质上是对.eslintrc.js文件进行相关配置。

有时候我们不希望eslint校验所有文件。类似git提交时,我们不希望所有文件都被git识别并提交,此时就有了.gitignore文件去指定git应该忽略的文件。.eslintignore的作用也是如此,指定eslint应该忽略哪些文件去做代码校验。

  • 步骤4:  配置.eslintignore文件。
dist
node_modules
webpack.config.js
  • 步骤5:  配置.eslintrc.js文件。
module.exports = {
  root: true,
  env: {
    node: true, // 启用node中全局变量
    browser: true, // 启用浏览器中全局变量
  },
  parserOptions: {
    ecmaVersion: 6,
    sourceType: "module",
  },
  rules: {
    "no-var": 2, // 不能使用 var 定义变量
  },
  extends: ["eslint:recommended"], // 继承 Eslint 规则
};
字段含义
root是否为根目录
env指定环境,使用 env 关键字指定你想启用的环境,并设置它们为 true
parserOptions解析配置选项
rules可以使用注释或配置文件修改你项目中要使用的规则,修改对应规则的值即可;"off"或0关闭规则,"warn"或1为开启规则,使用警告级别的错误,"error"或2开启规则,使用错误级别的错误
extends可以让eslint继承已经配置好的规则。

eslint的配置项很多,这里列举了一些字段的含义,详细的配置推荐大家去官网查阅。

在这里我们配置了eslint并设置了不允许使用var来定义变量这一规则,一旦使用var定义变量,eslint会报error级别的错误,并中止程序。

我们在src/index.js中用var定义一个变量,运行webpack查看结果

7.png

eslint的校验符合预期,大功告成。接下来我们进行ts校验的相关配置。

2.5.2 校验ts-tsc

校验ts有2种方式,使用eslinttsc

eslint的校验笔者在参考其他资料后做出的配置仅能校验js文件,不能校验ts文件。下面贴出配置,欢迎大家讨论,给出解决方案。

  • 步骤1:  配置.eslintrc.js文件。
module.exports = {
  // 继承 Eslint 规则
  root: true,
  env: {
    node: true, // 启用node中全局变量
    browser: true, // 启用浏览器中全局变量
  },
  parserOptions: {
    ecmaVersion: 2021,
    sourceType: "module",
  },
  rules: {
    // 禁止使用 var
    'no-var': 2,
    // 优先使用 interface 而不是 type
    '@typescript-eslint/consistent-type-definitions': [
    "error",
    "interface"
    ],
    '@typescript-eslint/no-explicit-any': 'off',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
  },
  parser: "@typescript-eslint/parser",
  plugins: ["@typescript-eslint"],
  extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
};
  • 步骤2:  更改components/Add/index.ts文件。
const add = (a: number, b: number):number => a + b

const Hello = () => {
  const result = add(2,'o')
  console.log(result)
}

export default Hello
  • 步骤3:  配置package.json文件的script脚本。
  "scripts": {
    "lint:es": "eslint --ext .ts src/",
    "lint:tsc": "tsc"
  },

这里我们把调用add函数的入参改成了numberstring类型的参数。这不符合定义函数时需要的入参类型,理论上校验时需要报错,但执行yarn lint:es的时候,编译并未报错。如果在这个ts文件里用var定义一个变量它会报错(遵循了no-var的原则)。

这里欢迎各位尝试,找出原因给出解决方案,接下来我们讲解tsc校验。

tsc校验的步骤如下:

  • 步骤1:  根目录新增tsconfig.json文件。
├─ src
├─ .gitignore
└─ README.md
└─ .eslintignore
└─ .eslintrc.js
└─ tsconfig.json
└─ webpack.config.js
└─ package.json
  • 步骤2:  配置tsconfig.json文件。
{
  "compilerOptions": {
    "target": "es5",
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
  },
  "include": [
    "src"
  ]
}

tsconfig.json指定ts的一些解析规则和方式,如果没有这个json文件,需要在命令行中做大量配置,这样会非常麻烦。

配置完成后执行yarn lint:tsc查看结果:

8.png

符合预期正常报错。

注意,这里配置的scripts报错不会阻碍程序的正常运行;脚本命令的校验也是一次性的;我们更改下scripts脚本进行功能上的优化。

"scripts": {
    "lint:es": "eslint --ext .ts src/",
    "lint:tsc": "tsc --watch",
    "start": "tsc && webpack serve"
  },
  • lint:tsc: 保持ts校验时刻生效。

  • start: 运行webpack serve前进行ts校验,如果有报错,本次服务不会被开启。

每当我们yarn start成功的时候,再额外开启一个终端执行yarn lint:tsc就能保持ts的语法校验时刻生效了。

测试完校验功能后,记得把错误的代码改回去,避免影响接下来的演示。

2.6 省略引入文件后缀名-resolve

我们看看开发中“省略引入文件后缀名”这个问题发生的场景,查看src/index.js的内容:

import Header from './components/Header/index.js'
import Hello from './components/Add/index.ts'

Header()
Hello()

日常开发中我们有很多import的使用,为了方便开发我们希望引用时能省略文件后缀名如.js .ts。如果直接在import语句中去掉文件后缀名,编译会报错:

9.png

此时可以配置resolveextensions属性,它会按顺序解析这些后缀名。

  • 步骤1:  修改webpack.config.js
module.exports = {
  //...

  resolve:{
    // ...
    extensions: [".js", ".ts"]
  },
  
  // ...
}
  • 步骤2:  修改src/index.js的import。
import Header from './components/Header/index'
import Hello from './components/Add/index'

虽然我们省略了2个index文件的后缀名,但extensions会按顺序解析省略的后缀,编译仍然成功。

10.png

这下明白在Vue/React的官方脚手架中,为什么我们引入文件时省略后缀也能正常运行了吧,就是配置了resolveextensions属性的缘故。

到这里,“优化开发体验”这一章节的内容就全部讲解完毕了,我们进入下一个章节的讲解。

三. 提升构建速度

本章节我们从规则匹配,排除/包含文件,babel缓存,缓存其他资源,多进程打包等方面讲解如何提升webpack的构建速度。

3.1 规则匹配-oneOf

webpack打包时每个文件都会经过所有 loader 处理,虽然loader并不会处理与test不匹配的文件。但文件还是会遍历所有的loader,导致匹配变慢。

解决方式使用oneOf,让文件匹配上对应的loader后,就不与其他loader做匹配。修改webpack.config.js配置:

module.exports = {
  // ...

  module: {
    rules: [{
      oneOf: [{
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      }, {
        // 匹配less文件
        test: /\.less$/,
        // loader的使用顺序 less-loader,css-loader,style-loader
        use: [
          'style-loader',
          'css-loader',
          'less-loader'
        ]
      }
      // ...
      ]
    }]
  },
  
  // ...
}

3.2 排除/包含文件-exclude,include

我们使用loader处理js ts文件时,要排除node_modules下面的文件,或直接指定只处理src目录下的文件,通过配置babel-loader达成效果,注意exclude/include为互斥关系,二者只能开启其中的一种。

{
    test: /\.(js|ts)$/,
    // exclude: /node_modules/,
    include: resolvePath('./src'),
    use: 'babel-loader',
}

3.3 babel缓存

每次处理js ts文件都要经过eslint校验和babel编译,速度较慢。我们可以开启缓存,将上次编译的结果缓存起来,提升构建速度。

  • 步骤1:  修改babel-loader配置开启babel缓存。
{
    test: /\.(js|ts)$/,
    exclude: /node_modules/,
    // include: resolvePath('./src'),
    loader: 'babel-loader',
    options: {
      cacheDirectory: true, // 开启babel编译缓存
      cacheCompression: false, // 缓存文件不压缩
    }
}

这里解释一下为何需要关闭缓存压缩。

生产环境的代码无需用上这些压缩缓存文件。如果在开发模式中开启缓存压缩,执行压缩的过程需要耗费一定的时间。为了更快的构建速度,这里选择关闭该功能。

  • 步骤2:  修改plugins中ESLintWebpackPlugin的配置开启eslint缓存。
new ESLintWebpackPlugin({
  // 指定检查文件的根目录
  context: resolvePath('./src'),
  exclude: "node_modules", // 默认值
  cache: true, // 开启缓存
  // 缓存目录
  cacheLocation: cacheLocation: resolvePath('../node_modules/.cache/.eslintcache')
})

运行查看结果,cache文件缓存了babel的解析。

11.png

3.4 缓存其他资源-cache-loader

如果想缓存编译的其他资源我们需要使用cache-loader,现在尝试缓存sass-loader的编译结果:

  • 步骤1:  安装cache-loader。

yarn add cache-loader -D

  • 步骤2:  修改sass-loader的配置,cache-loader只能配置在MiniCssExtractPlugin.loadercss-loader之间做缓存。
{
    test: /\.s[ac]ss$/,
    use: [
      MiniCssExtractPlugin.loader,
      'cache-loader',
      'css-loader',
      'sass-loader'
    ]
}

yarn start查看结果,cache-loader生效。

12.png

3.5 多进程打包-thread-loader

当项目过大,打包时间过长时,我们使用多进程的打包方式加快打包速度。需要注意:每个进程启动时长大约600ms,因此务必要在项目足够大,打包时间过长的时候才开启此功能。 开启步骤如下:

  • 步骤1:  安装thread-loader。

yarn add thread-loader -D

  • 步骤2:  在webpack.config.js中引入thread-loader。
const os = require('os')
//  cpu逻辑处理器个数
const threads = os.cpus().length
  • 步骤3:  修改webpack.config.js中的babel-loader配置。
{
    test: /\.(js|ts)$/,
    exclude: /node_modules/,
    // include: resolvePath('./src'),
    use: [{
      loader: "thread-loader", // 开启多进程
      options: {
        workers: threads, // 数量
      },
    }, {
      loader: 'babel-loader',
      options: {
        cacheDirectory: true, // 开启babel编译缓存
        cacheCompression: false, // 缓存文件不压缩
      }
    }]
}

这样就开启了多进程打包模式。

至此提升构建速度的常用优化手段就讲解完毕,我们进入下一章的讲解。

四. 减少构建体积

本章我们将从treeShaking,资源压缩等方面讲解如何减少webpack的构建体积。

4.1 treeShaking

treeShakig可以筛去在JS上下文中未被引用的代码,它依赖ES Module,默认是开启的状态无需其他配置。

将webpack的mode改为production正常打包项目时,打包结果如下:

14.png

修改src/index.js注释掉Hello的引用,然后看看它是如何作用的。

import Header from './components/Header/index'
import Hello from './components/Add/index'

Header()
// Hello()

打包查看结果。

13.png

可以看到Hello这个chunk的结果console.log(5),未被打包到bundle中,这就是treeShaking,那么treeShaking是100%生效吗?不一定,我们先来了解下treeShaking的机制。

treeShaking会筛除以下两种代码:

  • 未被引用的代码

  • 无副作用的代码

未被引用很好理解,就像上述的例子,Hello模块仅仅被import,但是未被调用。打包时自然删除了这个模块的代码。

副作用(sideEffects) 的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export,如会影响到全局应用的一些文件。

webpack4中默认将所有代码视为有副作用,避免打包时删除一些必要文件。所以webpack4中的默认行为不支持treeShaking,webpack5则是默认支持的。

无论是否默认支持,我们都可以通过package.json中的sideEffects字段,指定哪些文件有副作用。

我们做个测试,更改package.json的配置:

"sideEffects": false,

"sideEffects": false配置了所有文件都无副作用,所以在打包后样式被筛除了。

15.png

当然如果我们设置"sideEffects": true,或者直接拿掉这个属性(webpack5默认开启)。样式就回来了,如下所示:

16.png

只能指定全部文件是否有副作用这不够灵活,所以sideEffects还支持以数组的形式配置哪些文件有副作用,此数组支持简单的 glob 模式匹配相关文件。其内部使用了 glob-to-regexp(支持:***{a,b}[a-z])。

比如:"sideEffects": ["*.css","*.common.js"]会指定所有的css文件和后缀为.common.js的文件有副作用。treeShaking的时候,即时它们未被使用也不会被筛除了。

4.2 资源压缩

资源压缩从css,js,图片三个层面跟大家展开讲解。

4.2.1 css压缩

css压缩需要用到css-minimizer-webpack-plugin,使用步骤如下:

  • 步骤1:  安装css-minimizer-webpack-plugin

yarn add css-minimizer-webpack-plugin -D

  • 步骤2:  配置webpack.config.js
const CssMinimizerPlugin  = require('css-minimizer-webpack-plugin'

module.exports = {
  // ...
  plugins: [
    //...
    new CssMinimizerPlugin()
  ],
  
  // ...
}

也可以通过配置webpack.config.jsoptimization属性开启css压缩,需要注意开启后会影响到下文要讲解的js压缩,开启方式如下:

module.exports = {
  optimization: {
    minimizer: [
      // 在 webpack@5 中,你可以使用 `...` 语法来扩展现有的 minimizer(即 `terser-webpack-plugin`),将下一行取消注释
      // `...`,
      new CssMinimizerPlugin(),
    ],
  },
}

4.2.2 js压缩

webpack5的生产模式默认开启了js和html的压缩。但是在webpack.config.js中,如果单独配置了optimization会导致默认的js压缩失效,此时需要我们手动去配置压缩功能。

上个小节我们自己配置了optimization属性,我们来看看打包后生产环境代码的样子:

17.png

显然这是未压缩的代码。接下来我们手动配置下js的压缩功能。

压缩js的plugin是terser-webpack-plugin,这个plugin在webpack5中已被内置,开箱即用,我们无需下载只需要做好配置即可。

const TerserPlugin = require('terser-webpack-plugin')

module.exports = {
  // ...
  optimization: {
    minimize: true,
    minimizer: [
      new CssMinimizerPlugin(),
      new TerserPlugin()
    ],
  },
  
  // ...
}

配置完成后,打包查看结果,js已被压缩:

18.png

4.2.3 图片资源压缩

将小于指定体积的图片转化成data URI的Base64形式的资源。

Base64是一种基于 64 个可打印字符来表示二进制数据的表示方法。它常用于在处理文本数据的场合,表示、传输、存储一些二进制数据,包括 MIME 的电子邮件及 XML 的一些复杂数据。

图片的Base64编码就是可以将一幅图片数据编码成一串字符串,使用该字符串代替图片地址,从而不需要使用图片的 URL 地址,这有利于减少请求的数量。

搞明白这个东西后,我们开始实践,在demo中引入几张图片查看效果:

19.png

接下来我们将最左边的图片进行Base64转换,配置webpack.config.js让小于60kb的图片变成base64格式,找到之前处理图片资源的loader进行如下配置即可:

{
    test: /\.(jpe?g|png|gif|webp|svg)$/,
    type: 'asset',
    generator: {
      filename: 'assets/img/[hash:10][ext]'
    },
    parser: {
      dataUrlCondition: {
        maxSize: 60 * 1024 // 小于60kb的图片会被base64处理
      }
    }
},

重新执行yarn start查看结果,发现最左边的图片已经变成了Base64格式,不再多做网络请求。

20.png

即使我们将项目打包,左边的图片也不会被打入生产环境。

21.png

这就是Base64的处理方式。至此我们常用的减少构建体积的方法就介绍完毕了,我们进入下一个章节的讲解。

五. 优化应用性能

本章我们将通过代码分割,动态导入模块,缓存文件,CDN加载资源等方面讲解如何优化webpack的应用性能。

5.1 代码分割

代码分割起到“化整为零”的作用,它可以把代码分到不同的bundle中,以减小单个bundle的体积。之后按照使用需求,将这些代码按需加载或并行加载。如果使用合理,它能极大减少应用程序的加载时间以提升应用性能。

举个例子,假设我们的应用程序在打包后生成了一个10M的bundle文件,用户在进入应用首页时就要加载这个10M的JS脚本。如果网速不快,加载时间会变得极为漫长,这显然拖垮了应用的整体性能。

理想情况是webpack帮我们把应用程序的代码做了分割,每个模块的代码体积都很小。我们进入哪个模块,就加载对应模块的文件,程序空闲时它会异步或并行加载其他资源,这样就能提升我们应用的性能。

分割代码的方式有2种,entry入口分离,splitChunks下面我们看看二者如何使用。

在正式开始前我们将之前的src文件做个备份,并调整src目录结如下(同名文件不做改动,有其他改动在下方已经说明):

├─ src
│  ├─ components
│  │  ├─ Add
│  │  │  └── index.ts
│  │  ├─ Header
│  │  │  └── index.js
│  ├─ css
│  │  └── index.scss
│  ├─ index.html
│  └─ index.js
│  └─ main.js

src/index.js内容如下:

import Header from './components/Header/index'
import Hello from './components/Add/index.ts'

console.log('Hello Index!')

Header()
Hello()

src/main.js内容如下:

import Hello from './components/Add/index.ts'

console.log('Hello Main!')

Hello()

5.1.1 entry入口分离

  • 步骤1: 配置webpack.config.js,关闭js压缩功能,并重新配置entry。
entry: {
    index: './src/index.js',
    main: './src/main.js',
}
  • 步骤2: 打包查看结果,两个入口js文件引用的Add下的Hello模块都能正常运行。

22.png

打包后的dist结构如下,可以看到2个js文件都被单独打包了出来:

23.png

查看编译后的index.js

23.png

编译后的main.js

24.png

可以看到二者共同引用的模块Hello,被重复地分别打包进了indexmain这2个bundle中。这代码分离了,又好像没分离。我们希望通用的代码只被打包一次然后引用即可,此时就需要使用到splitChunks

5.1.2 splitChunks

splitChunks的配置方式较为复杂,详细介绍请查看官方文档。这里我们挑取最简单的配置展示给大家看。

// ...

module.exports = {
  // ...

  optimization: {
    // ...
    splitChunks:{
      // 对所有模块进行分割
      chunks:'all',
      cacheGroups: {
        default: {
          // chunks需达到一定体积才能被分割,我们定义的chunk体积太小,所以更改生成 chunk 的最小体积(以 bytes 为单位)。
          minSize: 0,
          minChunks: 2,
          priority: -20,
          // 如果当前 chunk 包含已从主 bundle 中拆分出的模块,则它将被重用,而不是生成新的模块
          reuseExistingChunk: true,
        }
      }
    }
  },

  // ...
}

更改分割策略后,打包代码查看结果,发现新生成了一个名为749的bundle文件:

26.png

这个bundle的内容,就是我们Add文件里的内容

27.png

接下来查看src/index.js的bundle内容,发现它引入了749这个bundle,原先Add中的Hello模块的内容并没有被重新定义。

28.png

再看看src/main.js的bundle,发现也是如此。

29.png

这就是splitChunks,它帮助我们分割出通用的代码避免重复打包。

5.2 动态导入模块

应用在初始化时不会一次加载全部资源,当它需要使用某个功能时加载对应功能的模块,这就是模块的动态导入。动态导入模块的方式有懒加载,预获取/预加载,它的使用方式是用import引入需要懒加载的模块,下面我们进行详细讲解。

5.2.1 懒加载

懒加载就是上文提到的“用到谁加载谁”的出处。在使用懒加载前,我们调整目录结构,新增一个Minus组件。

├─ src
│  ├─ components
│  │  ├─ Add
│  │  │  └── index.ts
│  │  ├─ Header
│  │  │  └── index.js
│  │  ├─ Minus
│  │  │  └── index.js
│  ├─ css
│  │  └── index.scss
│  ├─ index.html
│  └─ index.js
│  └─ main.js

组件内容如下:

const Minus = (a, b) => {
  return a - b
}

export default Minus

接着更新src/index.js的内容,使用import引入需要懒加载的Minus组件,并将它打印出来:

import Header from './components/Header/index'
import Hello from './components/Add/index.ts'

console.log('Hello Index!')

Header()
Hello()

const body = document.body
const btn = document.createElement("button")
btn.innerText = '点击加载Minus组件'
body.append(btn)

const btnDom = document.querySelector('button')
btnDom.addEventListener('click',() => {
  console.log(import("./components/Minus/index"))
})

运行yarn start查看内容:

30.png

点击按钮查看加载资源的变化:

31.png

前后对比发现多了个375.js的bundle文件,点开文件查看内容,发现这就是Minus组件的bundle:

32.png

接下来再去控制台看看console打印了什么内容:

33.png

从打印的结果可以看出,Minus组件以ES Module为形式,Promise为结果被引入到index.js中。如果想在index.js中使用Minus组件我们更改下面的内容:

btnDom.addEventListener('click',() => {
  import("./components/Minus/index").then((res) => {
    // 模块暴露的方式为默认暴露所以调用default方法使用
    const result = res.default(5,3)
    console.log(res)
    console.log(result)
  })
})

点击按钮查看结果,模块已被正常加载并使用:

34.png

如果想给懒加载的bundle命名,可以在import时添加对应的魔法注释:

import(/* webpackChunkName:"Minus" */ "./components/Minus/index").then((res) => {
    // 模块暴露的方式为默认暴露所以调用default方法使用
    const result = res.default(5,3)
    console.log(res)
    console.log(result)
})

再次点击按钮查看加载的资源文件,此时这个bundle名称由原来的375变成了我们更改的命名Minus

35.png

懒加载的使用方法是不是看着格外眼熟?在Vue或React中,路由的懒加载方式也是如此。

懒加载很好用,但它仍然有一些不足之处,当我们需要懒加载的资源过大,加载时间过长,会导致应用的体验变差。此时我们需要一个更好的解决方法——预加载。

5.2.2 预加载

预获取/预加载的使用很简单,扩展一下魔法注释即可,写法如下:

import(/* webpackChunkName:"Minus", webpackPrefetch: true */ "./components/Minus/index").then((res) => {
    // 模块暴露的方式为默认暴露所以调用default方法使用
    const result = res.default(5,3)
    console.log(res)
    console.log(result)
})

启动服务查看结果,在点击按钮前Minus组件就被加载了进来:

36.png

应用的head标签内通过link标签将Minus以prefetch的方式加载了下来。

37.png

点击按钮后,查看结果,Minus正常运行:

38.png

这就是预加载,它能在浏览器空闲的时候,去加载我们指定的资源。它只会加载资源,并不执行。

5.3 缓存文件

浏览器的缓存技术能极大减少客户端访问应用的时间,提升用户体验。但如果新打包的bundle文件名称未被修改,会导致浏览器申请资源时总是触发缓存机制,使客户端无法获取到最新的资源。

本小节我们通过配置output属性,确保编译的bundle能被客户端缓存,在资源发生变动时也能请求到新的文件。配置方式如下:

output: {
    // ...
    filename: 'scripts/[name].[contenthash:10].js',
  }

这里获取了对应bundle的文件内容,取文件内容的hash值前10位为扩展后缀名。在文件发生更改时,hash后缀会产生变化,反之则无变化。 打包后dist内容如下:

39.png

此时bundle文件都加上了hash后缀,这里我们给Minus组件新增一条console语句再打包看看前后变化:

console.log('我是Minus组件')

const Minus = (a, b) => {
  return a - b
}

export default Minus

40.png

可以看出Minus, index对应的bundle和map文件命名发生改变,main, 749命名未被改变。

Minus因为内容改变,所以contenthash被改变,可index的contenthash为什么也会被改变呢?我们看看index的bundle文件。

41.png

这里可以看出index依赖Minus,在index的bundle中保存了Minus的hash值。如果Minus发生变化,index的bundle中记录Minushash值的这部分也会发生变化。导致index的文件内容发生改变,从而引起index的contenthash被更改。

这里很好理解,假设某个bundle发生了改变,这个bundle和依赖该模块的bundle的contenthash都会发生改变。这种“依赖性改变”非常合情合理对吧?......对吗?

显然这是不合理的,我们希望webpack只专注于产生了实质性改变的文件,关联文件不受影响,从而让缓存更加持久。处理这种问题就需要配置runtimeChunk属性。

runtimeChunk会把文件之间依赖的映射关系提取成单独的文件保管,这个文件就叫runtime文件。如果Minus发生改变只有它和依赖它的runtime文件会被改变,index不被改变。具体配置如下:

optimization: {
    // ...

    runtimeChunk: {
      name: entryChunk => `runtime-${entryChunk.name}.js`
    }
  },

这里我们给runtime文件加上了runtime-的前缀,重新打包后如下所示,新增了indexmain的依赖runtime文件:

42.png

我们更改Minus组件,去掉刚刚添加的console语句,再次打包查看结果:

43.png

可以看出只有Minus, runtime-index文件以及它们的map文件发生了变化,index文件本身并没有发生变化。这样就保证了缓存的持久性,让用户体验更好。

5.4 CDN加载资源——externals

好了到了最后一个环节,如何让webpack使用CDN加载资源。这里需要配置externals属性,它的作用是防止将某些import的资源打包到bundle中,在运行时,再去外部获得这些扩展依赖。

这里我们以Vue为例,进行CDN的加载并使用。

  • 步骤1:   src/index.html中引入CDN资源:
<body>
  <script src="https://unpkg.com/vue@next"></script>
</body>
  • 步骤2:   配置webpack.config.js
// ...

module.exports = {
  // ...

  externals: {
    vue: 'Vue',
  },

  mode: 'production'
}
  • 步骤3:   main.js引入Vue并使用:
import Hello from './components/Add/index.ts'
import {ref} from 'vue'

console.log('Hello Main!')

Hello()

const a = ref('cengfan')
console.log(a)

执行yarn start查看打印结果:

44.png

可以看到用ref声明的变量被正确打印了出来。到这里我们就成功引入并使用了Vue的CDN资源。

从引入到使用,这一套流程是如何走通的呢?下面我们在main.js中打印一下window对象跟大家进行全流程的讲解。

45.png

  • 步骤1: index.htmlscript引入的Vue,被挂载在了window对象上。

  • 步骤2: 因为externals的配置,main.js或整个项目中,import *** from 'vue'中的vue不再去node_modules中寻找,而是从全局对象window中寻找。那有人就有疑问了,全局对象是Vue,import的是vue这是如何匹配上的呢,我们来看下一个步骤。

  • 步骤3: import *** from 'vue'中的vueexternals的key匹配,window中的Vueexternals的value匹配。

externals中的key-value映射关系如下表:

key-vuevalue-Vue
import *** from 'vue'window中的Vue

再举个例子帮助理解,修改main.jsimport语句将from 'vue'变成from 'cengfan'

import Hello from './components/Add/index.ts'
import {ref} from 'cengfan'

console.log('Hello Main!')

Hello()

const a = ref('cengfan')
console.log(window)
console.log(a)

修改webpack.config.jsexternals属性:

externals: {
    cengfan: 'Vue',
  },

启动服务后,仍能正常打印结果。

44.png

这下理解externals的作用了吧,到这里它的使用就介绍完毕了,“优化应用性能”这一章节也讲解完毕。

至此,webpack常见的优化手段已介绍完毕,看到这里恭喜大家都能学成下山了。当然如开头所说,笔者介绍的方法肯定是不够全面的,如有补充欢迎各位读者列在评论区供大家一起参考学习。

六. 尾巴

webpack系列的第二个坑正式填完,这也是我在掘金的第一篇万字文章,马一下留个纪念。

本篇文章万字,45张图,希望体系化的总结,大量的讲解和案例对你有所帮助。

前段时间工作量较大,距离上次更新也过去了将近2个月,不过好饭不怕晚,祝大家用餐愉快。本系列的下一篇实战篇——“使用webpack从0到1搭建React的开发环境”即将新建文件夹,有缘我们下次再见!

我是来蹭饭,一个会点儿吉他和编曲,绞尽脑汁想傍个富婆的摸鱼大师,希望本次的分享对你有帮助。

end.jpg

最后贴上本文的参考资料链接: