从一个例子看开闭原则

什么开闭原则?

开闭原则(Open Closed Principle)是Java世界里最基础的设计原则,它指导我们如何建立一个稳定的、灵活的系统。

设计模式之六大原则——开闭原则(OCP):一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。

例子

这是一个实战中的项目,需求目标很简单:提供统一内容搜索能力 ,包括 文档,知识,视频。可以通过目录树切换查看该库 的 文档详情/知识列表/视频列表。
搜索页面比较简单,这里就不讲了。重点看详情,列表,目录树/文档树 设计。

概念

  • 库:每种内容类型都归属于一个库,比如,有文档库A,文档库B....
  • 内容类型:目前搜索范围 是
    • 文档:下面简称Doc
    • 知识:下面简称Faq
    • 视频:下面简称Video
  • 类目:不是所有的内容类型都有类目树。在这个例子里面,Faq和Video有目录节点,即每个目录节点对应一组Faq/Video。但 Doc 类型是通过每一篇文档指定parent属性将文档上下级关系串联起来,所以,它的类目树就是文档树。

类似的交互图

详情页.png
列表页.png

用例图

内容用例.png

第一步:梳理异同

动手之前,先撸一撸基于内容类型,交互的相同点和不同点。

  1. 相同点:
    1.1 目录树/文档树 展示UI完成一样,都是标准<Tree />组件
    1.2 目录树点击只会触发两种方式:展示【列表】 或者 【详情】
    1.3 文案型的详情都是富文本展示
  2. 不同点
    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.tsxCategoryTree 代码里面里面判断下内容类型,就可以愉快的加载不同的内容组件了。

export enum ContentTypes {
  FAQ = 'Faq',
  DOC = 'Doc',
  VIDEO = 'Video',
}

想一想,这个方案的问题在哪里?
如果新增了一个【案例】case类型,需要修改多少地方?

  1. 新增两个case.tsx组件,分别为列表和详情
  2. 修改两个入口文件index.tsx,新增case类型
  3. 修改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',
      })
    }
  }
}

整个可变部分的封装结构如下图:


ContentComp.png

回到之前的问题,“如果新增了一个【案例】case类型,需要修改多少地方?”

  1. 新增两个case.tsx组件,分别为列表和详情
  2. @/components/ContentComp/List@/components/ContentComp/Detail里面配置新类型,如下:
export const ContentListConfig = {
  [ContentTypes.FAQ]: FaqList,
  [ContentTypes.VIDEO]: VideoList,
  [ContentTypes.CASE]: CaseList,
}

如果Case和Doc类似,没有列表页面,那更简单了,只要在@/components/ContentComp/Detail里新增配给即可。

结论

多看看设计模式,还是挺香的。

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):