rollup-插件钩子及使用场景

/post/rollup-plugin article cover image

rollup作为一个轻量、良好的插件系统专注于esmodule的打包器,对于插件开发提供了很多友好的钩子方法特此记录并根据遇到的问题解决办法持续更新.

范式约定

插件编写规范

  1. 插件名称以rollup-plugin-作为前缀
  2. package.json中包含rollup-plugin关键字
  3. 建议单元测试并支持Promise开箱即用
  4. 开发中尽可能使用异步方法,例如fs.readFile而不是fs.readFileSync
  5. 使用英文作为文档语言
  6. 尽可能输出正确的source-map
  7. 使用virtual-module时插件名称尽可能使用\0作为前缀,避免与其他插件冲突

插件钩子种类

  1. async:此钩子将可能返回一个Promiseresolve后的同类型结果,否则会被标记为同步钩子
  2. first:如果多个插件调用此钩子,在顺序运行的情况下当返回不为nullundefined时停止,否则就会交由下一个插件处理
  3. sequential:如果多个插件调用此钩子,会根据指定顺序运行,如果其中一个为异步钩子时其他钩子会等待它resolved后再运行
  4. parallel:运行方式与sequential相似,只不过在顺序钩子中执行时不会等待异步钩子resolved,各自并行运行

插件钩子形式

除了作为一个函数外,也可以是一个包含ordersequentialhandler属性的对象,sequential只能用于设置parallel类型的钩子是否等待异步钩子执行后顺序执行,具体说明参考 sequential specification

ts
export default function example() {
  // return function(source) {
  //   // ...
  // }
  return {
    name: 'resolve-first',
    resolveId: {
      order: 'pre', // 'pre' | 'post' | null用以指定钩子在相同钩子里的执行顺序
      handler(source) { // 处理函数
        // ...
      }
    },
    // 当需要在相互依赖的不同 writeBundle 钩子中运行多个命令行工具的情形,需要手动设置
    writeBundle: {
			sequential: true,
			order: 'post',
			async handler({ dir }) {
				const topLevelFiles = await readdir(resolve(dir));
				console.log(topLevelFiles);
			}
		}
  }
}

构建阶段钩子

构建阶段钩子按照顺序执行为:

options

用于修改或操控配置在未被rollup.rollup()接收执行之前触发,读取配置的场景更推荐使用buildStart因为可能存在多个options插件对配置进行了修改

buildstart

rollup.rollup()被调用时触发,此时所有插件的options都已经处理并转换过可以在此钩子访问最终的配置信息(包含默认配置)

resolveid

当模块被引用解析时被调用,一般用做对引入模块的信息进行修改处理并在load钩子中用作自定义处理

load

自定义加载器,可以返回指定代码片段或者{ code, ast, map }对象,以官方virtual插件为例,引入对应虚拟模块返回定义的代码:

ts
export default {
  input: 'entry',
  plugins: [
    virtual({
      entry: `
        console.log('this is virtual entry content')
      `
    })
  ]
}
ts
import * as path from 'path';
import type { Plugin } from 'rollup';
import type { RollupVirtualOptions } from '../';

const PREFIX = `\0virtual:`;

export default function virtual(modules: RollupVirtualOptions): Plugin {
  const resolvedIds = new Map<string, string>();

  // 遍历传入的虚拟模块对象并保存在map中
  Object.keys(modules).forEach((id) => {
    resolvedIds.set(path.resolve(id), modules[id]);
  });

  return {
    name: 'virtual',
    resolveId(id, importer) {
      // 如果解析的模块存在于传入的对象中,则增加自定义前缀以供
      // load逻辑判断处理
      if (id in modules) return PREFIX + id;

      if (importer) {
        // 获取实际模块解析路径
        const importerNoPrefix = importer.startsWith(PREFIX)
          ? importer.slice(PREFIX.length)
          : importer;
        const resolved = path.resolve(path.dirname(importerNoPrefix), id);
        // 如果被解析模块存在缓存map中则返回前缀拼接形式的
        if (resolvedIds.has(resolved)) return PREFIX + resolved;
      }
      // 否则交由其他resolveId钩子处理
      return null;
    },
    load(id) {
      // 只对拥有自定义前缀的模块返回对应模块时分别在传入对象和缓存中试图返回对应代码
      if (id.startsWith(PREFIX)) {
        const idNoPrefix = id.slice(PREFIX.length);

        return idNoPrefix in modules ? modules[idNoPrefix] : resolvedIds.get(idNoPrefix);
      }
      return null;
    }
  };
}

shouldtransformcachedmodule

如果load钩子加载的代码与缓存副本中的相同则跳过transform钩子使用模块缓存并交由moduleParsed钩子,如果shouldTransformCachedModule返回true则从缓存中删除此模块并重新执行transform

transform

常用于对单独模块通过astcode进行转换操作并返回{ ast, map, ast }对象对源代码修改、返回null交由其他插件钩子处理,以官方strip插件为例对源码进行debugger语句方法消除:

ts
import { walk } from 'estree-walker';
import MagicString from 'magic-string';
import { createFilter } from '@rollup/pluginutils';

const whitespace = /\s/;

function getName(node) {
	if (node.type === 'Identifier') return node.name;
	if (node.type === 'ThisExpression') return 'this';
	if (node.type === 'Super') return 'super';
	return null;
}

function flatten(node) {
	const parts = [];
	while (node.type === 'MemberExpression') {
		if (node.computed) return null;
		parts.unshift(node.property.name);
		node = node.object;
	}
	const name = getName(node);
	if (!name) return null;
	parts.unshift(name);
	return parts.join('.');
}

export default function strip(options = {}) {
	const include = options.include || '**/*.js';
	const { exclude } = options;
	const filter = createFilter(include, exclude);
	const sourceMap = options.sourceMap !== false;

	const removeDebuggerStatements = options.debugger !== false;
	const functions = (options.functions || ['console.*', 'assert.*']).map((keypath) =>
		keypath.replace(/\*/g, '\\w+').replace(/\./g, '\\s*\\.\\s*')
	);

	const labels = options.labels || [];

	const labelsPatterns = labels.map((l) => `${l}\\s*:`);

	const firstPass = [...functions, ...labelsPatterns];
	if (removeDebuggerStatements) {
		firstPass.push('debugger\\b');
	}

	const reFunctions = new RegExp(`^(?:${functions.join('|')})$`);
	const reFirstpass = new RegExp(`\\b(?:${firstPass.join('|')})`);
	const firstPassFilter = firstPass.length > 0 ? (code) => reFirstpass.test(code) : () => false;
	const UNCHANGED = null;

	return {
		name: 'strip',
		transform(code, id) {
			if (!filter(id) || !firstPassFilter(code)) {
				return UNCHANGED;
			}

			let ast;

			try {
        // 使用rollup上下文方法通过acron解析语法树
				ast = this.parse(code);
			} catch (err) {
				err.message += ` in ${id}`;
				throw err;
			}

			let edited = false;
			const magicString = new MagicString(code);

      // 删除指定语法树节点
			function remove(start, end) {
				while (whitespace.test(code[start - 1])) start -= 1;
				magicString.remove(start, end);
			}

      // 是否块声明语句
			function isBlock(node) {
				return node && (node.type === 'BlockStatement' || node.type === 'Program');
			}

      // 删除表达式
			function removeExpression(node) {
				const { parent } = node;

				if (parent.type === 'ExpressionStatement') {
					removeStatement(parent);
				} else {
					magicString.overwrite(node.start, node.end, '(void 0)');
				}

				edited = true;
			}

      // 删除声明语句
			function removeStatement(node) {
				const { parent } = node;

				if (isBlock(parent)) {
					remove(node.start, node.end);
				} else {
					magicString.overwrite(node.start, node.end, '(void 0);');
				}

				edited = true;
			}

			walk(ast, {
				enter(node, parent) {
					Object.defineProperty(node, 'parent', {
						value: parent,
						enumerable: false,
						configurable: true
					});

					if (sourceMap) {
						magicString.addSourcemapLocation(node.start);
						magicString.addSourcemapLocation(node.end);
					}

          // 删除debugger语句
					if (removeDebuggerStatements && node.type === 'DebuggerStatement') {
						removeStatement(node);
						this.skip();
					}
          // 删除用户自定义配置label语句
          else if (node.type === 'LabeledStatement') {
						if (node.label && labels.includes(node.label.name)) {
							removeStatement(node);
							this.skip();
						}
					}
          // 删除调用表达式
          else if (node.type === 'CallExpression') {
						const keypath = flatten(node.callee);
						if (keypath && reFunctions.test(keypath)) {
							removeExpression(node);
							this.skip();
						}
					}
				}
			});

			if (!edited) return UNCHANGED;

			code = magicString.toString();
			const map = sourceMap ? magicString.generateMap() : null;

      // 将最终转换修改后的代码返回
			return { code, map };
		}
	};
}

moduleparsed

当每个模块被完全解析后调用,传入的参数为this.getModuleInfo返回值对于一些动态加载模块和元信息并不完成,如果需要完整的模块信息可在buildEnd中获取

resolvedynamicimport

每当动态加载模块时被调用,当所有plugin钩子返回null时保持原样作为external处理,返回ResolvedId形式对象{ id, external }会被resolveId钩子处理,返回string时并会作为idload钩子处理

ts
export default {
	input: "src/index.js",
	output: {
		dir: './dist',
		format: 'esm',
		name: 'myBundle'
	},
	plugins: [
		{
			load(id) {
				console.log('load>>>>>>>>>>>>>', id)
			},
			resolveId(id) {
				console.log('resolveId>>>>>>>>>>>', id)
			},
			async resolveDynamicImport(specifier, importer) {
				return 'src/someB.js'
				// return await this.resolve('./src/someB.js')
			}
		}
	]
}

buildend

generatewrite调用前,所有构建打包后执行

生成产出阶段钩子

生成产出阶段钩子按照顺序执行为:

outputoptions

此钩子对构建配置选项进行替换修改操作,返回null什么都不做.推荐使用renderStart来访问所有此类钩子处理后的配置

renderstart

bundle.generate()bundle.write()被调用时调用,一般用于获取outputOptions同样可以接收传递给bundle()inputOptions

renderdynamicimport

此钩子提供了对动态引入import('xxx.js')的左位和右位进行细粒度自定义替换,返回null时交由其他钩子处理最终返回指定格式默认值

ts
function dynamicImportPolyfillPlugin() {
	return {
		name: 'dynamic-import-polyfill',
		renderDynamicImport() {
			return {
				left: 'dynamicImportPolyfill(',
				right: ', import.meta.url)'
			};
		}
	};
}

// 对于动态引入的语句会被替换成如下
// import('test.js') -> dynamicImportPolyfill('test.js', import.meta.url)

resolvefileurlresolveimportmeta

bannerfooterintrooutro

renderchunk

augmentchunkhash

generatebundle

writebundle

closebundle