从一个例子看开闭原则

什么开闭原则?

开闭原则(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里新增配给即可。

结论

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容