我去!居然把对话框组件的控制权交由全局状态,不会被打吗?

1,078 阅读7分钟

前言

对话框是日常开发很常用的组件(反正我是基本每个页面都用到,嗯。。。不少情况下一个页面还不只一个),为了能更爽地使用它,一般我们都会进行二次封装,今我就给哥几个讲讲我是这么个封装法

未封装的使用方法

本文使用的是antdModal组件来举例,当然思想都是相通的,我之前用vue3的时候也封装过类似的组件,下面我们来看看antd官方的基本使用例子:

import React, { useState } from 'react';
import { Modal, Button } from 'antd';

const App = () => {
  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>
    </>
  );
};

ReactDOM.render(<App />, mountNode);

这里是直接复制的官方的代码👻(别着急,好戏在后头呢)

我个人的一些看法

对于未定制封装的对话框组件,我们每使用一个通常都会为之创建一个相应的hook用来管理它的显示状态。对此我个人觉得可能hook会定的太多,有点太繁琐了。基于能省一行就省一行的代码理念,我认为把项目内所有Modal组件的显示控制权用redux来管理也许会是个不错的方案👌

具体我是怎么做的?

要实现这一点,我们首先要定义好全局的redux状态,这里我使用的是reduxjs/toolkit,下面上代码:

store/index.ts

import { useDispatch, TypedUseSelectorHook, useSelector } from 'react-redux'
import { AnyAction, combineReducers, configureStore, ThunkAction } from '@reduxjs/toolkit'
// redux-persist是用来给redux数据持久化的插件
import {
  FLUSH,
  PAUSE,
  PERSIST,
  PersistConfig,
  persistReducer,
  PURGE,
  REGISTER,
  REHYDRATE,
} from 'redux-persist'
import storage from 'redux-persist/lib/storage'
import modalReducer from './reducers/modalReducer'
import userReducer from './reducers/userReducer'

const reducers = combineReducers({
  modal: modalReducer,
  user: userReducer,
})

const persistConfig: PersistConfig<ReturnType<typeof reducers>> = {
  key: 'root',
  storage,
  // 数据持久化白名单,需要持久化就搁这写上
  whitelist: [
    'user',
  ],
}

const persistedReducer = persistReducer(persistConfig, reducers)

const store = configureStore({
  reducer: persistedReducer,
  devTools: !import.meta.env.PROD,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
})

export type IRootState = ReturnType<typeof store.getState>

export type IAppDispatch = typeof store.dispatch

export type IAppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  IRootState,
  unknown,
  AnyAction
>

export default store

// 添加类型提示方便使用
export const useAppDispatch = () => useDispatch<IAppDispatch>()
export const useAppSelector: TypedUseSelectorHook<IRootState> = useSelector

main.tsx

import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { persistStore } from 'redux-persist'
import { PersistGate } from 'redux-persist/integration/react'
import RouterView from './routers/RouterView'
import store from '@store/.'

const persistor = persistStore(store)

ReactDOM.render(
  // <React.StrictMode> // antd部分组件严格模式报红
  (
    <Provider store={store}>
      <PersistGate loading={null} persistor={persistor}>
        <ConfigProvider locale={zhCN}>
          <RouterView />
        </ConfigProvider>
      </PersistGate>
    </Provider>
  ),
  // </React.StrictMode>,
  document.getElementById('root'),
)

store.tsmain.tsx这两个文件我本来是不想放上来的,但考虑到有的伙计可能没上手过react,这里简单地放一下,不做过多解析了,就提一下redux-persistreact的严格模式吧

  1. redux-persist:是一个为redux数据持久化的插件,有需要可以使用一下,可通过配置黑或白名单来控制要持久化的Reducer
  2. react严格模式:可以看到我把严格模式给注释掉了,因为antd的部分组件使用了旧的reactapi导致控制台报红,在GitHub上有不少人提了bug,不知什么时候解决 关于reduxjs/toolkit其他的一些基本使用可以看官方文档或在站内搜索相关内容,下面来看一下真正与对话框组件有关的Slice

modalReducer.ts

import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import store, { IRootState } from '..'

export interface IModalState {
  visible: boolean
}

interface IModalPayload {
  isVisible: boolean
}

const initialState: IModalState = {
  visible: false,
}

export const slice = createSlice({
  name: 'modal',
  initialState,
  reducers: {
    setModal: (state, { payload }: PayloadAction<IModalPayload>) => {
      const {
        isVisible,
      } = payload

      state.visible = isVisible
    },
  },
})

export const {
  setModal,
} = slice.actions

export function showModal () {
  store.dispatch(setModal({
    isVisible: true,
  }))
}

export function hideModal () {
  store.dispatch(setModal({
    isVisible: false,
  }))
}

export const selectPage = (state: IRootState) => state.page

export default (function modalReducer () {
  return slice.reducer
}())

  1. 首先我们将modal slice的状态结构定义为IModalState,其中visible这个全局状态就是用来控制我们封装后对话框的显隐的,给它默认为false隐藏
  2. 接着我们要为visible定义专门的Action状态更新函数setModal
  3. 最后,为了更方便的调用(我个人觉得🤤)我们再把显示隐藏的控制拆分为两个函数showModalhideModal
  4. 为什么默认导出是一个闭包?不为什么,就是好玩🤪 到这里我们要为对话框二次封装组件的专属状态就定好了,非常的简单👌,下面就到组件的实现代码了:
import { useAppSelector } from '@store/hooks'
import { hideModal } from '@store/reducers/pageReducer'
import { ColProps, Modal } from 'antd'
import { ReactNode } from 'react'

interface IProps {
  title: string

  width?: number
  children?: ReactNode
  labelCol?: ColProps
}

const defaultLabelCol = { span: 6 }

export default function DiyModal (props: IProps) {
  const {
    title,

    children,
    width = 600,
    labelCol = defaultLabelCol,
  } = props

  const isModalVisible = useAppSelector(state => state.modal).visible

  return (
    <Modal
      width={width}
      title={title}
      visible={isModalVisible}
      onOk={() => console.log('ojbk')}
      onCancel={() => hideModal()}
    >
      { children }
    </Modal>
  )
}

到这里一个由全局状态控制的对话框组件封装就完成了,整个实现也灰常简单,就三行主要的代码:

const isModalVisible = useAppSelector(state => state.page).visible

visible={isModalVisible}
onCancel={() => hideModal()}

就是通过拿到全局的visible赋予Modal和关闭时调用hideModalvisible设置为false,使用也是三岁小孩都会方式:

import { Button } from 'antd'
import DiyModal from '@com/DiyModal'

export default function MyPage () {
  return <div>
    <Button type="primary" onClick={() => showModal()}>打开</Button>
    <DiyModal title="我是标题">我是内容</DiyModal>
  </div>
}

image.png 可想而知的结果

还不够!得上加强版

当然我前面说过一个页面有时是不只一个对话框,并且我司一般对话框都会包含着表单,所以我们还需要进一步改造加强它的功能性

  1. 既然一个页面不是固定的一个对话框,那么我们将显示控制状态换成boolean数组就好了:
export interface IModalState {
  visibleList: boolean[] // modal可见列表
}
  1. 用于对话框内表单数据回显的初始化状态也应该可以放到全局管理,所以加上表单的状态数组:
import { Store } from 'antd/lib/form/interface'

export interface IModalState {
  visibleList: boolean[] // modal可见列表
  formStateList: (Store | undefined)[] // 对话框表单默认值列表
}

// 也许这样写更优雅点,但我的例子已经用上面的形式写了,就不改了
export interface IModalState {
  list: {
    visible: boolean
    formState?: Store
  }[]
}
  1. setModal给力点:
interface IModalPayload {
  isVisible: boolean
  index: number
  formState?: Store
}

const initialState: IModalState = {
  visibleList: [],
  formStateList: [],
}

const slice = createSlice({
  name: 'modal',
  initialState,
  reducers: {
    setModal: (state, { payload }: PayloadAction<IModalPayload>) => {
      const {
        isVisible,
        index,
        formState,
      } = payload

      state.formStateList[index] = formState

      state.visibleList[index] = isVisible
    },
  },
})

现在,我们可以通过index控制指定的对话框组件

  1. 当然,showModalhideModal函数不能落下:
export function showModal (
  index: number,
  formState?: IModalPayload['formState'],
) {
  store.dispatch(setModal({
    index,
    formState,
    isVisible: true,
  }))
}

export function hideModal (
  index: number,
) {
  store.dispatch(setModal({
    index,
    isVisible: false,
  }))
}
  1. 最后,DiyModal长这样:
import { useAppSelector } from '@store/hooks'
import { hideModal } from '@store/reducers/pageReducer'
import { ColProps, Form, Modal } from 'antd'
import { Store } from 'antd/lib/form/interface'
import { ReactNode, useEffect } from 'react'

interface IProps {
  index: number
  title: string

  width?: number
  children?: ReactNode
  labelCol?: ColProps
}

/* eslint-disable no-template-curly-in-string */
const validateMessages = {
  required: '${label}是必填项!',
}
/* eslint-disable no-template-curly-in-string */

const defaultLabelCol = { span: 6 }

export default function DiyModal (props: IProps) {
  const {
    index,
    title,

    children,
    width = 600,
    labelCol = defaultLabelCol,
  } = props

  const modalState = useAppSelector(state => state.modal)

  const isModalVisible = modalState.visibleList[index]
  const formState = modalState.formStateList[index]

  const [form] = Form.useForm()

  const onCheck = async () => {
    try {
      const values = await form.validateFields()
      console.log('成功:', values)
    } catch (errorInfo) {
      console.log('失败:', errorInfo)
    }
  }

  return (
    <Modal
      destroyOnClose
      width={width}
      title={title}
      visible={isModalVisible}
      onOk={onCheck}
      onCancel={() => hideModal(index)}
    >
      <Form
        form={form}
        preserve={false}
        labelCol={labelCol}
        validateMessages={validateMessages}
        initialValues={formState}
      >
        { children }
      </Form>
    </Modal>
  )
}

也还是简简单单的使用方式

  1. 为页面编写你的对话框:
import DiyModal from '@com/DiyModal'
import { Form, Input } from 'antd'

export default function Modal0 () {
  return (
    <DiyModal
      index={0}
      title="我是标题"
    >
      <Form.Item
        name="userName"
        label="用户名"
        rules={[{ required: true }]}
      >
        <Input />
      </Form.Item>
    </DiyModal>
  )
}
  1. 引用Modal0
import { Button } from 'antd'
import Modal0 from './Modal0'

export default function MyPage () {
  return <div>
    <Button type="primary" onClick={() => showModal(0, { userName: '23' })}>
      打开
    </Button>
    <Modal0/>
  </div>
}

image.png 数据回显莫得问题🤪,接下来你可以根据自己需要继续扩展组件的属性像onCancelonOk之类的

formState的类型提示

你也许会需要根据index获得对应formState的类型提示,关于这一点你可以阅读我另外一篇文章,看完后你应该可以尝试自己去实现它,我就不演示了,文章链接:都快2022年了你连个API都写不好吗?来!这份ts+proxy的API编写风格指南你收好了

结语

因为对话框的状态并不是持久化保存的,所以在基于BrowserRouter路由跳转页面和刷新的时候会被清掉,这样你就只需要根据单个页面对话框数量来定义index就好,而不是整个项目🙄,另外在开发环境下有可能出现刷新或路由跳转,状态不被清除的情况,会导致两个页面能互相控制对方对话框的情况,在打包后生产环境暂时没遇到过,个人觉得可以接受。如果你不能接受,可以尝试给路由添加守卫来手动清除它。什么?你不会给React Router v6添加守卫。那还不点赞关注一波等我更文教你实现React Router v6的守卫,懒加载和类似vuemeta元信息等等功能