從一個例子看開閉原則

什么開閉原則污尉?

開閉原則(Open Closed Principle)是Java世界里最基礎的設計原則,它指導我們?nèi)绾谓⒁粋€穩(wěn)定的、靈活的系統(tǒng)斥滤。

設計模式之六大原則——開閉原則(OCP):一個軟件實體如類修然、模塊和函數(shù)應該對擴展開放非区,對修改關閉郭膛。

例子

這是一個實戰(zhàn)中的項目麸拄,需求目標很簡單:提供統(tǒng)一內(nèi)容搜索能力 惜索,包括 文檔特笋,知識,視頻巾兆×晕铮可以通過目錄樹切換查看該庫 的 文檔詳情/知識列表/視頻列表。
搜索頁面比較簡單角塑,這里就不講了蔫磨。重點看詳情,列表圃伶,目錄樹/文檔樹 設計堤如。

概念

  • 庫:每種內(nèi)容類型都歸屬于一個庫蒲列,比如,有文檔庫A煤惩,文檔庫B....
  • 內(nèi)容類型:目前搜索范圍 是
    • 文檔:下面簡稱Doc
    • 知識:下面簡稱Faq
    • 視頻:下面簡稱Video
  • 類目:不是所有的內(nèi)容類型都有類目樹嫉嘀。在這個例子里面,F(xiàn)aq和Video有目錄節(jié)點魄揉,即每個目錄節(jié)點對應一組Faq/Video剪侮。但 Doc 類型是通過每一篇文檔指定parent屬性將文檔上下級關系串聯(lián)起來,所以洛退,它的類目樹就是文檔樹瓣俯。

類似的交互圖

詳情頁.png
列表頁.png

用例圖

內(nèi)容用例.png

第一步:梳理異同

動手之前,先擼一擼基于內(nèi)容類型兵怯,交互的相同點和不同點彩匕。

  1. 相同點:
    1.1 目錄樹/文檔樹 展示UI完成一樣,都是標準<Tree />組件
    1.2 目錄樹點擊只會觸發(fā)兩種方式:展示【列表】 或者 【詳情】
    1.3 文案型的詳情都是富文本展示
  2. 不同點
    1.1 列表頁面展示UI基于內(nèi)容類型不同而不同
    1.2 詳情頁展示UI基于內(nèi)容類型不同而不同媒区,但是部分可歸類

最后考慮下拓展性驼仪。假設,以后新增了 【案例】這種內(nèi)容類型袜漩,列表可能用<Table />組件绪爸,詳情頁可能是JSON式格式化數(shù)據(jù)渲染,那么宙攻,如何最小成本支持該類型呢奠货?

這就是該實戰(zhàn)需要解決的問題:對擴展開放,對修改關閉

第二步:按照“面條”思維做第一版本

先不要急著一蹴而就座掘,可以流程化的做一個簡單版本递惋,注意,此時不要將重點放在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 代碼里面里面判斷下內(nèi)容類型形真,就可以愉快的加載不同的內(nèi)容組件了贩挣。

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

想一想,這個方案的問題在哪里没酣?
如果新增了一個【案例】case類型,需要修改多少地方卵迂?

  1. 新增兩個case.tsx組件裕便,分別為列表和詳情
  2. 修改兩個入口文件index.tsx,新增case類型
  3. 修改CategoryTree組件见咒,新增新類型點擊事件

可以看出來偿衰,第1點是必須要做的,而其他修改比較散亂。有沒有什么更好的方案呢下翎?

第三部:抽象缤言,封裝

詳情和列表的主頁面需要關系類型內(nèi)容嗎?可以不需要视事!

先看下新版的列表主頁代碼胆萧。

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 }函數(shù)俐东。
一探究竟吧跌穗!

// @/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,
}

/**
 * 因為列表數(shù)據(jù)只有List組件使用,所以虏辫,List 組件自行獲取數(shù)據(jù)且渲染蚌吸。
 *
 * @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,
}

那可變部分的接口入?yún)⑹鞘裁茨仄鲎咳缦拢?/p>

<ListContent
    data={...}
    footerConfig={...}
/>

遵循接口標準羹唠,再看一下Faq列表組件如何實現(xiàn)功能的:

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 />事件點擊如何根據(jù)不同內(nèi)容類型而操作不同呢娄昆?探探 @/components/ContentComp提供的 { isCorrectType, getTreeLink }函數(shù)吧佩微。

import { ContentTypesConfig } from '@/utils/const'
import { getDetailUrl, getListUrl } from '@/utils/url'
import { ContentListConfig } from './List'
import { ContentConfig } from './Detail'

const types = Object.keys(ContentTypesConfig)

/**
 * 判斷是否支持該內(nèi)容類型
 * @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里新增配給即可殖卑。

結論

多看看設計模式,還是挺香的坊萝。

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末孵稽,一起剝皮案震驚了整個濱河市许起,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌菩鲜,老刑警劉巖园细,帶你破解...
    沈念sama閱讀 219,427評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異接校,居然都是意外死亡猛频,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,551評論 3 395
  • 文/潘曉璐 我一進店門馅笙,熙熙樓的掌柜王于貴愁眉苦臉地迎上來伦乔,“玉大人,你說我怎么就攤上這事董习×液停” “怎么了?”我有些...
    開封第一講書人閱讀 165,747評論 0 356
  • 文/不壞的土叔 我叫張陵皿淋,是天一觀的道長招刹。 經(jīng)常有香客問我,道長窝趣,這世上最難降的妖魔是什么疯暑? 我笑而不...
    開封第一講書人閱讀 58,939評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮哑舒,結果婚禮上妇拯,老公的妹妹穿的比我還像新娘。我一直安慰自己洗鸵,他們只是感情好越锈,可當我...
    茶點故事閱讀 67,955評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著膘滨,像睡著了一般甘凭。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上火邓,一...
    開封第一講書人閱讀 51,737評論 1 305
  • 那天丹弱,我揣著相機與錄音,去河邊找鬼铲咨。 笑死躲胳,一個胖子當著我的面吹牛纤勒,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播踊东,決...
    沈念sama閱讀 40,448評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼闸翅,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了济赎?” 一聲冷哼從身側響起记某,我...
    開封第一講書人閱讀 39,352評論 0 276
  • 序言:老撾萬榮一對情侶失蹤液南,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后统扳,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體畅姊,經(jīng)...
    沈念sama閱讀 45,834評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡若未,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,992評論 3 338
  • 正文 我和宋清朗相戀三年粗合,在試婚紗的時候發(fā)現(xiàn)自己被綠了萍嬉。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片舌劳。...
    茶點故事閱讀 40,133評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖大诸,靈堂內(nèi)的尸體忽然破棺而出贯卦,到底是詐尸還是另有隱情,我是刑警寧澤撵割,帶...
    沈念sama閱讀 35,815評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站羹与,受9級特大地震影響,放射性物質發(fā)生泄漏纵搁。R本人自食惡果不足惜腾誉,卻給世界環(huán)境...
    茶點故事閱讀 41,477評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望趣效。 院中可真熱鬧猪贪,春花似錦哮伟、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,022評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至尤慰,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間伟端,已是汗流浹背匪煌。 一陣腳步聲響...
    開封第一講書人閱讀 33,147評論 1 272
  • 我被黑心中介騙來泰國打工萎庭, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人驳规。 一個月前我還...
    沈念sama閱讀 48,398評論 3 373
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親砸狞。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,077評論 2 355

推薦閱讀更多精彩內(nèi)容