专栏名称: 前端从进阶到入院
我是 ssh,只想用最简单的方式把原理讲明白。wx:sshsunlight,分享前端的前沿趋势和一些有趣的事情。
目录
相关文章推荐
前端大全  ·  前端行情变了,差别真的挺大。。。 ·  昨天  
前端早读课  ·  【开源】TinyEngine开启新篇章,服务 ... ·  昨天  
前端大全  ·  React+AI 技术栈(2025 版) ·  3 天前  
商务河北  ·  经开区“美•强•优”三重奏 ·  3 天前  
51好读  ›  专栏  ›  前端从进阶到入院

这种流行的前端目录结构,该停止了!

前端从进阶到入院  · 公众号  · 前端  · 2024-08-20 09:13

主要观点总结

文章讨论了Barrel Files(聚合文件)在编码中的使用,虽然它能简化导入路径和降低模块耦合,但也带来了一些问题。主要关键点包括:

关键观点总结

关键观点1: Tree-Shaking失效

使用Barrel Files可能导致Tree-Shaking失效,因为所有被Barrel Files导出的模块都会被构建工具视为有sideEffects的代码,导致最终产物包含许多无用代码。

关键观点2: 循环引用问题

Barrel Files模式可能导致循环引用结构,增加了工程问题的风险,如模块未定义、程序崩溃或行为异常。

关键观点3: 影响工程化工具性能

Barrel Files模式容易引入无用代码,这些无用代码会影响工程化工具(如Typescript、VS Code、Webpack等)的性能。

关键观点4: 复杂的模块间依赖关系

使用Barrel Files会使模块间的依赖关系变得更复杂,增加调试和理解代码的难度。

关键观点5: 最佳实践建议

文章建议尽量克制使用Barrel Files,并提供了替代方案,如直接引用具体模块、重构项目结构等。


正文

这是一个很纠结的问题: 是否应该使用 Barrel Files 管理不同目录的导出结构? 我个人曾经非常推崇这种编码模式,毕竟这确实是一种简单但非常便于管理模块之间依赖关系的方法,但经过长久实践后,发现潜在的弊端远远大于收益,因此强烈建议大家从此刻开始, 停止使用 Barrel File ,具体原因且听我娓娓道来。

Barrel File 是什么

模块化 是一种非常重要且有用的技术,早期的 ECMAScript 规范一直被诟病缺乏模块化能力,所有变量和函数都是全局的,这非常容易导致名称冲突、代码污染等问题,导致这时候的 Javascript 语言根本无法支撑起大规模项目开发,为此开源社区及 ECMA 组织前后产出 CMD、UMD、ESM 等模块化方案。模块化能力使得开发能够基于模块粒度做好耦合度与内聚性管理,模块之间划定好交互与边界,彼此独立互不侵扰。某种程度上,这使得 Javascript 从简单的脚本语言晋升为具备大规模开发能力的现代化编程语言。

但是 ,随项目规模增长新的问题接踵而至,模块数量增长容易导致模块之间的依赖关系变得复杂,特别在大型项目中,可能需要横跨多层目录结构后才能引用到目标模块,例如在下面的项目结构中:

src/
├── components/
│   ├── Button/
│   │   ├── Button.ts
│   │   └── index.ts
│   ├── Input/
│   │   ├── Input.ts
│   │   └── index.ts
│   └── Modal/
│       ├── Modal.ts
│       └── index.ts
├── utils/
│   ├── format.ts
│   └── validate.ts
└── services/
    ├── api/
    │   ├── userApi.ts
    │   └── index.ts
    └── auth/
        ├── authService.ts
        └── index.ts

假设 src/components/Button/Button.ts 模块需要使用 src/services/api/auth/authService.ts 模块,则相关导入语句:

// src/components/Button/Button.ts
import { authService } from '../../services/auth/authService';

这种方式存在许多缺点:

  1. 可读性差 :随着目录层级的增加,引用路径会变得越来越长和复杂,这不仅降低了代码的可读性,还增加了理解代码结构的难度;
  2. 强耦合 Button 强依赖于 authService 文件所在的相对路径,目录层级间边界模糊不清;
  3. 维护成本高 :在大型项目中,随着模块和文件数量的增加,维护相对路径变得更加困难,任何一次目录结构的调整都可能需要大量的路径更新工作。

所幸这个问题并不难解决,常见解题思路有 alias Barrel Files

  • alias :使用构建工具 —— 如 Typescript 的 alias 指定路径别名:

    // tsconfig.json
    {
    "compilerOptions": {
      "paths": {
        "@services/*": ["src/services/*"]
      }
    }
    }

之后即可简化引用方式为:

import { authService } from '@services/auth/authService';
  • Barrel Files :设置 Barrel Files 统一导出模块,如:

    // services/index.ts
    export { authService } from './auth/authService';

之后即可简化引用方式为:

import { authService } from '../../services';

PS: alias 模式也同样存在许多影响工程可维护性的细微问题,此处先按下不表。

结合上面的示例, Barrel files 本质上就是一种聚合多个模块并统一导出的编码模式,我们可以代码文件夹中创建一个 Barrel File,通过该文件统一导出可用模块,外部模块在消费时只需引用到 Barrel 文件即可,无需关心代码文件夹内部细节,这会带来一些好处:

  1. 引用方无需感知依赖模块的具体文件结构,达到简化导入语句,在大型工程中这有利于提升开发效率;
  2. Barrel Files 有助于管理模块的可见性,对外屏蔽不必要的细节,从而降低模块间耦合;
  3. 模块之间通过 Barrel Files 解耦后,后续更容易做重构,例如重命名、移动文件等,都只需要修改 barrel files 即可;
  4. 使用 Barrel Files 可以统一模块的导出方式, 使代码的结构和导入方式更加一致和规范,便于团队协作。

如果严格遵循这种模式,只要确保 Barrel File 文件对外暴露内容的稳定性,文件夹内部无论怎么腾挪转移,上层甚至无需同步做出重构。

这听着很美好,那么问题在哪呢?

问题:

1.  Tree-Shaking 失效

这是 Barrel Files 模式最严重的问题: 使用 Barrel Files 容易导致 Tree-shaking 失败

Tree-shaking 是前端构建工具提供的非常基础而实用的性能优化特性,其底层依赖于 ESM 模块规范的静态特性,在构建过程中通过追踪分析各模块导入导出结构,删除无用模块,达到性能优化效果。

更多实现细节,可参考:《 Webpack 原理系列九:Tree-Shaking 实现原理 》一文。

但是,在使用 Barrel Files 模式时,情况发生了变化,举例来说,假设项目结构如下:

src/
├── components/
│   ├── Button.js
│   ├── Input.js
│   └── index.js  // Barrel file
└── index.js

对应核心代码:

// src/components/Button.js
export const Button = () => {
  console.log("Buttond");
};

// src/components/Input.js
class SingleTon // 这里是重点
  constructor() {
    console.log("SingleTon");
  }
}

export const instance = new SingleTon();

export const Input = () => {
  console.log("input");
};

// src/components/index.js
export { Button } from "./Button";
export { Input } from "./Input";

// src/index.js
import { Button } from "./components";

Button();

结果来看,entry 文件 src/index.js 仅消费了 Button 函数,但构建结果却是:

原因很简单,构建工具认为 SingleTon 是一段有 sideEffects 的代码,出于安全考虑不予删除。在 Barrel Files 模式下,这意味着下游模块所有被判定为带有 sideEffects 的代码都会被保留下来,导致最终产物可能被打入许多无用代码。注意,有许多代码模式会被判定为具有 sideEffects,包括:

  • 顶层函数调用,如: console.log('a')
  • 修改全局状态或对象,如: document.title = 'new Title'
  • IIFE 函数;
  • 动态导入语句,如: import('./mod')
  • 原型链污染,如: Array.prototype.xxx = function (){xxx}
  • 非 JS 资源:Tree-shaking 能力仅对 ESM 代码生效,一旦引用非 JS 资源则无法树摇;
  • 等等;

这些都是非常常见的编码模式,特别是非 JS 资源,在前端项目中通过 import/require 引用样式、多媒体文件是非常常见的,但在 Barrel File 模式下却容易打入不必要代码。例如扩展上述示例,引入 Less 文件:

即使 s 并未被消费,产物中依然带有 input.module.less 代码,以及对应 CSS module 运行时代码。

严格来说,并不单纯是 Barrel Files 模式导致 tree-shaking 失效,而是 Barrel Files 叠加 sideEffects 的判定逻辑导致部分场景下树摇失败。那么相对的,假如放弃 Barrel Files 模式(虽然这会给损害 DX),直接引用具体模块代码,必然也就不会带入其他无用模块的 sideEffects。

2.  循环引用

循环引用是指两个或多个模块相互依赖,形成一个闭环,例如,模块 A 引用了模块 B,而模块 B 又引用了模块 A。而 Barrel Files 模式又非常容易导致循环引用结构,例如对于下面的项目结构:

src/
├── components/
│   ├── Button.ts
│   ├── Input.ts
│   └── index.ts  // Barrel 文件
└── index.ts
// Button.ts

import { Input } from './index';

export function Button({
  Input();  
}
// Input.ts

import { Button } from './index';

export function Input({
  Button();  
}

这里面,Barrel File 模式看似隐蔽了 Button Input 模块的实现细节,降低两者耦合,但依赖关系并没有消失而是发生转移,两者的循环依赖从直接变成间接,以人类的认知能力而言变得相对隐晦而难以察觉,这只是一个简单示例,当项目规模增长十倍、百倍时,循环依赖的概率也会相应大幅增长。

这种依赖结构是非常脆弱不健康的,容易进一步引发许多工程问题:

  1. 模块未定义问题 :当出现循环引用时,某些模块可能会在未完全定义之前被使用,导致 undefined 错误。例如:
// Button.ts
import { Input } from './index';

console.log(Input);  // 可能是 undefined
  1. 程序崩溃或行为异常 :循环引用会导致模块加载顺序问题,验证时可能引发程序崩溃或行为异常。例如:
// Button.ts




    

import { Input } from './index';

export function Button({
  Input();  
}

// Input.ts
import { Button } from './index';

export function Input({
  Button();  // Button 与 Input 递归调用,导致程序死循环
}
  1. 构建困难 :“如何构建循环依赖”是一个非常复杂的问题,业界并没有对此形成统一规范,各家构建工具的处理逻辑都有所不同,致使某些代码在当下看似可用,但换一个构建环境可能出现各种细微问题;
  2. 调试困难 :循环引用导致的问题往往隐蔽且难以调试。开发者需要深入理解模块加载顺序,才能找到并修复问题。

幸运的是,这类问题相对容易检测,社区有不少工具可用于辅助检测循环依赖,常见如 eslint-plugin-import no-cycle 规则,接入成本低,但其内部实现需要向下遍历被依赖模块,IO 与 CPU 都比较密集,有较高性能成本,官方文档也警告过需要关注性能问题:

其次,更推荐使用 oxlint import/no-cycle 规则,由于底层是 rust 实现的,执行性能要比 eslint-plugin-import 插件高出不少,使用方法:

npm i -g oxlint@latest
echo '{"rules": {"import/no-cycle": "error"}}' > .oxlintrc.json
oxlint -c .oxlintrc.json --quiet --import-plugin .

3.  影响部分工程化工具性能

这里有一个基础前提: Barrel Files 模式容易引入无用代码 ,无用是指代码被定义、导入却从未被业务系统消费,但这些无用代码却是实实在在影响着许多工程工具的执行性能,包括但不限于:Typescript、VS Code、Vitest、Webpack、RSPack、ESLint 等等。

以 VS Code 为例,不同导入风格最终需要处理的空间复杂度差异极大,以 antd 为例:

  • 使用 Barrel Files 时:

对应 TS Server 日志,需要处理许多无关模块:

  • 直接引用模块:

对应 TS Server 日志,只需处理 affix 模块即可:

类似的,使用 Barrel Files 时, tsc 也需要消费更多的时间索引那些根本不会被消费的文件:

  • 使用 Barrel Files 时:
  • 直接引用模块:

从 540ms 到 141ms,两者相差接近 4 倍的性能开销,本质上,这是因为 Typescript 并没有智能到能够识别出 Barrel Files 导入的无用模块, tsc ts server 会忠实的解析编译所有遇到的模块及子模块,结果,Barrel Files 模式使用的越多,越容易造成不必要的性能浪费。







请到「今天看啥」查看全文