rollup技术揭秘系列四 graph.build(可能是全网最系统性的rollup源码分析文章)

657 阅读6分钟

graph

graph 直接翻译过来就是“图”,但是放在 rollup 中我更愿意把它称作为 rollup 的“依赖图谱”。因为 graph 保存了源代码中的各个 module 信息(包括源代码和模块 id 等等),将 module 按照引入顺序进行排序。

在介绍 graph 之前我们先简单回顾下 getInputOptions 方法是如何合并用户传入的 inputOptions 的。在 src/rollup/rollup.ts 文件中:

const { options: inputOptions, unsetOptions: unsetInputOptions } = await getInputOptions(
  rawInputOptions,
  watcher !== null
);

async function getInputOptions(
  rawInputOptions: InputOptions,
  watchMode: boolean
): Promise<{ options: NormalizedInputOptions, unsetOptions: Set<string> }> {
  if (!rawInputOptions) {
    throw new Error('You must supply an options object to rollup');
  }
  const rawPlugins = getSortedValidatedPlugins(
    'options',
    await normalizePluginOption(rawInputOptions.plugins)
  );
  const { options, unsetOptions } = await normalizeInputOptions(
    await rawPlugins.reduce(applyOptionHook(watchMode), Promise.resolve(rawInputOptions))
  );
  normalizePlugins(options.plugins, ANONYMOUS_PLUGIN_PREFIX);
  return { options, unsetOptions };
}

function applyOptionHook(watchMode: boolean) {
	return async (inputOptions: Promise<RollupOptions>, plugin: Plugin): Promise<InputOptions> => {
		const handler = 'handler' in plugin.options! ? plugin.options.handler : plugin.options!;
		return (
			(await handler.call({ meta: { rollupVersion, watchMode } }, await inputOptions)) ||
			inputOptions
		);
	};
}

function normalizePlugins(plugins: readonly Plugin[], anonymousPrefix: string): void {
	for (const [index, plugin] of plugins.entries()) {
		if (!plugin.name) {
			plugin.name = `${anonymousPrefix}${index + 1}`;
		}
	}
}

getInputOptions 方法首先会检查用户是否传入了 InputOptions,如果没有则会抛出一个错误提示:'You must supply an options object to rollup'。这句话的意思是告诉你必须要提供一个 options 给 rollup。因为 rollup 打包需要知道入口文件是哪个。

接着设置 plugin.name,调用 normalizeInputOptions 方法合并配置信息。最后返回了 options 和 unsetOptions。

normalizeInputOptions 方法就是最终合并配置的地方, 代码在 src/utils/options/normalizeInputOptions.ts 目录下:

export async function normalizeInputOptions(config: InputOptions): Promise<{
	options: NormalizedInputOptions;
	unsetOptions: Set<string>;
}> {
	// These are options that may trigger special warnings or behaviour later
	// if the user did not select an explicit value
	const unsetOptions = new Set<string>();
	const context = config.context ?? 'undefined';
	const onwarn = getOnwarn(config);
	const strictDeprecations = config.strictDeprecations || false;
	const maxParallelFileOps = getmaxParallelFileOps(config, onwarn, strictDeprecations);
	const options: NormalizedInputOptions & InputOptions = {
		acorn: getAcorn(config) as unknown as NormalizedInputOptions['acorn'],
		acornInjectPlugins: getAcornInjectPlugins(config),
		cache: getCache(config),
		context,
		experimentalCacheExpiry: config.experimentalCacheExpiry ?? 10,
		external: getIdMatcher(config.external),
		inlineDynamicImports: getInlineDynamicImports(config, onwarn, strictDeprecations),
		input: getInput(config),
		makeAbsoluteExternalsRelative: config.makeAbsoluteExternalsRelative ?? 'ifRelativeSource',
		manualChunks: getManualChunks(config, onwarn, strictDeprecations),
		maxParallelFileOps,
		maxParallelFileReads: maxParallelFileOps,
		moduleContext: getModuleContext(config, context),
		onwarn,
		perf: config.perf || false,
		plugins: await normalizePluginOption(config.plugins),
		preserveEntrySignatures: config.preserveEntrySignatures ?? 'exports-only',
		preserveModules: getPreserveModules(config, onwarn, strictDeprecations),
		preserveSymlinks: config.preserveSymlinks || false,
		shimMissingExports: config.shimMissingExports || false,
		strictDeprecations,
		treeshake: getTreeshake(config)
	};
  //...
	return { options, unsetOptions };
}

弄清楚了合并配置的过程,接着我们继续分析 new Graph(inputOptions, watcher) 逻辑。代码在 src/Graph.ts :

export default class Graph {
	readonly acornParser: typeof acorn.Parser;//使用acornParser解析ast
	readonly cachedModules = new Map<string, ModuleJSON>();//缓存的modules,提升性能
	readonly deoptimizationTracker = new PathTracker();
	entryModules: Module[] = []; //入口模块
	readonly fileOperationQueue: Queue;
	readonly moduleLoader: ModuleLoader; //模块加载器
	readonly modulesById = new Map<string, Module | ExternalModule>(); //使用Map结构来保存modules
	needsTreeshakingPass = false;
	phase: BuildPhase = BuildPhase.LOAD_AND_PARSE; // 构建的 phase 标志
	readonly pluginDriver: PluginDriver; // 插件驱动
	readonly scope = new GlobalScope(); // 作用域
	readonly watchFiles: Record<string, true> = Object.create(null);
	watchMode = false;

	private readonly externalModules: ExternalModule[] = [];//外部的modules
	private implicitEntryModules: Module[] = [];//隐式入口模块
	private modules: Module[] = []; //保存的模块
	private declare pluginCache?: Record<string, SerializablePluginCache>;

	constructor(private readonly options: NormalizedInputOptions, watcher: RollupWatcher | null) {
		//初始化的时候option.cache = undefined
		if (options.cache !== false) {
			if (options.cache?.modules) {
				for (const module of options.cache.modules) this.cachedModules.set(module.id, module);
			}
			this.pluginCache = options.cache?.plugins || Object.create(null);
			// increment access counter
			for (const name in this.pluginCache) {
				const cache = this.pluginCache[name];
				for (const value of Object.values(cache)) value[0]++;
			}
		}
		//...
		//初始化插件
		this.pluginDriver = new PluginDriver(this, options, options.plugins, this.pluginCache);
		//使用acorn解析ast,并且扩展用户自定义配置
		this.acornParser = acorn.Parser.extend(...(options.acornInjectPlugins as any[]));
		//初始化 moduleLoader
		this.moduleLoader = new ModuleLoader(this, this.modulesById, this.options, this.pluginDriver);
    //初始化任务队列
		this.fileOperationQueue = new Queue(options.maxParallelFileOps);
	}
  //...
}

new Graph()

初始化 Graph 的时候定义了非常多的属性和方法。再看到 constructor 内部主要做了以下几件事情:

  1. 初始化 this.pluginCache
  2. 初始化插件 this.pluginDriver
  3. 初始化 this.acornParser
  4. 初始化模块加载器 this.moduleLoader
  5. 初始化任务队列 this.fileOperationQueue

完成一系列的初始化工作之后在 rollupInternal 方法内部紧接着执行了如下两行代码:

await graph.pluginDriver.hookParallel('buildStart', [inputOptions]);
//...
await graph.build();

graph.pluginDriver.hookParallel('buildStart', [inputOptions]) 是调用 buildStart 钩子。为了保持主线逻辑的纯粹,我们这一章节忽略插件的逻辑。稍后我们会在专门的插件系统章节进行详细的讲解。

接着来到 graph.build(); 的方法:

//...
async build(): Promise<void> {
    //...
    /**
     * generateModuleGraph 方法主要做了以下事情:
     * 1、通过 input 配置找出入口模块(entryModules)
     * 2、从 entryModules 分析、读取所有依赖模块并生成 Module 实例
     * 3、设置各模块的 dependences 和依赖模块的 importers
     * 4、创建全局作用域、模块作用域
     * 5、添加 watchFiles
     */
    await this.generateModuleGraph();
    //...
    /**
     * sortModules主要做了两件事情:
     * 1、按照引入顺序排序模块
     * 2、绑定node.variable。即变量的引用信息
     */
    this.sortModules();
    //...
    //遍历所有的ast.node并且修改node.included的值
    this.includeStatements();
    //...
}
//...

总结

graph.build 方法内部主要做了三件事情:

  1. 调用 this.generateModuleGraph 方法生成依赖图谱。具体是通过 options.input 的值找出入口模块(entryModules),模块 id 就是文件的绝对路径。从 entryModules 分析、读取所有依赖模块(依赖模块实际上是根据 import 语句来判断)并生成 Module 实例、设置各模块的 dependences 和依赖模块的 importers、创建全局作用域、模块作用域、添加 watchFiles 等等
  2. 调用 this.sortModules 方法对模块进行排序。绑定 ast.node.variable,即变量的引用信息。
  3. 调用 this.includeStatements 方法对所有的 ast.node 进行 included 标记。如果 included = true 代表该节点会被最终的 bundle 包含进来,否则将会使用 MagicString 对其进行删除或者替换操作。这个过程就是 tree-shaking 的工作原理。

当然,判断 included 的值的时候需要判断该节点是否是 hasEffects 。判断 hasEffects 的逻辑通俗的来讲就是判断节点是否调用全局的方法(例如 console.log)或者修改了全局变量或者其他变量或者方法等等。

rollup 揭秘相关文章