揭开 webpack 的那层纱

863 阅读14分钟

前言

关于 webpack,在我眼里一直都是蒙了一层“面纱”,一些配置也只能做到 “眼熟”。至于如何从 0 到 1 地去搭建一套“能用”的 webpack 配置更是“痴人说梦”。

“痛定思痛”之后, 我决定揭开那层“面纱”,重新梳理一遍它的基础配置,并进行一次从 0 到 1 的搭建。而这篇文章就是我揭开它的 “面纱”的整个过程,希望能对你有所帮助~

一、准备

webpack 是一个现代 JavaScript 应用程序的静态模块打包器。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle

核心概念

  • 入口(entry)

    入口起点指示 webpack 应该使用哪个模块,来作为构建其内部依赖图的开始。

  • 输出(output)

    output 属性告诉 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件,默认值为 ./dist

  • loader

    loaderwebpack 能够去处理那些非 JavaScript 文件。

  • 插件(plugins)

    loader 被用于转换某些类型的模块,而插件则可以用于执行范围更广的任务。插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量。

初始化项目

mkdir webpack-demo && cd webpack-demo
npm init -y
# webpack@4.43.0
# webpack-cli@3.3.11
npm install --save-dev webpack webpack-cli

设置目录结构:

├── src                     #
|   ├── assets              # 资源文件
|   ├── css                 # css
|   ├── html                # html
|   |── js                  # js
|   |──index.js             # 入口文件
├── static                  # 静态资源文件
├── webpack.config.js       # webpack配置文件
├── package.json            #

package.json 中添加命令:

"scripts": {
  "dev": "webpack --mode=development",
  "build": "webpack --mdoe=production"
}

二、JS

src/js 目录下新建 index.js 文件,内容如下。并在入口文件 index.js 中引入该 js import './js/index'

const arrowFn = () => {
  console.log('arrowFn')
}

arrowFn()

由于 webpack 4 是开箱即用的,所以我们可以直接执行命令 npm run dev (前面已经将 webpack 的命令配置在了 package.json 中)。

执行完成后,在 dist/main.js 中我们可以看到刚刚的箭头函数,而 webpack 并没有主动帮助我们将它转义为低版本的代码。想要实现这个功能,我们需要通过 loader 对代码进行转换。

如何使用 loader

  1. 将我们需要使用的 loader 编写在 webpack 配置中的 module.rules 数组中。
  2. loader 的基本格式为:
    {
      test: xxx,
      use: xxx,
      options: {}
    }
    
    其中,test 属性用于标识出应该被对应的 loader 进行转换的某个或某些文件;use 属性表示进行转换时,应该使用哪个 loaderoptions 属性用于设置单独的配置。
  3. use 属性有三种写法:
    • 字符串
    use: 'babel-loader'
    
    • 对象
    use: {
      loader: 'babel-loader',
      options: {}
    }
    
    • 数组
    use: ['babel-loader']
    

处理 js

将 js 转换为低版本的代码,我们需要使用 babel-loader,另外还需要添加 babel 相关的配置。

依次执行下面的命令(关于babel 7的详细介绍):

# babel-loader@8.1.0
npm install --save-dev babel-loader

# @babel/core:babel的核心,包含所有的核心 API
# @babel/preset-env:将 js 引入的新语法转换为 ES5 的语法(不包括新增的全局变量、方法等)
# @babel/plugin-transform-runtime:用于构建过程中的代码转换
npm install --save-dev @babel/core @babel/preset-env @babel/plugin-transform-runtime

# @babel/runtime:实际导入项目代码的功能模块
# @babel/runtime-corejs3:配合 @babel/plugin-transform-runtime 避免全局污染
npm install --save @babel/runtime @babel/runtime-corejs3

修改 webpack.config.js,内容如下:

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

添加 babel 配置文件 .babelrc,内容如下:

{
  "presets": ["@babel/preset-env"],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": 3
      }
    ]
  ]
}

添加 .browserslist(控制目标浏览器的范围),内容如下:

> 0.25%
not dead

执行 npm run dev后,我们会发现 dist/main.js 中输出的代码已经被转义成低版本的代码了。

三、HTML

src/html 目录下新建 index.html 文件。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Index</title>
</head>
<body>
  这是 Index 页面
</body>
</html>

打包 html 文件,我们需要使用 html-webpack-plugin 插件。

# html-webpack-plugin@4.3.0
npm install --save-dev html-webpack-plugin

修改 webpack.config.js

const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  # ...
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/html/index.html',
      filename: 'index.html', # 打包后的文件名
      minify: { # 设置静态资源压缩情况
        collapseWhitespace: false, # 是否折叠空白
      }
    })
  ]
  # ...
}

执行 npm run dev 命令后,我们可以看到 dist 文件夹下新增了 index.html 文件,并且其中自动引入的是打包之后的 js 文件。此时直接通过浏览器访问 dist/index.html,一切都那么自然~

html-webpack-plugin 插件的可扩展性还是很强的,例如设置 faviconmeta 等,我们也可以自己添加自定义属性,根据不同的环境,在 html 中进行不同的设置等,例如可以手动设置页面的标题:<title><%= htmlWebpackPlugin.options.title %></title>,而在 webpack 的配置文件中,title 属性的值可以设置为某个变量,以此达到灵活配置的目的。更多的使用方式还需要我们自己结合实际情况进行配置。

四、CSS

webpack 需要借助 style-loadercss-loader 才能处理 css 文件,由于考虑到兼容性的问题,我们通常还会添加 postcss-loader 进行处理。而当我们用到 css 预处理器: sassless 时,还需要分别使用 less-loaderless-loader。这里以 less 为例。

安装依赖:

npm install --save-dev style-loader css-loader postcss-loader autoprefixer less-loader less

新建 css/index.less 文件,内容如下:

@bgColor: skyblue;

body {
  background: @bgColor;
}

.bold {
  font-weight: 500;
}

webpack.config.js 文件的 module.rules 下添加:

{
  test: /\.(le|c)ss$/,
  use: [
    'style-loader',
    'css-loader',
    {
      loader: 'postcss-loader',
      options: {
        plugins: [require('autoprefixer')]
     }
    },
    'less-loader'
  ]
}

在入口文件 src/index.js 中引入样式文件:

import './css/index.less'

执行 npm run dev,在浏览器中打开 dist/index.html,打开控制台,检查 htmlhead 标签,我们可以看到新增了 style 标签以及刚刚添加的样式。

首先我们要知道的是,loader 的执行顺序是从后往前的,即上面的执行顺序是:less-loader -> postcss-loader -> css-loader -> style-loader

下面我们就按顺序分析一下,刚刚的几个 loader 分别做了哪些事情吧~

  1. less-loader:处理 .less 文件,将其转为 .css 文件;
  2. postcss-loaderautoprefixer:生成浏览器前缀;
  3. css-loader:处理 import 等语句,分析多个 css 文件并合成一段 css
  4. style-loader:动态创建 style 标签,将 css 插入到 head 中;

到了这里我们应该对 webpack 如何处理样式文件有了一个比较清晰的认识了。下面我们来看一下,如何抽离 css。即:通过 link 标签的形式从外部引入。

抽离 css

安装 mini-css-extract-plugin

# mini-css-extract-plugin@0.9.0
npm install --save-dev mini-css-extract-plugin

mini-css-extract-pluginextract-text-webpack-plugin 比较:

  1. 异步加载
  2. 无重复编译
  3. 使用方式简单
  4. 只适用于 css

修改 webpack.config.js 文件如下:

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
# 引入插件
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader'
      },
      {
        test: /\.(le|c)ss$/,
        use: [
          MiniCssExtractPlugin.loader, # 替换之前的 style-loader
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              plugins: [require('autoprefixer')]
            }
          },
          'less-loader'
        ]
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/html/index.html',
      filename: 'index.html',
      minify: {
        collapseWhitespace: false,
      }
    }),
    # 输出 css 文件
    new MiniCssExtractPlugin({
      filename: '[name].[hash:6].css'
    })
  ]
}

执行编译命令后,在 dist 目录下我们可以看到已经多出了 main.xxxxxx.css 样式文件,并在 html 中通过 link 标签的形式进行了引入,“大功告成”~

简单对比一下,这两种的方式其本质的不同就是在最后一步的写入。前者通过 style-loaderhtml 中添加内联样式表,后者首先通过 MiniCssExtractPlugin 输出 css ,再通过 MiniCssExtractPlugin.loaderhtml 中添加外部样式表。

优化压缩 css

安装 optimize-css-assets-webpack-plugin:

npm install --save-dev optimize-css-assets-webpack-plugin

webpack.config.js 中添加配置:

plugins: [
  new OptimizeCssPlugin(),
]

五、图片资源处理

css 使用本地图片

在样式文件中使用了图片资源时,需要使用 url-loaderfile-loader 进行处理,url-loader 依赖于 file-loader,可以理解为 url-loader 是对 file-loader 的一种封装。他们的区别在于,url-loaderoptions 配置添加了一个 limit 属性。当资源大小小于设置的 limit 值时,webpack 会将资源转换为 base64,超过 限制则会将图片拷贝到 dist 目录下。

安装 loader

# url-loader@4.1.0
# file-loader@6.0.0
npm install --save-dev url-loader file-loader

webpack.config.jsmodule.rules 中添加配置:

# ...
{
  test: /\.(png|jpg|gif|jpeg)$/,
  use: [
    {
      loader: 'url-loader',
      options: {
        limit: 10240, # 10K
        name: '[name]_[hash:6].[ext]',
        outputPath: 'assets'
      }
    }
  ]
}
# ...

src/assets 目录下添加两张图片:avatar.jpeg (超过 10k)和 logo.png (小于 10k)。

html/index.html 中添加:

<div class="logo"></div>
<div class="avatar"></div>

css/index.less 中添加样式:

.avatar {
  width: 200px;
  height: 200px;
  background: url('../assets/avatar.jpeg');
}

.logo {
  width: 200px;
  height: 200px;
  background: url('../assets/logo.png');
}

执行编译命令,在浏览器中打开 dist/index.html,两个背景图片都可以正常显示出来。我们也可以很清楚的看到 avatar.jpeg 是直接被拷贝到 dist/assets 目录下的,而 logo.png 是被转换成 base64 进行使用的。

html 使用本地图片

html/index.html 中添加:

<img src="../assets/avatar.jpeg" alt="">

此时执行编译命令,打开 dist/index.html 我们会发现找不到 avatar.jpeg 图片。这是因为经过 webpack 的构建后,通过相对路径已经无法找到图片了。

我们可以通过添加 html-loader 来解决:

# html-loader@1.1.0
npm install --save-dev html-loader

修改 webpack.config.js

{
  test: /\.html$/,
  use: 'html-loader'
}

重新打包后,所有资源都可以正常加载了~

至此,我们常见的 htmljscss 以及图片资源的使用都可以通过 webpack 打包了。但是这个时候都是使用的 webpack 默认的入口出口配置,下面我们就来看一下如何设置入口出口的配置吧。

六、入口出口配置

入口配置

入口配置的字段为:entry,值可以是字符串、数组、对象。单个页面一般这三种都可以选择,但用的最多的还是字符串和对象。数组是在当有“多个主入口”,多个依赖文件一起注入时使用的。

修改 webpack.config.js

module.exports = {
  # webpack的默认配置
  entry: {
    index: './src/index.js'
  }
}

这里通过对象的形式进行了设置,执行编译命令后我们会发现,之前的 main.[hash].[ext] 现在都变成了 index.[hash].[ext],这也就说明我们设置的入口配置成功了。如果直接是以字符串的形式设置,那么在配置中使用 [name] ,打包后的输出结果的默认名就是 main。(注意:我们在配置中所使用的 [name] 不是所有地方都是指文件的名称,部分是指设置的入口的属性名称)

出口配置

出口配置的字段为:output

修改 webpack.config.js

const path = require('path')
# ...
output: {
  path: path.resolve(__dirname, 'dist'),
  filename: '[name].[hash:6].js',
  publicPath: '/'
}
# ...

由于我们在出口配置中设置了 publicPath: '/',则所有的资源默认从根目录下获取。所以我们就不能再使用之前的方式查看编译后的效果了。

解决方案:

  1. dist 目录下,通过 http-server 开启本地服务器。
  2. 通过 webpack-dev-server 开启本地服务。

七、配置补充

mode

提供 mode 配置选项,告知 webpack 使用相应模式的内置优化。

webpack 给我们提供了两种选项:developmentproduction。(关于 mode

一开始我们就将 mode 配置进了命令行,通过 --mode=xxx 的形式指定。下面我们将通过环境变量来判断到底启用哪种模式。

安装 cross-env (跨平台设置 NODE_ENV):

npm install --save-dev cross-env

修改 package.json:

"scripts": {
  "dev": "cross-env NODE_ENV=development webpack",
  "build": "cross-env NODE_ENV=production webpack"
},

修改 webpack.config.js

const isDev = process.env.NODE_ENV === 'development'
module.exports = {
  mode: isDev ? 'development' : 'production'
}

配置 resolve

webpack.dev.conf.js 中添加 resolve 配置:

resolve: {
  modules: [path.resolve(__dirname, 'src'), 'node_modules'],
  extensions: ['.js', '.less', '.json', '/index.js'],
  alias: {
    '@': path.join(__dirname, 'src'),
  }
}

module

通过 resolve.module 告诉 webpack 查找模块时应该搜索哪些目录,默认值为 ['node_modules']。按照数组顺序从左往右依次查找。

我们通过一个例子可以更加直观的理解下面的配置:

import { Button } from 'antd',此时 webpack 会先查找 src 目录下有没有对应的模块,如果没有再去 node_modules 目录下查找。

extensions

如果多个文件有着相同的名称,但具有不同的扩展名,则 webpack 将解析其扩展名列在数组中首位的文件,并跳过其余文件。

举个例子:

common 文件夹下有 index.jsindex.less 文件。当我们通过 import './common/index' 的形式引入文件时,webpack 会根据 resolve.extensions 的配置进行判断,从左往右依次匹配。当找到可以匹配的目标后,跳过其余文件。此处 webpack 的解析结果就等于是 import './common/index.js'

alias

当我们的项目越来越庞大,目录结构越来越复杂时,有些时候通过相对路径引入文件,代码会显得异常不清晰(很多层 “../”)。

所幸 webpack 为我们提供了 resolve.alias 配置项来设置别名,帮助我们优雅地通过绝对路径引入文件。

例子:引入 css/index.css

import '../../../css/index.css'
import '@/css/index.css'

这里通过别名引入文件,可以很清晰的知道文件的位置:src/css/index.css

webpack-dev-server

前面我们只是让 webpack 正常的运行起来,但在实际开发中我们会需要:提供 HTTP 服务而不是使用本地文件预览;监听文件的变化并自动刷新网页。

官方已经为我们准备好了开发工具 DevServer ,它会启动一个 HTTP 服务用于网页请求,同时会帮助我们启动 webpack,并接收 webpack 发出的变更信号,自动刷新网页。

安装 DevServer

# webpack-dev-server@3.11.0
npm install --save-dev webpack-dev-server

修改 webpack.config.js

"scripts": {
  "dev": "cross-env NODE_ENV=development webpack-dev-server",
  "build": "cross-env NODE_ENV=production webpack"
},

webpack.config.js 中添加配置:

module.exports = {
  devServer: {
    host: 'localhost', # 服务器地址
    port: '8899', # 默认是8080
    compress: true # 是否启用 gzip 压缩
  }
}

执行 npm run dev 后,访问 http://localhost:8899/,这样我们就可以在浏览器中看到实际的效果啦。

这个时候我们会发现并没有文件输出到 dist 目录下,原因是因为 DevServer 会把 webpack 构建出的文件保存在内存中,在要访问输出的文件时,必须通过 HTTP 服务访问。

通过 DevServer 启动的 webpack 会开启监听模式(默认是关闭的,可以通过 webpack --watch 手动开启监听),当发生变化时重新执行构建,并通知 DevServerDevServer 会让 webpack 在构建出的 js 代码里注入一个代理客户端用于控制网页,以方便 DevServer 主动向客户端发送命令。 DevServer 在收到来自 webpack 的文件变化通知时通过注入的客户端控制网页刷新。

另外我们还可以通过 devServer 解决开发环境跨域的问题。

例子:假设我们本地代码运行在 localhost:8899,而服务端接口在 http://dev.api.com。此时有 http://dev.api.com/user/login

devServer 中添加 proxy 配置:

proxy: {
  "/api": { # 起标识作用,表示此处配置用于接口。(名字可以随意定)
    target: "http://dev.api.com", # 目标服务器
    pathRewrite: {
      "/api": "" # 重写 “/api” 为空
    }
  }
}

清空 dist

之前我们每次执行 npm run builddist 目录下都会保留上一次的打包内容,每次手动去删除岂不是太麻烦了,所以我们需要通过 clean-webpack-plugin 插件帮助我们在每次打包前,先将 dist 目录清空。

安装 clean-webpack-plugin

npm install --save-dev clean-webpack-plugin

修改 webpack.config.js

const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
  plugins: [
    new CleanWebpackPlugin()
  ]
}

静态资源处理

src/assetsstatic,它们俩都是用来存放静态资源文件的,那么它们有什么区别呢?

在我们的项目中,htmlcss 通过 html-loadercss-loader 分析静态资源 URL 的,例如 <img src="../assets/avatar.jpeg" alt="">background: url('../assets/avatar.jpeg');。这些文件都是通过相对路径引用的,webpack 会将它们作为依赖模块处理。相比之下,我们存放在 static 目录下的资源文件不会 webpack 处理(这是我们的初衷),而是直接拷贝到打包后的 dist 目录下,所以我们在使用 static 目录下的文件时,需要使用绝对路径的方式进行引用。

安装 copy-webpack-plugin

# copy-webpack-plugin@6.0.2
npm install --save-dev copy-webpack-plugin

static 目录下新建 index.js,并在 html/index.html 中引入:

const fn = () => {
  console.log('我是static目录下的index.js')
}
fn()

修改 webpack.config.js

const CopyWebpackPlugin = require('copy-webpack-plugin')
module.exports = {
  plugins: [
    new CopyWebpackPlugin({
      patterns: [
        {
          from: 'static/*',
          to: 'static/[name].[ext]'
        }
      ]
    }),
  ]
}

html/index.html 中引用 static/index.js

<script src="/static/index.js"></script>

重启本地服务器(当我们修改了 webpack 配置后,都需要重新启动才能生效)。访问 http://localhost:8899/,一切都很正常~

exclude 和 include

我们在配置 loader 的时候,可以通过 excludeinclude 来减少 webpack 转义的文件。这两个配置只需要设置其中一个即可,exclude 优先级大于 include

exclude:排除某些文件。

include:指定某些文件。

八、webapck 多环境配置

通常我们在开发环境和生产环境会采取不同的编译配置,下面我们就来看看如何对不同的环境添加不同的配置。

新建 build 目录,在该目录下添加以下文件:

  • util.js:提供通用方法。
  • webpack.base.conf.js:提供公共配置。
  • webpack.dev.conf.js:提供开发环境配置。
  • webpack.prod.conf.js:提供生产环境配置。

新建 config 目录,在该目录下添加以下文件:

  • index.js:提供webpack编译参数。
  • dev.env.js:提供开发环境参数。
  • prod.env.js:提供生产环境参数。

两个不同文件的 webpack 我们可以通过 wepack-merge 进行合并。

ps:目前项目的目录是参考的 vue-cli 创建的项目,这里可以根据自己的想法进行设置。

九、多页应用

通过前面的配置,我们单页应用的打包配置和多环境的配置基本上已经 ok 了,下面我们来看一下如何进行多页应用打包。

设置多页应用

  1. html 目录下新建 home.html

  2. 直接看目录结构吧:

    新建 entry 目录,用于存放入口文件,修改文件内资源引用的路径。

  1. 修改 webpack.base.conf.js

    module.exports = {
      entry: {
        index: './src/entry/index.js',
        home: './src/entry/home.js'
      },
      plugins: [
        new HtmlWebpackPlugin({
          template: './src/html/index.html',
          filename: 'index.html',
          minify: {
            collapseWhitespace: !isDev,
          },
          chunks: ['index']
        }),
        new HtmlWebpackPlugin({
          template: './src/html/home.html',
          filename: 'home.html',
          minify: {
            collapseWhitespace: !isDev,
          },
          chunks: ['home']
        }),
      ]
    }
    

historyApiFallback

现在项目有多个入口JS和HTML,对于这种多页应用(实际上就是由多个单页应用组成的),我们期望的是在开发时能通过路由切换到对应的页面下,所以我们需要通过 historyApiFallbackdevServer 中设置路由规则。

修改 webpack.dev.conf.js

devServer: {
  host: config.dev.host,
  port: config.dev.port,
  compress: true,
  historyApiFallback: {
    rewrites: [
      { from: /^\/index/, to: '/index.html' },
      { from: /^\/home/, to: '/home.html' }
    ]
  }
},

这样我们就可以在开发的时候随便查看对应的页面啦~

提取公共代码

在多页应用中,经常会有一些公共的 cssjs,我们需要通过 optimization.splitChunks 将它们进行分割,打包成一个新的模块后在对应的页面分别进行引入。

webpack.base.conf.js 中添加相关配置:

optimization: {
  splitChunks: {
    chunks: 'all', # 代码块类型,`all`(默认值)、`initial`(初始化)、`async`(动态加载)
    minSize: 0, # 生成块的最小大小
    minChunks: 1, # 生成块前共享模块的最小块数
    # 缓存组可以继承或覆盖splitChunks.*;中的任何选项配置。
    # 但是test,priority和reuseExistingChunk只能用于缓存组级别上的配置。
    # 要禁用任何默认缓存组,需要在缓存组配置中添加 default: false。
    cacheGroups: {
      vendors: { # 处理第三方依赖
        priority: 1,
        name: 'vendor',
        test: /[\\/]node_modules[\\/]/,
        chunks: 'initial',
        minSize: 0,
        minChunks: 1
      },
      commons: { # 处理公共模块
        chunks: 'initial',
        name: 'common',
        minSize: 0,
        minChunks: 2
      }
    }
  }
}

最后

没有最好的,只有最适合的,采用什么样的配置还是需要根据项目的实际情况进行分析。

有一段时间没更新了,一方面是有点忙,另一方面是自己也松懈了。嗐,越学习就会发现自己不会的越多,而“会”的部分有很多也是一知半解,真是令人惆怅。不过还是要加油呢~文中若有不足之处和错误的地方还请大佬们帮忙指正,谢谢~