# 尝新

Webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。

它做的事情是,分析你的项目结构,递归地构建一个依赖关系图(dependency graph),找到应用程序需要的每个模块以及其它的一些浏览器不能直接运行的拓展语言(Scss,TypeScript 等),将其转换和打包为合适的一个或多个 bundle

# 初始化

针对项目的一些基本配置,对项目仓库进行初始化,比如创建 MIT 协议文件、配置不需要进行版本控制的文件等。

针对各类编辑器,还可以设置一些通用的代码格式:

# .editorconfig
root = true

# Unix-style
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

# Matches markdown files
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false

# 安装

首先创建一个目录,并在该目录中使用 yarn init 进行初始化,根据项目的相关信息进行配置,或则你也可以直接在其后面加上参数 -y 直接使用默认的信息。

mkdir webpack-basis && cd $_
yarn init -y

随后在本地安装 webpackwebpack-cli(用于在命令行中运行 webpack)。

yarn add -D webpack webpack-cli

# 零配置打包

Webpack 4.0 开始秉承约定优于配置的理念的思想,现在我们可以进行零配置使用,默认情况下它约定:

  • entry 的默认值是 ./src
  • output.path 的默认值是 ./dist
  • mode 的默认值是 production

现在,创建项目的入口文件 ./src/index.js,并在其中输入一些测试代码:

console.log('Hello world!')

然后使用下面的命令进行打包:

npx webpack

打包完成后会在 ./dist 下生成被打包优化后的 main.js

我们可以创建一个 ./index.html 来引入使用我们打包后的文件,当我们用浏览器打开该 HTML 文件时,可见会在控制台打印 "Hello world!" 的字样。

更多默认配置你可以在这里看到 完整的约定 (opens new window),而关于 npx 的信息你也可以点击 npx 使用教程 - 阮一峰的网络日志 (opens new window) 进行了解。

# 基础配置

Webpack 默认使用的配置文件为 ./webpack.config.js,不过我们在执行 webpack 命令时可以通过 --config 参数指定配置文件,这里我们创建配置文件 webpack.common.js 放置在 ./config 目录下面。

// webpack.common.js
const path = require('path')

const baseConfig = {
  entry: '../src/index.js', // 入口文件
  output: {
    path: path.resolve(__dirname, '../dist'), // 指定输出文件所在目录
    filename: '[name].js', // 输出文件名,其中 name 为变量,值为入口文件名
  },
}

module.exports = baseConfig

需要注意的是指定的输出文件所在目录必须使用绝对地址,这里可以使用 path 模块来处理。

另外,入口文件可以以数组的形式指定多个入口文件,如果要给每个文件取特定的名称的话,也可以使用对象的模式:

entry: {
    app: './src/app.js',
    vendors: './src/vendors.js'
}

对应的,在指定出口文件时可以使用 name、hash、chunkhash、contenthash 等来指定,相关内容可点击 查看更多 (opens new window) 进行了解。

# 脚本命令

完成上面的配置我们就可以使用命令 npx webpack --config config/webpack.common.js 来进行打包了,但是这条命令看起来显得比较长,如果添加更多参数的话,那么就更加糟糕了,它会导致使用起来很麻烦。

所幸 NPM 允许在 package.json 文件里面,使用 scripts 字段定义脚本命令,这给我们带来了很大的方便。

但是,单纯的使用它也可能会让 package.json 文件中变得不可收拾,比如过多的脚本命令会让该文件变得很臃肿。

另外,当我们在 JSON 中配置脚本时,不能对一些关键的选项进行注释,所以这里我们使用 nps 来管理脚本命令。

首先我们需要安装 npsnps-utils,建议将 nps 进行全局安装,或者将 ./node_modules/.bin 添加到环境变量,这样以便于使用 nps 命令。

然后,创建 nps 模块的配置文件 ./package-scripts.js 来指定一些命令:

const npsUtils = require('nps-utils')
// Accepts any number of scripts, filters out any falsy ones and joins them with ' && '
const { series } = npsUtils
// The UNIX command `rm -rf` for node
const { rimraf } = npsUtils

module.exports = {
  scripts: {
    build: {
      description: 'Clean dist directory and run all builds',
      default: series(rimraf('dist/*'), 'webpack --config config/webpack.common.js'),
    },
  },
}

现在你可以使用命令 nps build 来进行打包了。如果没有进行全局安装,也没有修改环境变量的话,你依然可以通过 npx nps build 来执行打包。

当然我们依然可以结合在 package.json 文件里面的 scripts 字段来使用,就像这样:

"scripts": {
    "build": "npx nps build"
},

显然这比一开始直接在 package.json 文件里面,使用 scripts 字段要简单的多,而且我们还获得了更多的配置和其它方便,你可以点击 nps - Github (opens new window)nps-utils - API Document (opens new window) 了解更多相关信息。

# 打包模式

到目前位置,每次我们使用前面配置的打包命令时都会得到一个警告,提示我们需要会打包指定一个模式(development 或 production)。

通常我们会针对不同的模式做一些不同的配置,因此我们可以为每个环境编写彼此独立的 webpack 配置。

遵循不重复原则(Don't repeat yourself - DRY),我们需要保留一个“通用”配置。通过“通用”配置,我们不必在环境特定(environment-specific)的配置中重复代码。为了将这些配置合并在一起,我们将使用一个名为 webpack-merge 的工具。

首先我们需要先安装 webpack-merge

yarn add -D webpack-merge

然后创建 ./config/webpack.prod.js./config/webpack.dev.js 两个文件:

// ./config/webpack.dev.js
const merge = require('webpack-merge')
const common = require('./webpack.common')

const devConfig = {
  mode: 'development',
}

module.exports = merge(common, devConfig)

// ./config/webpack.prod.js
const merge = require('webpack-merge')
const common = require('./webpack.common')

const prodConfig = {
  mode: 'production',
}

module.exports = merge(common, prodConfig)

最后在 ./package-scripts 文件中添加启用两种模式的命令:

// 省略部分代码...
build: {
    default: {
        description: 'Clean dist directory and run all builds in mode production',
        script: series(
            rimraf('dist/*'),
            'webpack --config config/webpack.prod.js'
        )
    },
    dev: {
        description: 'Clean dist directory and run all builds in mode development',
        script: series(
            rimraf('dist/*'),
            'webpack --config config/webpack.dev.js'
        )
    }
}
// 省略部分代码...

现在,当我们使用 npm run build 命令打包时,打开 ./dist/main.js 文件可以看到其中的内容是经过压缩的,如果我们需要运行在开发环境的代码只需要执行 npx nps build.dev 命令,或者你也可以在 package.json 文件中进行配置相应的命令。

# 动态生产 HTML 文件

首先改变目录 ./config./scripts 以更加语义化,因为其中的配置文件都是给脚本执行的。而且,这样后续我们也可以将相应的命令添加到该目录中以便管理。

然后,将目录 ./src 改名为 ./app,将所有程序的代码放在该目录中。

最开始我们为项目创建了一个 ./index.html 文件,并手动引入了打包后的文件,但是当我们为打包后的文件提供动态的文件名时,我们需要再次更改其中引用部分的内容,这样添加了开发的任务。

HtmlWebpackPlugin 插件简化了 HTML 文件的创建,以便为你的 webpack 包提供服务,这对于解决上面的问题显得尤为又用。现在我们先来对其进行安装:

yarn add -D html-webpack-plugin

然后我们移除 ./index.html 文件,并在 ./index.html 目录下创建该插件使用的 HTML 模版文件 ./app/index.html,随便将 /favicon.ico 也移动到 ./app/image 目录,然后改变配置文件:

const HtmlWebpackPlugin = require('html-webpack-plugin')
const merge = require('webpack-merge')
const common = require('./webpack.common')

const htmlWebpackPluginConf = {
  template: './app/index.html', // 指定模版
  filename: 'index.html', // 输出文件名
  favicon: './app/images/favicon.ico',
}

const prodConfig = {
  mode: 'development',
  plugins: [new HtmlWebpackPlugin(htmlWebpackPluginConf)],
}

module.exports = merge(common, prodConfig)

development 模式下还应该在 htmlWebpackPluginConf 中添加 minify 属性:

minify: {
    removeComments: true, // 移除注释
    removeRedundantAttributes: true, // 移除冗余属性
    collapseWhitespace: true // 移除空白
}

# 加载样式

前面我们已经可以动态的创建 HTML 文件了,现在开始处理一些样式文件。首先我们创建一个样式文件 ./app/styles/app.css

html,
body {
  width: 100%;
  height: 100%;
}

body {
  background: linear-gradient(#c0c0c0, #707070);
}

然后在入口文件中引入:

import './styles/app.css'
// ...

但是现在直接打包的话会报错,因为它并不是一个模块文件,所以此时我们需要使用 css-lodaer 来将 CSS 转化成 CommonJS 模块,另外我们还需要使用 style-loader 将 JavaScript 字符串生成为 style 节点。同样我们需要对其进行安装:

yarn add -D css-loader style-loader

使用多个 loader 的原因是 loader 使用保持功能单一的思想,它们的执行顺序由下往上(从右向左)执行,现在我们来对其进行配置:

// webpack-common.js
// ...
const rules = [
  {
    test: /\.css$/,
    use: [
      {
        loader: 'style-loader',
      },
      {
        loader: 'css-loader',
      },
    ],
  },
]

const baseConfig = {
  // ...
  module: {
    rules: rules,
  },
}

尽管它已经可以工作了,但是更多的在开发过程中我们使用的会是 lesssass 或者是 stylus,对此我们需要做的就是安装相应的依赖和 loader,并添加相应的匹配规则,然后同上一样进行配置,只需要在最后再添加上安装的 loader 即可,比如将上面的 app.css 改为 app.less 并安装 lessless-loader

yarn add -D less less-loader

最后启用对 less 支持:

{
    test: /\.less$/,
    use: [{
        loader: 'style-loader'
    }, {
        loader: 'css-loader'
    }, {
        loader: 'less-loader'
    }]
}

# 添加 CSS 前缀

不同的浏览器对于某些样式的支持程度也是不一样的,为了解决一些兼容的问题,通常我们会添加一些浏览器前缀,postcss-loader 正是用来自动解决这个需求的,首先对其进行安装:

yarn add -D postcss-loader autoprefixer

然后在配置文件后启用它:

{
    test: /\.less$/,
    use: [{
        loader: 'style-loader'
    }, {
        loader: 'css-loader'
    }, {
        loader: 'post-loader'
    }, {
        loader: 'less-loader'
    }]
}

最后再为其添加一个单独的配置文件 ./postcss.config.js

module.exports = {
  plugins: [require('autoprefixer')],
}

# 使用环境变量

在分离 CSS 之前先来安装一下 cross-env,虽然我们为不同的环境启用了不同的配置文件,但是对于一些配置来说,在各个环境下的差异比较小,没有必要为了一点差异而再将大部分内容重写一遍。

cross-env 能跨平台地设置及使用环境变量,我们通过该变量来进行一些轻量的特殊配置,比如我们可以将前面的 HtmlWebpackPlugin 的配置来改变一些,将其它环境的配置移除后修改通用配置 webpack-base.js,配置内容和 webpack.prod.js 的配置基本一样,只是配置对象中 minify 属性的值我们不能直接给定:

const htmlWebpackPluginConf = {
  template: './app/index.html', // 指定模版
  filename: 'index.html', // 输出文件名
  favicon: './app/images/favicon.ico',
  minify:
    process.env.NODE_ENV !== 'production'
      ? null
      : {
          removeComments: true, // 移除注释
          removeRedundantAttributes: true, // 移除冗余属性
          collapseWhitespace: true, // 移除空白
        },
}

那么,其中的 process.env.NODE_ENV 变量怎么得到呢,这就需要使用到上面安装的 cross-env,我们不能在 package-scripts.js 中用队列设置,因为它用 && 进行拼接导致 cross-env 模块下,它划分出前后两个环境,因此我们直接在 package.json 中配置:

"scripts": {
    "build": "npx cross-env NODE_ENV=production npx nps build",
    "build.dev": "npx nps build.dev"
},

# 分离样式文件

ExtractTextWebpackPluginmini-css-extract-plugin 两个插件都可以用于提取 css 到独立的文件,在这里我们来使用后者来进行构建,因为它使用异步加载,并且更加容易使用,现在先对其进行安装:

yarn add -D mini-css-extract-plugin

然后在 webpack.common.js 中进行配置:

// ...
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

const rules = [
  {
    test: /\.(le|c)ss$/,
    use: [
      {
        loader: MiniCssExtractPlugin.loader,
        options: {
          hmr: devMode,
        },
      },
      {
        loader: 'css-loader',
      },
      {
        loader: 'postcss-loader',
      },
      {
        loader: 'less-loader',
      },
    ],
  },
]

const miniCssExtractPluginCfg = {
  name: 'styles/[name].css',
}

const baseConfig = {
  // ...
  plugins: [new MiniCssExtractPlugin(miniCssExtractPluginCfg)],
}

module.exports = baseConfig

可见,我们将样式的处理最后一步从 style-loader 的手中转交给了 MiniCssExtractPlugin 插件并在相应位置启用了它。

# 压缩样式文件

现在样式文件虽然被抽离出来了,但是点开打包后的 CSS 文件会发现在两种模式下代码都没有被压缩,在开发环境也许这是我们想要的,但对于线上的代码来说却是不必要的,所以我们需要根据不用的模式来进行配置。

Webpack 4 官方移除了 commonchunk 插件,改用了 optimization 属性进行更加灵活的配置。

如果我们需要对样式代码进行压缩的话,就需要使用到 OptimizeCSSAssetsPlugin 插件,此时由于打破了默认启用的压缩脚本的配置,因此还需要连通 TerserJSPlugin 插件一起安装:

yarn add -D optimize-css-assets-webpack-plugin terser-webpack-plugin

然后在生产模式的配置文件 webpack.prod.js 中配置:

// ...
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')
const TerserJSPlugin = require('terser-webpack-plugin')

const prodConfig = {
  mode: 'production',
  optimization: {
    minimizer: [new OptimizeCSSAssetsPlugin(), new TerserJSPlugin()],
  },
}
// ...

# 编译 JavaScript

现在,开发中我们通常会使用版本更新的 JavaScript,但是同层叠样式一样存在许多兼容上的问题,为了解决这个问题,我们需要一个名为 Babel 的强大编译器。

Babel 是一个工具链,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。

首先将入口文件打印 'Hello world!' 得语句包含在一个箭头函数中:

// index.js
// ...
const sayHello = () => console.log('Hello world!')

sayHello()

然后,我们在对其进行编译,现在打开打包后的 HTML 文件查看控制台,可以发现依然顺利的输出了 'Hello world!',但是查看打包后的 JavaScript 文件会看到其中的箭头函数依然存在,并没有转换为低版本中的语法。

现在准备使用 Babel 来进行转换,要使用它首先我们得对相关依赖进行安装:

  • babel-loader => 使能够使用 BabelWebpack 来转换生产 JavaScript 文件。
  • @babel/cli => Babel 的命令行工具。
  • @babel/core => Babel 的核心功能,包含各种转换方法。
  • @babel/preset-env => Babel 的插件集合(官方预设),定义如何转换,可以根据定制。
yarn add -D babel-loader @babel/cli @babel/core @babel/preset-env

然后在通用配置文件中配置将脚本文件交给 babel-loader 来处理:

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

接着在根目录下创建一个配置文件 babel.config.js

module.exports = function(api) {
  api.cache(true)

  const presets = ['@babel/preset-env']
  const plugins = []

  return {
    presets,
    plugins,
  }
}

到这里它已经可以正常工作了,至少将我们的箭头函数转换为了普通的函数,现在我们还需要创建一个配置文件 .browserslistrc(也可以在 package.josn 文件中通过 字段设置),它指定了项目的目标浏览器的范围。这个值会被 @babel/preset-env, Autoprefixer, stylelint, eslint-plugin-compat 等用来确定需要转译的 JavaScript 特性和需要添加的 CSS 浏览器前缀。

[production staging]
>0.2%,
not dead,
not op_mini all

[development]
last 1 chrome version,
last 1 firefox version,
last 1 safari version

# 按需配置 Babel 插件

现在一些基本的高级语法都可以被转换为低版本的语法了,但是并不是所有最新的语法它都支持,因为 @babel/preset-env 是一部分插件的集合,但并不是所有的,不过它包含基本上我们所能用到的。

如果你需要使用更高级的语法,比如 classdecorate,需要我们安装配置相应的插件,现在我们在入口文件中来创建使用这两个语法:

function sayName(target) {
  console.log(target.name)
}

@sayName
class TestClass {
  constructor() {
    console.log('Hello world!')
  }
}

new TestClass()

此时进行打包毫无意外会报错,接着安装相应的插件:

yarn add -D @babel/plugin-proposal-decorators @babel/plugin-proposal-class-properties

最后,在配置文件中配置即可:

// babal.config.js
// ...
const plugins = [
  [
    '@babel/plugin-proposal-decorators',
    {
      legacy: true,
    },
  ],
  '@babel/plugin-proposal-class-properties',
]
// ...

这里只是对这两种常用的高级语法做一个说明,对于其它不再开始集合中的插件,我们都可以这样进行使用。就像一开始说的,Babel 是一个编译器(输入源码 => 输出编译后的代码)。就像其他编译器一样,编译过程分为三个阶段:解析、转换和打印输出。

我们配置的插件主要就是用于转换阶段,如果不使用插件(或其集合),那么 Babel 就相当于什么也不做,然后原样输出你的代码(空格、换行等格式不保证)。

# 其它语法支持

@babel/polyfill 模块包括 core-js 和一个自定义的 regenerator runtime 模块用于模拟完整的 ES2015+ 环境。

这样我们就可以使用诸如 PromiseWeakMap 之类的新的内置组件、 Array.fromObject.assign 之类的静态方法、 Array.prototype.includes 之类的实例方法以及生成器函数(需要 regenerator 插件)。

// ./app/index.js
const str = 'Hello world!'
console.log(str.includes('e'))

如果现在直接打包,那么对于 includes 方法不会做任何处理,我们需要安装 @babel/polyfill

yarn add @babel/polyfill core-js@3

当然我们没必要直接加载整个 @babel/polyfill 模块,幸运的是在我们所使用的 env preset 提供了一个 "useBuiltIns" 参数,当此参数设置为 "usage" 时,就会加载上面所提到的最后一个优化措施,也就是只包含你所需要的 polyfill

// ...
const presets = [
  // 如果 preset 名称的前缀为 babel-preset- 可以省略该前缀
  [
    '@babel/preset-env',
    {
      useBuiltIns: 'usage',
      corejs: 3,
    },
  ],
]
// ...

这样 Babel 将检查我们的所有代码,以便查找目标环境中缺失的功能,然后只把必须的 polyfill 包含进来,再次进行打包 Babel 会将 includes 方法的实现打包在代码中,需要注意的是需要在通用的 Webpack 配置文件中在匹配脚本的规则里,使用 exclude 属性排除 node_modules 目录。

# 加载字体和图片

file-loaderurl-loader 可以接收并加载任何文件,然后将其输出到构建目录。这就是说,我们可以将它们用于任何类型的文件,包括字体。

首先来安装一下依赖:

yarn add -D url-loader file-loader

然后在 Webpack 的配置文件中相应地方(rules)添加下面的规则,以让其知道何时如何处理:

{
    test: /\.(png|svg|jpg|gif)$/,
    use: [{
        loader: 'url-loader',
        options: {
            limit: 8192,
            fallback: 'file-loader',
            name: 'images/[name].[ext]',
        }
    }]
}, {
    test: /\.(woff|woff2|eot|ttf|otf)$/,
    options: {
        limit: 8192,
        fallback: 'file-loader',
        name: 'font/[name].[ext]',
    }
}

# 使用 source map

Webpack 打包源代码时,可能会很难追踪到错误和警告在源代码中的原始位置,因为在提示错误时,只会提示到打包后文件中的位置,这通常对我们来说没有多大的帮助。

所幸 JavaScript 提供了 source map 功能,将编译后的代码映射回原始源代码,并提供了很多不同的选项可用。

  • source-map:单独生成一个 source-map 文件,报错会定位到行和列。
  • eval-source-map:不会生成单独的文件,报错会定位到行和列。
  • cheap-module-source-map:生成一个单独的映射文件,报错仅提示所在行。
  • cheap-module-eval-source-map:不会生成单独的文件,报错仅提示所在行。

你可以在 这里 (opens new window) 查看更加详细的介绍,实现的基本原理就是一个 JSON 键值对,利用 VLQ 编码与特定的规则存储位置信息。

要在 Webpack 中开启 source map 尤为简单,通常在生成模式下我们并不需要改种映射关系,因此我们只需要在开发的环境中配置即可:

// webpack.dev.js
// ...
const devConfig = {
  mode: 'development',
  devtool: 'cheap-module-eval-source-map',
}
// ...

# 自动编译工具

现在整个项目已经启用了大部分的基本功能,但是每次修改代码之后我们都要重新执行打包命令,然后再刷行页面,这样显得很不方便。现在我们有多个选择:

使用观察模式:只需要为 webpack 命令加上 --watch 参数即可。如此,我们执行打包命令后就不会退出命令行,而是继续观察项目的文件,当我们改变文件内容时会自动再次打包。不过,为了看到修改后的实际效果,我们需要刷新浏览器。

如果需要加上自动刷新浏览器的功能,则可以选择使用 webpack-dev-server 来解决这个问题,它将提供了一个简单的 web 服务器,并且能够实时重新加载。首先对其进行安装:

yarn add -D webpack-dev-server

然后在开发配置文件中对其进行配置:

随后为了使用方便,再在 package-scripts.js 文件中配置启动命令:

// ...
dev: {
    default: {
        description: 'Start a WEB service with development mode',
        script: 'webpack-dev-server --config scripts/webpack.dev.js'
    }
}
// ...
// 顺便将 build 命令中环境变量的设置命令移到了该配置文件中

最后再修改一下 package.json 文件:

"scripts": {
    "build": "npx nps build",
    "build.dev": "npx nps build.dev",
    "dev": "npx nps dev"
},

现在,可以执行命令 yarn run dev 启动项目,然后修改项目中的文件来感受它的妙处了,当然如果你需要更加灵活的配置的话,你也可以使用 webpack-dev-middleware,它可以把 webpack 处理后的文件传递给一个服务器,这样,我们也可以结合 express 来进行处理更多符合自己需求的自定义开发选项。

# tree shaking

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块系统中的静态结构特性,例如 importexport

具体表现为在我们的项目中添加一个新的通用模块文件 src/math.js,此文件导出两个函数:

export const sum = (a, b) => a + b

export const square = num => num ** 2

然后在入口文件中引用其中一个函数:

import { square } from './math'

console.log(square(2))

现在,虽然我们只引用了 src/math.js 文件中导出的其中一个函数,但是当我们进行打包时,会发现其中的两个函数都被打包在了结果中,显然这样的代码有些冗余。

现在我们来 开始使用 tree shaking

  • 使用 ES2015 模块语法(即 import 和 export)。
  • 在项目 package.json 文件中,添加一个 "sideEffects" 入口,向 compiler 提供提示,表明项目中的哪些文件是 "pure(纯的 ES2015 模块)",由此可以安全地删除文件中未使用的部分。
  • 引入一个能够删除未引用代码(dead code)的压缩工具(minifier)(例如 UglifyJSPlugin)。

显然上面我们使用了符合标准的语法,而且压缩工具我们在之前也在 压缩样式文件 进行了配置,现在只需要添加 sideEffects 字段。

# 消除冗余样式

在项目开发中可能产生一些未曾使用的样式代码,使用 PurifyCSS 可以大大减少 CSS 冗余,比如我们经常使用的 BootStrap(140KB),这在实际开发当中是非常有用的。

首先对依赖进行安装:

yarn add -D glob-all purifycss-webpack purify-css

然后在生产环境的配置文件中进行配置:

// ...
const path = require('path')
const glob = require('glob-all')
const PurifyCSSPlugin = require('purifycss-webpack')
// ...
plugins: [
  new PurifyCSSPlugin({
    // Give paths to parse for rules. These should be absolute
    paths: glob.sync([
      path.join(process.cwd(), 'app/**/*.html'),
      path.join(process.cwd(), 'app/**/*.js'),
    ]),
  }),
]
// ...

测试了一下,好像以 id 开头的样式名称(比如 “#id”,“#idtest”,“.id“等)都会被匹配,尽量避免吧。

# 懒加载

懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。

在 Webpack 中我们使用使用符合 ECMAScript 提案 的 import() 语法来实现这个需求,为了直观感受到它的作用,首先我们在入口文件中添加如下代码:

// ...
const ele = document.createElement('button')
ele.innerHTML = '加载 lazy.js'
ele.onclick = () => {
  import(/* webpackChunkName: "lazy" */ './lazy.js').then(data => console.log(data.default))
}
document.body.appendChild(ele)

然后在 app 目录下创建 lazy.js 并输入一下内容:

export default 'Hello world!'

显然现在打包会报错,因为不支持上面所使用的懒加载语法,现在我们需要安装 @babel/plugin-syntax-dynamic-import 来提供支持:

yarn add -D @babel/plugin-syntax-dynamic-import

最后在 babel.config.js 中配置就可以了:

// ...
const plugins = [
  // ...
  '@babel/plugin-syntax-dynamic-import',
]
// ...

# 代码分离

Webpck 4 开始使用 optimization.splitChunks 来代替了之前被大家所熟知的 CommonsChunkPlugin,默认情况下 Webpack 会按照下面的条件进行拆分代码块:

  • 可以被共享的新代码块或者模块来自 node_modules 目录。
  • 新的代码块大于 30kb(min+gziped 之前的体积)。
  • 当按需加载块时,并行请求的最大数目将小于或等于 5 。
  • 初始页面加载时的最大并行请求数将小于或等于 3。

块打包默认情况下只会影响按需加载模块,因为对初始块也进行优化打包会影响 HTML 中的 script 标签数,增加请求数,当然我们可以对其进行配置。

// 此次我们在生成模式的配置文件中配置
// ...
optimization: {
    // ...
    splitChunks: {
        chunks: 'async',
        name: true,
        automaticNameDelimiter: '-',
        // the priority is maxInitialRequest/maxAsyncRequests < maxSize < minSize
        minSize: 0,
        maxSize: 0,
        minChunks: 1, // 引用计数
        maxAsyncRequests: 5, // 最大的并行请求数
        maxInitialRequests: 3, // 入口最大的并行请求数
        cacheGroups: { // 缓存组,会继承 splitChunks 的配置
            lodash: {
            priority: -10, // 缓存组打包的先后优先级
            test: /lodash/, // 控制哪些模块被这个缓存组匹配到
            chunks: 'all',
            },
            common: {
            priority: -20,
            chunks: 'all',
            minChunks: 2,
            reuseExistingChunk: true, // 如果当前代码块包含的模块已经有了,就不在产生一个新的代码块
            },
        },
    },
},
// ...

然后在入口文件中添加一些测试的内容:

// ...
// 被 splitChunks 下缓存组的 lodash 匹配
import _ from 'lodash'

// 被 splitChunks 匹配
import(/* webpackChunkName: "async-util" */ 'util').then(data => console.log(data.default))

const ele = document.createElement('button')
ele.innerHTML = '加载 lazy.js'
ele.onclick = () => {
  // 被 @babel/plugin-syntax-dynamic-import 插件匹配
  import(/* webpackChunkName: "lazy" */ './lazy.js').then(data => console.log(data.default))
}
document.body.appendChild(ele)

现在我们进行打包,并可以看到匹配的代码块被打包到了单独的文件中:

                     favicon.ico   31.3 KiB          [emitted]
                      index.html  532 bytes          [emitted]
              javascript/lazy.js  125 bytes       0  [emitted]  lazy
       javascript/lodash-main.js   68.9 KiB       1  [emitted]  lodash-main
              javascript/main.js   18.6 KiB       2  [emitted]  main
javascript/vendors-async-util.js   9.62 KiB       3  [emitted]  vendors-async-util
                 styles/main.css   61 bytes       2  [emitted]  main

这对于多入口的情况尤其有用。

# 缓存

通过命中缓存,以降低网络流量,使网站加载速度更快,然而,如果我们在部署新版本时不更改资源的文件名,浏览器可能会认为它没有被更新,就会使用它的缓存版本。由于缓存的存在,当你需要获取新的代码时,就会显得很棘手。

通过使用 output.filename 进行文件名替换(对于 CSS 中 miniCssExtractPluginCfg 的处理也是如此),可以确保浏览器获取到修改后的文件。我们可以在指定输出文件名使用 contenthash 占位符,它会随着内容的改变而产生变化。

另外,Webpack 提供了一个优化功能,可以使用 optimization.runtimeChunk 选项将运行时代码拆分为单独的块。将其设置为 single 以为所有块创建单个运行时包,这样避免每次都处理这部分代码。

现在先进行一次打包,然后更改一下 app 下的代码再次进行代码,理想的结果应该是只有 main 模块的 contenthash 会改变,而事实上并非如此,因为每个 module.id 会默认地基于解析顺序(resolve order)进行增量。也就是说,当解析顺序发生变化,ID 也会随之改变。

好在,我们可以使用 HashedModuleIdsPlugin 插件来解决这个问题。

# shim 预置依赖

我们可能会遇到一些需要导出的全局变量的第三方模块,例如 jQuery 中的 $。通常我们可以直接在 HTML 模版中直接通过 CDN 直接引入就可以了。

如果我们还想在代码中 import $ from 'jquery'; 来引入的话,自然,这样会产生多余的代码。为了解决这个问题,我们可以在配置文件中进行配置(之后就不会对其进行打包):

// ...
const baseConfig = {
  // ...
  externals: {
    $: 'jquery',
  },
}
// ...

不过,这样会直接引入整个库。虽然使用全局依赖是不被推崇的,但是如果需要的话,那么就是 shim (预置依赖) 发挥作用的地方。

使用 ProvidePlugin 后,能够在 Webpack 编译的每个模块中,通过访问一个变量来获取一个 package。如果 Webpack 看到模块中用到这个变量,它将在最终 bundle 中引入给定的 package

首先先安装一下 lodash

yarn add lodash

然后在通用配置文件中进行配置:

// ...
const webpack = require('webpack')
// ...
const baseConfig = {
  plugins: [
    // ...
    new webpack.ProvidePlugin({
      _: 'lodash',
    }),
  ],
}
// ...

不过这样依旧引入了整个库,所以这里我们结合 exports-loader 来仅暴露我们需要的到全局,因此首先要对其进行安装:

yarn add -D exports-loader

然后修改一下通用配置文件:

// ...
const webpack = require('webpack')
// ...
const baseConfig = {
  plugins: [
    // ...
    new webpack.ProvidePlugin({
      clone: 'exports-loader?clone!lodash/clone',
    }),
  ],
}
// ...

现在,我们可以在全局使用 clone 方法了,并且只打包了其必须的相关代码。

# module.noParse

防止 webpack 解析那些任何与给定正则表达式相匹配的文件。忽略的文件中不应该含有 import, require, define 的调用,或任何其他导入机制。忽略大型的 library 可以提高构建性能。

webpack 中我们可以通过 module.noParse 来匹配那些不需要被解析依赖的库,比如 jquery,首先我们对其进行安装:

yarn add jquery

然后我们可以在通用配置文件中进行配置即可:

module.exports = {
  //...
  module: {
    noParse: /jquery/,
  },
}

需要注意的是,这样匹配的话这个第三方库应当是全部引入的,否则会出现错误。比如之前我们按需加载了 lodashclone 方法,如果在此直接匹配 lodash 不处理其中依赖的话是行不通的。

# IgnorePlugin

IgnorePlugin 防止与指定正则或函数返回结果匹配的资源在 importrequire 调用产生模块。

比如,我们在处理时间时经常会选择使用 moment 模块,该模块的入口文件 moment.js 中引入了其中 local 目录下的各个语言包,通常在我们的项目中我们并不需要所有的语言包。

为了解决引入所有语音包带来的冗余代码,我们可以就可以使用 IgnorePlugin 来实现。首先我们还是先来安装一下 moment

yarn add moment

然后在入口文件中引入,并使用它:

import moment from 'moment'
import 'moment/locale/zh-cn' // 由于忽略了语言包的引入,所以需要单独引入我们所需的语言包

moment.locale('zh-cn')
console.log(moment().format('MMMM Do YYYY, h:mm:ss a'))

最后在通用配置文件中配置:

new webpack.IgnorePlugin({
  resourceRegExp: /^\.\/locale$/,
  contextRegExp: /moment$/,
})

现在在 moment 中所有从 local 中的引入都会被忽略。

# expose-loader

假设你要将 jQuery 暴露至全局并称为 $,那么只需要安装一下 expose-loader

yarn add -D expose-loader

然后在配置文件中添加一下规则即可:

// webpack.config.js
module: {
  rules: [
    {
      test: require.resolve('jquery'),
      use: [
        {
          loader: 'expose-loader',
          options: '$',
        },
      ],
    },
  ]
}

需要注意的是,前面我们使用 module.noParse 忽略了 jquery 相关依赖方面的信息,因此我们需要取消忽略。

# HappyPack

HappyPack 就能够基于 Webpack 把任务分解给多个子进程去并发的执行,子进程处理完后再把结果发送给主进程,以实现并行打包。

1、安装:

yarn add -D happypack

2、配置

const HappyPack = require('happypack')
const happyThreadPool = HappyPack.ThreadPool({ size: 5 })
// ...
exports.module = {
  rules: [
    {
      test: /\.(le|c)ss$/,
      use: [
        {
          // 这里我们并没有将 MiniCssExtractPlugin 中的 loader 移到 HappyPack 中
          // 因为一些原因移动进去后并不能工作
          loader: MiniCssExtractPlugin.loader,
          options: {
            hmr: devMode,
          },
        },
        {
          loader: 'happypack/loader?id=styles',
        },
      ],
    },
    {
      test: /.js$/,
      use: 'happypack/loader?id=js',
    },
  ],
}
// ...
exports.plugins = [
  new HappyPack({
    id: 'js',
    threadPool: happyThreadPool, // 使用共享进程池
    loaders: ['babel-loader'],
  }),
  new HappyPack({
    id: 'styles',
    threadPool: happyThreadPool,
    loaders: [
      {
        loader: 'css-loader',
      },
      {
        loader: 'postcss-loader',
      },
      {
        loader: 'less-loader',
      },
    ],
  }),
]

当然,并不是分配的进程越多越好,毕竟开启子进程和进程间的交互也是比较耗时和资源的,因此,推荐在打包大型项目的时候使用该插件,可以更明显的加快打包的速度。

# resolve 属性的配置

我们经常会使用该属性上的 extensions 属性,也就是扩展名。当我们加载一个只有指定后缀的文件时,默认加载的扩展名顺序是 .wasm, .mjs, .js, .json,然后通常我们并不会接触到 .wasm 文件。因此,我们可以改变这个加载顺序。

// webpack.common.js
module.exports = {
  //...
  resolve: {
    extensions: ['.js', '.json', '.less', '.css', '.mjs'],
  },
}

另外,我们还可以通过其上的 alias 来做一些别名,最常用的就是路径别名,对于一些比较的路径用一个名称去代替。

module.exports = {
  //...
  resolve: {
    alias: {
      '@style': path.resolve(__dirname, '../app/styles/'),
    },
  },
}

现在,我们就可以改变入口文件引入样式文件的方式了。

# Eslint

ESLint 是一个 JavaScript 语法规则和代码风格的检查工具,它的目标是保证代码的一致性和避免错误。

首先,我们需要安装 ESLint

yarn add -D eslint

然后,在根目录中添加配置文件:

// .eslintrc.js
module.exports = {
  env: {
    browser: true,
    es6: true,
  },
  extends: 'eslint:recommended',
  globals: {
    Atomics: 'readonly',
    SharedArrayBuffer: 'readonly',
  },
  parserOptions: {
    ecmaVersion: 2018,
    sourceType: 'module',
  },
  rules: {},
}

通常,如果我们需要忽略掉一些文件或目录的检测我们可以添加 .eslintignore 文件。

# Prettier

Prettier 环境下,所有的 JavaScript 代码都会转换为抽象语法树,然后被重新格式化。因此,它确保不会对编写的代码进行任何破坏更改。

ESLint 不同,ESLint 只是一个代码质量工具 (确保没有未使用的变量、没有全局变量,等等)。而 Prettier 只关心格式化文件 (最大长度、混合标签和空格、引用样式等)。你可以将 ESLintPrettier 结合起来使用,以获得双赢的组合。

在项目中使用,我们将把它添加为 dev 依赖项:

yarn add -D prettier

安装成功后,要针对某个特定文件运行的话,只需要运行命令 yarn prettier --write index.js,这样便可在当前目录生成 index.js 文件。

根据需要可以配置 Prettier 的其他选项,通常使用配置文件来做。也可以将这些添加到 .prettierrc.js 文件中并放置在项目的根文件夹下:

module.exports = {
  printWidth: 100,
  trailingComma: 'none',
  singleQuote: true,
  semi: false,
  jsxBracketSameLine: false,
  bracketSpacing: true,
}

然后,你可以将这个命令添加到你的 NPM 脚本中,并将 app 文件夹作为一个 glob 传递给它,而不是一个单独的文件。格式化脚本是这样的:

// ...
"scripts": {
    // ...
    "prettier": "prettier --write './app/**/*.{js,css,json}'"
},
// ...

现在,你可以使用 yarn run prettier 命令来进行格式化代码了。

# 代码提交前检查

husky 可以让 git hooks 的使用变得更简单方便。它会在我们项目根目录下面的 .git/hooks 文件夹下面创建 pre-commitpre-pushhooks。这些 hooks 可以让我们直接在 package.jsonscripts 里运行我们想要在某个 hook 阶段执行的命令。

lint-staged 可以在 staged 阶段的文件上执行 linters,简单点来说就是当我们运行 eslintstylelint 的命令时,只会检查我们通过 git add 添加到暂存区的文件,可以避免我们每次检查都把整个项目的代码都检查一遍。

首先我们对两者进行安装:

yarn add - D husky lint-staged

然后在 package.json 文件中进行配置:

{
  "scripts": {
    "lint:base": "eslint --rule \"prettier/prettier: 2\"",
    "lint": "yarn run lint:base -- .",
    "lint:fix": "yarn run lint -- --fix"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "linters": {
      "*.{ts,js,less,css,json}": ["yarn run lint", "git add"]
    },
    "ignore": ["dist/**/*"]
  }
}

现在当我们再次提交内容时,就会先对我们提交的内容进行检测,检测失败则无法提交。

# 配置 Stylelint

ESLint 很像。二者都提供了工具与规则。但如何配置这些规则完全取决于使用者。所以我们要根据需要自己引入或配置规则。

因此我们之前配置使用了 prettier,所以这里使用官方推荐的 stylelint-config-recommended。当然,首先还是要对它们进行安装:

yarn add -D prettier stylelint-config-recommended

然后在配置文件 stylelintrc.js 中进行配置:

module.exports = {
  processors: [],
  plugins: [],
  extends: ['stylelint-config-standard'],
  rules: {},
}

通过 stylelintrc.js 文件还可以忽略对特定的文件或目录进行检测,其语法与 .gitignore 一致。

# 代码检测冲突

stylelint-config-prettier 可以关闭 stylelint 中一些不必要或则与 prettier 相互冲突的规则,同样的 eslint-config-prettier 也是用于解决 eslint 与其的冲突,使用前先进行安装:

yarn add -D stylelint-config-prettier eslint-config-prettier

然后,在各自配置文件中的规则继承的地方写入配置即可:

// stylelintrc.js
module.exports = {
  // ...
  extends: [
    // 继承的规则
    'stylelint-config-standard',
    'stylelint-config-prettier',
  ],
  // ...
}

// eslintrc.js
module.exports = {
  // ...
  extends: ['eslint:recommended', 'prettier'],
  // ...
  rules: {
    'prettier/prettier': 'error',
  },
}

# 注释

  • 文中文件的相对路径均是相对于项目跟路径而言的。
  • 代码快中的 // ... 表示省略了一些其它代码。

# 参考资料