编写webpack-loader和插件

6次阅读

共计 8255 个字符,预计需要花费 21 分钟才能阅读完成。

webpack 简介

基本概念

  • Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
  • Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
  • Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
  • Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
  • Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情

工作流程

Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
  3. 确定入口:根据配置中的 entry 找出所有的入口文件;
  4. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  5. 完成模块编译:在经过第 4 步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

编写 loader

职责:一个 Loader 的职责是单一的,只需要完成一种转换。

初始化

module.exports = function(source) {  
    // source 为 compiler 传递给 Loader 的一个文件的原内容  
    // 对 source 进行一些操作 之后返回给下一个 loader
    return source;
};
  • 获得 Loader 的 options

    const loaderUtils = require('loaderutils');
    module.exports = function(source) {  
        // 获取到用户给当前 Loader 传入的 options 
        const options = loaderUtils.getOptions(this);
        // 根据不同的 options 进行不同的操作
        return source;
    };

返回其它结果

例如以用 babel-loader 转换 ES6 代码为例,它还需要输出转换后的 ES5 代码对应的 Source Map,以方便调试源码。为了把 Source Map 也一起随着 ES5 代码返回给 Webpack

module.exports = function(source) {this.callback(null, source, sourceMaps); 
    // 通过 this.callback 告诉 Webpack 返回的结果
    // 当使用 this.callback 返回内容时,该 Loader 必须返回 undefined 以让 Webpack 知道该 Loader 返回的结果 this.callback 中,而不是 return 中   
    return;
};

其中的 this.callback 是 Webpack 给 Loader 注入的 API,以方便 Loader 和 Webpack 之间通信。this.callback 的详细使用方法如下:

this.callback(    
    // 当无法转换原内容时,给 Webpack 返回一个 Error   
    err: Error | null,    
    // 原内容转换后的内容    
    content: string | Buffer,    
    // 用于把转换后的内容得出原内容的 Source Map,方便调试    sourceMap?: SourceMap,    
    // 如果本次转换为原内容生成了 AST 语法树,可以把这个 AST 返回, 以方便之后需要 AST 的 Loader 复用该 AST,以避免重复生成 AST,提升性能    
    abstractSyntaxTree?: AST
);

同步与异步

但在有些场景下转换的步骤只能是异步完成的,例如你需要通过网络请求才能得出结果,如果采用同步的方式网络请求就会阻塞整个构建,导致构建非常缓慢。

module.exports = function(source) {    
    // 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果    
    var callback = this.async();    
    someAsyncOperation(
    source, 
    function(err, result, sourceMaps, ast) {  
    // 通过 callback 返回异步执行后的结果
    callback(err, result, sourceMaps, ast);   
    });
};

处理二进制数据

在默认的情况下,Webpack 传给 Loader 的原内容都是 UTF-8 格式编码的字符串。但有些场景下 Loader 不是处理文本文件,而是处理二进制文件,例如 file-loader,就需要 Webpack 给 Loader 传入二进制格式的数据。

    module.exports = function(source) {    
    // 在 exports.raw === true 时,Webpack 传给 Loader 的 source 是 Buffer 类型的    
    source instanceof Buffer === true;    
    // Loader 返回的类型也可以是 Buffer 类型的    
    // 在 exports.raw !== true 时,Loader 也可以返回 Buffer 类型的结果    
    return source;
    };
    // 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要二进制数据 
    module.exports.raw = true;

其它 Loader API(Loader API 地址)

  • this.context:当前处理文件的所在目录,假如当前 Loader 处理的文件是 /src/main.js,则 this.context 就等于 /src
  • this.resource:当前处理文件的完整请求路径,包括 querystring,例如 /src/main.js?name=1
  • this.resourcePath:当前处理文件的路径,例如 /src/main.js
  • this.resourceQuery:当前处理文件的 querystring
  • this.target:等于 Webpack 配置中的 Target。
  • this.loadModule:但 Loader 在处理一个文件时,如果依赖其它文件的处理结果才能得出当前文件的结果时,就可以通过 this.loadModule(request:string,callback:function(err,source,sourceMap,module)) 去获得 request 对应文件的处理结果。
  • this.resolve:像 require 语句一样获得指定文件的完整路径,使用方法为 resolve(context:string,request:string,callback:function(err,result:string))
  • this.addDependency:给当前处理文件添加其依赖的文件,以便再其依赖的文件发生变化时,会重新调用 Loader 处理该文件。使用方法为 addDependency(file:string)
  • this.addContextDependency:和 addDependency 类似,但 addContextDependency 是把整个目录加入到当前正在处理文件的依赖中。使用方法为 addContextDependency(directory:string)
  • this.clearDependencies:清除当前正在处理文件的所有依赖,使用方法为 clearDependencies()
  • this.emitFile:输出一个文件,使用方法为 emitFile(name:string,content:Buffer|string,sourceMap:{...})

加载本地 Loader

Npmlink

Npm link 专门用于开发和调试本地 Npm 模块,能做到在不发布模块的情况下,把本地的一个正在开发的模块的源码链接到项目的 node_modules 目录下,让项目可以直接使用本地的 Npm 模块。由于是通过软链接的方式实现的,编辑了本地的 Npm 模块代码,在项目中也能使用到编辑后的代码。

完成 Npm link 的步骤如下:

  • 确保正在开发的本地 Npm 模块(也就是正在开发的 Loader)的 package.json 已经正确配置好;
  • 在本地 Npm 模块根目录下执行 npm link,把本地模块注册到全局;
  • 在项目根目录下执行 npm link loader-name,把第 2 步注册到全局的本地 Npm 模块链接到项目的 node_moduels 下,其中的 loader-name 是指在第 1 步中的 package.json 文件中配置的模块名称。

链接好 Loader 到项目后你就可以像使用一个真正的 Npm 模块一样使用本地的 Loader 了。

ResolveLoader

ResolveLoader 用于配置 Webpack 如何寻找 Loader。默认情况下只会去 node_modules 目录下寻找,为了让 Webpack 加载放在本地项目中的 Loader 需要修改 resolveLoader.modules

假如本地的 Loader 在项目目录中的 ./loaders/loader-name 中,则需要如下配置:

module.exports = {  
    resolveLoader:{    
    // 去哪些目录下寻找 Loader,有先后顺序之分   
    modules: ['node\_modules','./loaders/'\],  }
}

加上以上配置后,Webpack 会先去 node_modules 项目下寻找 Loader,如果找不到,会再去 ./loaders/ 目录下寻找。

编写插件

Webpack 插件组成

在自定义插件之前,我们需要了解,一个 Webpack 插件由哪些构成,下面摘抄文档:

  • 一个具名 JavaScript 函数;
  • 在它的原型上定义 apply 方法;
  • 指定一个触及到 Webpack 本身的事件钩子;
  • 操作 Webpack 内部的实例特定数据;
  • 在实现功能后调用 Webpack 提供的 callback。

Webpack 插件基本架构

插件由一个构造函数实例化出来。构造函数定义 apply 方法,在安装插件时,apply 方法会被 Webpack compiler调用一次。apply 方法可以接收一个 Webpack compiler对象的引用,从而可以在回调函数中访问到 compiler 对象。

官方文档提供一个简单的插件结构:

class HelloWorldPlugin {apply(compiler) {
    compiler.hooks.done.tap('Hello World Plugin', (stats /* 在 hook 被触及时,会将 stats 作为参数传入。*/) => {console.log('Hello World!');
    });
  }
}
module.exports = HelloWorldPlugin;

使用插件:

// webpack.config.js
var HelloWorldPlugin = require('hello-world');

module.exports = {
  // ... 这里是其他配置 ...
  plugins: [new HelloWorldPlugin({ options: true})]
};

插件触发时机

Webpack 提供钩子有很多,完整具体可参考文档《Compiler Hooks》

  • entryOption : 在 webpack 选项中的 entry 配置项 处理过之后,执行插件。
  • afterPlugins : 设置完初始插件之后,执行插件。
  • compilation : 编译创建之后,生成文件之前,执行插件。。
  • emit : 生成资源到 output 目录之前。
  • done : 编译完成。

compiler.hooks 下指定 事件钩子函数,便会触发钩子时,执行回调函数。
Webpack 提供三种触发钩子的方法:

  • tap:以 同步方式 触发钩子;
  • tapAsync:以 异步方式 触发钩子;
  • tapPromise:以 异步方式 触发钩子,返回 Promise;

compiler 和 compilation 介绍

Compiler 和 Compilation 的区别在于:Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。

compiler

webpack 的 compiler 模块是其核心部分。其包含了 webpack 配置文件传递的所有选项,包含了诸如 loader、plugins 等信息。

我们可以看看 Compiler 类中定义的一些核心方法。

// 继承自 Tapable 类,使得自身拥有发布订阅的能力
class Compiler extends Tapable {// 构造函数,context 实际传入值为 process.cwd(),代表当前的工作目录
  constructor(context) {super();
    // 定义了一系列的事件钩子,分别在不同的时刻触发
    this.hooks = {shouldEmit: new SyncBailHook(["compilation"]),
      done: new AsyncSeriesHook(["stats"]),
      //.... 更多钩子
    };
    this.running = true;
    // 其他一些变量声明
  }

  // 调用该方法之后会监听文件变更,一旦变更则重新执行编译
  watch(watchOptions, handler) {
    this.running = true;
    return new Watching(this, watchOptions, handler)
  }
  
  // 用于触发编译时所有的工作
  run(callback) {
    // 编译之后的处理,省略了部分代码
    const onCompiled = (err, compilation) => {this.emitAssets(compilation, err => {...})
    }
  }

  // 负责将编译输出的文件写入本地
  emitAssets(compilation, callback) {}

  // 创建一个 compilation 对象,并将 compiler 自身作为参数传递
  createCompilation() {return new Compilation(this);
  }

  // 触发编译,在内部创建 compilation 实例并执行相应操作
  compile() {}


  // 以上核心方法中很多会通过 this.hooks.someHooks.call 来触发指定的事件
  
}

可以看到,compiler 中设置了一系列的事件钩子和各种配置参数,并定义了 webpack 诸如启动编译、观测文件变动、将编译结果文件写入本地等一系列核心方法。在 plugin 执行的相应工作中我们肯定会需要通过 compiler 拿到 webpack 的各种信息。

compilation

如果把 compiler 算作是总控制台,那么 compilation 则专注于编译处理这件事上。

在启用 Watch 模式后,webpack 将会监听文件是否发生变化,每当检测到文件发生变化,将会执行一次新的编译,并同时生成新的编译资源和新的 compilation 对象。
compilation 对象中包含了模块资源、编译生成资源以及变化的文件和被跟踪依赖的状态信息等等,以供插件工作时使用。如果我们在插件中需要完成一个自定义的编译过程,那么必然会用到这个对象。

常用 API(全部 API)

插件可以用来修改输出文件、增加输出文件、甚至可以提升 Webpack 性能、等等,总之插件通过调用 Webpack 提供的 API 能完成很多事情。

读取输出资源、代码块、模块及其依赖

有些插件可能需要读取 Webpack 的处理结果,例如输出资源、代码块、模块及其依赖,以便做下一步处理。

emit 事件发生时,代表源文件的转换和组装已经完成,在这里可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。

监听文件变化

Webpack 会从配置的入口模块出发,依次找出所有的依赖模块,当入口模块或者其依赖的模块发生变化时,就会触发一次新的 Compilation。

在开发插件时经常需要知道是哪个文件发生变化导致了新的 Compilation

默认情况下 Webpack 只会监视入口和其依赖的模块是否发生变化,在有些情况下项目可能需要引入新的文件,例如引入一个 HTML 文件。由于 JavaScript 文件不会去导入 HTML 文件,Webpack 就不会监听 HTML 文件的变化,编辑 HTML 文件时就不会重新触发新的 Compilation。为了监听 HTML 文件的变化,我们需要把 HTML 文件加入到依赖列表中,为此可以使用如下代码:

compiler.plugin('after-compile', 
    (compilation, callback) => {  
    // 把 HTML 文件添加到文件依赖列表,好让 Webpack 去监听 HTML 模块文件,在 HTML 模版文件发生变化时重新启动一次编译 
    compilation.fileDependencies.push(filePath);   
    callback();}
);

修改输出资源

有些场景下插件需要修改、增加、删除输出的资源,要做到这点需要监听 emit 事件,因为发生 emit 事件时所有模块的转换和代码块对应的文件已经生成好,需要输出的资源即将输出,因此 emit 事件是修改 Webpack 输出资源的最后时机。

所有需要输出的资源会存放在 compilation.assets 中,compilation.assets 是一个键值对,键为需要输出的文件名称,值为文件对应的内容。

设置 compilation.assets 的代码如下:

compiler.plugin('emit',
(compilation, callback) => {  
    // 设置名称为 fileName 的输出资源  
    compilation.assets[fileName] = {    
        // 返回文件内容    
        source: () => {      
            // fileContent 既可以是代表文本文件的字符串,也可以是代表二进制文件的 Buffer      
            return fileContent;      
        },    
        // 返回文件大小      
        size: () => {return Buffer.byteLength(fileContent, 'utf8');    
        }  
    };  
    callback();}
);

读取 compilation.assets 的代码如下:

compiler.plugin('emit', 
(compilation, callback) => {  
    // 读取名称为 fileName 的输出资源  
    const asset = compilation.assets[fileName];  
    // 获取输出资源的内容 
    asset.source();  
    // 获取输出资源的文件大小 
    asset.size(); 
    callback();});

判断 Webpack 使用了哪些插件

在开发一个插件时可能需要根据当前配置是否使用了其它某个插件而做下一步决定,因此需要读取 Webpack 当前的插件配置情况。以判断当前是否使用了 ExtractTextPlugin 为例,可以使用如下代码:

// 判断当前配置使用了 ExtractTextPlugin,compiler 参数即为 Webpack 在 apply(compiler) 中传入的参数
function hasExtractTextPlugin(compiler) {  
// 当前配置所有使用的插件列表  
const plugins = compiler.options.plugins;  
// 去 plugins 中寻找有没有 ExtractTextPlugin 的实例  
return plugins.find(plugin=>plugin.\_\_proto\_\_.constructor === ExtractTextPlugin) != null;}

写在最后

参考文章

  • webpack 工作流程分析
  • webpack 贡献 - 编写 loader
  • webpack 贡献 - 编写 plugin
  • webpack 学习笔记

推荐阅读

  • Compiler Source
  • Compilation Source
正文完
 0