likes
comments
collection
share

业务组件封装思路——ComponentTemplate+stateHook

作者站长头像
站长
· 阅读数 17

常规组件抽取思路

💡 这里以React组件为例。

业务组件封装思路——ComponentTemplate+stateHook

常规组件中的困境

💡 问题① 如何平衡组件内部复杂度与外部复杂度?(在react的语境中,可以简单的将内部复杂度对应为state复杂度、外部复杂度对应为props复杂度)

一般而言,组件使用者的灵活空间往往被局限于props。 如果一个React组件既要支持复杂的内部交互又要支持一定程度的灵活度,将会给组件开发者带来不小的挑战。如下示意图,左图为props相对简单(规整)的组件,右图为props相对复杂的组件

业务组件封装思路——ComponentTemplate+stateHook业务组件封装思路——ComponentTemplate+stateHook

💡 问题② 如何共享组件的状态和方法?

通常,父组件是无法获取子组件的内部状态和方法的。“状态提升”是一种方式,但一旦将复杂状态提升到父组件中维护,与复杂状态相关的一堆方法也将会在父组件中堆积成山,这样一来,我们的初衷就会被违背,从"为父组件减轻负担的子组件"变成"成为父组件负担的啃老型子组件"。

💡 问题③ 如何保持组件的相对纯净?

如果,一个组件在被频繁使用的过程中,由于无法满足业务需要而被频繁改动,久而久之,它将变得面目全非。这就是一个组件变"脏"的过程。

ComponentTemplate+stateHook

💡 如果将业务组件拆成ComponentTemplate和stateHook两部分,则可以按如下的思路进行拆分

业务组件封装思路——ComponentTemplate+stateHook

举个栗子

  • 最简单的例子 Modal+useModalVisibleState

💡 最简单的例子。假设我们有这么一个用于Modal的stateHook,这个stateHook提供了Modal最常用的visible状态以及openModal和closeModal方法。

import {useState} from 'react' ;

type TUseModalVisibleStateResult = [
  boolean ,	//visible	对话框是否可见
  {
    openModal:()=>void ,	//打开对话框
    closeModal:()=>void		//关闭对话框
  }
]
function useModalVisibleState (initVisible?:boolean):TUseModalVisibleStateResult {
  const [visible,setVisible] = useState<boolean>(initVisible||false) ;
  function switchVisible(visible:boolean){
    return ()=>setVisible(visible);
  }
  return [
    visible , //state
    { //methods
      openModal:switchVisible(true) ,
      closeModal:switchVisible(false) ,
    }
  ] ;
}

export default useModalVisibleState ;
const App: React.FC = () => {
  const [isModalVisible, setIsModalVisible] = useState(false);

  const showModal = () => {
    setIsModalVisible(true);
  };

  const handleOk = () => {
    setIsModalVisible(false);
  };

  const handleCancel = () => {
    setIsModalVisible(false);
  };

  return (
    <>
      <Button type="primary" onClick={showModal}>
        Open Modal
      </Button>
      <Modal title="Basic Modal" visible={isModalVisible} onOk={handleOk} onCancel={handleCancel}>
        <p>Some contents...</p>
        <p>Some contents...</p>
        <p>Some contents...</p>
      </Modal>
    </>
  );
};
const App: React.FC = () => {
  const [isModalVisible,{openModal,closeModal}] = useModalVisible(false);

  const handleOk = () => {
    //do sth behind
    closeModal();
    //do sth after
  };

  const handleCancel = () => {
    closeModal();
  };

  return (
    <>
      <Button type="primary" onClick={openModal}>
        Open Modal
      </Button>
      <Modal title="Basic Modal" visible={isModalVisible} onOk={handleOk} onCancel={handleCancel}>
        <p>Some contents...</p>
        <p>Some contents...</p>
        <p>Some contents...</p>
      </Modal>
    </>
  );
};

由于这个例子相对简单,乍一看其实并没有减少太多的编码量。然而从代码可读性上来讲,openModal()和closeModal()比setIsModalVisible(true)和setIsModalVisible(false)的可读性还是略强的。

  • 验证码功能封装 img+useVerificationCode

💡 验证码是个常见的前端需求。如果我们尝试分拆一下一个验证码功能的基本要素,大概会是:验证码img_src+验证码id。为了增加切换验证码时的过渡效果,可以再增加一个请求时的isFetching状态。这个场景非常简单易懂,分析完基本要素,就可以非常顺利地抽出验证码stateHook,如下代码块。

业务组件封装思路——ComponentTemplate+stateHook

//自定义hook:使用验证码
import { getVerificationCode } from '../../apis/accountManager' ;
import { useState } from 'react' ;

type TResult = [
  {
    isFetching:boolean ,
    codeImgSrc:string ,
    codeId:string
  } ,
  ()=>Promise<any>
]

function useVerificationCode ():TResult {
  const [ verificationCodeState , setVerificationCodeState ] = useState({
    codeImgSrc:"" , codeId:""
  }) ;
  const [isFetching,setIsFetching] = useState(false) ;
  async function fetchVerificationCode () {
    //获取验证码
    setIsFetching(true) ;
    const {status,data,msg} = await getVerificationCode() ;
    if(status===1){
      setVerificationCodeState(data) ;
    }else{
      console.error(msg) ;
    }
    setIsFetching(false) ;
  }
  return [
    //与验证码相关的状态
    {
      isFetching ,
      codeImgSrc:verificationCodeState.codeImgSrc ,
      codeId:verificationCodeState.codeId
    } ,
    //用于更新验证码状态的方法
    fetchVerificationCode
  ] ;
}

export default useVerificationCode ;
const App: React.FC = () => {
  //调用验证码hook
  const [verificationCodeState,fetchVerificationCode] = useVerificationCode() ;
  return (
    <img
      alt={verificationCodeState.isFetching?"加载中...":"验证码"}
      src={verificationCodeState.codeImgSrc}
      onClick={fetchVerificationCode}
    />
  );
}

这个例子也很简单,对于需要在项目中多处使用验证码,而验证码的样式显示又有所差异的场景,可以有效减少对于验证码状态维护相关的代码。此外,如有更强的复用性需要,也可以按需扩展(比如验证码的请求可以抽出来),基本思路变化不大。

经典场景之表格

💡 表格也是个很常见的前端需求,更常常伴随着分页、过滤条件等其他要素一起出现。相信每个深陷业务泥潭的前端开发人员都尝试过抽取与表格相关的公共业务逻辑。

业务组件封装思路——ComponentTemplate+stateHook 业务组件封装思路——ComponentTemplate+stateHook 如上图场景,我们通常的抽取思路是:将表格上方的表单区域、表格区域、表格下方的分页区域一并抽进一个表格组件中,此类抽取思路的典型可以参考蚂蚁的高级表格——ProTable。这种抽取方式思路上最为直接,但也存在一些美中不足,比如配置项复杂度高、外部灵活度较低、样式相对固定等问题。

  1. 配置复杂度高

使用ProTable来编写我们的表格页面时,需要配置大量结构复杂的props。(ProTable与 antd Table 不同的 api

  1. 外部灵活度较低

ProTable是相对封闭的,因为和表格相关的状态及改变这些状态的方法都封装在组件内部,如此一来,ProTable的父组件想取得ProTable的内部状态或者调用ProTable的内部方法,就只能通过Ref这种相对ugly的方式了。(ProTable ActionRef 手动触发ProTable FormRef 手动触发

  1. 样式相对固定

因为ProTable是一个包含了表单在内的粒度比较粗的整体,所以ProTable中的布局调整是相对有限的。假设开发者希望开发一个如下的表格页面,如果ProTable支持这样的布局,谢天谢地,但如果不支持那就只能魔改ProTable了,这种侵入性的改动是大家所不愿见到的。 业务组件封装思路——ComponentTemplate+stateHook

Less is More

ProTable的思路是将"可预见的可能"封装进组件,这是"大而全"的思路,无可厚非。虽然这种"大家长式"的封装思路可以减少使用者犯错误的机会,但如果你觉得未免有些"爹味太浓",不妨可以尝试一下ComponentTemplate+stateHook的组件封装思路。 业务组件封装思路——ComponentTemplate+stateHook

  • ComponentTemplate部分使用样例
<TableTemplate<TFilterCondition>
  tableState={tableState}
  tableActions={tableActions}
  tableProps={{
    columns:[
      {
        title:"账号" ,
        dataIndex:"account" ,
        key:"account"
      },
    ]
  }}
/>
  • stateHook部分使用样例
const [
  tableState,
  tableActions
] = useTableState<TFilterCondition>({
  isFetching:false ,
  filterCondition:{
    page:1 ,
    size:10 ,
    mockOther:""
  } ,
  tableData:{
    list:[] ,
    total:0 ,
    page:1 ,
    size:10
  },
  tableConfig:{
    rowKey:"id" ,
    rowSelectionType:"checkbox"
  } ,
  selectedRowKeys:[],
},theFetch,{
  filterConditionEffectKeys:[] //在此处标记的条件参数改变后会自动触发表格数据的重新获取,如:["mockOther"]
});
  • 如何使用(更详细)
// Function Component Usage
import React from 'react'
import TableTemplate, from 'mg-table-template'
import 'mg-table-template/dist/index.css'

interface TFilterCondition {
  page:number ; //内置条件参数:页签
  size:number ; //内置条件参数:每页记录数
  mockOther:string ; //示例条件参数:组件使用者可扩展自己需要的条件参数
}

function getUserList(_filterCondition:TFilterCondition){
  return {
    status:1,
    tableData:{
      list:[] ,
      total:0 ,
      page:1 ,
      size:10
    } ,
    msg:"hello"
  } ;
}

async function theFetch(filterCondition:TFilterCondition){ //获取数据
  try{
    const {
      status,
      tableData ,
      msg
    } = await getUserList(filterCondition) ;
    if(status===1){
      return {
        list:tableData.list ,
        total:tableData.total ,
        page:tableData.page ,
        size:filterCondition.size
      }
    }
  }catch(error){
    console.error(error);
  }
  return ;
}

const FunctionComponentDemo = ()=>{
  const [
    tableState,
    tableActions
  ] = useTableState<TFilterCondition>({
    isFetching:false ,
    filterCondition:{
      page:1 ,
      size:10 ,
      mockOther:""
    } ,
    tableData:{
      list:[] ,
      total:0 ,
      page:1 ,
      size:10
    },
    tableConfig:{
      rowKey:"id" ,
      rowSelectionType:"checkbox"
    } ,
    selectedRowKeys:[],
  },theFetch,{
    filterConditionEffectKeys:[] //在此处标记的条件参数改变后会自动触发表格数据的重新获取,如:["mockOther"]
  });

  const {
    updateTableState , //更新表格数据(自动携带条件参数filterCondition并触发theFetch的调用)
    setFilterCondition , //设置条件参数
    // __setSelectedRowKeys__ //非请勿用
  } = tableActions ;

  function handleChangeOfMockOther(e:any){
    const mockOther = e.target.value ;
    setFilterConfition((filterCondition:TFilterCondition)=>{
      return {
        ...filterCondition ,
        mockOther
      }
    });
  }
    
  return (
    <React.Fragment>
      <label>
        模拟一个查询条件字段:
        <input
          value={tableState.filterCondition.mockOther}
          // eslint-disable-next-line react/jsx-no-bind
          onChange={handleChangeOfMockOther}
        />
      </label>
      <button
        // eslint-disable-next-line react/jsx-no-bind
        onClick={updateTableState}
      >
        查询
      </button>
      <TableTemplate<TFilterCondition>
        tableState={tableState}
        tableActions={tableActions}
        tableProps={{
          columns:[
            {
              title:"账号" ,
              dataIndex:"account" ,
              key:"account"
            },
          ]
        }}
      />
    </React.Fragment>
  );
}

export default FunctionComponentDemo ;
// Class Component Usage
import React from 'react'
import TableTemplate,{useTableState,withTableStateAndActions,TTableStateAndActions} from 'mg-table-template'
import 'mg-table-template/dist/index.css'

interface TFilterCondition {
  page:number ; //内置条件参数:页签
  size:number ; //内置条件参数:每页记录数
  mockOther:string ; //示例条件参数:组件使用者可扩展自己需要的条件参数
}

interface TProps {
  tableStateAndActions?:TTableStateAndActions<TFilterCondition> ;
}

class ClassComponentDemo extends React.Component<TProps ,TState> {
  render () {
    const {
      tableStateAndActions
    } = this.props ;
    return (
      <TableTemplate<TFilterCondition>
        tableState={tableStateAndActions.tableState}
        tableActions={tableStateAndActions.tableActions}
        tableProps={{
          columns:[
            {
              title:"账号" ,
              dataIndex:"account" ,
              key:"account"
            },
          ]
        }}
      />
    ) ;
  }
}

function getUserList(_filterCondition:TFilterCondition){
  return {
    status:1,
    tableData:{
      list:[] ,
      total:0 ,
      page:1 ,
      size:10
    } ,
    msg:"hello"
  } ;
}

async function theFetch(filterCondition:TFilterCondition){ //获取数据
  try{
    const {
      status,
      tableData ,
      msg
    } = await getUserList(filterCondition) ;
    if(status===1){
      return {
        list:tableData.list ,
        total:tableData.total ,
        page:tableData.page ,
        size:filterCondition.size
      }
    }
  }catch(error){
    console.error(error);
  }
  return ;
}

export default withTableStateAndActions<TProps,TFilterCondition>(
  ClassComponentDemo as any,
  (props)=>{
    const [
      tableState,
      tableActions
    ] = useTableState<TFilterCondition>({
      isFetching:false ,
      filterCondition:{
        page:1 ,
        size:10 ,
        mockOther:""
      } ,
      tableData:{
        list:[] ,
        total:0 ,
        page:1 ,
        size:10
      },
      tableConfig:{
        rowKey:"id" ,
        rowSelectionType:"checkbox"
      } ,
      selectedRowKeys:[],
    },theFetch,{
      filterConditionEffectKeys:[] //在此处标记的条件参数改变后会自动触发表格数据的重新获取,如:["mockOther"]
    });
    return {
      tableStateAndActions:[
        tableState ,
        tableActions
      ]
    } ;
  }
)

import React, { useState, useEffect } from 'react';
import { Table, TableProps, Pagination } from 'bellejs';
import styles from './index.module.css'

function showTotal(total: number) {
  return `共 ${total} 条`
}

interface TTableData { //用于渲染表格的状态
  list:object[] ; //表格数据
  total:number ; //总记录数
  page:number ; //当前页码
  size:number ; //每页最大记录数
}

interface TTableConfig { //用于渲染表格的配置项
  rowKey:React.Key ; //record[rowKey]作为主键
  rowSelectionType?:"checkbox"|"radio" ; //表格行选择框(空:不可选;checkbox:多选;radio:单选)
}

interface TFilterConditionExtends { //泛型约束
  page:number ; //当前页
  size:number ; //每页最大记录数
}

interface TTableState<TFilterCondition> {
  isFetching:boolean ; //是否正在获取
  filterCondition:TFilterCondition ; //过滤条件(当前页码page,每页最大记录数size 等)
  tableData:TTableData ;
  tableConfig:TTableConfig ;
  selectedRowKeys:React.Key[];
}

type TUpdateTableState = (...arg0: any[])=>any ;

type TSetFilterCondition<TFilterCondition> = (filterCondition:TFilterCondition|((filterCondition:TFilterCondition)=>TFilterCondition))=>any ;

type TSetSelectedRowKeys = (selectedRowKeys:React.Key[])=>any ;

export type TTableStateAndActions<TFilterCondition> = [
  TTableState<TFilterCondition>,
  {
    updateTableState :TUpdateTableState,
    setFilterCondition:TSetFilterCondition<TFilterCondition> ,
    __setSelectedRowKeys__:TSetSelectedRowKeys,
  }  
] ; //表格状态钩子

interface TProps<TFilterCondition> {
  tableState:TTableState<TFilterCondition> ;
  tableActions:{
    updateTableState:TUpdateTableState ;
    setFilterCondition:TSetFilterCondition<TFilterCondition> ;
    __setSelectedRowKeys__:TSetSelectedRowKeys ;    
  },
  tableProps?:TableProps<any> ; //Table组件原有的PropsType
  headerNode?:any ;
  footerNode?:any ;
}

interface TState {

}

class TableTemplate<TFilterCondition> extends React.Component<TProps<TFilterCondition>,TState> {
  state={

  }
  
  handleChangeOfPagination = async (currentPage:number , pageSize:number) => { //分页器处理器
    const {
      tableActions:{
        setFilterCondition ,
      }
    } = this.props ;
    setFilterCondition((filterCondition)=>{
      return {
        ...filterCondition ,
        page:currentPage ,
        size:pageSize
      }
    })
  }
  getRowKey = (record: { [x: string]: string; }):string => {
    //rowKey映射
    const {
      tableState:{
        tableConfig:{rowKey} ,
      },
    } = this.props ;
    return record[rowKey] ;
  }
  get selectedRowKeys () {
    const {
      tableState:{
        selectedRowKeys
      }
    } = this.props ;
    return selectedRowKeys ;
  }
  handleChangeOfTableRowSelect = (selectedRowKeys: React.Key[]) => {
    const {
      tableActions:{
        __setSelectedRowKeys__
      }
    } = this.props ;
    __setSelectedRowKeys__(selectedRowKeys) ;
  }
  onRow = (record: { [x: string]: any; })=>{
    const {
      tableState:{
        tableConfig:{
          rowKey ,
          rowSelectionType
        } ,
        selectedRowKeys=[] ,
      },
      tableActions:{
        __setSelectedRowKeys__
      }
    } = this.props ;
    return {
      onClick: (_event: any) => {
        if(selectedRowKeys.includes(record[rowKey] as never)){//如果存在,则取消
          const newSelectedRowKeys = selectedRowKeys.filter(key => key !== record[rowKey]);
          __setSelectedRowKeys__(rowSelectionType==="radio"?[]:newSelectedRowKeys) ;
        }else{//如果不存在,则选中
          __setSelectedRowKeys__([...rowSelectionType==="radio"?[]:selectedRowKeys,...[record[rowKey]]]) ;
        }
      }
    }
  }
  render(){
    const {
      tableState:{
        isFetching,
        tableData:{list,total,page,size} ,
        tableConfig:{rowSelectionType} ,
        selectedRowKeys
      },
      tableProps ,
      headerNode ,
      footerNode
    } = this.props ;
    return (
      <div className={styles["page-common-table-box"]}>
        {headerNode}
        <Table
          size="small"
          loading={isFetching}
          pagination={false}
          dataSource={list}
          rowKey={this.getRowKey}
          onRow={this.onRow}
          rowSelection={rowSelectionType&&{
            type: rowSelectionType ,
            onChange:this.handleChangeOfTableRowSelect ,
            selectedRowKeys
          }}
          {...tableProps}
        />
        <Pagination
          className={styles["pagination-right"]}
          size="small"
          showSizeChanger
          showQuickJumper
          showTotal={showTotal}
          current={page}
          pageSize={size}
          total={total}
          onChange={this.handleChangeOfPagination}
        />
        {footerNode}
      </div>
    ) ;
  }
}


/*
封装了有关TableTemplate所需的【状态】和相应【行为】的【自定义hook】
*/
function useTableState<TFilterCondition extends TFilterConditionExtends> (
  initTableState:TTableState<TFilterCondition> ,
  fetchTableData:(filterCondition:TFilterCondition)=>Promise<TTableData|void> , //若返回类型为TTableData,则为正常;若为void则为异常
  options?:{
    filterConditionEffectKeys:string[] //设置了则添加至useEffect中
  } 
):TTableStateAndActions<TFilterCondition> {

  const [tableState,setTableState] = useState(initTableState) ;
  
  useEffect(() => {
    updateTableState() ;
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    tableState.filterCondition.size,
    tableState.filterCondition.page,
    ...(options?.filterConditionEffectKeys||[]).map((key)=>{
      return tableState.filterCondition[key] ;
    })
  ]);

  async function setFilterCondition(
    filterCondition:TFilterCondition|((filterCondition:TFilterCondition)=>TFilterCondition) ,
    shouldForceUpdateTableState?:boolean , //是否强制刷新表格数据
  ) { //更新filterCondition(支持对象式和函数式)
    const nextFilterCondition = typeof filterCondition === 'function'?filterCondition(tableState.filterCondition):filterCondition ;
    await setTableState((tableState)=>{
      return {
        ...tableState ,
        filterCondition:nextFilterCondition
      }
    }) ;
    if(shouldForceUpdateTableState===true){
      console.log("update之前:",tableState.filterCondition) //这段逻辑好像没用
      __updateTableStateByFilterCondition__(nextFilterCondition) ;
    }
  }

  function setSelectedRowKeys(selectedRowKeys:[]) {
    setTableState({
      ...tableState ,
      selectedRowKeys
    }) ;
  }

  function getTablePage(page:number,size:number,total:number):number { //获取表格页码(判断当前页码是否越界)
    const lastPage = Math.ceil(total/size) ; //向上取整获得最后一页的页码 
    if(page!==1&&page>lastPage){ //页码越界判断
      return lastPage ;
    }else{
      return page ;
    }
  }

  async function __updateTableStateByFilterCondition__(filterCondition:TFilterCondition):Promise<any> { //内部方法
    await setTableState((tableState)=>({
      ...tableState ,
      isFetching:true ,
    })) ;
    /**** fetch START */
    try{
      const tableData:TTableData|void = await fetchTableData(filterCondition) ;
      console.log("tableData:",tableData) ;
      if(tableData){
        setTableState({
          ...tableState ,
          filterCondition:{
            ...filterCondition ,
            page:getTablePage(tableData.page,tableData.size,tableData.total) //filterCondition.page与tableData.page保持同步
          },
          tableData ,
          selectedRowKeys:tableState.selectedRowKeys.filter(
            (key)=>tableData.list.map(
              (record)=>record[tableState.tableConfig.rowKey]).includes(
                key
              )), //过滤掉当前表格不存在的selectedRowKeys
          isFetching:false
        }) ;
      }else{ //若返回为空,则按异常处理
        setTableState({
          ...tableState ,
          isFetching:false
        });
      }
    }catch(error){ //异常处理
      console.error(error) ;
      setTableState({
        ...tableState ,
        isFetching:false
      });
    }
    /**** fetch END */
  }

  function updateTableState() { //作为hook调用后返回的提供给外部的方法
    __updateTableStateByFilterCondition__(tableState.filterCondition) ;
  }
  return [
    tableState ,
    {
      updateTableState ,
      setFilterCondition ,
      __setSelectedRowKeys__:setSelectedRowKeys
    }
  ] ;
}


/*
携带form实例的wrapper\HOC
(form实例由自定义函数getFormInstances提供)
*/
function withTableStateAndActions<TComponentProps,TFilterCondition> (
  TheComponent:React.ComponentClass,
  getTableStateAndActions:()=>{[key:string]:TTableStateAndActions<TFilterCondition>}
) {
  return (props:TComponentProps)=>{
    return (
      <TheComponent
        {...props}
        {...getTableStateAndActions()}
      />
    )
  }
}

export default TableTemplate ;
export {
  useTableState ,
  withTableStateAndActions
}

结语

本文就“业务组件封装思路”这个主题进行了探讨,分析了常规的组件抽取思路及其容易遇到的困境,与此同时由简入繁地介绍了ComponentTemplate+stateHook这种相对灵活的组件抽取思路。本文抛砖引玉,希望能为大家今后业务组件的抽取开拓一条新的思路,不断挖掘新的可能。