Rollup 插件体系概述

阅读本文之前希望你具备一定 Rollup 或 Vie 基础使用经验

作为一名前端开发者,几乎绕不开 Webpack、Rollup、Vite 等打包工具,在遇到复杂场景时我们通常需要编写插件来满足需求。笔者近期刚好遇到一个编写 Rollup 插件的需求,本文是从这次插件开发过程中的要点记录简单整理而来。

初探插件

基础结构

实现一个 Rollup 插件并非难事,只需要返回一个符合协议的 JavaScript 对象即可,通常长这样:

// rollup-plugin-my-example.js
export default function myExample () {
  return {
    name: 'my-example', // this name will show up in warnings and errors
    resolveId ( source ) {
      if (source === 'virtual-module') {
        // this signals that rollup should not ask other plugins or check
        // the file system to find this id
        return source;
      }
      return null; // other ids should be handled as usually
    },
    load ( id ) {
      if (id === 'virtual-module') {
        // the source code for "virtual-module"
        return 'export default "This is virtual!"';
      }
      return null; // other ids should be handled as usually
    }
  };
}

// rollup.config.js
import myExample from './rollup-plugin-my-example.js';
export default ({
  input: 'virtual-module', // resolved by our plugin
  plugins: [myExample()],
  output: [{
    file: 'bundle.js',
    format: 'es'
  }]
});

官方建议

Rollup 官方给插件开发者的几点建议是:

  • 插件应该有一个带有 rollup-plugin- 前缀的清晰名称。
  • 在 package.json 中包含 rollup-plugin 关键字。
  • 应该测试插件。
  • 尽可能使用异步方法,例如 fs.readFile 而不是 fs.readFileSync
  • 使用英文编写插件文档。
  • 如果合适,请确保您的插件输出正确的 SourceMap。
  • 如果插件使用“虚拟模块”(例如辅助函数),请在模块 ID 前加上 \0,这可以防止其他插件尝试处理它。

钩子分类

从上面的例子可以看到编写插件的主要难度在于选择合适的 钩子,在 Rollup 中这些钩子被分为 5 类。

async

钩子也可能返回一个 Promise 解析为相同类型的值。

sync

钩子只能同步返回结果。

first

如果有多个插件实现了这个钩子,那么钩子会按顺序运行,直到一个钩子返回一个非 null 或 undefined 的值。

sequential

如果有多个插件实现了这个钩子,所有插件都会按照指定的插件顺序运行。如果一个挂钩是异步的,则此类后续挂钩将等待当前挂钩被解析。

parallel

如果有多个插件实现了这个钩子,所有插件都会按照指定的插件顺序运行。如果一个钩子是异步的,那么后续的这种钩子将并行运行,而不是等待当前的钩子。

需要注意的是这 5 大类型不是互斥的,换句话说一个钩子可以是 async + parallel 或 async + sequential 的

生命周期

Rollup 的生命周期可以分为两大类,分别是 BuildOutput,你可以这么简单理解:

  • Build 阶段是 Rollup 打包的主要阶段,包括了解析、转化、打包等,对应钩子是file级别
  • Output 阶段是 Rollup 打包的最后阶段,包括了输出、写入文件等,对应钩子是chunkbundle 级别

Build 阶段

这张图里不同颜色代表了不同类型的钩子,整体流程比较清晰,主要步骤如下:

  1. options 转化配置
  2. buildStart 开始构建
  3. resolveId 解析文件地址
    • 如果配置了 external,说明不需要打包,那么直接跳到 buildEnd
  4. load 加载文件内容
  5. shouldTransformCachedModule 检查结构是否被缓存过
  6. transform 转化文件内容
  7. moduleParsed 解析模块依赖
    • 如果是同步 import,递归调用 resolveId,进到下一个文件 build
    • 如果是异步 import,执行 resolveDynamicImport
  8. buildEnd 结束构建

Output 阶段

输出阶段主要步骤如下:

  1. outputOptions 转化配置
  2. renderStart 开始打包
  3. bannerfooterintrooutro 注入自定义内容,类似协议声明等
  4. renderChunk 打包一个 chunk
  5. augmentChunkHash 修改 chunk hash 值
  6. generateBundle 生成整个 bundle
  7. writeBundle 写入整个 bundle
  8. choseBundle 结束前的清理工作

插件上下文

在编写插件时候,你可以通过 this 访问到插件的上下文,类似比较常用的

this.emitFile

输出一个新文件,并返回一个 referenceId

// rollup.config.js
function generateHtmlPlugin() {
	let ref1;
	return {
		name: 'generate-html',
		buildStart() {
			ref1 = this.emitFile({
				type: 'chunk',
				id: 'src/entry1'
			});
		},
		generateBundle() {
			this.emitFile({
				type: 'asset',
				fileName: 'index.html',
				source: `
        <!DOCTYPE html>
        <html>
        <head>
          <meta charset="UTF-8">
          <title>Title</title>
         </head>
        <body>
          <script src="${this.getFileName(ref1)}" type="module"></script>
        </body>
        </html>`
			});
		}
	};
}

this.parse

调用 Rollup 内置的 acorn 来将代码转化成 ast

this.warn、this.error

用于打印日志

所有方法可以参考 https://rollupjs.org/plugin-development/#plugin-context

插件间通信

插件之间直接通信(推荐)

简单粗暴,保存插件引用并直接调用。

function parentPlugin() {
	return {
		name: 'parent',
		api: {
			//...methods and properties exposed for other plugins
			doSomething(...args) {
				// do something interesting
			}
		}
		// ...plugin hooks
	};
}

function dependentPlugin() {
	let parentApi;
	return {
		name: 'dependent',
		buildStart({ plugins }) {
			const parentName = 'parent';
			const parentPlugin = plugins.find(
				plugin => plugin.name === parentName
			);
			if (!parentPlugin) {
				// or handle this silently if it is optional
				throw new Error(
					`This plugin depends on the "${parentName}" plugin.`
				);
			}
			// now you can access the API methods in subsequent hooks
			parentApi = parentPlugin.api;
		},
		transform(code, id) {
			if (thereIsAReasonToDoSomething(id)) {
				parentApi.doSomething(id);
			}
		}
	};
}

自定义模块元数据

可以通过 meta 字段来实现,这个字段是一个对象,可以用来存储任何数据,但是不要存储大量数据,因为这个字段会被序列化到 bundle 中。

function annotatingPlugin() {
	return {
		name: 'annotating',
		transform(code, id) {
			if (thisModuleIsSpecial(code, id)) {
				return { meta: { annotating: { special: true } } };
			}
		}
	};
}

function readingPlugin() {
	let parentApi;
	return {
		name: 'reading',
		buildEnd() {
			const specialModules = Array.from(this.getModuleIds()).filter(
				id => this.getModuleInfo(id).meta.annotating?.special
			);
			// do something with this list
		}
	};
}

自定义解析器选项

比较难理解,不如上面两个简单直接

function requestingPlugin() {
	return {
		name: 'requesting',
		async buildStart() {
			const resolution = await this.resolve('foo', undefined, {
				custom: { resolving: { specialResolution: true } }
			});
			console.log(resolution.id); // "special"
		}
	};
}

function resolvingPlugin() {
	return {
		name: 'resolving',
		resolveId(id, importer, { custom }) {
			if (custom.resolving?.specialResolution) {
				return 'special';
			}
			return null;
		}
	};
}