原来rollup这么简单之 rollup.generate + rollup.write篇

你们好,我是小雨小雨,致力于分享有趣的、实用的技术文章。 内容分为翻译和原创,若是有问题,欢迎随时评论或私信,但愿和你们一块儿进步。 分享不易,但愿可以获得你们的支持和关注。javascript

计划

rollup系列打算一章一章的放出,内容更精简更专注更易于理解java

目前打算分为如下几章:node

TL;DR

书接上文,咱们知道rollup.rollup对配置中的入口进行了解析、依赖挂载、数据化这些操做,最终返回了一个chunks,而后返回了一些方法:webpack

rollup() {
    const chunks = await graph.build();
    return {
        generate,
        // ...
    }
}
这其中利用了闭包的原理,以便后续方法能够访问到rollup结果
复制代码

这期咱们就深刻generate方法,来看看它的心里世界git

仍是老套路,在看代码前,先大白话说下整个过程,rollup.generate()主要分为如下几步:github

  1. 配置标准化、建立插件驱动器
  2. chunks、assets收集
  3. preserveModules模式处理
  4. 预渲染
  5. chunk优化
  6. 源码render
  7. 产出过滤、排序

最近看到这么一句话:web

'将者,智、信、仁、勇、严也'数组

指的是将者的素养,顺序表明着每一个能力的重要性:promise

智: 智略、谋略 信:信义、信用 仁:仁义、声誉 勇:勇武、果断 严:铁律、公证缓存

时至今日,仍然奏效,哪怕是放到it领域。虽然不能直接拿过来,但内涵都是同样的。

想要作好it这一行,先要自身硬(智),而后是产出质量(信),同事间的默契合做(仁),对事情的判断(勇)和对团队的要求以及奖惩制度(严)。

注意点

全部的注释都在这里,可自行阅读

!!!版本 => 笔者阅读的rollup版本为: 1.32.0

!!!提示 => 标有TODO为具体实现细节,会视状况分析。

!!!注意 => 每个子标题都是父标题(函数)内部实现

!!!强调 => rollup中模块(文件)的id就是文件地址,因此相似resolveID这种就是解析文件地址的意思,咱们能够返回咱们想返回的文件id(也就是地址,相对路径、决定路径)来让rollup加载

rollup是一个核心,只作最基础的事情,好比提供默认模块(文件)加载机制, 好比打包成不一样风格的内容,咱们的插件中提供了加载文件路径,解析文件内容(处理ts,sass等)等操做,是一种插拔式的设计,和webpack相似 插拔式是一种很是灵活且可长期迭代更新的设计,这也是一个中大型框架的核心,人多力量大嘛~

主要通用模块以及含义

  1. Graph: 全局惟一的图,包含入口以及各类依赖的相互关系,操做方法,缓存等。是rollup的核心
  2. PathTracker: 无反作用模块依赖路径追踪
  3. PluginDriver: 插件驱动器,调用插件和提供插件环境上下文等
  4. FileEmitter: 资源操做器
  5. GlobalScope: 全局做用局,相对的还有局部的
  6. ModuleLoader: 模块加载器
  7. NodeBase: ast各语法(ArrayExpression、AwaitExpression等)的构造基类

主流程解析

  • generate方法:

调用封装好的内置私有方法,返回promise,一个一个的来,先来看getOutputOptionsAndPluginDriver

generate: ((rawOutputOptions: GenericConfigObject) => {
    // 过滤output配置选项,并建立output的插件驱动器
    const { outputOptions, outputPluginDriver } = getOutputOptionsAndPluginDriver(
        rawOutputOptions
    );
    const promise = generate(outputOptions, false, outputPluginDriver).then(result =>
        createOutput(result)
    );
    // 丢弃老版本字段
    Object.defineProperty(promise, 'code', throwAsyncGenerateError);
    Object.defineProperty(promise, 'map', throwAsyncGenerateError);
    return promise;
})
复制代码
  • getOutputOptionsAndPluginDriver:

该方法经过output配置生成标准化配置和output插件驱动器

PluginDriver类暴露了createOutputPluginDriver方法

class PluginDriver {
    // ...
    public createOutputPluginDriver(plugins: Plugin[]): PluginDriver {
        return new PluginDriver(
            this.graph,
            plugins,
            this.pluginCache,
            this.preserveSymlinks,
            this.watcher,
            this
        );
    }
	// ...
}
复制代码

引用该方法,建立output的插件驱动器: graph.pluginDriver.createOutputPluginDriver

const outputPluginDriver = graph.pluginDriver.createOutputPluginDriver(
    // 统一化插件
    normalizePlugins(rawOutputOptions.plugins, ANONYMOUS_OUTPUT_PLUGIN_PREFIX)
);
复制代码

生成标准output配置更简单了,调用以前在rollup.rollup方法中用到的,用来提取input配置的mergeOptions(参考mergeOptions.ts)方法,获取处理后的配置,调用outputOptions钩子函数,该钩子能够读取到即将传递给generate/write的配置,进行更改,可是rollup更推荐在renderStart中进行更改等操做。以后进行一些列校验判断最终返回ourputOptions

function normalizeOutputOptions( inputOptions: GenericConfigObject, rawOutputOptions: GenericConfigObject, hasMultipleChunks: boolean, outputPluginDriver: PluginDriver ): OutputOptions {
	const mergedOptions = mergeOptions({
		config: {
			output: {
				...rawOutputOptions,
				// 能够用output里的覆盖
				...(rawOutputOptions.output as object),
				// 不过input里的output优先级最高,可是不是每一个地方都返回,有的不会使用
				...(inputOptions.output as object)
			}
		}
	});

	// 若是merge过程当中出错了
	if (mergedOptions.optionError) throw new Error(mergedOptions.optionError);

	// 返回的是数组,可是rollup不支持数组,因此获取第一项,目前也只会有一项
	const mergedOutputOptions = mergedOptions.outputOptions[0];

	const outputOptionsReducer = (outputOptions: OutputOptions, result: OutputOptions) =>
		result || outputOptions;

	// 触发钩子函数
	const outputOptions = outputPluginDriver.hookReduceArg0Sync(
		'outputOptions',
		[mergedOutputOptions],
		outputOptionsReducer,
		pluginContext => {
			const emitError = () => pluginContext.error(errCannotEmitFromOptionsHook());
			return {
				...pluginContext,
				emitFile: emitError,
				setAssetSource: emitError
			};
		}
	);

	// 检查通过插件处理过的output配置
	checkOutputOptions(outputOptions);

	// output.file 和 output.dir是互斥的
	if (typeof outputOptions.file === 'string') {
		if (typeof outputOptions.dir === 'string')
			return error({
				code: 'INVALID_OPTION',
				message:
					'You must set either "output.file" for a single-file build or "output.dir" when generating multiple chunks.'
			});
		if (inputOptions.preserveModules) {
			return error({
				code: 'INVALID_OPTION',
				message:
					'You must set "output.dir" instead of "output.file" when using the "preserveModules" option.'
			});
		}
		if (typeof inputOptions.input === 'object' && !Array.isArray(inputOptions.input))
			return error({
				code: 'INVALID_OPTION',
				message: 'You must set "output.dir" instead of "output.file" when providing named inputs.'
			});
	}

	if (hasMultipleChunks) {
		if (outputOptions.format === 'umd' || outputOptions.format === 'iife')
			return error({
				code: 'INVALID_OPTION',
				message: 'UMD and IIFE output formats are not supported for code-splitting builds.'
			});
		if (typeof outputOptions.file === 'string')
			return error({
				code: 'INVALID_OPTION',
				message:
					'You must set "output.dir" instead of "output.file" when generating multiple chunks.'
			});
	}

	return outputOptions;
}
复制代码
  • generate内部的generate方法

获取到标准化以后的output配合和插件驱动器后,到了内置的generate方法了,该方法接受三个参数,其中第二个参数标识是否写入,也就是说该方法同时用于generate和下一篇write中。

首先获取用户定义的资源名,没有的话取默认值

const assetFileNames = outputOptions.assetFileNames || 'assets/[name]-[hash][extname]';
复制代码

获取chunks的目录交集,也就是公共的根目录

const inputBase = commondir(getAbsoluteEntryModulePaths(chunks));
复制代码

getAbsoluteEntryModulePaths获取全部绝对路径的chunks id,commondir参考的node-commondir模块,原理是先获取第一个文件的路径,进行split转成数组(设为a),而后遍历剩余全部文件id,进行比对,找到不相等的那个索引,而后从新赋值给a,进行下一次循环,直到结束,就获得了公共的目录。

function commondir(files: string[]) {
	if (files.length === 0) return '/';
	if (files.length === 1) return path.dirname(files[0]);
	const commonSegments = files.slice(1).reduce((commonSegments, file) => {
		const pathSegements = file.split(/\/+|\\+/);
		let i;
		for (
			i = 0;
			commonSegments[i] === pathSegements[i] &&
			i < Math.min(commonSegments.length, pathSegements.length);
			i++
		);
		return commonSegments.slice(0, i);
	}, files[0].split(/\/+|\\+/));

	// Windows correctly handles paths with forward-slashes
	return commonSegments.length > 1 ? commonSegments.join('/') : '/';
}
复制代码

建立一个包含全部chunks和assets信息的对象

const outputBundleWithPlaceholders: OutputBundleWithPlaceholders = Object.create(null);
复制代码

调用插件驱动器上的setOutputBundle将output设置到上面建立的outputBundleWithPlaceholders上。

outputPluginDriver.setOutputBundle(outputBundleWithPlaceholders, assetFileNames);
复制代码

setOutputBundle在FileEmitter类上实现,在插件驱动器类(PluginDriver)上实例化,并将公共方法赋给插件驱动器。 reserveFileNameInBundle方法为outputBundleWithPlaceholders上挂载文件chunks。 finalizeAsset方法只处理资源,将资源格式化后,添加到outputBundleWithPlaceholders上。格式为:

{
    fileName,
    get isAsset(): true {
        graph.warnDeprecation(
            'Accessing "isAsset" on files in the bundle is deprecated, please use "type === \'asset\'" instead',
            false
        );

        return true;
    },
    source,
    type: 'asset'
};
复制代码
class FileEmitter {
    // ...
    setOutputBundle = (
        outputBundle: OutputBundleWithPlaceholders,
        assetFileNames: string
    ): void => {
        this.output = {
            // 打包出来的命名
            assetFileNames,
            // 新建的空对象 => Object.create(null)
            bundle: outputBundle
        };
        // filesByReferenceId是经过rollup.rollup中emitChunks的时候设置的,表明已使用的chunks
        // 处理文件
        for (const emittedFile of this.filesByReferenceId.values()) {
            if (emittedFile.fileName) {
                // 文件名挂在到this.output上,做为key,值为: FILE_PLACEHOLDER
                reserveFileNameInBundle(emittedFile.fileName, this.output.bundle, this.graph);
            }
        }
        // 遍历set 处理资源
        for (const [referenceId, consumedFile] of this.filesByReferenceId.entries()) {
            // 插件中定义了source的状况
            if (consumedFile.type === 'asset' && consumedFile.source !== undefined) {
                // 给this.output上绑定资源
                this.finalizeAsset(consumedFile, consumedFile.source, referenceId, this.output);
            }
        }
    };
	// ...
}
复制代码

调用renderStart钩子函数,用来访问output和input配置,可能你们看到了不少调用钩子函数的方法,好比hookParallel、hookSeq等等,这些都是用来触发插件里提供的钩子函数,不过是执行方式不一样,有的是并行的,有的是串行的,有的只能执行经过一个等等,这会单独抽出来讲。

await outputPluginDriver.hookParallel('renderStart', [outputOptions, inputOptions]);
复制代码

执行footer banner intro outro钩子函数,内部就是执行这几个钩子函数,默认值为option[footer|banner|intro|outro],最后返回字符串结果待拼接。

const addons = await createAddons(outputOptions, outputPluginDriver);
复制代码

处理preserveModules模式,也就是是否尽量少的打包,而不是每一个模块都是一个chunk 若是是尽量少的打包的话,就将chunks的导出多挂载到chunks的exportNames属性上,供以后使用 若是每一个模块都是一个chunk的话,推导出导出模式

for (const chunk of chunks) {
    // 尽量少的打包模块
    // 设置chunk的exportNames
    if (!inputOptions.preserveModules) chunk.generateInternalExports(outputOptions);

    // 尽量多的打包模块
    if (inputOptions.preserveModules || (chunk.facadeModule && chunk.facadeModule.isEntryPoint))
        // 根据导出,去推断chunk的导出模式
        chunk.exportMode = getExportMode(chunk, outputOptions, chunk.facadeModule!.id);
}
复制代码

预渲染chunks。 使用magic-string模块进行source管理,初始化render配置,对依赖进行解析,添加到当前chunks的dependencies属性上,按照执行顺序对依赖们进行排序,处理准备动态引入的模块,设置惟一标志符(?)

for (const chunk of chunks) {
    chunk.preRender(outputOptions, inputBase);
}
复制代码

优化chunks

if (!optimized && inputOptions.experimentalOptimizeChunks) {
    optimizeChunks(chunks, outputOptions, inputOptions.chunkGroupingSize!, inputBase);
    optimized = true;
}
复制代码

将chunkId赋到上文建立的outputBundleWithPlaceholders上

assignChunkIds(
    chunks,
    inputOptions,
    outputOptions,
    inputBase,
    addons,
    outputBundleWithPlaceholders,
    outputPluginDriver
);
复制代码

设置好chunks的对象,也就是将chunks依照id设置到outputBundleWithPlaceholders上,这时候outputBundleWithPlaceholders上已经有完整的chunk信息了

outputBundle = assignChunksToBundle(chunks, outputBundleWithPlaceholders);
复制代码

语法树解析生成code操做,最后返回outputBundle。

await Promise.all(
    chunks.map(chunk => {
        const outputChunk = outputBundleWithPlaceholders[chunk.id!] as OutputChunk;
        return chunk
            .render(outputOptions, addons, outputChunk, outputPluginDriver)
            .then(rendered => {
                // 引用类型,outputBundleWithPlaceholders上的也变化了,因此outputBundle也变化了,最后返回outputBundle
                outputChunk.code = rendered.code;
                outputChunk.map = rendered.map;

                return outputPluginDriver.hookParallel('ongenerate', [
                    { bundle: outputChunk, ...outputOptions },
                    outputChunk
                ]);
            });
    })
);

return outputBundle;
复制代码
  • generate内部的createOutput方法

createOutput接受generate的返回值,并对生成的OutputBundle进行过滤和排序

function createOutput(outputBundle: Record<string, OutputChunk | OutputAsset | {}>): RollupOutput {
	return {
		output: (Object.keys(outputBundle)
			.map(fileName => outputBundle[fileName])
			.filter(outputFile => Object.keys(outputFile).length > 0) as (
			| OutputChunk
			| OutputAsset
		)[]).sort((outputFileA, outputFileB) => {
			const fileTypeA = getSortingFileType(outputFileA);
			const fileTypeB = getSortingFileType(outputFileB);
			if (fileTypeA === fileTypeB) return 0;
			return fileTypeA < fileTypeB ? -1 : 1;
		}) as [OutputChunk, ...(OutputChunk | OutputAsset)[]]
	};
}
复制代码
  • rollup.write

write方法和generate方法几乎一致,只不过是generate方法的第二个参数为true,供generateBundle钩子函数中使用,已代表当前是wirte仍是generate阶段。 以后是获取当前的chunks数,多出口的时候会检测配置的file和sourcemapFile进而抛出错误提示

let chunkCount = 0; //计数
for (const fileName of Object.keys(bundle)) {
    const file = bundle[fileName];
    if (file.type === 'asset') continue;
    chunkCount++;
    if (chunkCount > 1) break;
}
if (chunkCount > 1) {
    // sourcemapFile配置
    if (outputOptions.sourcemapFile)
        return error({
            code: 'INVALID_OPTION',
            message: '"output.sourcemapFile" is only supported for single-file builds.'
        });
    // file字段
    if (typeof outputOptions.file === 'string')
        return error({
            code: 'INVALID_OPTION',
            message:
                'When building multiple chunks, the "output.dir" option must be used, not "output.file".' +
                (typeof inputOptions.input !== 'string' ||
                inputOptions.inlineDynamicImports === true
                    ? ''
                    : ' To inline dynamic imports, set the "inlineDynamicImports" option.')
        });
}
复制代码

以后调用写入方法: writeOutputFile

await Promise.all(
    Object.keys(bundle).map(chunkId =>
        writeOutputFile(result, bundle[chunkId], outputOptions, outputPluginDriver)
    )
);
复制代码

writeOutputFile方法就很直观了,解析路径

const fileName = resolve(outputOptions.dir || dirname(outputOptions.file!), outputFile.fileName);
复制代码

根据chunk类型进行不一样的处理,assets直接获取代码便可,chunks的话还需根据sourcemap选项将sourcemp追加到代码以后。

if (outputFile.type === 'asset') {
    source = outputFile.source;
} else {
    source = outputFile.code;
    if (outputOptions.sourcemap && outputFile.map) {
        let url: string;
        if (outputOptions.sourcemap === 'inline') {
            url = outputFile.map.toUrl();
        } else {
            url = `${basename(outputFile.fileName)}.map`;
            writeSourceMapPromise = writeFile(`${fileName}.map`, outputFile.map.toString());
        }
        if (outputOptions.sourcemap !== 'hidden') {
            source += `//# ${SOURCEMAPPING_URL}=${url}\n`;
        }
    }
}
复制代码

最后调用fs模块进行文件建立和内容写入便可

function writeFile(dest: string, data: string | Buffer) {
	return new Promise<void>((fulfil, reject) => {
		mkdirpath(dest);

		fs.writeFile(dest, data, err => {
			if (err) {
				reject(err);
			} else {
				fulfil();
			}
		});
	});
}
复制代码

以上就是代码流程的解析部分,具体细节参考代码库注释

部分功能的具体解析

总结

随着深刻阅读发现rollup细节操做不少,很复杂,须要话更多的时间去打磨,暂时先分析了下主流程,具体的实现细节好比优化chunks、prerender等以后视状况再说吧。

不过也学到了一些东西,rollup将全部的ast类型分红了一个个的类,一个类专门处理一个ast类型,调用的时候只须要遍历ast body,获取每一项的类型,而后动态调用就能够了,很使用。对于ast没有画面感的同窗能够看这里 => ast在线解析

rollup从构建到打包,经历了三个大步骤:

加载、解析 => 分析(依赖分析、引用次数、无用模块分析、类型分析等) => 生成

看似简单,实则庞杂。为rollup点个赞吧。