Skip to content

Optimizable Features: Barrel Files

Definition Of Barrel Files

barrel files 是一个文件(通常以 index 命名),仅做重新导出其他文件的引用,用来将多个相关特性的模块整合到一起,对外暴露一个统一的接口。本质上是类似于 npm,封闭内部结构,提供统一的接口给消费模块。

json
{
  "name": "acorn",
  "exports": {
    ".": [
      {
        "import": "./dist/acorn.mjs",
        "require": "./dist/acorn.js",
        "default": "./dist/acorn.js"
      },
      "./dist/acorn.js"
    ],
    "./package.json": "./package.json"
  }
}

barrel files 仅做目录结构的调整、减少模块的导入路径、优化目录结构的特性,是可接受的。但在实际项目中,barrel files 的使用并不规范,里面的依赖项并非相互独立,而是存在模块间互相依赖(副作用、执行顺序等)、存在大量与模块相关的副作用问题。

我们不能否认 barrel files 带来的便利性,但与此同时,我觉得 bundlers 与其下游运行时相关的工具(例如 vitetest runnerbrowsernode 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 侧设计了专门针对 optimizeBarrelExportsswc 转换。该转换器只关心生成 export map,而不对其他情况做解析。

Rust Optimization

Prospect Informs

  1. importer 使用 wildcard export 依赖模块时,那么依赖模块就会进入 wildcard 模式。

  2. name_str 是当前模块上下文中的引用名称,orig_str 是依赖模块中上下文中的引用名称。

    • 直接导出

      js
      export { foo } from './foo';

      name_strorig_str 都是 foo

    • 重命名导出

      js
      export { foo as bar } from './foo';

      name_strbarorig_strfoo

    • 通配符导出

      js
      export * as utils from './utils';

      name_strutilsorig_str*

  3. export_map

    export_map 是一个元组数组,记录 export 语句的导出信息。

    rust
    // Exported meta information.
    let mut export_map: Vec<(String, String, String)> = vec![];

    每个元组包含三个元素:(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: 使用 "*" 表示是整个命名空间
      );
    • 具名导出 (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: 原始名称
      );
    • 导出声明 (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: 空字符串

name_str:

  • 模块上下文中声明的变量标识符,最终导出时使用的名称

src:

  • 如果是从其他模块导入再导出,则为源文件路径
  • 如果是模块声明,则为空字符串

orig_str:

  • 如果是具名导出,则为原始名称
  • 如果是命名空间导出,则为 *
  • 如果是模块声明,则为空字符串

这个 export_map 的设计允许:

  • 追踪每个导出的来源
  • 知道导出是否被重命名
  • 区分本地声明的导出和重导出
  • 识别命名空间导出
Namespace Export

首先处理 ExportSpecifier::Namespace 的情况:

定义 name_str 为当前模块上下文中导出引用名称。

rust
ExportSpecifier::Namespace(s) => {
  let name_str = match &s.name {
    ModuleExportName::Ident(n) => n.sym.to_string(),
    ModuleExportName::Str(n) => n.value.to_string(),
  };
}

分三种情况讨论:

rust
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;
}

并通过 export_map 记录导出映射。

js
// 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 };
Named Export

定义导出引用名称,分为 orig_strname_str

rust
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(),
};

其中 orig_strexport 语句中导出的 原始引用名称name_strexport 语句中导出的 新引用名称

根据具名导出的方式,收集不同信息到 export_map 中:

rust
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;
}
js
// 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 模式下不允许
Wildcard Export
rust
ModuleDecl::ExportAll(export_all) => {
  export_wildcards.push(export_all.src.value.to_string());
}

这处理的是 * 通配符导出的情况:

js
export * from './foo';

这总是被允许的,因为它是典型的 barrel files 行为。

完整的实现逻辑如下:

rust
// 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;
        }
      }
    },
  }
}
Non-Wildcard Mode

在非 wildcard 模式下,它严格要求所有导出都必须是从其他模块重新导出的形式。

在非 wildcard 模式下(即 self.wildcard = false),决策模块是否符合 barrel files 规范的逻辑如下:

  1. 命名空间导出(ExportSpecifier::Namespace)时:

    js
    // ❌ 没有源文件的命名空间导出,判定模块不符合 `barrel files` 规范。
    import * as utils from './utils';
    export { utils };
    
    // ✅ 有源文件的命名空间导出是允许的,符合 `barrel files` 规范。
    export * as utils from './utils';
  2. 具名导出(ExportSpecifier::Named)时:

    js
    // ✅ 模块先导入后导出统一引用,符合 `barrel files` 规范。
    import { foo } from './foo';
    export { foo };
    
    // ✅ 模块重导出其他模块的导出,符合 `barrel files` 规范。
    export { foo } from './foo';
    
    // ❌ 模块上下文声明的变量标识符导出,不符合 `barrel files` 规范。
    const x = 42;
    export { x };
  3. 导出声明(ExportDecl)时:

    js
    // ❌ 模块上下文函数声明导出,不符合 `barrel files` 规范。
    export function foo() {}
    
    // ❌ 模块上下文类声明导出,不符合 `barrel files` 规范。
    export class MyClass {}
    
    // ❌ 模块上下文变量声明导出,不符合 `barrel files` 规范。
    export const x = 42;
  4. 其他模块声明时:

    js
    // ❌ 模块上下文默认导出声明,不符合 `barrel files` 规范。
    export default function () {}
  5. 非字符串字面量的表达式语句:

    js
    // ❌ 普通表达式,不符合 `barrel files` 规范。
    console.log('hello');
    
    // ❌ 非指令(例如 `"use strict"`)的字面量,不符合 `barrel files` 规范。
    42;

总的来说,在非 wildcard 模式下,一个合法的 barrel file 只能包含:

  • 使用 export 语句重导出其他模块。
  • 使用 import 语句先导入后导出。
  • 使用 "use strict" 指令字符串。
Wildcard Mode

wildcard 模式下,它允许更灵活的导出模式。

  1. 命名空间导出(ExportSpecifier::Namespace):

    js
    // ✅ 命名空间重导出源模块,符合 `barrel files` 规范。
    export * as utils from './utils';
    
    // ✅ 符合 `barrel files` 规范。
    import * as helpers from './helpers';
    export { helpers };
  2. 具名导出(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 };
  3. 导出声明(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;
  4. 其他模块声明:

    js
    // ✅ 模块上下文默认导出,符合 `barrel files` 规范。
    export default function () {}
    
    // ✅ 通配符导出,符合 `barrel files` 规范。
    export * from './utils';
  5. 表达式和语句:

    js
    // ✅ 普通表达式,符合 `barrel files` 规范。
    console.log('hello');
    
    // ✅ 字面量,符合 `barrel files` 规范。
    42;
    ('use strict');

总结一下,wildcard 模式的主要特点:

  • 允许所有类型的导出(命名、默认、声明等)。
  • 允许模块声明并导出。
  • 允许表达式和其他语句。
  • 不要求必须从其他模块导出。

这种模式实际上放宽了对 barrel file 的定义,使其可以:

  • 既作为重导出的集中模块。
  • 又可以包含自身模块的导出引用。
  • 还可以包含其他代码逻辑。
Rust Side Optimization Summary

如果模块判定为非 barrel files,那么 next.js 不会导出任何内容。

rust
if !is_barrel {
  new_items = vec![];
}

而如果模块判定为 barrel files,那么 next.js 会重写模块内容,导出上述收集的 export_mapexport_wildcards 信息。

rust
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()
      })),
    })));
  }
}

举一个例子说明

js
'use client';

import { foo as foo1 } from './foo';
export { bar } from './bar';
export * as utils from './utils';
export * from './utils2';
export { foo1 };

next.js 会重写 index.js

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'
]);

这个实现通过维护 export_mapexport_wildcards 两个集合来收集模块的所有导出方式的关系,并使用 is_barrel 标志来判断当前的模块是否符合 barrel files 的定义。在 wildcard 模式下,它允许更灵活的导出模式,而在非 wildcard 模式下,它严格要求所有导出都必须是从其他模块重新导出的形式。

JS Optimization

JS 侧,next.js 实现了 NextBarrelLoader 插件来优化 barrel files

ts
// 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
  );
};

在插件中会调用 getBarrelMapping 来获取模块的导出信息。如果 mapping 为空,则说明该模块不是 barrel files,结束后续的优化。

ts
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;
}

那么优化的核心逻辑就是 getBarrelMapping 函数,它负责解析 barrel files 的导出信息,并返回一个包含导出信息的对象。

ts
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;
}

getBarrelMapping 中,会调用 getMatches 函数来获取 barrel files 的导出信息。当然此处有做缓存,避免重复解析。

ts
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);
  // ...
}

Attention

传递给 rust 侧会包含 模块的路径源码是否启用 wildcard 模式

源码是通过 js 侧的 fs.readFile 读取的,这一块逻辑可以进一步优化。

getMatches 中,会调用 transpileSource 函数。

ts
// 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);
    })
  );
}

transpileSource 函数中会调用 rust 侧针对 barrel files 的优化逻辑,返回一个解析后的产物,仅包含模块的 export 语句,详情可参考 Rust Side Implementation Details

Attention

需要注意,用户指定的 barrel files 文件是作为 rust 侧解析的入口模块,不启用 wildcard 模式(即 wildcard = false)。

ts
// 第二个参数为 false,表示不启用 `wildcard` 模式
const res = await getMatches(resourcePath, false, false);

紧接着通过正则匹配 rust 侧返回的产物,获取 exportListwildcardExportsisClientEntry 信息。

ts
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
  };
}

rust 侧返回的解析产物中存在 wildcard exports

js
export * from './utils';

会继续递归调用 getMatches 函数

ts
const targetMatches = await getMatches(
  targetPath,
  true,
  isClientEntry
)

直到所有 wildcard exports 都被解析完毕。此时可以注意到在解析 wildcard exports 时,第二个参数为 true,表示启用 wildcard 模式。

最终执行完 getBarrelMapping 函数返回的是用户指定的 barrel files 的导出与导出路径的映射关系和指令信息。

ts
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]);
  }
};

获取完 barrel files 的导出与导出路径的映射关系和指令信息后,那么接下来的工作就是按需重写 barrel files 的导入路径。

ts
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}`;
  }
};

以上就是 next.js 针对 barrel files 的完整优化逻辑,包括 rust 侧的优化、 js 侧的优化和最后的 barrel files 的导入路径重写为具体的模块路径。

Summarize Optimization Of Next.js

  1. next.js 每一次与 rust 侧通信时,只会解析模块一次。

    • rust 侧会判断若解析的模块为用户指定的入口 barrel files 模块,则判断是否模块符合 barrel files 规范。
      • 若不符合则停止当前模块的 barrel files 优化。
      • 若符合则收集该模块的 export 相关的信息,并返回给 js 侧。
    • rust 侧会判断若解析的模块为 wildcard 模式下的模块,即这个模块是 importer 模块通过 export * from ... 重导出的模块,则收集该模块的 export 相关的信息,并返回给 js 侧。
  2. next.js 借助 rust 侧的 swc 提供的语义分析来加速解析模块的 importexport 语句。

  3. next.js 会在 js 侧通过 fs.readFile 读取要解析的模块的源码,并将源码以字符串的形式传递给 rust 侧。

  4. next.js 对于 export * from ... 的通配符重导出语句的解析

Feasibility Analysis

上述,已经了解了 next.js 的优化逻辑,那么接下来做一下运行时的可行性分析。

Scene One: The dependent module is barrel files

根据以下例子,barrel files 的依赖模块 foo 自身也是 barrel files

js
import { bar } from '@/utils';
console.log(bar);
js
export { bar } from './foo.js';
js
export { bar } from './bar.js';
js
export const bar = 'bar.js';
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'
      }
    ])
  ]
};

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 侧通信时,只解析一次重导出语句。

举例说明:

js
// 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';

在上述例子中 next.js 不会递归解析 foo.js 模块,也就是不会解析第二次重导出模块。

解析第二次重导出模块时,由于用户没有在配置项中指定二次重导出模块(foo.js)为 barrel files,意味着二次重导出模块(foo.js)中可能不仅仅有 导出语句,还可能存在 副作用语句

如果再继续递归解析,很大程度上会得不到想要的结果

简单说明一下:

foo.js 模块可能存在如下几种可能性:

  1. 非 barrel files 模块
    1. 模块存在副作用且重导出其他模块的引用声明标识符。
    2. 模块不存在副作用且重导出其他模块的引用声明标识符。
    3. 模块存在副作用且导出模块上下文中的引用声明标识符。
    4. 模块不存在副作用且导出模块上下文中的引用声明标识符。
  2. barrel files 模块
    1. 模块重导出其他模块的引用声明标识符。

只有二次重导出模块(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 依赖模块是否存在副作用。

下面例子中存在着与 模块执行顺序 相关的副作用。

js
import { foo } from '@/barrel-file';
console.log(foo);
js
export { bar } from './bar.js';
export { foo } from './foo.js';
js
// moduleSideEffects: true
export const foo = 'foo.js';
console.log('side effect foo.js');
js
// moduleSideEffects: true
export const bar = 'bar.js';
console.log('side effect bar.js');

如果仅依赖用户实际使用的 foo 引用声明标识符所属的模块(foo.js),那么 bar.js 模块的副作用不会被检测到。当 foo.jsbar.js 都标识存在副作用时,这就会存在开发阶段的运行结果和生产阶段的产物运行结果不一致的问题。

这其实也就是 vite 现阶段所考虑的问题,Consider treeshaking module for serving files in dev modeissue 到现阶段还未解决的原因:

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 文件并没有副作用,但依旧存在副作用的场景。如果将其作为核心功能,我们仍需确保其正确性。(此外,这种方法可能会带来单例重复引用的风险。)

  • 结论

    因此,我的看法是,我们可以先看看 #14291vite 现阶段无副作用而优化的效果如何,只有在不得已的情况下才采用此问题提出的方案。

    如果你想通过检测发现哪些插件执行较慢,可以通过运行 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 这类依赖运行时的工具来说更为重要。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)