Optimizable Features: Barrel Files
Reference Materials
Speeding up the JavaScript ecosystem - The barrel file debacle
Definition Of Barrel Files
barrel files
是一个文件(通常以 index
命名),仅做重新导出其他文件的引用,用来将多个相关特性的模块整合到一起,对外暴露一个统一的接口。本质上是类似于 npm
,封闭内部结构,提供统一的接口给消费模块。
{
"name": "acorn",
"exports": {
".": [
{
"import": "./dist/acorn.mjs",
"require": "./dist/acorn.js",
"default": "./dist/acorn.js"
},
"./dist/acorn.js"
],
"./package.json": "./package.json"
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
barrel files
仅做目录结构的调整、减少模块的导入路径、优化目录结构的特性,是可接受的。但在实际项目中,barrel files
的使用并不规范,里面的依赖项并非相互独立,而是存在模块间互相依赖(副作用、执行顺序等)、存在大量与模块相关的副作用问题。
我们不能否认 barrel files
带来的便利性,但与此同时,我觉得 bundlers
与其下游运行时相关的工具(例如 vite
、test runner
、browser
、node runtime
等)需要对 barrel files
模块解析上需要做些优化,否则会带来巨大的性能开销。
Impact Of Bundlers
bundlers
在构建阶段生成完整的模块依赖图后,虽然可以通过启用 tree-shaking
来优化 barrel files
,在最后的产物中仅包含实际依赖的模块,但问题是,bundlers
无法在构建完整的模块依赖图前就可以明确知道哪些模块实际需要使用到。
bundlers
需要递归解析每一个模块,包括 barrel files
。在解析完依赖图中的所有模块之后才会进入 tree-shaking
阶段,在 tree-shaking
阶段会清理掉实际未引用到的模块(sideEffects = 'false'
)。
也就是说,即使是 barrel files
中的大部分依赖模块实际上并没有被使用到,但在完整模块依赖图解析完成之前(即 tree-shaking
阶段之前),bundlers
会同等解析最终被 tree-shaking
掉的模块和实际需要的模块。
我们站在上帝的视角来看其实上并不希望 bundlers
去解析 barrel files
中依赖的每一个模块,只需要解析实际需要引用的模块。但这对于 bundlers
来说,在完整的模块依赖图解析完成之前,无法获取足够的上下文信息,在解析的过程中确定哪些模块需要使用,那么只能同等对待解析所有的模块。
那么这就导致一个关乎 barrel files
的优化问题。如果 barrel files
模块十分臃肿,里面包含大量来自其他依赖模块的重导出引用且广泛应用于项目中,那么势必会耗费 bundlers
大量的解析时间。
Impact Of Runtime
tree-shaking
是打包工具的特性,而非 javascript
运行时特性。对于依赖运行时的工具来说,臃肿且大量的 barrel files
模块会在执行阶段带来巨大的性能开销。
以 vite
为例,开发阶段依赖浏览器对于 esm
的支持,实现及时编译和渲染的工作。但缺点就是在执行阶段,vite
并未获知完整的模块依赖图,模块没有足够的上下文来执行 tree-shaking
优化。因此当 barrel files
包含复杂副作用的场景下,vite
相比 bundlers
来说,就显得无力。
现阶段当 vite
处理 barrel files
模块时,浏览器会并发请求服务器端,这在大规模的 barrel files
场景下,大量的并发请求对于浏览器来说存在不可控因素,即使有 http 2
的多路复用,大量的并发请求也会显著增加加载时间。若在开发阶段使用 http
请求,即使本地网络延迟极低,不受网络带宽限制,也会带来大量的请求瀑布流,挑战浏览器的上限,存在请求停滞的问题,相比仅在 bundlers
统一在 node
侧处理会更为严重。因此对于依赖运行时的工具来说,更需要对 barrel files
做特殊优化。
vite
擅长于 快速启动,如果耗费大量的性能来语义分析 barrel files
的所有模块,那将本末倒置,会显著增加 vite
的启动时间。在大部分情况下,barrel files
在实际生产阶段中并没有包含复杂的副作用,因此 vite
可以为用户提供提供一个配置项来优化 barrel files
,但用户需确保优化的 barrel files
模块内的依赖项之间相互独立。
Optimize Barrel Files
社区中也广泛使用 barrel files
的特性,我觉得仅做内部模块的重导出,只需要工具来提供路径重写的工作,那么我觉得可以对这类 barrel files
的使用场景做优化。
bundlers
可以提供一个选项,允许用户配置需要优化的 barrel files
文件,前提是用户需要确保 barrel files
模块中不存在 副作用。
换句话说 bundlers
可以帮助用户优化纯净的 barrel files
文件。
Optimization Achieved
Next.js
Optimization
How we optimized package imports in Next.js 文章中,详细介绍了 next.js
是如何优化 barrel files
文件的,并带来显著的性能提升。
为了提速,next.js
内部在 rust
侧设计了专门针对 optimizeBarrelExports
的 swc
转换。该转换器只关心生成 export map
,而不对其他情况做解析。
Rust
Optimization
Prospect Informs
当
importer
使用wildcard export
依赖模块时,那么依赖模块就会进入wildcard
模式。name_str
是当前模块上下文中的引用名称,orig_str
是依赖模块中上下文中的引用名称。直接导出
jsexport { foo } from './foo';
1name_str
与orig_str
都是foo
。重命名导出
jsexport { foo as bar } from './foo';
1name_str
是bar
,orig_str
是foo
。通配符导出
jsexport * as utils from './utils';
1name_str
是utils
,orig_str
是*
。
export_map
export_map
是一个元组数组,记录export
语句的导出信息。rust// Exported meta information. let mut export_map: Vec<(String, String, String)> = vec![];
1
2每个元组包含三个元素:(
name_str
,src
,orig_str
)其中
src
如果是从其他文件导入再导出,则记录的是源文件路径。而如果是在模块中直接导出,则是空字符串。命名空间导出 (ExportSpecifier::Namespace):
js// 代码 export * as utils from './utils'; // export_map 记录 export_map.push( ('utils', // name_str: 导出的名称 './utils', // src: 源文件路径 '*') // orig_str: 使用 "*" 表示是整个命名空间 );
1
2
3
4
5
6
7
8
9具名导出 (ExportSpecifier::Named):
js// 情况1:重导出源文件的引用。 export { foo } from './foo'; export_map.push( ('foo', // name_str: 导出的名称 './foo', // src: 源文件路径 'foo') // orig_str: 原始名称 ); // 情况2:重导出源文件的引用,并为其命名。 export { foo as bar } from './foo'; export_map.push( ('bar', // name_str: 新的导出名称 './foo', // src: 源文件路径 'foo') // orig_str: 原始名称 ); // 情况3:模块的 import 声明标识符导出。 import { foo } from './source.js'; export { foo }; export_map.push( ('foo', // name_str: 导出名称 './source.js', // src: 原始源文件路径 'foo') // orig_str: 原始名称 );
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24导出声明 (ExportDecl):
js// 情况1:函数声明 export function hello() {} // export_map 记录 'hello', // name_str: 函数名 '', // src: 空字符串,因为是本地声明 ''; // orig_str: 空字符串,因为是本地声明 // 情况2:类声明 export class MyClass {} // export_map 记录 'MyClass', // name_str: 类名 '', // src: 空字符串 ''; // orig_str: 空字符串 // 情况3:变量声明 export const x = 42; // export_map 记录 'x', // name_str: 变量名 '', // src: 空字符串 ''; // orig_str: 空字符串
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name_str
:
- 模块上下文中声明的变量标识符,最终导出时使用的名称
src
:
- 如果是从其他模块导入再导出,则为源文件路径
- 如果是模块声明,则为空字符串
orig_str
:
- 如果是具名导出,则为原始名称
- 如果是命名空间导出,则为
*
- 如果是模块声明,则为空字符串
这个 export_map
的设计允许:
- 追踪每个导出的来源
- 知道导出是否被重命名
- 区分本地声明的导出和重导出
- 识别命名空间导出
Namespace Export
首先处理 ExportSpecifier::Namespace
的情况:
定义 name_str
为当前模块上下文中导出引用名称。
ExportSpecifier::Namespace(s) => {
let name_str = match &s.name {
ModuleExportName::Ident(n) => n.sym.to_string(),
ModuleExportName::Str(n) => n.value.to_string(),
};
}
2
3
4
5
6
分三种情况讨论:
if let Some(src) = &export_named.src {
// case1: 有源文件的情况
export_map.push((name_str.clone(), src.value.to_string(), "*".to_string()));
} else if self.wildcard {
// case2: 无源文件且模块处于 wildcard 模式
export_map.push((name_str.clone(), "".into(), "*".to_string()));
} else {
// case3: 无源文件且模块处于非 wildcard 模式
is_barrel = false;
break;
}
2
3
4
5
6
7
8
9
10
11
并通过 export_map
记录导出映射。
// case1: export 语句有源文件
// 符合 `barrel files` 规范
export * as utils from './utils';
// case2: export 语句无源文件
import * as utils from './utils';
// 模块在 wildcard 模式下判定符合 `barrel files` 规范。
export { utils };
// case3: export 语句无源文件
import * as utils from './utils';
// 模块在非 wildcard 模式下判定不符合 `barrel files` 规范。
export { utils };
2
3
4
5
6
7
8
9
10
11
12
13
Named Export
定义导出引用名称,分为 orig_str
和 name_str
。
let orig_str = match &s.orig {
ModuleExportName::Ident(n) => n.sym.to_string(),
ModuleExportName::Str(n) => n.value.to_string(),
};
let name_str = match &s.exported {
Some(n) => match &n {
ModuleExportName::Ident(n) => n.sym.to_string(),
ModuleExportName::Str(n) => n.value.to_string(),
},
None => orig_str.clone(),
};
2
3
4
5
6
7
8
9
10
11
其中 orig_str
是 export
语句中导出的 原始引用名称,name_str
是 export
语句中导出的 新引用名称。
根据具名导出的方式,收集不同信息到 export_map
中:
if let Some(src) = &export_named.src {
// case1: 重导出其他模块的引用。
export_map.push((name_str.clone(), src.value.to_string(), orig_str.clone()));
} else if let Some((src, orig)) = local_idents.get(&orig_str) {
// case2: 直接导出自身模块声明的引用。
export_map.push((name_str.clone(), src.clone(), orig.clone()));
} else if self.wildcard {
// case3: wildcard 模式下允许其他导出方式。
export_map.push((name_str.clone(), "".into(), orig_str.clone()));
} else {
// case4: 非 wildcard 模式下不允许其他导出方式。
is_barrel = false;
break;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
// case1: 重导出其他模块的引用。
export { foo } from './foo';
export { bar as baz } from './bar';
// case2: 直接导出自身模块的引用。
import { foo } from './foo';
export { foo };
// case3: wildcard 模式下的其他导出
const x = 42;
export { x }; // 仅在 wildcard 模式下允许
// case4: 非 wildcard 模式下的其他导出
const x = 42;
export { x }; // 在非 wildcard 模式下不允许
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Wildcard Export
ModuleDecl::ExportAll(export_all) => {
export_wildcards.push(export_all.src.value.to_string());
}
2
3
这处理的是 *
通配符导出的情况:
export * from './foo';
这总是被允许的,因为它是典型的 barrel files
行为。
完整的实现逻辑如下:
// code path: next.js/crates/next-custom-transforms/src/transforms/optimize_barrel.rs
let mut is_barrel = true;
for item in &items {
match item {
ModuleItem::ModuleDecl(decl) => {
allowed_directives = false;
match decl {
ModuleDecl::Import(_) => {}
// export { foo } from './foo';
ModuleDecl::ExportNamed(export_named) => {
for spec in &export_named.specifiers {
match spec {
ExportSpecifier::Namespace(s) => {
let name_str = match &s.name {
ModuleExportName::Ident(n) => n.sym.to_string(),
ModuleExportName::Str(n) => n.value.to_string(),
};
if let Some(src) = &export_named.src {
export_map.push((
name_str.clone(),
src.value.to_string(),
"*".to_string(),
));
} else if self.wildcard {
export_map.push((
name_str.clone(),
"".into(),
"*".to_string(),
));
} else {
is_barrel = false;
break;
}
}
ExportSpecifier::Named(s) => {
let orig_str = match &s.orig {
ModuleExportName::Ident(n) => n.sym.to_string(),
ModuleExportName::Str(n) => n.value.to_string(),
};
let name_str = match &s.exported {
Some(n) => match &n {
ModuleExportName::Ident(n) => n.sym.to_string(),
ModuleExportName::Str(n) => n.value.to_string(),
},
None => orig_str.clone(),
};
if let Some(src) = &export_named.src {
export_map.push((
name_str.clone(),
src.value.to_string(),
orig_str.clone(),
));
} else if let Some((src, orig)) =
local_idents.get(&orig_str)
{
export_map.push((
name_str.clone(),
src.clone(),
orig.clone(),
));
} else if self.wildcard {
export_map.push((
name_str.clone(),
"".into(),
orig_str.clone(),
));
} else {
is_barrel = false;
break;
}
}
_ => {
if !self.wildcard {
is_barrel = false;
break;
}
}
}
}
}
ModuleDecl::ExportAll(export_all) => {
export_wildcards.push(export_all.src.value.to_string());
}
ModuleDecl::ExportDecl(export_decl) => {
// Export declarations are not allowed in barrel files.
if !self.wildcard {
is_barrel = false;
break;
}
match &export_decl.decl {
Decl::Class(class) => {
export_map.push((
class.ident.sym.to_string(),
"".into(),
"".into(),
));
}
Decl::Fn(func) => {
export_map.push((
func.ident.sym.to_string(),
"".into(),
"".into(),
));
}
Decl::Var(var) => {
let ids = collect_idents_in_var_decls(&var.decls);
for id in ids {
export_map.push((id, "".into(), "".into()));
}
}
_ => {}
}
}
_ => {
if !self.wildcard {
// Other expressions are not allowed in barrel files.
is_barrel = false;
break;
}
}
}
}
ModuleItem::Stmt(stmt) => match stmt {
Stmt::Expr(expr) => match &*expr.expr {
Expr::Lit(l) => {
if let Lit::Str(s) = l {
if allowed_directives && s.value.starts_with("use ") {
directives.push(s.value.to_string());
}
} else {
allowed_directives = false;
}
}
_ => {
allowed_directives = false;
if !self.wildcard {
is_barrel = false;
break;
}
}
},
_ => {
allowed_directives = false;
if !self.wildcard {
is_barrel = false;
break;
}
}
},
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
Non-Wildcard Mode
在非 wildcard
模式下,它严格要求所有导出都必须是从其他模块重新导出的形式。
在非 wildcard
模式下(即 self.wildcard = false
),决策模块是否符合 barrel files
规范的逻辑如下:
命名空间导出(
ExportSpecifier::Namespace
)时:js// ❌ 没有源文件的命名空间导出,判定模块不符合 `barrel files` 规范。 import * as utils from './utils'; export { utils }; // ✅ 有源文件的命名空间导出是允许的,符合 `barrel files` 规范。 export * as utils from './utils';
1
2
3
4
5
6具名导出(
ExportSpecifier::Named
)时:js// ✅ 模块先导入后导出统一引用,符合 `barrel files` 规范。 import { foo } from './foo'; export { foo }; // ✅ 模块重导出其他模块的导出,符合 `barrel files` 规范。 export { foo } from './foo'; // ❌ 模块上下文声明的变量标识符导出,不符合 `barrel files` 规范。 const x = 42; export { x };
1
2
3
4
5
6
7
8
9
10导出声明(
ExportDecl
)时:js// ❌ 模块上下文函数声明导出,不符合 `barrel files` 规范。 export function foo() {} // ❌ 模块上下文类声明导出,不符合 `barrel files` 规范。 export class MyClass {} // ❌ 模块上下文变量声明导出,不符合 `barrel files` 规范。 export const x = 42;
1
2
3
4
5
6
7
8其他模块声明时:
js// ❌ 模块上下文默认导出声明,不符合 `barrel files` 规范。 export default function () {}
1
2非字符串字面量的表达式语句:
js// ❌ 普通表达式,不符合 `barrel files` 规范。 console.log('hello'); // ❌ 非指令(例如 `"use strict"`)的字面量,不符合 `barrel files` 规范。 42;
1
2
3
4
5
总的来说,在非 wildcard
模式下,一个合法的 barrel file
只能包含:
- 使用
export
语句重导出其他模块。 - 使用
import
语句先导入后导出。 - 使用
"use strict"
指令字符串。
Wildcard Mode
在 wildcard
模式下,它允许更灵活的导出模式。
命名空间导出(
ExportSpecifier::Namespace
):js// ✅ 命名空间重导出源模块,符合 `barrel files` 规范。 export * as utils from './utils'; // ✅ 符合 `barrel files` 规范。 import * as helpers from './helpers'; export { helpers };
1
2
3
4
5
6具名导出(
ExportSpecifier::Named
):js// ✅ 具名重导出源模块,符合 `barrel files` 规范。 export { foo } from './foo'; // ✅ 先导入后导出,符合 `barrel files` 规范。 import { bar } from './bar'; export { bar }; // ✅ 具名重导出模块声明的标识符,符合 `barrel files` 规范。 const x = 42; export { x }; // ✅ 重命名导出模块声明的标识符,符合 `barrel files` 规范。 export { x as myValue };
1
2
3
4
5
6
7
8
9
10
11
12
13导出声明(
ExportDecl
):js// ✅ 模块上下文函数声明导出,符合 `barrel files` 规范。 export function foo() {} // ✅ 模块上下文类声明导出,符合 `barrel files` 规范。 export class MyClass {} // ✅ 模块上下文变量声明导出,符合 `barrel files` 规范。 export const x = 42; export const y = 'hello'; export var z = true;
1
2
3
4
5
6
7
8
9
10其他模块声明:
js// ✅ 模块上下文默认导出,符合 `barrel files` 规范。 export default function () {} // ✅ 通配符导出,符合 `barrel files` 规范。 export * from './utils';
1
2
3
4
5表达式和语句:
js// ✅ 普通表达式,符合 `barrel files` 规范。 console.log('hello'); // ✅ 字面量,符合 `barrel files` 规范。 42; ('use strict');
1
2
3
4
5
6
总结一下,wildcard
模式的主要特点:
- 允许所有类型的导出(命名、默认、声明等)。
- 允许模块声明并导出。
- 允许表达式和其他语句。
- 不要求必须从其他模块导出。
这种模式实际上放宽了对 barrel file
的定义,使其可以:
- 既作为重导出的集中模块。
- 又可以包含自身模块的导出引用。
- 还可以包含其他代码逻辑。
Rust Side Optimization Summary
如果模块判定为非 barrel files
,那么 next.js
不会导出任何内容。
if !is_barrel {
new_items = vec![];
}
2
3
而如果模块判定为 barrel files
,那么 next.js
会重写模块内容,导出上述收集的 export_map
和 export_wildcards
信息。
if is_barrel {
// Otherwise we export the meta information.
new_items.push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl {
span: DUMMY_SP,
decl: Decl::Var(Box::new(VarDecl {
span: DUMMY_SP,
kind: VarDeclKind::Const,
decls: vec![VarDeclarator {
span: DUMMY_SP,
name: Pat::Ident(BindingIdent {
id: private_ident!("__next_private_export_map__"),
type_ann: None,
}),
init: Some(Box::new(Expr::Lit(Lit::Str(Str {
span: DUMMY_SP,
value: serde_json::to_string(&export_map).unwrap().into(),
raw: None,
})))),
definite: false,
}],
..Default::default()
})),
})));
// Push "export *" statements for each wildcard export.
for src in export_wildcards {
new_items.push(ModuleItem::ModuleDecl(ModuleDecl::ExportAll(ExportAll {
span: DUMMY_SP,
src: Box::new(Str {
span: DUMMY_SP,
value: format!("__barrel_optimize__?names=__PLACEHOLDER__!=!{src}").into(),
raw: None,
}),
with: None,
type_only: false,
})));
}
// Push directives.
if !directives.is_empty() {
new_items.push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl {
span: DUMMY_SP,
decl: Decl::Var(Box::new(VarDecl {
span: DUMMY_SP,
kind: VarDeclKind::Const,
decls: vec![VarDeclarator {
span: DUMMY_SP,
name: Pat::Ident(BindingIdent {
id: private_ident!("__next_private_directive_list__"),
type_ann: None,
}),
init: Some(Box::new(Expr::Lit(Lit::Str(Str {
span: DUMMY_SP,
value: serde_json::to_string(&directives).unwrap().into(),
raw: None,
})))),
definite: false,
}],
..Default::default()
})),
})));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
举一个例子说明
'use client';
import { foo as foo1 } from './foo';
export { bar } from './bar';
export * as utils from './utils';
export * from './utils2';
export { foo1 };
2
3
4
5
6
7
next.js
会重写 index.js
为
export const __next_private_export_map__ = JSON.stringify([
['foo1', './foo', 'foo'],
['bar', './bar', 'bar'],
['utils', './utils', '*']
]);
export * from '__barrel_optimize__?names=__PLACEHOLDER__!=!./utils2';
export const __next_private_directive_list__ = JSON.stringify([
'use client'
]);
2
3
4
5
6
7
8
9
10
11
这个实现通过维护 export_map
和 export_wildcards
两个集合来收集模块的所有导出方式的关系,并使用 is_barrel
标志来判断当前的模块是否符合 barrel files
的定义。在 wildcard
模式下,它允许更灵活的导出模式,而在非 wildcard
模式下,它严格要求所有导出都必须是从其他模块重新导出的形式。
JS
Optimization
在 JS
侧,next.js
实现了 NextBarrelLoader
插件来优化 barrel files
。
// code path: next.js/packages/next/src/build/webpack/loaders/next-barrel-loader.ts
const NextBarrelLoader = async function (
this: webpack.LoaderContext<{
names: string[];
swcCacheDir: string;
}>
) {
// For barrel optimizations, we always prefer the "module" field over the
// "main" field because ESM handling is more robust with better tree-shaking.
const resolve = this.getResolve({
mainFields: ['module', 'main']
});
const mapping = await getBarrelMapping(
this.resourcePath,
swcCacheDir,
resolve,
this.fs
);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
在插件中会调用 getBarrelMapping
来获取模块的导出信息。如果 mapping
为空,则说明该模块不是 barrel files
,结束后续的优化。
if (!mapping) {
// This file isn't a barrel and we can't apply any optimizations. Let's re-export everything.
// Since this loader accepts `names` and the request is keyed with `names`, we can't simply
// return the original source here. That will create these imports with different names as
// different modules instances.
this.callback(null, `export * from ${JSON.stringify(this.resourcePath)}`);
return;
}
2
3
4
5
6
7
8
那么优化的核心逻辑就是 getBarrelMapping
函数,它负责解析 barrel files
的导出信息,并返回一个包含导出信息的对象。
async function getBarrelMapping(
resourcePath: string,
swcCacheDir: string,
resolve: (context: string, request: string) => Promise<string>,
fs: {
readFile: (
path: string,
callback: (err: any, data: string | Buffer | undefined) => void
) => void;
}
) {
// ...
const res = await getMatches(resourcePath, false, false);
barrelTransformMappingCache.set(resourcePath, res);
return res;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在 getBarrelMapping
中,会调用 getMatches
函数来获取 barrel files
的导出信息。当然此处有做缓存,避免重复解析。
async function getMatches(
file: string,
isWildcard: boolean,
isClientEntry: boolean
) {
if (visited.has(file)) {
return null;
}
visited.add(file);
const source = await new Promise<string>((res, rej) => {
fs.readFile(file, (err, data) => {
if (err || data === undefined) {
rej(err);
} else {
res(data.toString());
}
});
});
const output = await transpileSource(file, source, isWildcard);
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Attention
传递给 rust
侧会包含 模块的路径
、源码
、是否启用 wildcard 模式
。
源码是通过 js
侧的 fs.readFile
读取的,这一块逻辑可以进一步优化。
在 getMatches
中,会调用 transpileSource
函数。
// This is a SWC transform specifically for `optimizeBarrelExports`. We don't
// care about other things but the export map only.
async function transpileSource(
filename: string,
source: string,
isWildcard: boolean
) {
const isTypeScript =
filename.endsWith('.ts') || filename.endsWith('.tsx');
return new Promise<string>(res =>
transform(source, {
filename,
inputSourceMap: undefined,
sourceFileName: filename,
optimizeBarrelExports: {
wildcard: isWildcard
},
jsc: {
parser: {
syntax: isTypeScript ? 'typescript' : 'ecmascript',
[isTypeScript ? 'tsx' : 'jsx']: true
},
experimental: {
cacheRoot: swcCacheDir
}
}
}).then(output => {
res(output.code);
})
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
transpileSource
函数中会调用 rust
侧针对 barrel files
的优化逻辑,返回一个解析后的产物,仅包含模块的 export
语句,详情可参考 Rust Side Implementation Details。
Attention
需要注意,用户指定的 barrel files
文件是作为 rust
侧解析的入口模块,不启用 wildcard
模式(即 wildcard = false
)。
// 第二个参数为 false,表示不启用 `wildcard` 模式
const res = await getMatches(resourcePath, false, false);
2
紧接着通过正则匹配 rust
侧返回的产物,获取 exportList
、wildcardExports
、isClientEntry
信息。
async function getMatches(
file: string,
isWildcard: boolean,
isClientEntry: boolean
) {
// ...
const output = await transpileSource(file, source, isWildcard);
const matches = output.match(
/^([^]*)export (const|var) __next_private_export_map__ = ('[^']+'|"[^"]+")/
);
if (!matches) {
return null;
}
const matchedDirectives = output.match(
/^([^]*)export (const|var) __next_private_directive_list__ = '([^']+)'/
);
const directiveList = matchedDirectives
? JSON.parse(matchedDirectives[3])
: [];
// "use client" in barrel files has to be transferred to the target file.
isClientEntry = directiveList.includes('use client');
let exportList = JSON.parse(matches[3].slice(1, -1)) as [
string,
string,
string
][];
const wildcardExports = [
...output.matchAll(/export \* from "([^"]+)"/g)
].map(match => match[1]);
// In the wildcard case, if the value is exported from another file, we
// redirect to that file (decl[0]). Otherwise, export from the current
// file itself.
if (isWildcard) {
for (const decl of exportList) {
decl[1] = file;
decl[2] = decl[0];
}
}
// This recursively handles the wildcard exports (e.g. `export * from './a'`)
if (wildcardExports.length) {
await Promise.all(
wildcardExports.map(async req => {
const targetPath = await resolve(
path.dirname(file),
req.replace('__barrel_optimize__?names=__PLACEHOLDER__!=!', '')
);
const targetMatches = await getMatches(
targetPath,
true,
isClientEntry
);
if (targetMatches) {
// Merge the export list
exportList = exportList.concat(targetMatches.exportList);
}
})
);
}
return {
exportList,
wildcardExports,
isClientEntry
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
若 rust
侧返回的解析产物中存在 wildcard exports
export * from './utils';
会继续递归调用 getMatches
函数
const targetMatches = await getMatches(
targetPath,
true,
isClientEntry
)
2
3
4
5
直到所有 wildcard exports
都被解析完毕。此时可以注意到在解析 wildcard exports
时,第二个参数为 true
,表示启用 wildcard
模式。
最终执行完 getBarrelMapping
函数返回的是用户指定的 barrel files
的导出与导出路径的映射关系和指令信息。
const NextBarrelLoader = async function (
this: webpack.LoaderContext<{
names: string[];
swcCacheDir: string;
}>
) {
const mapping = await getBarrelMapping(
this.resourcePath,
swcCacheDir,
resolve,
this.fs
);
const exportList = mapping.exportList;
const isClientEntry = mapping.isClientEntry;
const exportMap = new Map<string, [string, string]>();
for (const [name, filePath, orig] of exportList) {
exportMap.set(name, [filePath, orig]);
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
获取完 barrel files
的导出与导出路径的映射关系和指令信息后,那么接下来的工作就是按需重写 barrel files
的导入路径。
const NextBarrelLoader = async function (
this: webpack.LoaderContext<{
names: string[];
swcCacheDir: string;
}>
) {
let output = '';
const missedNames: string[] = [];
for (const name of names) {
// If the name matches
if (exportMap.has(name)) {
const decl = exportMap.get(name)!;
if (decl[1] === '*') {
output += `\nexport * as ${name} from ${JSON.stringify(decl[0])}`;
} else if (decl[1] === 'default') {
output += `\nexport { default as ${name} } from ${JSON.stringify(
decl[0]
)}`;
} else if (decl[1] === name) {
output += `\nexport { ${name} } from ${JSON.stringify(decl[0])}`;
} else {
output += `\nexport { ${decl[1]} as ${name} } from ${JSON.stringify(
decl[0]
)}`;
}
} else {
missedNames.push(name);
}
}
// These are from wildcard exports.
if (missedNames.length > 0) {
for (const req of mapping.wildcardExports) {
output += `\nexport * from ${JSON.stringify(
req.replace('__PLACEHOLDER__', missedNames.join(',') + '&wildcard')
)}`;
}
}
// When it has `"use client"` inherited from its barrel files, we need to
// prefix it to this target file as well.
if (isClientEntry) {
output = `"use client";\n${output}`;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
以上就是 next.js
针对 barrel files
的完整优化逻辑,包括 rust
侧的优化、 js
侧的优化和最后的 barrel files
的导入路径重写为具体的模块路径。
Summarize Optimization Of Next.js
next.js
每一次与rust
侧通信时,只会解析模块一次。rust
侧会判断若解析的模块为用户指定的入口barrel files
模块,则判断是否模块符合barrel files
规范。- 若不符合则停止当前模块的
barrel files
优化。 - 若符合则收集该模块的
export
相关的信息,并返回给js
侧。
- 若不符合则停止当前模块的
rust
侧会判断若解析的模块为wildcard
模式下的模块,即这个模块是importer
模块通过export * from ...
重导出的模块,则收集该模块的export
相关的信息,并返回给js
侧。
next.js
借助rust
侧的swc
提供的语义分析来加速解析模块的import
和export
语句。next.js
会在js
侧通过fs.readFile
读取要解析的模块的源码,并将源码以字符串的形式传递给rust
侧。next.js
对于export * from ...
的通配符重导出语句的解析
Feasibility Analysis
上述,已经了解了 next.js
的优化逻辑,那么接下来做一下运行时的可行性分析。
Scene One: The dependent module is barrel files
根据以下例子,barrel files
的依赖模块 foo
自身也是 barrel files
。
import { bar } from '@/utils';
console.log(bar);
2
export { bar } from './foo.js';
export { bar } from './bar.js';
export const bar = 'bar.js';
import barrelOptimize from '@rollup/plugin-barrel-optimize';
export default {
input: 'src/main.js',
output: {
file: 'dist/bundle.js',
format: 'esm'
},
plugins: [
barrelOptimize([
{
entry: 'src/utils/index.js'
}
])
]
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
next.js
提供的策略是,在配置项中(experimental.optimizePackageImports
)指定的为需要优化的 barrel files
的入口模块,也就是说 utils/index.js
模块依赖 utils/foo.js
模块,那么 utils/foo.js
也应该包含在 experimental.optimizePackageImports
中,来告知 next.js
还需要继续优化 utils/foo.js
模块。
当 utils/foo.js
模块不包含在 experimental.optimizePackageImports
中,那么 next.js
会认为 utils/foo.js
模块不属于 barrel files
,也就是说在 utils/foo.js
模块中,可能会存在副作用语句。
next.js
parses re-exports only once
for performance optimization
不管 barrel files
模块后续的重导出链有多长,next.js
每次与 rust
侧通信时,只解析一次重导出语句。
举例说明:
// user code
import { bar } from '@/utils';
console.log(bar);
// utils/index.js
export { bar } from './foo.js';
// utils/foo.js
export { bar } from './bar.js';
// utils/bar.js
export const bar = 'bar.js';
2
3
4
5
6
7
8
9
10
11
12
在上述例子中 next.js
不会递归解析 foo.js
模块,也就是不会解析第二次重导出模块。
解析第二次重导出模块时,由于用户没有在配置项中指定二次重导出模块(foo.js
)为 barrel files
,意味着二次重导出模块(foo.js
)中可能不仅仅有 导出语句,还可能存在 副作用语句。
如果再继续递归解析,很大程度上会得不到想要的结果
简单说明一下:
foo.js
模块可能存在如下几种可能性:
非 barrel files
模块- 模块存在副作用且重导出其他模块的引用声明标识符。
- 模块不存在副作用且重导出其他模块的引用声明标识符。
- 模块存在副作用且导出模块上下文中的引用声明标识符。
- 模块不存在副作用且导出模块上下文中的引用声明标识符。
barrel files
模块- 模块重导出其他模块的引用声明标识符。
只有二次重导出模块(foo.js
)依旧是 barrel files
模块,next.js
递归解析 foo.js
模块才有意义。而确认 foo.js
是否为 barrel files
模块,与配置项(experimental.optimizePackageImports
)功能重复。
可能会有一个疑问,如果
foo.js
模块是非barrel files
模块,那么如果这个模块没有副作用,那么递归解析不是也有意义吗?
需要注意的是,当前优化的阶段是处于 构建模块依赖图
阶段,这是在 tree-shaking
阶段之前的优化,因此无法确切判断是否 foo.js
模块是否存在副作用。
可能还会有疑惑,难道不能尊重
sideEffects
配置项吗?
如果有这个疑惑,那么就说明你还没有理解 sideEffects
配置项的含义,详细说明可参考 The Mechanism Of Module Side Effects,里面详细说明了 sideEffects
配置项如何在 rollup
中运行。
简单来说,即使模块指定了 sideEffects: false
,虽然第一遍扫描时不会检测这个模块(sideEffects: false
)的副作用,但若这个模块(sideEffects: false
)导出的引用声明标识符被其他实际需执行模块使用到,那么会重新激活这个模块(sideEffects: false
)的副作用检测,下一次扫描时会重新检测这个模块(sideEffects: false
)的副作用。
Scene Two: The dependent module has side effects
上面已经提到了,next.js
在优化 barrel files
时,由于模块依赖图还未构建完成,因此没有足够的上下文信息来 语义分析 barrel files
依赖模块是否存在副作用。
下面例子中存在着与 模块执行顺序 相关的副作用。
import { foo } from '@/barrel-file';
console.log(foo);
2
export { bar } from './bar.js';
export { foo } from './foo.js';
2
// moduleSideEffects: true
export const foo = 'foo.js';
console.log('side effect foo.js');
2
3
// moduleSideEffects: true
export const bar = 'bar.js';
console.log('side effect bar.js');
2
3
如果仅依赖用户实际使用的 foo
引用声明标识符所属的模块(foo.js
),那么 bar.js
模块的副作用不会被检测到。当 foo.js
和 bar.js
都标识存在副作用时,这就会存在开发阶段的运行结果和生产阶段的产物运行结果不一致的问题。
这其实也就是 vite
现阶段所考虑的问题,Consider treeshaking module for serving files in dev mode
的 issue
到现阶段还未解决的原因:
bluwy 对此的 回复
关于这个问题,我想发表一下我的看法。
是什么让它变慢的?
通过一些分析,我们发现请求数量并不是速度慢的确切指标。一个导入
10000
个原始js
模块的js
文件,对于vite
来说,它解析速度一样很快。真正导致慢的是每一个模块都需要 通过插件 执行
transform
,例如将.ts
模块转译为.js
模块、.vue
模块转译为.js
模块。大量的解析工作会导致vite
内部产生瀑布效应
,即按照顺序依次并发解析模块的所有的依赖模块。因此,即使我们团队维护的内部的vite
插件足够快,但第三方插件也可能对性能产生巨大影响,而这些第三方插件是我们无法控制的。我们今天能做什么
一种解决办法是通过预热一些文件,使它们提前转换并在需要时准备就位:详见 #14291。在这个
PR
中也详细解释了为什么选择这种方式。不过,这个功能目前并未覆盖热模块替换(HMR)后的预热,但这一点很快就会实现。关于 Tree-shaking
vite-plugin-entry-shaking 插件的效果不错,但我对副作用持保留态度,因为一些来自
barrel
导出的文件可能依赖这些副作用。对barrel files
模块进行 语义分析副作用 的成本远高于 预热模块 的成本。不过不得不承认,大多数
barrel files
文件并没有副作用,但依旧存在副作用的场景。如果将其作为核心功能,我们仍需确保其正确性。(此外,这种方法可能会带来单例重复引用的风险。)结论
因此,我的看法是,我们可以先看看 #14291 对
vite
现阶段无副作用而优化的效果如何,只有在不得已的情况下才采用此问题提出的方案。如果你想通过检测发现哪些插件执行较慢,可以通过运行
DEBUG="vite:plugin-*" vite dev
方式来实现,但请谨慎对待这些日志,因为异步代码的测量并不总是可靠,但仍然能暴露一些更消耗性能的工作。
vite
现阶段还是在于无法合理的处理副作用而依旧存在 barrel files
的性能问题。这对于 vite
这类依赖运行时的工具来说,是致命的。
官网也警示了 Avoid Barrel Files,大量使用 barrel files
会显著增加 vite
的性能开销。
Summary Of Runtime Feasibility Analysis
在构建依赖图阶段优化 barrel files
,由于缺乏足够的模块上下文信息,无法确切判断模块是否存在副作用。额外考虑模块副作用势必会存在边界和性能问题。如果想要在构建依赖图的过程中对 barrel files
进行合理优化,我认为需要用户确保 barrel files
中每一个导出引用声明标识符所属的模块 均为独立
的,大部分的 barrel files
其实都是独立模块,因此对于大部分的用户的应用来说是符合要求的。
下游工具 vite
或上游工具 rollup
我觉得可以在官方手册中明确指出限制条件,实现对于 barrel files
的优化,特别是对于像 vite
这类依赖运行时的工具来说更为重要。