原来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)
加载全部内容