问题的原因其实就已经浮出水面,删除 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'时就给删除了?
问题背景
a 同学运行一个服务端渲染(Server-side rendering,简称 SSR) 项目时抛出了如上的错误,熟悉 SSR 原理的同学应该比较了解这类问题的原因,通常是代码中未区分 Node 环境还是浏览器环境就直接使用了 window、document、XMLHttpRequest 等浏览器环境才有的变量
由于错误堆栈较少,现在需要帮忙定位到有问题的代码,首先就对他的代码进行了删减, 把 App 组件减少到了如下极简的代码发现就不再报错了
接着通过二分法逐步恢复 App 组件的代码,最终发现如下 changeResult 函数代码补充上后就会报错
这里的一个 ⚠️ 可疑点是 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'
时就给删除了?此时 🐛 debug 的方向就是追踪
import { isCompanyDomain } from '@util/router'
代码在 webpack 里面被分析的生命周期,排查是哪一步被剔除了?在 webpack 的分析流程中一个 import 语句首当其冲是被 webpack 内置的 Parser 生成 Dependency Graph 时给遍历与记录。即如下的 prewalkImportDeclaration 函数会把 AST 遍历到的 import 语句通过 hooks 机制给抛出,订阅了该 hooks 事件的插件再进行后续的处理
奇怪的是在此处进行 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'
代码给删除了webpack Tree Shaking
上面说了半天还只是编译器 tsc 的剔除未使用代码的行为,那么 webpack 自己的 Tree Shaking 是如何实现的了?
接下来我们通过如下 demo 代码对 Tree Shaking 的实现原理进行探索,预期是 webpack 打包后的代码会删除
src/test.js
中没有使用到的 testtest1 的代码,而 testtest2 因为有实际被使用到会保留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; } } } }
第三步静态分析
此时 webpack 的静态分析到了
console.log(testtest2, 123)
语句,发现有代码引用到了 testtest2 变量,于是继续通过hooks.expression
勾子抛出当前变量的信息第四步新增一个 Dependency
订阅了
hooks.expression
事件的 HarmonyImportDependencyParserPlugin 插件接收到了 testtest2 变量的信息,并且发现第二步记录信息的 parser.state.harmonySpecifier Map 中刚好记录了 testtest2 的信息, 于是确定了这是一个合法的 import 引用, 最后为src/index.js
模块新增了一个类型为 HarmonyImportSpecifierDependency 的 Dependency第五步开始 Tree Shaking
到第四步我们已经知道 webpack 静态分析
src/index.js
模块时会给被 import 了且被使用到的 testtest2 变量分配一个 HarmonyImportSpecifierDependency。而当 webpack 开始静态分析src/test.js
模块时又会给 testtest1, testtest2 都分配一个 HarmonyExportSpecifierDependencysrc/index.js
模块, 因为 import 引用且使用到了 testtest2,所以为其分配了一个指向 testtest2 变量的 HarmonyImportSpecifierDependencysrc/test.js
模块, 因为源码包含了两个 export 导出语句,所以为导出语句 testtest1, testtest2 变量各分配了一个 HarmonyExportSpecifierDependencysrc/test.js
模块最后生成的代码是按照它包含的两个 HarmonyExportSpecifierDependency 的模版函数的规则决定因为
src/test.js
模块的代码是导出的 testtest2 有被使用(used 的值为 true),导出的 testtest1 未被使用(used 的值为 false),那么按照 getContent 函数生成的代码就是类似如下那么为什么 testtest2 变量 used 的值为 true,下面追溯一下为 true 的原因
src/test.js
模块的 usedExports 的值为["testtest2"]
, 所以isUsed("testtest2")
函数返回为 true,即 used 的值为 truesrc/index.js
,且该 module 有一个 Dependency 即第二个参数 dep 是指向 testtest2 变量的 HarmonyImportSpecifierDependency 时,而 testtest2 变量是src/test.js
模块(referenceModule)的导出, 故后续继续调用 processModule 函数给src/test.js
模块(referenceModule)的 usedExports 赋值为了["testtest2"]
(importedNames )// 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 与 Terser 的行为
接着我们运行如下 Terser 命令来压缩一下 input.js
最后我们发现
const testtest1 = '1oooooooooooooooooo';
这行代码已经不见了,而有 Object.defineProperty 引用的 testtest2 变量的代码就被保留了下来小结