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

大家好,我是小雨小雨,致力于分享有趣的、实用的技术文章。 内容分为翻译和原创,如果有问题,欢迎随时评论或私信,希望和大家一起进步。 分享不易,希望能够得到大家的支持和关注。 计划 rollup系列打算一章一章的放出,内容更精简更专一更易于理解 目前打算分为以下几章: rollup.rollup rollup.generate + rollup.write <==== 当前文章 rollup.watch tree shaking plugins#### TL;DR 书接上文,我们知道rollup.rollup对配置中的入口进行了解析、依赖挂载、数据化这些操作,最终返回了一个chunks,然后返回了一些方法: rollup() { const chunks = await graph.build(); return { generate, // ... } } 这其中利用了闭包的原理,以便后续方法可以访问到rollup结果 这期我们就深入generate方法,来看看它的内心世界 还是老套路,在看代码前,先大白话说下整个过程,rollup.generate()主要分为以下几步: 配置标准化、创建插件驱动器 chunks、assets收集 preserveModules模式处理 预渲染 chunk优化 源码render 产出过滤、排序 最近看到这么一句话: '将者,智、信、仁、勇、严也' 指的是将者的素养,顺序代表着每个能力的重要性: 智: 智略、谋略 信:信义、信用 仁:仁义、声誉 勇:勇武、果断 严:铁律、公证 时至今日,仍然奏效,哪怕是放到it领域。虽然不能直接拿过来,但内涵都是一样的。 想要做好it这一行,先要自身硬(智),然后是产出质量(信),同事间的默契合作(仁),对事情的判断(勇)和对团队的要求以及奖惩制度(严)。 注意点 所有的注释都在这里,可自行阅读 !!!版本 => 笔者阅读的rollup版本为: 1.32.0 !!!提示 => 标有TODO为具体实现细节,会视情况分析。 !!!注意 => 每一个子标题都是父标题(函数)内部实现 !!!强调 => rollup中模块(文件)的id就是文件地址,所以类似resolveID这种就是解析文件地址的意思,我们可以返回我们想返回的文件id(也就是地址,相对路径、决定路径)来让rollup加载 rollup是一个核心,只做最基础的事情,比如提供默认模块(文件)加载机制, 比如打包成不同风格的内容,我们的插件中提供了加载文件路径,解析文件内容(处理ts,sass等)等操作,是一种插拔式的设计,和webpack类似 插拔式是一种非常灵活且可长期迭代更新的设计,这也是一个中大型框架的核心,人多力量大嘛~ 主要通用模块以及含义 Graph: 全局唯一的图,包含入口以及各种依赖的相互关系,操作方法,缓存等。是rollup的核心 PathTracker: 无副作用模块依赖路径追踪 PluginDriver: 插件驱动器,调用插件和提供插件环境上下文等 FileEmitter: 资源操作器 GlobalScope: 全局作用局,相对的还有局部的 ModuleLoader: 模块加载器 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): 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((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点个赞吧。

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):