原來rollup這麼簡單之 tree shaking篇

你們好,我是小雨小雨,致力於分享有趣的、實用的技術文章。 內容分爲翻譯和原創,若是有問題,歡迎隨時評論或私信,但願和你們一塊兒進步。 分享不易,但願可以獲得你們的支持和關注。javascript

計劃

rollup系列打算一章一章的放出,內容更精簡更專注更易於理解java

目前打算分爲如下幾章:node

TL;DR

es node: 各類語法塊的類,好比if塊,箭頭函數塊,函數調用塊等等webpack

rollup()階段,分析源碼,生成ast tree,對ast tree上的每一個節點進行遍歷,判斷出是否include,是的話標記,而後生成chunks,最後導出。 generate()或者write()階段根據rollup()階段作的標記,進行代碼收集,最後生成真正用到的代碼,這就是tree shaking的基本原理。git

一句話就是,根據side effects的定義,設定es node的include 與 不一樣的es node生成不一樣的渲染(引入到magic string實例)函數,有magic string進行收集,最後寫入。github

本文沒有具體分析各es node渲染方法和include設定的具體實現,不過有問題歡迎討論,拍磚~web

注意點

!!!版本 => 筆者閱讀的rollup版本爲: 1.32.0數組

!!!提示 => 標有TODO爲具體實現細節,會視狀況分析。緩存

!!!注意 => 每個子標題都是父標題(函數)內部實現sass

!!!強調 => 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等)的構造基類

流程解析

此次就不全流程解析了,我們舉個最簡單的例子分析一下,更有助於理解。

好比咱們有這麼一段簡單的代碼:

function test() {
    var name = 'test';
    console.log(123);
}
const name = '測試測試';
function fn() {
    console.log(name);
}

fn();
複製代碼

如你所見,打包的結果應該是不包含test函數的,像下面這樣:

'use strict';

const name = '測試測試';
function fn() {
    console.log(name);
}

fn();
複製代碼

那rollup是怎麼處理這段代碼的呢?

  • 模塊解析

還得回到了rollup()流程,根據例子,咱們能夠把對importexportre-export等相關的都幹掉,暫時不須要關注,瞭解最基本的流程後會水到渠成的。 關於插件,我也不會使用任何插件,只使用rollup內置的默認插件。

對於這個例子來講,首先會根據解析文件地址,獲取文件真正的路徑:

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模塊,設置緩存等:

const module: Module = new Module(
    this.graph,
    id,
    moduleSideEffects,
    syntheticNamedExports,
    isEntry
);
複製代碼

以後經過內置的load鉤子獲取文件內容,固然咱也能夠自定義該行爲:

// 第二個參數是 傳給load鉤子函數的 參數,內部使用的apply
return Promise.resolve(this.pluginDriver.hookFirst('load', [id]))
複製代碼

以後通過transform(transform這裏能夠理解爲webpack的各類loader,處理不一樣類型文件的)處理生成一段標準化的結構:

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上:

// 生成 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);
        }
    }
}
複製代碼

後面處理相關依賴模塊,直接跳過咯~

return this.fetchAllDependencies(module).then();
複製代碼

到目前爲止,咱們將文件轉換成了模塊,並解析出 es tree node 以及其內部包含的各種型的語法樹

  • 使用PathTracker追蹤上下文關係
for (const module of this.modules) {
    // 每一個一個節點本身的實現,不是全都有
    module.bindReferences();
}
複製代碼

好比咱們有箭頭函數,因爲沒有this指向因此默認設置UNKONW

// 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方法

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實現,可自身觀摩學習。能夠經過該篇文章,簡單瞭解一些 side effects。

  • chunks的生成

後面就是經過模塊,生成chunks,固然其中還包含多chunk,少chunks等配置選項的區別,這裏再也不贅述,有興趣的朋友能夠參考本系列第一篇文章或者直接查看帶註釋的源碼

  • 經過chunks生成代碼(字符串)

調用rollup方法後,會返回一個對象,其中包括了代碼生成和寫入操做的write方法(已去掉一些warn等):

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字段,作了一個簡單的判斷,好比:

// 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,對代碼字符串進行處理,而後傳遞給renderChunk方法,該方法主要爲了調用renderChunktransformChunktransformBundle三個鉤子函數,對結果進行進一步處理。不過因爲我分析的版本不是最新的,因此會與當前2.x有出入,改動詳見changlog

對了,還有sourceMap,這個能力是magic string提供的,可自行查閱

這樣咱們就獲得了最終想要的結果:

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模塊提供的能力。

function writeOutputFile( build: RollupBuild, outputFile: OutputAsset | OutputChunk, outputOptions: OutputOptions, outputPluginDriver: PluginDriver ): Promise<void> {
	const fileName = resolve(outputOptions.dir || dirname(outputOptions.file!), outputFile.fileName);
	let writeSourceMapPromise: Promise<void>;
	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(() => {});
}
複製代碼
  • 其餘 對於importexportre-export這類ast node,rollup會解析生成的ast tree,獲取其中的value,也就是模塊名,組合有用的信息備用。而後就和上述流程相似了。

推薦使用ast explorer解析一段code,而後看看裏面的結構,瞭解後會更容易理解。

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系列也快接近尾聲了,雖然一直在自嗨,可是也蠻爽的。

學習使我快樂,哈哈~~

相關文章
相關標籤/搜索