React组件设计实践总结02 - 组件的组织

一个复杂的应用都是由简单的应用发展而来的, 随着愈来愈多的功能加入项目, 代码就会变得愈来愈难以控制. 本文章主要探讨在大型项目中如何对组件进行组织, 让项目具有可维护性.css

系列目录html


目录前端




1. 组件设计的基本原则

基本原则

单一职责(Single Responsibility Principle). 这本来来源于面向对象编程, 规范定义是"一个类应该只有一个发生变化的缘由", 白话说"一个类只负责一件事情". 无论是什么编程范式, 只要是模块化的程序设计都适用单一职责原则. 在 React 中, 组件就是模块.node

单一职责要求将组件限制在一个'合适'的粒度. 这个粒度是比较主观的概念, 换句话说'单一'是一个相对的概念. 我我的以为单一职责并非追求职责粒度的'最小'化, 粒度最小化是一个极端, 可能会致使大量模块, 模块离散化也会让项目变得难以管理. 单一职责要求的是一个适合被复用的粒度.react

每每一开始咱们设计的组件均可能复合多个职责, 后来出现了代码重复或者模块边界被打破(好比一个模块依赖另外一个模块的'细节'), 咱们才会惰性将可复用的代码抽离. 随着愈来愈多的重构和迭代, 模块职责可能会愈来愈趋于'单一'(😂 看谁, 也可能变成面条).webpack

固然有经验的开发者能够一开始就能考虑组件的各类应用场景, 能够观察到模块的重合边界. 对于入门者来讲Don't repeat yourself原则更有用, 不要偷懒/多思考/重构/消除重复代码, 你的能力就会慢慢提高git

单一职责的收益:github

  • 下降组件的复杂度. 职责单一组件代码量少, 容易被理解, 可读性高
  • 下降对其余组件的耦合. 当变动到来时能够下降对其余功能的影响, 不至于牵一发而动全身
  • 提升可复用性. 功能越单一可复用性越高, 就好比一些基础组件

高质量组件的特征

一个高质量的组件必定是高内聚, 低耦合, 这两个原则或者特征是组件独立性的一个判断标准.web

高内聚, 要求一个组件有一个明确的组件边界, 将紧密相关的内容汇集在一个组件下, 实现"专注"的功能. 和传统的前端编程不同, 一个组件是一个自包含的单元, 它包含了逻辑/样式/结构, 甚至是依赖的静态资源. 这也使得组件自然就是一个比较独立的个体. 固然这种独立性是相对的, 为了最大化这种独立性, 须要根据单一职责将组件拆分为更小粒度的组件, 这样能够被更灵活的组合和复用.typescript

虽然组件是独立的, 可是他须要和其余组件进行组合才能实现应用, 这就有了'关联'. 低耦合要求最小化这种关联性, 好比明确模块边界不该该访问其余组件的内部细节, 组件的接口最小化, 单向数据流等等

文章后续内容主要讨论实现高内聚/低耦合主要措施




2. 基本技巧

这些技巧来源于react-bits:

  • 若是组件不须要状态, 则使用无状态组件
  • 性能上比较: 无状态函数 > 有状态函数 > class 组件
  • 最小化 props(接口). 不要传递超过要求的 props
  • 若是组件内部存在较多条件控制流, 这一般意味着须要对组件进行抽取
  • 不要过早优化. 只要求组件在当前需求下可被复用, 而后'随机应变'



3. 组件的分类

1️⃣ 容器组件展现组件分离

容器组件和展现组件分离是 React 开发的重要思想, 它影响的 React 应用项目的组织和架构. 下面总结一下二者的区别:


容器组件 展现组件
关注点 业务 UI
数据源 状态管理器/后端 props
组件形式 高阶组件 普通组件

  • 展现组件是一个只关注展现的'元件', 为了能够在多个地方被复用, 它不该该耦合'业务/功能', 或者说不该该过渡耦合. 像antd这类组件库提供通用组件显然就是'展现组件'

    下面是一个典型的应用目录结构, 咱们能够看到展现组件与业务/功能是可能有不一样的耦合程度的, 和业务的耦合程度越低, 通用性/可复用性越强:

    node_modules/antd/     🔴 通用的组件库, 不能和任何项目的业务耦合
    src/
      components/          🔴 项目通用的组件库, 能够被多个容器/页面组件共享
      containers/
        Foo/
          components/      🔴 容器/页面组件特有的组件库, 和一个业务/功能深度耦合. 以至于不能被其余容器组件共享
          index.tsx
        Bar/
          components/
          index.tsx
    复制代码

    对于展现组件,咱们要以一种'第三方组件库'的标准来考虑组件的设计, 减小与业务的耦合度, 考虑各类应用的场景, 设计好公开的接口.


  • 容器组件主要关注业务处理. 容器组件通常以'高阶组件'形式存在, 它通常 ① 从外部数据源(redux 这些状态管理器或者直接请求服务端数据)获取数据, 而后 ② 组合展现组件来构建完整的视图.

    容器组件经过组合展现组件来构建完整视图, 但二者未必是简单的包含与被包含的关系.


容器组件和展现组件的分离能够带来好处主要是可复用性可维护性:

  • 可复用性: 展现组件能够用于多个不一样的数据源(容器组件). 容器组件(业务逻辑)也能够被复用于不一样'平台'的展现组件
  • 展现和容器组件更好的分离,有助于更好的理解应用和 UI, 二者能够被独立地维护
  • 展现组件变得轻量(无状态/或局部状态), 更容易被测试

了解更多Presentational and Container Components




2️⃣ 分离逻辑和视图

容器组件和展现组件的分离本质上是逻辑和视图的分离. 在React Hooks出现后, 容器组件能够被 Hooks 形式取代, Hooks 能够和视图层更天然的分离, 为视图层提供纯粹的数据来源.

抽离的后业务逻辑能够复用于不一样的'展现平台', 例如 web 版和 native 版:

Login/
  useLogin.ts   // 可复用的业务逻辑
  index.web.tsx
  index.tsx
复制代码

上面使用了useLogin.tsx来单独维护业务逻辑. 能够被 web 平台和 native 平台的代码复用.


不只仅是业务逻辑, 展现组件逻辑也能够分离. 例如上图, FilePickerImagePicker两个组件的'文件上传'逻辑是共享的, 这部分逻辑能够抽取到高阶组件或者 hooks, 甚至是 Context 中(能够统一配置文件上传行为)

分离逻辑和视图的主要方式有:

  • hooks
  • 高阶组件
  • Render Props
  • Context



3️⃣ 有状态组件和无状态组件

无状态组件内部不存储状态, 彻底由外部的 props 来映射. 这类组件以函数组件形式存在, 做为低级/高复用的底层展现型组件. 无状态组件自然就是'纯组件', 若是无状态组件的映射须要一点成本, 可使用 React.memo 包裹避免重复渲染




4️⃣ 纯组件和非纯组件

纯组件的'纯'来源于函数式编程. 指的是对于一个函数而言, 给定相同的输入, 它老是返回相同的输出, 过程没有反作用, 没有额外的状态依赖. 对应到 React 中, 纯组件指的是 props(严格上说还有 state 和 context, 它们也是组件的输入)没有变化, 组件的输出就不会变更.

和 React 组件的输出输出模型相比, Cyclejs对组件输入/输出的抽象则作的更加完全,更加‘函数式’👇。它的组件就是一个普通的函数,只有'单向'的输入和输出:

函数式编程和组件式编程思想某种意义上是一致的, 它们都是'组合'的艺术. 一个大的函数能够有多个职责单一函数组合而成. 组件也是如此. 咱们将一个大的组件拆分为子组件, 对组件作更细粒度的控制, 保持它们的纯净性, 让它们的职责更单一, 更独立. 这带来的好处就是可复用性, 可测试性和可预测性.

纯组件对 React 的性能优化也有重要意义. 若是一个组件是一个纯组件, 若是'输入'没有变更, 那么这个组件就不须要从新渲染. 组件树越大, 纯组件带来的性能优化收益就越高.

咱们能够很容易地保证一个底层组件的纯净性, 由于它原本就很简单. 可是对于一个复杂的组件树, 则须要花点心思进行构建, 因此就有了'状态管理'的需求. 这些状态管理器一般都在组件树的外部维护一个或多个状态库, 而后经过依赖注入形式, 将局部的状态注入到子树中. 经过视图和逻辑分离的原则, 来维持组件树的纯净性.

Redux 就是一个典型的解决方案, 在 Redux 的世界里能够认为一个复杂的组件树就是一颗状态树的映射, 只要状态树(须要依靠不可变数据来保证状态的可预测性)不变, 组件树就不变. Redux 建议保持组件的纯净性, 将组件状态交给 Redux 和配套的异步处理工具来维护, 这样就将整个应用抽象成了一个"单向的数据流", 这是一种简单的"输入/输出"关系

无论是 Cyclejs 仍是 Redux,抽象是须要付出一点代价的,就好比 redux 代码可能会很罗嗦; 一个复杂的状态树, 若是缺少良好的组织,整个应用会变得很难理解。实际上, 并非全部场景都可以顺利/优雅经过'数据驱动'进行表达(能够看一下这篇文章Modal.confirm 违反了 React 的模式吗?), 例如文本框焦点, 或者模态框. 因此没必要极端追求无反作用或者数据驱动

后续会专门写篇文章来回顾总结状态管理.

扩展:




5️⃣ 按照 UI 划分为布局组件内容组件

  • 布局组件用于控制页面的布局,为内容组件提供占位。经过 props 传入组件来进行填充. 好比Grid, Layout, HorizontalSplit
  • 内容组件会包含一些内容,而不只有布局。内容组件一般被布局组件约束在占位内. 好比Button, Label, Input

例以下图, List/List.Item 就是布局组件,而 Input,Address 则是内容组件

将布局从内容组件中抽取出来,分离布局和内容,可让二者更好维护,好比布局变更不会影响内容,内容组件能够被应用不一样的布局; 另外一方面组件是一个自包含内聚的隔离单元, 不该该影响其外部的状态, 例如一个按钮不该该修改外部的布局, 另外也要避免影响全局的样式




6️⃣ 接口一致的数据录入组件

数据录入组件, 或者称为表单, 是客户端开发必不可少的元素. 对于自定义表单组件, 我认为应该保持一致的 API:

interface Props<T> {
  value?: T;
  onChange: (value?: T) => void;
}
复制代码

这样作的好处:

  • 接近原生表单元素原语. 自定义表单组件通常不须要封装到 event 对象中

  • 几乎全部组件库的自定义表单都使用这种 API. 这使得咱们的自定义组件能够和第三方库兼容, 好比antd 的表单验证机制

  • 更容易被动态渲染. 由于接口一致, 能够方便地进行动态渲染或集中化处理, 减小代码重复

  • 回显问题. 状态回显是表单组件的功能之一, 我我的的最佳实践是value应该是自包含的:

    好比一个支持搜索的用户选择器, option 都是异步从后端加载, 若是 value 只保存用户 id, 那么回显的时候就没法显示用户名, 按照个人实践的 value 的结构应该为: {id: string, name: string}, 这样就解决了回显问题. 回显须要的数据都是由父节点传递进来, 而不是组件本身维护

  • 组件都是受控的. 在实际的 React 开发中, 非受控组件的场景很是少, 我认为自定义组件均可以忽略这种需求, 只提供彻底受控表单组件, 避免组件本身维护缓存状态




4. 目录划分

1️⃣ 基本目录结构

关于项目目录结构的划分有两种流行的模式:

  • Rails-style/by-type: 按照文件的类型划分为不一样的目录,例如componentsconstantstypingsviews
  • Domain-style/by-feature: 按照一个功能特性或业务建立单独的文件夹,包含多种类型的文件或目录

实际的项目环境咱们通常使用的是混合模式,下面是一个典型的 React 项目结构:

src/
  components/      # 🔴 项目通用的‘展现组件’
    Button/
      index.tsx    # 组件的入口, 导出组件
      Groups.tsx   # 子组件
      loading.svg  # 静态资源
      style.css    # 组件样式
    ...
    index.ts       # 处处全部组件
  containers/      # 🔴 包含'容器组件'和'页面组件'
    LoginPage/     # 页面组件, 例如登陆
      components/  # 页面级别展现组件,这些组件不能复用与其余页面组件。
        Button.tsx # 组件未必是一个目录形式,对于一个简单组件能够是一个单文件形式. 但仍是推荐使用目录,方便扩展
        Panel.tsx
      reducer.ts   # redux reduces
      useLogin.ts  # (可选)放置'逻辑', 按照👆分离逻辑和视图的原则,将逻辑、状态处理抽取到hook文件
      types.ts     # typescript 类型声明
      style.css
      logo.png
      message.ts
      constants.ts
      index.tsx
    HomePage/
    ...
    index.tsx      # 🔴应用根组件
  hooks/           # 🔴可复用的hook
    useList.ts
    usePromise.ts
  ...
  index.tsx        # 应用入口, 在这里使用ReactDOM对跟组件进行渲染
  stores.ts        # redux stores
  contants.ts      # 全局常量
复制代码

上面使用Domain-style风格划分了LoginPageHomePage目录, 将全部该业务或者页面相关的文件聚合在一块儿; 这里也使用Rails-style模式根据文件类型/职责划分不一样的目录, 好比components, hooks, containers; 你会发如今LoginPage内部也有相似Rails-Style的结构, 如components, 只不过它的做用域不一样, 它只归属于LoginPage, 不能被其余 Page 共享

前端项目通常按照页面路由来拆分组件, 这些组件咱们暂且称为‘页面组件’, 这些组件是和业务功能耦合的,并且每一个页面之间具备必定的独立性.

这里将页面组件放置在containers, 如其名,这个目录本来是用来放置容器组件的, 实际项目中一般是将‘容器组件’和‘页面组件’混合在了一块儿, 现阶段若是要实现纯粹的逻辑分离,我我的以为仍是应该抽取到 hook 中. 这个目录也能够命名为 views, pages...(whatever), 命名为 containers 只是一种习惯(来源于 Redux).

扩展:




2️⃣ 多页应用的目录划分

对于大型应用可能有多个应用入口, 例如不少 electron 应用有多个 windows; 再好比不少应用除了 App 还有后台管理界面. 我通常会这样组织多页应用:

src/
  components/       # 共享组件
  containers/
    Admin/          # 后台管理页面
      components/   # 后台特定的组件库
      LoginPage/
      index.tsx
      ...
    App/
      components/  # App特定的组件库
      LoginPage/   # App页面
      index.tsx
      stores.ts    # redux stores
    AnotherApp/    # 另一个App页面
  hooks/
  ...
  app.tsx          # 应用入口
  anotherApp.tsx   # 应用入口
  admin.tsx        # 后台入口
复制代码

webpack 支持多页应用的构建, 我通常会将应用入口文件命名为*.page.tsx, 而后在 src 自动扫描匹配的文件做为入口.

利用 webpack 的SplitChunksPlugin能够自动为多页应用抽取共享的模块, 这个对于功能差很少和有较多共享代码的多页应用颇有意义. 意味着资源被一块儿优化, 抽取共享模块, 有利于减小编译文件体积, 也便于共享浏览器缓存.

html-webpack-plugin4.0 开始支持注入共享 chunk. 在此以前须要经过 SplitChunksPlugin 显式定义共享的 chunk, 而后也要 html-webpack-plugin 显式注入该 chunk, 比较挫.




3️⃣ 多页应用的目录划分: monorepo 模式

上面的方式, 全部页面都汇集在一个项目下面, 共享同样的依赖和 npm 模块. 这可能会带了一些问题:

  1. 不能容许不一样页面有不一样版本的依赖
  2. 对于毫无相关的应用, 这种组织方式会让代码变得混乱, 例如 App 和后台, 他们使用的技术栈/组件库/交互体验均可能相差较大, 并且容易形成命名冲突.
  3. 构建性能. 你但愿单独对某个页面进行构建和维护, 而不是全部页面混合在一块儿构建

这种场景能够利用lerna或者 yarn workspace 这里 monorepo 机制, 将多页应用隔离在不一样的 npm 模块下, 以 yarn workspace 为例:

package.json
yarn.lock
node_modules/      # 全部依赖都会安装在这里, 方便yarn对依赖进行优化
share/             # 🔴 共享模块
  hooks/
  utils/
admin/             # 🔴 后台管理应用
  components/
  containers/
  index.tsx
  package.json     # 声明本身的模块以及share模块的依赖
app/               # 🔴 后台管理应用
  components/
  containers/
  index.tsx
  package.json     # 声明本身的模块以及share模块的依赖
复制代码

扩展:




4️⃣ 跨平台应用

使用 ReactNative 能够将 React 衍生到原生应用的开发领域. 尽管也有react-native-web这样的解决方案, Web 和 Native 的 API/功能/开发方式, 甚至产品需求上可能会相差很大, 长此以往就可能出现大量没法控制的适配代码; 另外 react-native-web 自己也可能成为风险点。 因此一些团队须要针对不一样平台进行开发, 通常按照下面风格来组织跨平台应用:

src/
  components/
    Button/
      index.tsx     # 🔴 ReactNative 组件
      index.web.tsx # 🔴 web组件, 以web.tsx为后缀
      loading.svg   # 静态资源
      style.css     # 组件样式
    ...
    index.ts
    index.web.ts
  containers/
    LoginPage/
      components/
      ....
      useLogin.ts   # 🔴 存放分离的逻辑,能够在React Native和Web组件中共享
      index.web.tsx
      index.tsx
    HomePage/
    ...
    index.tsx
  hooks/
    useList.ts
    usePromise.ts
  ...
  index.web.tsx        # web应用入口
  index.tsx            # React Native 应用入口
复制代码

能够经过 webpack 的resolve.extensions来配置扩展名补全的优先级. 早期的antd-mobile就是这样组织的.




5️⃣ 跨平台的另一种方式: taro

对于国内的开发者来讲,跨平台可不仅 Native 那么简单,咱们还有各类各样的小程序、小应用。终端的碎片化让前端的开发工做愈来愈有挑战性.

Taro 就这样诞生了, Taro 基于 React 的标准语法(DSL), 结合编译原理的思想, 将一套代码转换为多种终端的目标代码, 并提供一套统一的内置组件库和 SDK 来抹平多端的差别

由于 Taro 使用 React 的标准语法和 API,这使得咱们按照原有的 React 开发约定和习惯来开发多端应用,且只保持一套代码. 可是不要忘了抽象都是有代价的

能够查看 Taro 官方文档了解更多

Flutter是近期比较或的跨平台方案,可是跟本文主题无关




5. 模块

1️⃣ 建立严格的模块边界

下图是一个某页面的模块导入,至关混乱,这还算能够接受,笔者还见过上千行的组件,其中模块导入语句就占一百多行. 这有一部分缘由多是 VsCode 自动导入功能致使(可使用 tslint 规则对导入语句进行排序和分组规范),更大的缘由是这些模块缺少组织。

我以为应该建立严格的模块边界,一个模块只有一个统一的'出口'。例如一个复杂的组件:

ComplexPage/
  components/
    Foo.tsx
    Bar.tsx
  constants.ts
  reducers.ts
  style.css
  types.ts
  index.tsx # 出口
复制代码

能够认为一个‘目录’就是一个模块边界. 你不该该这样子导入模块:

import ComplexPage from '../ComplexPage';
import Foo from '../ComplexPage/components/Foo';
import Foo from '../ComplexPage/components/Bar';
import { XXX } from '../ComplexPage/components/constants';
import { User, ComplexPageProps } from '../ComplexPage/components/type';
复制代码

一个模块/目录应该由一个‘出口’文件来统一管理模块的导出,限定模块的可见性. 好比上面的模块,components/Foocomponents/Barconstants.ts这些文件实际上是 ComplexPage 组件的'实现细节'. 这些是外部模块不该该去耦合实现细节,但这个在语言层面并无一个限定机制,只能依靠规范约定.

当其余模块依赖某个模块的'细节'时, 多是一种重构的信号: 好比依赖一个模块的一个工具函数或者是一个对象类型声明, 这时候可能应该将其抬升到父级模块, 让兄弟模块共享它.

在前端项目中 index 文件最适合做为一个'出口'文件, 当导入一个目录时,模块查找器会查找该目录下是否存在的 index 文件. 开发者设计一个模块的 API 时, 须要考虑模块各类使用方式, 并使用 index 文件控制模块可见性:

// 导入外部模块须要使用的类型
export * from './type';
export * from './constants';
export * from './reducers';

// 不暴露外部不须要关心的实现细节
// export * from './components/Foo'
// export * from './components/Bar'

// 模块的默认导出
export { ComplexPage as default } from './ComplexPage';
复制代码

如今导入语句能够更加简洁:

import ComplexPage, { ComplexPageProps, User, XXX } from '../ComplexPage';
复制代码

这条规则也能够用于组件库. 在 webpack 的 Tree-shaking 特性还不成熟以前, 咱们都使用了各类各样的技巧来实现按需导入. 例如babel-plugin-import或直接子路径导入:

import TextField from '~/components/TextField';
import SelectField from '~/components/SelectField';
import RaisedButton from '~/components/RaisedButton';
复制代码

如今可使用Named import直接导入,让 webpack 来帮你优化:

import { TextField, SelectField, RaisedButton } from '~/components';
复制代码

但不是全部目录都有出口文件, 这时候目录就不是模块的边界了. 典型的有utils/, utils 只是一个模块命名空间, utils 下面的文件都是一些互不相关或者不一样类型的文件:

utils/
  common.ts
  dom.ts
  sdk.ts
复制代码

咱们习惯直接引用这些文件, 而不是经过一个入口文件, 这样能够更明确导入的是什么类型的:

import { hide } from './utils/dom'; // 经过文件名能够知道, 这多是隐藏某个DOM元素
import { hide } from './utils/sdk'; // webview sdk 提供的的某个方法
复制代码

最后再总结一下:

根据模块边界原则(如上图): 一个模块能够访问兄弟(同个做用域下)、 祖先及祖先的兄弟模块. 例如:

  • Bar 能够访问 Foo, 但不能再向下访问它的细节, 即不能访问../Foo/types.ts, 但能够访问它的出口文件../Foo
  • src/types.ts 不能访问 containers/HomePage
  • LoginPage 和访问 HomePage
  • LoginPage 能够访问 utils/sdk



2️⃣ Named export vs default export

这两种导出方式都有各自的适用场景,这里不该该一棒子打死就不使用某种导出方式. 首先看一下named export 有什么优势:

  • 命名肯定

    • 方便 Typescript 进行重构

    • 方便智能提醒和自动导入(auto-import)识别

    • 方便 reexport

      // named
      export * from './named-export';
      
      // default
      export { default as Foo } from './default-export';
      复制代码
  • 一个模块支持多个named export


再看一下default export有什么优势?:

  • default export通常表明‘模块自己’, 当咱们使用‘默认导入’导入一个模块时, 开发者是天然而然知道这个默认导入的是一个什么对象。

    例如 react 导出的是一个 React 对象; LoginPage 导出的是一个登陆页面; somg.png 导入的是一张图片. 这类模块总有一个肯定的'主体对象'. 因此默认导入的名称和模块的名称通常是保持一致的(Typescript 的 auto-import 就是基于文件名).

    固然'主体对象'是一种隐式的概念, 你只能经过规范去约束它

  • default export的导入语句更加简洁。例如lazy(import('./MyPage'))

default export也有一些缺点:

  • 和其余模块机制(commonjs)互操做时比较难以理解. 例如咱们会这样子导入default export: require('./xx').default
  • named import 优势就是default export的缺点

因此总结一下:

  1. 对于'主体对象'明确的模块须要有默认导出, 例如页面组件,类
  2. 对于'主体对象'不明确的模块不该该使用默认导出,例如组件库、utils(放置各类工具方法)、contants 常量

按照这个规则能够这样子组织 components 目录:

components/
    Foo/
      Foo.tsx
      types.ts
      constants.ts
      index.ts         # 导出Foo组件
    Bar/
      Bar.tsx
      index.tsx
    index.ts           # 导出全部组件
复制代码

对于 Foo 模块来讲, 存在一个主体对象即 Foo 组件, 因此这里使用default export导出的 Foo 组件, 代码为:

// index.tsx
// 这三个文件所有使用named export导出
export * from './contants';
export * from './types';
export * from './Foo';

// 导入主体对象
export { Foo as default } from './Foo';
复制代码

如今假设 Bar 组件依赖于 Foo:

// components/Bar/Bar.tsx
import React from 'react';

// 导入Foo组件, 根据模块边界规则, 不能直接引用../Foo/Foo.tsx
import Foo from '../Foo';

export const Bar = () => {
  return (
    <div>
      <Foo />
    </div>
  );
};

export default Bar;
复制代码

对于components模块来讲,它的全部子模块都是平等的,因此不存在一个主体对象,default export在这里不适用。 components/index.ts代码:

// components/index.ts
export * from './Foo';
export * from './Bar';
复制代码



3️⃣ 避免循环依赖

循环依赖是模块糟糕设计的一个表现, 这时候你须要考虑拆分和设计模块文件, 例如

// --- Foo.tsx ---
import Bar from './Bar';

export interface SomeType {}

export const Foo = () => {};
Foo.Bar = Bar;

// --- Bar.tsx ----
import { SomeType } from './Foo';
...
复制代码

上面 Foo 和 Bar 组件就造成了一个简单循环依赖, 尽管它不会形成什么运行时问题. 解决方案就是将 SomeType 抽取到单独的文件:

// --- types.ts ---
export interface SomeType {}

// --- Foo.tsx ---
import Bar from './Bar';
import {SomeType} from './types'

export const Foo = () => {};
...
Foo.Bar = Bar;

// --- Bar.tsx ----
import {SomeType} from './types'
...
复制代码



4️⃣ 相对路径不要超过两级

当项目愈来愈复杂, 目录可能会愈来愈深, 这时候会出现这样的导入路径:

import { hide } from '../../../utils/dom';
复制代码

首先这种导入语句很是不优雅, 并且可读性不好. 当你在不清楚当前文件的目录上下文时, 你不知道具体模块在哪; 即便你知道当前文件的位置, 你也须要跟随导入路径在目录树中向上追溯在能定位到具体模块. 因此这种相对路径是比较反人类的.

另外这种导入路径不方便模块迁移(尽管 Vscode 支持移动文件时重构导入路径), 文件迁移须要重写这些相对导入路径.

因此通常推荐相对路径导入不该该超过两级, 即只能是.././. 能够尝试将相对路径转换成绝对路径形式, 例如webpack中能够配置resolve.alias属性来实现:

...
    resolve: {
      ...
      alias: {
        // 能够直接使用~访问相对于src目录的模块
        // 如 ~/components/Button
        '~': context,
      },
    }
复制代码

如今咱们能够这样子导入相对于src的模块:

import { hide } from '~/utils/dom';
复制代码

扩展




6. 拆分

1️⃣ 拆分 render 方法

当 render 方法的 JSX 结构很是复杂的时候, 首先应该尝试分离这些 JSX, 最简单的作法的就是拆分为多个子 render 方法:

固然这种方式只是暂时让 render 方法看起来没有那么复杂, 它并无拆分组件自己, 全部输入和状态依然汇集在一个组件下面. 因此一般拆分 render 方法只是重构的第一步: 随着组件愈来愈复杂, 表现为文件愈来愈长, 笔者通常将 300 行做为一个阈值, 超过 300 行则说明须要对这个组件进进一步拆分




2️⃣ 拆分为组件

若是已经按照 👆 上述方法对组件的 render 拆分为多个子 render, 当一个组件变得臃肿时, 就能够方便地将这些子 render 方法拆分为组件. 通常组件抽离有如下几种方式:

  1. 纯渲染拆分: 子 render 方法通常是纯渲染的, 他们能够很直接地抽离为无状态组件
public render() {
  const { visible } = this.state
  return (
    <Modal
      visible={visible}
      title={this.getLocale('title')}
      width={this.width}
      maskClosable={false}
      onOk={this.handleOk}
      onCancel={this.handleCancel}
      footer={<Footer {...}></Footer>}
    >
    <Body {...}></Body>
  </Modal>
  )
}
复制代码
  1. 纯逻辑拆分: 按照逻辑和视图分离的原则, 将逻辑控制部分抽离到 hooks 或高阶组件中
  2. 逻辑和渲染拆分: 将相关的视图和逻辑抽取出去造成一个独立的组件, 这是更为完全的拆分方式, 贯彻单一职责原则.



7. 组件划分示例

咱们通常会从 UI 原型图中分析和划分组件, 在 React 官方的Thinking in react也提到经过 UI 来划分组件层级: "这是由于 UI 和数据模型每每遵循着相同的信息架构,这意味着将 UI 划分红组件的工做每每是很容易的。只要把它划分红能准确表示你数据模型的一部分的组件就能够". 组件划分除了须要遵循上文 👆 提到的一些原则, 他还依赖于你的开发经验.

本节经过一个简单的应用讲述划分组件的过程. 这是某政府部门的服务申报系统, 一共由四个页面组成:

1️⃣ 划分页面

页面一般是最顶层的组件单元, 划分页面很是简单, 咱们根据原型图就能够划分四个页面: ListPage, CreatePage, PreviewPage, DetailPage

src/
  containers/
    ListPage/
    CreatePage/
    PreviewPage/
    DetailPage/
    index.tsx     # 根组件, 通常在这里定义路由
复制代码



2️⃣ 划分基础 UI 组件

首先看ListPage

ListPage 根据 UI 能够划分为下面这些组件:

ScrollView        # 滚动视图, 提供下拉刷新, 无限加载等功能
  List            # 列表容器, 布局组件
    Item          # 列表项, 布局组件, 提供header, body等占位符
      props - header
         Title       # 渲染标题
      props - after
         Time        # 渲染时间
      props - body
         Status      # 渲染列表项的状态
复制代码

再看看CreatePage

这是一个表单填写页面, 为了提升表单填写体验, 这里划分为多个步骤; 每一个步骤里有还有多个表单分组; 每一个表单的结构都差很少, 左边是 label 展现, 右边是实际表单组件, 因此根据 UI 能够对组件进行这样的划分:

CreatePage
  Steps            # 步骤容器, 提供了步骤布局和步骤切换等功能
    Step           # 单一步骤容器
      List         # 表单分组
        List.Item  # 表单容器, 支持设置label
          Input    # 具体表单类型
          Address
          NumberInput
          Select
          FileUpload
复制代码

组件命名的建议: 对于集合型组件, 通常会使用单复数命名, 例如上面的 Steps/Step; List/Item 这种形式也比较常见, 例如 Form/Form.Item, 这种形式比较适合做为子组件形式. 能够学习一下第三方组件库是怎么给组件命名的.

再看一下PreviewPage, PreviewPage 是建立后的数据预览页面, 数据结构和页面结构和 CreatePage 差很少. 将Steps 对应到 Preview 组件, Step 对应到 Preview.Item. Input 对应到 Input.Preview:




3️⃣ 设计组件的状态

对于 ListPage 来讲状态比较简单, 这里主要讨论 CreatePage 的状态. CreatePage 的特色:

  • 表单组件使用受控模式, 自己不会存储表单的状态. 另外表单之间的状态多是联动的
  • 状态须要在 CreatePage 和 PreviewPage 之间共享
  • 须要对表单进行统一校验
  • 草稿保存

因为须要在 CreatePage 和 PreviewPage 中共享数据, 表单的状态应该抽取和提高到父级. 在这个项目的实际开发中, 个人作法是建立一个 FormStore 的 Context 组件, 下级组件经过这个 context 来统一存储数据. 另外我决定使用配置的方式, 来渲染动态这些表单. 大概的结构以下:

// CreatePage/index.tsx
<FormStore defaultValue={draft} onChange={saveDraft}>
  <Switch>
    <Route path="/create/preview" component={Preview} />
    <Route path="/create" component={Create} />
  </Switch>
</FormStore>

// CreatePage/Create.tsx
<Steps>
  {steps.map(i =>
    <Step key={i.name}>
      <FormRenderer forms={i.forms}  /> {/* forms为表单配置, 根据配置的表单类型渲染表单组件, 从FormStore的获取和存储值 */}
    </Step>
  )}
</Steps>
复制代码



8. 文档

组件的文档化推荐使用Storybook, 这是一个组件 Playground, 有如下特性

  • 可交互的组件示例
  • 能够用于展现组件的文档. 支持 props 生成和 markdown
  • 能够用于组件测试. 支持组件结构测试, 交互测试, 可视化测试, 可访问性或者手动测试
  • 丰富的插件生态

React 示例. 因为篇幅缘由, Storybook 就不展开细节, 有兴趣的读者能够参考官方文档.




扩展

相关文章
相关标签/搜索