作者現在制作一款網頁端聊天室(青春版
),之前一直有這個想法,現在總算是邁出了第一步開始制作了… 雄關漫道真如鐵,而今邁步從頭越!
啟程
當前已經完成左側聊天室列表顯示,通過http://localhost:10086/chatRoom/chatRoomList
接口進行獲取,可選傳入當前用戶id,這樣將返回所有存在此用戶的聊天室數據。
數據從node + express后端獲取,存儲數據庫為mongoDB。
- 獲取到的數據:
- 當前效果:
組件設計
在聊天室項目里,右側的聊天內容區域組件設計要慎重,畢竟屬于項目當中的重中之重。
因為聊天一般分為群聊和私聊,所以我準備開發兩種復用組件groupChat
(群聊)和privateChat
(私聊)。
關于組件的引入,有兩種方案可選:動態組件模式 和 單一組件條件渲染
這里我選擇使用 動態組件模式,其優點:
- 類型隔離:群聊/私聊邏輯完全解耦
- 性能優化:
key
保證切換時完全重建實例,可以進行異步加載 - 可擴展性:后續想到新的聊天類型,添加起來也非常方便
組件通信規范:
- 父組件 → 子組件:Props
- 子組件 → 父組件:Emit
- 跨層級通信:Provide/Inject
這兩種聊天組件當中也具有相同的結構,實現私聊和群聊組件的高復用性設計,關鍵在于將通用邏輯與特殊邏輯分離。
對于設計思路,準備使用組合式API + 插槽組件的方式。
動手:組件創建
創建相應的組件文件,groupChat.vue
、privateChat.vue
和BaseMessage.vue
三個組件文件
在BaseMessage.vue
中主要由三部分組成,這也是群聊和私聊的共同點,三部分分別是:
- 頂部:群聊顯示聊天室名稱,私聊顯示對方用戶名
- 內容:消息的滾動條列表,用戶頭像、消息氣泡、消息時間,間隔時間較久的消息間顯示時間分割線
- 底部:消息輸入框,可輸入文字、圖片、表情包
可以先添加相應插槽進入每一塊元素,這樣可以增強組件的可擴展性。
接下來在群聊和私聊組件當中引入基礎插件,傳入聊天室名稱
將群聊組件和私聊組件引入聊天室當中,使用動態切換的方式進行加載。
<div class="chat-main"><!-- 動態組件模式 --><component :is="curChatRoom.key" :key="curChatRoom.key" :chatRoom="curChatRoom" />
</div>
...
<script>import privateChat from "@/components/privateChat.vue";import groupChat from "@/components/groupChat.vue";// 定義組件選項,這樣下面可以直接用字符串名稱代表組件defineOptions({components:{groupChat,privateChat}})// 定義當前聊天的聊天室,動態組件的key,用于強制組件重新渲染const curChatRoom = reactive({roomId: "",key: "groupChat",roomName: "",roomType: "public"});// 切換聊天室,設置當前聊天室和組件const switchChatRoom = (room) => {curChatRoom.roomId = room.roomId;curChatRoom.key = room.roomType == "private" ? "privateChat" : "groupChat";curChatRoom.roomName = room.roomName;}
</script>
當前效果可做到點擊切換左側聊天室,右側群聊的名稱相應改變
現在已經生成了可復用的群聊組件。
組件交互
在最外層數據傳入群聊groupChat
和私聊private
組件后,組件都需要根據變化的聊天室roomId去獲取此聊天室的聊天記錄并展示,因為當前我尚未在后端編寫相應接口,先用模擬數據代替。
這里用戶頭像數據不放在聊天數據一起,這能夠減輕聊天數據負擔。
// 聊天數據
const mockChatHistory = [{username: '張三',time: new Date('2024-07-01 10:00:00').toLocaleString(),content: '大家好,今天天氣不錯!'},{username: '李四',time: new Date('2024-07-01 10:05:00').toLocaleString(),content: '是的,適合出去走走。'},{username: '王五',time: new Date('2024-07-01 10:10:00').toLocaleString(),content: '有沒有推薦的地方?'}
];// 對應的頭像
mockChatUserAvatar = {"張三": 'icon-animal-4',"李四": 'icon-animal-9',"王五": 'icon-animal-1'
}
得到這些數據后,可以將數據傳入BaseMessage.vue
基礎組件當中,當然外部切換聊天室需要內部對roomId進行監聽。
// 假設這里有一個外部動態變化的響應式變量 propswatch(() => props.chatRoom?.roomId, async (newRoomId, oldRoomId) => {if (newRoomId) {await getChatUserAvatar();await getChatHistory();}});
注意:接下來很多內容都是在
BaseMessage.vue
基礎組件中,基礎組件由頭部、消息列表、輸入框三大部分組成
時間分割線
在聊天窗中判斷是否添加時間切割線,通常可參考時間維度和聊天活躍度維度標準:
- 時間維度:
- 日期變化 :當新消息與上一條消息不在同一天時,添加時間切割線,這是最常見的判斷方式,能清晰區分不同日期的聊天記錄。
- 設定時間間隔 :根據預設的時間間隔(如每 5 分鐘、每小時等)來判斷。當兩條消息的時間間隔超過設定值,就添加時間切割線,方便用戶按特定時間范圍查看聊天記錄 。
- 聊天活躍度維度:
- 消息密集程度 :若一段時間內聊天信息較為密集,即使在同一天,也可能添加多個時間切割線,以區分不同活躍時段的聊天內容;反之,若聊天信息比較稀疏,即使跨越幾天,也可能不添加切割線。
根據傳入的時間判斷時間分割線是否顯示:
// 判斷時間分隔線是否顯示
const shouldShowTimeDivider = (index) => {if (index === 0) return true; // 第一個消息顯示分隔線const currentTime = new Date(props.mockChatHistory[index].time);const previousTime = new Date(props.mockChatHistory[index - 1].time);if (currentTime - previousTime > 5 * 60 * 1000) { // 超過5分鐘顯示分隔線return true;} else {return false;}
}
// 時間分割線時間顯示形式
const formatTime = (time) => {const date = new Date(time);const now = new Date();const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());const yesterday = new Date(today);yesterday.setDate(yesterday.getDate() - 1);const dayBeforeYesterday = new Date(today);dayBeforeYesterday.setDate(dayBeforeYesterday.getDate() - 2);// 今天:顯示小時:分鐘if (date >= today) {return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });} // 昨天:顯示昨天 小時:分鐘else if (date >= yesterday) {return `昨天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;}// 前天:顯示前天 小時:分鐘else if (date >= dayBeforeYesterday) {return `前天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;}// 今年:顯示月份-日期else if (date.getFullYear() === now.getFullYear()) {return `${date.getMonth() + 1}月${date.getDate()}日 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;}// 往年:顯示xxxx年else {return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;}
}
對于時間分割線上時間內容顯示:
- 當日時間:hh:mm
- 昨天/前天: 昨天/前天 hh:mm
- 今年內: 月份日期 hh:mm
- 以往年份:年份月份日期 hh:mm
PS:這里對聊天內容界面的元素布局和樣式等內容就不詳細說明了,都算比較基礎的了
消息輸入框
聊天消息輸入框需要兼顧用戶體驗、功能完備性和技術實現優雅性,當然現在我只進行基礎的設計
需要考慮的內容
- 輸入框高度調節
- 功能擴展區
- @成員提及
- 表情插入
高度調節
給底部chat-footer
元素添加偽類,高度3px,設置ns-resize
雙向調整大小光標
CSS樣式:
&::before {content: '';position: absolute;top: 0px;left: 0;right: 0;height: 3px;background-color: #ccc;cursor: ns-resize;z-index: 1;
}
效果:
現在光設置css樣式還不能拖動邊線,需要添加相應的Js方法:(這里在onDragMove
移動方法中,設置可移動的最大200px最小100px,chat-content
是底部輸入框上面的聊天內容區域)
// 底部footer輸入框拖動方法
const footerRef = ref(null);
const isDragging = ref(false);
const startY = ref(0);
const startHeight = ref(0);
// 底部輸入框頂部邊拖動 鼠標按下事件
const onDragStart = (e) => {e.preventDefault(); // 阻止默認行為isDragging.value = true;startY.value = e.clientY;startHeight.value = footerRef.value.offsetHeight;document.addEventListener('mousemove', onDragMove);document.addEventListener('mouseup', onDragEnd);document.body.style.userSelect = 'none'; // 禁用文本選擇
};
// 底部輸入框頂部邊拖動 鼠標移動事件
const onDragMove = (e) => {if (!isDragging.value) return;const dy = e.clientY - startY.value;const newHeight = startHeight.value - dy;if (newHeight >= 100 && newHeight <= 200) {footerRef.value.style.height = `${newHeight}px`;document.querySelector('.chat-content').style.height = `calc(100% - 50px - ${newHeight}px)`;}
};
// 底部輸入框頂部邊拖動 鼠標松開事件
const onDragEnd = () => {isDragging.value = false;document.removeEventListener('mousemove', onDragMove);document.removeEventListener('mouseup', onDragEnd);document.body.style.userSelect = ''; // 恢復文本選擇
};onMounted(() => {footerRef.value = document.querySelector('.chat-footer');// 直接在footer元素上監聽mousedown事件,通過event.target判斷是否點擊了頂部邊框footerRef.value.addEventListener('mousedown', (e) => {if (e.offsetY <= 3) { // 判斷是否點擊了頂部3px區域onDragStart(e);}});
});
功能擴展區
有最基礎的表情和聊天記錄icon按鈕,如果后續有需要可以繼續添加擴展功能,考慮到后續可能群里和私聊功能不同,添加了slot
擴展
<!-- 功能列表 -->
<div class="input-function-list"><div class="input-function-item" v-for="funItem in inputFunctionList" :key="funItem.name" :title="funItem.text"><svg class="icon" aria-hidden="true"><use :xlink:href="'#' + funItem.icon"></use></svg></div><slot name="input-extra-function"></slot>
</div>
基礎輸入框功能:
// 輸入框功能列表
const inputFunctionList = [{ name: 'emoji', icon: 'icon-biaoqing', text: '表情', event: () => { console.log('點擊了表情') } },{ name: 'record', icon: 'icon-liaotianjilu', text: '聊天記錄', event: () => { console.log('點擊了聊天記錄')} }
];
可編輯DIV輸入框
使用div的contenteditable
去制作一個可輸入消息框,讓div元素可編輯
<div class="input-area"><!-- 可擴展的輸入區域 --><slot name="input-area-solt"><div ref="editableDiv" class="editable-area" contenteditable @input="handleInput"@keydown.enter.exact.prevent="sendMessage"@paste="handlePaste"></div></slot>
</div>
這里設計:
- enter:發送消息
- shift + enter:換行
通過@keydown.enter.exact.prevent
已經讓回車鍵和發送消息方法關聯起來了,現在設置shift + enter
配置換行。在div
中配置 @keydown.shift.enter.prevent="handleShiftEnter"
handleShiftEnter方法用于獲取當前選區,在輸入框文本對象末尾添加換行節點br,并重新設置光標位置
// 輸入框換行事件處理
const handleShiftEnter = (e) => {e.preventDefault();const selection = window.getSelection();// 檢查選區是否在可編輯區域內if (!selection.containsNode(editableDiv.value, true)) {return;}const range = selection.getRangeAt(0);// 創建并插入換行節點const br = document.createElement('br');range.insertNode(br);// 創建新范圍并設置光標位置const newRange = document.createRange();newRange.setStartAfter(br); // 在新創建的 <br> 元素后設置光標位置newRange.collapse(true);// 更新選區selection.removeAllRanges();selection.addRange(newRange);
}
效果:
當前的復制會將顏色復制過來,所以需要設置handlePaste
復制方法,只復制文本內容
這里對于圖片數據復制時,也需要進行在輸入框自動縮小圖片尺寸,主要修改包括:
- 檢測剪貼板中的圖片數據
- 使用
FileReader
讀取原始圖片數據(event.target.result
)創建 img 元素 - 保持原有的
maxWidth
樣式設置將圖片縮小到最大寬度100px - 保持圖片比例不變
- 將縮小后的圖片插入到可編輯區域
- 仍然保留原有的純文本粘貼功能
// 輸入框復制事件處理
const handlePaste = (e) => {e.preventDefault();// 檢查是否由圖片數據const clipboardData = e.clipboardData;if (clipboardData.files && clipboardData.files.length > 0) {const file = clipboardData.files[0];if (file.type.startsWith('image/')) {const reader = new FileReader();reader.onload = (event) => {const range = window.getSelection().getRangeAt(0);range.deleteContents();const imgElement = document.createElement('img');imgElement.src = event.target.result;imgElement.style.maxWidth = '100px';range.insertNode(imgElement);};reader.readAsDataURL(file);return;}}// 處理純文本粘貼const text = e.clipboardData.getData('text/plain');document.execCommand('insertHTML', false, text);
}
to be continued
讓作者先歇歇吧,成員提及和表情包插入功能尚未開發,后續還要加入最重要WebSocket發送機制
這些都將再下篇文章中出現,先到此這里吧