阅读 2957

从今天开始,学习Webpack,减少对脚手架的依赖(上)

问:这篇文章适合哪些人?
答:适合没接触过Webpack或者了解不全面的人。

问:这篇文章的目录怎么安排的?
答:先介绍背景,由背景引入Webpack的概念,进一步介绍Webpack基础、核心和一些常用配置案例、优化手段,Webpack的plugin和loader确实非常多,短短2w多字还只是覆盖其中一小部分。

问:这篇文章的出处?
答:此篇文章知识来自付费视频(链接在文章末尾),文章由自己独立撰写,已获得讲师授权并首发于掘金。

下一篇:从今天开始,学习Webpack,减少对脚手架的依赖(下)

如果你觉得写的不错,请给我点一个star,原博客地址:原文地址

Webpack

注意,本篇博客 Webpack 版本是4.0+,请确保你安装了Node.js最新版本。

Webpack 的核心概念是一个 模块打包工具,它的主要目标是将js文件打包在一起,打包后的文件用于在浏览器中使用,但它也能胜任 转换(transform)打包(bundle)包裹(package) 任何其他资源。

追本溯源

在学习 Webpack 之前,我们有必要来了解一下前端领域的开发历程,只有明白了这些开发历程,才能更加清楚 Webpack 是怎么应运而生的,又能给我们解决什么样的问题。

面向过程开发

特征: 一锅乱炖
在早期 js 能力还非常有限的时候,我们通过面向过程的方式把代码写在同一个.js文件中,一个面向过程的开发模式可能如下所示:

<!-- index.html代码 -->
<p>这里是我们网页的内容</p>
<div id="root"></div>
<script src="./index.js"></script>
复制代码
// index.js代码
var root = document.getElementById('root');

// header模块
var header = document.createElement('div');
header.innerText = 'header';
root.appendChild(header);

// sidebar模块
var sidebar = document.createElement('div');
sidebar.innerText = 'sidebar';
root.appendChild(sidebar);

// content模块
var content = document.createElement('div');
content.innerText = 'content';
root.appendChild(content);
复制代码

面向对象开发

特征: 面向对象开发模式便于代码维护,深入人心。
随着 js 的不断发展,它所能解决的问题也越来越多,如果再像面向过程那样把所有代码写在同一个.js文件中,那么代码将变得非常难以理解和维护,此时面向对象开发模式便出现了,一个面向对象开发模式可能如下所示:

index.html中引入不同的模块:

<!-- index.html代码 -->
<p>这里是我们网页的内容</p>
<div id="root"></div>
<script src="./src/header.js"></script>
<script src="./src/sidebar.js"></script>
<script src="./src/content.js"></script>
<script src="./index.js"></script>
复制代码
// header.js代码
function Header() {
  var header = document.createElement('div');
  header.innerText = 'header';
  root.appendChild(header);
}
复制代码
// sidebar.js代码
function Sidebar() {
  var sidebar = document.createElement('div');
  sidebar.innerText = 'sidebar';
  root.appendChild(sidebar);
}
复制代码
// content.js代码
function Content() {
  var content = document.createElement('div');
  content.innerText = 'content';
  root.appendChild(content);
}

复制代码
// index.js代码
var root = document.getElementById('root');
new Header();
new Sidebar();
new Content();
复制代码

不足: 以上的代码示例中,虽然使用面向对象开发模式解决了面向过程开发模式中的一些问题,但似乎又引入了一些新的问题。

  1. 每一个模块都需要引入一个.js文件,随着模块的增多,这会影响页面性能
  2. index.js文件中,并不能直接看出模块的逻辑关系,必须去页面才能找到
  3. index.html页面中,文件的引入顺序必须严格按顺序来引入,例如:index.js必须放在最后引入,如果把header.js文件放在index.js文件后引入,那么代码会报错

现代开发模式

特征: 模块化加载方案让前端开发进一步工程化
根据面向对象开发模式中的一系列问题,随后各种模块化加载的方案如雨后春笋,例如:ES ModuleAMDCMD以及CommonJS等,一个ES Module模块化加载方案可能如下所示:

<!-- index.html代码 -->
<p>这里是我们网页的内容</p>
<div id="root"></div>
<script src="./index.js"></script>
复制代码
// header.js
export default function Header() {
  var root = document.getElementById('root');
  var header = document.createElement('div');
  header.innerText = 'header';
  root.appendChild(header);
}
复制代码
// sidebar.js
export default function Sidebar() {
  var root = document.getElementById('root');
  var sidebar = document.createElement('div');
  sidebar.innerText = 'sidebar';
  root.appendChild(sidebar);
}
复制代码
// content.js代码
export default function Content() {
  var root = document.getElementById('root');
  var content = document.createElement('div');
  content.innerText = 'content';
  root.appendChild(content);
}
复制代码
// index.js代码
import Header from './src/header.js';
import Sidebar from './src/sidebar.js';
import Content from './src/content.js';

new Header();
new Sidebar();
new Content();
复制代码

注意: 以上代码并不能直接在浏览器上执行,因为浏览器并不能直接识别ES Module代码,需要借助其他工具来进行翻译,此时 Webpack 就粉墨登场了。

Webpack初体验

不建议跟随此小结一起安装,此次示例仅仅作为一个例子,详细学习步骤请直接阅读下一章节

生成package.json文件

-y参数表示直接生成默认配置项的package.json文件,不加此参数需要一步步按需进行配置。

$ npm init -y
复制代码

生成的package.json文件:

{
  "name": "webpack-vuepress",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

复制代码

安装Webpack

-D参数代表在本项目下安装 Webpack ,它是--save-dev的简写

$ npm install webpack webpack-cli -D
复制代码

修改代码

Webpack默认打包路径到dist文件夹,打包后的js文件名字叫main.js

其他代码不动,将index.html中的.js文件改成如下引用方式(引用打包后的文件):

<!-- index.html代码 -->
<p>这里是我们网页的内容</p>
<div id="root"></div>
<script src="./dist/main.js"></script>
复制代码

Webpack打包

参数说明

  1. npx webpack代表在本项目下寻找 Webpack 打包命令,它区别npm命令
  2. index.js参数代表本次打包的入口是index.js
$ npx webpack index.js
复制代码

打包结果:

正如上面你所看到的那样,网页正确显示了我们期待的结果,这也是 Webpack 能为我们解决问题的一小部分能力,下面将正式开始介绍 Webpack 。

安装

全局安装

如果你只是想做一个 Webpack 的 Demo案例,那么全局安装方法可能会比较适合你。如果你是在实际生产开发中使用,那么推荐你使用本地安装方法。

全局安装命令

Webpack4.0+的版本,必须安装webpack-cli,-g命令代表全局安装的意思

$ npm install webpack webpack-cli -g
复制代码

卸载

通过npm install安装的模块,对应的可通过npm uninstall进行卸载

$ npm uninstall webpack webpack-cli -g
复制代码

本地安装(推荐)

本地安装的 Webpack 意思是,只在你当前项目下有效。而通过全局安装的Webpack,如果两个项目的 Webpack 主版本不一致,则可能会造成其中一个项目无法正常打包。本地安装方式也是实际开发中推荐的一种 Webpack 安装方式。

$ npm install webpack webpack-cli -D 或者 npm install webpack webpack-cli --save-dev
复制代码

版本号安装

如果你对Webpack的具体版本有严格要求,那么可以先去github的Webpack仓库查看历史版本记录或者使用npm view webpack versions查看Webpack的npm历史版本记录

// 查看webpack的历史版本记录
$ npm view webpack versions

// 按版本号安装
$ npm install webpack@4.25.0 -D
复制代码

起步

创建项目结构

现在我们来创建基本的项目结构,它可能是下面这样

|-- webpack-vuepress
|   |-- index.html
|   |-- index.js
|   |-- package.json
复制代码

其中package.json是利用下面的命令自动生成的配置文件

$ npm init -y
复制代码

添加基础代码

在创建了基本的项目结构以后,我们需要为我们创建的文件添加一些代码

index.html页面中的代码:

<p>这是最原始的网页内容</p>
<div id="root"></div>
<!-- 引用打包后的js文件 -->
<script src="./dist/main.js"></script>
复制代码

index.js文件中的代码:

console.log('hello,world');
复制代码

安装Webpack

运行如下命令安装webpack4.0+webpack-cli

$ npm install webpack webpack-cli -D
复制代码

添加配置文件

使用如下命令添加 Webpack 配置文件:

$ touch webpack.config.js
复制代码

使用此命令,变更后的项目结构大概如下所示:

|-- webpack-vuepress
|   |-- index.html
|   |-- index.js
|   |-- webpack.config.js
|   |-- package.json
复制代码

至此我们的基础目录已创建完毕,接下来需要改写webpack.config.js文件,它的代码如下:

// path为Node的核心模块
const path = require('path');
module.exports = {
  entry: './index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist')
  }
}
复制代码

配置参数说明:

  1. entry配置项说明了webpack打包的入口。
  2. output配置项说明了webpack输出配置,其中filename配置了打包后的文件叫main.js
  3. path配置了打包后的输出目录为dist文件夹下

改写package.json文件

改写说明:

  1. 添加private属性并设置为true,此属性能让我们的项目为私有的,防止意外发布代码
  2. 移除main属性,我们的项目并不需要对外暴露一个入口文件
  3. 添加scripts命令,即我们的打包命令

改写后的package.json文件如下所示:

{
  "name": "webpack-vuepress",
  "version": "1.0.0",
  "description": "",
  "private": true,
  "scripts": {
    "bundle": "webpack"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "webpack": "^4.31.0",
    "webpack-cli": "^3.3.2"
  }
}
复制代码

第一次打包

npm run代表运行一个脚本命令,而bundle就是我们配置的打包命令,即npm run bundle就是我们配置的webpack打包命令。

运行如下命令进行项目打包:

$ npm run bundle
复制代码

打包后的效果如下所示:

打包后的项目目录如下所示,可以看到我们多出了一个叫dist的目录,它里面有一个main.js文件

|-- dist
|   |-- main.js
|-- index.html
|-- index.js
|-- webpack.config.js
|-- package.json
复制代码

打包成功后,我们需要在浏览器中运行index.html,它的运行结果如下图所示

理解webpack打包输出

在上一节中,我们第一次运行了一个打包命令,它在控制台上有一些输出内容,这一节我们详细来介绍这些输出是什么意思

  1. Hash: hash代表本次打包的唯一hash值,每一次打包此值都是不一样的
  2. Version: 详细展示了我们使用webpack的版本号
  3. Time: 代表我们本次打包的耗时
  4. Asset: 代表我们打包出的文件名称
  5. Size: 代表我们打包出的文件的大小
  6. Chunks: 代表打包后的.js文件对应的idid0开始,依次往后+1
  7. Chunks Names: 代表我们打包后的.js文件的名字,至于为何是main,而不是其他的内容,这是因为在我们的webpack.config.js中,entry:'./index.js'是对如下方式的简写形式:
// path为Node的核心模块
const path = require('path');
module.exports = {
  // entry: './index.js',
  entry: {
    main: './index.js'
  }
  // 其它配置
}
复制代码
  1. Entrypoint main = bundle.js: 代表我们打包的入口为main
  2. warning in configuration: 提示警告,意思是我们没有给webpack.config.js设置mode属性,mode属性有三个值:development代表开发环境、production代表生产环境、none代表既不是开发环境也不是生产环境。如果不写的话,默认是生产环境,可在配置文件中配置此项,配置后再次打包将不会再出现此警告。
// path为Node的核心模块
const path = require('path');
module.exports = {
  // 其它配置
  mode: 'development'
}
复制代码

打包静态资源

什么是loader?

loader是一种打包规则,它告诉了 Webpack 在遇到非js文件时,应该如何处理这些文件

loader有如下几种固定的运用规则:

  • 使用test正则来匹配相应的文件
  • 使用use来添加文件对应的loader
  • 对于多个loader而言,从 右到左 依次调用

使用loader打包图片

打包图片需要用到file-loader或者url-loader,需使用npm install进行安装

$ npm install file-loader -D 或者 npm install url-loader -D
复制代码

一点小改动

在打包图片之前,让我们把index.html移动到上一节打包后的dist目录下,index.html中相应的.js引入也需要修改一下,像下面这样

// index.html的改动部分
<script src="./main.js"></script>
复制代码

添加打包图片规则

对于打包图片,我们需要在webpack.config.js中进行相应的配置,它可以像下面这样:

// path为Node的核心模块
const path = require('path');
module.exports = {
  // 其它配置
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/,
        use: {
          loader: 'file-loader'
        }
      }
    ]
  }
}
复制代码

改写index.js

import avatar from './avatar.jpg'

var root = document.getElementById('root');
var img = document.createElement('img');
img.src = avatar
root.appendChild(img)
复制代码

打包后的项目目录

|-- dist
|   |-- bd7a45571e4b5ccb8e7c33b7ce27070a.jpg
|   |-- main.js
|   |-- index.html
|-- index.js
|-- avatar.jpg
|-- package.json
|-- webpack.config.js
复制代码

打包结果

运用占位符

在以上打包图片的过程中,我们发现打包生成的图片好像名字是一串乱码,如果我们要原样输出原图片的名字的话,又该如何进行配置呢?这个问题,可以使用 占位符 进行解决。

文件占位符它有一些固定的规则,像下面这样:

  • [name]代表原本文件的名字
  • [ext]代表原本文件的后缀
  • [hash]代表一个唯一编码

根据占位符的规则再次改写webpack.config.js文件,

// path为Node的核心模块
const path = require('path');
module.exports = {
  // 其它配置
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/,
        use: {
          loader: 'file-loader',
          options: {
            name: '[name]_[hash].[ext]'
          }
        }
      }
    ]
  }
}
复制代码

根据上面占位符的运用,打包生成的图片,它的名字如下

|-- dist
|   |-- avatar_bd7a45571e4b5ccb8e7c33b7ce27070a.jpg
复制代码

使用loader打包CSS

样式文件分为几种情况,每一种都需要不同的loader来处理:

  1. 普通.css文件,使用style-loadercss-loader来处理
  2. .less文件,使用less-loader来处理
  3. .sass或者.scss文件,需要使用sass-loader来处理
  4. .styl文件,需要使用stylus-loader来处理

打包css文件

首先安装style-loadercss-loader

$ npm install style-loader css-loader -D
复制代码

改写webpack配置文件:

// path为Node的核心模块
const path = require('path');
module.exports = {
  // 其它配置
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/,
        use: {
          loader: 'file-loader',
          options: {
            name: '[name]_[hash].[ext]'
          }
        }
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'] // 从右到左的顺序调用,所以顺序不能错
      }
    ]
  }
}
复制代码

根目录下创建index.css

.avatar{
  width: 150px;
  height: 150px;
}
复制代码

改写index.js文件

import avatar from './avatar.jpg';
import './index.css';

var root = document.getElementById('root');
var img = new Image();
img.src = avatar;
img.classList.add('avatar');
root.appendChild(img);
复制代码

打包结果

打包Sass文件

需要安装sass-loadernode-sass

$ npm install sass-loader node-sass -D
复制代码

改写webpack.config.js文件

// path为Node的核心模块
const path = require('path');
module.exports = {
  // 其它配置
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/,
        use: {
          loader: 'file-loader',
          options: {
            name: '[name]_[hash].[ext]'
          }
        }
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.(sass|scss)$/,
        use: ['style-loader','css-loader','sass-loader']
      }
    ]
  }
}
复制代码

根目录下添加index-sass.sass文件

body{
  .avatar-sass{
    width: 150px;
    height: 150px;
  }
}
复制代码

改写index.js

import avatar from './avatar.jpg';
import './index.css';
import './index-sass.sass';

var img = new Image();
img.src = avatar;
img.classList.add('avatar-sass');

var root = document.getElementById('root');
root.appendChild(img);
复制代码

根据上面的配置和代码改写后,再次打包,打包的结果会是下面这个样子

自动添加CSS厂商前缀

当我们在css文件中写一些需要处理兼容性的样式的时候,需要我们分别对于不同的浏览器书添加不同的厂商前缀,使用postcss-loader可以帮我们在webpack打包的时候自动添加这些厂商前缀。 自动添加厂商前缀需要npm install安装postcss-loaderautoprefixer

npm install postcss-loader autoprefixer -D
复制代码

修改index-sass.sass

.avatar-sass {
  width: 150px;
  height: 150px;
  transform: translate(50px,50px);
}
复制代码

在修改sass文件代码后,我们需要对webpack.config.js

// path为Node的核心模块
const path = require('path');
module.exports = {
  // 其它配置
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/,
        use: {
          loader: 'file-loader',
          options: {
            name: '[name]_[hash].[ext]'
          }
        }
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.(sass|scss)$/,
        use: ['style-loader','css-loader','sass-loader','postcss-loader'] // 顺序不能变
      }
    ]
  }
}
复制代码

根目录下添加postcss.config.js,并添加代码

module.exports = {
  plugins: [require('autoprefixer')]
}
复制代码

根据上面的配置,我们再次打包运行,在浏览器中运行index.html,它的结果如下图所示

模块化打包CSS文件

CSS的模块化打包的理解是:除非我主动引用你的样式,否则你打包的样式不能影响到我。

根目录下添加createAvatar.js文件,并填写下面这段代码

import avatar from './avatar.jpg';
export default function CreateAvatar() {
  var img = new Image();
  img.src = avatar;
  img.classList.add('avatar-sass');

  var root = document.getElementById('root');
  root.appendChild(img);
}
复制代码

改写index.js,引入createAvatar.js并调用

import avatar from './avatar.jpg';
import createAvatar from './createAvatar';
import './index.css';
import './index-sass.sass';

createAvatar();

var img = new Image();
img.src = avatar;
img.classList.add('avatar-sass');

var root = document.getElementById('root');
root.appendChild(img);
复制代码

打包运行

我们可以看到,在createAvatar.js中,我们写的img标签的样式,它受index-sass.sass样式文件的影响,如果要消除这种影响,需要我们开启对css样式文件的模块化打包。

进一步改写webpack.config.js

// path为Node的核心模块
const path = require('path');
module.exports = {
  // 其它配置
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/,
        use: {
          loader: 'file-loader',
          options: {
            name: '[name]_[hash].[ext]'
          }
        }
      },
      {
        test: /\.(sass|scss)$/,
        use: ['style-loader', {
          loader: 'css-loader',
          options: {
            modules: true
          }
        }, 'sass-loader', 'postcss-loader']
      }
    ]
  }
}
复制代码

开启css模块化打包后,我们需要在index.js中做一点小小的改动,像下面这样子

import avatar from './avatar.jpg';
import createAvatar from './createAvatar';
import './index.css';
import style from  './index-sass.sass';

createAvatar();

var img = new Image();
img.src = avatar;
img.classList.add(style['avatar-sass']);

var root = document.getElementById('root');
root.appendChild(img);
复制代码

打包运行后,我们发现使用createAvatar.js创建出来的img没有受到样式文件的影响,证明我们的css模块化配置已经生效,下图是css模块化打包的结果:

Webpack核心

使用WebpackPlugin

plugin的理解是:当 Webpack 运行到某一个阶段时,可以使用plugin来帮我们做一些事情。

在使用plugin之前,我们先来改造一下我们的代码,首先删掉无用的文件,随后在根目录下新建一个src文件夹,并把index.js移动到src文件夹下,移动后你的目录看起来应该是下面这样子的

|-- dist
|   |-- index.html
|-- src
|   |-- index.js
|-- postcss.config.js
|-- webpack.config.js
|-- package.json
复制代码

接下来再来处理一下index.js文件的代码,写成下面这样

// src/index.js
var root = document.getElementById('root');
var dom = document.createElement('div');
dom.innerHTML = 'hello,world';
root.appendChild(dom);
复制代码

最后我们来处理一下我们的webpack.config.js文件,它的改动有下面这些

  • 因为index.js文件的位置变动了,我们需要改动一下entry
  • 删除掉我们配置的所有loader规则 按照上面的改动后,webpack.config.js中的代码看起来是下面这样的
const path = require('path');
module.exports = {
  mode: 'development',
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname,'dist')
  }
}
复制代码

html-webpack-plugin

html-webpack-plugin可以让我们使用固定的模板,在每次打包的时候 自动生成 一个.html文件,并且它会 自动 帮我们引入我们打包后的.js文件

使用如下命令安装html-webpack-plugin

$ npm install html-webpack-plugin -D
复制代码

src目录下创建index.html模板文件,它的代码可以写成下面这样子

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Html 模板</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>
复制代码

因为我们要使用html-webpack-plugin插件,所以我们需要再次改写webpack.config.js文件(具体改动部分见高亮部分掘金无高亮)

const path = require('path');
const htmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  mode: 'development',
  entry: {
    main: './src/index.js'
  },
  plugins: [
    new htmlWebpackPlugin({
      template: 'src/index.html'
    })
  ],
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname,'dist')
  }
}
复制代码

在完成上面的配置后,我们使用npm run bundle命令来打包一下测试一下,在打包完毕后,我们能在dist目录下面看到index.html中的代码变成下面这样子

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>HTML模板</title>
</head>
<body>
  <div id="root"></div>
  <script type="text/javascript" src="main.js"></script>
</body>
</html>
复制代码

我们发现,以上index.html的结构,正是我们在src目录下index.html模板的结构,并且还能发现,在打包完成后,还自动帮我们引入了打包输出的.js文件,这正是html-webpack-plugin的基本功能,当然它还有其它更多的功能,我们将在后面进行详细的说明。

clean-webpack-plugin

clean-webpack-plugin它能帮我们在打包之前 自动删除dist打包目录及其目录下所有文件,不用我们手动进行删除。

我们使用如下命令来安装clean-webpack-plugin

$ npm install clean-webpack-plugin -D
复制代码

安装完毕以后,我们同样需要在webpack.config.js中进行配置(改动部分参考高亮代码块掘金无高亮)

const path = require('path');
const htmlWebpackPlugin = require('html-webpack-plugin');
const cleanWebpackPlugin = require('clean-webpack-plugin');
module.exports = {
  mode: 'development',
  entry: {
    main: './src/index.js'
  },
  plugins: [
    new htmlWebpackPlugin({
      template: 'src/index.html'
    }),
    new cleanWebpackPlugin()
  ],
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname,'dist')
  }
}
复制代码

在完成以上配置后,我们使用npm run bundle打包命令进行打包,它的打包结果请自行在你的项目下观看自动清理dist目录的实时效果。

在使用WebpackPlugin小节,我们只介绍了两种常用的plugin,更多plugin的用法我们将在后续进行讲解,你也可以点击Webpack Plugins来学习更多官网推荐的plugin用法。

配置SourceMap

SourceMap的理解:它是一种映射关系,它映射了打包后的代码和源代码之间的对应关系,一般通过devtool来配置。

以下是官方提供的devtool各个属性的解释以及打包速度对比图:

通过上图我们可以看出,良好的source-map配置不仅能帮助我们提高打包速度,同时在代码维护和调错方面也能有很大的帮助,一般来说,source-map的最佳实践是下面这样的:

  • 开发环境下(development):推荐将devtool设置成cheap-module-eval-source-map
  • 生产环境下(production):推荐将devtool设置成cheap-module-source-map

使用WebpackDevServer

webpack-dev-server的理解:它能帮助我们在源代码更改的情况下,自动*帮我们打包我们的代码并启动一个小型的服务器。如果与热更新一起使用,它能帮助我们高效的开发。

自动打包的方案,通常来说有如下几种:

  • watch参数自动打包:它是在打包命令后面跟了一个--watch参数,它虽然能帮我们自动打包,但我们任然需要手动刷新浏览器,同时它不能帮我们在本地启动一个小型服务器,一些http请求不能通过。
  • webpack-dev-server插件打包(推荐):它是我们推荐的一种自动打包方案,在开发环境下使用尤其能帮我们高效的开发,它能解决watch参数打包中的问题,如果我们与热更新(HMR)一起使用,我们将拥有非常良好的开发体验。
  • webpack-dev-middleware自编码启动小型服务器(不讲述)

watch参数自动打包

使用watch参数进行打包,我们需要在package.json中新增一个watch打包命令,它的配置如下

{
  // 其它配置
  "scripts": {
    "bundle": "webpack",
    "watch": "webpack --watch"
  }
}
复制代码

在配置好上面的打包命令后,我们使用npm run watch命令进行打包,然后在浏览器中运行dist目录下的index.html,运行后,我们尝试修改src/index.js中的代码,例如把hello,world改成hello,dell-lee,改动完毕后,我们刷新一下浏览器,会发现浏览器成功输出hello,dell-lee,这也证明了watch参数确实能自动帮我们进行打包。

webpack-dev-server打包

要使用webpack-dev-server,我们需要使用如下命令进行安装

$ npm install webpack-dev-server -D
复制代码

安装完毕后,我们和watch参数配置打包命令一样,也需要新增一个打包命令,在package.json中做如下改动:

// 其它配置
  "scripts": {
    "bundle": "webpack",
    "watch": "webpack --watch",
    "dev": "webpack-dev-server'
  }
复制代码

配置完打包命令后,我们最后需要对webpack.config.js做一下处理:

module.exports = {
  // 其它配置
  devServer: {
    // 以dist文件为基础启动一个服务器,服务器运行在4200端口上,每次启动时自动打开浏览器
    contentBase: 'dist',
    open: true,
    port: 4200
  }
}
复制代码

在以上都配置完毕后,我们使用npm run dev命令进行打包,它会自动帮我们打开浏览器,现在你可以在src/index.js修改代码,再在浏览器中查看效果,它会有惊喜的哦,ღ( ´・ᴗ・` )比心

这一小节主要介绍了如何让工具自动帮我们打包,下一节我们将讲解模块热更新(HMR)。

模块热更新(HMR)

模块热更新(HMR)的理解:它能够让我们在不刷新浏览器(或自动刷新)的前提下,在运行时帮我们更新最新的代码。

模块热更新(HMR)已内置到 Webpack ,我们只需要在webpack.config.js中像下面这样简单的配置即可,无需安装别的东西。

const webpack = require('webpack');
module.exports = {
  // 其它配置
  devServer: {
    contentBase: 'dist',
    open: true,
    port: 3000,
    hot: true, // 启用模块热更新
    hotOnly: true // 模块热更新启动失败时,重新刷新浏览器
  },
  plugins: [
    // 其它插件
    new webpack.HotModuleReplacementPlugin()
  ]
}
复制代码

在模块热更新(HMR)配置完毕后,我们现在来想一下,什么样的代码是我们希望能够热更新的,我们发现大多数情况下,我们似乎只需要关心两部分内容:CSS文件和.js文件,根据这两部分,我们将分别来进行介绍。

CSS中的模块热更新

首先我们在src目录下新建一个style.css样式文件,它的代码可以这样下:

div:nth-of-type(odd) {
  background-color: yellow;
}
复制代码

随后我们改写一下src目录下的index.js中的代码,像下面这样子:

import './style.css';

var btn = document.createElement('button');
btn.innerHTML = '新增';
document.body.appendChild(btn);

btn.onclick = function() {
  var dom = document.createElement('div');
  dom.innerHTML = 'item';
  document.body.appendChild(dom);
}
复制代码

由于我们需要处理CSS文件,所以我们需要保留处理CSS文件的loader规则,像下面这样

module.exports = {
  // 其它配置
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  }
}
复制代码

在以上代码添加和配置完毕后,我们使用npm run dev进行打包,我们点击按钮后,它会出现如下的情况

理解: 由于item是动态生成的,当我们要将yellow颜色改变成red时,模块热更新能帮我们在不刷新浏览器的情况下,替换掉样式的内容。直白来说:自动生成的item依然存在,只是颜色变了。

在js中的模块热更新

在介绍完CSS中的模块热更新后,我们接下来介绍在js中的模块热更新。

首先,我们在src目录下创建两个.js文件,分别叫counter.jsnumber.js,它的代码可以写成下面这样:

// counter.js代码
export default function counter() {
  var dom = document.createElement('div');
  dom.setAttribute('id', 'counter');
  dom.innerHTML = 1;
  dom.onclick = function() {
    dom.innerHTML = parseInt(dom.innerHTML,10)+1;
  }
  document.body.appendChild(dom);
}
复制代码

number.js中的代码是下面这样的:

// number.js代码
export default function number() {
  var dom = document.createElement('div');
  dom.setAttribute('id','number');
  dom.innerHTML = '1000';
  document.body.appendChild(dom);
}
复制代码

添加完以上两个.js文件后,我们再来对index.js文件做一下小小的改动:

// index.js代码
import counter from './counter';
import number from './number';
counter();
number();
复制代码

在以上都改动完毕后,我们使用npm run dev进行打包,在页面上点击数字1,让它不断的累计到你喜欢的一个数值(记住这个数值),这个时候我们再去修改number.js中的代码,将1000修改为3000,也就是下面这样修改:

// number.js代码
export default function number() {
  var dom = document.createElement('div');
  dom.setAttribute('id','number');
  dom.innerHTML = '3000';
  document.body.appendChild(dom);
}
复制代码

我们发现,虽然1000成功变成了3000,但我们累计的数值却重置到了1,这个时候你可能会问,我们不是配置了模块热更新了吗,为什么不像CSS一样,直接替换即可?

回答:这是因为CSS文件,我们是使用了loader来进行处理,有些loader已经帮我们写好了模块热更新的代码,我们直接使用即可(类似的还有.vue文件,vue-loader也帮我们处理好了模块热更新)。而对于js代码,还需要我们写一点点额外的代码,像下面这样子:

import counter from './counter';
import number from './number';
counter();
number();

// 额外的模块HMR配置
if(module.hot) {
  module.hot.accept('./number.js', () => {
    document.body.removeChild(document.getElementById('number'));
    number();
  })
}
复制代码

写完上面的额外代码后,我们再在浏览器中重复我们刚才的操作,即:

  • 累加数字1带你喜欢的一个值
  • 修改number.js中的1000为你喜欢的一个值

以下截图是我的测试结果,同时我们也可以在控制台console上,看到模块热更新第二次启动时,已经成功帮我们把number.js中的代码输出到了浏览器。

小结:在更改CSS样式文件时,我们不用书写module.hot,这是因为各种CSSloader已经帮我们处理了,相同的道理还有.vue文件的vue-loader,它也帮我们处理了模块热更新,但在.js文件中,我们还是需要根据实际的业务来书写一点module.hot代码的。

处理ES6语法

我们在项目中书写的ES6代码,由于考虑到低版本浏览器的兼容性问题,需要把ES6代码转换成低版本浏览器能够识别的ES5代码。使用babel-loader@babel/core来进行ES6ES5之间的链接,使用@babel/preset-env来进行ES6ES5

在处理ES6代码之前,我们先来清理一下前面小节的中的代码,我们需要删除counter.jsnumber.jsstyle.css这个三个文件,删除后的文件目录大概是下面这样子的:

|-- dist
|   |-- index.html
|   |-- main.js
|-- src
|   |-- index.html
|   |-- index.js
|-- package.json
|-- webpack.config.js
复制代码

要处理ES6代码,需要我们安装几个npm包,可以使用如下的命令去安装

// 安装 babel-loader @babel/core
$ npm install babel-loader @babel/core --save-dev

// 安装 @babel/preset-env
$ npm install @babel/preset-env --save-dev

// 安装 @babel/polyfill进行ES5代码补丁
$ npm install @babel/polyfill --save-dev
复制代码

安装完毕后,我们需要改写src/index.js中的代码,可以是下面这个样子:

import '@babel/polyfill';
const arr = [
  new Promise(() => {}),
  new Promise(() => {}),
  new Promise(() => {})
]

arr.map(item => {
  console.log(item);
})
复制代码

处理ES6代码,需要我们使用loader,所以需要在webpack.config.js中添加如下的代码:

module.exports = {
  // 其它配置
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader'
        }
      }
    ]
  }
}
复制代码

@babel/preset-env需要在根目录下有一个.babelrc文件,所以我们新建一个.babelrc文件,它的代码如下:

{
  "presets": ["@babel/preset-env"]
}
复制代码

为了让我们的打包变得更加清晰,我们需要在webpack.config.js中把source-map配置成none,像下面这样:

module.exports = {
  // 其他配置
  mode: 'development',
  devtool: 'none'
}
复制代码

本次打包,我们需要使用npx webpack,打包的结果如下图所示:

在以上的打包中,我们可以发现:

  • 箭头函数被转成了普通的函数形式
  • 如果你仔细观察这次打包输出的话,你会发现打包体积会非常大,有几百K,这是因为我们将@babel/polyfill中的代码全部都打包进了我们的代码中

针对以上最后一个问题,我们希望,我们使用了哪些ES6代码,就引入它对应的polyfill包,达到一种按需引入的目的,要实现这样一个效果,我们需要在.babelrc文件中做一下小小的改动,像下面这样:

{
  "presets": [["@babel/preset-env", {
    "corejs": 2,
    "useBuiltIns": "usage"
  }]]
}
复制代码

同时需要注意的时,我们使用了useBuiltIns:"usage"后,在index.js中就不用使用import '@babel/polyfill'这样的写法了,因为它已经帮我们自动这样做了。

在以上配置完毕后,我们再次使用npx webpack进行打包,如下图,可以看到此次打包后,main.js的大小明显变小了。

Webpack进阶

Tree Shaking

Tree Shaking是一个术语,通常用于描述移除项目中未使用的代码,Tree Shaking 只适用于ES Module语法(既通过export导出,import引入),因为它依赖于ES Module的静态结构特性。

在正式介绍Tree Shaking之前,我们需要现在src目录下新建一个math.js文件,它的代码如下:

export function add(a, b) {
  console.log(a + b);
}
export function minus(a, b) {
  console.log(a - b);
}
复制代码

接下来我们对index.js做一下处理,它的代码像下面这样,从math.js中引用add方法并调用:

import { add } from './math'
add(1, 4);
复制代码

在上面的.js改动完毕后,我们最后需要对webpack.config.js做一下配置,让它支持Tree Shaking,它的改动如下:

const path = require('path');
module.exports = {
  mode: 'development',
  devtool: 'source-map',
  entry: {
    main: './src/index.js'
  },
  optimization: {
    usedExports: true
  },
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname,'dist')
  }
}
复制代码

在以上webpack.config.js配置完毕后,我们需要使用npx webpack进行打包,它的打包结果如下:

// dist/main.js
"use strict";
/* harmony export (binding) */ 
__webpack_require__.d(__webpack_exports__, "a", function() { return add; });
/* unused harmony export minus */
function add(a, b) {
  console.log(a + b);
}
function minus(a, b) {
  console.log(a - b);
}
复制代码

打包结果分析:虽然我们配置了 Tree Shaking,但在开发环境下,我们依然能够看到未使用过的minus方法,以上注释也清晰了说明了这一点,这个时候你可能会问:为什么我们配置了Tree Shakingminus方法也没有被使用,但依然还是被打包进了main.js中?

其实这个原因很简单,这是因为我们处于开发环境下打包,当我们处于开发环境下时,由于source-map等相关因素的影响,如果我们不把没有使用的代码一起打包进来的话,source-map就不是很准确,这会影响我们本地开发的效率。

看完以上本地开发Tree Shaking的结果,我们也知道了本地开发Tree Shaking相对来说是不起作用的,那么在生产环境下打包时,Tree Shaking的表现又如何呢?

在生产环境下打包,需要我们对webpack.config.js中的mode属性,需要由development改为production,它的改动如下:

const path = require('path');
module.exports = {
  mode: 'production',
  devtool: 'source-map',
  entry: {
    main: './src/index.js'
  },
  optimization: {
    usedExports: true
  },
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname,'dist')
  }
}
复制代码

配置完毕后,我们依然使用npx webpack进行打包,可以看到,它的打包结果如下所示:

// dist/main.js
([function(e,n,r){
  "use strict";
  var t,o;
  r.r(n),
  t=1,
  o=4,
  console.log(t+o)
}]);
复制代码

打包代码分析:以上代码是一段被压缩过后的代码,我们可以看到,上面只有add方法,未使用的minus方法并没有被打包进来,这说明在生产环境下我们的Tree Shaking才能真正起作用。

SideEffects

由于Tree Shaking作用于所有通过import引入的文件,如果我们引入第三方库,例如:import _ from 'lodash'或者.css文件,例如import './style.css' 时,如果我们不 做限制的话,Tree Shaking将起副作用,SideEffects属性能帮我们解决这个问题:它告诉webpack,我们可以对哪些文件不做 Tree Shaking

// 修改package.json
// 如果不希望对任何文件进行此配置,可以设置sideEffects属性值为false
// *.css 表示 对所有css文件不做 Tree Shaking
// @babael/polyfill 表示 对@babel/polyfill不做 Tree Shaking
"sideEffects": [
  "*.css",
  "@babel/polyfill"
],
复制代码

小结:对于Tree Shaking的争议比较多,推荐看你的Tree Shaking并没有什么卵用,看完你会发现我们对Tree Shaking的了解真是太浅薄了。

区分开发模式和生产模式

像上一节那样,如果我们要区分Tree Shaking的开发环境和生产环境,那么我们每次打包的都要去更改webpack.config.js文件,有没有什么办法能让我们少改一点代码呢? 答案是有的!

区分开发环境和生产环境,最好的办法是把公用配置提取到一个配置文件,生产环境和开发环境只写自己需要的配置,在打包的时候再进行合并即可,webpack-merge 可以帮我们做到这个事情。

首先,我们效仿各大框架的脚手架的形式,把 Webpack 相关的配置都放在根目录下的build文件夹下,所以我们需要新建一个build文件夹,随后我们要在此文件夹下新建三个.js文件和删除webpack.config.js,它们分别是:

  • webpack.common.js:Webpack 公用配置文件
  • webpack.dev.js:开发环境下的 Webpack 配置文件
  • webpack.prod.js:生产环境下的 Webpack 配置文件
  • webpack.config.js删除根目录下的此文件

新建完webpack.common.js文件后,我们需要把公用配置提取出来,它的代码看起来应该是下面这样子的:

const path = require('path');
const htmlWebpackPlugin = require('html-webpack-plugin');
const cleanWebpackPlugin = require('clean-webpack-plugin');
module.exports = {
  entry: {
    main: './src/index.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader','css-loader']
      },
      { 
        test: /\.js$/, 
        exclude: /node_modules/, 
        loader: "babel-loader" 
      }
    ]
  },
  plugins: [
    new htmlWebpackPlugin({
      template: 'src/index.html'
    }),
    new cleanWebpackPlugin()
  ],
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname,'dist')
  }
}
复制代码

提取完 Webpack 公用配置文件后,我们开发环境下的配置,也就是webpack.dev.js中的代码,将剩下下面这些:

const webpack = require('webpack');
module.exports = {
  mode: 'development',
  devtool: 'cheap-module-eval-source-map',
  devServer: {
    contentBase: 'dist',
    open: true,
    port: 3000,
    hot: true,
    hotOnly: true
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
}
复制代码

而生产环境下的配置,也就是webpack.prod.js中的代码,可能是下面这样子的:

module.exports = {
  mode: 'production',
  devtool: 'cheap-module-source-map',
  optimization: {
    usedExports: true
  }
}
复制代码

在处理完以上三个.js文件后,我们需要做一件事情:

  • 当处于开发环境下时,把webpack.common.js中的配置和webpack.dev.js中的配置合并在一起
  • 当处于开发环境下时,把webpack.common.js中的配置和webpack.prod.js中的配置合并在一起

针对以上问题,我们可以使用webpack-merge进行合并,在使用之前,我们需要使用如下命令进行安装:

$ npm install webpack-merge -D
复制代码

安装完毕后,我们需要对webpack.dev.jswebpack.prod.js做一下手脚,其中webpack.dev.js中的改动如下(代码高亮部分掘金无高亮):

const webpack = require('webpack');
const merge = require('webpack-merge');
const commonConfig = require('./webpack.common');
const devConfig = {
  mode: 'development',
  devtool: 'cheap-module-eval-source-map',
  devServer: {
    contentBase: 'dist',
    open: true,
    port: 3000,
    hot: true,
    hotOnly: true
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
}
module.exports = merge(commonConfig, devConfig);
复制代码

相同的代码,webpack.prod.js中的改动部分如下:

const merge = require('webpack-merge');
const commonConfig = require('./webpack.common');
const prodConfig = {
  mode: 'production',
  devtool: 'cheap-module-source-map',
  optimization: {
    usedExports: true
  }
}
module.exports = merge(commonConfig, prodConfig);
复制代码

聪明的你一定想到了,因为上面我们已经删除了webpack.config.js文件,所以我们需要重新在package.json中配置一下我们的打包命令,它们是这样子写的:

"scripts": {
  "dev": "webpack-dev-server --config ./build/webpack.dev.js",
  "build": "webpack --config ./build/webpack.prod.js"
},
复制代码

配置完打包命令,心急的你可能会马上开始尝试进行打包,你的打包目录可能长成下面这个样子:

|-- build
|   |-- dist
|   |   |-- index.html
|   |   |-- main.js
|   |   |-- main.js.map
|   |-- webpack.common.js
|   |-- webpack.dev.js
|   |-- webpack.prod.js
|-- src
|   |-- index.html
|   |-- index.js
|   |-- math.js
|-- .babelrc
|-- postcss.config.js
|-- package.json
复制代码

问题分析:当我们运行npm run build时,dist目录打包到了build文件夹下了,这是因为我们把Webpack 相关的配置放到了build文件夹下后,并没有做其他配置,Webpack 会认为build文件夹会是根目录,要解决这个问题,需要我们在webpack.common.js中修改output属性,具体改动的部分如下所示:

output: {
  filename: '[name].js',
  path: path.resolve(__dirname,'../dist')
}
复制代码

那么解决完上面这个问题,赶紧使用你的打包命令测试一下吧,我的打包目录是下面这样子,如果你按上面的配置后,你的应该跟此目录类似

|-- build
|   |-- webpack.common.js
|   |-- webpack.dev.js
|   |-- webpack.prod.js
|-- dist
|   |-- index.html
|   |-- main.js
|   |-- main.js.map
|-- src
|   |-- index.html
|   |-- index.js
|   |-- math.js
|-- .babelrc
|-- postcss.config.js
|-- package.json
复制代码

代码分离(CodeSplitting)

Code Splitting 的核心是把很大的文件,分离成更小的块,让浏览器进行并行加载。

常见的代码分割有三种形式:

  • 手动进行分割:例如项目如果用到lodash,则把lodash单独打包成一个文件。
  • 同步导入的代码:使用 Webpack 配置进行代码分割。
  • 异步导入的代码:通过模块中的内联函数调用来分割代码。

手动进行分割

手动进行分割的意思是在entry上配置多个入口,例如像下面这样:

module.exports = {
  entry: {
    main: './src/index.js',
    lodash: 'lodash'
  }
}
复制代码

这样配置后,我们使用npm run build打包命令,它的打包输出结果为:

        Asset       Size  Chunks             Chunk Names
  index.html  462 bytes          [emitted]
    lodash.js   1.46 KiB       1  [emitted]  lodash
lodash.js.map   5.31 KiB       1  [emitted]  lodash
      main.js   1.56 KiB       2  [emitted]  main
  main.js.map   5.31 KiB       2  [emitted]  main
复制代码

它输出了两个模块,也能在一定程度上进行代码分割,不过这种分割是十分脆弱的,如果两个模块共同引用了第三个模块,那么第三个模块会被同时打包进这两个入口文件中,而不是分离出来。

所以我们常见的做法是关心最后两种代码分割方法,无论是同步代码还是异步代码,都需要在webpack.common.js中配置splitChunks属性,像下面这样子:

module.exports = {
  // 其它配置
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
}
复制代码

你可能已经看到了其中有一个chunks属性,它告诉 Webpack 应该对哪些模式进行打包,它的参数有三种:

  • async:此值为默认值,只有异步导入的代码才会进行代码分割。
  • initial:与async相对,只有同步引入的代码才会进行代码分割。
  • all:表示无论是同步代码还是异步代码都会进行代码分割。

同步代码分割

在完成上面的配置后,让我们来安装一个相对大一点的包,例如:lodash,然后对index.js中的代码做一些手脚,像下面这样:

import _ from 'lodash'
console.log(_.join(['Dell','Lee'], ' '));
复制代码

就像上面提到的那样,同步代码分割,我们只需要在webpack.common.js配置chunks属性值为initial即可:

module.exports = {
  // 其它配置
  optimization: {
    splitChunks: {
      chunks: 'initial'
    }
  }
}
复制代码

webpack.common.js配置完毕后,我们使用npm run build来进行打包, 你的打包dist目录看起来应该像下面这样子:

|-- dist
|   |-- index.html
|   |-- main.js
|   |-- main.js.map
|   |-- vendors~main.js
|   |-- vendors~main.js.map
复制代码

打包分析main.js使我们的业务代码,vendors~main.js是第三方模块的代码,在此案例中也就是lodash中的代码。

异步代码分割

由于chunks属性的默认值为async,如果我们只需要针对异步代码进行代码分割的话,我们只需要进行异步导入,Webpack会自动帮我们进行代码分割,异步代码分割它的配置如下:

module.exports = {
  // 其它配置
  optimization: {
    splitChunks: {
      chunks: 'async'
    }
  }
}
复制代码

注意:由于异步导入语法目前并没有得到全面支持,需要通过 npm 安装 @babel/plugin-syntax-dynamic-import 插件来进行转译

$ npm install @babel/plugin-syntax-dynamic-import -D
复制代码

安装完毕后,我们需要在根目录下的.babelrc文件做一下改动,像下面这样子:

{
  "presets": [["@babel/preset-env", {
    "corejs": 2,
    "useBuiltIns": "usage"
  }]],
  "plugins": ["@babel/plugin-syntax-dynamic-import"]
}
复制代码

配置完毕后,我们需要对index.js做一下代码改动,让它使用异步导入代码块:

// 点击页面,异步导入lodash模块
document.addEventListener('click', () => {
  getComponent().then((element) => {
    document.getElementById('root').appendChild(element)
  })
})

function getComponent () {
  return import(/* webpackChunkName: 'lodash' */'lodash').then(({ default: _ }) => {
    var element = document.createElement('div');
    element.innerHTML = _.join(['Dell', 'lee'], ' ')
    return element;
  })
}
复制代码

上面import里面的注释内容是plugin-syntax-dynamic-import插件支持的注释内容,俗称为"魔法注释",它的含义是告诉 Webpack 我们的异步模块的名字叫lodash,在后续preloading和prefetch也使用了相同的"魔法注释"方法。

写好以上代码后,我们同样使用npm run build进行打包,dist打包目录的输出结果如下:

|-- dist
|   |-- 1.js
|   |-- 1.js.map
|   |-- index.html
|   |-- main.js
|   |-- main.js.map
复制代码

我们在浏览器中运行dist目录下的index.html,切换到network面板时,我们可以发现只加载了main.js,如下图:



当我们点击页面时,才 真正开始加载 第三方模块,如下图(1.js):

SplitChunksPlugin配置参数详解

在上一节中,我们配置了splitChunks属性,它能让我们进行代码分割,其实这是因为 Webpack 底层使用了 splitChunksPlugin 插件。这个插件有很多可以配置的属性,它也有一些默认的配置参数,它的默认配置参数如下所示,我们将在下面为一些常用的配置项做一些说明。

module.exports = {
  // 其它配置项
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 30000,
      maxSize: 0,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: '~',
      name: true,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};
复制代码

chunks参数

此参数的含义在上一节中已详细说明,同时也配置了相应的案例,就不再次累述

minSize 和 maxSize

minSize默认值是30000,也就是30kb,当代码超过30kb时,才开始进行代码分割,小于30kb的则不会进行代码分割;与minSize相对的,maxSize默认值为0,为0表示不限制打包后文件的大小,一般这个属性不推荐设置,一定要设置的话,它的意思是:打包后的文件最大不能超过设定的值,超过的话就会进行代码分割。

为了测试以上两个属性,我们来写一个小小的例子,在src目录下新建一个math.js文件,它的代码如下:

export function add(a, b) {
  return a + b;
}
复制代码

新建完毕后,在index.js中引入math.js:

import { add } from './math.js'
console.log(add(1, 2));
复制代码

打包分析:因为我们写的math.js文件的大小非常小,如果应用默认值,它是不会进行代码分割的,如果你要进一步测试minSizemaxSize,请自行修改后打包测试。

minChunks

默认值为1,表示某个模块复用的次数大于或等于一次,就进行代码分割。

如果将其设置大于1,例如:minChunks:2,在不考虑其他模块的情况下,以下代码不会进行代码分割:

// 配置了minChunks: 2,以下lodash不会进行代码分割,因为只使用了一次 
import _ from 'lodash';
console.log(_.join(['Dell', 'Lee'], '-'));
复制代码

maxAsyncRequests 和 maxInitialRequests

  • maxAsyncRequests:它的默认值是5,代表在进行异步代码分割时,前五个会进行代码分割,超过五个的不再进行代码分割。
  • maxInitialRequests:它的默认值是3,代表在进行同步代码分割时,前三个会进行代码分割,超过三个的不再进行代码分割。

automaticNameDelimiter

这是一个连接符,左边是代码分割的缓存组,右边是打包的入口文件的项,例如vendors~main.js

cacheGroups

在进行代码分割时,会把符合条件的放在一组,然后把一组中的所有文件打包在一起,默认配置项中有两个分组,一个是vendors和default

vendors组: 以下代码的含义是,将所有通过引用node_modules文件夹下的都放在vendors组中

vendors: {
  test: /[\\/]node_modules[\\/]/,
  priority: -10
}
复制代码

default组: 默认组,意思是,不符合vendors的分组都将分配在default组中,如果一个文件即满足vendors分组,又满足default分组,那么通过priority的值进行取舍,值最大优先级越高。

default: {
  minChunks: 2,
  priority: -20,
  reuseExistingChunk: true
}
复制代码

reuseExistingChunk: 中文解释是复用已存在的文件。意思是,如果有一个a.js文件,它里面引用了b.js,但我们其他模块又有引用b.js的地方。开启这个配置项后,在打包时会分析b.js已经打包过了,直接可以复用不用再次打包。

// a.js
import b from 'b.js';
console.log('a.js');

// c.js
import b from 'b.js';
console.log('c.js');
复制代码

Lazy Loading懒加载

Lazy Loading懒加载的理解是:通过异步引入代码,它说的异步,并不是在页面一开始就加载,而是在合适的时机进行加载。

Lazy Loading懒加载的实际案例我们已经在上一小节书写了一个例子,不过我们依然可以做一下小小的改动,让它使用async/await进行异步加载,它的代码如下:

// 页面点击的时候才加载lodash模块
document.addEventListener('click', () => {
  getComponet().then(element => {
    document.body.appendChild(element);
  })
})
async function getComponet() {
  const { default: _ }  = await import(/* webpackChunkName: 'lodash' */ 'lodash');
  var element = document.createElement('div');
  element.innerHTML = _.join(['1', '2', '3'], '**')
  return element;
}
复制代码

以上懒加载的结果与上一小节的结果类似,就不在此展示,你可以在你本地的项目中打包后自行测试和查看。

PreLoading 和Prefetching

在以上Lazy Loading的例子中,只有当我们在页面点击时才会加载lodash,也有一些模块虽然是异步导入的,但我们希望能提前进行加载,PreLoadingPrefetching可以帮助我们实现这一点,它们的用法类似,但它们还是有区别的:Prefetching不会跟随主进程一些下载,而是等到主进程加载完毕,带宽释放后才进行加载,PreLoading会随主进程一起加载。

实现PreLoading或者Prefetching非常简单,我们只需要在上一节的例子中加一点点代码即可:

// 页面点击的时候才加载lodash模块
document.addEventListener('click', () => {
  getComponet().then(element => {
    document.body.appendChild(element);
  })
})
async function getComponet() {
  const { default: _ }  = await import(/* webpackPrefetch: true */ 'lodash');
  var element = document.createElement('div');
  element.innerHTML = _.join(['1', '2', '3'], '**')
  return element;
}
复制代码

改写完毕后,我们使用npm run dev或者npm run build进行打包,在浏览器中点击页面,我们将在network面板看到如下图所示:

相信聪明的你一定看到了0.js,它是from disk cache,那为什么?原因在于,Prefetching的代码它会在head头部,添加像这样的一段内容:

<link rel="prefetch" as="script" href="0.js">
复制代码

这样一段内容追加到head头部后,指示浏览器在空闲时间里去加载0.js,这正是Prefetching它所能帮我们做到的事情,而PreLoading的用法于此类似,请自行测试。

CSS代码分割

当我们在使用style-loadercss-loader打包.css文件时会直接把CSS文件打包进.js文件中,然后直接把样式通过<style></style>的方式写在页面,如果我们要把CSS单独打包在一起,然后通过link标签引入,那么可以使用mini-css-extract-plugin插件进行打包。

截止到写此文档时,此插件还未支持HMR,意味着我们要使用这个插件进行打包CSS时,为了开发效率,我们需要配置在生产环境下,开发环境依然还是使用style-loader进行打包
此插件的最新版已支持HMR

在配置之前,我们需要使用npm install进行安装此插件:

$ npm install mini-css-extract-plugin -D
复制代码

安装完毕后,由于此插件已支持HMR,那我们可以把配置写在webpack.common.js中(以下配置为完整配置,改动参考高亮代码块掘金无高亮):

const path = require('path');
const htmlWebpackPlugin = require('html-webpack-plugin');
const cleanWebpackPlugin = require('clean-webpack-plugin');
const miniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
  entry: {
    main: './src/index.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          { 
            loader: miniCssExtractPlugin.loader,
            options: {
              hmr: true,
              reloadAll: true
            }
          },
          'css-loader'
        ]
      },
      { 
        test: /\.js$/, 
        exclude: /node_modules/, 
        loader: "babel-loader" 
      }
    ]
  },
  plugins: [
    new htmlWebpackPlugin({
      template: 'src/index.html'
    }),
    new cleanWebpackPlugin(),
    new miniCssExtractPlugin({
      filename: '[name].css'
    })
  ],
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname,'../dist')
  }
}
复制代码

配置完毕以后,我们来在src目录下新建一个style.css文件,它的代码如下:

body {
  color: green;
}
复制代码

接下来,我们改动一下index.js文件,让它引入style.css,它的代码可以这样写:

import './style.css';
var root = document.getElementById('root');
root.innerHTML = 'Hello,world'
复制代码

使用npm run build进行打包,dist打包目录如下所示:

|-- dist
|   |-- index.html
|   |-- main.css
|   |-- main.css.map
|   |-- main.js
|   |-- main.js.map
复制代码

如果发现并没有打包生成main.css文件,可能是Tree Shaking的副作用,应该在package.json中添加属性sideEffects:['*.css']

CSS压缩

CSS压缩的理解是:当我们有两个相同的样式分开写的时候,我们可以把它们合并在一起;为了减`CSS文件的体积,我们需要像压缩JS文件一样,压缩一下CSS文件。

我们再在src目录下新建style1.css文件,内容如下:

body{
  line-height: 100px;
}
复制代码

index.js文件中引入此CSS文件

import './style.css';
import './style1.css';
var root = document.getElementById('root');
root.innerHTML = 'Hello,world'
复制代码

使用打包npm run build打包命令,我们发现虽然插件帮我们把CSS打包在了一个文件,但并没有合并压缩。

body {
  color: green;
}
body{
  line-height: 100px;
}
复制代码

要实现CSS的压缩,我们需要再安装一个插件:

$ npm install optimize-css-assets-webpack-plugin -D
复制代码

安装完毕后我们需要再一次改写webpack.common.js的配置,如下:

const optimizaCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
  // 其它配置
  optimization: {
    splitChunks: {
      chunks: 'all'
    },
    minimizer: [
      new optimizaCssAssetsWebpackPlugin()
    ]
  }
}
复制代码

配置完毕以后,我们再次使用npm run build进行打包,打包结果如下所示,可以看见,两个CSS文件的代码已经压缩合并了。

body{color:red;line-height:100px}
复制代码

Webpack和浏览器缓存(Caching)

在讲这一小节之前,让我们清理下项目目录,改写下我们的index.js,删除掉一些没用的文件:

import _ from 'lodash';

var dom = document.createElement('div');
dom.innerHTML = _.join(['Dell', 'Lee'], '---');
document.body.append(dom);
复制代码

清理后的项目目录可能是这样的:

|-- build
|   |-- webpack.common.js
|   |-- webpack.dev.js
|   |-- webpack.prod.js
|-- src
    |-- index.html
    |-- index.js
|-- postcss.config.js
|-- package.json
复制代码

我们使用npm run build打包命令,打包我们的代码,可能会生成如下的文件:

|-- build
|   |-- webpack.common.js
|   |-- webpack.dev.js
|   |-- webpack.prod.js
|-- dist
|   |-- index.html
|   |-- main.js
|   |-- main.js.map
|   |-- vendors~main.js
|   |-- vendors~main.js.map
|-- src
    |-- index.html
    |-- index.js
|-- package.json
|-- postcss.config.js
复制代码

我们可以看到,打包生成的dist目录下,文件名是main.jsvendors~main.js,如果我们把dist目录放在服务器部署的话,当用户第一次访问页面时,浏览器会自动把这两个.js文件缓存起来,下一次非强制性刷新页面时,会直接使用缓存起来的文件。

假如,我们在用户第一次刷新页面和第二次刷新页面之间,我们修改了我们的代码,并再一次部署,这个时候由于浏览器缓存了这两个.js文件,所以用户界面无法获取最新的代码。

那么,我们有办法能解决这个问题呢,答案是[contenthash]占位符,它能根据文件的内容,在每一次打包时生成一个唯一的hash值,只要我们文件发生了变动,就重新生成一个hash值,没有改动的话,[contenthash]则不会发生变动,可以在output中进行配置,如下所示:

// 开发环境下的output配置还是原来的那样,也就是webpack.common.js中的output配置
// 因为开发环境下,我们不用考虑缓存问题
// webpack.prod.js中添加output配置
output: {
  filename: '[name].[contenthash].js',
  chunkFilename: '[name].[contenthash].js'
}
复制代码

使用npm run build进行打包,dist打包目录的结果如下所示,可以看到每一个.js文件都有一个唯一的hash值,这样配置后就能有效解决浏览器缓存的问题。

|-- dist
|   |-- index.html
|   |-- main.8bef05e11ca1dc804836.js
|   |-- main.8bef05e11ca1dc804836.js.map
|   |-- vendors~main.4b711ce6ccdc861de436.js
|   |-- vendors~main.4b711ce6ccdc861de436.js.map
复制代码

Shimming

有时候我们在引入第三方库的时候,不得不处理一些全局变量的问题,例如jQuery的$,lodash的_,但由于一些老的第三方库不能直接修改它的代码,这时我们能不能定义一个全局变量,当文件中存在$或者_的时候自动的帮他们引入对应的包。

这个问题,可以使用ProvidePlugin插件来解决,这个插件已经被 Webpack 内置,无需安装,直接使用即可。

src目录下新建jquery.ui.js文件,代码如下所示,它使用了jQuery$符号,创建这个文件目的是为了来模仿第三方库。

export function UI() {
  $('body').css('background','green');
}
复制代码

创建完毕后,我们修改一下index.js文件, 让它使用刚才我们创建的文件:

import _ from 'lodash';
import $ from 'jquery';
import { UI } from './jquery.ui';

UI();

var dom = $(`<div>${_.join(['Dell', 'Lee'], '---')}</div>`);
$('#root').append(dom);
复制代码

接下来我们使用npm run dev进行打包,它的结果如下:

问题: 我们发现,根本运行不起来,报错$ is not defined
解答: 这是因为虽然我们在index.js中引入的jquery文件,但$符号只能在index.js才有效,在jquery.ui.js无效,报错是因为jquery.ui.js$符号找不到引起的。

以上场景完美再现了我们最开始提到的问题,那么我们接下来就通过配置解决,首先在webpack.common.js文件中使用ProvidePlugin插件:

配置$:'jquery',只要我们文件中使用了$符号,它就会自动帮我们引入jquery,相当于import $ from 'jquery'

const webpack = require('webpack');
module.exports = {
  // 其它配置
  plugins: [
    new webpack.ProvidePlugin({
      $: 'jquery',
      _: 'lodash'
    })
  ]
}
复制代码

打包结果: 使用npm run dev进行打包,打包结果如下,可以发现,项目已经可以正确运行了。

处理全局this指向问题

我们现在来思考一个问题,一个模块中的this到底指向什么,是模块自身还是全局的window对象

// index.js代码,在浏览器中输出:false
console.log(this===window);
复制代码

如上所示,如果我们使用npm run dev运行项目,运行index.html时,会在浏览器的console面板输出false,证明在模块中this指向模块自身,而不是全局的window对象,那么我们有什么办法来解决这个问题呢?可以安装使用imports-loader来解决这个问题!

$ npm install imports-loader -D
复制代码

安装完毕后,我们在webpack.common.js加一点配置,在.js的loader处理中,添加imports-loader

module.exports = {
  // ... 其它配置
  module: {
    rules: [
      { 
        test: /\.js$/, 
        exclude: /node_modules/, 
        use: [
          {
            loader: 'babel-loader'
          },
          {
            loader: 'imports-loader?this=>window'
          }
        ]
      }
    ]
  }
}
复制代码

配置完毕后使用npm run dev来进行打包,查看console控制台输出true,证明this这个时候已经指向了全局window对象,问题解决。

本篇博客由慕课网视频从基础到实战手把手带你掌握新版Webpack4.0阅读整理而来,观看视频请支持正版。