一个复杂的应用都是由简单的应用发展而来的, 随着愈来愈多的功能加入项目, 代码就会变得愈来愈难以控制. 本文章主要探讨在大型项目中如何对组件进行组织, 让项目具有可维护性.css
系列目录html
目录前端
单一职责(Single Responsibility Principle). 这本来来源于面向对象编程, 规范定义是"一个类应该只有一个发生变化的缘由", 白话说"一个类只负责一件事情". 无论是什么编程范式, 只要是模块化的程序设计都适用单一职责原则. 在 React 中, 组件就是模块.node
单一职责要求将组件限制在一个'合适'的粒度. 这个粒度是比较主观的概念, 换句话说'单一'是一个相对的概念. 我我的以为单一职责并非追求职责粒度的'最小'化, 粒度最小化是一个极端, 可能会致使大量模块, 模块离散化也会让项目变得难以管理. 单一职责要求的是一个适合被复用的粒度.react
每每一开始咱们设计的组件均可能复合多个职责, 后来出现了代码重复或者模块边界被打破(好比一个模块依赖另外一个模块的'细节'), 咱们才会惰性将可复用的代码抽离. 随着愈来愈多的重构和迭代, 模块职责可能会愈来愈趋于'单一'(😂 看谁, 也可能变成面条).webpack
固然有经验的开发者能够一开始就能考虑组件的各类应用场景, 能够观察到模块的重合边界. 对于入门者来讲Don't repeat yourself
原则更有用, 不要偷懒/多思考/重构/消除重复代码, 你的能力就会慢慢提高git
单一职责的收益:github
一个高质量的组件必定是高内聚, 低耦合
的, 这两个原则或者特征是组件独立性的一个判断标准.web
高内聚, 要求一个组件有一个明确的组件边界, 将紧密相关的内容汇集在一个组件下, 实现"专注"的功能. 和传统的前端编程不同, 一个组件是一个自包含的单元, 它包含了逻辑/样式/结构, 甚至是依赖的静态资源. 这也使得组件自然就是一个比较独立的个体. 固然这种独立性是相对的, 为了最大化这种独立性, 须要根据单一职责将组件拆分为更小粒度的组件, 这样能够被更灵活的组合和复用.typescript
虽然组件是独立的, 可是他须要和其余组件进行组合才能实现应用, 这就有了'关联'. 低耦合要求最小化这种关联性, 好比明确模块边界不该该访问其余组件的内部细节, 组件的接口最小化, 单向数据流等等
文章后续内容主要讨论实现高内聚/低耦合主要措施
这些技巧来源于react-bits:
容器组件和展现组件分离是 React 开发的重要思想, 它影响的 React 应用项目的组织和架构. 下面总结一下二者的区别:
容器组件 | 展现组件 | |
---|---|---|
关注点 | 业务 | UI |
数据源 | 状态管理器/后端 | props |
组件形式 | 高阶组件 | 普通组件 |
展现组件是一个只关注展现的'元件', 为了能够在多个地方被复用, 它不该该耦合'业务/功能', 或者说不该该过渡耦合. 像antd
这类组件库提供通用组件显然就是'展现组件'
下面是一个典型的应用目录结构, 咱们能够看到展现组件与业务/功能是可能有不一样的耦合程度的, 和业务的耦合程度越低, 通用性/可复用性越强:
node_modules/antd/ 🔴 通用的组件库, 不能和任何项目的业务耦合
src/
components/ 🔴 项目通用的组件库, 能够被多个容器/页面组件共享
containers/
Foo/
components/ 🔴 容器/页面组件特有的组件库, 和一个业务/功能深度耦合. 以至于不能被其余容器组件共享
index.tsx
Bar/
components/
index.tsx
复制代码
对于展现组件,咱们要以一种'第三方组件库'的标准来考虑组件的设计, 减小与业务的耦合度, 考虑各类应用的场景, 设计好公开的接口.
容器组件主要关注业务处理. 容器组件通常以'高阶组件'形式存在, 它通常 ① 从外部数据源(redux 这些状态管理器或者直接请求服务端数据)获取数据, 而后 ② 组合展现组件来构建完整的视图.
容器组件经过组合展现组件来构建完整视图, 但二者未必是简单的包含与被包含的关系.
容器组件和展现组件的分离
能够带来好处主要是可复用性和可维护性:
了解更多Presentational and Container Components
容器组件和展现组件
的分离本质上是逻辑和视图
的分离. 在React Hooks
出现后, 容器组件能够被 Hooks 形式取代, Hooks 能够和视图层更天然的分离, 为视图层提供纯粹的数据来源.
抽离的后业务逻辑能够复用于不一样的'展现平台', 例如 web 版和 native 版:
Login/
useLogin.ts // 可复用的业务逻辑
index.web.tsx
index.tsx
复制代码
上面使用了useLogin.tsx
来单独维护业务逻辑. 能够被 web 平台和 native 平台的代码复用.
不只仅是业务逻辑, 展现组件逻辑也能够分离. 例如上图, FilePicker
和ImagePicker
两个组件的'文件上传'逻辑是共享的, 这部分逻辑能够抽取到高阶组件或者 hooks, 甚至是 Context 中(能够统一配置文件上传行为)
分离逻辑和视图的主要方式有:
无状态组件内部不存储状态, 彻底由外部的 props 来映射. 这类组件以函数组件形式存在, 做为低级/高复用的底层展现型组件. 无状态组件自然就是'纯组件', 若是无状态组件的映射须要一点成本, 可使用 React.memo 包裹避免重复渲染
纯组件的'纯'来源于函数式编程. 指的是对于一个函数而言, 给定相同的输入, 它老是返回相同的输出, 过程没有反作用, 没有额外的状态依赖. 对应到 React 中, 纯组件指的是 props(严格上说还有 state 和 context, 它们也是组件的输入)没有变化, 组件的输出就不会变更.
和 React 组件的输出输出模型相比, Cyclejs对组件输入/输出的抽象则作的更加完全,更加‘函数式’👇。它的组件就是一个普通的函数,只有'单向'的输入和输出:
函数式编程和组件式编程思想某种意义上是一致的, 它们都是'组合'的艺术. 一个大的函数能够有多个职责单一函数组合而成. 组件也是如此. 咱们将一个大的组件拆分为子组件, 对组件作更细粒度的控制, 保持它们的纯净性, 让它们的职责更单一, 更独立. 这带来的好处就是可复用性, 可测试性和可预测性.
纯组件对 React 的性能优化也有重要意义. 若是一个组件是一个纯组件, 若是'输入'没有变更, 那么这个组件就不须要从新渲染. 组件树越大, 纯组件带来的性能优化收益就越高.
咱们能够很容易地保证一个底层组件的纯净性, 由于它原本就很简单. 可是对于一个复杂的组件树, 则须要花点心思进行构建, 因此就有了'状态管理'的需求. 这些状态管理器一般都在组件树的外部维护一个或多个状态库, 而后经过依赖注入形式, 将局部的状态注入到子树中. 经过视图和逻辑分离的原则, 来维持组件树的纯净性.
Redux 就是一个典型的解决方案, 在 Redux 的世界里能够认为一个复杂的组件树就是一颗状态树的映射, 只要状态树(须要依靠不可变数据来保证状态的可预测性)不变, 组件树就不变. Redux 建议保持组件的纯净性, 将组件状态交给 Redux 和配套的异步处理工具来维护, 这样就将整个应用抽象成了一个"单向的数据流", 这是一种简单的"输入/输出"关系
无论是 Cyclejs 仍是 Redux,抽象是须要付出一点代价的,就好比 redux 代码可能会很罗嗦; 一个复杂的状态树, 若是缺少良好的组织,整个应用会变得很难理解。实际上, 并非全部场景都可以顺利/优雅经过'数据驱动'进行表达(能够看一下这篇文章Modal.confirm 违反了 React 的模式吗?), 例如文本框焦点, 或者模态框. 因此没必要极端追求无反作用或者数据驱动
后续会专门写篇文章来回顾总结状态管理.
扩展:
布局组件
和内容组件
Grid
, Layout
, HorizontalSplit
Button
, Label
, Input
例以下图, List/List.Item 就是布局组件,而 Input,Address 则是内容组件
将布局从内容组件中抽取出来,分离布局和内容,可让二者更好维护,好比布局变更不会影响内容,内容组件能够被应用不一样的布局; 另外一方面组件是一个自包含内聚的隔离单元, 不该该影响其外部的状态, 例如一个按钮不该该修改外部的布局, 另外也要避免影响全局的样式
数据录入组件, 或者称为表单, 是客户端开发必不可少的元素. 对于自定义表单组件, 我认为应该保持一致的 API:
interface Props<T> {
value?: T;
onChange: (value?: T) => void;
}
复制代码
这样作的好处:
接近原生表单元素原语. 自定义表单组件通常不须要封装到 event 对象中
几乎全部组件库的自定义表单都使用这种 API. 这使得咱们的自定义组件能够和第三方库兼容, 好比antd 的表单验证机制
更容易被动态渲染. 由于接口一致, 能够方便地进行动态渲染或集中化处理, 减小代码重复
回显问题. 状态回显是表单组件的功能之一, 我我的的最佳实践是value
应该是自包含的:
好比一个支持搜索的用户选择器, option 都是异步从后端加载, 若是 value 只保存用户 id, 那么回显的时候就没法显示用户名, 按照个人实践的 value 的结构应该为: {id: string, name: string}
, 这样就解决了回显问题. 回显须要的数据都是由父节点传递进来, 而不是组件本身维护
组件都是受控的. 在实际的 React 开发中, 非受控组件的场景很是少, 我认为自定义组件均可以忽略这种需求, 只提供彻底受控表单组件, 避免组件本身维护缓存状态
关于项目目录结构的划分有两种流行的模式:
components
、constants
、 typings
、views
实际的项目环境咱们通常使用的是混合模式,下面是一个典型的 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
风格划分了LoginPage
和HomePage
目录, 将全部该业务或者页面相关的文件聚合在一块儿; 这里也使用Rails-style
模式根据文件类型/职责划分不一样的目录, 好比components
, hooks
, containers
; 你会发如今LoginPage
内部也有相似Rails-Style
的结构, 如components
, 只不过它的做用域不一样, 它只归属于LoginPage
, 不能被其余 Page 共享
前端项目通常按照页面路由来拆分组件, 这些组件咱们暂且称为‘页面组件’, 这些组件是和业务功能耦合的,并且每一个页面之间具备必定的独立性.
这里将页面组件放置在containers
, 如其名,这个目录本来是用来放置容器组件的, 实际项目中一般是将‘容器组件’和‘页面组件’混合在了一块儿, 现阶段若是要实现纯粹的逻辑分离,我我的以为仍是应该抽取到 hook 中. 这个目录也能够命名为 views, pages...(whatever), 命名为 containers 只是一种习惯(来源于 Redux).
扩展:
对于大型应用可能有多个应用入口, 例如不少 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-plugin
4.0 开始支持注入共享 chunk. 在此以前须要经过 SplitChunksPlugin 显式定义共享的 chunk, 而后也要 html-webpack-plugin 显式注入该 chunk, 比较挫.
上面的方式, 全部页面都汇集在一个项目下面, 共享同样的依赖和 npm 模块. 这可能会带了一些问题:
这种场景能够利用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模块的依赖
复制代码
扩展:
使用 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就是这样组织的.
对于国内的开发者来讲,跨平台可不仅 Native 那么简单,咱们还有各类各样的小程序、小应用。终端的碎片化让前端的开发工做愈来愈有挑战性.
Taro 就这样诞生了, Taro 基于 React 的标准语法(DSL), 结合编译原理的思想, 将一套代码转换为多种终端的目标代码, 并提供一套统一的内置组件库和 SDK 来抹平多端的差别
由于 Taro 使用 React 的标准语法和 API,这使得咱们按照原有的 React 开发约定和习惯来开发多端应用,且只保持一套代码. 可是不要忘了抽象都是有代价的
能够查看 Taro 官方文档了解更多
Flutter是近期比较或的跨平台方案,可是跟本文主题无关
下图是一个某页面的模块导入,至关混乱,这还算能够接受,笔者还见过上千行的组件,其中模块导入语句就占一百多行. 这有一部分缘由多是 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/Foo
、 components/Bar
和constants.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 提供的的某个方法
复制代码
最后再总结一下:
根据模块边界原则(如上图): 一个模块能够访问兄弟(同个做用域下)、 祖先及祖先的兄弟模块. 例如:
../Foo/types.ts
, 但能够访问它的出口文件../Foo
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
也有一些缺点:
default export
: require('./xx').default
named import
优势就是default export
的缺点因此总结一下:
按照这个规则能够这样子组织 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';
复制代码
循环依赖是模块糟糕设计的一个表现, 这时候你须要考虑拆分和设计模块文件, 例如
// --- 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'
...
复制代码
当项目愈来愈复杂, 目录可能会愈来愈深, 这时候会出现这样的导入路径:
import { hide } from '../../../utils/dom';
复制代码
首先这种导入语句很是不优雅, 并且可读性不好. 当你在不清楚当前文件的目录上下文时, 你不知道具体模块在哪; 即便你知道当前文件的位置, 你也须要跟随导入路径在目录树中向上追溯在能定位到具体模块. 因此这种相对路径是比较反人类的.
另外这种导入路径不方便模块迁移(尽管 Vscode 支持移动文件时重构导入路径), 文件迁移须要重写这些相对导入路径.
因此通常推荐相对路径导入不该该超过两级, 即只能是../
和./
. 能够尝试将相对路径转换成绝对路径形式, 例如webpack
中能够配置resolve.alias
属性来实现:
...
resolve: {
...
alias: {
// 能够直接使用~访问相对于src目录的模块
// 如 ~/components/Button
'~': context,
},
}
复制代码
如今咱们能够这样子导入相对于src
的模块:
import { hide } from '~/utils/dom';
复制代码
扩展
babel-plugin-module-resolver
插件来转换为相对路径当 render 方法的 JSX 结构很是复杂的时候, 首先应该尝试分离这些 JSX, 最简单的作法的就是拆分为多个子 render 方法:
固然这种方式只是暂时让 render 方法看起来没有那么复杂, 它并无拆分组件自己, 全部输入和状态依然汇集在一个组件下面. 因此一般拆分 render 方法只是重构的第一步: 随着组件愈来愈复杂, 表现为文件愈来愈长, 笔者通常将 300 行做为一个阈值, 超过 300 行则说明须要对这个组件进进一步拆分
若是已经按照 👆 上述方法对组件的 render 拆分为多个子 render, 当一个组件变得臃肿时, 就能够方便地将这些子 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>
)
}
复制代码
逻辑和视图分离
的原则, 将逻辑控制部分抽离到 hooks 或高阶组件中咱们通常会从 UI 原型图中分析和划分组件, 在 React 官方的Thinking in react也提到经过 UI 来划分组件层级: "这是由于 UI 和数据模型每每遵循着相同的信息架构,这意味着将 UI 划分红组件的工做每每是很容易的。只要把它划分红能准确表示你数据模型的一部分的组件就能够". 组件划分除了须要遵循上文 👆 提到的一些原则, 他还依赖于你的开发经验.
本节经过一个简单的应用讲述划分组件的过程. 这是某政府部门的服务申报系统, 一共由四个页面组成:
页面一般是最顶层的组件单元, 划分页面很是简单, 咱们根据原型图就能够划分四个页面: ListPage
, CreatePage
, PreviewPage
, DetailPage
src/
containers/
ListPage/
CreatePage/
PreviewPage/
DetailPage/
index.tsx # 根组件, 通常在这里定义路由
复制代码
首先看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:
对于 ListPage 来讲状态比较简单, 这里主要讨论 CreatePage 的状态. CreatePage 的特色:
因为须要在 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>
复制代码
组件的文档化推荐使用Storybook, 这是一个组件 Playground
, 有如下特性
React 示例. 因为篇幅缘由, Storybook 就不展开细节, 有兴趣的读者能够参考官方文档.