動手做個聊天室,前端工程師百無聊賴的人生
本專案服務端基於node.js技術,使用了koa框架,所有資料儲存在mongodb中。客戶端使用react框架,使用redux和immutable.js管理狀態,APP端基於react-native和expo開發。本文需要對JavaScript較為熟悉,講解核心功能點的設計思路。

g
服務端架構
服務端負責兩件事:
1.提供基於Socket/">WebSocket的介面
2.提供index.html響應
服務端使用了koa-socket這個包,它集成了socket.io並實現了socket中介軟體機制,服務端基於該中介軟體機制,自己實現了一套介面路由
每個介面都是一個async函式,函式名即介面名,同時也是socket事件名
非同步 登入(CTX){ 回報 “登入成功” }
然後寫了個 route
中介軟體,用來完成路由匹配,當判斷路由匹配時,以 ctx
物件作為引數執行路由方法,並將方法返回值作為介面返回值
functionnoop(){} / ** *路由處理 * @引數 {IO}IO KOA插座IO例項 * @引數 {物件}路由路由 * /模組。exports = function( io, _io, routes){ Object。鑰匙(路線)。的forEach((路線) => { IO。上(路線,空操作); //註冊事件 }); 返回 非同步(CTX)=> { //判斷路由是否存在 ,如果(路線[ CTX。事件 ]){ 常量 { 事件,資料,插座 } = CTX; //執行路由並獲取返回資料 ctx。res=等待路由[ ctx。event ]({ event,//事件名 資料,//請求資料 socket,//使用者socket例項 io,// koa-socket例項 _io,// socket.io例項 }); } }; };
還有一個重要 catchError
中介軟體是,它負責捕獲全域性異常,業務流程中大量使用 assert
判斷業務邏輯,不滿足條件時會中斷流程並返回錯誤訊息,catchError將捕獲業務邏輯異常,並取出錯誤訊息返回給客戶端
這些就是服務端的核心邏輯,基於該架構下定義介面組成業務邏輯
另外,服務端還負責提供index.html響應,即客戶端首頁。客戶端的其他資源是放在CDN上的,這樣可以緩解服務端頻寬壓力,但是index.html不能使用強快取,因為會使得客戶端更新不可控,因此index.html放在服務端
客戶端架構
客戶端使用socket.io-client連線服務端,連線成功後請求介面嘗試登入,如果localStorage沒有令牌或者介面返回令牌過期,將會以遊客身份登入,登入成功會返回使用者資訊以及群組,好友列表,接著去請求各群組,好友的歷史訊息
客戶端需要監聽connect / disconnect / message三個訊息
1.connect:socket連線成功
2.disconnect socket連線斷開
3.message 接收到新訊息
客戶端使用redux管理資料,需要被元件共享的資料放在redux中,只有自身使用的資料還是放在元件的state中,客戶端儲存的redux資料結構如下:
[圖片上傳失敗...(image-fcdad3-1542894883246)]
使用者使用者資訊
_id使用者id
使用者名稱使用者名稱
linkmans聯絡人列表,包括群組,好友以及臨時會話
isAdmin是否是管理員
焦點當前聚焦的聯絡人id,既對話中的目標
連線連線狀態
ui客戶端UI相關和功能開關
客戶端的資料流,主要有兩條線路
1.使用者操作=>請求介面=>返回資料=>更新redux =>檢視重新渲染
2.監聽新訊息=>處理資料=>更新redux =>檢視重新渲染
使用者系統
使用者架構定義:
constUserSchema=newSchema({ createTime : {type : Date,default : Date。現在 }, lastLoginTime : {type : Date,default : Date。現在 }, 使用者名稱: { type : String, 修剪: 是的, 獨一無二: 真的, 匹配:/ ^([ 0-9a-zA-Z ] {1,2} | [ \ u4e00 - \ u9eff ]){1,8} $ /, index : true, }, 鹽: 字串, 密碼: 字串, 頭像: { type : String, }, });
createTime
:建立時間
lastLoginTime
:最後一次登入時間,用來清理殭屍號用
username
:使用者暱稱,同時也是賬號
salt
:加密鹽
password
:使用者密碼
avatar
:使用者頭像URL地址
使用者註冊
註冊介面需要 username
/ password
兩個引數,首先做判空處理
const { username,password } =ctx。資料 ; 斷言(使用者名稱,'使用者名稱不能為空'); 斷言(密碼,'密碼不能為空');
然後判斷使用者名稱是否已存在,同時獲取預設群組,新註冊使用者要加入到預設群組
constuser=等待 使用者。findOne({username}); 斷言(! user,'該使用者名稱已存在'); constdefaultGroup=awaitGroup。findOne({isDefault : true }); assert(defaultGroup,'預設群組不存在');
存密碼明文肯定是不行的,生成隨機鹽,並使用鹽加密密碼
constsalt=awaitbcrypt。genSalt $(saltRounds); consthash=awaitbcrypt。hash $(密碼,鹽);
給使用者一個隨機預設頭像,儲存使用者資訊到資料庫
讓 newUser =null ; 嘗試 { newUser =等待 使用者。創造({ 使用者名稱, 鹽, 密碼:雜湊, avatar : getRandomAvatar(), }); } 捕獲(ERR){ 如果(ERR。名 ===' ValidationError '){ 返回 '使用者名稱包含不支援的字元或者長度超過限制' ; } 扔錯了; }
將使用者新增到預設群組,然後生成使用者令牌
令是用來免費碼登入的憑證,儲存在客戶端localStorage,令牌裡帶帶使用者id,過期時間,客戶端資訊三個資料,使用者id和過期時間容易理解,客戶端資訊是為了防止令牌盜用,之前也試過驗證客戶端ip一致性,但是ip可能會有經常改變的情況,搞得使用者每次自動登入都被判定為盜用了......
defaultGroup。成員。推(newUser); 等待 defaultGroup。save(); 常量 令牌 =的generateToken(NEWUSER。_id,環境);
將使用者id與當前socket連線關聯,服務端是以為否 ctx.socket.user
為undefined來判斷登入態的
更新Socket表中當前socket連線資訊,後面獲取線上使用者會取Socket表資料
ctx。插座。user=newUser。_id ; 等待 Socket。更新({ID : CTX。插槽。ID },{ user : newUser。_id, os,//客戶端系統 瀏覽器,//客戶端瀏覽器 環境,//客戶端環境資訊 });
最後將資料返回客戶端
返回 { _id : newUser。_id, 頭像: newUser。頭像, 使用者名稱: newUser。使用者名稱, 組: [{ _id : defaultGroup。_id, name : defaultGroup。名字, avatar : defaultGroup。頭像, creator : defaultGroup。創作者, createTime : defaultGroup。createTime, 訊息: [], }], 朋友們: [], 令牌, }
使用者登入
fiora是不限制多登陸的,每個使用者都可以在無限個終端登入
登入有三種情況:
遊客登入
令牌登入
使用者名稱/密碼登入
遊客登入僅能檢視預設群組訊息,並且不能發訊息,主要是為了降低第一次來的使用者的體驗成本
令牌登入是最常用的,客戶端首先從localStorage取令牌,令牌存在就會使用令牌登入
首先對令牌解碼取出負載資料,判斷令牌是否過期以及客戶端資訊是否匹配
let payload =null ; 嘗試 { payload =jwt。解碼(令牌,配置。jwtSecret); } catch(err){ return'非法令牌' ; } 斷言(日期。現在()<有效載荷。期滿,'令牌已過期'); 斷言。相等(環境,有效載荷,環境,'非法登入');
從資料庫查詢使用者資訊,更新最後登入時間,查詢使用者所在的群組,並將socket新增到該群組,然後查詢使用者的好友
constuser=等待 使用者。findOne({_id : 有效載荷。使用者 },{_id : 1,頭像: 1,使用者名稱: 1 }); 斷言(使用者,'使用者不存在'); 使用者。lastLoginTime=日期。now(); 等待 使用者。save(); constgroups=等待 組。find({members : user},{_ id : 1,name : 1,avatar : 1,creator : 1,createTime : 1 }); 團體。的forEach((組)=> { CTX。插座。插座。加入(組。_id); 返回基; }); constfriends=await Friend 。找到({來自: 使用者。_id }) 。populate(' to ',{avatar : 1,username : 1 });
更新socket資訊,與註冊相同
ctx。插座。user=user。_id ; 等待 Socket。更新({ID : CTX。插槽。ID },{ 使用者: 使用者。_id, 作業系統, 瀏覽器, 環境, });
最後返回資料
使用者名稱/密碼與令牌登入僅一開始的邏輯不同,沒有解碼令牌驗證資料這步
先驗證使用者名稱是否存在,然後驗證密碼是否匹配
constuser=等待 使用者。findOne({username}); 斷言(使用者,'該使用者不存在'); constisPasswordCorrect=bcrypt。compareSync(密碼,使用者。密碼); assert(isPasswordCorrect,'密碼錯誤');
接下來邏輯就與令牌登入一致了
訊息系統
傳送訊息
sendMessage介面有三個引數:
to:傳送的物件,群組或者使用者
type:訊息型別
content:訊息內容
因為群聊和私聊共用這一個介面,所以首先需要判斷是群聊還是私聊,獲取群組id或者使用者戶ID,群聊/私聊通過引數
區分群聊時到是相應的群組id,然後獲取群組資訊
私聊時到是傳送者和接收者二人id拼接的結果,去掉髮送者id就得到了接收者id,然後獲取接收者資訊
讓 groupId =' ' ; let userId =' ' ; if(isValid(to)){ constgroup=awaitGroup。findOne({_ id : to}); assert(group,'群組不存在'); } else { userId =to。替換(CTX。插座。使用者,' '); assert(isValid(userId),'無效的使用者ID '); constuser=等待 使用者。findOne({_ id : userId}); 斷言(使用者,'使用者不存在'); }
部分訊息型別需要做些處理,文字訊息判斷長度並做xss處理,邀請訊息判斷邀請的群組是否存在,然後將邀請人,群組id,群組名等資訊儲存到訊息體中
讓 messageContent = content; 如果(型別==='文字'){ 斷言(在messageContent。長度 <=2048,'訊息長度過長'); messageContent =xss(content); } elseif(type ===' invite '){ constgroup=awaitGroup。findOne({name : content}); 斷言(group,'目標群組不存在'); constuser=等待 使用者。findOne({_id : CTX。插座。使用者 }); messageContent =JSON。stringify({ 邀請者: 使用者。使用者名稱, groupId : group。_id, groupName : group。名字, }); }
將新訊息存入資料庫
讓訊息; 嘗試 { message =等待 訊息。創造({ 來自: ctx。插座。使用者, 至, 型別, content : messageContent, }); } catch(err){ throw err; }
接下來構造一個不包含敏感資訊的訊息資料,資料中包含傳送者的id,使用者名稱,頭像,其中使用者名稱和頭像是比較冗餘的資料,以後考慮會優化成只傳一個id,客戶端維護使用者資訊,通過id匹配出使用者名稱和頭像,能
節約很多流量如果是群聊訊息,直接把訊息推送到對應群組
即可私聊訊息更復雜一些,因為fiora是允許多登入的,首先需要推送給接收者的所有線上插座,然後還要推送給自身的其餘線上插座
constuser=等待 使用者。findOne({_id : CTX。插座。使用者 },{使用者名稱: 1,頭像: 1 }); constmessageData= { _id : 訊息。_id, createTime : message。createTime, 來自: 使用者。toObject(), 至, 型別, content : messageContent, }; if(groupId){ ctx。插座。插座。到(groupId)。emit(' message ',messageData); } else { constsockets=awaitSocket。find({user : userId}); 插座。的forEach((插座)=> { CTX。_IO。到(插座。ID。)發射('訊息',messageData); }); constselfSockets=awaitSocket。找到({使用者: CTX。插座。使用者 }); selfSockets。的forEach((插座)=> { 如果(插座。ID!==CTX。插座。ID){ CTX。_IO。到(插座。ID)。發射('訊息',messageData); } }); }
最後把訊息資料返回給客戶端,表示訊息傳送成功。客戶端為了優化使用者體驗,傳送訊息時會立即在頁面上顯示新資訊,同時請求介面傳送訊息。如果訊息傳送失敗,就刪掉該條訊息
獲取歷史訊息
getLinkmanHistoryMessages介面有兩個引數:
linkmanId
:聯絡人id,群組或者倆使用者id拼接
existCount
:已有的訊息個數
詳細邏輯比較簡單,按建立時間倒序查詢已有個數+每次獲取個數數量的訊息,然後去掉已有個數的訊息再反轉一下,就是按時間排序的新訊息
constmessages=等待訊息 。找到( {to : linkmanId}, {type : 1,content : 1,from : 1,createTime : 1 }, {sort : {createTime : - 1 },limit : EachFetchMessagesCount + existCount}, ) 。populate(' from ',{username : 1,avatar : 1 }); constresult=messages。切片(existCount)。reverse();
返回給客戶端
接收推送訊息
客戶端訂閱訊息事件接收新訊息 socket.on('message')
接收到新訊息時,先判斷狀態中是否存在該聯絡人,如果存在則將訊息存到對應的聯絡人下,如果不存在則是一條臨時會話的訊息,構造一個臨時聯絡人並獲取歷史訊息,然後將臨時聯絡人新增到州中。如果是來自自己其它終端的訊息,則不需要建立聯絡人
conststate=store。getState(); constisSelfMessage=message。來自。_id===州。getIn([ '使用者',' _id ' ]); constlinkman=state。getIn([ ' user ',' linkmans ' ])。找到(升 =>升。獲得(“ _id') ===訊息。to); let title =' ' ; if(linkman){ action。addLinkmanMessage(訊息。到,訊息); 如果(聯絡人,得到( “型別”) ===“組”){ title =` $ { message。來自。username }在$ { linkman。get(' name ')}對大家說:` ; } else { title =` $ { message。來自。username }對你說:` ; } } else { //聯絡人不存在並且是自己發的訊息,不建立新聯絡人 if(isSelfMessage){ return ; } constnewLinkman= { _id : getFriendId( 狀態。getIn([ '使用者',' _id ' ]), 訊息。從。_id, ) 型別: '臨時', createTime : 日期。now(), 頭像: 訊息。來自。頭像, 名稱: 訊息。來自。使用者名稱, 訊息: [], 未讀: 1, }; 行動。addLinkman(newLinkman); title =` $ { message。來自。username }對你說:` ; 取(' getLinkmanHistoryMessages ',{linkmanId : newLinkman。_id })。然後(([ ERR,RES ])=> { 如果(! ERR){ 動作。addLinkmanMessages(newLinkman。_id,RES); } }); }
如果當前聊天頁是在後臺的,並且打開了訊息通知開關,則會彈出桌面提醒
如果(windowStatus ==='模糊'&&狀態。getIn([ ' UI ',' notificationSwitch ' ])){ 通知( 標題, 訊息。來自。頭像, 訊息。鍵入 ===' text ' ? 訊息。內容 : ` [ $ { message。type } ] `, 數學。random(), ); }
如果打開了聲音開關,則響一聲新訊息提示音
如果(狀態。getIn([ ' UI ',' soundSwitch ' ])){ 常量 soundType=狀態。getIn([ ' ui ',' sound ' ]); 聲音(soundType); }
如果打開了語言播報開關並且是文字訊息,將訊息內的url和#過濾掉,排除長度大於200的訊息,然後推送到訊息朗讀佇列中
如果(訊息。型別 ==='文字'&&狀態。getIn([ ' UI ',' voiceSwitch ' ])){ 常量 文字 =訊息。內容 。取代(/ HTTPS ?:\ / \ /(WWW \。 )? [ - A-ZA-Z0-9 @:% 。 _ +〜#=] {2256} 。\ [ AZ ] {2,6-} \ b(- [ A-ZA-Z0-9:@%_ + 。〜#&// =?] *)/克,' ') 。replace(/#/ g,' '); //字的最大數目是200 ,如果(文字。長度 >200){ 返回 ; } constfrom= linkman &&linkman。get(' type ')===' group ' ? ` $ { 訊息。來自。username }在$ { linkman。get(' name ')}說` : ` $ { message。來自。username }對你說` ; if(text){ 聲音。推(從==! prevFrom ?從+文字:文字訊息。來自。使用者名稱); } prevFrom = from; }
更多中介軟體
限制未登入請求
大多數介面是隻允許已登入使用者訪問的,如果介面需要登入且socket連線沒有使用者資訊,則返回“未登入”錯誤
/ ** *攔截未登入請求 * / module。exports = function(){ const noUseLoginEvent = { 註冊: 真的, 登入: true, loginByToken : 是的, 嘉賓: 真的, getDefalutGroupHistoryMessages : true, getDefaultGroupOnlineMembers : true, }; 返回 非同步(CTX,下一個)=> { 如果(! noUseLoginEvent [ CTX。事件 ] &&!CTX。插座。使用者){ CTX。res='請登入後再試' ; 迴歸 ; } 等待 下一個(); }; };
限制呼叫頻率
為了防止刷介面的情況,減輕伺服器壓力,限制同一插座連線每分鐘內最多請求30次介面
constMaxCallPerMinutes=30 ; / ** *限制介面呼叫的頻率 * /模組。exports = function(){ let callTimes = {}; setInterval(()=> callTimes = {},60000); //每隔60秒清空一次 returnasync(ctx,next)=> { constsocketId=ctx。插座。id ; constcount= callTimes [socketId] ||0 ; if(count > = MaxCallPerMinutes){ returnctx。res='介面呼叫頻繁' ; } callTimes [socketId] = count +1 ; 等待 下一個(); }; };
小黑屋
管理員賬號可以將使用者新增到小黑屋,被新增到小黑屋的使用者無法請求任何介面,10分鐘後自動解禁
/ ** *拒絕密封使用者請求 * /模組。exports = function(){ return async( ctx, next) => { const sealList = global。mdb。get( ' sealList '); 如果( CTX。插槽。使用者&& sealList。有( CTX。插槽。使用者。 toString())){ returnctx。res='你已經被關進小黑屋中,請反思後再試' ; } 等待 下一個(); }; };
其它
表情
表情是一張雪碧圖,點選表情會向輸入框插入格式為 #(xx)
的文字,例如 #(滑稽)
。在渲染訊息時,通過正則匹配將這些文字替換為 <img>
,並計算出該表情在雪碧圖中的位置,然後渲染到頁面上
不設定src會顯示一個邊框,需要將src設定為一張透明圖
functionconvertExpression(txt){ returntxt。替換( /#\(([ \ u4e00 - \ u9fa5 a-z ] +)\)/ g, (r,e)=> { constindex=表示式。預設。indexOf(e); 如果(索引!==- 1){ 返回 ` <IMG類= “表達-百度” SRC = “ $ { transparentImage } ”樣式=“背景位置:左$ { - 30*指數} PX;” onerror =“this.style.display ='none'”alt =“ $ { r } ”> ` ; } 返回 r; }, ); }
表情包搜尋
的爬 ofollow,noindex">https://www.doutula.com 上的搜尋查詢查詢結果
constres=等待 axios。得到(` https://www.doutula.com/search?keyword= $ { encodeURIComponent(keywords)} `); 斷言(RES。狀態 ===200,'搜尋表情包失敗,請重試'); constimages=res。資料。匹配(/資料原始=” [ ^ “] + ” /克)|| []; 返回 影象。圖(我 =>我。子(15,我。長度 -1));
桌面訊息通知
[圖片上傳失敗...(image-31fdb0-1542894883354)]
效果如上圖,不同系統/瀏覽器在樣式上會有區別
經常有人問到這個是怎麼實現的,其實是HTML5增加的功能Notification
貼上發圖
監聽paste事件,獲取貼上內容,如果包含Files型別內容,則讀取內容並生成Image物件。注意:通過該方式拿到的圖片,會比原圖片體積大很多,因此最好壓縮一下再使用
@autobind handlePaste(É){ 常量 { 項,型別 } =(Ë。clipboardData||Ë。originalEvent。clipboardData); //如果包含檔案內容 如果(型別。的indexOf( '檔案') >- 1){ 對於(讓索引 =0 ;索引 <項。長度 ;索引 ++){ 常量 項 =項[指數]; 如果(專案。那種 ==='檔案'){ const的 檔案 =項。getAsFile(); if(file){ constthat=this ; constreader=newFileReader(); 讀者。onloadend=function(){ constimage=newImage(); 影象。onload=()=> { //獲取到圖片圖片物件 }; 影象。src=這個。結果 ; }; 讀者。readAsDataURL(file); } } } e。preventDefault(); } }
後話
想把前端學好,js真的真的很重要!!!我的web前端學習q.u.n【731771211】,學習資源免費分享,資深五年資深前端工程師線上課堂講解實戰技術。歡迎新手,進階