用 Feature First 的方式管理前端项目复杂度

11,085

什么是复杂度

软件系统变得复杂的三个成因是规模、结构与变化。

针对三个成因,我们可以通过以下方式简化系统:

  • 分而治之,控制规模
  • 保持结构的清晰与一致
  • 拥抱变化

依据这些原则,我们该如何组织文件结构,管理好前端项目复杂度?

前端项目中,组织文件结构的两种常见方式

File-Type First (FTF)

『按文件类型组织』,这也是前端项目中最普遍的组织方式。例如:

src
├── index.js
├── components
│   ├── index.js
│   ├── footer
│   ├── header
│   ├── posts
│   └── users
│       ├── UserDetail.js
│       └── UserList.js
├── containers
│   ├── home
│   ├── posts
│   │   └── index.js
│   └── users
│       └── index.js
├── actions
│   ├── posts.js
│   └── users.js
├── reducers
│   ├── posts.js
│   └── users.js
└── sagas
    ├── posts.js
    └── users.js

Feature First

『按功能特性组织』。例如:

src
├── components
│   ├── footer
│   ├── header
│   └── index.js
├── features
│   ├── posts
│   │   ├── action.js
│   │   ├── components
│   │   │   └── index.js
│   │   ├── containers
│   │   │   └── index.js
│   │   ├── reducers.js
│   │   └── sagas.js
│   └── users
│       ├── action.js
│       ├── components
│       │   └── index.js
│       ├── containers
│       │   └── index.js
│       ├── reducers.js
│       └── sagas.js
└── index.js

『关注点分离』的差异

两种文件组织方式都是在做『关注点分离』,不同的是对『关注点』的理解。

  • File-Type First 的 『关注点』是技术和手段。
  • Feature First 的『关注点』是功能和目标。

『语言』分离和『组件』分离

(图片来源:A feature based approach to React development

『功能特性』分离

(图片来源:Why React developers should modularize their applications?

你应该采用哪种方式?

File-Type First 的特点是:简洁、直接,符合开发者的直觉。而 Feature First 在管理大规模项目的复杂度上更有优势。

深入 Feature First 的文件组织方式

Feature First 文件组织方式的优势:

  1. 代码易于查找、定位 代码的组织方式反映了产品结构,与产品需求相对应。

  2. 代码更易于维护 每个 Feature 隔离,当修改一个 Feature 中的代码修改、重构时,不会影响其它 Feature。 多特性并行开发时,更大程度上避免 merge 时产生的冲突。

  3. 启用 Feature Flags 机制

Feature Flag 是一种通过配置开功能特性的技术,无须重新部署代码。

像类似 A/B Testing 的需求,也可以借用 Feature Flags 实现。

代码示例:

// features.json
{
  ...,
  portal: true,
  users: true,
  posts: false
}

// index.js
export function isFeatureEnabled(feature) {
  return features[feature] || false;
}

将 Feature First 进行到底

如果某个 Feature 比较复杂,可以将其进行一步细分,形成 Feature 的嵌套结构。 例如:将 features/users 细分为

  • features/users/features/detailView
  • features/users/features/listView
src
├── features
│   └── users
│       ├── components
│       │   ├── Table.js
│       │   └── index.js
│       └── features
│           ├── index.js
│           ├── detailView
│           │   └── components
│           │       └── Detail.js
│           └── listView
│               └── components
│                   └── List.js
└── index.js

如何组织应用内的共享组件

有的组件跨 Feature 复用,有的组件同 Feature 内跨 Page 复用。 为了保证良好的可维护性,共享组件的组织应该遵循明确的规则。

下面是一个供参考的方案。(components 目录下的 index.js 只负责 export 组件,不实现具体功能)

src
├── components
│   ├── Notification.js
│   └── index.js
├── features
│   ├── posts
│   │   └── components
│   │       └── index.js
│   └── users
│       ├── components
│       │   ├── Table.js
│       │   └── index.js
│       └── features
│           ├── index.js
│           ├── detailView
│           │   └── components
│           │       └── Detail.js
│           └── listView
│               └── components
│                   ├── List.js
│                   ├── Title.js
│                   └── components
│                       ├── index.js
│                       ├── Pagination.js
│                       └── components
│                           └── index.js
└── index.js

请思考一下,在上述项目结构中, src/features/users/pages/listView/components/List.js 可以共享使用的组件有哪些?

接下来,我们一一来看:

  • src/components 内放置的是应用范围内的共享组件,所以,List 可以使用其中的所有组件。
  • src/features/posts/components 跟 List 属于不同 Feature,所以,无法使用其中组件。
  • src/features/users/pages/detailView/components,虽然跟 List 属于同一个 Feature,如果允许从listViewdetailView的『同层引用』,也会增加 Feature 内的文件引用复杂度。所以,这类引用也是要被禁止的。
  • src/features/users/pages/listView/components/components (与 List 同级的components 目录),List 的子组件就放在同级的 components 目录中,所以,允许访问。
  • src/features/users/pages/listView/components/components/component (与 List 同级的 components 目录的子级 components),『跨层引用』也会增加复杂度,所以,也不允许此类引用。

把上述情况,归结成一句话就是:

除了同目录文件,组件只能引用其所在文件各级路径下的 components 目录。

src/features/user/pages/listView/components/List.js 按照上述规则展开:

  • src/features/user/pages/listView/components/components
  • src/features/user/pages/listView/components
  • src/features/user/pages/components
  • src/features/user/components
  • src/features/components
  • src/components

看到这,可能找个了一个熟悉的身影。 是的,它跟 CommonJS 中的模块查找规则很类似。

components 目录里放什么?components 目录的严格意义是,放置仅供同级组件复用的子组件。例如:上述与 List 同级的 components 目录,应该存放仅供 List、Title 复用的子组件。

超越 Feature 的项目复杂度

新的逻辑层次

在人才管理、财务管理等企业级应用中,仅有 App 和 Feature 已经不能如期地对应上产品结构了。

这时,我们需要一个新的逻辑层次,如:Solution。 一个 Solution 包含若干个 App,每个 App 有多个 Feature。

有了 Solution 新的逻辑层次,根据内聚性,可以把原来的 Feature 分拆到不同的 App 中。 相比于 Feature,App 间的耦合性更小,甚至可以当作独立的部署单元。

从多模块(multi-module) 到 多包(multi-package)

项目的规模大了,依赖管理上的挑战也出现了。 Feature 之间要减少依赖, App 之间更要进一步隔离, 跨 App 复用的模块,就不能简单地 import 了。

为了减少 App 之间的耦合,需要将复用单元需要封装成 package, 然后,各个 App 在 package.json 中声明依赖。 同样,在 Solution 的眼中,各个 App 也是 package。

通过从 multi-module 到 multi-package 的转变,耦合减小了, 但给开发也带了新的成本,这些问题可借助于 Lerna 等工具解决。

使用 Lerna 可以解决依赖和版本管理的问题,除此之外,还要做好 package 的分层设计。

多包管理中的分层设计

App 是一个 package,App 依赖的模块也是一个 package, 但是,两类 package 是不同『质』的。

为了让项目结构更清晰、易理解,我们需要区分这些 package,进行分层设计。例如:

packages
├── solutions
│   ├── login.sln
│   ├── finance.sln
│   └── people.sln
├── apps
│   ├── portal.app
│   ├── financial-management.app
│   ├── recruting.app
│   └── global-search.app
├── biz-libs
│   ├── workflow-engine
│   ├── rendering-engine
│   └── network
├── base-libs
│   ├── ui-components
│   └── animations
└── vendor-libs
    ├── router
    └── state-manager

总结

与 File-Type First 的文件组织方式相比, Feature First 在提升大规模项目的可维护性上有着明显的优势。

  • 更易于查找、定位代码文件;
  • 重构、修改代码时,更好地控制影响范围;
  • 更利于使用 Feature Flags;

在组织应用内的共享组件时,可以遵循跟 CommonJS 类似的模块查找方式:组件只引用其所在文件各级路径下的 components 目录

在企业级应用等大规模项目中,可以通过引入新的逻辑层次和多包管理,进一步控制项目复杂度。

参考资料


文章作者:孔常柱

BDEEFE 在全国各地长期招聘优秀的前端工程师,招聘需求了解下?