webpack 之 loader 和 plugin

512 阅读4分钟

工作原理概括

Webpack 启动后会从 Entry 里配置的 Module 开始递归解析 Entry 依赖的所有 Module。 每找到一个 Module, 就会根据配置的 Loader 去找出对应的转换规则,对 Module 进行转换后,再解析出当前 Module 依赖的 Module。 这些模块会以 Entry 为单位进行分组,一个 Entry 和其所有依赖的 Module 被分到一个组也就是一个 Chunk。最后 Webpack 会把所有 Chunk 转换成文件输出。 在整个流程中 Webpack 会在恰当的时机执行 Plugin 里定义的逻辑。

Webpack 常见名词解释

参数说明
entry项目入口
module开发中每一个文件都可以看做 module,模块不局限于 js,也包含 css、图片等
chunk代码块,一个 chunk 可以由多个模块组成
loader模块转化器,模块的处理器,对模块进行转换处理
plugin扩展插件,插件可以处理 chunk,也可以对最后的打包结果进行处理,可以完成 loader 完不成的任务
bundle最终打包完成的文件,一般就是和 chunk 一一对应的关系,bundle 就是对 chunk 进行便意压缩打包等处理后的产出

基本流程

Webpack 的基本流程可以分为三个阶段:

  1. 准备阶段:主要任务是创建 Compiler 和 Compilation 对象;
  2. 编译阶段:这个阶段任务是完成 modules 解析,并且生成 chunks;
  • module 解析:包含了三个主要步骤,创建实例、loaders 应用和依赖收集;
  • chunks 生成,主要步骤是找到每个 chunk 所需要包含的 modules。
  1. 产出阶段:这个阶段的主要任务是根据 chunks 生成最终文件,主要有三个步骤:模板 Hash 更新,模板渲染 chunk,生成文件。 如果只执行一次构建,以上阶段将会按照顺序各执行一次。但在开启监听模式下,流程将变为如下:

image
在每个大阶段中又会发生很多事件,Webpack 会把这些事件广播出来供给 Plugin 使用。

一个比较形象的比喻:

Webpack 可以看做是一个工厂车间,plugin和loader是车间中的两类机器,工厂有一个车间主任和一个生产车间。车间主任叫Compiler,负责指挥生产车间机器Compilation进行生产劳动,Compilation会首先将进来的原材料(entry)使用一种叫做loader的机器进行加工,生产出来的产品就是Chunk;Chunk生产出来之后,会被组装成Bundle,然后通过一类plugin的机器继续加工,得到最后的Bundle,然后运输到对应的仓库(output)。这个工厂的生产线就是 Tapable,厂子运作的整个流程都是生产线控制的,车间中有好几条生产线,每个生产线有很多的操作步骤(hook),一步操作完毕,会进入到下一步操作,直到生产线全流程完成,再将产出传给下一个产品线处理。整个车间生产线也组成了一条最大的生产线。

编写loader

根据上面的工作流程描述,我们知道在 Webpack 中,真正起编译作用的便是我们的 loader,loader实际就是处理单个模块的解析器(加载器不如解析器更好理解),平时我们进行 babel 的 ES6 编译,SCSS、LESS 等编译都是在 loader 里面完成的。

  module.exports = {
  module: {
    rules: [
      {
        // 增加对 SCSS 文件的支持
        test: /\.scss$/,
        // SCSS 文件的处理顺序为先 sass-loader 再 css-loader 再 style-loader
        use: [
          'style-loader',
          {
            loader:'css-loader',
            // 给 css-loader 传入配置项
            options:{
              minimize:true, 
            }
          },
          'sass-loader'],
      },
    ]
  },
};

由上面的例子可以看出:一个 Loader 的职责是单一的,只需要完成一种转换。 如果一个源文件需要经历多步转换才能正常使用,就通过多个 Loader 去转换。由此可以得出loader 开发的基本原则:

  1. 单一原则: 每个Loader只做一件事,简单易用,便于维护;
  2. 链式调用: Webpack 会按顺序链式调用每个Loader;
  3. 统一原则: 遵循Webpack制定的设计规则和结构,输入与输出均为字符串,各个Loader完全独立,即插即用;
  4. 无状态原则:在转换不同模块时,不应该在loader中保留状态;

loader 本质上是一个函数,通过接受处理的内容,然后处理后返回结果。loader 的函数一般写法是:

module.exports = function(content, sourcemap) {
    // 处理 content 操作...
    return content;
};

在上面的示例中,我们看到 loader 实际是一个funtion,所以我们使用了 return 的方式返回 loader 处理后的数据。但其实这并不是我们最推荐的写法,在大多数情况下,我们还是更希望使用 this.callback 方法去返回数据。如果改成这种写法,示例代码可以改写为:

module.exports = function(content) {
    // 处理 content 操作...
    this.callback(err, content); // err 为错误对象, content 为经过翻译后返回的字符串
};

tips: 一定要注意,编写 loader 的时候,如果要使用this.callback或者后面提到的loader-utils的getOptions等方法,this是 webpack 调用 loader 时候传入的自定义的特殊上下文, 所以这时候不应该使用箭头函数!

异步loader
在上面的示例中,不论是使用 return 还是 this.callback,本质上都是同步的执行过程,假如我们的 loader 里存在异步操作,比如拉取异步的请求等又该怎么办呢?在 loader 中提供了两种异步写法。

第一种是使用async/await异步函数写法:

module.exports = async function(content) {
    function timeout(delay) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                // 模拟一些异步操作处理 content
                resolve(content);
            }, delay);
        });
    }
    const data = await timeout(1000);
    return data;
};

还有一种方式是使用this.async方法获取一个异步的callback,然后返回它,上面的示例代码使用this.async修改如下:

module.exports = function(content) {
    function timeout(delay) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                // 模拟一些异步操作处理 content
                resolve(content);
            }, delay);
        });
    }
    const callback = this.async();
    timeout(1000).then(data => {
        callback(null, data);
    });
};

tips: this.async获取的 callback,参数也是跟this.callback的参数一致,
例子1:

//loader/style-loader.js
function loader(source, map) {
  let style = `
    let style = document.createElement('style');
    style.innerHTML = ${JSON.stringify(source)};
    document.head.appendChild(style)
  `;
  return style;
}
module.exports = loader;

引入本地loader

module.exports = {
  module: {
    rules: [{
      test: /\.css/,
      use: [
        {
          loader: './loader/style-loader.js',
        },
      ],
    }]
  }
}

例子2:
现在我们来手动写个 markdown-loader。Markdown-loader 是将 markdown 语法的文件转换成 HTML,这里使用的是showdown来转换 markdown 到 HTML。为了方便编写 loader,Webpack 官方将编写 loader 中常用的工具函数打包成了loader-utils和schema-utils模块,这里面包括了常用的获取 loader 选项(options)和参数验证等方法。

markdown-loader.js

// 引入依赖
const showdown = require('showdown');
const loaderUtils = require('loader-utils');

module.exports = function(content) {
    // getOptions 用于在loader里获取传入的options,返回的是对象值。
    const options = loaderUtils.getOptions(this);
    // 设置 cache
    this.cacheable();
    // 初始化 showdown 转换器
    const converter = new showdown.Converter(options);
    console.log('options', options);
    // 处理 content
    content = converter.makeHtml(content);
    // 返回结果
    this.callback(null, content);
};

markdown.md

# 测试 markdown

测试自动options 生效,自动检测网址添加 link:www.google.com

## table

| h1    |   h2    |      h3 |
| ---- | ----- | ------ |
| 100   | [a][1]  | ![b][2] |
| *foo* | **bar** | ~~baz~~ |

index.js

import html from './markdown.md';
console.log(html);

webpack.config.js

      {
        test: /\.md$/,
        use: [
          'html-loader',
            {
                loader: "./loader/markdown-loader.js",
                options: {
                    simplifiedAutoLink: true,
                    tables: true
                }
            }
        ]
    }

结果:
image

常用loader
file-loader:打包图片,打包字体图标.
url-loader 功能类似于 file-loader,但是在文件大小(单位 byte)低于指定的限制时,可以返回一个 DataURL(提升网页性能)
css-loader:和图片一样webpack默认能不能处理CSS文件, 所以也需要借助loader将CSS文件转换为webpack能够处理的类型。解析css文件中的@import依赖关系,打包时会将依赖的代码复制过来代替@import。
style-loader: 将css文件通过css-loader处理之后,将处理之后的内容插入到HTML的HEAD代码中。
scss-loader:自动将scss转换为CSS
less-loader:自动将less转换为CSS
PostCSS-loader:PostCSS和sass/less不同, 它不是CSS预处理器(换个格式编写css)。PostCSS是一款使用插件去转换CSS的工具,PostCSS有许多非常好用的插件。例如:autoprefixer(自动补全浏览器前缀)、postcss-pxtorem(自动把px代为转换成rem)。使用说明,必须放在css规则的最后,最先执行。 eslint-loader:用于检查常见的 JavaScript 代码错误,也可以进行"代码规范"检查,在企业开发中项目负责人会定制一套 ESLint 规则,然后应用到所编写的项目上,从而实现辅助编码规范的执行,有效控制项目代码的质量。在编译打包时如果语法有错或者有不符合规范的语法就会报错, 并且会提示相关错误信息

编写plugin

将上述提到的打包流程细化,大概可以分为:

  1. 初始化参数:包括从配置文件和 shell 中读取和合并参数,然后得出最终参数;shell 中的参数要优于配置文件的;
  2. 一步得到的参数实例化一个 Compiler 类,注册所有的插件,给对应的 Webpack 构建生命周期绑定 Hook;
  3. 开始编译:执行 Compiler 类的 run 方法开始执行编译;
    compiler.run 方法调用 compiler.compile,在compile 内实例化一个Compilation类,Compilation是做构建打包的事情,主要事情包括:

1)查找入口:根据 entry 配置,找出全部的入口文件; 2)编译模块:根据文件类型和 loader 配置,使用对应 loader 对文件进行转换处理; 3)解析文件的 AST 语法树; 4)找出文件依赖关系; 5)递归编译依赖的模块。

递归完后得到每个文件的最终结果,根据 entry 配置生成代码块 chunk; 输出所有 chunk 到对应的output路径。
在 Webpack 工作流程里,Tapable始终贯穿其中,Tapable 各种 Hook(钩子)组成了 Webpack 的生命周期。Tapable Hook 和生命周期的关系为:
hook: 钩子,对应 Tapable hook; 生命周期: Webpack 的执行流程,钩子实际就是生命周期,一般类似 entryOption 的 Hook,在生命周期中entry-option。

Webpack 的plugin是 Webpack 的核心概念,可以说整个 Webpack 都是由插件组成的。这个是在整个工作流程的后半部分,Webpack 将整个模块的依赖关系都处理完毕,最终生成 bundle 的时候,然后扔给内置的插件和用户配置的插件依次处理。与loader只操作单个模块不同,plugin关注得是打包后的 bundle 整体,即所有模块组成的 bundle。所以跟产出相关的都是需要插件来实现的,比如压缩、拆分公共代码。

webpack插件需要包含的条件:

  1. Webapck 的插件必须要是一个类;
  2. 该类必须包含一个apply的函数,该函数接收compiler对象参数;
  3. 该类可以使用 Webpack 的 compiler 和 Compilation 对象的钩子;

compiler 与compilation

Compiler 模块是 Webpack 最核心的模块。每次执行 Webpack 构建的时候,在 Webpack 内部,会首先实例化一个 Compiler 对象,然后调用它的 run 方法来开始一次完整的编译过程。我们直接使用 Webpack API webpack(options)的方式得到的就是一个Compiler实例化的对象,这时候 Webpack 并不会立即开始构建,需要我们手动执行comipler.run()才可以。

const webpack = require('webpack');
const webpackConfig = require('./webpack.config.js');

// 只传入 config
const compiler = webpack(webpackConfig);
// 开始执行
compiler.run(callback);

在 webpack plugin 中,每个插件都有个apply方法。这个方法接收到的参数就是Compiler对象,我们可以通过在对应的钩子时机绑定处理函数来编写插件.compiler钩子传送门

在 Compilation 阶段,模块会被加载(loaded)、封存(sealed)、优化(optimized)、分块(chunked)、哈希(hashed)和重新创建(restored),Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以监听(watch)模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。 compilation钩子传送门

一个最简单的plugin
MyPlugin.js:

class MyPlugin {
 constructor(options) {
    console.log("Plugin Created by Scarlett");
    console.log(options);
    this.options = options;
  }
  apply (compiler) {}
}
module.exports = MyPlugin;

webpack.config.js

const MyPlugin = require('./plugins/MyPlugin')
module.exports = {
  plugins: [
    new MyPlugin({title:'MyPlugin'})
  ],
}

结果:
image

tips: webpack 的插件实际是上一个包含apply方法的类。

如果我们想在指定 compiler 钩子时机执行某些脚本,自然可以在对应的事件钩子上添加回调方法,在回调里执行你所需的操作。由于 webpack 的钩子都是来自于Tapable类,所以一些特殊类型的钩子需要特殊的tap方法,例如 compiler 的emit 钩子是支持tap、tapPromise和tapAsync多种类型的 tap 方式,但是不管哪种方式的 tap,都需要按照Tapable的规范来返回对应的值,下面的例子是使用了emit.tapPromise,则需要返回一个Promise对象。
一个异步的例子:

class HelloWorldPlugin {
    apply(compiler) {
        compiler.hooks.emit.tapPromise('HelloAsyncPlugin', compilation => {
            // 返回一个 Promise,在我们的异步任务完成时 resolve……
            return new Promise((resolve, reject) => {
                setTimeout(function() {
                    console.log('异步工作完成……');
                    resolve();
                }, 1000);
            });
        });
    }
}

module.exports = HelloWorldPlugin;

官方插件 FileListPlugin

class FileListPlugin {
    apply(compiler) {
        // emit 是异步 hook,使用 tapAsync 触及它,还可以使用 tapPromise/tap(同步)
        compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
            // 在生成文件中,创建一个头部字符串:
            var filelist = 'In this build:\n\n';

            // 遍历所有编译过的资源文件,
            // 对于每个文件名称,都添加一行内容。
            for (var filename in compilation.assets) {
                filelist += '- ' + filename + '\n';
            }

            // 将这个列表作为一个新的文件资源,插入到 webpack 构建中:
            compilation.assets['filelist.md'] = {
                source: function() {
                    return filelist;
                },
                size: function() {
                    return filelist.length;
                }
            };

            callback();
        });
    }
}

module.exports = FileListPlugin;

image

文件上传七牛CDN Plugin

const qiniu = require('qiniu');
const path = require('path');

class MyWebpackPlugin {
    // 七牛SDK mac对象

    constructor(options) {
          // 读取传入选项
        this.options = options || {};
          // 检查选项中的参数
        this.checkQiniuConfig();
          // 初始化七牛mac对象
        this.mac = new qiniu.auth.digest.Mac(
            this.options.qiniu.accessKey,
            this.options.qiniu.secretKey
        );
    }
    checkQiniuConfig() {
        // 配置未传qiniu,读取环境变量中的配置
        if (!this.options.qiniu) {
            this.options.qiniu = {
                accessKey: process.env.QINIU_ACCESS_KEY,
                secretKey: process.env.QINIU_SECRET_KEY,
                bucket: process.env.QINIU_BUCKET,
                keyPrefix: process.env.QINIU_KEY_PREFIX || ''
            };
        }
        const qiniu = this.options.qiniu;
        if (!qiniu.accessKey || !qiniu.secretKey || !qiniu.bucket) {
            throw new Error('invalid qiniu config');
        }
    }

    apply(compiler) {
        compiler.hooks.afterEmit.tapPromise('MyWebpackPlugin', (compilation) => { // afterEmit: 资源输出到目录完成
            return new Promise((resolve, reject) => {
                // 总上传数量
                const uploadCount = Object.keys(compilation.assets).length;
                // 已上传数量
                let currentUploadedCount = 0;
                                // 七牛SDK相关参数
                const putPolicy = new qiniu.rs.PutPolicy({ scope: this.options.qiniu.bucket });
                const uploadToken = putPolicy.uploadToken(this.mac);
                const config = new qiniu.conf.Config();
                config.zone = qiniu.zone.Zone_z1;
                const formUploader = new qiniu.form_up.FormUploader()
                const putExtra = new qiniu.form_up.PutExtra();
                                // 因为是批量上传,需要在最后将错误对象回调
                let globalError = null;

                  // 遍历编译资源文件
                for (const filename of Object.keys(compilation.assets)) {
                    // 开始上传
                    formUploader.putFile(
                        uploadToken,
                        this.options.qiniu.keyPrefix + filename,
                        path.resolve(compilation.outputOptions.path, filename),
                        putExtra,
                        (err) => {
                            console.log(`uploade ${filename} result: ${err ? `Error:${err.message}` : 'Success'}`)
                            currentUploadedCount++;
                            if (err) {
                                globalError = err;
                            }
                            if (currentUploadedCount === uploadCount) {
                                globalError ? reject(globalError) : resolve();
                            }
                        });
                }
            })
        });
    }
}

module.exports = MyWebpackPlugin;
// webpack.config.js
plugins: [
      
        new QiniuWebpackPlugin({
            qiniu: {
                accessKey: '七牛AccessKey',
                secretKey: '七牛SecretKey',
                bucket: 'static',
                keyPrefix: 'webpack-inaction/demo1/'
            }
        })
    ]

结果:
image

常用plugin

webpack插件,采用不同的plugin完成各类不同的性需求,热更新,css去重之类的问题

1.ProvidePlugin:自动加载模块,代替require和import 2.html-webpack-plugin可以根据模板自动生成html代码,并自动引用css和js文件 3.extract-text-webpack-plugin 将js文件中引用的样式单独抽离成css文件 4.DefinePlugin 编译时配置全局变量,这对开发模式和发布模式的构建允许不同的行为非常有用。 5.HotModuleReplacementPlugin 热更新 6.optimize-css-assets-webpack-plugin 不同组件中重复的css可以快速去重 7.webpack-bundle-analyzer 一个webpack的bundle文件分析工具,将bundle文件以可交互缩放的treemap的形式展示。 8.compression-webpack-plugin 生产环境可采用gzip压缩JS和CSS 9.happypack:通过多进程模型,来加速代码构建 10.clean-webpack-plugin 清理每次打包下没有使用的文件