浅谈Webpack的AMD插件开发和运行机制

2,565 阅读8分钟
参考:
  1. 细说 webpack 之流程篇
  2. webpack之plugin内部运行机制
  3. How webpack works
  4. github.com/webpack/tap…
  5. 玩转webpack(一)上篇:webpack的基本架构和构建流程

怎么写webpack插件

  在写webpack的插件时,内部必须要实现一个apply方法,传入compiler参数,就可以通过调用plugin方法,在对应的事件钩子订阅事件,然后取出源码进行自定义开发后,return回去即可。

  这是因为webpack的内部机制是通过tapable来实现的,通过plugin方法订阅事件,我们可以根据不同事件钩子类型,执行对应的同步代码,或者触发对应的异步回调--串行、并行、瀑布流(上一个操作的输出是下一个操作的输入,有点类似pipeLine),在异步钩子的回调函数里,要返回callback参数。

简单的同步插件示例如下:

class MyPlugin {
	apply(compiler) {
		compiler.plugin(“compilation”, compilation => {
			compilation.plugin(“optimize-modules”, modules => {
				modules.forEach(…);
			}
		}
	}
}

Webpack运行机制

  我们可以看到,遵循对应的同步、异步规则编写简单的webpack插件其实是不难的,但是最难的怎么找到合适的事件钩子执行对应事件,也就是怎么去理解webpack运行机制,找到合适突破口。

  理解webpack运行机制有几个概念是需要注意一下:

  1. compiler-编译器对象

      compiler对象代表了完整的webpack环境配置。该对象在启动webpack时就被一次性创建,由webpack组合所有的配置项(包括原始配置,加载器和插件)构建生成。

      当在webpack环境中应用一个插件时,插件会收到compiler的引用,通过使用compiler,插件就可以访问到整个webpack的环境(包括原始配置,加载器和插件)。

  2. compilation-构建过程

      compilation对象在compiler的compile方法里创建,它代表了一次单一的版本构建以及构建生成资源的汇总:

    1. compilation对象负责组织整个打包过程,包含了每个构建环节及输出环节所对应的方法
    2. 该对象内部存放着所有module、chunk、生成的assets以及用来生成最后打包文件的template的信息

    当运行webpack-dev-server时,每当检测到一个文件变化,就会创建一次新的编译,从而生成一组新的编译资源。

  3. 插件

      插件本质上是被实例化的带有apply原型方法的对象,其apply方法在安装插件时将被webpack编译器调用一次,apply入参提供了一个compiler(编译器对象)的引用,从而可以在插件内部访问到webpack的环境。

      具体使用方式,在webpack.config.js里require对应的插件,然后在module.exports对象的plugins数组里实例化对应插件即可。

Compiler运行模式

Compiler有两种运行模式,一种是run执行模式,另一种是watch监测模式(热加载模式webpack-dev-server)。在执行模式下,compiler有三个主要的事件钩子:

  1. compile/编译

    开始编译,创建对应的compilation对象。

  2. make/构建

    根据配置的不同Entry入口构建对应的chunk块,并输出assets资源。

  3. emit/提交

    将最终的assets资源写入硬盘,输出至指定的文件路径。

相比较,监测模式则分为两部分:

  1. run执行模式
  2. 监测依赖如果发生改动,重新回到第一步

Compiler事件钩子

在执行模式下,Compiler的事件钩子如下:

  1. entry-option:生成不同的插件应用

    解析传给 webpack 的配置中的 entry 属性,然后生成不同的插件应用到 Compiler 实例上。这些插件可能是 SingleEntryPlugin, MultiEntryPlugin 或者 DynamicEntryPlugin。但不管是哪个插件,内部都会监听 Compiler 实例对象的 make 任务点

  2. (before-)run:开始执行

    开始执行,启动构建

  3. (before/after-)compile:编译源码,创建对应的Compilation对象

    Compiler 实例将会开始创建 Compilation 对象,这个对象是后续构建流程中最核心最重要的对象,它包含了一次构建过程中所有的数据。也就是说一次构建过程对应一个 Compilation 实例。

  4. make:构建

  5. (after-)emit:输出结果

  6. done:结束

在监测模式下则对应下面三个钩子

  1. watch-run
  2. invalid
  3. watch-close

同时,在Compiler里内嵌了compilation/编译对象、normal-module-factory/常规模块工厂、context-module-factory/上下文模块工厂,可以在事件钩子的回调函数里直接读取。

Compilation事件钩子

compiler的几个事件钩子贯穿了webpack的整个生命周期,但是具体到实际版本构建等操作,却是由compilation来具体执行。compilation对象是指单一的版本构建以及构建生成资源的汇总,它是在compiler的compile/编译事件里被创建,重点负责后续的添加模块、构建模块,打包并输出资源的具体操作(对应了compiler的make构建、emit输出)。

addEntry开始构建模块

在compiler的make方法里开始构建模块,对应了compilation的addEntry方法开始添加模块该方法实际上是调用了_addModuleChain/私有方法,根据模块的类型获取对应的模块工厂并创建模块、构建模块。

首先,这里用了工厂模式来创建module实例,compilation通过读取每个module/模块的dependency/依赖信息,在依赖里读取对应的模块工厂,然后在用工厂的create事件创建出module实例。

创建出module/模块后,再添加至compilation对象里,然后由compilation来统筹模块的构建和module的依赖处理。

在处理module的依赖时,每一个依赖模块还是按照第一个步骤进行操作,迭代循环。直至模块及依赖完全处理完。

addModule添加模块

我们可以看看addModule事件里发生了什么事情,整个addModule里分为两块:读取依赖工厂,把模块添加至compilation。在后面一个步骤里webpack专门做了一系列的优化逻辑。

  在addModule里,compilation会通过identifier判断是否已经有当前module,如果有则跳过;

  • 如果没有的话,会在cache缓存里判断是否有这个模块,如果也没有那么直接添加至compilation里;
  • 如果有那么就需要判断缓存里的模块是否已过期,是否需要重构(通过timeStamps判断),如果需要重构那么就触发disconnect事件,如果不需要重构,那么触发unbuild事件;
  • 不管是否需要重构,只要缓存里有这个模块,都要添加至compilation里。

buildModule构建模块

经过上面的步骤,已经把所有的模块添加至compilation里,接下来是由compilation统筹进行构建

构建模块是整个webpack最耗时的操作,在这里分为几个步骤:

  1. 读取module/模块,调用对应Loader加载器进行处理,并输出对应源码

    1. 遇到依赖时,递归地处理依赖的module/模块
    2. 把处理完的module/模块依赖添加至当前模块
  2. 调用acorn解析加载器输出的源文件,并生成抽象语法树 AST

  3. 遍历AST树,构建该模块以及所依赖的模块

  4. 整合模块和所有对应的依赖,输出整体的module

seal打包输出模块

在构建完模块后,就会在回调函数里调用compilation的seal方法。Compilation的seal事件里,会根据webpack里配置的每个Entry入口开始打包,每个Entry对应地打包出一个chunk,逐次对每个module和chunk进行整理,生成编译后的源码chunk,合并、拆分、生成hash,最终输出Assets资源。

针对每一个Entry入口构建chunk的过程有点类似上面的buildModule,具体细节可以查看流程图。在这里我们需要重点关注createChunkAssets生成最终的资源并输出至指定文件路径。

在整个createChunkAssets过程里,有个有意思的地方需要注意,就是mainTemplate、chunkTemplate和moduleTemplate。template是用来处理上面输出的chunk,打包成最终的Assets资源。但是mainTemplate是用来处理入口模块,chunkTemplate是处理非入口模块,即引用的依赖模块。

通过这两个template的render处理输出source源码,都会汇总到moduleTemplate进行render处理输出module。接着module调用source抽象方法输出assets,最终由compiler统一调用emitAssets,输出至指定文件路径。

我们需要注意一下mainTemplate的render(render-with-entry)方法,整个webpack通过该方法找到整个入口开始构建,引用依赖。如果我们想自定义AMD插件,把整个webpack编译结果包裹起来,那么我们可以通过mainTemplate的render-with-entry方法,进行自定义开发。

关于webpack运行机制

关于webpack运行机制的理解,建议大家有空再看看下面两个流程图,讲得很仔细,有助于理解。

编写AMD插件

假设我们现在要写一个插件使得其能够生成 AMD 模块,那么我们要怎么操作?我们可能会有下面这些疑惑。

具体开发的思路,是通过在webpack打包输出最终资源时,从入口模块构建的节点入手,订阅compilation.mainTemplate的render-with-entry事件,在里边用AMD声明包裹住源码,然后返回即可。这样子后续展开依赖模块时也会统一被AMD声明所包裹。

具体插件源码如下:

/** plugin.js **/
const ConcatSource = require('webpack-sources').ConcatSource,
      path = require('path')

class DefPlugin {
  constructor(name) {
  }

  apply(compiler) {
    compiler.plugin('compilation', (compilation)=>{
      compilation.mainTemplate.plugin('render-with-entry', function(source, chunk, hash){
        return new ConcatSource(`global.define(['require','module','exports'],function(require, module, exports) { 
          ${source.source()} 
        })`);
      }) 
    })
  }
}

module.exports = DefPlugin

实际调用时,只需要在webpack.config.js的plugins数组里实例化该插件即可。

/** webpack.config.js **/
const path = require('path')
const Plugin = require('./plugin')

module.exports = {
    context: path.join(__dirname, 'src'),
    entry: './index.js',
    output: {
        libraryTarget: 'commonjs2',
        path: path.join(__dirname, 'dist'),
        filename: './bundle.js'
    },
    plugins: [
        new Plugin
    ]
}