什么开闭原则?
开闭原则(Open Closed Principle)是Java世界里最基础的设计原则,它指导我们如何建立一个稳定的、灵活的系统。
设计模式之六大原则——开闭原则(OCP):一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。
例子
这是一个实战中的项目,需求目标很简单:提供统一内容搜索能力 ,包括 文档,知识,视频。可以通过目录树切换查看该库 的 文档详情/知识列表/视频列表。
搜索页面比较简单,这里就不讲了。重点看详情,列表,目录树/文档树 设计。
概念
- 库:每种内容类型都归属于一个库,比如,有文档库A,文档库B....
- 内容类型:目前搜索范围 是
- 文档:下面简称Doc
- 知识:下面简称Faq
- 视频:下面简称Video
- 类目:不是所有的内容类型都有类目树。在这个例子里面,Faq和Video有目录节点,即每个目录节点对应一组Faq/Video。但 Doc 类型是通过每一篇文档指定parent属性将文档上下级关系串联起来,所以,它的类目树就是文档树。
类似的交互图
用例图
第一步:梳理异同
动手之前,先撸一撸基于内容类型,交互的相同点和不同点。
- 相同点:
1.1 目录树/文档树 展示UI完成一样,都是标准<Tree />
组件
1.2 目录树点击只会触发两种方式:展示【列表】 或者 【详情】
1.3 文案型的详情都是富文本展示 - 不同点
1.1 列表页面展示UI基于内容类型不同而不同
1.2 详情页展示UI基于内容类型不同而不同,但是部分可归类
最后考虑下拓展性。假设,以后新增了 【案例】这种内容类型,列表可能用<Table />
组件,详情页可能是JSON式格式化数据渲染,那么,如何最小成本支持该类型呢?
这就是该实战需要解决的问题:对扩展开放,对修改关闭
第二步:按照“面条”思维做第一版本
先不要急着一蹴而就,可以流程化的做一个简单版本,注意,此时不要将重点放在UI上(别急着画样式),搭建框架更重要。
第一版文件结构可能如下:
++ /pages
++++++/List // 列表页
+++++++++/index.tsx
+++++++++/Faq.tsx // Faq列表组件
+++++++++/Doc.tsx // Doc列表组件
+++++++++/Video.tsx // Video列表组件
++++++/Detail // 详情页
+++++++++/index.tsx
+++++++++/Faq.tsx // Faq列表组件
+++++++++/Doc.tsx // Doc列表组件
+++++++++/Video.tsx // Video列表组件
++ /components
++++++/CategoryTree // 目录树组件
++++++/RichHtml // 富文本渲染组件
...
看起来还不错哦,只要在List & Detail/index.tsx
和 CategoryTree
代码里面里面判断下内容类型,就可以愉快的加载不同的内容组件了。
export enum ContentTypes {
FAQ = 'Faq',
DOC = 'Doc',
VIDEO = 'Video',
}
想一想,这个方案的问题在哪里?
如果新增了一个【案例】case类型,需要修改多少地方?
- 新增两个case.tsx组件,分别为列表和详情
- 修改两个入口文件
index.tsx
,新增case类型 - 修改
CategoryTree
组件,新增新类型点击事件
可以看出来,第1点是必须要做的,而其他修改比较散乱。有没有什么更好的方案呢?
第三部:抽象,封装
详情和列表的主页面需要关系类型内容吗?可以不需要!
先看下新版的列表主页代码。
import React, { FC } from 'react'
import { useParams } from 'react-router-dom'
import CategoryTree from '@/components/CategoryTree'
import { isCorrectType, getTreeLink } from '@/components/ContentComp'
import TwoColsLayout from '@/components/TwoColsLayout'
import ContentList from '@/components/ContentComp/List'
type RouteParams = {
contentType: string
libraryCode: string
cateCode: string
}
export type DetailListParams = {
contentType: string
data: Record<string, any>
}
/**
* 列表页面 /list/[contentType]/[libraryCode]/[cateCode]
*/
const List: FC = () => {
const { contentType, libraryCode, cateCode } = useParams<RouteParams>()
const isCurrentList = isCorrectType(contentType)
return (
<TwoColsLayout
isShow={isCurrentList}
leftComponent={
<CategoryTree
contentType={contentType}
libraryCode={libraryCode}
libraryCode={libraryCode}
currentCategoryCode={cateCode}
getTreeLink={getTreeLink(contentType)}
/>
}
rightComponent={
<ContentList
contentType={contentType}
libraryCode={libraryCode}
cateCode={cateCode}
/>
}
/>
)
}
export default List
其中,最重要的就是 @/components/ContentComp/List
组件 和 @/components/ContentComp
提供的 { isCorrectType, getTreeLink }
函数。
一探究竟吧!
// @/components/ContentComp/List 组件
import React, { useState, useEffect } from 'react'
import ListFooterHandler, { DEFAULT_PAGE_SIZE } from '@/components/ListFooter'
import { ContentTypes } from '@/utils/const'
import { getContentList } from '@/services/index'
import FaqList from './Faq/List'
import VideoList from './Video/List'
export const ContentListConfig = {
[ContentTypes.FAQ]: FaqList,
[ContentTypes.VIDEO]: VideoList,
}
/**
* 因为列表数据只有List组件使用,所以,List 组件自行获取数据且渲染。
*
* @param { contentType, libraryCode, cateCode }
* @returns
*/
const ContentList = ({ contentType, libraryCode, cateCode }) => {
const [listData, setListData] = useState({
datas: [],
totalCount: 0,
})
const [searchParam, setSearchParam] = useState({
contentType,
libraryCode,
cateCode,
offset: 0,
limit: DEFAULT_PAGE_SIZE,
})
useEffect(() => {
console.log('get content list!')
const newParams = { ...searchParam, contentType, libraryCode, cateCode }
const result = getContentList(newParams)
setListData(result)
setSearchParam(newParams)
}, [contentType, libraryCode, cateCode])
const ListContent = ContentListConfig[contentType]
return (
<ListContent
data={listData}
footerConfig={ListFooterHandler.getConfig({
routerChange: (offset) => setSearchParam({ ...searchParam, offset }),
total: listData.totalCount,
current: Number(searchParam.offset) / DEFAULT_PAGE_SIZE + 1,
})}
/>
)
}
export default ContentList
可以看到“可变”配置了,
export const ContentListConfig = {
[ContentTypes.FAQ]: FaqList,
[ContentTypes.VIDEO]: VideoList,
}
那可变部分的接口入参是什么呢?如下:
<ListContent
data={...}
footerConfig={...}
/>
遵循接口标准,再看一下Faq列表组件如何实现功能的:
import React, { FC } from 'react'
import { Link } from 'react-router-dom'
import { List } from 'antd'
import { ListParams } from '../const'
import { getDetailUrl } from '@/utils/url'
import EmptyContent from '@/components/EmptyContent'
import { ContentTypes } from '@/utils/const'
import styles from './index.less'
const Faq: FC<ListParams> = ({ data = { datas: [], totalCount: 0 }, footerConfig = {} }) => {
const { totalCount, datas } = data
return (
<>
{totalCount != 0 ? (
<List
className={styles.faqList}
itemLayout="horizontal"
dataSource={datas}
split={false}
{...footerConfig}
renderItem={(item: any) => {
const { title, libraryCode, contentCode } = item as any
const href = getDetailUrl({
contentType: ContentTypes.FAQ,
libraryCode,
contentCode,
lang: 'zh',
})
return (
<Link to={href}>
<div className={styles.listTitle}>{title}</div>
</Link>
)
}}
/>
) : (
<EmptyContent />
)}
</>
)
}
export default Faq
UI组件部分解决了,那<Tree />
事件点击如何根据不同内容类型而操作不同呢?探探 @/components/ContentComp
提供的 { isCorrectType, getTreeLink }
函数吧。
import { ContentTypesConfig } from '@/utils/const'
import { getDetailUrl, getListUrl } from '@/utils/url'
import { ContentListConfig } from './List'
import { ContentConfig } from './Detail'
const types = Object.keys(ContentTypesConfig)
/**
* 判断是否支持该内容类型
* @param type
* @returns
*/
export const isCorrectType = (type) => {
return types.includes(type)
}
/**
* 1. 如果支持List,展示列表页面;
* 2. 不满足条件1,且支持详情页面,展示详情页面;
* 3. 条件1和2都不支持,什么都不做;
* @param type
* @returns 返回跳转url相对路径地址
*/
export const getTreeLink = (type) => {
// ContentListConfig 哪里定义的,还记得吗?往上翻翻就找到了 :)
if (ContentListConfig[type]) {
return ({ libraryCode, categoryCode }) => {
return getListUrl({ contentType: type, libraryCode, cateCode: categoryCode })
}
} else if (ContentConfig[type]) {
return ({ libraryCode, categoryCode }) => {
return getDetailUrl({
contentType: type,
libraryCode,
contentCode: categoryCode,
lang: 'zh',
})
}
}
}
整个可变部分的封装结构如下图:
回到之前的问题,“如果新增了一个【案例】case类型,需要修改多少地方?”
- 新增两个case.tsx组件,分别为列表和详情
- 在
@/components/ContentComp/List
或@/components/ContentComp/Detail
里面配置新类型,如下:
export const ContentListConfig = {
[ContentTypes.FAQ]: FaqList,
[ContentTypes.VIDEO]: VideoList,
[ContentTypes.CASE]: CaseList,
}
如果Case和Doc类似,没有列表页面,那更简单了,只要在@/components/ContentComp/Detail
里新增配给即可。
结论
多看看设计模式,还是挺香的。