作用域提升

历史上,JavaScript 打包器通过将每个模块包装在一个函数中工作,该函数在导入模块时被调用。这确保每个模块都有一个单独的隔离作用域,副作用在预期的时间运行,并支持开发功能,如热模块替换。然而,所有这些单独的函数都有成本,无论是在下载大小还是运行时性能方面。

在生产构建中,Parcel 尽可能将模块连接到单个作用域,而不是将每个模块包装在单独的函数中。这称为**"作用域提升"**。这有助于使压缩更有效,并通过使模块之间的引用静态而不是动态对象查找来改善运行时性能。

Parcel 还静态分析每个模块的导入和导出,并删除未使用的内容。这称为**"树摇""死代码消除"**。树摇支持静态和动态导入CommonJSES 模块,甚至跨语言的 CSS 模块

作用域提升的工作原理

#

Parcel 的作用域提升实现通过独立且并行地分析每个模块,最后将它们连接在一起。为了使连接到单个作用域安全,重命名每个模块的顶级变量以确保它们是唯一的。此外,导入的变量被重命名以匹配已解析模块的导出变量名。最后,删除任何未使用的导出。

index.js:
import { add } from "./math";

console.log(add(2, 3));
math.js:
export function add(a, b) {
return a + b;
}

export function square(a) {
return a * a;
}

编译为类似于:

function $fa6943ce8a6b29$add(a, b) {
return a + b;
}

console.log($fa6943ce8a6b29$add(2, 3));

如您所见,add 函数已被重命名,并且引用已更新以匹配。由于未使用,square 函数已被删除。

与每个模块都包装在函数中相比,这会生成更小、更快的输出。不仅没有额外的函数,而且也没有 exports 对象,并且对 add 函数的引用是静态的,而不是属性查找。

避免退出

#

Parcel 可以静态分析许多模式,包括 ES 模块 importexport 语句、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";

// ✅ 静态属性访问
console.log(math.add(2, 3));

// 🚫 动态属性访问
console.log(math[op](2, 3));

此外,Parcel 不跟踪将命名空间对象重新分配给另一个变量。除静态属性访问外,导入命名空间的任何使用都将导致包含所有导出。

import * as math from "./math";

// 🚫 重新分配导入命名空间
let utils = math;
console.log(utils.add(2, 3));

// 🚫 未知使用导入命名空间
doSomething(math);

动态导入

#

Parcel 支持具有静态属性访问或解构的树摇动态导入。这同时支持 await 和 Promise then 语法。但是,如果以任何其他方式访问 import() 返回的 Promise,Parcel 必须保留已解析模块的所有导出。

**注意:**对于 await 情况,不幸的是,只有当 await 未被转译(即使用现代 browserslist 配置)时,才能删除未使用的导出。

// ✅ 解构 await
let {add} = await import('./math');

// ✅ await 的静态成员访问
let math = await import('./math');
console.log(math.add(2, 3));

// ✅ Promise#then 解构
import('./math').then(({add}) => console.log(add(2, 3)));

// ✅ Promise#then 的静态成员访问
import('./math').then(math => console.log(math.add(2, 3)));

// 🚫 await 的动态属性访问
let math = await import('./math');
console.log(math[op](2, 3));

// 🚫 Promise#then 的动态属性访问
import('./math').then(math => console.log(math[op](2, 3)));

// 🚫 未知使用返回的 Promise
doSomething(import('./math'));

// 🚫 传递给 Promise#then 的未知参数
import('./math').then(doSomething);

CommonJS

#

除了 ES 模块,Parcel 还可以分析许多 CommonJS 模块。Parcel 支持对 CommonJS 模块中的 exportsmodule.exportsthis 进行静态赋值。这意味着属性名必须在构建时静态已知(即不是变量)。

当看到非静态模式时,Parcel 创建一个 exports 对象,所有导入模块在运行时访问该对象。所有导出必须包含在最终构建中,并且无法执行树摇。

// ✅ 静态导出赋值
exports.foo = 2;
module.exports.foo = 2;
this.foo = 2;

// ✅ module.exports 赋值
module.exports = 2;

// 🚫 动态导出赋值
exports[someVar] = 2;
module.exports[someVar] = 2;
this[someVar] = 2;

// 🚫 导出重新赋值
let e = exports;
e.foo = 2;

// 🚫 模块重新赋值
let m = module;
m.exports.foo = 2;

// 🚫 未知导出使用
doSomething(exports);
doSomething(this);

// 🚫 未知模块使用
doSomething(module);

在导入端,Parcel 支持 require 调用的静态属性访问和解构。当看到非静态访问时,必须包含已解析模块的所有导出,并且无法执行树摇。

// ✅ 静态属性访问
const math = require("./math");
console.log(math.add(2, 3));

// ✅ 静态解构
const { add } = require("./math");

// ✅ 静态属性赋值
const add = require("./math").add;

// 🚫 非静态属性访问
const math = require("./math");
console.log(math[op](2, 3));

// 🚫 内联 require
doSomething(require("./math"));
console.log(require("./math").add(2, 3));

避免 eval

#

eval 函数在当前作用域内执行字符串中的任意 JavaScript 代码。这意味着 Parcel 无法重命名作用域内的任何变量,以防它们被 eval 访问。在这种情况下,Parcel 必须将模块包装在函数中并避免压缩变量名。

let x = 2;

// 🚫 Eval 导致包装并禁用压缩
eval("x = 4");

如果需要从字符串运行 JavaScript 代码,您可能可以改用 Function 构造函数。

避免顶级 return

#

CommonJS 允许在模块的顶级(即在函数外)使用 return 语句。当看到这种情况时,Parcel 必须将模块包装在函数中,以便仅停止该模块而不是整个捆绑包。此外,树摇被禁用,因为导出可能无法静态已知(例如,如果返回是条件的)。

exports.foo = 2;

if (someCondition) {
// 🚫 顶级 return 导致包装并禁用树摇
return;
}

exports.bar = 3;

避免 moduleexports 重新赋值

#

当重新分配 CommonJS moduleexports 变量时,Parcel 无法静态分析模块的导出。在这种情况下,模块必须包装在函数中,并且树摇被禁用。

exports.foo = 2;

// 🚫 导出重新赋值导致包装并禁用树摇
exports = {};

exports.foo = 5;

避免条件 require()

#

与仅允许在模块顶层的 ES 模块 import 语句不同,require 是一个可以从任何地方调用的函数。但是,当 require 从条件或其他控制流语句中调用时,Parcel 必须将已解析模块包装在函数中,以便在正确的时间执行副作用。这也递归地应用于已解析模块的任何依赖项。

// 🚫 条件 require 导致递归包装
if (someCondition) {
require("./something");
}

副作用

#

许多模块只包含声明,如函数或类,但有些可能还包含副作用。例如,模块可能将某些内容插入 DOM、记录控制台消息、赋值给全局变量(即 polyfill)或初始化单例。即使模块的导出未使用,这些副作用也必须始终保留,以使程序正常工作。

默认情况下,Parcel 包含所有模块,这确保副作用始终运行。但是,package.json 中的 sideEffects 字段可用于为 Parcel 和其他工具提供关于文件是否包含副作用的提示。这对于库在其 package.json 文件中包含最有意义。

sideEffects 字段支持以下值:

当一个文件被标记为没有副作用时,如果它没有任何被使用的导出,Parcel 就能在合并捆绑包时跳过整个文件。这可以显著减小捆绑包的大小,尤其是当模块在初始化期间调用辅助函数时。

app.js:
import { add } from "math";

console.log(add(2, 3));
node_modules/math/package.json:
{
"name": "math"
"sideEffects": false
}
node_modules/math/index.js:
export { add } from "./add.js";
export { multiply } from "./multiply.js";

let loaded = Date.now();
export function elapsed() {
return Date.now() - loaded;
}

在这个例子中,只使用了 math 库中的 add 函数。multiplyelapsed 是未使用的。通常,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 仍然会被调用,以防它有副作用。