Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

webpack Tree Shaking 的实现原理 #51

Open
xiaoxiaojx opened this issue Dec 28, 2022 · 1 comment
Open

webpack Tree Shaking 的实现原理 #51

xiaoxiaojx opened this issue Dec 28, 2022 · 1 comment
Labels
webpack A bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting a

Comments

@xiaoxiaojx
Copy link
Owner

xiaoxiaojx commented Dec 28, 2022

image

问题背景

ReferenceError: XMLHttpRequest is not defined

a 同学运行一个服务端渲染(Server-side rendering,简称 SSR) 项目时抛出了如上的错误,熟悉 SSR 原理的同学应该比较了解这类问题的原因,通常是代码中未区分 Node 环境还是浏览器环境就直接使用了 window、document、XMLHttpRequest 等浏览器环境才有的变量

由于错误堆栈较少,现在需要帮忙定位到有问题的代码,首先就对他的代码进行了删减, 把 App 组件减少到了如下极简的代码发现就不再报错了

import * as React from 'react'
import { isCompanyDomain } from '@util/router'

class App extends React.Component {
  render() {
    return <div>hello</div>
  }
}

接着通过二分法逐步恢复 App 组件的代码,最终发现如下 changeResult 函数代码补充上后就会报错

import * as React from 'react'
import { isCompanyDomain } from '@util/router'

class App extends React.Component {
  changeResult() {
    if (isCompanyDomain()) {
      // xxx
    }
  }
  render() {
    return <div>hello</div>
  }
}

这里的一个 ⚠️ 可疑点是 changeResult 函数并没有任何地方有调用,为何它成了报错的关键? 如果把 changeResult 函数里面的 isCompanyDomain 代码删除发现也不会再报错

问题的原因其实就已经浮出水面,删除 isCompanyDomain 函数调用的代码,那么 import { isCompanyDomain } from '@util/router' 这行代码极有可能由于 isCompanyDomain is declared but its value is never read. 在打包后就被删除了也就不再有报错,所以问题是 @util/router 文件引用了未区分环境就直接使用 XMLHttpRequest 变量的代码

谁进行了 Tree Shaking

对于 Tree Shaking | webpack 有了解的同学应该知道 webpack 默认只会在 mode: 'production' 时开启 Tree Shaking 功能。那么我们上面分析的 import { isCompanyDomain } from '@util/router' 代码又是谁在 mode: 'development'时就给删除了?

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
 mode: 'production',
};

此时 🐛 debug 的方向就是追踪 import { isCompanyDomain } from '@util/router' 代码在 webpack 里面被分析的生命周期,排查是哪一步被剔除了?

在 webpack 的分析流程中一个 import 语句首当其冲是被 webpack 内置的 Parser 生成 Dependency Graph 时给遍历与记录。即如下的 prewalkImportDeclaration 函数会把 AST 遍历到的 import 语句通过 hooks 机制给抛出,订阅了该 hooks 事件的插件再进行后续的处理

// webpack/lib/Parser.js

class Parser extends Tapable {
	prewalkImportDeclaration(statement) {
		const source = statement.source.value;
		this.hooks.import.call(statement, source);
		for (const specifier of statement.specifiers) {
			const name = specifier.local.name;
			this.scope.renames.set(name, null);
			this.scope.definitions.add(name);
			switch (specifier.type) {
				case "ImportDefaultSpecifier":
					this.hooks.importSpecifier.call(statement, source, "default", name);
					break;
				case "ImportSpecifier":
					this.hooks.importSpecifier.call(
						statement,
						source,
						specifier.imported.name,
						name
					);
					break;
				case "ImportNamespaceSpecifier":
					this.hooks.importSpecifier.call(statement, source, null, name);
					break;
			}
		}
	}
}

奇怪的是在此处进行 debug 发现 import { isCompanyDomain } from '@util/router' 语句没有被 prewalkImportDeclaration 函数捕获? 那么说明 webpack Parser 拿到的代码就已经没有了 import { isCompanyDomain } from '@util/router' 这行代码

在 webpack Parser 之前工作的就只能是 loader 了,那么我们 debug 一下 ts-loader 编译完业务代码后的产物,发现产物中这行 import { isCompanyDomain } from '@util/router' 代码就被删除了

此时我们可以使用 tsc 命令来验证一下 TypeScript 编译器的行为, 发现编译后的 output.js 确实把未使用到的 import { testtest1 } from './test' 代码给删除了

// input.js

import { testtest1 } from './test'

console.log(123)
// output.js

console.log(123);
export {};

webpack Tree Shaking

上面说了半天还只是编译器 tsc 的剔除未使用代码的行为,那么 webpack 自己的 Tree Shaking 是如何实现的了?

接下来我们通过如下 demo 代码对 Tree Shaking 的实现原理进行探索,预期是 webpack 打包后的代码会删除 src/test.js 中没有使用到的 testtest1 的代码,而 testtest2 因为有实际被使用到会保留

// src/index.js

import { testtest1, testtest2 } from './test'

console.log(testtest2, 123)
// src/test.js

export const testtest1 = '1oooooooooooooooooo';

export const testtest2 = '2oooooooooooooooooo';

为了防止 ts-loader 编译 src/index.js 时直接把import { testtest1, testtest2 } from './test' 编译成了 import { testtest2 } from './test',这样会让 webpack 过于轻松的分析到未使用的代码, 所以我们可以篡改下 ts-loader 的产物,使得产物依然是import { testtest1, testtest2 } from './test'

第一步静态分析

webpack 第一步先对入口文件 src/index.js 进行静态分析,通过 AST 遍历拿到所有的 import 语句,然后通过 hooks.importSpecifier, hooks.importSpecifier, hooks.importSpecifier 勾子抛出每一个 import 语句的信息,最后真正订阅了这些勾子的插件再开始工作

// webpack/lib/Parser.js

class Parser extends Tapable {
	prewalkImportDeclaration(statement) {
		const source = statement.source.value;
		this.hooks.import.call(statement, source);
		for (const specifier of statement.specifiers) {
			const name = specifier.local.name;
			this.scope.renames.set(name, null);
			this.scope.definitions.add(name);
			switch (specifier.type) {
				case "ImportDefaultSpecifier":
					this.hooks.importSpecifier.call(statement, source, "default", name);
					break;
				case "ImportSpecifier":
					this.hooks.importSpecifier.call(
						statement,
						source,
						specifier.imported.name,
						name
					);
					break;
				case "ImportNamespaceSpecifier":
					this.hooks.importSpecifier.call(statement, source, null, name);
					break;
			}
		}
	}
}

第二步记录信息

如下 HarmonyImportDependencyParserPlugin 插件就订阅了hooks.importSpecifier事件,它会把 import 信息都通过 parser.state.harmonySpecifier 这个 Map 给记录了下来

// webpack/lib/dependencies/HarmonyImportDependencyParserPlugin.js

module.exports = class HarmonyImportDependencyParserPlugin {
	apply(parser) {
		parser.hooks.importSpecifier.tap(
			"HarmonyImportDependencyParserPlugin",
			(statement, source, id, name) => {
				parser.scope.definitions.delete(name);
				parser.scope.renames.set(name, "imported var");
				if (!parser.state.harmonySpecifier) {
					parser.state.harmonySpecifier = new Map();
				}
				parser.state.harmonySpecifier.set(name, {
					source,
					id,
					sourceOrder: parser.state.lastHarmonyImportOrder
				});
				return true;
			}
		);

第三步静态分析

此时 webpack 的静态分析到了console.log(testtest2, 123)语句,发现有代码引用到了 testtest2 变量,于是继续通过hooks.expression勾子抛出当前变量的信息

// webpack/lib/Parser.js

class Parser extends Tapable {
	walkIdentifier(expression) {
		if (!this.scope.definitions.has(expression.name)) {
			const hook = this.hooks.expression.get(
				this.scope.renames.get(expression.name) || expression.name
			);
			if (hook !== undefined) {
				const result = hook.call(expression);
				if (result === true) return;
			}
		}
	}
}

第四步新增一个 Dependency

订阅了hooks.expression事件的 HarmonyImportDependencyParserPlugin 插件接收到了 testtest2 变量的信息,并且发现第二步记录信息的 parser.state.harmonySpecifier Map 中刚好记录了 testtest2 的信息, 于是确定了这是一个合法的 import 引用, 最后为 src/index.js 模块新增了一个类型为 HarmonyImportSpecifierDependency 的 Dependency

// webpack/lib/dependencies/HarmonyImportDependencyParserPlugin.js

module.exports = class HarmonyImportDependencyParserPlugin {
	apply(parser) {
		parser.hooks.expression
			.for("imported var")
			.tap("HarmonyImportDependencyParserPlugin", expr => {
				const name = expr.name;
				const settings = parser.state.harmonySpecifier.get(name);
				const dep = new HarmonyImportSpecifierDependency(
					settings.source,
					parser.state.module,
					settings.sourceOrder,
					parser.state.harmonyParserScope,
					settings.id,
					name,
					expr.range,
					this.strictExportPresence
				);
				dep.shorthand = parser.scope.inShorthand;
				dep.directImport = true;
				dep.loc = expr.loc;
				parser.state.module.addDependency(dep);
				return true;
			});

第五步开始 Tree Shaking

到第四步我们已经知道 webpack 静态分析 src/index.js 模块时会给被 import 了且被使用到的 testtest2 变量分配一个 HarmonyImportSpecifierDependency。而当 webpack 开始静态分析 src/test.js 模块时又会给 testtest1, testtest2 都分配一个 HarmonyExportSpecifierDependency

  • 对于 src/index.js 模块, 因为 import 引用且使用到了 testtest2,所以为其分配了一个指向 testtest2 变量的 HarmonyImportSpecifierDependency
  • 对于 src/test.js 模块, 因为源码包含了两个 export 导出语句,所以为导出语句 testtest1, testtest2 变量各分配了一个 HarmonyExportSpecifierDependency

src/test.js 模块最后生成的代码是按照它包含的两个 HarmonyExportSpecifierDependency 的模版函数的规则决定

// webpack/lib/dependencies/HarmonyExportSpecifierDependency.js

HarmonyExportSpecifierDependency.Template = class HarmonyExportSpecifierDependencyTemplate {
	getContent(dep) {
		const used = dep.originModule.isUsed(dep.name);
		if (!used) {
			return `/* unused harmony export ${dep.name || "namespace"} */\n`;
		}
		const exportsName = dep.originModule.exportsArgument;

		return `/* harmony export (binding) */ __webpack_require__.d(${exportsName}, ${JSON.stringify(
			used
		)}, function() { return ${dep.id}; });\n`;
	}
};

因为 src/test.js 模块的代码是导出的 testtest2 有被使用(used 的值为 true),导出的 testtest1 未被使用(used 的值为 false),那么按照 getContent 函数生成的代码就是类似如下

// src/test.js

const testtest1 = '1oooooooooooooooooo';

const testtest2 = '2oooooooooooooooooo';

/* unused harmony export testtest1 */

/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "testtest2", function() { return testtest2; });

那么为什么 testtest2 变量 used 的值为 true,下面追溯一下为 true 的原因

  1. 因为 src/test.js 模块的 usedExports 的值为 ["testtest2"], 所以isUsed("testtest2") 函数返回为 true,即 used 的值为 true
  2. 模块的 usedExports 的值是 processDependency 函数运行时进行的赋值,processDependency 函数会对 webpack 分析到的所有文件模块 module 进行遍历。当 processDependency 函数遍历到的 module 是 src/index.js,且该 module 有一个 Dependency 即第二个参数 dep 是指向 testtest2 变量的 HarmonyImportSpecifierDependency 时,而 testtest2 变量是 src/test.js 模块(referenceModule)的导出, 故后续继续调用 processModule 函数给 src/test.js 模块(referenceModule)的 usedExports 赋值为了 ["testtest2"](importedNames )
// 1
// webpack/lib/Module.js
isUsed(exportName) {
    // ...
    let idx = this.usedExports.indexOf(exportName);
    if (idx < 0) return false;
    // ...
    return exportName;
}

// 2
// webpack/lib/FlagDependencyUsagePlugin.js
// 当遍历到 module 为 src/index.js, dep 为指向 testtest2 变量的 HarmonyImportSpecifierDependency 时
const processDependency = (module, dep) => {
  const reference = compilation.getDependencyReference(module, dep)
  if (!reference) return
  // 此时的 referenceModule 为 src/test.js 模块, 即导出 testtest2 变量的模块
  const referenceModule = reference.module
  // importedNames 为第四步新增的 HarmonyImportSpecifierDependency 的 name 值为 testtest2
  const importedNames = reference.importedNames
  const oldUsed = referenceModule.used
  const oldUsedExports = referenceModule.usedExports
  if (
    !oldUsed ||
    (importedNames &&
      (!oldUsedExports || !isSubset(oldUsedExports, importedNames)))
  ) {
    // 调用 processModule 函数
    processModule(referenceModule, importedNames)
  }
}
// 给 src/test.js 模块的 usedExports 数组新增 "testtest2" 元素, 即 usedExports 的值为 ["testtest2"]
const processModule = (module, usedExports) => {
	module.used = true;
	if (module.usedExports === true) return;
	if (usedExports === true) {
		module.usedExports = true;
	} else if (Array.isArray(usedExports)) {
		const old = module.usedExports ? module.usedExports.length : -1;
		module.usedExports = addToSet(
			module.usedExports || [],
			usedExports
		);
		if (module.usedExports.length === old) {
			return;
		}
	}
    // ...
};

第六步删除未使用代码

到了第五步我们发现 webpack 已经确定了哪些导出有被使用到,哪些导出没有被使用,且为生成的代码作了标记。那么未使用到的const testtest1 = '1oooooooooooooooooo';这行代码是被谁给删除的了?

其实这里是 TerserPlugin 进行的删除, 因为 Terser 的功能本身也是压缩代码,删除冗余代码,这也体现了单一职责原则

// webpack/lib/WebpackOptionsDefaulter.js

this.set("optimization.minimizer", "make", options => [
	{
		apply: compiler => {
			// Lazy load the Terser plugin
			// const TerserPlugin = require("terser-webpack-plugin");
			// const SourceMapDevToolPlugin = require("./SourceMapDevToolPlugin");
			new TerserPlugin({
				cache: true,
				parallel: true,
				sourceMap:
					(options.devtool && /source-?map/.test(options.devtool)) ||
					(options.plugins &&
						options.plugins.some(p => p instanceof SourceMapDevToolPlugin))
			}).apply(compiler);
		}
	}
]);

让我们手写一个简版的打包后的代码来看清楚 webpack 与 Terser 的行为

// input.js

const exports = {
  './src/test.js': (m) => {
    const testtest1 = '1oooooooooooooooooo'
    const testtest2 = '2oooooooooooooooooo'

    // 如下模拟 webpack 的标记代码
    Object.defineProperty(m, 'testtest2', {
      value: testtest2
    })
  }
}

console.log(exports)

接着我们运行如下 Terser 命令来压缩一下 input.js

terser input.js --compress ecma=2015,computed_props=false -o output.js

最后我们发现const testtest1 = '1oooooooooooooooooo';这行代码已经不见了,而有 Object.defineProperty 引用的 testtest2 变量的代码就被保留了下来

// output.js

const exports = {
  './src/test.js': (m) => {
    Object.defineProperty(m, 'testtest2', { value: '2oooooooooooooooooo' })
  }
}
console.log(exports)

小结

  • tsc, esbuild, babel 等编译器会自动删除未使用到的代码
  • webpack Tree Shaking 的实现原理是 webpack 依赖分析时记录模块的导出是否有被使用,然后在生成后的代码中进行了标记,最后 Terser 把未被标记的导出即未使用到的代码给删除
@xiaoxiaojx xiaoxiaojx added the webpack A bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting a label Dec 28, 2022
@hai-x
Copy link

hai-x commented Feb 22, 2024

再次阅读,受益匪浅。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
webpack A bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting a
Projects
None yet
Development

No branches or pull requests

2 participants