亲宝软件园·资讯

展开

原来rollup这么简单之 tree shaking篇

小雨小雨丶 人气:3
> 大家好,我是小雨小雨,致力于分享有趣的、实用的技术文章。 > 内容分为翻译和原创,如果有问题,欢迎随时评论或私信,希望和大家一起进步。 > 分享不易,希望能够得到大家的支持和关注。 #### 计划 rollup系列打算一章一章的放出,内容更精简更专一更易于理解 目前打算分为以下几章: - [rollup.rollup](https://juejin.im/post/5e6f820de51d452716052170) - [rollup.generate + rollup.write](https://juejin.im/post/5e735723e51d4526d326d9c9) - [rollup.watch](https://juejin.im/post/5e7c5012e51d455c0c18d427) - tree shaking <==== 当前文章 - plugins #### TL;DR es node: 各种语法块的类,比如if块,箭头函数块,函数调用块等等 rollup()阶段,分析源码,生成ast tree,对ast tree上的每个节点进行遍历,判断出是否include,是的话标记,然后生成chunks,最后导出。 generate()或者write()阶段根据rollup()阶段做的标记,进行代码收集,最后生成真正用到的代码,这就是tree shaking的基本原理。 一句话就是,根据side effects的定义,设定es node的include 与 不同的es node生成不同的渲染(引入到magic string实例)函数,有magic string进行收集,最后写入。 本文没有具体分析各es node渲染方法和include设定的具体实现,不过有问题欢迎讨论,拍砖~ #### 注意点 > !!!版本 => 笔者阅读的rollup版本为: 1.32.0 > !!!提示 => 标有TODO为具体实现细节,会视情况分析。 > !!!注意 => 每一个子标题都是父标题(函数)内部实现 > !!!强调 => rollup中模块(文件)的id就是文件地址,所以类似resolveID这种就是解析文件地址的意思,我们可以返回我们想返回的文件id(也就是地址,相对路径、决定路径)来让rollup加载 > rollup是一个核心,只做最基础的事情,比如提供[默认模块(文件)加载机制](https://github.com/FoxDaxian/rollup-analysis/blob/master/src/utilshttps://img.qb5200.com/download-x/defaultPlugin.ts#L6), 比如打包成不同风格的内容,我们的插件中提供了加载文件路径,解析文件内容(处理ts,sass等)等操作,是一种插拔式的设计,和webpack类似 > 插拔式是一种非常灵活且可长期迭代更新的设计,这也是一个中大型框架的核心,人多力量大嘛~ #### 主要通用模块以及含义 1. Graph: 全局唯一的图,包含入口以及各种依赖的相互关系,操作方法,缓存等。是rollup的核心 2. PathTracker: 引用(调用)追踪器 3. PluginDriver: 插件驱动器,调用插件和提供插件环境上下文等 4. FileEmitter: 资源操作器 5. GlobalScope: 全局作用局,相对的还有局部的 6. ModuleLoader: 模块加载器 7. NodeBase: ast各语法(ArrayExpression、AwaitExpression等)的构造基类 #### 流程解析 这次就不全流程解析了,咱们举个最简单的例子分析一下,更有助于理解。 比如我们有这么一段简单的代码: ```javascript function test() { var name = 'test'; console.log(123); } const name = '测试测试'; function fn() { console.log(name); } fn(); ``` 如你所见,打包的结果应该是不包含test函数的,像下面这样: ```javascript 'use strict'; const name = '测试测试'; function fn() { console.log(name); } fn(); ``` 那rollup是怎么处理这段代码的呢? - 模块解析 还得回到了[rollup()流程](https://juejin.im/post/5e6f820de51d452716052170),根据例子,我们可以把对`import`、`export`、`re-export`等相关的都干掉,暂时不需要关注,了解最基本的流程后会水到渠成的。 关于插件,我也不会使用任何插件,只使用rollup内置的默认插件。 对于这个例子来说,首先会根据解析文件地址,获取文件真正的路径: ```javascript function createResolveId(preserveSymlinks: boolean) { return function(source: string, importer: string) { if (importer !== undefined && !isAbsolute(source) && source[0] !== '.') return null; // 最终调用path.resolve,将合法路径片段转为绝对路径 return addJsExtensionIfNecessary( resolve(importer ? dirname(importer) : resolve(), source), preserveSymlinks ); }; } ``` 然后创建rollup模块,设置缓存等: ```javascript const module: Module = new Module( this.graph, id, moduleSideEffects, syntheticNamedExports, isEntry ); ``` 之后通过内置的`load`钩子获取文件内容,当然咱也可以自定义该行为: ```javascript // 第二个参数是 传给load钩子函数的 参数,内部使用的apply return Promise.resolve(this.pluginDriver.hookFirst('load', [id])) ``` 之后经过`transform`(transform这里可以理解为webpack的各种loader,处理不同类型文件的)处理生成一段标准化的结构: ```javascript const source = { ast: undefined, code: 'function test() {\n var name = \'test\';\n console.log(123);\n}\nconst name = \'测试测试\';\nfunction fn() {\n console.log(name);\n}\n\nfn();\n', customTransformCache: false, moduleSideEffects: null, originalCode: 'function test() {\n var name = \'test\';\n console.log(123);\n}\nconst name = \'测试测试\';\nfunction fn() {\n console.log(name);\n}\n\nfn();\n', originalSourcemap: null, sourcemapChain: [], syntheticNamedExports: null, transformDependencies: [] } ``` 然后就到了比较关键的一步,将source解析并设置到当前module上: ```javascript // 生成 es tree ast this.esTreeAst = ast || tryParse(this, this.graph.acornParser, this.graph.acornOptions); // 调用 magic string,超方便操作字符串的工具库 this.magicString = new MagicString(code, options). // 搞一个ast上下文环境,包装一些方法,比如动态导入、导出等等吧,之后会将分析到的模块或内容填充到当前module中,bind的this指向当前module this.astContext = { addDynamicImport: this.addDynamicImport.bind(this), // 动态导入 addExport: this.addExport.bind(this), // 导出 addImport: this.addImport.bind(this), // 导入 addImportMeta: this.addImportMeta.bind(this), // importmeta annotations: (this.graph.treeshakingOptions && this.graph.treeshakingOptions.annotations)!, code, // Only needed for debugging deoptimizationTracker: this.graph.deoptimizationTracker, error: this.error.bind(this), fileName, // Needed for warnings getExports: this.getExports.bind(this), getModuleExecIndex: () => this.execIndex, getModuleName: this.basename.bind(this), getReexports: this.getReexports.bind(this), importDescriptions: this.importDescriptions, includeDynamicImport: this.includeDynamicImport.bind(this), includeVariable: this.includeVariable.bind(this), isCrossChunkImport: importDescription => (importDescription.module as Module).chunk !== this.chunk, magicString: this.magicString, module: this, moduleContext: this.context, nodeConstructors, preserveModules: this.graph.preserveModules, propertyReadSideEffects: (!this.graph.treeshakingOptions || this.graph.treeshakingOptions.propertyReadSideEffects)!, traceExport: this.getVariableForExportName.bind(this), traceVariable: this.traceVariable.bind(this), treeshake: !!this.graph.treeshakingOptions, tryCatchDeoptimization: (!this.graph.treeshakingOptions || this.graph.treeshakingOptions.tryCatchDeoptimization)!, unknownGlobalSideEffects: (!this.graph.treeshakingOptions || this.graph.treeshakingOptions.unknownGlobalSideEffects)!, usesTopLevelAwait: false, warn: this.warn.bind(this), warnDeprecation: this.graph.warnDeprecation.bind(this.graph) }; // 实例化Program,将结果赋给当前模块的ast属性上以供后续使用 // !!! 注意实例中有一个included属性,用于是否打包到最终输出文件中,也就是tree shaking。默认为false。!!! this.ast = new Program( this.esTreeAst, { type: 'Module', context: this.astContext }, // astContext里包含了当前module和当前module的相关信息,使用bind绑定当前上下文 this.scope ); // Program内部会将各种不同类型的 estree node type 的实例添加到实例上,以供后续遍历使用 // 不同的node type继承同一个NodeBase父类,比如箭头函数表达式(ArrayExpression类),详见[nodes目录](https://github.com/FoxDaxian/rollup-analysis/tree/master/src/ast/nodes) function parseNode(esTreeNode: GenericEsTreeNode) { // 就是遍历,然后new nodeType,然后挂载到实例上 for (const key of Object.keys(esTreeNode)) { // That way, we can override this function to add custom initialisation and then call super.parseNode // this 指向 Program构造类,通过new创建的 // 如果program上有的话,那么跳过 if (this.hasOwnProperty(key)) continue; // ast tree上的每一个属性 const value = esTreeNode[key]; // 不等于对象或者null或者key是annotations // annotations是type if (typeof value !== 'object' || value === null || key === 'annotations') { (this as GenericEsTreeNode)[key] = value; } else if (Array.isArray(value)) { // 如果是数组,那么创建数组并遍历上去 (this as GenericEsTreeNode)[key] = []; // this.context.nodeConstructors 针对不同的语法书类型,进行不同的操作,比如挂载依赖等等 for (const child of value) { // 循环然后各种new 各种类型的node,都是继成的NodeBase (this as GenericEsTreeNode)[key].push( child === null ? null : new (this.context.nodeConstructors[child.type] || this.context.nodeConstructors.UnknownNode)(child, this, this.scope) // 处理各种ast类型 ); } } else { // 以上都不是的情况下,直接new (this as GenericEsTreeNode)[key] = new (this.context.nodeConstructors[value.type] || this.context.nodeConstructors.UnknownNode)(value, this, this.scope); } } } ``` 后面处理相关依赖模块,直接跳过咯~ ```javascript return this.fetchAllDependencies(module).then(); ``` 到目前为止,我们将文件转换成了模块,并解析出 es tree node 以及其内部包含的各类型的语法树 - 使用PathTracker追踪上下文关系 ```javascript for (const module of this.modules) { // 每个一个节点自己的实现,不是全都有 module.bindReferences(); } ``` 比如我们有箭头函数,由于没有`this`指向所以默认设置`UNKONW` ```javascript // ArrayExpression类,继承与NodeBase bind() { super.bind(); for (const element of this.elements) { if (element !== null) element.deoptimizePath(UNKNOWN_PATH); } } ``` 如果有外包裹函数,就会加深一层path,最后会根据层级关系,进行代码的wrap - 标记模块是否可shaking 其中核心为根据isExecuted的状态进行模块以及es tree node的引入,再次之前我们要知道includeMarked方式是获取入口之后调用的。 也就是所有的入口模块(用户定义的、动态引入、入口文件依赖、入口文件依赖的依赖..)都会module.isExecuted为true 之后才会调用下面的`includeMarked`方法,这时候module.isExecuted已经为true,即可调用include方法 ```javascript function includeMarked(modules: Module[]) { // 如果有treeshaking不为空 if (this.treeshakingOptions) { // 第一个tree shaking let treeshakingPass = 1; do { timeStart(`treeshaking pass ${treeshakingPass}`, 3); this.needsTreeshakingPass = false; for (const module of modules) { // 给ast node标记上include if (module.isExecuted) module.include(); } timeEnd(`treeshaking pass ${treeshakingPass++}`, 3); } while (this.needsTreeshakingPass); } else { // Necessary to properly replace namespace imports for (const module of modules) module.includeAllInBundle(); } } // 上面module.include()的实现。 include(context: InclusionContext, includeChildrenRecursively: IncludeChildren) { // 将当然程序块的included设为true,再去遍历当前程序块中的所有es node,根据不同条件进行include的设定 this.included = true; for (const node of this.body) { if (includeChildrenRecursively || node.shouldBeIncluded(context)) { node.include(context, includeChildrenRecursively); } } } ``` module.include内部就涉及到es tree node了,由于NodeBase初始include为false,所以还有第二个判断条件:当前node是否有副作用side effects。 这个是否有副作用是继承与NodeBase的各类node子类自身的实现。目前就我看来,副作用也是有自身的协议规定的,比如修改了全局变量这类就算是副作用,当然也有些是肯定无副作用的,比如export语句,rollup中就写死为false了。 rollup内部不同类型的es node 实现了不同的hasEffects实现,可自身观摩学习。可以通过[该篇文章](https://codesource.io/avoiding-side-effects-in-javascript-code/),简单了解一些 side effects。 - chunks的生成 后面就是通过模块,生成chunks,当然其中还包含多chunk,少chunks等配置选项的区别,这里不再赘述,有兴趣的朋友可以参考本系列[第一篇文章](https://juejin.im/post/5e6f820de51d452716052170)或者直接查看带注释的[源码](https://github.com/FoxDaxian/rollup-analysis) - 通过chunks生成代码(字符串) 调用rollup方法后,会返回一个对象,其中包括了代码生成和写入操作的write方法(已去掉一些warn等): ```javascript return { write: ((rawOutputOptions: GenericConfigObject) => { const { outputOptions, outputPluginDriver } = getOutputOptionsAndPluginDriver( rawOutputOptions ); // 这里是关键 return generate(outputOptions, true, outputPluginDriver).then(async bundle => { await Promise.all( Object.keys(bundle).map(chunkId => writeOutputFile(result, bundle[chunkId], outputOptions, outputPluginDriver) // => 写入操作 ) ); // 修改生成后的代码 await outputPluginDriver.hookParallel('writeBundle', [bundle]); // 目前看来是供之后缓存用,提高构建速度 return createOutput(bundle); }); }) as any } ``` generate是就相对简单些了,就是一些钩子和方法的调用,比如: preRender方法将es node渲染为字符串,调用的是各es node自身实现的render方法,具体参考代码哈。规则很多,这里不赘述,我也没细看~~ 那哪些需要渲染,哪些不需要渲染呢? 没错,就用到了之前定义的`include`字段,做了一个简单的判断,比如: ```javascript // label node类中 function render(code: MagicString, options: RenderOptions) { // 诺~ if (this.label.included) { this.label.render(code, options); } else { code.remove( this.start, findFirstOccurrenceOutsideComment(code.original, ':', this.label.end) + 1 ); } this.body.render(code, options); } ``` 之后添加到chunks中,这样chunks中不仅有ast,还有生成后的可执行代码。 之后根据format字段获取不同的[wrapper](https://github.com/FoxDaxian/rollup-analysis/tree/master/src/finalisers),对代码字符串进行处理,然后传递给`renderChunk`方法,该方法主要为了调用`renderChunk`、`transformChunk`、`transformBundle`三个钩子函数,对结果进行进一步处理。不过由于我分析的版本不是最新的,所以会与当前2.x有出入,改动详见[changlog](https://github.com/rollup/rollup/blobhttps://img.qb5200.com/download-x/d18cb37d7c328a63c36761583ce456275f164462/CHANGELOG.md) 对了,还有sourceMap,这个能力是magic string提供的,可自行[查阅](https://github.com/Rich-Harris/magic-string#sgeneratedecodedmap-options-) 这样我们就得到了最终想要的结果: ```javascript chunks.map(chunk => { // 通过id获取之前设置到outputBundleWithPlaceholders上的一些属性 const outputChunk = outputBundleWithPlaceholders[chunk.id!] as OutputChunk; return chunk .render(outputOptions, addons, outputChunk, outputPluginDriver) .then(rendered => { // 引用类型,outputBundleWithPlaceholders上的也变化了,所以outputBundle也变化了,最后返回outputBundle // 在这里给outputBundle挂载上了code和map,后面直接返回 outputBundle 了 outputChunk.code = rendered.code; outputChunk.map = rendered.map; // 调用生成的钩子函数 return outputPluginDriver.hookParallel('ongenerate', [ { bundle: outputChunk, ...outputOptions }, outputChunk ]); }); }) ``` 上面函数处理的是引用类型,所以最后可以直接返回结果。不在赘述。 - 文件写入 这部分没啥好说的,大家自己看下下面的代码吧。其中`writeFile`方法调用的node fs模块提供的能力。 ```javascript function writeOutputFile( build: RollupBuild, outputFile: OutputAsset | OutputChunk, outputOptions: OutputOptions, outputPluginDriver: PluginDriver ): Promise { const fileName = resolve(outputOptions.dir || dirname(outputOptions.file!), outputFile.fileName); let writeSourceMapPromise: Promise; let source: string | Buffer; 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`; } } } return writeFile(fileName, source) .then(() => writeSourceMapPromise) .then( (): any => outputFile.type === 'chunk' && outputPluginDriver.hookSeq('onwrite', [ { bundle: build, ...outputOptions }, outputFile ]) ) .then(() => {}); } ``` - 其他 对于`import`、`export`、`re-export`这类ast node,rollup会解析生成的ast tree,获取其中的value,也就是模块名,组合有用的信息备用。然后就和上述流程类似了。 推荐使用[ast explorer](https://astexplorer.net/)解析一段code,然后看看里面的结构,了解后会更容易理解。 ```javascript function addImport(node: ImportDeclaration) { // 比如引入了path模块 // source: { // type: 'Literal', // start: 'xx', // end: 'xx', // value: 'path', // raw: '"path"' // } const source = node.source.value; this.sources.add(source); for (const specifier of node.specifiers) { const localName = specifier.local.name; // 重复引入了 if (this.importDescriptions[localName]) { return this.error( { code: 'DUPLICATE_IMPORT', message: `Duplicated import '${localName}'` }, specifier.start ); } const isDefault = specifier.type === NodeType.ImportDefaultSpecifier; const isNamespace = specifier.type === NodeType.ImportNamespaceSpecifier; const name = isDefault ? 'default' : isNamespace ? '*' : (specifier as ImportSpecifier).imported.name; // 导入的模块的相关描述 this.importDescriptions[localName] = { module: null as any, // filled in later name, source, start: specifier.start }; } } ``` #### 总结 感觉这次写的不好,看下来可能会觉得只是标记与收集的这么一个过程,但是其内部细节是非常复杂的。以至于你需要深入了解side effects的定义与影响。日后也许会专门整理一下。 rollup系列也快接近尾声了,虽然一直在自嗨,但是也蛮爽的。 学习使我快乐,哈哈~~ ![](https://user-gold-cdn.xitu.io/2020/4/6/1714fc78404f1734?w=218&h=240&f=jpeg&s=10705)

加载全部内容

相关教程
猜你喜欢
用户评论