原來rollup這麼簡單之 tree shaking篇
阿新 • • 發佈:2020-04-07
> 大家好,我是小雨小雨,致力於分享有趣的、實用的技術文章。
> 內容分為翻譯和原創,如果有問題,歡迎隨時評論或私信,希望和大家一起進步。
> 分享不易,希望能夠得到大家的支援和關注。
#### 計劃
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/utils/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/blob/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