非常全的webpack打包工具面试考点整理10+方面

420 阅读33分钟

非常全的webpack打包工具面试考点整理10+方面

[toc]

webpack搬运工,小圆脸同学是学习了webpack后整理搬运过来的知识点,原文收费所以没有附上链接

webpack 的概念和基础使用(一)

webpack 是一个 JS 代码模块化的打包工具,藉由它强大的扩展能力,随着社区的发展,逐渐成为一个功能完善的构建工具

安装和使用

我们使用 npm 或者 yarn 来安装 webpack,可以作为一个全局的命令来使用:

npm install webpack webpack-cli -g 

# 或者
yarn global add webpack webpack-cli

# 然后就可以全局执行命令了
webpack --help

webpack-cli 是使用 webpack 的命令行工具,在 4.x 版本之后不再作为 webpack 的依赖了,我们使用时需要单独安装这个工具。

在项目中,我们更多地会把 webpack 作为项目的开发依赖来安装使用,这样可以指定项目中使用的 webpack 版本,更加方便多人协同开发:

确保你的项目中有 package.json 文件,如果没有可以使用 ==npm init== 来创建。

npm install webpack -D 

# 或者
yarn add webpack -D

这样 webpack 会出现在 package.json 中,我们再添加一个 npm scripts:

 "scripts": {
    "build": "webpack --mode production"
  },
  "devDependencies": {
    "webpack": "^4.1.1",
    "webpack-cli": "^2.0.12",
  }

然后我们创建一个 ./src/index.js 文件,可以写任意的 JS 代码。创建好了之后执行 ==npm run build== 或者 ==yarn build== 命令,你就会发现新增了一个 ==dist== 目录,里边存放的是 webpack 构建好的== main.js== 文件。

因为是作为项目依赖进行安装,所以不会有全局的命令,npm/yarn 会帮助我们在当前项目依赖中寻找对应的命令执行,如果是全局安装的 webpack,直接执行 ==webpack --mode production== 就可以。

webpack 4.x 的版本可以零配置就开始进行构建,但是笔者觉得这个功能还不全面,缺少很多实际项目需要的功能,所以基本你还是需要一个配置文件,后边会详细讲解。

我们先来了解 webpack 中的一些基本概念。

webpack 的基本概念

webpack 本质上是一个打包工具,它会根据代码的内容解析模块依赖,帮助我们把多个模块的代码打包。

如上图,webpack 会把我们项目中使用到的多个代码模块(可以是不同文件类型),打包构建成项目运行仅需要的几个静态文件。webpack 有着十分丰富的配置项,提供了十分强大的扩展能力,可以在打包构建的过程中做很多事情。我们先来看一下 webpack 中的几个基本概念。

入口

如上图所示,在多个代码模块中会有一个起始的 ==.js== 文件,这个便是 webpack 构建的入口。 webpack 会读取这个文件,并从它开始解析依赖,然后进行打包。如图,一开始我们使用 webpack 构建时,默认的入口文件就是 ==./src/index.js==。

我们常见的项目中,如果是单页面应用,那么可能入口只有一个;如果是多个页面的项目,那么经常是一个页面会对应一个构建入口。

入口可以使用 entry 字段来进行配置,webpack 支持配置多个入口来进行构建:

module.exports = {
  entry: './src/index.js' 
}

// 上述配置等同于
module.exports = {
  entry: {
    main: './src/index.js'
  }
}

// 或者配置多个入口
module.exports = {
  entry: {
    foo: './src/page-foo.js',
    bar: './src/page-bar.js', 
    // ...
  }
}

// 使用数组来对多个文件进行打包
module.exports = {
  entry: {
    main: [
      './src/foo.js',
      './src/bar.js'
    ]
  }
}

最后的例子,可以理解为多个文件作为一个入口,webpack 会解析两个文件的依赖后进行打包。

loader

webpack 中提供一种处理多种文件格式的机制,便是使用 loader。我们可以把 loader 理解为是一个转换器,负责把某种文件格式的内容转换成 webpack 可以支持打包的模块。

举个例子,在没有添加额外插件的情况下,webpack 会默认把所有依赖打包成 js 文件,如果入口文件依赖一个 .hbs 的模板文件以及一个 .css 的样式文件,那么我们需要 handlebars-loader 来处理 .hbs 文件,需要 css-loader 来处理 .css 文件(这里其实还需要 style-loader,后续详解),最终把不同格式的文件都解析成 js 代码,以便打包后在浏览器中运行。

当我们需要使用不同的 loader 来解析处理不同类型的文件时,我们可以在 ==module.rules== 字段下来配置相关的规则,例如使用 Babel 来处理 .js 文件:

module: {
  // ...
  rules: [
    {
      test: /\.jsx?/, // 匹配文件路径的正则表达式,通常我们都是匹配文件类型后缀
      include: [
        path.resolve(__dirname, 'src') // 指定哪些路径下的文件需要经过 loader 处理
      ],
      use: 'babel-loader', // 指定使用的 loader
    },
  ],
}

loader 是 webpack 中比较复杂的一块内容,它支撑着 webpack 来处理文件的多样性。后续我们还会介绍如何更好地使用 loader 以及如何开发 loader。

plugin

在 webpack 的构建流程中,plugin 用于处理更多其他的一些构建任务。可以这么理解,模块代码转换的工作由 loader 来处理,除此之外的其他任何工作都可以交由 plugin 来完成。通过添加我们需要的 plugin,可以满足更多构建中特殊的需求。例如,要使用压缩 JS 代码的 uglifyjs-webpack-plugin 插件,只需在配置中通过 ==plugins== 字段添加新的 plugin 即可:

const UglifyPlugin = require('uglifyjs-webpack-plugin')

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

除了压缩 JS 代码的 uglifyjs-webpack-plugin,常用的还有定义环境变量的 DefinePlugin,生成 CSS 文件的 ExtractTextWebpackPlugin 等。

plugin 理论上可以干涉 webpack 整个构建流程,可以在流程的每一个步骤中定制自己的构建需求。

输出

webpack 的输出即指 webpack 最终构建出来的静态文件,可以看看上面 webpack 官方图片右侧的那些文件。当然,构建结果的文件名、路径等都是可以配置的,使用 ==output== 字段:

module.exports = {
  // ...
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
}

// 或者多个入口生成不同文件
module.exports = {
  entry: {
    foo: './src/foo.js',
    bar: './src/bar.js',
  },
  output: {
    filename: '[name].js',
    path: __dirname + '/dist',
  },
}

// 路径中使用 hash,每次构建时会有一个不同 hash 值,避免发布新版本时线上使用浏览器缓存
module.exports = {
  // ...
  output: {
    filename: '[name].js',
    path: __dirname + '/dist/[hash]',
  },
}

我们一开始直接使用 webpack 构建时,默认创建的输出内容就是 ./dist/main.js

一个简单的 webpack 配置

我们把上述涉及的几部分配置内容合到一起,就可以创建一个简单的 webpack 配置了,webpack 运行时默认读取项目下的 webpack.config.js 文件作为配置。

所以我们在项目中创建一个 webpack.config.js 文件:

const path = require('path')
const UglifyPlugin = require('uglifyjs-webpack-plugin')

module.exports = {
  entry: './src/index.js',

  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },

  module: {
    rules: [
      {
        test: /\.jsx?/,
        include: [
          path.resolve(__dirname, 'src')
        ],
        use: 'babel-loader',
      },
    ],
  },

  // 代码模块路径解析的配置
  resolve: {
    modules: [
      "node_modules",
      path.resolve(__dirname, 'src')
    ],

    extensions: [".wasm", ".mjs", ".js", ".json", ".jsx"],
  },

  plugins: [
    new UglifyPlugin(), 
    // 使用 uglifyjs-webpack-plugin 来压缩 JS 代码
    // 如果你留意了我们一开始直接使用 webpack 构建的结果,你会发现默认已经使用了 JS 代码压缩的插件
    // 这其实也是我们命令中的 --mode production 的效果,后续的小节会介绍 webpack 的 mode 参数
  ],
}

webpack 的配置其实是一个 Node.js 的脚本,这个脚本对外暴露一个配置对象,webpack 通过这个对象来读取相关的一些配置。因为是 Node.js 脚本,所以可玩性非常高,你可以使用任何的 Node.js 模块,如上述用到的 path 模块,当然第三方的模块也可以。

创建了 webpack.config.js 后再执行 webpack 命令,webpack 就会使用这个配置文件的配置了。

有的时候我们开始一个新的前端项目,并不需要从零开始配置 webpack,而可以使用一些工具来帮助快速生成 webpack 配置。

现今,大多数前端框架都提供了简单的工具来协助快速生成项目基础文件,一般都会包含项目使用的 webpack 的配置,如:

  • create-react-app
    • create-react-app 的 webpack 配置在这个项目下:react-scripts。
  • angular/devkit/build-webpack
    • 通常 angular 的项目开发和生产的构建任务都是使用 angular-cli 来运行的,但 angular-cli 只是命令的使用接口,基础功能是由 angular/devkit 来实现的,webpack 的构建相关只是其中一部分,详细的配置可以参考 webpack-configs 。
  • vue-cli
    • vue-cli 使用 webpack 模板生成的项目文件中,webpack 相关配置存放在 build 目录下。

这些工具都提供了极其完整的配置来帮助开发者快捷开始一个项目,我们可以学习了解它们所提供的 webpack 配置,有些情况下,还会尝试修改这些配置以满足特殊的需求。

所以你也会发现,这些极其流行的前端类库或者框架都提供了基于 webpack 的工具,webpack 基本成为前端项目构建工具的标配。

这三个工具中,只有 angular-cli 使用了 4.x 版本的 webpack,其他的都还是用的 3.x 版本,学习的时候要留意一下版本区别。

小结

webpack 的安装和使用和大多数使用 Node.js 开发的命令行工具一样,使用 npm 安装后执行命令即可,webpack 4.x 版本的零配置特性也让上手变得更加简单。

前面我们已经介绍了 webpack 的几个重要的概念:入口,loader,plugin,输出,并且展示了一个简单的 webpack 配置例子,最后提供了前端社区三大框架基于 webpack 的脚手架工具的链接,也许这些工具提供的配置会比较难懂,后续的小节会帮助你逐渐去深入,慢慢地,你会对 webpack 配置越来越得心应手。

搭建基本的前端开发环境(二)

我们日常使用的前端开发环境应该是怎样的?我们可以尝试着把基本前端开发环境的需求列一下:

  • 构建我们发布需要的 HTML、CSS、JS 文件
  • 使用 CSS 预处理器来编写样式
  • 处理和压缩图片
  • 使用 Babel 来支持 ES 新特性
  • 本地提供静态服务以方便开发调试

上述几项应该可以满足比较简单的前端项目开发环境需求了,下面会一一介绍如何配置 webpack 来满足这些需求。

关联 HTML

webpack 默认从作为入口的 .js 文件进行构建(更多是基于 SPA 去考虑),但通常一个前端项目都是从一个页面(即 HTML)出发的,最简单的方法是,创建一个 HTML 文件,使用 script 标签直接引用构建好的 JS 文件,如:

<script src="./dist/bundle.js"></script>

但是,如果我们的文件名或者路径会变化,例如使用 [hash] 来进行命名,那么最好是将 HTML 引用路径和我们的构建结果关联起来,这个时候我们可以使用 html-webpack-plugin。

html-webpack-plugin 是一个独立的 node package,所以在使用之前我们需要先安装它,把它安装到项目的开发依赖中:

npm install html-webpack-plugin -D 

# 或者
yarn add html-webpack-plugin -D

然后在 webpack 配置中,将 html-webpack-plugin 添加到 plugins 列表中:

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

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

这样配置好之后,构建时 html-webpack-plugin 会为我们创建一个 HTML 文件,其中会引用构建出来的 JS 文件。实际项目中,默认创建的 HTML 文件并没有什么用,我们需要自己来写 HTML 文件,可以通过 html-webpack-plugin 的配置,传递一个写好的 HTML 模板:

module.exports = {
  // ...
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html', // 配置输出文件名和路径
      template: 'assets/index.html', // 配置文件模板
    }),
  ],
}

这样,通过 html-webpack-plugin 就可以将我们的页面和构建 JS 关联起来,回归日常,从页面开始开发。如果需要添加多个页面关联,那么实例化多个 html-webpack-plugin, 并将它们都放到 plugins 字段数组中就可以了。

更多配置这里就不展开讲解了,参考文档 html-webpack-plugin 以及官方提供的例子 html-webpack-plugin/examples

构建 CSS

我们编写 CSS,并且希望使用 webpack 来进行构建,为此,需要在配置中引入 loader 来解析和处理 CSS 文件

module.exports = {
  module: {
    rules: [
      // ...
      {
        test: /\.css/,
        include: [
          path.resolve(__dirname, 'src'),
        ],
        use: [
          'style-loader',
          'css-loader',
        ],
      },
    ],
  }
}

style-loader 和 css-loader 都是单独的 node package,需要安装。

我们创建一个 index.css 文件,并在 index.js 中引用它,然后进行构建。

import "./index.css"

可以发现,构建出来的文件并没有 CSS,先来看一下新增两个 loader 的作用:

  • css-loader 负责解析 CSS 代码,主要是为了处理 CSS 中的依赖,例如 @import 和 url() 等引用外部文件的声明;
  • style-loader 会将 css-loader 解析的结果转变成 JS 代码,运行时动态插入 style 标签来让 CSS 代码生效。

经由上述两个 loader 的处理后,CSS 代码会转变为 JS,和 index.js 一起打包了。如果需要单独把 CSS 文件分离出来,我们需要使用 extract-text-webpack-plugin 插件。

const ExtractTextPlugin = require('extract-text-webpack-plugin')

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.css$/,
        // 因为这个插件需要干涉模块转换的内容,所以需要使用它对应的 loader
        use: ExtractTextPlugin.extract({ 
          fallback: 'style-loader',
          use: 'css-loader',
        }), 
      },
    ],
  },
  plugins: [
    // 引入插件,配置文件名,这里同样可以使用 [hash]
    new ExtractTextPlugin('index.css'),
  ],
}

CSS 预处理器

在上述使用 CSS 的基础上,通常我们会使用 Less/Sass 等 CSS 预处理器,webpack 可以通过添加对应的 loader 来支持,以使用 Less 为例,我们可以在官方文档中找到对应的 loader。

我们需要在上面的 webpack 配置中,添加一个配置来支持解析后缀为 .less 的文件:

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.less$/,
        // 因为这个插件需要干涉模块转换的内容,所以需要使用它对应的 loader
        use: ExtractTextPlugin.extract({ 
          fallback: 'style-loader',
          use: [
            'css-loader', 
            'less-loader',
          ],
        }), 
      },
    ],
  },
  // ...
}

处理图片文件

在前端项目的样式中总会使用到图片,虽然我们已经提到 css-loader 会解析样式中用 url() 引用的文件路径,但是图片对应的 jpg/png/gif 等文件格式,webpack 处理不了。是的,我们只要添加一个处理图片的 loader 配置就可以了,现有的 file-loader 就是个不错的选择。

file-loader 可以用于处理很多类型的文件,它的主要作用是直接输出文件,把构建后的文件路径返回。配置很简单,在 rules中添加一个字段,增加图片类型文件的解析配置

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/,
        use: [
          {
            loader: 'file-loader',
            options: {},
          },
        ],
      },
    ],
  },
}

更多关于 file-loader 的配置可以参考官方文档 file-loader。

使用 Babel

Babel 是一个让我们能够使用 ES 新特性的 JS 编译工具,我们可以在 webpack 中配置 Babel,以便使用 ES6、ES7 标准来编写 JS 代码。

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.jsx?/, // 支持 js 和 jsx
        include: [
          path.resolve(__dirname, 'src'), // src 目录下的才需要经过 babel-loader 处理
        ],
        loader: 'babel-loader',
      },
    ],
  },
}

Babel 的相关配置可以在目录下使用 .babelrc 文件来处理,详细参考 Babel 官方文档 .babelrc

启动静态服务

至此,我们完成了处理多种文件类型的 webpack 配置。我们可以使用 webpack-dev-server 在本地开启一个简单的静态服务来进行开发。

在项目下安装 webpack-dev-server,然后添加启动命令到 package.json 中:

"scripts": {
  "build": "webpack --mode production",
  "start": "webpack-dev-server --mode development"
}

也可以全局安装 webpack-dev-server,但通常建议以项目开发依赖的方式进行安装,然后在 npm package 中添加启动脚本。

尝试着运行 npm run start 或者 yarn start,然后就可以访问 http://localhost:8080/ 来查看你的页面了。默认是访问 index.html,如果是其他页面要注意访问的 URL 是否正确。

小结

我们现在已经可以使用 webpack 来完成日常中需要的基础前端构建需求:构建 HTML、CSS、JS 文件、使用 CSS 预处理器来编写样式、处理和压缩图片、使用 Babel、方便开发调试的静态服务,接下来的小节会在这个基础上,深入 webpack 配置细节,结合实际工作中的一些需要,更进一步地了解 webpack 的使用。

webpack 如何解析代码模块路径(三)

在 webpack 支持的前端代码模块化中,我们可以使用类似 ==import * as m from './index.js'== 来引用代码模块 ==index.js==。

引用第三方类库则是像这样:==import React from 'react'==。webpack 构建的时候,会解析依赖后,然后再去加载依赖的模块文件,那么 webpack 如何将上述编写的 ./index.js 或 react 解析成对应的模块文件路径呢?

在 JavaScript 中尽量使用 ECMAScript 2015 Modules 语法来引用依赖。

webpack 中有一个很关键的模块 enhanced-resolve 就是处理依赖模块路径的解析的,这个模块可以说是 Node.js 那一套模块路径解析的增强版本,有很多可以自定义的解析配置。

不熟悉 Node.js 模块路径解析机制的同学可以参考这篇文章:深入 Node.js 的模块机制

模块解析规则

我们简单整理一下基本的模块解析规则,以便更好地理解后续 webpack 的一些配置会产生的影响。

  • 解析相对路径
    • 查找相对当前模块的路径下是否有对应文件或文件夹
    • 是文件则直接加载
    • 是文件夹则继续查找文件夹下的 package.json 文件
    • 有 package.json 文件则按照文件中 main 字段的文件名来查找文件
    • 无 package.json 或者无 main 字段则查找 index.js 文件
  • 解析模块名
    • 查找当前文件目录下,父级目录及以上目录下的 node_modules 文件夹,看是否有对应名称的模块
  • 解析绝对路径(不建议使用)
  • 直接查找对应路径的文件

在 webpack 配置中,和模块路径解析相关的配置都在 resolve 字段下:

module.exports = {
  resolve: {
    // ...
  }

接下来的内容会省略上述代码,直接描述 resolve 字段中的内容。

常用的一些配置

我们先从一些简单的需求来阐述 webpack 可以支持哪些解析路径规则的自定义配置。

==resolve.alias==

假设我们有个 utils 模块极其常用,经常编写相对路径很麻烦,希望可以直接 import 'utils' 来引用,那么我们可以配置某个模块的别名,如:

alias: {
  utils: path.resolve(__dirname, 'src/utils') // 这里使用 path.resolve 和 __dirname 来获取绝对路径
}

上述的配置是模糊匹配,意味着只要模块路径中携带了 utils 就可以被替换掉,如:

import 'utils/query.js' // 等同于 import '[项目绝对路径]/src/utils/query.js'

如果需要进行精确匹配可以使用:

alias: {
  utils$: path.resolve(__dirname, 'src/utils') // 只会匹配 import 'utils'
}

更多匹配相关的写法可以参考官方文档 Resolve Alias,这里不一一举例说明。

==resolve.extensions==

在看第 1 小节中的 webpack 配置时,你可能留意到了这么一行:

extensions: ['.wasm', '.mjs', '.js', '.json', '.jsx'],
// 这里的顺序代表匹配后缀的优先级,例如对于 index.js 和 index.jsx,会优先选择 index.js

看到数组中配置的字符串大概就可以猜到,这个配置的作用是和文件后缀名有关的。是的,这个配置可以定义在进行模块路径解析时,webpack 会尝试帮你补全那些后缀名来进行查找,例如有了上述的配置,当你在 src/utils/ 目录下有一个 common.js 文件时,就可以这样来引用:

import * as common from './src/utils/common'

webpack 会尝试给你依赖的路径添加上 extensions 字段所配置的后缀,然后进行依赖路径查找,所以可以命中 src/utils/common.js 文件。

但如果你是引用 src/styles 目录下的 common.css 文件时,如 import './src/styles/common',webpack 构建时则会报无法解析模块的错误。

你可以在引用时添加后缀,import './src/styles/common.css' 来解决,或者在 extensions 添加一个 .css 的配置:

extensions: ['.wasm', '.mjs', '.js', '.json', '.jsx', '.css'],

==resolve.modules==

前面的内容有提到,对于直接声明依赖名的模块(如 react ),webpack 会类似 Node.js 一样进行路径搜索,搜索 node_modules 目录,这个目录就是使用 resolve.modules 字段进行配置的,默认就是:

resolve: {
  modules: ['node_modules'],
},

通常情况下,我们不会调整这个配置,但是如果可以确定项目内所有的第三方依赖模块都是在项目根目录下的 node_modules 中的话,那么可以在 node_modules 之前配置一个确定的绝对路径:

resolve: {
  modules: [
    path.resolve(__dirname, 'node_modules'), // 指定当前目录下的 node_modules 优先查找
    'node_modules', // 如果有一些类库是放在一些奇怪的地方的,你可以添加自定义的路径或者目录
  ],
},

这样配置在某种程度上可以简化模块的查找,提升构建速度。

==resolve.mainFields==

有 package.json 文件则按照文件中 main 字段的文件名来查找文件 我们之前有提到这么一句话,其实确切的情况并不是这样的,webpack 的 resolve.mainFields 配置可以进行调整。当引用的是一个模块或者一个目录时,会使用 package.json 文件的哪一个字段下指定的文件,默认的配置是这样的:

resolve: {
  // 配置 target === "web" 或者 target === "webworker" 时 mainFields 默认值是:
  mainFields: ['browser', 'module', 'main'],

  // target 的值为其他时,mainFields 默认值为:
  mainFields: ["module", "main"],
},

因为通常情况下,模块的 package 都不会声明 browser 或 module 字段,所以便是使用 main 了。

在 NPM packages 中,会有些 package 提供了两个实现,分别给浏览器和 Node.js 两个不同的运行时使用,这个时候就需要区分不同的实现入口在哪里。如果你有留意一些社区开源模块的 package.json 的话,你也许会发现 browser 或者 module 等字段的声明。

resolve.mainFiles 当目录下没有 package.json 文件时,我们说会默认使用目录下的 index.js 这个文件,其实这个也是可以配置的,是的,使用 resolve.mainFiles 字段,默认配置是:

resolve: {
  mainFiles: ['index'], // 你可以添加其他默认使用的文件名
},

通常情况下我们也无须修改这个配置,index.js 基本就是约定俗成的了。

resolve.resolveLoader 这个字段 resolve.resolveLoader 用于配置解析 loader 时的 resolve 配置,原本 resolve 的配置项在这个字段下基本都有。我们看下默认的配置:

resolve: {
  resolveLoader: {
    extensions: ['.js', '.json'],
    mainFields: ['loader', 'main'],
  },
},

这里提供的配置相对少用,我们一般遵从标准的使用方式,使用默认配置,然后把 loader 安装在项目根路径下的 node_modules 下就可以了。

小结

webpack 依赖 enhanced-resolve 来解析代码模块的路径,webpack 配置文件中和 resolve 相关的选项都会传递给 enhanced-resolve 使用,我们介绍了这些选项的作用:

  • resolve.alias
  • resolve.extensions
  • resolve.modules
  • resolve.mainFiles
  • resolve.resolveLoader

webpack 提供的这些选项可以帮助你更加灵活地去控制项目中代码模块的解析,除了上述的选项外,其他的选项在日常项目中相对比较少用到,如若需要,可以参考官方文档 Resolve。

webpack 如何解析代码模块路径(四)

在 webpack 支持的前端代码模块化中,我们可以使用类似 ==import * as m from './index.js'== 来引用代码模块 ==index.js==。

引用第三方类库则是像这样:==import React from 'react'==。webpack 构建的时候,会解析依赖后,然后再去加载依赖的模块文件,那么 webpack 如何将上述编写的 ./index.js 或 react 解析成对应的模块文件路径呢?

在 JavaScript 中尽量使用 ECMAScript 2015 Modules 语法来引用依赖。

webpack 中有一个很关键的模块 enhanced-resolve 就是处理依赖模块路径的解析的,这个模块可以说是 Node.js 那一套模块路径解析的增强版本,有很多可以自定义的解析配置。

不熟悉 Node.js 模块路径解析机制的同学可以参考这篇文章:深入 Node.js 的模块机制

模块解析规则

我们简单整理一下基本的模块解析规则,以便更好地理解后续 webpack 的一些配置会产生的影响。

  • 解析相对路径
    • 查找相对当前模块的路径下是否有对应文件或文件夹
    • 是文件则直接加载
    • 是文件夹则继续查找文件夹下的 package.json 文件
    • 有 package.json 文件则按照文件中 main 字段的文件名来查找文件
    • 无 package.json 或者无 main 字段则查找 index.js 文件
  • 解析模块名
    • 查找当前文件目录下,父级目录及以上目录下的 node_modules 文件夹,看是否有对应名称的模块
  • 解析绝对路径(不建议使用)
  • 直接查找对应路径的文件

在 webpack 配置中,和模块路径解析相关的配置都在 resolve 字段下:

module.exports = {
  resolve: {
    // ...
  }

接下来的内容会省略上述代码,直接描述 resolve 字段中的内容。

常用的一些配置

我们先从一些简单的需求来阐述 webpack 可以支持哪些解析路径规则的自定义配置。

==resolve.alias==

假设我们有个 utils 模块极其常用,经常编写相对路径很麻烦,希望可以直接 import 'utils' 来引用,那么我们可以配置某个模块的别名,如:

alias: {
  utils: path.resolve(__dirname, 'src/utils') // 这里使用 path.resolve 和 __dirname 来获取绝对路径
}

上述的配置是模糊匹配,意味着只要模块路径中携带了 utils 就可以被替换掉,如:

import 'utils/query.js' // 等同于 import '[项目绝对路径]/src/utils/query.js'

如果需要进行精确匹配可以使用:

alias: {
  utils$: path.resolve(__dirname, 'src/utils') // 只会匹配 import 'utils'
}

更多匹配相关的写法可以参考官方文档 Resolve Alias,这里不一一举例说明。

==resolve.extensions==

在看第 1 小节中的 webpack 配置时,你可能留意到了这么一行:

extensions: ['.wasm', '.mjs', '.js', '.json', '.jsx'],
// 这里的顺序代表匹配后缀的优先级,例如对于 index.js 和 index.jsx,会优先选择 index.js

看到数组中配置的字符串大概就可以猜到,这个配置的作用是和文件后缀名有关的。是的,这个配置可以定义在进行模块路径解析时,webpack 会尝试帮你补全那些后缀名来进行查找,例如有了上述的配置,当你在 src/utils/ 目录下有一个 common.js 文件时,就可以这样来引用:

import * as common from './src/utils/common'

webpack 会尝试给你依赖的路径添加上 extensions 字段所配置的后缀,然后进行依赖路径查找,所以可以命中 src/utils/common.js 文件。

但如果你是引用 src/styles 目录下的 common.css 文件时,如 import './src/styles/common',webpack 构建时则会报无法解析模块的错误。

你可以在引用时添加后缀,import './src/styles/common.css' 来解决,或者在 extensions 添加一个 .css 的配置:

extensions: ['.wasm', '.mjs', '.js', '.json', '.jsx', '.css'],

==resolve.modules==

前面的内容有提到,对于直接声明依赖名的模块(如 react ),webpack 会类似 Node.js 一样进行路径搜索,搜索 node_modules 目录,这个目录就是使用 resolve.modules 字段进行配置的,默认就是:

resolve: {
  modules: ['node_modules'],
},

通常情况下,我们不会调整这个配置,但是如果可以确定项目内所有的第三方依赖模块都是在项目根目录下的 node_modules 中的话,那么可以在 node_modules 之前配置一个确定的绝对路径:

resolve: {
  modules: [
    path.resolve(__dirname, 'node_modules'), // 指定当前目录下的 node_modules 优先查找
    'node_modules', // 如果有一些类库是放在一些奇怪的地方的,你可以添加自定义的路径或者目录
  ],
},

这样配置在某种程度上可以简化模块的查找,提升构建速度。

==resolve.mainFields==

有 package.json 文件则按照文件中 main 字段的文件名来查找文件 我们之前有提到这么一句话,其实确切的情况并不是这样的,webpack 的 resolve.mainFields 配置可以进行调整。当引用的是一个模块或者一个目录时,会使用 package.json 文件的哪一个字段下指定的文件,默认的配置是这样的:

resolve: {
  // 配置 target === "web" 或者 target === "webworker" 时 mainFields 默认值是:
  mainFields: ['browser', 'module', 'main'],

  // target 的值为其他时,mainFields 默认值为:
  mainFields: ["module", "main"],
},

因为通常情况下,模块的 package 都不会声明 browser 或 module 字段,所以便是使用 main 了。

在 NPM packages 中,会有些 package 提供了两个实现,分别给浏览器和 Node.js 两个不同的运行时使用,这个时候就需要区分不同的实现入口在哪里。如果你有留意一些社区开源模块的 package.json 的话,你也许会发现 browser 或者 module 等字段的声明。

resolve.mainFiles 当目录下没有 package.json 文件时,我们说会默认使用目录下的 index.js 这个文件,其实这个也是可以配置的,是的,使用 resolve.mainFiles 字段,默认配置是:

resolve: {
  mainFiles: ['index'], // 你可以添加其他默认使用的文件名
},

通常情况下我们也无须修改这个配置,index.js 基本就是约定俗成的了。

resolve.resolveLoader 这个字段 resolve.resolveLoader 用于配置解析 loader 时的 resolve 配置,原本 resolve 的配置项在这个字段下基本都有。我们看下默认的配置:

resolve: {
  resolveLoader: {
    extensions: ['.js', '.json'],
    mainFields: ['loader', 'main'],
  },
},

这里提供的配置相对少用,我们一般遵从标准的使用方式,使用默认配置,然后把 loader 安装在项目根路径下的 node_modules 下就可以了。

小结

webpack 依赖 enhanced-resolve 来解析代码模块的路径,webpack 配置文件中和 resolve 相关的选项都会传递给 enhanced-resolve 使用,我们介绍了这些选项的作用:

  • resolve.alias
  • resolve.extensions
  • resolve.modules
  • resolve.mainFiles
  • resolve.resolveLoader

webpack 提供的这些选项可以帮助你更加灵活地去控制项目中代码模块的解析,除了上述的选项外,其他的选项在日常项目中相对比较少用到,如若需要,可以参考官方文档 Resolve。

配置loader(五)

webpack 的 loader 用于处理不同的文件类型,但是日常项目中使用loader时候,可能会遇到比较复杂的情况,下面我们深入探讨一下loader的配置细节。

loader匹配规则

当我们需要配置loader时,都是在module.rules中添加新的配置项,在该字段中,每一项被视为一条匹配使用loader的规则。

先来看一个基础的例子:

module.exports = {
  // ...
  module: {
    rules: [ 
      {
        test: /\.jsx?/, // 条件
        include: [ 
          path.resolve(__dirname, 'src'),
        ], // 条件
        use: 'babel-loader', // 规则应用结果
      }, // 一个 object 即一条规则
      // ...
    ],
  },
}

loader 的匹配规则中有两个最关键的因素:一个是匹配条件,一个是匹配规则后的应用。

匹配条件通常都使用请求资源文件的绝对路径来进行匹配,在官方文档中成为resource,除此之外还有比较少用到的issuser,则是声明依赖请求源文件的绝对路径。举个例子:在 /path/to/app.js 中声明引入 import './src/style.scss',resource 是 /path/to/src/style.scss,issuer 是 /path/to/app.js,规则条件会对这两个值来尝试匹配。

上述代码中的 test 和 include 都用于匹配 resource 路径,是 resource.test 和 resource.include 的简写,你也可以这么配置:

module.exports = {
  // ...
  rules: [ 
      {
        resource: { // resource 的匹配条件
          test: /\.jsx?/, 
          include: [ 
            path.resolve(__dirname, 'src'),
          ],
        },
        // 如果要使用 issuer 匹配,便是 issuer: { test: ... }
        use: 'babel-loader',
      },
      // ...
    ], 
}

issuer 规则匹配的场景比较少见,你可以用它来尝试约束某些类型的文件中只能引用某些类型的文件。

当规则的条件匹配时,便会使用对应的 loader 配置,如上述例子中的 babel-loader。关于 loader 配置后面再详细介绍,这里先来看看如何配置更加复杂的规则匹配条件。

规则条件配置

大多数情况下,配置 loader 的匹配条件时,只要使用 test 字段就好了,很多时候都只需要匹配文件后缀名来决定使用什么 loader,但也不排除在某些特殊场景下,我们需要配置比较复杂的匹配条件。webpack 的规则提供了多种配置形式:

  • { test: ... } 匹配特定条件
  • { include: ... } 匹配特定路径
  • { exclude: ... } 排除特定路径
  • { and: [...] }必须匹配数组中所有条件
  • { or: [...] } 匹配数组中任意一个条件
  • { not: [...] } 排除匹配数组中所有条件

上述的所谓条件的值可以是:

字符串:必须以提供的字符串开始,所以是字符串的话,这里我们需要提供绝对路径 正则表达式:调用正则的 test 方法来判断匹配 函数:(path) => boolean,返回 true 表示匹配 数组:至少包含一个条件的数组 对象:匹配所有属性值的条件

通过例子来帮助理解:

rules: [
  {
    test: /\.jsx?/, // 正则
    include: [
      path.resolve(__dirname, 'src'), // 字符串,注意是绝对路径
    ], // 数组
    // ...
  },
  {
    test: {
      js: /\.js/,
      jsx: /\.jsx/,
    }, // 对象,不建议使用
    not: [
      (value) => { /* ... */ return true; }, // 函数,通常需要高度自定义时才会使用
    ],
  },
],

上述多个配置形式结合起来就能够基本满足各种各样的构建场景了,通常我们会结合使用 test/and 和 include&exclude 来配置条件,如上述那个简单的例子。

module type

webpack 4.x 版本强化了 module type,即模块类型的概念,不同的模块类型类似于配置了不同的 loader,webpack 会有针对性地进行处理,现阶段实现了以下 5 种模块类型。

  • javascript/auto:即 webpack 3 默认的类型,支持现有的各种 JS 代码模块类型 —— CommonJS、AMD、ESM
  • javascript/esm:ECMAScript modules,其他模块系统,例如 CommonJS 或者 AMD 等不支持,是 .mjs 文件的默认类型
  • javascript/dynamic:CommonJS 和 AMD,排除 ESM
  • javascript/json:JSON 格式数据,require 或者 import 都可以引入,是 .json 文件的默认类型
  • webassembly/experimental:WebAssembly modules,当前还处于试验阶段,是 .wasm 文件的默认类型

如果不希望使用默认的类型的话,在确定好匹配规则条件时,我们可以使用 type 字段来指定模块类型,例如把所有的 JS 代码文件都设置为强制使用 ESM 类型:

{
  test: /\.js/,
  include: [
    path.resolve(__dirname, 'src'),
  ],
  type: 'javascript/esm', // 这里指定模块类型
},

上述做法是可以帮助你规范整个项目的模块系统,但是如果遗留太多不同类型的模块代码时,建议还是直接使用默认的 javascript/auto。

webpack 后续的开发计划会增加对更多模块类型的支持,例如极其常见的 CSS 和 HTML 模块类型,这个特性值得我们期待一下。

使用 loader 配置

当然,在当前版本的 webpack 中,module.rules 的匹配规则最重要的还是用于配置 loader,我们可以使用 use 字段:

rules: [
  {
    test: /\.less/,
    use: [
      'style-loader', // 直接使用字符串表示 loader
      {
        loader: 'css-loader',
        options: {
          importLoaders: 1
        },
      }, // 用对象表示 loader,可以传递 loader 配置等
      {
        loader: 'less-loader',
        options: {
          noIeCompat: true
        }, // 传递 loader 配置
      },
    ],
  },
],

我们看下上述的例子,先忽略 loader 的使用情况,单纯看看如何配置。use 字段可以是一个数组,也可以是一个字符串或者表示 loader 的对象。如果只需要一个 loader,也可以这样:use: { loader: 'babel-loader', options: { ... } }。

我们还可以使用 options 给对应的 loader 传递一些配置项,这里不再展开。当你使用一些 loader 时,loader 的说明一般都有相关配置的描述。

loader 应用顺序

前面提到,一个匹配规则中可以配置使用多个 loader,即一个模块文件可以经过多个 loader 的转换处理,执行顺序是从最后配置的 loader 开始,一步步往前。例如,对于上面的 less 规则配置,一个 style.less 文件会途径 less-loader、css-loader、style-loader 处理,成为一个可以打包的模块。

loader 的应用顺序在配置多个 loader 一起工作时很重要,通常会使用在 CSS 配置上,除了 style-loader 和 css-loader,你可能还要配置 less-loader 然后再加个 postcss 的 autoprefixer 等。

上述从后到前的顺序是在同一个 rule 中进行的,那如果多个 rule 匹配了同一个模块文件,loader 的应用顺序又是怎样的呢?看一份这样的配置:

rules: [
  {
    test: /\.js$/,
    exclude: /node_modules/,
    loader: "eslint-loader",
  },
  {
    test: /\.js$/,
    exclude: /node_modules/,
    loader: "babel-loader",
  },
],

这样无法法保证 eslint-loader 在 babel-loader 应用前执行。webpack 在 rules 中提供了一个 enforce 的字段来配置当前 rule 的 loader 类型,没配置的话是普通类型,我们可以配置 pre 或 post,分别对应前置类型或后置类型的 loader。

eslint-loader 要检查的是人工编写的代码,如果在 babel-loader 之后使用,那么检查的是 Babel 转换后的代码,所以必须在 babel-loader 处理之前使用。

还有一种行内 loader,即我们在应用代码中引用依赖时直接声明使用的 loader,如 const json = require('json-loader!./file.json') 这种。不建议在应用开发中使用这种 loader,后续我们还会再提到。

顾名思义,所有的 loader 按照++前置 -> 行内 -> 普通 -> 后置++的顺序执行。所以当我们要确保 eslint-loader 在 babel-loader 之前执行时,可以如下添加 enforce 配置:

rules: [
  {
    enforce: 'pre', // 指定为前置类型
    test: /\.js$/,
    exclude: /node_modules/,
    loader: "eslint-loader",
  },
]

当项目文件类型和应用的 loader 不是特别复杂的时候,通常建议把要应用的同一类型 loader 都写在同一个匹配规则中,这样更好维护和控制。

使用 noParse

在 webpack 中,我们需要使用的 loader 是在 module.rules 下配置的,webpack 配置中的 module 用于控制如何处理项目中不同类型的模块。

除了 module.rules 字段用于配置 loader 之外,还有一个 module.noParse 字段,可以用于配置哪些模块文件的内容不需要进行解析。对于一些不需要解析依赖(即无依赖) 的第三方大型类库等,可以通过这个字段来配置,以提高整体的构建速度。

使用 noParse 进行忽略的模块文件中不能使用 import、require、define 等导入机制。

module.exports = { // ... module: { noParse: /jquery|lodash/, // 正则表达式

// 或者使用 function
noParse(content) {
  return /jquery|lodash/.test(content)
},

} }

noParse 从某种程度上说是个优化配置项,日常也可以不去使用。

小结

webpack 的 loader 相关配置都在 module.rules 字段下,我们需要通过 test、include、exclude 等配置好应用 loader 的条件规则,然后使用 use 来指定需要用到的 loader,配置应用的 loader 时还需要注意一下 loader 的执行顺序。

除此之外,webpack 4.x 版本新增了模块类型的概念,相当于 webpack 内置一个更加底层的文件类型处理,暂时只有 JS 相关的支持,后续会再添加 HTML 和 CSS 等类型。

使用 plugin(六)

webpack 中的 plugin 大多都提供额外的能力,它们在 webpack 中的配置都只是把插件实例添加到 plugins 字段的数组中。不过由于需要提供不同的功能,不同的插件本身的配置比较多样化。

社区中有很多 webpack 插件可供使用,而优秀的插件基本上都提供了详细的使用说明文档。更多的插件可以在这里查找:plugins in awesome-webpack

下面通过介绍几个常用的插件来了解插件的使用方法。

DefinePlugin

DefinePlugin 是 webpack 内置的插件,可以使用 webpack.DefinePlugin 直接获取。

这个插件用于创建一些在编译时可以配置的全局常量,这些常量的值我们可以在 webpack 的配置中去指定,例如:

module.exports = {
  // ...
  plugins: [
    new webpack.DefinePlugin({
      PRODUCTION: JSON.stringify(true), // const PRODUCTION = true
      VERSION: JSON.stringify('5fa3b9'), // const VERSION = '5fa3b9'
      BROWSER_SUPPORTS_HTML5: true, // const BROWSER_SUPPORTS_HTML5 = 'true'
      TWO: '1+1', // const TWO = 1 + 1,
      CONSTANTS: {
        APP_VERSION: JSON.stringify('1.1.2') // const CONSTANTS = { APP_VERSION: '1.1.2' }
      }
    }),
  ],
}

有了上面的配置,就可以在应用代码文件中,访问配置好的变量了,如:

console.log("Running App version " + VERSION);

if(!BROWSER_SUPPORTS_HTML5) require("html5shiv");

上面配置的注释已经简单说明了这些配置的效果,这里再简述一下整个配置规则。

  • 如果配置的值是字符串,那么整个字符串会被当成代码片段来执行,其结果作为最终变量的值,如上面的 "1+1",最后的结果是 2
  • 如果配置的值不是字符串,也不是一个对象字面量,那么该值会被转为一个字符串,如 true,最后的结果是 'true'
  • 如果配置的是一个对象字面量,那么该对象的所有 key 会以同样的方式去定义

这样我们就可以理解为什么要使用 JSON.stringify() 了,因为 JSON.stringify(true) 的结果是 'true',JSON.stringify("5fa3b9") 的结果是 "5fa3b9"。

社区中关于 DefinePlugin 使用得最多的方式是定义环境变量,例如 PRODUCTION = true 或者 DEV = true 等。部分类库在开发环境时依赖这样的环境变量来给予开发者更多的开发调试反馈,例如 react 等。

建议使用 process.env.NODE_ENV: ... 的方式来定义 process.env.NODE_ENV,而不是使用 process: { env: { NODE_ENV: ... } } 的方式,因为这样会覆盖掉 process 这个对象,可能会对其他代码造成影响。

copy-webpack-plugin

这个插件看名字就知道它有什么作用,没错,就是用来复制文件的。

我们一般会把开发的所有源码和资源文件放在 src/ 目录下,构建的时候产出一个 build/ 目录,通常会直接拿 build 中的所有文件来发布。有些文件没经过 webpack 处理,但是我们希望它们也能出现在 build 目录下,这时就可以使用 CopyWebpackPlugin 来处理了。

我们来看下如何配置这个插件:

const CopyWebpackPlugin = require('copy-webpack-plugin')

module.exports = {
  // ...
  plugins: [
    new CopyWebpackPlugin([
      { from: 'src/file.txt', to: 'build/file.txt', }, // 顾名思义,from 配置来源,to 配置目标路径
      { from: 'src/*.ico', to: 'build/*.ico' }, // 配置项可以使用 glob
      // 可以配置很多项复制规则
    ]),
  ],
}

glob 用法可以参考 glob-primer

上述的配置日常应用已经足够,更多的配置内容可以参考 copy-webpack-plugin

extract-text-webpack-plugin

extract-text-webpack-plugin 之前的章节有简单介绍过,我们用它来把依赖的 CSS 分离出来成为单独的文件。这里再看一下使用 extract-text-webpack-plugin 的配置:

const ExtractTextPlugin = require('extract-text-webpack-plugin')

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.css$/,
        // 因为这个插件需要干涉模块转换的内容,所以需要使用它对应的 loader
        use: ExtractTextPlugin.extract({ 
          fallback: 'style-loader',
          use: 'css-loader',
        }), 
      },
    ],
  },
  plugins: [
    // 引入插件,配置文件名,这里同样可以使用 [hash]
    new ExtractTextPlugin('index.css'),
  ],
}

在上述的配置中,我们使用了 index.css 作为单独分离出来的文件名,但有的时候构建入口不止一个,extract-text-webpack-plugin 会为每一个入口创建单独分离的文件,因此最好这样配置:

plugins: [
  new ExtractTextPlugin('[name].css'),
],

这样确保在使用多个构建入口时,生成不同名称的文件。

这里再次提及 extract-text-webpack-plugin,一个原因是它是一个蛮常用的插件,另一个原因是它的使用方式比较特别,除了在 plugins 字段添加插件实例之外,还需要调整 loader 对应的配置。

在这里要强调的是,在 webpack 中,loader 和 plugin 的区分是很清楚的,针对文件模块转换要做的使用 loader,而其他干涉构建内容的可以使用 plugin。 ExtractTextWebpackPlugin 既提供了 plugin,也提供了 extract 方法来获取对应需要的 loader。

ProvidePlugin

ProvidePlugin 也是一个 webpack 内置的插件,我们可以直接使用 webpack.ProvidePlugin 来获取。

该组件用于引用某些模块作为应用运行时的变量,从而不必每次都用 require 或者 import,其用法相对简单:

new webpack.ProvidePlugin({
  identifier: 'module',
  // ...
})

// 或者
new webpack.ProvidePlugin({
  identifier: ['module', 'property'], // 即引用 module 下的 property,类似 import { property } from 'module'
  // ...
})

在你的代码中,当 identifier 被当作未赋值的变量时,module 就会被自动加载了,而 identifier 这个变量即 module 对外暴露的内容。

注意,如果是 ES 的 default export,那么你需要指定模块的 default 属性:identifier: ['module', 'default'],。

更多使用例子可以查看官方文档 ProvidePlugin

IgnorePlugin

IgnorePlugin 和 ProvidePlugin 一样,也是一个 webpack 内置的插件,可以直接使用 webpack.IgnorePlugin 来获取。

这个插件用于忽略某些特定的模块,让 webpack 不把这些指定的模块打包进去。例如我们使用 moment.js,直接引用后,里边有大量的 i18n 的代码,导致最后打包出来的文件比较大,而实际场景并不需要这些 i18n 的代码,这时我们可以使用 IgnorePlugin 来忽略掉这些代码文件,配置如下:

module.exports = {
  // ...
  plugins: [
    new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
  ]
}

IgnorePlugin 配置的参数有两个,第一个是匹配引入模块路径的正则表达式,第二个是匹配模块的对应上下文,即所在目录名。

小结

本小节介绍了几个相对常见的 webpack plugin 的使用:

  • DefinePlugin
  • copy-webpack-plugin
  • extract-text-webpack-plugin
  • ProvidePlugin
  • IgnorePlugin

更好地使用 webpack-dev-server(七)

在构建代码并部署到生产环境之前,我们需要一个本地环境,用于运行我们开发的代码。这个环境相当于提供了一个简单的服务器,用于访问webpck构建好的静态文件,我们日常开发时可以使用它来调试前端代码。

之前我已经简单介绍过webpack-dev-server的使用了。webpack-dev-server是webpack官网提供的一个工具,可以基于当前的webpack构建配置快速启动一个静态服务。当mode的development时,会具备hot reload的功能,即当源文件变化时,会及时更新当前页面,一遍你看到最新的效果。

webpack-dev-server 的基础使用

webpack-dev-server 是一个 npm package,安装后在已经有 webpack 配置文件的项目目录下直接启动就可以

npm install webpack-dev-server -g
webpack-dev-server --mode development 

webpack-dev-server 本质上也是调用 webpack,4.x 版本的也要指定 mode,其实 webpack-dev-server 应该直接把 development 作为默认值,有兴趣的同学可以查看这个 issue:Default mode to development?

建议把 webpack-dev-server 作为开发依赖安装,然后使用 npm scripts 来启动,如:

npm install webpack-dev-server --save-dev

package 中的 scripts 配置:

{
  // ...
  "scripts": {
    "start": "webpack-dev-server --mode development"
  }
}
npm run start

webpack-dev-server 默认使用 8080 端口,如果你使用了 html-webpack-plugin 来构建 HTML 文件,并且有一个 index.html 的构建结果,那么直接访问 http://localhost:8080/ 就可以看到 index.html 页面了。如果没有 HTML 文件的话,那么 webpack-dev-server 会生成一个展示静态资源列表的页面。

webpack-dev-server 的配置

在 webpack 的配置中,可以通过 devServer 字段来配置 webpack-dev-server,如端口设置、启动 gzip 压缩等,这里简单讲解几个常用的配置。

public 字段用于指定静态服务的域名,默认是 http://localhost:8080/ ,当你使用 Nginx 来做反向代理时,应该就需要使用该配置来指定 Nginx 配置使用的服务域名。

port 字段用于指定静态服务的端口,如上,默认是 8080,通常情况下都不需要改动。

publicPath 字段用于指定构建好的静态文件在浏览器中用什么路径去访问,默认是 /,例如,对于一个构建好的文件 bundle.js,完整的访问路径是 http://localhost:8080/bundle.js,如果你配置了 publicPath: 'assets/',那么上述 bundle.js 的完整访问路径就是 http://localhost:8080/assets/bundle.js。可以使用整个 URL 来作为 publicPath 的值,如 publicPath: 'http://localhost:8080/assets/'。如果你使用了 HMR,那么要设置 publicPath 就必须使用完整的 URL。

建议将 devServer.publicPath 和 output.publicPath 的值保持一致。

proxy 用于配置 webpack-dev-server 将特定 URL 的请求代理到另外一台服务器上。当你有单独的后端开发服务器用于请求 API 时,这个配置相当有用。例如:

proxy: {
  '/api': {
    target: "http://localhost:3000", // 将 URL 中带有 /api 的请求代理到本地的 3000 端口的服务上
    pathRewrite: { '^/api': '' }, // 把 URL 中 path 部分的 `api` 移除掉
  },
}

webpack-dev-server 的 proxy 功能是使用 http-proxy-middleware 来实现的,如果需要更详细的 proxy 配置,可以参考官方文档 http-proxy-middleware

contentBase 用于配置提供额外静态文件内容的目录,之前提到的 publicPath 是配置构建好的结果以什么样的路径去访问,而 contentBase 是配置额外的静态文件内容的访问路径,即那些不经过 webpack 构建,但是需要在 webpack-dev-server 中提供访问的静态资源(如部分图片等)。推荐使用绝对路径:

// 使用当前目录下的 public
contentBase: path.join(__dirname, "public") 

// 也可以使用数组提供多个路径
contentBase: [path.join(__dirname, "public"), path.join(__dirname, "assets")]

publicPath 的优先级高于 contentBase。

before 和 after 配置用于在 webpack-dev-server 定义额外的中间件,如

before(app){
  app.get('/some/path', function(req, res) { // 当访问 /some/path 路径时,返回自定义的 json 数据
    res.json({ custom: 'response' })
  })
}

before 在 webpack-dev-server 静态资源中间件处理之前,可以用于拦截部分请求返回特定内容,或者实现简单的数据 mock。

after 在 webpack-dev-server 静态资源中间件处理之后,比较少用到,可以用于打印日志或者做一些额外处理。

webpack-dev-server 的配置项比较多,这里只列举了一些日常比较有用的,更多的请参考官方文档 webpack-dev-server

webpack-dev-middleware

如果你熟悉使用 Node.js 来开发 Web 服务,使用过 Express 或者 Koa,那么对中间件的概念应该会有所了解。

简而言之,中间件就是在 Express 之类的 Web 框架中实现各种各样功能(如静态文件访问)的这一部分函数。多个中间件可以一起协同构建起一个完整的 Web 服务器。

不熟悉 Express 中间件概念的同学可以参考 Express 的官方文档 使用中间件。

webpack-dev-middleware 就是在 Express 中提供 webpack-dev-server 静态服务能力的一个中间件,我们可以很轻松地将其集成到现有的 Express 代码中去,就像添加一个 Express 中间件那么简单。

首先安装 webpack-dev-middleware 依赖:

npm install webpack-dev-middleware --save-dev

接着创建一个 Node.js 服务的脚本文件,如 app.js:

const webpack = require('webpack')
const middleware = require('webpack-dev-middleware')
const webpackOptions = require('./webpack.config.js') // webpack 配置文件的路径

// 本地的开发环境默认就是使用 development mode
webpackOptions.mode = 'development'

const compiler = webpack(webpackOptions)
const express = require('express')
const app = express()

app.use(middleware(compiler, {
  // webpack-dev-middleware 的配置选项
}))

// 其他 Web 服务中间件
// app.use(...)

app.listen(3000, () => console.log('Example app listening on port 3000!'))

然后用 Node.js 运行该文件即可:

node app.js # 使用刚才创建的 app.js 文件

使用 webpack-dev-server 的好处是相对简单,直接安装依赖后执行命令即可,而使用 webpack-dev-middleware 的好处是可以在既有的 Express 代码基础上快速添加 webpack-dev-server 的功能,同时利用 Express 来根据需要添加更多的功能,如 mock 服务、代理 API 请求等。

其实 webpack-dev-server 也是基于 Express 开发的,前面提及的 webpack-dev-server 中 before 或 after 的配置字段,也可以用于编写特定的中间件来根据需要添加额外的功能。

实现一个简单的 mock 服务

在前端的日常开发工作中,我们本地需要的不仅仅是提供静态内容访问的服务,还需要模拟后端 API 数据来做一些应用测试工作,这个时候我们需要一个 mock 数据的服务,而 webpack-dev-server 的 before 或 proxy 配置,又或者是 webpack-dev-middleware 结合 Express,都可以帮助我们来实现简单的 mock 服务。

这一部分内容涉及比较多的 Node.js 代码实现,这里不做过于详细的例子解释,只提供一些实现的思路。

我们最主要的需求是当浏览器请求某一个特定的路径时(如 /some/path ),可以访问我们想要的数据内容。

我们先基于 Express app 实现一个简单 mock 功能的方法:

module.export = function mock(app) {
  app.get('/some/path', (req, res) => {
    res.json({ data: '' })
  })

  // ... 其他的请求 mock
  // 如果 mock 代码过多

然后应用到配置中的 before 字段:

const mock = require('./mock')

// ...
before(app) {
  mock(app) // 调用 mock 

这样的 mock 函数照样可以应用到 Express 中去,提供与 webpack-dev-middleware 同样的功能。

由于 app.get('', (req, res) => { ... }) 的 callback 可以拿到 req 请求对象,其实可以根据请求参数来改变返回的结果,即通过参数来模拟多种场景的返回数据来协助测试多种场景下的代码应用。

当你单独实现或者使用一个 mock 服务时,你可以通过 proxy 来配置部分路径代理到对应的 mock 服务上去,从而把 mock 服务集成到当前的开发服务中去,相对来说也很简单。

当你和后端开发进行联调时,亦可使用 proxy 代理到对应联调使用的机器上,从而可以使用本地前端代码的开发环境来进行联调。当然了,连线上环境的异常都可以这样来尝试定位问题。

小结

本小节介绍了 webpack-dev-server 的基础使用及其更多的一些配置选项,如何使用 webpack-dev-middleware 来将 webpack 的开发环境集成到现有的 Node 服务中去,以及如何在 webpack-dev-server 和 webpack-dev-middleware 的基础上实现简单的 mock 服务。

例子

github.com/teabyii/web…

开发和生产环境的构建配置差异(八)

我们在日常的前端开发工作中,一般都会有两套构建环境:一套开发时使用,构建结果用于本地开发调试,不进行代码压缩,打印 debug 信息,包含 sourcemap 文件;另外一套构建后的结果是直接应用于线上的,即代码都是压缩后,运行时不打印 debug 信息,静态文件不包括sourcemap 的。有的时候可能还需要多一套测试环境,在运行时直接进行请求 mock 等工作。

webpack 4.x 版本引入了 mode 的概念,在运行 webpack 时需要指定使用 production 或 development 两个 mode 其中一个,这个功能也就是我们所需要的运行两套构建环境的能力。

当你指定使用 production mode 时,默认会启用各种性能优化的功能,包括构建结果优化以及 webpack 运行性能优化,而如果是 development mode 的话,则会开启 debug 工具,运行时打印详细的错误信息,以及更加快速的增量编译构建。关于这两个 mode 的更详细区别,可以查阅 webpack 作者的medium.com/webpack/web…

虽然 webpack 的 mode 参数已经给我们带来了一些很方便的环境差异化配置,但是针对一些项目情况,例如使用 css-loader 或者 url-loader 等,不同环境传入 loader 的配置也不一样,而 mode 并没有帮助我们做这些事情,因此有些配置还是需要手动区分环境后来进行调整。

在配置文件中区分 mode

之前我们的配置文件都是直接对外暴露一个 JS 对象,这种方式暂时没有办法获取到 webpack 的 mode 参数,我们需要更换一种方式来处理配置。根据官方的文档多种配置类型,配置文件可以对外暴露一个函数,因此我们可以这样做:

module.exports = (env, argv) => ({
  // ... 其他配置
  optimization: {
    minimize: false,
    // 使用 argv 来获取 mode 参数的值
    minimizer: argv.mode === 'production' ? [
      new UglifyJsPlugin({ /* 你自己的配置 */ }), 
      // 仅在我们要自定义压缩配置时才需要这么做
      // mode 为 production 时 webpack 会默认使用压缩 JS 的 plugin
    ] : [],
  },
})

这样获取 mode 之后,我们就能够区分不同的构建环境,然后根据不同环境再对特殊的 loader 或 plugin 做额外的配置就可以了。

以上是 webpack 4.x 的做法,由于有了 mode 参数,区分环境变得简单了。不过在当前业界,估计还是使用 webpack 3.x 版本的居多,所以这里也简单介绍一下 3.x 如何区分环境。

webpack 的运行时环境是 Node.js,我们可以通过 Node.js 提供的机制给要运行的 webpack 程序传递环境变量,来控制不同环境下的构建行为。例如,我们在 npm 中的 scripts 字段添加一个用于生产环境的构建命令:

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

然后在 webpack.config.js 文件中可以通过 process.env.NODE_ENV 来获取命令传入的环境变量:

const config = {
  // ... webpack 配置
}

if (process.env.NODE_ENV === 'production') {
  // 生产环境需要做的事情,如使用代码压缩插件等
  config.plugins.push(new UglifyJsPlugin())
}

module.exports = config

运行时的环境变量

我们使用 webpack 时传递的 mode 参数,是可以在我们的应用代码运行时,通过 process.env.NODE_ENV 这个变量获取的。这样方便我们在运行时判断当前执行的构建环境,使用最多的场景莫过于控制是否打印 debug 信息。

下面这个简单的例子,在应用开发的代码中实现一个简单的 console 打印封装:

export default function log(...args) {
  if (process.env.NODE_ENV === 'development' && console && console.log) {
    console.log.apply(console, args)
  }
}

同样,以上是 webpack 4.x 的做法,下面简单介绍一下 3.x 版本应该如何实现。这里需要用到 DefinePlugin 插件,它可以帮助我们在构建时给运行时定义变量,那么我们只要在前面 webpack 3.x 版本区分构建环境的例子的基础上,再使用 DefinePlugin 添加环境变量即可影响到运行时的代码。

在 webpack 的配置中添加 DefinePlugin 插件:

module.exports = {
  // ...
  // webpack 的配置

  plugins: [
    new webpack.DefinePlugin({
      // webpack 3.x 的 process.env.NODE_ENV 是通过手动在命令行中指定 NODE_ENV=... 的方式来传递的
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
    }),
  ],
}

常见的环境差异配置

前面提及的使用环境变量的方式可以让我们在不同的构建环境中完成不同的构建需求,这里列举一下常见的 webpack 构建差异配置:

  • 生产环境可能需要分离 CSS 成单独的文件,以便多个页面共享同一个 CSS 文件
  • 生产环境需要压缩 HTML/CSS/JS 代码
  • 生产环境需要压缩图片
  • 开发环境需要生成 sourcemap 文件 开发环境需要打印 debug 信息 开发环境需要 live reload 或者 hot reload 的功能

以上是常见的构建环境需求差异,可能更加复杂的项目中会有更多的构建需求(如划分静态域名等),但是我们都可以通过判断环境变量来实现这些有环境差异的构建需求。

webpack 4.x 的 mode 已经提供了上述差异配置的大部分功能,mode 为 production 时默认使用 JS 代码压缩,而 mode 为 development 时默认启用 hot reload,等等。这样让我们的配置更为简洁,我们只需要针对特别使用的 loader 和 plugin 做区分配置就可以了。

webpack 3.x 版本还是只能自己动手修改配置来满足大部分环境差异需求,所以如果你要开始一个新的项目,建议直接使用 webpack 4.x 版本。

拆分配置

前面我们列出了几个环境差异配置,可能这些构建需求就已经有点多了,会让整个 webpack 的配置变得复杂,尤其是有着大量环境变量判断的配置。我们可以把 webpack 的配置按照不同的环境拆分成多个文件,运行时直接根据环境变量加载对应的配置即可。基本的划分如下:

  • webpack.base.js:基础部分,即多个文件中共享的配置
  • webpack.development.js:开发环境使用的配置
  • webpack.production.js:生产环境使用的配置
  • webpack.test.js:测试环境使用的配置

一些复杂的项目可能会有更多配置。这里介绍一下如何处理这样的配置拆分。

首先我们要明白,对于 webpack 的配置,其实是对外暴露一个 JS 对象,所以对于这个对象,我们都可以用 JS 代码来修改它,例如:

const config = {
  // ... webpack 配置
}

// 我们可以修改这个 config 来调整配置,例如添加一个新的插件
config.plugins.push(new YourPlugin());

module.exports = config;

当然,如果是对外暴露一个 JS 函数的话,像本小节第一个例子那样,那么修改配置就更加容易了,这里不再举例说明。

因此,只要有一个工具能比较智能地合并多个配置对象,我们就可以很轻松地拆分 webpack 配置,然后通过判断环境变量,使用工具将对应环境的多个配置对象整合后提供给 webpack 使用。这个工具就是 webpack-merge

我们的 webpack 配置基础部分,即 webpack.base.js 应该大致是这样的:

module.exports = {
  entry: '...',
  output: {
    // ...
  },
  resolve: {
    // ...
  },
  module: {
    // 这里是一个简单的例子,后面介绍 API 时会用到
    rules: [
      {
        test: /\.js$/, 
        use: ['babel'],
      },
    ],
    // ...
  },
  plugins: [
    // ...
  ],
}


然后 webpack.development.js 需要添加 loader 或 plugin,就可以使用 webpack-merge 的 API,例如:

const { smart } = require('webpack-merge')
const webpack = require('webpack')
const base = require('./webpack.base.js')

module.exports = smart(base, {
  module: {
    rules: [
      // 用 smart API,当这里的匹配规则相同且 use 值都是数组时,smart 会识别后处理
      // 和上述 base 配置合并后,这里会是 { test: /\.js$/, use: ['babel', 'coffee'] }
      // 如果这里 use 的值用的是字符串或者对象的话,那么会替换掉原本的规则 use 的值
      {
        test: /\.js$/,
        use: ['coffee'],
      },
      // ...
    ],
  },
  plugins: [
    // plugins 这里的数组会和 base 中的 plugins 数组进行合并
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
    }),
  ],
})

可见 webpack-merge 提供的 smart 方法,可以帮助我们更加轻松地处理 loader 配置的合并。webpack-merge 还有其他 API 可以用于自定义合并行为,这里就不详细介绍了,需要深入了解的同学可以查阅官方文档 webpack-merge。

小结

本小节介绍了 webpack 4.x 和 3.x 如何在配置文件中区分环境来应用不同的配置选项(4.x 使用 mode 参数,3.x 使用 Node.js 的 process.env.NODE_ENV),如何在应用代码运行时携带当前构建环境的相关信息,以及如何利用 webpack-merge 这个工具来更好地维护不同构建环境中对应的构建需求配置。

例子

github.com/teabyii/web…

用 HMR 提高开发效率(九)

HMR 全称是 Hot Module Replacement,即模块热替换。在这个概念出来之前,我们使用过 Hot Reloading,当代码变更时通知浏览器刷新页面,以避免频繁手动刷新浏览器页面。HMR 可以理解为增强版的 Hot Reloading,但不用整个页面刷新,而是局部替换掉部分模块代码并且使其生效,可以看到代码变更后的效果。所以,HMR 既避免了频繁手动刷新页面,也减少了页面刷新时的等待,可以极大地提高前端页面开发效率。

配置使用 HMR

HMR 是 webpack 提供的非常有用的一个功能,跟我们之前提到的一样,安装好 webpack-dev-server, 添加一些简单的配置,即在 webpack 的配置文件中添加启用 HMR 需要的两个插件:

const webpack = require('webpack')

module.exports = {
  // ...
  devServer: {
    hot: true // dev server 的配置要启动 hot,或者在命令行中带参数开启
  },
  plugins: [
    // ...
    new webpack.NamedModulesPlugin(), // 用于启动 HMR 时可以显示模块的相对路径
    new webpack.HotModuleReplacementPlugin(), // Hot Module Replacement 的插件
  ],
}

笔者觉得 HMR 应该是 development mode 默认启动的功能,这个希望 webpack 后续能有优化。

HMR 运行原理

HMR 的实现和运行相对复杂,需要多个部分协同配合,这里稍微介绍一下 HRM 的运行原理。

首先我们要知道一个概念:webpack 内部运行时,会维护一份用于管理构建代码时各个模块之间交互的表数据,webpack 官方称之为 Manifest,其中包括入口代码文件和构建出来的 bundle 文件的对应关系。可以使用 WebpackManifestPlugin 插件来输出这样的一份数据。

了解这个概念后,我们来看一下 HMR 的大致运行流程图。

图一

当你使用前面的配置启动了支持 HMR 的 webpack-dev-server,然后在浏览器打开页面时,你也可以从控制台看到大概的 HMR 执行流程:

图二

开启了 hot 功能的 webpack 会往我们应用的主要代码中添加 WS 相关的代码,用于和服务器保持连接,等待更新动作。

当你配置了 HMR 的插件时,会往应用代码中添加 HMR 运行时的代码,主要用于定义代码模块应用更新时的 API,后面会详细介绍。

有兴趣可以查看源码:HotModuleReplacement.runtime.js

有了这两个部分就可以支持整个 HMR 的功能了。我们先忽略流程图的右上角部分,左下角的流程相对容易理解:当有更新时,webpack-dev-server 发送更新信号给 HMR 运行时,然后 HMR 再请求所需要的更新数据,请求的更新数据没有问题的话就应用更新。

如果 HMR 只是简单替换了代码模块的内容,如替换掉所谓的 installedModules 中需要更新的部分,那么这样并没有办法把更新后的结果实时地在浏览器上显示出来,所以才会需要流程图的右上角部分。

如果无法理解 installedModules,可以参考第 13 小节中的「bundler 的基础流程」这一部分的内容

前面提到的 HMR 运行时代码会提供定义代码模块应用更新时执行的 API,这些 API 可以让我们在模块中定义接收到 HMR 更新应用信号时,需要额外做什么工作。例如, style-loader 就需要实现 HMR 接口,当收到更新时,使用新的样式替换掉旧的样式,大概是这样:

if (module.hot) {
  module.hot.accept('/some/path', function() {
    // ... 用新样式替换旧样式
  })
}

详情可以参考 style-loader 中的代码实现:HMR interface implemention in style-loader

HMR 应用更新时是使用 webpackHotUpdate 来处理的:

webpackHotUpdate(id, { 
  'modulePath': 
  function() {
    // 模块更新后的代码
  }
})

执行 webpackHotUpdate 时如发现模块代码实现了 HMR 接口,就会执行相应的回调或者方法,从而达到应用更新时,模块可以自行管理自己所需要额外做的工作。不过,并不是所有的模块都需要做相关的处理,当遇见没有实现 HMR 接口的模块时,就会往上层冒泡,如本节开头部分的流程图所示。

这里还有一个问题是,webpack 如何保证 HMR 接口中的引用是最新的模块代码?我们看一个简单的例子:

import './index.css'
import hello from './bar'

hello()

if (module.hot) {
  module.hot.accept('./bar', () => {
    // console.log('Accepting the updated bar module!')
    hello()
  })
}

从代码上看,hello 都是同一个,这样的话并没有办法引用最新的模块代码,但是我们看一下上述代码在 webpack 构建后的结果:

if (true) {
  module.hot.accept("./src/bar.js", function(__WEBPACK_OUTDATED_DEPENDENCIES__) { 
    /* harmony import */ 
    __WEBPACK_IMPORTED_MODULE_1__bar__ = __webpack_require__("./src/bar.js"); 
    (() => {
      // console.log('Accepting the updated bar module!')
      Object(__WEBPACK_IMPORTED_MODULE_1__bar__["default"])()
    })(__WEBPACK_OUTDATED_DEPENDENCIES__); 
  })
}

其他代码比较杂,我们集中看 module.hot 的处理部分。这里可以发现,我们的 hello 已经重新使用 webpack_require 来引用了,所以可以确保它是最新的模块代码。

基本上 HMR 的执行原理就是这样,更具体的实现部分就不展开讲解了。在日常开发中,我们需要更多的工具来帮助我们实现 HMR 的接口,避免编写过多 HMR 需要的代码。例如,React 在组件代码更新时可能需要触发重新 render 来实现实时的组件展示效果,官方提供了一些现有的工具,需要的可以参考一下:hot module replacement tools

module.hot 常见的 API

前面 HMR 实现部分已经讲解了实现 HMR 接口的重要性,下面来看看常见的 module.hot API 有哪些,以及如何使用。

之前已经简单介绍过,module.hot.accept 方法指定在应用特定代码模块更新时执行相应的 callback,第一个参数可以是字符串或者数组,如:

if (module.hot) {
  module.hot.accept(['./bar.js', './index.css'], () => {
    // ... 这样当 bar.js 或者 index.css 更新时都会执行该函数
  })
}

module.hot.decline 对于指定的代码模块,拒绝进行模块代码的更新,进入更新失败状态,如 module.hot.decline('./bar.js')。这个方法比较少用到。

module.hot.dispose 用于添加一个处理函数,在当前模块代码被替换时运行该函数,例如:

if (module.hot) {
  module.hot.dispose((data) => {
    // data 用于传递数据,如果有需要传递的数据可以挂在 data 对象上,然后在模块代码更新后可以通过 module.hot.data 来获取
  })
}

module.hot.accept 通常用于指定当前依赖的某个模块更新时需要做的处理,如果是当前模块更新时需要处理的动作,使用 module.hot.dispose 会更加容易方便。

module.hot.removeDisposeHandler 用于移除 dispose 方法添加的 callback。

关于 module.hot 的更多 API 详情可以参考官方文档:Hot Module Replacement APIs

小结

Hot Module Replacement 是 webpack 具备的一个相当重要的特性,用于提升开发效率和体验。在这一小节中,我们介绍了:

  • 在 webpack 中配置使用 HMR
  • HMR 的运行原理
  • 模块中的 HMR 接口 API

例子

本小节提及的一些简单的 Demo 可以在 webpack-examples 找到。

优化前端资源加载 1 - 图片加载优化和代码压缩(十)

前面我们已经提及如何使用 webpack 来满足不同环境的构建需求,其中在生产环境构建时会做额外的一些工作,例如代码压缩等。这一部分的工作就是这一小节的主题,即优化前端资源的加载性能。

我们总是希望浏览器在加载页面时用的时间越短越好,所以构建出来的文件应该越少越小越好,一来减少浏览器需要发起请求的数量,二来减少下载静态资源的时间。

其实 webpack 把多个代码文件打包成几个必需的静态资源,已经很大程度减少了静态资源请求数量了,接下来我们来介绍下如何使用 webpack 实现更多的前端资源加载的优化需求。

CSS Sprites

CSS Sprites 技术是前端领域一种很常见的用于减少图片资源请求数的优化方式,这里不做详细的介绍

在了解 webpack 配置之前,需要明白 CSS Sprites 的原理。

如果你使用的 webpack 3.x 版本,需要 CSS Sprites 的话,可以使用 webpack-spritesmith 或者 sprite-webpack-plugin

我们以 webpack-spritesmith 为例,先安装依赖:

npm install webpack-spritesmith --save-dev

在 webpack 的配置中应用该插件:

module: {
  loaders: [
    // ... 这里需要有处理图片的 loader,如 file-loader
  ]
},
resolve: {
  modules: [
    'node_modules', 
    'spritesmith-generated', // webpack-spritesmith 生成所需文件的目录
  ],
},
plugins: [
  new SpritesmithPlugin({
    src: {
      cwd: path.resolve(__dirname, 'src/ico'), // 多个图片所在的目录
      glob: '*.png' // 匹配图片的路径
    },
    target: {
      // 生成最终图片的路径
      image: path.resolve(__dirname, 'src/spritesmith-generated/sprite.png'), 
      // 生成所需 SASS/LESS/Stylus mixins 代码,我们使用 Stylus 预处理器做例子
      css: path.resolve(__dirname, 'src/spritesmith-generated/sprite.styl'), 
    },
    apiOptions: {
      cssImageRef: "~sprite.png"
    },
  }),
],

在你需要的样式代码中引入 sprite.styl 后调用需要的 mixins 即可:

@import '~sprite.styl'

.close-button
    sprite($close)
.open-button
    sprite($open)

更多的 webpack-spritesmith 配置可以参考:Config of webpack-spritesmith

遗憾的是,上面提到的这两个 plugin 还没更新到支持 webpack 4.x 版本,如果你使用的是 webpack 4.x,你需要配合使用 postcsspostcss-sprites,才能实现 CSS Sprites 的相关构建。

图片压缩

在一般的项目中,图片资源会占前端资源的很大一部分,既然代码都进行压缩了,占大头的图片就更不用说了。

我们之前提及使用 file-loader 来处理图片文件,在此基础上,我们再添加一个 image-webpack-loader 来压缩图片文件。简单的配置如下:

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /.*\.(gif|png|jpe?g|svg|webp)$/i,
        use: [
          {
            loader: 'file-loader',
            options: {}
          },
          {
            loader: 'image-webpack-loader',
            options: {
              mozjpeg: { // 压缩 jpeg 的配置
                progressive: true,
                quality: 65
              },
              optipng: { // 使用 imagemin-optipng 压缩 png,enable: false 为关闭
                enabled: false,
              },
              pngquant: { // 使用 imagemin-pngquant 压缩 png
                quality: '65-90',
                speed: 4
              },
              gifsicle: { // 压缩 gif 的配置
                interlaced: false,
              },
              webp: { // 开启 webp,会把 jpg  png 图片压缩为 webp 格式
                quality: 75
              },
          },
        ],
      },
    ],
  },
}

image-webpack-loader 的压缩是使用 imagemin 提供的一系列图片压缩类库来处理的,如果需要进一步了解详细的配置,可以查看对应类库的官方文档 usage of image-webpack-loader

使用 DataURL

有的时候我们的项目中会有一些很小的图片,因为某些缘故并不想使用 CSS Sprites 的方式来处理(譬如小图片不多,因此引入 CSS Sprites 感觉麻烦),那么我们可以在 webpack 中使用 url-loader 来处理这些很小的图片。

url-loader 和 file-loader 的功能类似,但是在处理文件的时候,可以通过配置指定一个大小,当文件小于这个配置值时,url-loader 会将其转换为一个 base64 编码的 DataURL,配置如下:

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192, // 单位是 Byte,当文件小于 8KB 时作为 DataURL 处理
            },
          },
        ],
      },
    ],
  },
}

更多关于 url-loader 的配置可以参考官方文档 url-loader,一般情况仅使用 limit 即可。

代码压缩

webpack 4.x 版本运行时,mode 为 production 即会启动压缩 JS 代码的插件,而对于 webpack 3.x,使用压缩 JS 代码插件的方式也已经介绍过了。在生产环境中,压缩 JS 代码基本是一个必不可少的步骤,这样可以大大减小 JavaScript 的体积,相关内容这里不再赘述。

除了 JS 代码之外,我们一般还需要 HTML 和 CSS 文件,这两种文件也都是可以压缩的,虽然不像 JS 的压缩那么彻底(替换掉长变量等),只能移除空格换行等无用字符,但也能在一定程度上减小文件大小。在 webpack 中的配置使用也不是特别麻烦,所以我们通常也会使用。

对于 HTML 文件,之前介绍的 html-webpack-plugin 插件可以帮助我们生成需要的 HTML 并对其进行压缩:

module.exports = {
  // ...
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html', // 配置输出文件名和路径
      template: 'assets/index.html', // 配置文件模板
      minify: { // 压缩 HTML 的配置
        minifyCSS: true, // 压缩 HTML 中出现的 CSS 代码
        minifyJS: true // 压缩 HTML 中出现的 JS 代码
      }
    }),
  ],
}

如上,使用 minify 字段配置就可以使用 HTML 压缩,这个插件是使用 html-minifier 来实现 HTML 代码压缩的,minify 下的配置项直接透传给 html-minifier,配置项参考 html-minifier 文档即可。

对于 CSS 文件,我们之前介绍过用来处理 CSS 文件的 css-loader,也提供了压缩 CSS 代码的功能:

module.exports = {
  module: {
    rules: [
      // ...
      {
        test: /\.css/,
        include: [
          path.resolve(__dirname, 'src'),
        ],
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              minimize: true, // 使用 css 的压缩功能
            },
          },
        ],
      },
    ],
  }
}

在 css-loader 的选项中配置 minimize 字段为 true 来使用 CSS 压缩代码的功能。css-loader 是使用 cssnano 来压缩代码的,minimize 字段也可以配置为一个对象,来将相关配置传递给 cssnano。更多详细内容请参考 cssnano 官方文档。

小结

由于优化前端资源加载这个主题相关的内容比较多,所以拆分成多个小节。本小节先介绍了比较基础的部分:CSS Sprites、图片压缩、使用 DataURL,以及基本的代码压缩,接下来的第 10、11 小节还会继续围绕前端资源加载优化的这个主题,介绍更加深入的内容