Webpack 进阶篇

1,191 阅读16分钟

前言

  接着基础篇,进阶篇将从webpack.config.js文件入手,将配置内容展开,一步步介绍webpack是如何工作。将从以下五个方面由浅入深展示webpack:

入口(entry)

出口(output)

预处理器(loader)

插件(plugins)

模式(mode)

在开始之前先问自己一个问题:webpack源文件从哪里来,组装完成之后的产出又要交给谁?


入口&出口

资源处理

  webapck故事的开始从指定一个或多个入口开始,这是故事的起源,如果webpack是一棵树,那么入口就相当于一棵树的根。事实上也确实如此,webapck会从入口文件开始检索,并将具有依赖关系的模块生成一颗依赖树,最终生成一个chunk(字面意思是代码块,在webpack中可以理解成被抽象和包装过后的一些模块)。由chunk得到的打包产物我们一般称之为bundle,它们关系如下:

entry -> module -> bundle

入口

在webpack通过context,entry两个配置项共同决定入口文件的位置。配置入口的时,实际上做了两件事情:

  1. 确定入口位置
  2. 定义chunk name

context

基本目录,一个绝对路径,用于从配置中解析入口点和加载器 context可以理解成资源入口的路径前缀,在配置时必须使用绝对路径的形式,而且它必须是字符串。

const path = require('path');

module.exports = {
  context: path.resolve(__dirname, './src')
  enrty: './compontens/index.js
};
module.exports = {
  context: path.resolve(__dirname, './src/compontens')
  enrty: './index.js
};

这两个等价,主要是为了让entry更加简洁。context可以省略~

entry

与context必须是字符串不同,entry可以是字符串、数组、对象、函数。

string [string] object = { <key> string | [string] | object = { import string | [string], dependOn string | [string], filename string }} (function() => string | [string] | object = { <key> string | [string] } | object = { import string | [string], dependOn string | [string], filename string })
  1. 字符串
module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "./bundle.js"
  },
  mode: "development",
};
  1. 数组类型 说明:传数组作用是将多个资源文件预先合并,在打包时webpack会将数组中的最后一个元素作为实际的入口路径。
module.exports = {
  entry: ['react', './src/index.js'],
  output: {
    filename: "./bundle.js"
  },
  mode: "development",
};
等同于
module.exports = {
  entry: './src/index.js',
  output: {
    filename: "./bundle.js"
  },
  mode: "development",
};

index.js
import 'react',

3.对象类型 如果想要定义多入口,则必须使用对象类型

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

对象值也可以是数组
module.exports = {
  entry: {
  	index: ['react', './src/index.js'],,
    lib: './src/lib.js',
  },
};
  1. 函数类型 函数类型支持异步操作,动态加载
module.exports = {
  entry: () => ({
  	index: ['react', './src/index.js'],,
    lib: './src/lib.js',
  }),
};

异步
module.exports = {
  entry: () => new Promise((resolve) => {
  	setTimeout(() => {
    	resolve('./src/index.js')
    })
  }),
};
动态加载
module.exports = {
  entry() {
  	// 从外部源(远程服务器,文件系统内容或数据库)获取实际条目
    return fetchPathsFromSomeExternalSource(); 
  }
};

vendor

当项目体积越来越大时,一旦代码更新,即便很小改动。用户都要下载整个资源文件,对性能很不友好,为了解决这个问题,我们可以使用提取vendor方法。vendor是供应商对意思,在webpack中一般指的是第三方模块集中打包而产生对bundle。提取vendor

module.exports = {
  entry: {
  	index: './src/index.js',
    vendor: ['react','react-dom','react-redux'],
  },
};

我们进行了vendor提取,那么问题来了,我们并没有为vendor设置入口路径,webpack要如何打包呢?这时候我们可以使用optimization.splitChunks,将index和vendor中的公共模块提取出来。通过这样的配置,index产生的bundle将只包含业务模块,而其依赖的第三方模块将被抽取出来形成一个新的bundel,这样就达到了我们提取vendor的目的。而由于这部分不会经常变动,因此可以有效的利用客户端缓存,在后续请求页面时会加快整体的渲染速度。

出口

  出口的配置项多达十余种,这里只详细将几种常用的配置项。先看简单的例子:

const path = require('path');

module.exports = {
  entry: './path/to/my/entry/file.js',
  output: {
    filename: 'my-first-webpack.bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist/',
  }
};

filename

filename作用是输出资源的文件名,其形式是字符串,看简单例子如下:

output: {
    filename: 'bundle.js',
  }
};

当然它还可以是文件路径:

output: {
    filename: './dist/bundle.js',
  }
};

我们也可以为每个bundle指定不同的名字,webpack使用类似模版语言动态的生成文件名:

output: {
    filename: '[name].js',
  }
};

如果要控制客户端缓存,最好加上[chunkhash],因为每个chunk所产生的chunkhash只与自身内容有关,单个内容的改变不会影响其他资源,可以精准的让客户端缓存得到更新。

output: {
    filename: '[name]@[chunkhash].js',
  }
};

path

path 指定资源输出位置,它的值必须是绝对路径。看例子:

const path = require('path');

module.exports = {
  entry: './path/to/my/entry/file.js',
  output: {
    filename: 'my-first-webpack.bundle.js',
    path: path.resolve(__dirname, 'dist'),
  }
};

例子中将输出路径设置为dist目录,webpack4之后,打包路径默认设置成dist目录

publicPath

publicPath容易与path混淆

path是指定文件输出位置 而publicPath 是指定资源的请求位置

实例

单入口

const path = require('path');

module.exports = {
  entry: './src/app.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'), // webpack4之后默认配置可省略
  }
};

多入口

const path = require('path');

module.exports = {
  entry: {
  	pageA: './src/pageA.js',
    pageB: './src/pageB.js'
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist'), // webpack4之后默认配置可省略
  }
};

预处理器 (loader)

  回顾之前的内容,我们讨论的都是如何进行javascript的打包,但是,一个项目不可能只要javascript文件,项目中的html,css, less, scss, 图片,字体,模版等等webpack是怎么处理的呢?webpack是如何对预编译同一管理的呢? loader,它赋予了webpack处理不同资源类型的能力,大大丰富了webpack的可扩展性

  对于webpack来说,一切都是模块,我们可以像加载js一样去加载css,可以在同一个js文件中去维护js和css文件。模块是具有高内聚及可复用性的结构,通过webapck一切皆模块的思想,我们可以将这些特性应用到每一种静态资源上面。那么我们一起来揭开loader的面纱吧~

什么是loader

在正式开始前先让我们了解一下loader是什么?其实每个loader在本质上都是一个函数。在webpack4之前,函数的输入输出都必须为字符串;在webpack4之后,loader也同时支持抽象语法树(AST)的传递,通过这样的方法来减少重复代码的解析。

output = loader(input)

这里的input可能是工程文件的字符串,也可能是上一个loader转化的结果,loader是链式的

output = loaderA(loaderB(loaderC(input)));

让我们看下loader源码结构

module.exports = function loader (content, map, meta) {
    var callback = this.async();
    var result = handle(content, map, meta);
    
    callback(
    	null,
        result.content, // 转换后的内容
        result.map, // 转换后的 source-map
        result.meta, // 转换后的AST
    )
}

可以看出loader本身就是一个函数,在该函数中对接受到的内容进行转换。

从打包一个css文件开始

我们在src目录下添加main.css文件,目录结构如下:

 └─ webpackDemo ························ sample root dir
    ├── src ·································· source dir
+   │   └── main.css ························· main styles
	│	└── index.js ························· index js
    ├── package.json ························· package file
    └── webpack.config.js ···················· webpack config file

main.css

/* ./src/main.css */
body {
  margin: 0 auto;
  padding: 100px;
  background: yellowgreen;
}

然后在index.js中引入css

import HelloWorld from "./hello-world";
import "./main.css";

document.write("My first webpack app <br />");
HelloWorld();

配置完成过后回到命令行终端再次运行 Webpack 打包命令,此时你会发现命令行报出了一个模块解析错误,如下所示: webapck无法处理css语法,所以抛出来一个错误,并且提示需要一个合适的loader来处理这种文件: 需要的是一个可以加载 CSS 模块的 Loader,最常用到的是 css-loader。我们需要通过 npm 先去安装这个 Loader,然后在配置文件中添加对应的配置,具体操作和配置如下所示:

$ npm install css-loader --save-dev 
# or yarn add css-loader --dev

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "./bundle.js"
  },
  module: {
    rules: [
      {
        test: /\.css$/, // 根据打包过程中所遇到文件路径匹配是否使用这个 loader
        use: "css-loader" // 指定具体的 loader
      }
    ]
  },
  mode: "development",
  devServer: {
    publicPath: "./dist" // 在后面会说publicPath是什么意思
  }
};

在配置对象的 module 属性中添加一个 rules 数组。这个数组就是我们针对资源模块的加载规则配置,其中的每个规则对象都需要设置两个属性:

  • 首先是 test 属性,它是一个正则表达式,用来匹配打包过程中所遇到文件路径,这里我们是以 .css 结尾;

  • 然后是 use 属性,它用来指定匹配到的文件需要使用的 loader,这里用到的是 css-loader。

配置完成之后在执行打包命令就不会出错了,流程如下:

css -> css-loader -> webpack -> bundle

但是这时候你会发现虽然打包成功来,但是css样式并没有生效,这是因为css-loader的作用仅仅是处理css的各种加载语法。如果要让css起作用,还需要style-loader来把样式插入页面,要css-loader和style-loader联合起来使用 同样的,我们先安装style-loader

$ npm install style-loader --save-dev 
# or yarn add style-loader --dev

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "./bundle.js"
  },
  module: {
    rules: [
      {
        test: /\.css$/, // 根据打包过程中所遇到文件路径匹配是否使用这个 loader
        // 对同一个模块使用多个 loader,注意顺序
        use: ["style-loader", "css-loader"]
      }
    ]
  },
  mode: "development",
  devServer: {
    publicPath: "./dist" // 在后面会说publicPath是什么意思
  }
};

配置完成之后,再次回到命令行重新打包,此时 bundle.js 文件中会额外多出两个模块。style-loader 的作用总结一句话就是,将 css-loader 中所加载到的所有样式模块,通过创建 style 标签的方式添加到页面上。

别忘记重新npm run build!

这样样式就会生效来,可以看到style标签,包含css样式,这样我们就完成来从js文件加载css文件的配置

exclude & include

这里介绍一下loader的两个相关配置 exclude的含义是,所以被正则匹配到的模块都被排除在改规则外,例如

rules: [
  {
    test: /\.css$/, // 根据打包过程中所遇到文件路径匹配是否使用这个 loader
    // 对同一个模块使用多个 loader,注意顺序
    use: ["style-loader", "css-loader"],
    exclude: /node_modules/,
  }
]

node_modules就被排除在外来,exclude一般在配置时是必加的,不然会拖慢整体的打包速度

include代表的则是该规则只对正则匹配到对模块生效。例如我们将include设置为源码src目录,自然也就排除了node_modules。 如果同时存在,exclude优先级更高。

常用loader

babel-loader https://webpack.js.org/loaders/babel-loader
html-loader  https://webpack.js.org/loaders/html-loader
file-loader	 https://webpack.js.org/loaders/file-loader
url-loader	 https://webpack.js.org/loaders/url-loader
style-loader https://webpack.js.org/loaders/style-loader
css-loader	 https://webpack.js.org/loaders/css-loader
sass-loader	 https://webpack.js.org/loaders/sass-loader
postcss-loader	https://webpack.js.org/loaders/postcss-loader
eslint-loader	https://github.com/webpack-contrib/eslint-loader

自定义loader

有时候现有loader不能满足我们的要求,这时候需要我们对其进行修改,现在让我们从头到位实现一个loader。 这里我的需求是开发一个可以加载 markdown 文件的加载器,以便可以在代码中直接导入 md 文件。我们都应该知道 markdown 一般是需要转换为 html 之后再呈现到页面上的,所以我希望导入 md 文件后,直接得到 markdown 转换后的 html 字符串,

每个 Webpack 的 Loader 都需要导出一个函数,这个函数就是我们这个 Loader 对资源的处理过程,它的输入就是加载到的资源文件内容,输出就是我们加工后的结果。我们通过 source 参数接收输入,通过返回值输出。

第一步

引入loader有两种形式,npm安装,直接引入;

这里直接在项目根目录下创建一个 markdown-loader.js 文件,完成后你可以把这个模块发布到 npm 上作为一个独立的模块使用。 了解了 Loader 大致的工作机制过后,我们再回到 markdown-loader.js 中,接着完成我的需求。这里需要安装一个能够将 Markdown 解析为 HTML 的模块,叫作 marked。 然后通过marked去解析我们的markdown语法,解析完的结果就是一段 HTML 字符串。

// ./markdown-loader.js
const marked = require("marked");
module.exports = source => {
  // 1. 将 markdown 转换为 html 字符串
  const html = marked(source);
  // html => '<h1>About</h1><p>this is a markdown file.</p>'
  // 2. 将 html 字符串拼接为一段导出字符串的 JS 代码
  const code = `module.exports = ${JSON.stringify(html)}`;
  return code;
  // code => 'export default "<h1>About</h1><p>this is a markdown file.</p>"'
};

第二步

创建md文件

<!-- ./src/markdown.md -->
# markdown
this is a markdown file.

在index.js文件中引入md文件

import markdown from "./markdown.md";
document.write(markdown);

接下来修改配置,引入loader

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "./bundle.js"
  },
  module: {
    rules: [
      {
        test: /\.css$/, // 根据打包过程中所遇到文件路径匹配是否使用这个 loader
        // 对同一个模块使用多个 loader,注意顺序
        use: ["style-loader", "css-loader"]
      },
      {
        test: /\.md$/,
        // 直接使用相对路径
        use: "./markdown-loader.js"
      }
    ]
  },
  mode: "development",
  devServer: {
    publicPath: "./dist" // 在后面会说publicPath是什么意思
  }
};

第三步

执行打包命令,并打开浏览器预览

npm run build
npm run dev

可以看到正确显示出了markdowm语法:

多个 Loader 的配合

我们还可以尝试一下刚刚说的第二种思路,就是在我们这个 markdown-loader 中直接返回 HTML 字符串,然后交给下一个 Loader 处理。这就涉及多个 Loader 相互配合工作的情况了。

// ./markdown-loader.js
const marked = require('marked')
module.exports = source => {
  // 1. 将 markdown 转换为 html 字符串
  const html = marked(source)
  return html
}

然后我们再安装一个处理 HTML 的 Loader,叫作 html-loader,代码如下所示:

npm install html-loader --save-dev

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "./bundle.js"
  },
  module: {
    rules: [
      {
        test: /\.css$/, // 根据打包过程中所遇到文件路径匹配是否使用这个 loader
        // 对同一个模块使用多个 loader,注意顺序
        use: ["style-loader", "css-loader"]
      },
      {
        test: /\.md$/,
        // 直接使用相对路径
        use: ["html-loader", "./markdown-loader"]
      }
    ]
  },
  mode: "development",
  devServer: {
    publicPath: "./dist" // 在后面会说publicPath是什么意思
  }
};

安装完成再进行打包,编译,你会发现代码仍然能够正常运行。

loader是webpack的核心功能之一,有了loader,webpack才可以去加载你想加载的资源。

插件(plugins)

Loader 是负责完成项目中各种各样资源模块的加载,从而实现整体项目的模块化,而 Plugin 则是用来解决项目中除了资源模块打包以外的其他自动化工作,所以说 Plugin 的能力范围更广,用途自然也就更多。 那我们能用插件做什么?

实现自动在打包之前清除 dist 目录(上次的打包结果);
自动生成应用所需要的 HTML 文件;
根据不同环境为代码注入类似 API 地址这种可能变化的部分;
拷贝不需要参与打包的资源文件到输出目录;
压缩 Webpack 打包完成后输出的文件;

现在让我们走进常用插件

clean-webpack-plugin

你可能已经发现,Webpack 每次打包的结果都是直接覆盖到 dist 目录。而在打包之前,dist 目录中就可能已经存入了一些在上一次打包操作时遗留的文件,当我们再次打包时,只能覆盖掉同名文件,而那些已经移除的资源文件就会一直累积在里面,最终导致部署上线时出现多余文件,这显然非常不合理。

更为合理的做法就是在每次完整打包之前,自动清理 dist 目录,这样每次打包过后,dist 目录中就只会存在那些必要的文件。

安装

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

安装完成之后在webpack.config.js引入文件

const { CleanWebpackPlugin } = require("clean-webpack-plugin");

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "./bundle.js"
  },
  module: {
    rules: [
      {
        test: /\.css$/, // 根据打包过程中所遇到文件路径匹配是否使用这个 loader
        // 对同一个模块使用多个 loader,注意顺序
        use: ["style-loader", "css-loader"]
      },
      {
        test: /\.md$/,
        // 直接使用相对路径
        use: ["html-loader", "./markdown-loader"]
      }
    ]
  },
  plugins: [new CleanWebpackPlugin()],
  mode: "development",
  devServer: {
    publicPath: "./dist" // 在后面会说publicPath是什么意思
  }
};

OK,现在让我们测试一下,重新打包看是否删除dist文件,你可以在bundle加些内容,看打包之后是否清空。

html-webpack-plugin

项目发布时,我们需要同时发布根目录下的 HTML 文件和 dist 目录中所有的打包结果,非常麻烦,而且上线过后还要确保 HTML 代码中的资源文件路径是正确的。 如果打包结果输出的目录或者文件名称发生变化,那 HTML 代码中所对应的 script 标签也需要我们手动修改路径。

解决这两个问题最好的办法就是让 Webpack 在打包的同时,自动生成对应的 HTML 文件,让 HTML 文件也参与到整个项目的构建过程。这样的话,在构建过程中,Webpack 就可以自动将打包的 bundle 文件引入到页面中。

安装

npm install html-webpack-plugin --save-dev

安装完成之后在webpack.config.js引入文件

const HtmlWebpackPlugin = require('html-webpack-plugin')
plugins: [
  new CleanWebpackPlugin(),
  new HtmlWebpackPlugin()
]

最后我们回到命令行终端,再次运行打包命令,此时打包过程中就会自动生成一个 index.html 文件到 dist 目录。我们找到这个文件,可以看到文件中的内容就是一段使用了 bundle.js 的空白 HTML,具体结果如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Webpack App</title>
  <meta name="viewport" content="width=device-width, initial-scale=1"></head>
  <body>
  <script src="./bundle.js"></script></body>
</html>

我们还可以对HtmlWebpackPlugin进行配置:

plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: "Webpack Plugin Sample",
      meta: {
        viewport: "width=device-width"
      }
    })
  ],

自定义 plugins

前面我们简单的实现了一个loader,知道了loader的基本原理:

  • Loader 导出一个函数
  • 经过函数内部对参数源文件的处理返回一个字符串

那么我们如何编写一个plugins呢?

钩子机制

Webpack 的插件机制就是我们在软件开发中最常见的钩子机制 钩子机制也特别容易理解,它有点类似于 Web 中的事件。或者说react,vue中的生命周期。 为了便于插件的扩展,Webpack 几乎在每一个环节都埋下了一个钩子。这样我们在开发插件的时候,通过往这些不同节点上挂载不同的任务,就可以轻松扩展 Webpack 的能力。

具体有哪些预先定义好的钩子,我们可以参考官方文档的 API:

Compiler Hooks

Compilation Hooks

JavascriptParser Hooks

感兴趣的可以去了解一些,现在让我们实现能够自动清除 Webpack 打包结果中的注释的插件。

第一步

在根目录新建remove-comments-plugin.js文件

Webpack 要求我们的插件必须是一个函数或者是一个包含 apply 方法的对象,一般我们都会定义一个类型,在这个类型中定义 apply 方法。然后在使用时,再通过这个类型来创建一个实例对象去使用这个插件。 这里定义一个 RemoveCommentsPlugin 类型,然后在这个类型中定义一个 apply 方法,这个方法会在 Webpack 启动时被调用,它接收一个 compiler 对象参数,这个对象是 Webpack 工作过程中最核心的对象,里面包含了我们此次构建的所有配置信息,我们就是通过这个对象去注册钩子函数,具体代码如下:

// ./remove-comments-plugin.js
class RemoveCommentsPlugin {
  apply (compiler) {
    console.log('RemoveCommentsPlugin 启动')
    // compiler => 包含了我们此次构建的所有配置信息
  }
}

第二步

如何删除打包文件的注释呢?是不是应该先等打包完成之后再去执行这个钩子函数? 所以我们使用一个叫作 emit 的钩子,这个钩子会在 Webpack 即将向输出目录输出文件时执行。 通过 compiler 对象的 hooks 属性访问到 emit 钩子,再通过 tap 方法注册一个钩子函数,这个方法接收两个参数:

  • 第一个是插件的名称,我们这里的插件名称是 RemoveCommentsPlugin;
  • 第二个是要挂载到这个钩子上的函数;

我们先for in 打印看一下每一个文件的名称

// ./remove-comments-plugin.js

class RemoveCommentsPlugin {
  apply(compiler) {
    console.log("MyPlugin 启动");

    compiler.hooks.emit.tap("RemoveCommentsPlugin", compilation => {
      // compilation => 可以理解为此次打包的上下文

      for (const name in compilation.assets) {
        console.log(name); // 输出文件名称
      }
    });
  }
}
module.exports = RemoveCommentsPlugin;

在weback.config.js引入插件

const RemoveCommentsPlugin = require("./remove-comments-plugin");

plugins: [
    new RemoveCommentsPlugin(),
  ],

运行之后可以看到正确执行了我们插件文件内容

第三步

能够拿到文件名和文件内容后,我们回到代码中。这里需要先判断文件名是不是以 .js 结尾,因为 Webpack 打包还有可能输出别的文件,而我们的需求只需要处理 JS 文件。

我们将文件内容得到,再通过正则替换的方式移除掉代码中的注释,最后覆盖掉 compilation.assets 中对应的对象,在覆盖的对象中,我们同样暴露一个 source 方法用来返回新的内容。另外还需要再暴露一个 size 方法,用来返回内容大小,这是 Webpack 内部要求的格式,具体代码如下:

// ./remove-comments-plugin.js
class RemoveCommentsPlugin {
  apply(compiler) {
    compiler.hooks.emit.tap("RemoveCommentsPlugin", compilation => {
      // compilation => 可以理解为此次打包的上下文

      for (const name in compilation.assets) {
        if (name.endsWith(".js")) {
          const contents = compilation.assets[name].source();

          const noComments = contents.replace(/\/\*{2,}\/\s?/g, "");

          compilation.assets[name] = {
            source: () => noComments,

            size: () => noComments.length
          };
        }
      }
    });
  }
}
module.exports = RemoveCommentsPlugin;

运行命令,查看bundle

清除成功,通过自己实现插件我们发现关键点就是使用生命周期钩子中挂载任务函数去完成一个插件点实现。

模式(mode)

通过选择 development, production 或 none 之中的一个,来设置 mode 参数,你可以启用 webpack 内置在相应环境下的优化。其默认值为 production。 传递mode的方式 第一种,在webpack.config.js中设置mode值

module.exports = {
  mode: 'production'
};

或者从 CLI 参数中传递:

webpack --mode=development

如果没有设置,webpack 会给 mode 的默认值设置为 production 我们可以通过mode进行开发环境的配置,来设置不同环境的相关配置。

总结

进阶篇从五个核心配置属性展开,介绍了webpack基本配置。webpack属性不可能全部介绍完,更重要的是对它们对理解,知道其实现的基本原理,举一反三,能够做到心中有数~

本文章的编写参考了

Webpack原理与实践

webapck实战

相关系列

Webpack 基础篇