iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0

gh

接下來要實作聊天功能的元件!切版部分就不會提太多細節,會以元件的設計思路為主~

佈局分析

回想一下 WooTalk 的聊天功能,內容大概由 3 個元件組成:

  1. 包裝全部訊息的盒子,長度隨訊息量延展
  2. 一則訊息
  3. 操作區塊:包含離開配對、輸入框、送出按鈕

可以明顯看出 一則訊息 一定是從 props 傳入然後顯示訊息內文,絕對不可能在一則訊息裡面透過打 API 的方式拿到訊息 XD

這樣的元件也被稱作 Presentational Component(展示層),不涉及複雜的業務邏輯,單純接收資料。

負責存取 API、重組資料、管理共用狀態的元件,則稱作 Container Component(邏輯層),可能小至一個表單,大到整個頁面,都可以是一個 container(或稱 module)。

在 Next 中這個觀念會更明顯,不論是過去的 Page Router 還是現在的 App Router,只要談到拿資料,就會想到 ISR、SSR、SSG,這些預渲染的機制都必須在 page 元件執行拿資料的邏輯。

那麼,在不確定什麼元件怎麼分類時該怎麼做?像是 包裝全部訊息的盒子 就很難說是哪種元件,這時我會先把主要的業務邏輯放到頂層的容器,例如 page 元件處理即可,這樣至少能預防在較末端的元件把 API 打爆的狀況。


一則訊息

展示層的元件最容易設計!props 可以照著先前規劃好的資料架構來定義。

訊息會根據是否由使用者發出,來決定排版要靠左還是靠右,所以這個元件要做的判斷是 排列位置是否由使用者發出 這個判斷我認為應該要在上層完成,減少展示層的邏輯:

export type ChatMessageData = {
  id: string;
  user_id: string;
  device: keyof typeof Device;
  message: string;
  created_at: string;
};

interface ChatMessageCardProps {
  data: ChatMessageData;
  isUser: boolean;
}

export default function ChatMessageCard({ data, isUser }: ChatMessageCardProps) {
  const { device, message, created_at } = data;
  const justify = isUser ? 'end' : 'start';

  return (
    <Flex justify={justify} align="end">
      <Flex
        p="xs"
        maw={360}
        bdrs="md"
        bg={alpha('var(--mantine-color-soft-lime-0)', 0.5)}
      >
        <Text
          style={{
            wordBreak: 'break-word',
            overflowWrap: 'break-word',
            whiteSpace: 'pre-wrap',
          }}
        >
          {message}
        </Text>
      </Flex>
      <Stack
        gap={0}
        mx="8"
        mb="4"
        ta={isUser ? 'right' : 'left'}
        style={{ order: isUser ? -1 : 1 }}
      >
        <Text size="xs">{Device[device]}</Text>
        <Text size="xs">{created_at}</Text>
      </Stack>
    </Flex>
  );
}

CSS Module

如果覺得 style props 多到干擾閱讀,這是正常的......就如同 MUI 的 sx 或是 Tailwind 大量的 utility class,雖然好寫但要付出一點代價 QQ

官方推薦的分離方式是使用 CSS Module,但這個方法一開始得反覆去查 Mantine 的 CSS 變數,所以我想不是大型專案的話,倒不一定要做很乾淨的樣式分離,我有強迫症所以我做了 XDDD

但如果善用科技(?),那麼用 style props 快速寫完後,再讓工讀生分離樣式就可以啦:

import styles from './styles.module.css';

export default function ChatMessageCard({ data, isUser }: ChatMessageCardProps) {
  const { device, message, created_at } = data;
  const justify = isUser ? styles.wrapperEnd : styles.wrapperStart;

  return (
    <Flex className={`${styles.wrapper} ${justify}`}>
      <Flex
        className={styles.messageBox}
        bg={alpha('var(--mantine-color-soft-lime-0)', 0.5)}
      >
        <Text className={styles.messageText}>{message}</Text>
      </Flex>
      <Stack
        className={`${styles.infoStack} ${isUser ? styles.infoStackRight : styles.infoStackLeft}`}
      >
        <Text size="xs">{Device[device]}</Text>
        <Text size="xs">{created_at}</Text>
      </Stack>
    </Flex>
  );
}

至於有沒有比較好讀就見仁見智,畢竟這是前端長年以來的宗教戰爭了(?)


訊息盒

先不考慮跨元件狀態管理的話,訊息盒也偏向展示層,大部分的資料要透過外部控制。

配對的狀態不是單純的已配對與未配對,至少還會有一個等待配對,因此 matchStatus 就不能只用 boolean 來處理:

interface ChatBoxProps {
  userId: string | null;
  messages: ChatMessage[];
  matchStatus: MatchStatus;
}

export default function ChatBox({
  userId,
  messages,
  matchStatus,
}: ChatBoxProps) {
  return (
    <Stack
      className={styles.chatBoxWrapper}
      bg={alpha('var(--mantine-color-moss-green-2)', 0.4)}
    >
      {matchStatus === 'waiting' ? (
        <Text>配對中...</Text>
      ) : (
        <>
          <Text>配對成功!</Text>
          <Text className={styles.chatBoxTextMb}>開始聊天吧!</Text>

          <Stack className={styles.messagesContainer}>
            {messages.map((message: ChatMessage) => (
              <ChatMessageCard
                key={message._id}
                data={message}
                isUser={message.user_id === userId}
              />
            ))}
          </Stack>
        </>
      )}
      {matchStatus === 'left' && <Text>對方已離開</Text>}
    </Stack>
  );
}

設計好訊息盒的架構後,上層的 page.tsx 可以試著做出對應的邏輯和假資料。

handleStartMatch 中可以加入 setTimeout 來模擬等待配對的效果:

export default function Home() {
  const [opened, { toggle }] = useDisclosure();
  const [matchStatus, setMatchStatus] = useState<matchStatus>('standby');
	const [messages, setMessages] = useState<ChatMessageData[]>([
	  // 假資料
	]);

  async function handleStartMatch() {
    setMatchStatus('waiting');

    await new Promise((resolve) =>
      setTimeout(() => {
        setMatchStatus('matched');
        resolve(true);
      }, 5000)
    );
  }

  function handleLeaveMatch() {
    setMatchStatus('standby');
  }

	function handleSendMessage(message: string) {
    setMessages([
      ...messages,
      {
        id: (messages.length + 1).toString(),
        user_id: '123',
        device: 'PC',
        message,
        created_at: new Date().toISOString(),
      },
    ]);
  }

輸入框

輸入框的邏輯也不多,送出訊息可以由按下 enter 或是按下 送出 按鈕觸發,加上輸入防呆就大致完成了。

這裡我加上了清空訊息的按鈕 rightSection,這是官方文件有示範的用法:

interface ChatActionBar {
  matchStatus: matchStatus;
  onSendMessage: (message: string) => void;
  onLeaveMatch: () => void;
}

export default function ChatActionBar({
  matchStatus,
  onSendMessage,
  onLeaveMatch,
}: MessageInputProps) {
  const [message, setMessage] = useState('');

  function handleSendMessage() {
    if (message.trim() === '') {
      return;
    }

    onSendMessage(message);
    setMessage('');
  }

  function handleClearMessage() {
    setMessage('');
  }

  if (matchStatus === 'standby') {
    return null;
  }

  return (
    <Flex className={styles.wrapper}>
      <Button
        variant="subtle"
        onClick={onLeaveMatch}
        classNames={{ root: styles.leaveButtonRoot }}
      >
        離開
      </Button>
      <Input
        disabled={matchStatus !== 'matched'}
        rightSection={
          <Input.ClearButton
            onClick={handleClearMessage}
            disabled={message.trim() === ''}
          />
        }
        rightSectionPointerEvents="auto"
        rightSectionWidth="auto"
        classNames={{
          wrapper: styles.inputWrapper,
          input: styles.inputInput,
        }}
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter') {
            handleSendMessage();
          }
        }}
      />
      <Button
        disabled={matchStatus !== 'matched'}
        classNames={{ root: styles.sendButtonRoot }}
        onClick={handleSendMessage}
      >
        送出
      </Button>
    </Flex>
  );
}

滾動軸

在測試配對成功後的狀態時,聊天訊息會不斷向下新增會出現 Y 軸,這是合理的現象,不過瀏覽器預設的 scrollbar 除了不好看,scrollbar 出現也會造成畫面一瞬間的推擠,我覺得多少會對使用體驗造成一點影響,像 WooTalk 一進去網站就是固定有 Y 軸的。

這時候可以用到 ScrollArea 這個元件來製造可以滾動的容器:

<AppShell.Main className={styles.appShellMain}>
  <ScrollArea type="auto" scrollbarSize={8} className={styles.scrollArea}>
    <Stack mx="auto" maw={480} className={styles.outerStack}>
      <Stack className={styles.innerStack}>
        <Image
          src="/logo-full.png"
          alt="Cozy Chat"
          width={256}
          height={256}
        />

看起來好多了~也不會因為突然出現的 Y 軸造成畫面推擠!

gh

還有一個相當影響使用體驗的問題:每次輸入新的訊息時容器高度會不斷改變,但 scrollbar thumb 並不會自動移動到底部。

通常呼叫 scrollTo 即可滾動,但如果是在特定區塊內才有 scrollbar 的話,要先用 useRef 綁定該區塊的 DOM 才能呼叫 scrollTo

剛剛加入的 ScrollArea 有提供 viewportRef 給我們綁定,所以就直接拿來用吧:

// 綁定 DOM
const viewport = useRef<HTMLDivElement>(null);

// JSX
<ScrollArea
  type="auto"
  scrollbarSize={8}
  className={styles.scrollArea}
  viewportRef={viewport}
>

接著就可以使用 viewport 來執行滾動,配合 useEffect 就可以在配對狀態變更或是新增訊息時,滾動到容器底部:

// 封裝成函式
function handleScrollToBottom() {
  viewport.current?.scrollTo({
	top: viewport.current.scrollHeight,
	behavior: 'smooth',
  });
}

useEffect(() => {
  handleScrollToBottom();
}, [matchStatus, messages]);

gh

:::info
useRef 操作 DOM 時,一定是在相關的狀態更新後,DOM 重新渲染結束才去操作,所以會搭配 useEffect。在比較複雜的容器中要留意狀態更新的問題,不然會變成先操作了 DOM 結果又被刷掉的狀況,這時候可能會懷疑人生 XD
:::


背景特效

在初期製作我通常不管特效,會先以完成整體佈局為主,這樣比較好交付進度跟抓到整體元件架構。

但現在這個畫面的組成只有漸層,看起來很單調...這時就可以發動一點前端魔法,我參考的是這位大大的文章:網頁的流動背景怎麼做?讓你的網站背景不再死版

原始程式碼不會太複雜,但直接叫 AI 改應該會出錯,因為這不是單純從 class component 轉成 function component,還附帶一些操作 DOM 的行為,所以描述得不夠詳細的話 AI 的產出會有點問題。

這邊也稍微講解我的改寫思路,如果改不動,其實把原始碼照搬過來也可以......小心不要用 useState 把 DOM 實例刷掉就好 XD

改寫邏輯

  1. 動畫邏輯主要是更新元素位置,並且靠 requestAnimationFrame 來達到順暢的重繪,如果用 useState 來控制元素位置,會造成一點延遲,我們只需要用到 useRef 初始化 DOM 就可以
  2. class 建構函式改用 useEffect 來初始化
  3. 容器也需要 useRef 來存這些泡泡的實例並觸發 requestAnimationFrame
  4. 泡泡本身也要暴露方法,讓容器可以在觸發 requestAnimationFrame 的過程操作這些暴露的方法,來更新泡泡的 DOM
  5. useEffect 中加入 clean up,來清理已經註冊過的動畫
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import styles from './style.module.css';

const MIN_SPEED = 0.5;
const MAX_SPEED = 2;
const BLOB_COUNT = 8;

const randomNumber = (min: number, max: number): number =>
  Math.random() * (max - min) + min;

type BlobStateType = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  size: number;
  initialX: number;
  initialY: number;
};

type BlobRefType = {
  initBlob: () => void;
  updateBlobPosition: () => void;
};

const Blob = forwardRef<BlobRefType>((_, ref) => {
  const blobRef = useRef<HTMLDivElement>(null);
  const blobStateRef = useRef<BlobStateType | null>(null);

  function initBlob() {
    if (!blobRef.current) {
      return;
    }

    blobRef.current.classList.add(styles.blob);
    const boundingRect = blobRef.current.getBoundingClientRect();
    const size = boundingRect.width;

    // 隨機初始位置
    const initialX = randomNumber(0, window.innerWidth - size);
    const initialY = randomNumber(0, window.innerHeight - size);

    // 初始化 DOM 位置
    blobRef.current.style.top = `${initialY}px`;
    blobRef.current.style.left = `${initialX}px`;

    // 速度
    const vx =
      randomNumber(MIN_SPEED, MAX_SPEED) * (Math.random() > 0.5 ? 1 : -1);
    const vy =
      randomNumber(MIN_SPEED, MAX_SPEED) * (Math.random() > 0.5 ? 1 : -1);

    blobStateRef.current = {
      x: initialX,
      y: initialY,
      vx,
      vy,
      size,
      initialX,
      initialY,
    };
  }

  function updateBlobPosition() {
    if (!blobStateRef.current || !blobRef.current) {
      return;
    }

    const state = blobStateRef.current;

    // 更新位置
    state.x += state.vx;
    state.y += state.vy;

    // 邊界檢測與反彈 - 優化後的邏輯
    if (state.x >= window.innerWidth - state.size || state.x <= 0) {
      state.vx *= -1;
      state.x = Math.max(0, Math.min(state.x, window.innerWidth - state.size));
    }
    if (state.y >= window.innerHeight - state.size || state.y <= 0) {
      state.vy *= -1;
      state.y = Math.max(0, Math.min(state.y, window.innerHeight - state.size));
    }

    // 直接更新 DOM,避免重新渲染
    blobRef.current.style.transform = `translate(${state.x - state.initialX}px, ${state.y - state.initialY}px)`;
  }

  // 暴露內部方法
  useImperativeHandle(ref, () => ({
    initBlob,
    updateBlobPosition,
  }));

  // 初始化泡泡位置
  useEffect(() => {
    initBlob();
  }, [blobRef.current]);

  return <div ref={blobRef} />;
});

Blob.displayName = 'Blob';

export default function Blobs() {
  const blobRefs = useRef<(BlobRefType | null)[]>(
    Array.from({ length: BLOB_COUNT }, () => null)
  );
  const animationRef = useRef<number | null>(null);
  const isRunning = useRef(false);

  // 初始化動畫
  useEffect(() => {
    isRunning.current = true;

    function update() {
      if (!isRunning.current) {
        return;
      }

      blobRefs.current.forEach((blob) => {
        blob?.updateBlobPosition();
      });

      animationRef.current = requestAnimationFrame(update);
    }

    animationRef.current = requestAnimationFrame(update);

    return () => {
      isRunning.current = false;
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current);
      }
    };
  }, []);

  // window size 改變時重新初始化所有泡泡位置
  useEffect(() => {
    function handleResize() {
      blobRefs.current.forEach((blob) => {
        if (blob) {
          blob.initBlob();
        }
      });
    }

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return (
    <div className={styles.blobs}>
      {Array.from({ length: BLOB_COUNT }, (_, index) => (
        <Blob
          key={index}
          ref={(el) => {
            blobRefs.current[index] = el;
          }}
        />
      ))}
    </div>
  );
}

雖然我的註解很像廢話,不過我覺得 side effect 容易出現一些隱性的 bug,所以我習慣稍微註解一下每個 effect 在做什麼。

替換 CSS 變數

原文的 CSS 變數可以換成 theme.ts 定義的顏色,Mantine 會為這些顏色生成全域的 CSS 變數:

.blobs {
  --cr-1: color-mix(
    in srgb,
    var(--mantine-color-moss-green-6),
    var(--mantine-color-deep-teal-4) 20%
  );
  --cr-2: color-mix(
    in srgb,
    var(--mantine-color-moss-green-6),
    var(--mantine-color-deep-teal-4) 40%
  );
  --cr-3: color-mix(
    in srgb,
    var(--mantine-color-moss-green-6),
    var(--mantine-color-deep-teal-4) 60%
  );
  --cr-4: color-mix(
    in srgb,
    var(--mantine-color-moss-green-6),
    var(--mantine-color-deep-teal-4) 80%
  );

  position: fixed;
  z-index: -1;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  filter: blur(120px);
}

.blob {
  width: max(240px, 50vw);
  aspect-ratio: 1;
  border-radius: 50%;
  position: absolute;
  top: 0;
  left: 0;
}

.blob:nth-of-type(1) {
  background: var(--mantine-color-cyan-1);
}

.blob:nth-of-type(2) {
  background: var(--mantine-color-yellow-1);
}

.blob:nth-of-type(3) {
  background: var(--mantine-color-cyan-3);
}

.blob:nth-of-type(4) {
  background: var(--mantine-color-yellow-3);
}

.blob:nth-of-type(5) {
  background: var(--cr-3);
}

.blob:nth-of-type(6) {
  background: var(--cr-1);
}

.blob:nth-of-type(7) {
  background: var(--cr-2);
}

.blob:nth-of-type(8) {
  background: var(--cr-4);
}

泡泡的數量和樣式可以改成傳參數、動態生成,做成更彈性的擴充,但目前先能用就好 XD

filter: blur(1px) 數值調低可以看到銳利的泡泡,方便觀察運動軌跡!

背景先前是用漸層填充,但沒有設透明度所以看不到泡泡。直接調背景的 alpha 值會疊到這些泡泡的顏色上,少了一點跳色的感覺,所以我改用 backdropFilter: blur,整體呼吸感(?)也比較好:

<AppShell.Main
  p={0}
	bg="soft-lime.2"
	style={{
      backdropFilter: 'blur(1px)',
      overflow: 'hidden',
	}}
>
  <Blobs />
  {/* 略 */}
</AppShell.Main>

我不是在 Blob 的 JSX 中透過 className 注入樣式,而是在 initBlob 中加入:blobRef.current.classList.add(styles.blob),因為我發現泡泡位置的初始化會稍微晚一點才執行,如果這時候已經注入樣式的話,會先帶入裡面設定的位置,讓泡泡停在左上角一下下:

gh

將顏色調整到滿意後就完成囉!

gh


本日小結

今天的內容其實花了好幾天撰寫,因為要持續對 Mantine 元件和排版試錯,也嘗試了以前沒寫過的特效,所以花了蠻多時間 XD

重點在於元件分離的邏輯,是否符合 Container/Presentational Pattern,因為它代表了樹狀的資料流模式,也反映出 DOM Tree 的觀念。

真實的配對邏輯就等後端完成後再進行實作吧!

:::info
上面的元件有很多方法是 handle 開頭,這是事件驅動相關的邏輯常用的命名慣例,代表某個事件被觸發時的實際要做出的回應邏輯(event handler)。
:::


參考資料


上一篇
[Day-10] 按圖施工-網頁篇:主題設定
系列文
熟悉的網聊最對味?來做個匿名聊天室吧!11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言