美文网首页让前端飞Web前端之路
从一个例子看开闭原则

从一个例子看开闭原则

作者: 娜姐聊前端 | 来源:发表于2022-03-09 11:36 被阅读0次

    什么开闭原则?

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

    结论

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

    相关文章

      网友评论

        本文标题:从一个例子看开闭原则

        本文链接:https://www.haomeiwen.com/subject/pcuyrrtx.html