[转]webpack loader编写

webpack简介

tapable(webpack控制事件流的超级管家)

Tapable的核心功能就是依据不同的钩子将注册的事件在被触发时按序执行。

image.png

BailWaterfallLoop等关键词,指定了注册的事件回调handler触发的顺序。

  • Basic hook:按照事件注册顺序,依次执行handlerhandler之间互不干扰;
  • Bail hook:按照事件注册顺序,依次执行handler,若其中任一handler返回值不为undefined,则剩余的handler均不会执行;
  • Waterfall hook:按照事件注册顺序,依次执行handler,前一个handler的返回值将作为下一个handler的入参;
  • Loop hook:按照事件注册顺序,依次执行handler,若任一handler的返回值不为undefined,则该事件链再次从头开始执行,直到所有handler均返回undefined

基本概念

  • 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 的运行结果。


image.png

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对象中包含了模块资源、编译生成资源以及变化的文件和被跟踪依赖的状态信息等等,以供插件工作时使用。如果我们在插件中需要完成一个自定义的编译过程,那么必然会用到这个对象。


image.png

编写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:{...})

编写插件

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;

常用 API(全部API)

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

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

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

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

class MyPlugin {
  apply(compiler) {
    compiler.plugin('emit', function(compilation, callback) {
      // compilation.chunks 是存放了所有的代码块,是一个数组,我们需要遍历
      compilation.chunks.forEach(function(chunk) {
        /*
         * chunk 代表一个代码块,代码块它是由多个模块组成的。
         * 我们可以通过 chunk.forEachModule 能读取组成代码块的每个模块
        */
        chunk.forEachModule(function(module) {
          // module 代表一个模块。
          // module.fileDependencies 存放当前模块的所有依赖的文件路径,它是一个数组
          module.fileDependencies.forEach(function(filepath) {
            console.log(filepath);
          });
        });
        /*
         webpack 会根据chunk去生成输出的文件资源,每个chunk都对应一个及以上的输出文件。
        */
        chunk.files.forEach(function(filename) {
          // compilation.assets 是存放当前所有即将输出的资源。
          // 调用一个输出资源的 source() 方法能获取到输出资源的内容
          const source = compilation.assets[filename].source();
        });
      });
      /*
       该事件是异步事件,因此要调用 callback 来通知本次的 webpack事件监听结束。
       如果我们没有调用callback(); 那么webpack就会一直卡在这里不会往后执行。
      */
      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 会从配置的入口模块出发,依次找出所有的依赖模块,当入口模块或者其依赖的模块发生变化时, 就会触发一次新的 Compilation。

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

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

// 当依赖的文件发生改变的时候 会触发 watch-run 事件
class MyPlugin {
  apply(compiler) {
    compiler.plugin('watch-run', (watching, callback) => {
      // 获取发生变换的文件列表
      const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes;
      // changedFiles 格式为键值对的形式,当键为发生变化的文件路径
      if (changedFiles[filePath] !== undefined) {
        // 对应的文件就发生了变化了
      }
      callback();
    });

    /*
     默认情况下Webpack只会监听入口文件或其依赖的模块是否发生变化,但是在有些情况下比如html文件发生改变的时候,那么webpack
     就会去监听html文件的变化。因此就不会重新触发新的 Compilation。因此为了监听html文件的变化,我们需要把html文件加入到
     依赖列表中。因此我们需要添加如下代码:
    */
    compiler.plugin('after-compile', (compilation, callback) => {
      /*
       如下的参数filePath是html文件路径,我们把HTML文件添加到文件依赖表中,然后我们的webpack会去监听html模块文件,
       html模板文件发生改变的时候,会重新启动下重新编译一个新的 Compilation.
      */
      compilation.fileDependencies.push(filePath);
      callback();
    })
  }
}

写在最后

参考文章

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

(https://cloud.tencent.com/dev...)

  • 编写自定义webpack插件从理解Tapable开始
  • Webpack源码解读:理清编译主流程

推荐阅读

  • Compiler Source
  • Compilation Source

你可能感兴趣的:([转]webpack loader编写)