• 使用Rust和React构建聊天室web端程序
  • 发布于 2个月前
  • 475 热度
    0 评论
在这篇文章中,我们将完成实时聊天应用程序的Web端的开发。

用React构建客户端UI
首先,用Node.js创建一个UI项目:
npx create-next-app@latest --js ui
一路选NO,然后进入UI目录,在项目中添加Tailwind CSS:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
现在,修改Tailwind配置文件:ui/tailwind.config.js
module.exports = {
    content: [
        "./pages/**/*.{js,ts,jsx,tsx}",
        "./components/**/*.{js,ts,jsx,tsx}",
    ],
    theme: {
        extend: {},
    },
    plugins: [],
}
我们会修改ui/package.json配置文件,将Node.js应用程序导出为静态HTML页面,以便我们可以使用Actix Web通过文件服务器访问它们:
{
    "name": "ui",
    "version": "0.1.0",
    "private": true,
    "scripts": {
      "dev": "next dev",
      "build": "next build && next export -o ../static",
      "start": "next start",
      "lint": "next lint"
    },
    "dependencies": {
      "@headlessui/react": "^1.7.4",
      "next": "13.0.4",
      "react": "18.2.0",
      "react-dom": "18.2.0"
    },
    "devDependencies": {
      "autoprefixer": "^10.4.13",
      "postcss": "^8.4.19",
      "tailwindcss": "^3.2.4"
    }
}
接下来,将Tailwind CSS工具导入到ui/styles/global.css文件中:
@tailwind base;
@tailwind components;
@tailwind utilities;
现在,让我们为客户端应用程序创建一些组件。

组件
头像组件
这里我们将为每个用户创建头像,ui/components/avatar.js
function getShortName(full_name = '') {
    if (full_name.includes(" ")) {
        const names = full_name.split(" ");
        return `${names[0].charAt(0)}${names[1].charAt(0)}`.toUpperCase()
    }
    return `${full_name.slice(0,2)}`.toUpperCase()
}

export default function Avatar({ children, color = '' }) {
  return (
    <div className='bg-blue-500 w-[45px] h-[45px] flex items-center justify-center rounded-full' style={{backgroundColor: color}}>
      <span className='font-bold text-sm text-white'>{getShortName(children)}</span>
    </div>
  )
}
登录组件
这里我们将创建用户登录组件,ui/components/login.js
import { useState } from "react";
async function createAccount({ username, phone }) {
    try {
        // 堆代码 duidaima.com
        const url = "http://localhost:8080/users/create";
        let result = await fetch(url, {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify({ username, phone })
        });
        return result.json();
    } catch (e) {
        return Promise.reject(e);
    }
}

async function signIn({ phone }) {
    try {
        const url = "http://localhost:8080/users/phone/" + phone;
        let result = await fetch(url);
        return result.json();
    } catch (e) {
        return Promise.reject(e);
    }
}

export default function Login({ show, setAuth }) {
    const [isShowSigIn, setShowSignIn] = useState(false);
    const showSignIn = () => {
        setShowSignIn(prev => !prev)
    }
    const FormCreateUsername = ({ setAuth }) => {
        const onCreateUsername = async (e) => {
            e.preventDefault();
            let username = e.target.username.value;
            let phone = e.target.phone.value;
            if (username === "" || phone === "") {
                return;
            }
            let res = await createAccount({ username, phone });
            if (res === null) {
                alert("Failed to create account");
                return;
            }
            setAuth(res)
        }
        return (
            <form action="" className="mt-4 space-y-2" onSubmit={onCreateUsername}>
                <div>
                    <label className="text-sm font-light">Username</label>
                    <input required type="text" name="username" placeholder="John Doe"
                        className="w-full px-4 py-2 mt-2 border rounded-md focus:outline-none focus:ring-1 focus:ring-blue-600" />
                </div>
                <div>
                    <label className="text-sm font-light">Phone</label>
                    <input required type="text" name="phone" placeholder="+1111..."
                        className="w-full px-4 py-2 mt-2 border rounded-md focus:outline-none focus:ring-1 focus:ring-blue-600" />
                </div>
                <div className="flex items-baseline justify-between">
                    <button type="submit"
                        className="px-6 py-2 mt-4 text-white bg-violet-600 rounded-lg hover:bg-violet-700 w-full">Submit</button>
                </div>
                <div className="pt-2 space-y-2 text-center">
                    <p className="text-base text-gray-700">Already have a username? <button onClick={showSignIn} className="text-violet-700 font-light">Sign In</button></p>
                </div>
            </form>
        )
    }
    const FormSignIn = ({ setAuth }) => {
        const onSignIn = async (e) => {
            e.preventDefault();
            let phone = e.target.phone.value;
            if (phone === "") {
                return;
            }
            let res = await signIn({ phone });
            if (res === null) {
                alert("Failed to create account");
                return;
            }
            if (!res.id) {
                alert(`Phone number not found ${phone}`);
                return;
            }
            setAuth(res)
        }
        return (
            <form action="" className="mt-4 space-y-2" onSubmit={onSignIn}>
                <div>
                    <label className="text-sm font-light">Phone</label>
                    <input required type="text" name="phone" placeholder="+1111..."
                        className="w-full px-4 py-2 mt-2 border rounded-md focus:outline-none focus:ring-1 focus:ring-blue-600" />
                </div>
                <div className="flex items-baseline justify-between">
                    <button type="submit"
                        className="px-6 py-2 mt-4 text-white bg-violet-600 rounded-lg hover:bg-violet-700 w-full">Submit</button>
                </div>
                <div className="pt-2 space-y-2 text-center">
                    <p className="text-base text-gray-700">Don't have username? <button onClick={showSignIn} className="text-violet-700 font-light">Create</button></p>
                </div>
            </form>
        )
    }
    return (
        <div className={`${show ? '' : 'hidden'} bg-gradient-to-b from-orange-400 to-rose-400`}>
            <div className="flex items-center justify-center min-h-screen">
                <div className="px-8 py-6 mt-4 text-left bg-white  max-w-[400px] w-full rounded-xl shadow-lg">
                    <h3 className="text-xl text-slate-800 font-semibold">{isShowSigIn ? 'Log in with your phone.' : 'Create your account.'}</h3>
                    {isShowSigIn ? <FormSignIn setAuth={setAuth} /> : <FormCreateUsername setAuth={setAuth} />}
                </div>
            </div>
        </div>
    )
}
聊天室组件
在这里,我们将创建聊天室组件,ui/components/room.js
import React, { useState, useEffect } from "react";
import Avatar from "./avatar";
// 堆代码 duidaima.com
async function getRooms() {
    try {
        const url = "http://localhost:8080/rooms";
        let result = await fetch(url);
        return result.json();
    } catch (e) {
        console.log(e);
        return Promise.resolve(null);
    }
}

function ChatListItem({ onSelect, room, userId, index, selectedItem }) {
    const { users, created_at, last_message } = room;
    const active = index == selectedItem;
    const date = new Date(created_at);
    const ampm = date.getHours() >= 12 ? 'PM' : 'AM';
    const time = `${date.getHours()}:${date.getMinutes()} ${ampm}`
    const name = users?.filter(user => user.id != userId).map(user => user.username)[0];
    return (
        <div
            onClick={() => onSelect(index, {})}
            className={`${active ? 'bg-[#FDF9F0] border border-[#DEAB6C]' : 'bg-[#FAF9FE] border border-[#FAF9FE]'} p-2 rounded-[10px] shadow-sm cursor-pointer`} >
            <div className='flex justify-between items-center gap-3'>
                <div className='flex gap-3 items-center w-full'>
                    <Avatar>{name}</Avatar>
                    <div className="w-full max-w-[150px]">
                        <h3 className='font-semibold text-sm text-gray-700'>{name}</h3>
                        <p className='font-light text-xs text-gray-600 truncate'>{last_message}</p>
                    </div>
                </div>
                <div className='text-gray-400 min-w-[55px]'>
                    <span className='text-xs'>{time}</span>
                </div>
            </div>
        </div>
    )
}

export default function ChatList({ onChatChange, userId }) {
    const [data, setData] = useState([])
    const [isLoading, setLoading] = useState(false)
    const [selectedItem, setSelectedItem] = useState(-1);
    useEffect(() => {
        setLoading(true)
        getRooms()
            .then((data) => {
                setData(data)
                setLoading(false)
            })
    }, [])
    const onSelectedChat = (idx, item) => {
        setSelectedItem(idx)
        let mapUsers = new Map();
        item.users.forEach(el => {
            mapUsers.set(el.id, el);
        });
        const users = {
            get: (id) => {
                return mapUsers.get(id).username;
            },
            get_target_user: (id) => {
                return item.users.filter(el => el.id != id).map(el => el.username).join("")
            }
        }
        onChatChange({ ...item.room, users })
    }
    return (
        <div className="overflow-hidden space-y-3">
            {isLoading && <p>Loading chat lists.</p>}
            {
                data.map((item, index) => {
                    return <ChatListItem
                        onSelect={(idx) => onSelectedChat(idx, item)}
                        room={{ ...item.room, users: item.users }}
                        index={index}
                        key={item.room.id}
                        userId={userId}
                        selectedItem={selectedItem} />
                })
            }
        </div>
    )
}
对话组件
在这里,我们将创建用户对话组件,ui/components/conversation.js
import React, { useEffect, useRef } from "react";
import Avatar from "./avatar"

function ConversationItem({ right, content, username }) {
    if (right) {
        return (
            <div className='w-full flex justify-end'>
                <div className='flex gap-3 justify-end'>
                    <div className='max-w-[65%] bg-violet-500 p-3 text-sm rounded-xl rounded-br-none'>
                        <p className='text-white'>{content}</p>
                    </div>
                    <div className='mt-auto'>
                        <Avatar>{username}</Avatar>
                    </div>
                </div>
            </div>
        )
    }
    return (
        <div className='flex gap-3 w-full'>
            <div className='mt-auto'>
                <Avatar color='rgb(245 158 11)'>{username}</Avatar>
            </div>
            <div className='max-w-[65%] bg-gray-200 p-3 text-sm rounded-xl rounded-bl-none'>
                <p>{content}</p>
            </div>
        </div>
    )
}

export default function Conversation({ data, auth, users }) {
    const ref = useRef(null);
    useEffect(() => {
        ref.current?.scrollTo(0, ref.current.scrollHeight)
    }, [data]);
    return (
        <div className='p-4 space-y-4 overflow-auto' ref={ref}>
            {
                data.map(item => {
                    return <ConversationItem
                        right={item.user_id === auth.id}
                        content={item.content}
                        username={users.get(item.user_id)}
                        key={item.id} />
                })
            }
        </div>
    )
}
现在让我们编写与WebSocket服务器和REST API服务器交互所需的hook。

HOOK
useWebsocket Hook
这个Hook是用来连接到WebSocket服务器的,使我们能够发送和接收消息,ui/libs/useWebsocket.js
import { useEffect, useRef } from "react";

export default function useWebsocket(onMessage) {
    const ws = useRef(null);
    useEffect(() => {
        if (ws.current !== null) return;
        const wsUri = 'ws://localhost:8080/ws';
        ws.current = new WebSocket(wsUri);
        ws.current.onopen = () => console.log("ws opened");
        ws.current.onclose = () => console.log("ws closed");
        const wsCurrent = ws.current;
        return () => {
            wsCurrent.close();
        };
    }, []);
    useEffect(() => {
        if (!ws.current) return;
        ws.current.onmessage = e => {
            onMessage(e.data)
        };
    }, []);
    const sendMessage = (msg) => {
        if (!ws.current) return;
        ws.current.send(msg);
    }
    return sendMessage;
}
useLocalStorage Hook
这个钩子使我们能够从localStorage获取用户数据,ui/libs/useLocalStorage.js
import { useEffect, useState } from "react";

export default function useLocalStorage(key, defaultValue) {
  const [storedValue, setStoredValue] = useState(defaultValue);
  const setValue = (value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      if (typeof window !== "undefined") {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch (error) {
    }
  };
  useEffect(() => {
    try {
      const item = window.localStorage.getItem(key);
      let data = item ? JSON.parse(item) : defaultValue;
      setStoredValue(data)
    } catch (error) {}
  }, [])
  return [storedValue, setValue];
}
useConversation Hook
我们将使用这个Hook来获取基于给定房间id的对话,ui/libs/useConversation.js
import { useEffect, useState } from "react";

const fetchRoomData = async (room_id) => {
    if (!room_id) return;
    const url = `http://localhost:8080/conversations/${room_id}`;
    try {
        let resp = await fetch(url).then(res => res.json());
        return resp;
    } catch (e) {
        console.log(e);
    }
}

export default function useConversations(room_id) {
    const [isLoading, setIsLoading] = useState(true);
    const [messages, setMessages] = useState([]);
    const updateMessages = (resp = []) => {
        setIsLoading(false);
        setMessages(resp)
    }
    const fetchConversations = (id) => {
        setIsLoading(true)
        fetchRoomData(id).then(updateMessages)
    }
    useEffect(() => fetchConversations(room_id), []);
    return [isLoading, messages, setMessages, fetchConversations];
}

构建聊天应用程序
现在让我们用Node.js连接所有组件和Hooks,在React中构建聊天应用程序。

首先,让我们导入所有需要的依赖,ui/pages/index.js
import Head from 'next/head'
import React, { useEffect, useState } from 'react'
import Avatar from '../components/avatar'
import ChatList from '../components/room'
import Conversation from '../components/conversation'
import Login from '../components/login'
import useConversations from '../libs/useConversation'
import useLocalStorage from '../libs/useLocalStorage'
import useWebsocket from '../libs/useWebsocket'
现在,让我们为聊天页面设置状态:
export default function Home() {
  const [room, setSelectedRoom] = useState(null);
  const [isTyping, setIsTyping] = useState(false);
  const [showLogIn, setShowLogIn] = useState(false);
  const [auth, setAuthUser] = useLocalStorage("user", false);
  const [isLoading, messages, setMessages, fetchConversations] = useConversations("");
  ...
}
下面的函数将处理所有进出WebSocket服务器的消息:
export default function Home() {
  ...
  const handleTyping = (mode) => {
    if (mode === "IN") {
      setIsTyping(true)
    } else {
      setIsTyping(false)
    }
  }
  const handleMessage = (msg, userId) => {
    setMessages(prev => {
      const item = { content: msg, user_id: userId };
      return [...prev, item];
    })
  }
  const onMessage = (data) => {
    try {
      let messageData = JSON.parse(data);
      switch (messageData.chat_type) {
        case "TYPING": {
          handleTyping(messageData.value[0]);
          return;
        }
        case "TEXT": {
          handleMessage(messageData.value[0], messageData.user_id);
          return;
        }
      }
    } catch (e) {
      console.log(e);
    }
  }
  const sendMessage = useWebsocket(onMessage)
  const updateFocus = () => {
    const data = {
      id: 0,
      chat_type: "TYPING",
      value: ["IN"],
      room_id: room.id,
      user_id: auth.id
    }
    sendMessage(JSON.stringify(data))
  }
  const onFocusChange = () => {
    const data = {
      id: 0,
      chat_type: "TYPING",
      value: ["OUT"],
      room_id: room.id,
      user_id: auth.id
    }
    sendMessage(JSON.stringify(data))
  }
  const submitMessage = (e) => {
    e.preventDefault();
    let message = e.target.message.value;
    if (message === "") {
      return;
    }
    if (!room.id) {
      alert("Please select chat room!")
      return
    }
    const data = {
      id: 0,
      chat_type: "TEXT",
      value: [message],
      room_id: room.id,
      user_id: auth.id
    }
    sendMessage(JSON.stringify(data))
    e.target.message.value = "";
    handleMessage(message, auth.id);
    onFocusChange();
  }
  ......
}
handleTyping:更新状态以显示键入指示器
handleMessage:处理状态的传入和传出消息
onMessage:处理从WebSocket服务器获取的消息
updateFocus:告诉WebSocket服务器当前用户是否仍在输入消息
onFocusChange:让WebSocket服务器知道当前用户何时完成输入
submitMessage:更新消息状态,然后在用户点击发送按钮时将消息发送到服务器

我们将使用以下函数来处理更新消息和用户登录和注销的状态:
export default function Home() {
  ......
  const updateMessages = (data) => {
    if (!data.id) return;
    fetchConversations(data.id)
    setSelectedRoom(data)
  }
  const signOut = () => {
    window.localStorage.removeItem("user");
    setAuthUser(false);
  }
  useEffect(() => setShowLogIn(!auth), [auth])
  ......
}
现在,让我们将所有数据显示给客户端:
export default function Home() {
  ......
  return (
    <div>
      <Head>
        <title>Rust with react chat app</title>
        <meta name="description" content="Rust with react chat app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Login show={showLogIn} setAuth={setAuthUser} />
      <div className={`${!auth && 'hidden'} bg-gradient-to-b from-orange-400 to-rose-400 h-screen p-12`}>
        <main className='flex w-full max-w-[1020px] h-[700px] mx-auto bg-[#FAF9FE] rounded-[25px] backdrop-opacity-30 opacity-95'>
          <aside className='bg-[#F0EEF5] w-[325px] h-[700px] rounded-l-[25px] p-4 overflow-auto relative'>
            <ChatList onChatChange={updateMessages} userId={auth.id} />
            <button onClick={signOut} className='text-xs w-full max-w-[295px] p-3 rounded-[10px] bg-violet-200 font-semibold text-violet-600 text-center absolute bottom-4'>LOG OUT</button>
          </aside>
          {room?.id && (<section className='rounded-r-[25px] w-full max-w-[690px] grid grid-rows-[80px_minmax(450px,_1fr)_65px]'>
            <div className='rounded-tr-[25px] w-ful'>
              <div className='flex gap-3 p-3 items-center'>
                <Avatar color='rgb(245 158 11)'>{room.users.get_target_user(auth.id)}</Avatar>
                <div>
                  <p className='font-semibold text-gray-600 text-base'>{room.users.get_target_user(auth.id)}</p>
                  <div className='text-xs text-gray-400'>{isTyping ? "Typing..." : "10:15 AM"}</div>
                </div>
              </div>
              <hr className='bg-[#F0EEF5]' />
            </div>
            {(isLoading && room.id) && <p className="px-4 text-slate-500">Loading conversation...</p>}
            <Conversation data={messages} auth={auth} users={room.users} />
            <div className='w-full'>
              <form onSubmit={submitMessage} className='flex gap-2 items-center rounded-full border border-violet-500 bg-violet-200 p-1 m-2'>
                <input
                  onBlur={onFocusChange}
                  onFocus={updateFocus}
                  name="message"
                  className='p-2 placeholder-gray-600 text-sm w-full rounded-full bg-violet-200 focus:outline-none'
                  placeholder='Type your message here...' />
                <button type='submit' className='bg-violet-500 rounded-full py-2 px-6 font-semibold text-white text-sm'>Sent</button>
              </form>
            </div>
          </section>)}
        </main>
      </div>
    </div>
  )
  ......
}
运行聊天应用
启动服务器
在项目根目录下执行命令 
cargo run

    Finished dev [unoptimized + debuginfo] target(s) in 1.16s
     Running `target/debug/rust-react-chat`
Server running at http://127.0.0.1:8080/
启动Web端 
在ui目录下执行命令:
npm run dev

- ready started server on 0.0.0.0:3000, url: http://localhost:3000
在浏览器中输入:http://localhost:3000

总结
在本文中,我们讨论了WebSockets的特性,它在Rust中的应用,以及如何与actix-web一起使用。我们演示了如何创建一个高效的实时聊天应用程序,使用React和Node.js建立到Actix Web服务器的WebSocket连接。

用户评论