作用域提升
从历史上看,JavaScript 打包器的工作原理是将每个模块包装在一个函数中,该函数在导入模块时被调用。这确保每个模块都有一个独立的隔离作用域,并且副作用在预期的时间运行,并支持诸如 热模块替换 之类的开发功能。但是,所有这些独立的函数都会带来成本,包括下载大小和 运行时性能 方面。
在生产构建中,Parcel 在可能的情况下将模块连接到单个作用域中,而不是将每个模块包装在单独的函数中。这称为 **“作用域提升”**。这有助于使缩小更有效,并且通过使模块之间的引用成为静态引用而不是动态对象查找来提高运行时性能。
Parcel 还静态分析每个模块的导入和导出,并删除所有未使用的内容。这称为 **“树摇”** 或 **“死代码消除”**。树摇支持静态和 动态导入、CommonJS 和 ES 模块,甚至跨语言使用 CSS 模块。
作用域提升的工作原理
#Parcel 的作用域提升实现通过独立地并行分析每个模块,并在最后将它们连接在一起。为了使连接到单个作用域安全,每个模块的顶层变量都被重命名以确保它们是唯一的。此外,导入的变量被重命名以匹配从解析的模块中导出的变量名。最后,任何未使用的导出都被删除。
编译成类似于以下内容
function $fa6943ce8a6b29$add(a, b) {
return a + b;
}
console.log($fa6943ce8a6b29$add(2, 3));
如您所见,add
函数已被重命名,并且引用已更新以匹配。square
函数已被删除,因为它未被使用。
这将产生比每个模块都包装在一个函数中更小、更快的输出。不仅没有额外的函数,而且也没有 exports
对象,并且对 add
函数的引用是静态的,而不是属性查找。
避免退出
#Parcel 可以静态分析许多模式,包括 ES 模块 import
和 export
语句、CommonJS require()
和 exports
赋值、动态 import()
解构和属性访问等等。但是,当遇到无法在构建时静态分析的代码时,Parcel 可能不得不“退出”并将模块包装在一个函数中,以保留副作用或允许在运行时解析导出。
要确定为什么树摇没有按预期发生,请使用 --log-level verbose
CLI 选项运行 Parcel。这将打印每个发生的退出的诊断信息,包括显示导致退出的代码帧。
parcel build src/app.html --log-level verbose
动态成员访问
#Parcel 可以静态解析在构建时已知的成员访问,但是当使用动态属性访问时,模块的所有导出都必须包含在构建中,并且 Parcel 必须创建一个导出对象,以便可以在运行时解析该值。
import * as math from './math';
// ✅ Static property access
console.log(math.add(2, 3));
// 🚫 Dynamic property access
console.log(math[op](2, 3));
此外,Parcel 不会跟踪命名空间对象到另一个变量的重新赋值。对导入命名空间的任何使用(除了静态属性访问之外)都会导致所有导出都被包含。
import * as math from './math';
// 🚫 Reassignment of import namespace
let utils = math;
console.log(utils.add(2, 3));
// 🚫 Unknown usage of import namespace
doSomething(math);
动态导入
#Parcel 支持使用静态属性访问或解构对动态导入进行树摇。这在 await
和 Promise then
语法中都受支持。但是,如果从 import()
返回的 Promise 以任何其他方式访问,Parcel 必须保留解析的模块的所有导出。
// ✅ Destructuring await
let {add} = await import('./math');
// ✅ Static member access of await
let math = await import('./math');
console.log(math.add(2, 3));
// ✅ Destructuring Promise#then
import('./math').then(({add}) => console.log(add(2, 3)));
// ✅ Static member access of Promise#then
import('./math').then(math => console.log(math.add(2, 3)));
// 🚫 Dynamic property access of await
let math = await import('./math');
console.log(math[op](2, 3));
// 🚫 Dynamic property access of Promise#then
import('./math').then(math => console.log(math[op](2, 3)));
// 🚫 Unknown use of returned Promise
doSomething(import('./math'));
// 🚫 Unknown argument passed to Promise#then
import('./math').then(doSomething);
CommonJS
#除了 ES 模块之外,Parcel 还可以分析许多 CommonJS 模块。Parcel 支持对 CommonJS 模块中的 exports
、module.exports
和 this
进行静态赋值。这意味着属性名必须在构建时静态地已知(即不是变量)。
当看到非静态模式时,Parcel 会创建一个 exports
对象,所有导入的模块都在运行时访问该对象。所有导出都必须包含在最终构建中,并且无法执行树摇。
// ✅ Static exports assignments
exports.foo = 2;
module.exports.foo = 2;
this.foo = 2;
// ✅ module.exports assignment
module.exports = 2;
// 🚫 Dynamic exports assignments
exports[someVar] = 2;
module.exports[someVar] = 2;
this[someVar] = 2;
// 🚫 Exports re-assignment
let e = exports;
e.foo = 2;
// 🚫 Module re-assignment
let m = module;
m.exports.foo = 2;
// 🚫 Unknown exports usage
doSomething(exports);
doSomething(this);
// 🚫 Unknown module usage
doSomething(module);
在导入方面,Parcel 支持对 require
调用的静态属性访问和解构。当看到非静态访问时,解析的模块的所有导出都必须被包含,并且无法执行树摇。
// ✅ Static property access
const math = require('./math');
console.log(math.add(2, 3));
// ✅ Static destructuring
const {add} = require('./math');
// ✅ Static property assignment
const add = require('./math').add;
// 🚫 Non-static property access
const math = require('./math');
console.log(math[op](2, 3));
// 🚫 Inline require
doSomething(require('./math'));
console.log(require('./math').add(2, 3));
避免 eval
#eval
函数在当前作用域内的字符串中执行任意 JavaScript 代码。这意味着 Parcel 无法重命名作用域内的任何变量,以防它们被 eval
访问。在这种情况下,Parcel 必须将模块包装在一个函数中,并避免缩小变量名。
let x = 2;
// 🚫 Eval causes wrapping and disables minification
eval('x = 4');
如果您需要从字符串运行 JavaScript 代码,您可以使用 Function 构造函数代替。
避免顶层 return
#CommonJS 允许在模块的顶层(即函数之外)使用 return
语句。当看到这种情况时,Parcel 必须将模块包装在一个函数中,以便执行仅停止该模块而不是整个包。此外,树摇被禁用,因为导出可能无法静态地已知(例如,如果返回是条件性的)。
exports.foo = 2;
if (someCondition) {
// 🚫 Top-level return causes wrapping and disables tree shaking
return;
}
exports.bar = 3;
避免 module
和 exports
重新赋值
#当 CommonJS module
或 exports
变量被重新赋值时,Parcel 无法静态分析模块的导出。在这种情况下,模块必须被包装在一个函数中,并且树摇被禁用。
exports.foo = 2;
// 🚫 Exports reassignment causes wrapping and disables tree shaking
exports = {};
exports.foo = 5;
避免条件 require()
#与 ES 模块 import
语句不同,ES 模块 import
语句只能在模块的顶层使用,require
是一个可以在任何地方调用的函数。但是,当 require
从条件语句或其他控制流语句中调用时,Parcel 必须将解析的模块包装在一个函数中,以便副作用在正确的时间执行。这也递归地适用于解析的模块的任何依赖项。
// 🚫 Conditional requires cause recursive wrapping
if (someCondition) {
require('./something');
}
副作用
#许多模块只包含声明,例如函数或类,但有些模块也可能包含 **副作用**。例如,模块可能会将某些内容插入 DOM,将某些内容记录到控制台,将某些内容分配给全局变量(即 polyfill),或初始化单例。这些副作用必须始终保留,以便程序正常工作,即使模块的导出未被使用。
默认情况下,Parcel 包含所有模块,这确保副作用始终运行。但是,package.json
中的 sideEffects
字段可用于向 Parcel 和其他工具提供有关您的文件是否包含副作用的提示。这对库在其 package.json
文件中包含此字段最有意义。
sideEffects
字段支持以下值
false
– 此包中的所有文件都没有副作用。string
– 与包含副作用的文件匹配的 glob。Array<string>
– 与包含副作用的文件匹配的 glob 数组。
当文件被标记为无副作用时,Parcel 能够在连接包时跳过整个文件,如果它没有任何使用的导出。这可以显着减少包大小,尤其是如果模块在其初始化期间调用辅助函数。
在这种情况下,只使用了 math
库中的 add
函数。multiply
和 elapsed
未被使用。通常,loaded
变量仍然需要,因为它包含在模块初始化期间运行的副作用。但是,由于 package.json
包含 sideEffects
字段,因此可以完全跳过 index.js
模块。
除了大小优势之外,使用 sideEffects
字段还具有构建性能优势。在上面的示例中,由于 Parcel 知道 multiply.js
没有副作用,并且它的导出都没有被使用,因此它根本不会被编译。但是,如果使用了 export *
,则情况并非如此,因为 Parcel 将不知道哪些导出可用。
sideEffects
的另一个好处是它也适用于打包。如果模块导入 CSS 文件或包含动态 import()
,则如果模块未被使用,则不会创建包。
PURE 注释
#您还可以使用 /*#__PURE__*/
注释来注释单个函数调用,这告诉缩小器当结果未被使用时可以安全地删除该函数调用。
export const radius = 23;
export const circumference = /*#__PURE__*/ calculateCircumference(radius);
在此示例中,如果 circumference
导出未被使用,则 calculateCircumference
函数也不会被包含。如果没有 PURE 注释,calculateCircumference
仍然会被调用,以防它有副作用。