IdbI

第30章—组件实战:Message全局提示组件

我们经常会用 Message 组件来展示一些成功、失败的提示:

和一般组件写在 JSX 里不同:

它是通过 api 的方式用的:

那它是怎么实现的呢?

我们来分析下思路:

其实单纯就这个列表来说,很简单:

我们学习过渡动画的时候,写过这种列表:

用 react-transition-group 或者 react-spring 都可以做。

但是怎么通过 api 的方式渲染这个列表组件呢?

先看看其他组件库是怎么做的,比如 arco design:

它是在组件渲染的过程中再重新渲染一个 root。

也就是你调用 message.info 的时候,它会创建一个新的 dom,然后通过 ReactDOM.render(或者 createRoot)在这个 dom 下新渲染一个组件树。

但是,这种方式会报 warning。

acro design 是这么解决的:

通过修改了内部的一个 waring 的开关来去掉了这个警告。

人家不让你这么用,你非得这么用,很显然,这样的实现方式不大好。

那怎么实现呢?

其实 message.info 只是需要调用列表元素的 add、remove 等方法。

那我们通过 forwardRef 的方式把 ref 转发出去,然后保存在 context 里。

这样 useMessage 里用 useContext 拿到这个 ref,是不是就可以调用 add、remove 等方法来添加删除 Message 了呢?

回过头来看下这个 Message 组件:

我们只需要维护一个数组 state,把它的 add、remove 方法通过 ref 暴露出去,保存在 context 里,使用的时候通过 useMesage 里的 useContext 拿到 add、remove 方法调用就好了。

渲染这个数组的时候要用 createPortal 在 body 下渲染,并且还要加上过渡动画。

思路理清了,我们来写下代码。

npx create-react-app --template=typescript message-component

用 cra 创建个 react 项目。

改下 index.tsx

import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<App />);

创建 Mesage/index.tsx

import { CSSProperties, FC, ReactNode } from "react";

export type Position = 'top' | 'bottom';

export interface MessageProps {
    style?: CSSProperties;
    className?: string | string[];
    content: ReactNode;
    duration?: number;
    id?: number;
    position?: Position;
}

export const MessageProvider: FC<{}> = (props) => {
    return <div></div>
}

这里的 MessageProps 就是每个 message 可以设置的参数,比如 content、duration。

而 MessageProviderProps 是整体的默认设置。

我们先实现管理 Message 列表的 hook:

Message/useStore.tsx

import { useState } from 'react';
import { MessageProps, Position } from '.';

type MessageList = {
    top: MessageProps[],
    bottom: MessageProps[]
}

const initialState = {
  top: [],
  bottom: []
};

function useStore(defaultPosition: Position) {
  const [messageList, setMessageList] = useState<MessageList>({ ...initialState });

  return {
    messageList,
    add: (messageProps: MessageProps) => {
  
    },

    update: (id: number, messageProps: MessageProps) => {

    },

    remove: (id: number) => {

    },
    
    clearAll: () => {

    },
  };
}

export default useStore;

就是通过 useState 创建一个列表,然后返回这个 state 和 add、remove、update、clearAll 方法。

列表有 top 和 bottom 两个,是因为可以在上面也可以出现在下面:

然后具体实现下 add、remove、update 方法。

先放全部代码再慢慢解释:

import { useState } from 'react';
import { MessageProps, Position } from '.';

type MessageList = {
    top: MessageProps[],
    bottom: MessageProps[]
}

const initialState = {
  top: [],
  bottom: []
};

function useStore(defaultPosition: Position) {
  const [messageList, setMessageList] = useState<MessageList>({ ...initialState });

  return {
    messageList,
    add: (messageProps: MessageProps) => {
      const id = getId(messageProps);
      setMessageList((preState) => {
        if (messageProps?.id) {
          const position = getMessagePosition(preState, messageProps.id);
          if (position) return preState;
        }

        const position = messageProps.position || defaultPosition;
        const isTop = position.includes('top');
        const messages = isTop
          ? [{ ...messageProps, id }, ...(preState[position] ?? [])]
          : [...(preState[position] ?? []), { ...messageProps, id }];

        return {
          ...preState,
          [position]: messages,
        };
      });
      return id;
    },

    update: (id: number, messageProps: MessageProps) => {
      if (!id) return;

      setMessageList((preState) => {
        const nextState = { ...preState };
        const { position, index } = findMessage(nextState, id);

        if (position && index !== -1) {
          nextState[position][index] = {
            ...nextState[position][index],
            ...messageProps,
          };
        }

        return nextState;
      });
    },

    remove: (id: number) => {
        setMessageList((prevState) => {
            const position = getMessagePosition(prevState, id);

            if (!position) return prevState;
            return {
                ...prevState,
                [position]: prevState[position].filter((notice) => notice.id !== id),
            };
        });
    },

    clearAll: () => {
      setMessageList({ ...initialState });
    },
  };
}

let count = 1;
export function getId(messageProps: MessageProps) {
  if (messageProps.id) {
    return messageProps.id;
  }
  count += 1;
  return count;
}

export function getMessagePosition(messageList: MessageList, id: number) {
    for (const [position, list] of Object.entries(messageList)) {
        if (list.find((item) => item.id === id)) {
            return position as Position;
        }
    }
}

export function findMessage(messageList: MessageList, id: number) {
    const position = getMessagePosition(messageList, id);
  
    const index = position ? messageList[position].findIndex((message) => message.id === id) : -1;
  
    return {
      position,
      index,
    };
}

export default useStore;

首先是 add:

add 核心就是 setMessageList 添加一个元素。

用 getId 方法生成一个新的 id:

如果传入了 id 就直接用传入的,否则返回递增的 id。

然后先根据 id 查找有没有已有的 message,如果有就不添加,直接返回之前的:

否则,top 的在前面插入一个元素,bottom 的在后面插入一个元素:

这个 getMessagePosition 方法就是遍历 top 和 bottom 数组,查找下有没有对应的 Message:

update 就是找到对应的 message 修改信息:

查找的方式就是先找到它在哪个数组里,然后返回对应数组中的下标:

remove 是找到对应的数组,从中删除这个元素,clear 是重置数组:

实现了列表的增删改查之后,加上过渡动画就能实现这种效果:

我们在 MessageProvider 里用 useStore 创建 message 列表,然后把它渲染出来:

import { CSSProperties, FC, ReactNode } from "react";
import useStore from "./useStore";

export type Position = 'top' | 'bottom';

export interface MessageProps {
    style?: CSSProperties;
    className?: string | string[];
    content: ReactNode | string;
    duration?: number;
    id?: number;
    position?: Position;
}

export const MessageProvider: FC<{}> = (props) => {

    const { messageList, add, update, remove, clearAll } = useStore('top');

    return <div>{
        messageList.top.map(item => {
            return  <div style={{width: 100, lineHeight: '30px', border: '1px solid #000', margin: '20px'}}>
                {item.content}
            </div>
        })
    }</div>
}

在 App.tsx 里调用下:

import { MessageProvider } from "./Message";

function App() {
  return (
    <div>
      <MessageProvider></MessageProvider>      
    </div>
  );
}

export default App;

把开发服务跑起来:

npm run start

因为我们还没调用 add、remove 等方法添加 message,所以啥也没有:

我们调用下:

useEffect(() => {
    setInterval(() => {
        add({
            content: Math.random().toString().slice(2, 8)
        })
    }, 2000);
}, []);

调用 add 添加 message 之后,页面就会渲染这个 message。

然后加上过渡动画,用 react-transition-group。

安装下:

npm install --save react-transition-group

npm install --save-dev @types/react-transition-group

return <div>
    <TransitionGroup>
        {
            messageList[position].map(item => {
                return  <CSSTransition key={item.id} timeout={1000} classNames='message'>
                    <div style={{width: 100, lineHeight: '30px', border: '1px solid #000', margin: '20px'}}>
                        {item.content}
                    </div>
                </CSSTransition>
            })
        }
    </TransitionGroup>
</div>

在 css 里写一下对应的 enter、enter-active 的具体样式:

Message/index.scss

.message-enter {
  opacity: 0;
  transform: scale(1.1);
}

.message-enter-active {
  opacity: 1;
  transform: scale(1);
  transition: opacity,transform 1s ease;
}

.message-exit {
  opacity: 1;
}

.message-exit-active {
  opacity: 0;
  transition: opacity 1s ease;
}

安装 sass 包:

npm install --save sass

在 Message/index.tsx 引入下:

当然,现在的 message 比较丑,我们写一下样式:

首先分为 .message-wrapper、.message-wrapper-top、.message-item 这三层。

import { CSSProperties, FC, ReactNode, useEffect } from "react";
import useStore, { MessageList } from "./useStore";
import { CSSTransition, TransitionGroup } from "react-transition-group";

import './index.scss';

export type Position = 'top' | 'bottom';

export interface MessageProps {
    style?: CSSProperties;
    className?: string | string[];
    content: ReactNode | string;
    duration?: number;
    onClose?: (...args: any) => void;
    id?: number;
    position?: Position;
}

export const MessageProvider: FC<{}> = (props) => {

    const { messageList, add, update, remove, clearAll } = useStore('top');

    useEffect(() => {
        setInterval(() => {
            add({
                content: Math.random().toString().slice(2, 8)
            })
        }, 2000);
    }, []);

    const positions = Object.keys(messageList) as Position[];

    return <div className="message-wrapper">
        {
            positions.map(direction => {
                return <TransitionGroup className={`message-wrapper-${direction}`} key={direction}>
                        {
                            messageList[direction].map(item => {
                                return  <CSSTransition key={item.id} timeout={1000} classNames='message'>
                                    <div className="message-item">
                                        {item.content}
                                    </div>
                                </CSSTransition>
                            })
                        }
                </TransitionGroup>
            })
        }
    </div>
}

然后写下样式:

.message-wrapper {
  position: fixed;
  width: 100%;
  height: 100%;

  pointer-events: none;

  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  align-items: center;

  &-top {
    position: absolute;
    top: 20px;
  }

  &-bottom {
    position: absolute;
    bottom: 20px;
  }
}

.message-item {
  margin-bottom: 12px;

  padding: 10px 16px;
  line-height: 14px;
  font-size: 14px;

  border: 1px solid #ccc;
  box-shadow: 0 0 3px #ccc;
  
  pointer-events: all;
}

最外层要设置 fixed,然后宽高 100%,然后加上 pointer-events:none 不响应鼠标事件。

wrapper 不响应鼠标事件,但是 message 还是要响应的,所以加上 pointer-event:all

好看多了。

只是现在的 message 都是在 root 下渲染的:

我们通过 createPortal 把它渲染到 body 下。

在 useMemo 里创建 div,因为依赖数组为空,所以只会创建一次。

然后用 createPortal 把 messageWrapper 渲染到它下面。

import { CSSProperties, FC, ReactNode, useEffect, useMemo, useRef } from "react";
import useStore, { MessageList } from "./useStore";
import { CSSTransition, TransitionGroup } from "react-transition-group";

import './index.scss';
import { createPortal } from "react-dom";

export type Position = 'top' | 'bottom';

export interface MessageProps {
    style?: CSSProperties;
    className?: string | string[];
    content: ReactNode | string;
    duration?: number;
    onClose?: (...args: any) => void;
    id?: number;
    position?: Position;
}

export const MessageProvider: FC<{}> = (props) => {

    const { messageList, add, update, remove, clearAll } = useStore('top');

    useEffect(() => {
        setInterval(() => {
            add({
                content: Math.random().toString().slice(2, 8)
            })
        }, 2000);
    }, []);

    const positions = Object.keys(messageList) as Position[];

    const messageWrapper = <div className="message-wrapper">
        {
            positions.map(direction => {
                return <TransitionGroup className={`message-wrapper-${direction}`} key={direction}>
                        {
                            messageList[direction].map(item => {
                                return  <CSSTransition key={item.id} timeout={1000} classNames='message'>
                                    <div className="message-item">
                                        {item.content}
                                    </div>
                                </CSSTransition>
                            })
                        }
                </TransitionGroup>
            })
        }
    </div>

    const el = useMemo(() => {
        const el = document.createElement('div');
        el.className = `wrapper`;

        document.body.appendChild(el);
        return el;
    }, []);

    return createPortal(messageWrapper, el);
}

可以看到,现在就是直接渲染在 body 下的 .wrapper 里了:

此外,我们还要处理下 hover 的事件,当 hover 的时候,这个提示会一直存在不会消失,直到鼠标移开才消失。

否则,到了 duration 的时间就会消失。

我们把逻辑放到一个自定义 hook 中写:

Message/useTimer.tsx

import { useEffect, useRef } from 'react';

export interface UseTimerProps {
    id: number;
    duration?: number;
    remove: (id: number) => void;
}

export function useTimer(props: UseTimerProps) {
  const { remove, id, duration = 2000 } = props;

  const timer = useRef<number | null>(null);

  const startTimer = () => {
    timer.current = window.setTimeout(() => {
      remove(id);
      removeTimer();
    }, duration);
  };

  const removeTimer = () => {
    if (timer.current) {
      clearTimeout(timer.current);
      timer.current = null;
    }
  };

  useEffect(() => {
    startTimer();
    return () => removeTimer();
  }, []);

  const onMouseEnter = () => {
    removeTimer();
  };

  const onMouseLeave = () => {
    startTimer();
  };

  return {
    onMouseEnter,
    onMouseLeave,
  };
}

传入 message 的 id、duration,还有 remove 方法。

用 useEffect 执行 startTimer,到 duration 的时候删掉 message、停止定时器。

然后如果 mouseEnter 的时候删掉定时器,mouseLeave 重新开启。

调用下:

import { CSSProperties, FC, ReactNode, useEffect, useMemo, useRef } from "react";
import useStore, { MessageList } from "./useStore";
import { CSSTransition, TransitionGroup } from "react-transition-group";

import './index.scss';
import { createPortal } from "react-dom";
import { useTimer } from "./useTimer";

export type Position = 'top' | 'bottom';

export interface MessageProps {
    style?: CSSProperties;
    className?: string | string[];
    content: ReactNode | string;
    duration?: number;
    onClose?: (...args: any) => void;
    id?: number;
    position?: Position;
}
const MessageItem:FC<MessageProps> = (item) => {
    const {onMouseEnter, onMouseLeave} = useTimer({
        id: item.id!,
        duration: item.duration,
        remove: item.onClose!,
    });

    return <div className="message-item" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
        {item.content}
    </div>
}

export const MessageProvider: FC<{}> = (props) => {

    const { messageList, add, update, remove, clearAll } = useStore('top');

    useEffect(() => {
        setInterval(() => {
            add({
                content: Math.random().toString().slice(2, 8)
            })
        }, 2000);
    }, []);

    const positions = Object.keys(messageList) as Position[];

    const messageWrapper = <div className="message-wrapper">
        {
            positions.map(direction => {
                return <div className={`message-wrapper-${direction}`} key={direction}>
                    <TransitionGroup>
                        {
                            messageList[direction].map(item => {
                                return  <CSSTransition key={item.id} timeout={1000} classNames='message'>
                                    <MessageItem onClose={remove} {...item}></MessageItem>
                                </CSSTransition>
                            })
                        }
                    </TransitionGroup>
                </div>
            })
        }
    </div>

    const el = useMemo(() => {
        const el = document.createElement('div');
        el.className = `wrapper`;

        document.body.appendChild(el);
        return el;
    }, []);

    return createPortal(messageWrapper, el);
}

可以看到过 2s message 就会消息,如果鼠标 hover 上去会直到移开鼠标再过 2s 消失。

样式写完了,我们再来处理下调用方式的问题。

用的时候我们是通过 message.info 的方式用,前面分析过,需要通过 forwardRef 把 api 转发出去。

使用 forwardRef + useImperative 转发 ref。

import { CSSProperties, FC, ReactNode, forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import useStore, { MessageList } from "./useStore";
import { CSSTransition, TransitionGroup } from "react-transition-group";

import './index.scss';
import { createPortal } from "react-dom";
import { useTimer } from "./useTimer";

export type Position = 'top' | 'bottom';

export interface MessageProps {
    style?: CSSProperties;
    className?: string | string[];
    content: ReactNode | string;
    duration?: number;
    onClose?: (...args: any) => void;
    id?: number;
    position?: Position;
}

const MessageItem:FC<MessageProps> = (item) => {
    const {onMouseEnter, onMouseLeave} = useTimer({
        id: item.id!,
        duration: item.duration,
        remove: item.onClose!,
    });

    return <div className="message-item" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
        {item.content}
    </div>
}

export interface MessageRef {
    add: (messageProps: MessageProps) => number;
    remove: (id: number) => void;
    update: (id: number, messageProps: MessageProps) => void;
    clearAll: () => void;
}

export const MessageProvider = forwardRef<MessageRef, {}>((props, ref) => {

    const { messageList, add, update, remove, clearAll } = useStore('top');

    // useEffect(() => {
    //     setInterval(() => {
    //         add({
    //             content: Math.random().toString().slice(2, 8)
    //         })
    //     }, 2000);
    // }, []);

    useImperativeHandle(ref, () => {
        return {
            add,
            update,
            remove,
            clearAll
        }
    }, []);

    const positions = Object.keys(messageList) as Position[];

    const messageWrapper = <div className="message-wrapper">
        {
            positions.map(direction => {
                return <div className={`message-wrapper-${direction}`} key={direction}>
                    <TransitionGroup>
                        {
                            messageList[direction].map(item => {
                                return  <CSSTransition key={item.id} timeout={1000} classNames='message'>
                                    <MessageItem onClose={remove} {...item}></MessageItem>
                                </CSSTransition>
                            })
                        }
                    </TransitionGroup>
                </div>
            })
        }
    </div>

    const el = useMemo(() => {
        const el = document.createElement('div');
        el.className = `wrapper`;

        document.body.appendChild(el);
        return el;
    }, []);

    return createPortal(messageWrapper, el);
})

在 App.tsx 里引入下:

import { useRef } from "react";
import { MessageProvider, MessageRef } from "./Message";

function App() {
  const messageRef = useRef<MessageRef>(null);

  return (
    <div>
      <MessageProvider ref={messageRef}></MessageProvider>     
      <button onClick={() =>{
        messageRef.current?.add({
          content:'请求成功'
        })
      }}>成功</button>
    </div>
  );
}

export default App;

是不是感觉很像了?

还差一点,我们要把它放到 Context 里。

创建 Message/ConfigProvider.tsx

import { PropsWithChildren, RefObject, createContext, useRef } from "react";
import { MessageProvider, MessageRef } from ".";

interface ConfigProviderProps {
    messageRef?: RefObject<MessageRef>
}

export const ConfigContext = createContext<ConfigProviderProps>({});

export function ConfigProvider(props: PropsWithChildren) {

    const { children } = props;
    
    const messageRef = useRef<MessageRef>(null);

    return (
      <ConfigContext.Provider value={{ messageRef }}>
          <MessageProvider ref={messageRef}></MessageProvider>
          {children}    
      </ConfigContext.Provider>
    );
}

这里用 createContext 创建 context,然后在其中放了 messageRef,这个 messageRef 的值是在 MessageProvider 设置的。

再添加一个 useMessage 的 hook:

Message/useMessage.tsx

import { useContext } from 'react';
import { ConfigContext } from './ConfigProvider';
import { MessageRef } from '.';

export function useMessage(): MessageRef {
  const { messageRef } = useContext(ConfigContext);

  if(!messageRef) {
    throw new Error('请在最外层添加 ConfigProvider 组件');
  }

  return messageRef.current!;
}

从 context 中拿到 messageRef,返回其中的 api。

这样在 App.tsx 里就可以这么用:

import { ConfigProvider } from "./Message/ConfigProvider";
import { useMessage } from "./Message/useMessage";

function Aaa() {
  const message = useMessage();

  return <button onClick={() =>{
    message.add({
      content:'请求成功'
    })
  }}>成功</button>
}

function App() {

  return (
    <ConfigProvider>
      <div>
        <Aaa></Aaa>
      </div>
    </ConfigProvider>
  );
}

export default App;

在最外层包裹 ConfigProvider 来设置 context,然后在 Aaa 组件里用 useMessage 拿到 message api,调用 add 方法。

但是跑起来你会发现报错了:

说 messageRef.current 是 null。

为什么呢,我们不是转发 ref 了么?

这个是时机的问题,我们在 useImperativeHandle 的回调函数,还有 useMessage 方法里加个 debugger:

你会发现先执行的 useMessage 取了 messageRef.current 的值,然后我们才设置了 messageRef.current。

这与我们预期是不符的。

这是用 useImperative 的一个问题,它并不是立刻修改 ref,而是会在之后的某个时间来修改。

所以这里我们要改成直接修改 ref.current 的方式。

if('current' in ref!) {
    ref.current = {
        add,
        update,
        remove,
        clearAll
    }
}

这样我们的 message 组件就完成了!

案例代码上传了小册仓库

总结

这节我们实现了 Message 组件。

它的核心就是一个列表元素的增删改,然后用 react-transition-group 加上过渡动画。

这个列表可以通过 createPortal 渲染到 body 下。

但是难点在于如何在 api 的方式来动态添加这个组件。

acro desigin 等都是用重新渲染一个 root 的方式来做的,但是这种会报警告,不建议用。

我们是通过 forwardRef + context 转发来实现的:

唯一要注意的问题就是需要直接修改 ref.current,而不是用 useImperativeHandle 来修改。

useImperative 的好处是可以在依赖数组改变的时候重新执行回调函数来修改 ref,但坏处是它不是同步修改 ref 的,有的时候不太合适。

这样,Message 组件就完成了。

这个组件还是比较复杂的,涉及到 ref 转发,context ,过渡动画,portal 等,还封装了两个自定义 hook,大家可以自己写一遍。

返回上级