- 浏览: 1466262 次
- 性别:
- 来自: 上海
文章分类
最新评论
-
luhouxiang:
写的很不错,学习了
Extjs 模块化动态加载js实践 -
kingkongtown:
如果想改成淘宝后台那样,可以在编辑器批量上传图片呢?
kissy editor 阶段体会 -
317966578:
兄弟我最近也在整jquery和caja 开放一些接口。在git ...
caja 原理 : 前端 -
liuweihug:
Javascript引擎单线程机制及setTimeout执行原 ...
setTimeout ,xhr,event 线程问题 -
辽主临轩:
怎么能让浏览器不进入 文档模式的quirks模式,进入标准的
浏览器模式与文本模式
认为长连接就是有个http请求被服务器阻塞了 ,这样的话浏览器就一直等在那,服务器可以随时给浏览器发送信息了,对于servlet 就是一个线程被阻塞在一个servlet实例那里,等待其他servlet线程的通知。
ps:一个servlet实例被无数个线程使用的,阻塞的线程在这个实例上排队
基于上述思想,实现实时聊天,客户端向一个receive.jsp发起一个 ajax 接受信息的请求,服务器判断有信息的话,就 ajax 处理后,再发送请求,否则 receive.jsp wait() ,等待。如果一个 ajax调用了 send.jsp ,则通知 receive.jsp notify 。还要用户退出时,也要 receive.jsp notify ,否则这个线程就永远阻塞了!这就需要sessionlistener
1.HttpSessionListener
用于记录当前在线用户 ,以及当前用户退出时通知其他用户
package hyjc.listener; import hyjc.common.SequenceUtil; import java.util.*; import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; import javax.servlet.*; import javax.servlet.http.*; import javax.servlet.jsp.*; import org.apache.log4j.Logger; public class CustomSessionListener implements HttpSessionListener, ServletContextListener { static Logger logger = Logger.getLogger(CustomSessionListener.class); private Hashtable allSessions = new Hashtable(); //session销毁前需要通知的servlet线程实例 private Map<String, Object> servers = Collections.synchronizedMap(new HashMap<String, Object>()); public CustomSessionListener() { logger.debug("CustomSessionListener constructed!"); } public void sessionCreated(HttpSessionEvent arg0) { HttpSession session = arg0.getSession(); logger.debug("CustomSessionListener sessionCreated " + session.getId()); allSessions.put(session.getId(), session); } public void sessionDestroyed(HttpSessionEvent arg0) { HttpSession session = arg0.getSession(); logger.debug("CustomSessionListener sessionDestroyed " + session.getId()); allSessions.remove(session.getId()); Set<String> keys = servers.keySet(); for (String key : keys) { logger.debug("CustomSessionListener notify " + key); Object o = servers.get(key); synchronized (o) { try { o.notifyAll(); } catch (Exception e) { e.printStackTrace(); } } } } /** * 应用关闭 */ public void contextDestroyed(ServletContextEvent sc) { ServletContext application = sc.getServletContext(); logger.debug("CustomSessionListener contextDestroyed " + application.getServletContextName()); } /** * 应用启动 */ public void contextInitialized(ServletContextEvent sc) { ServletContext application = sc.getServletContext(); logger.debug("CustomSessionListener contextInitialized " + application.getServletContextName()); application.setAttribute("allSessions", allSessions); application.setAttribute("_SESSIONSERVERLETLISTENSERS_", servers); application.setAttribute("contextInitializedTime", System.currentTimeMillis()); } }
2. receive.jsp
ajax 接收消息 ,当没有消息时线程阻塞
<%@ page contentType="text/plain; charset=GBK"%> <%@ page import="java.util.Hashtable"%><%@ page import="java.util.Map"%> <% boolean newM=false; //session销毁前需要通知的servlet线程实例 Map<String, Object> servers =(Map<String, Object>)application.getAttribute("_SESSIONSERVERLETLISTENSERS_"); if(servers.get("_UPDATECHATSERVLET_")==null) { servers.put("_UPDATECHATSERVLET_",this); } Hashtable allSessions = (Hashtable) application.getAttribute("allSessions"); while(!newM) { //如果已经退出,自己建的全局session hashtable已没有该id,则 直接输出非法json,extjs 不会再连了 if(allSessions.get(session.getId())==null) break; String im = (String)session.getAttribute("_IM_"); //有消息就调用回调函数 if (im != null) { out.println("\n{'msgs':["); out.println(im); out.println("\t]"); session.setAttribute("_IM_", null); out.print("}"); out.flush(); newM=true; } //否则继续等待 else { //必须必须同步 synchronized (this) { System.out.println("wait ******************************************s"+session.getId()); try{ //会释放lock wait();}catch (Exception e){ e.printStackTrace(); newM=true; } System.out.println("waked ******************************************s"+session.getId()); } } } %>
3.sendmsgLong.jsp
发送消息,并通知阻塞在接收消息的所有线程
<%@ page contentType="text/html; charset=GBK" %> <%@ page import="hyjc.common.ConversionUtil,java.sql.Timestamp,java.util.Hashtable" %> <%@ page import="java.util.Map" %> <% Timestamp now = new Timestamp(System.currentTimeMillis()); String sender = request.getParameter("sender"); if (!session.getId().equals(sender)) { out.println("{'result':'访问拒绝!'}"); return; } Hashtable allSessions = (Hashtable) application.getAttribute("allSessions"); String nickname = (String) session.getAttribute("_IM_NICKNAME_"); /* // 对昵称进行检查 String nickname = request.getParameter("nickname"); nickname = nickname.replace("\\", "\\\\").replace("'", "\\'"); // 2008.07.18 只有变化的时候才检查 if (!nickname.equals(oldNickname)) { Object[] sessions = allSessions.values().toArray(); for (Object s0 : sessions) { HttpSession s = (HttpSession) s0; if (s != session) { try { String name = (String) s.getAttribute("_IM_NICKNAME_"); if (nickname.equals(name)) { //out.println("{'result':'昵称已经存在,请修改!'}"); //return ; } } catch (IllegalStateException ex) { } } } session.setAttribute("_IM_NICKNAME_", nickname); } */ String content = request.getParameter("content"); String receivers = request.getParameter("receivers"); if ("_IM_".equals(receivers)) { out.println("{'result':'ok'}"); return; } String[] sessionIdList = receivers.split(","); String cur = "\t\t{\n" + "\t\t\t'sender':'" + sender + "',\n" + "\t\t\t'nickname':'" + nickname + "',\n" // 2008.07.23 + "\t\t\t'time':'" + ConversionUtil.toEmpty(now) + "',\n" + "\t\t\t'content':'" + content.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n").replace("\r", "") + "',\n" + "\t\t\t'receivers':["; int n = 0; for (String sid : sessionIdList) { HttpSession s = (HttpSession) allSessions.get(sid); if (s != null) { if (n != 0) cur += ","; cur += "'" + sid + "'"; ++n; } } cur += "]\n" + "\t\t}\n"; if (n == 0) { out.println("{'result':'接收者不在线!'}"); return; } for (String sid : sessionIdList) { HttpSession s = (HttpSession) allSessions.get(sid); if (s != null && s != session) { String im = (String) s.getAttribute("_IM_"); if (im == null) { im = cur; } else { im = im + "\t\t," + cur; } try { s.setAttribute("_IM_", im); } catch (IllegalStateException ex) { } } } // 对消息进行监控 String idmon = (String) application.getAttribute("_IM_MONITOR_"); if (idmon != null) { HttpSession s = (HttpSession) allSessions.get(idmon); if (s != null) { if (s != session) { // 自己发的消息不需要保存 try { String im = (String) s.getAttribute("_IM_"); if (im == null) { im = cur; } else { im = im + "\t\t," + cur; } s.setAttribute("_IM_", im); } catch (IllegalStateException ex) { } } } else { application.removeAttribute("_IM_MONITOR_"); } } out.println("{"); out.println("\t'result':'ok',success:true,"); out.println("\t'cur':" + cur + ","); out.println("\t'msgs':["); /* String im = (String) session.getAttribute("_IM_"); if (im != null) { out.println(im); session.setAttribute("_IM_", null); }*/ out.println("\t],"); out.println("\t'dummy':''"); out.println("}"); Map<String, Object> servers = (Map<String, Object>) application.getAttribute("_SESSIONSERVERLETLISTENSERS_"); Object o = servers.get("_UPDATECHATSERVLET_"); //必须必须同步,唤醒 等待接受消息的servlet线程实例 synchronized (o) { o.notifyAll(); } System.out.println("notified ******************************************s"); %>
4. chatWinLong.js
聊天引擎,只要访问一个长连jsp,返会处理后重新连接即可
Ext.onReady(function () { var chatWin = new Ext.Window({ width: 800, height: 500, title: 'Ext聊天窗口测试版', renderTo: document.body, border: false, hidden: true, layout: 'border', closeAction: 'hide', collapsible: true, constrain: true, iconCls: 'my-userCommentIcon', maximizable: true, items: [{ region: 'west', id: 'chat-west-panel', title: '用户面板', split: true, width: 170, minSize: 100, maxSize: 200, collapsible: true, constrain: true, //margins:'0 0 0 5', layout: 'accordion', layoutConfig: { animate: true }, items: [{ items: new Ext.tree.TreePanel({ id: 'im-tree', rootVisible: false, lines: false, border: false, dataUrl: 'chat/getUserFirst.jsp', singleExpand: true, selModel: new Ext.tree.MultiSelectionModel(), root: new Ext.tree.AsyncTreeNode({ text: 'Online', children: [{ text: 'Sunrise', id: 'SunriseIm', nodeType: 'async', singleClickExpand: true, expandable: true, expanded: true }] }) }), title: '在线人员', //layout:'form', border: false, autoScroll: true, iconCls: 'im_list', tools: [{ id: 'refresh', qtip: '刷新在线信息', // hidden:true, handler: function (event, toolEl, panel) { imRootNode.reload(); //reloadUser(); } }, { id: 'close', qtip: '清除选定', // hidden:true, handler: function (event, toolEl, panel) { Ext.getCmp('im-tree').getSelectionModel().clearSelections(); } }] }, { title: 'Settings', html: '<p>Some settings in here.</p>', border: false, iconCls: 'settings' }] }, { region: 'center', layout: 'border', items: [{ region: 'center', title: '历史记录 ', id: 'history_panel', autoScroll: true, iconCls: 'my-userCommentIcon', tools: [{ id: 'refresh', qtip: '注意:如果长时间没有收到对方回应,试一下', // hidden:true, handler: function (event, toolEl, panel) { // refresh logic } }] }, { region: 'south', title: '聊天啦', layout: 'fit', iconCls: 'user_edit', autoScroll: true, height: 200, collapsible: true, //margins:'0 0 0 0', items: { xtype: 'form', baseCls: 'x-plain', autoHeight: true, autoWidth: true, bodyStyle: 'padding:10 10px 0;', defaults: { anchor: '95%' }, items: [{ xtype: 'htmleditor', height: 130, id: 'htmleditor', hideLabel: true }] }, bbar: [{ text: '发送请输入Ctrl-Enter', handler: function () { sendmsg(); }, iconCls: 'my-sendingIcon' }, '-', { text: '清除', handler: function () { Ext.getCmp("htmleditor").reset(); } }] }] }] }); var tree = Ext.getCmp('im-tree'); var imRootNode = tree.getNodeById('SunriseIm'); var query = location.search.substring(1); //获取查询串 var sessionId = SESSION; //Ext.urlDecode(query).sid; // 发送消息 function sendmsg() { Ext.getCmp("htmleditor").syncValue(); var content_value = Ext.getCmp("htmleditor").getValue(); if (content_value.trim() == '') { alert("您没有输入消息文本内容!"); Ext.getCmp("htmleditor").focus(true); return; } var receivers_values = []; var tree = Ext.getCmp('im-tree'); var receivers = tree.getSelectionModel().getSelectedNodes(); for (var i = 0; i < receivers.length; ++i) { receivers_values.push(receivers[i].attributes.sessionId); } if (receivers_values.length == 0) { alert("您没有选择接收者!"); tree.focus(); return; } //alert(receivers_values.length); if (receivers_values.length > 1) { if (!confirm("您选择了多个接收者,是否继续?")) { return; } } var nickname_value = 'forget'; var pars = { "content": content_value, "receivers": "" + receivers_values, "sender": sessionId // "nickname":'forget' }; var conn = new Ext.data.Connection(); // 发送异步请求 conn.request({ // 请求地址 url: 'chat/sendmsgLong.jsp', method: 'post', params: pars, // 指定回调函数 callback: msgsent }); } function msgsent(options, success, response) { requestCount--; if (success) { try { var jsonObj = Ext.util.JSON.decode(response.responseText); } catch(e) {} if (jsonObj && jsonObj.success) { var cur = jsonObj.cur; var sessions = []; var c = imRootNode.childNodes; for (var i = 0; i < c.length; i++) { sessions[c[i].attributes.sessionId] = c[i].attributes; //alert(c[i].attributes.sessionId); } if (cur) { var a = []; for (var j = 0; j < cur.receivers.length; j++) { //alert(cur.receivers[j]); a.push(sessions[cur.receivers[j]].loginName); } var msg = '<div style="margin:20px 5px 10px 5px"> <img src="js/ext/user_comment.png"/> {0} <b>{1}</b> 对 <b>{2}</b> 说:<br> </div>'; var chat_record = new Ext.Element(document.createElement('div')); chat_record.addClass('chat_record'); chat_record.update('<span style="margin:0px 5px 0px 5px">' + cur.content + '</span>'); Ext.getCmp("history_panel").body.appendChild(chat_record); var canvas = new Ext.Element(document.createElement('canvas')); var size_chat = chat_record.getSize(); if (!Ext.isIE && size_chat.height < 100) { chat_record.setHeight(100); size_chat.height = 100; } canvas.setSize(size_chat.width - 30, size_chat.height); //canvas.setSize(size_chat.width-,40); chat_record.appendChild(canvas); if (window['G_vmlCanvasManager']) { G_vmlCanvasManager.initElement(canvas.dom); } draw_m(chat_record.dom.lastChild, '#FFB100'); var mc = String.format(msg, cur.time, sessions[cur.sender].loginName, a); Ext.getCmp("history_panel").body.insertHtml('beforeEnd', mc); Ext.getCmp("history_panel").body.scroll('b', 10000, { duration: 0.1 }); } Ext.getCmp("htmleditor").reset(); } else if (response.responseText.trim()) alert(response.responseText); } else { if (response.responseText.trim()) alert(response.responseText); } } //event for source editing mode new Ext.KeyMap(Ext.getCmp("htmleditor").getEl(), [{ key: 13, ctrl: true, stopEvent: true, fn: sendmsg }]); //event for normal mode Ext.getCmp("htmleditor").onEditorEvent = function (e) { this.updateToolbar(); var keyCode = (document.layers) ? keyStroke.which : e.keyCode; if (keyCode == 13 && e.ctrlKey) sendmsg(); //it'a my handler } var requestCount = 0; function getMsgs() { var conn = new Ext.data.Connection({ timeout: 24 * 3600 * 1000 }); // 发送异步请求 conn.request({ // 请求地址 url: 'chat/updateChatLong.jsp', method: 'post', // 指定回调函数 callback: getMsgsCallback }); } function getUsers() { var conn = new Ext.data.Connection({ timeout: 24 * 3600 * 1000 }); // 发送异步请求 conn.request({ // 请求地址 url: 'chat/getUserLong.jsp', method: 'post', // 指定回调函数 callback: getUserLongCallback }); } function getUserLongCallback(options, success, response) { if (success) { try { var jsonObj = Ext.util.JSON.decode(response.responseText); } catch(e) {} if (jsonObj) { //不是退出时notify if (jsonObj.nodes) { imRootNode.reload(); getUsers(); } } } else { if (response.responseText.trim()) alert(response.responseText); } } //回调函数 function getMsgsCallback(options, success, response) { if (success) { try { var jsonObj = Ext.util.JSON.decode(response.responseText); } catch(e) {} if (jsonObj) { var msgs = jsonObj.msgs; var msg = '<div style="margin:20px 5px 10px 5px"> <img src="js/ext/user_comment.png"/> {0} <b>{1}</b> 对 <b>{2}</b> 说:<br> </div>'; var sessions = []; var c = imRootNode.childNodes; for (var i = 0; i < c.length; i++) { sessions[c[i].attributes.sessionId] = c[i].attributes; } if (msgs) { for (var i = 0; i < msgs.length; i++) { var a = []; for (var j = 0; j < msgs[i].receivers.length; j++) { a.push(sessions[msgs[i].receivers[j]].loginName); } var chat_record = new Ext.Element(document.createElement('div')); chat_record.addClass('chat_record'); chat_record.update('<span style="margin:0px 5px 0px 5px">' + msgs[i].content + '</span>'); Ext.getCmp("history_panel").body.appendChild(chat_record); var canvas = new Ext.Element(document.createElement('canvas')); var size_chat = chat_record.getSize(); if (!Ext.isIE && size_chat.height < 100) { chat_record.setHeight(100); size_chat.height = 100; } canvas.setSize(size_chat.width - 10, size_chat.height); //canvas.setSize(size_chat.width-,40); chat_record.appendChild(canvas); if (window['G_vmlCanvasManager']) { G_vmlCanvasManager.initElement(canvas.dom); } draw_m(chat_record.dom.lastChild, '#FFB100'); var mc = String.format(msg, msgs[i].time, sessions[msgs[i].sender].loginName, a); Ext.getCmp("history_panel").body.insertHtml('beforeEnd', mc); Ext.getCmp("history_panel").body.scroll('b', 10000, { duration: 0.1 }); } if (!chatWin.isVisible()) { self.focus(); Ext.example.msg('叮当', '您有新的短消息 <a href="javascript:window.startChatWin()">查看</a>'); } getMsgs(); } } else if (response.responseText.trim()) alert(response.responseText); } else { if (response.responseText.trim()) alert(response.responseText); } } //chatWin.show(); //chatWin.setSize(0,0); //chatWin.hide(); if (!Ext.isIE) { chatWin.collapse(); } /* var chatTask = { run:reloadUser, //scope:this, interval: 5000 //1 second }; time_pro = new Ext.util.TaskRunner(); time_pro.start(chatTask); */ //长连接方式 getMsgs(); //长连接方式 getUsers(); //chatWin.hide(); window.startChatWin = function () { chatWin.show(); chatWin.center(); //Ext.getCmp('htmleditor').focus(); }; function draw_m(canvas, color) { var context = canvas.getContext("2d"); var width = canvas.width; var height2 = canvas.height - 4.5; var height = canvas.height; context.beginPath(); context.strokeStyle = color; context.moveTo(0.5, 0.5 + 5); context.arc(5.5, 5.5, 5, -Math.PI, -Math.PI / 2, false); context.lineTo(width - 0.5 - 5, 0.5); context.arc(width - 0.5 - 5, 5.5, 5, -Math.PI / 2, 0, false); context.lineTo(width - 0.5, height2 - 5); context.arc(width - 0.5 - 5, height2 - 5, 5, 0, Math.PI / 2, false); context.lineTo(width / 2 + 3, height2); context.lineTo(width / 2, height); context.lineTo(width / 2 - 3, height2); context.lineTo(0.5 + 5, height2); context.arc(0.5 + 5, height2 - 5, 5, Math.PI / 2, Math.PI, false); context.lineTo(0.5, 0.5 + 5); context.stroke(); } });
图中可以看到:updateChatlong.jsp 一直在 load 状态 ,因为服务器端 wait 了,在等待send.jsp notify,这样反映速度就很快了。
ps : pushlet简介
http://www.ibm.com/developerworks/cn/web/wa-lo-comet/
Extjs 聊天窗口 -续3 用pushlet来实现
评论
建议使用 pushlet : http://yiminghe.iteye.com/blog/300050
发表评论
-
Extjs 实践细节备忘 -3
2009-04-12 20:26 24301. dragdrop 继承层次 dd - ... -
Extjs 实践细节备忘 -2
2009-04-12 05:43 27511. grid 列宽问题 可以指定 每列的宽度数值 ... -
Extjs 实践细节备忘 -1
2009-04-11 01:47 2711在使用 extjs 开发 OAOP 应用中 ,除了API文档外 ... -
长字串换行问题
2009-03-01 23:10 5788很久没用过 ,textarea ... -
Extjs portal 应用初探
2009-02-24 23:09 6007近期在研究拖放的实现 ,看了看 Extjs 的 portal ... -
Extjs 模块化动态加载js实践
2009-01-09 18:12 23507前一段转载了一篇 透明加载外部 javascript ... -
Extjs 聊天窗口 -续3 用pushlet来实现
2008-12-23 22:16 16092前一篇 自己实现了http长连接 , 很繁琐,后 ... -
Extjs CRUD 模板
2008-12-12 00:52 7812也算是一个总结吧,备忘,总结了一个增删改查的例子,以后要达到的 ... -
Ext 聊天窗口的实现 - 续
2008-12-02 15:13 6701<filter> <fil ... -
Ext 树操作
2008-11-10 23:21 4503后台 树 节点 定义 menu_info { ... -
Ext 一个聊天窗口的设计
2008-11-10 00:26 37741.关键是要 弹性设计,自动适应浏览器 部件要: ... -
Ext.data.Store 与 GridFilters
2008-11-03 16:12 5366Store 每次 reload 会记着上次的参数,比如 pa ... -
Ext 实现 文件上传 进度显示
2008-10-24 18:15 5201利用了 ahxu-commons-fileuploadex-b ... -
Ext official doc - class-event-observer
2008-10-23 18:20 1439....ppt -
Ext 多文件上传面板扩展
2008-10-23 16:53 8380扩展了 Ext.Panel ,加入文件框列表 ,并控制删 ... -
Ext Grid 表头显示问题
2008-09-27 23:51 3343出现问题了,当 Grid div 放在 table 布局下的话 ... -
JSON marsh && unmarsh
2008-09-23 00:46 1988Ext 端用 //Encodes an Objec ... -
[extjs] formpanel 标准提交问题
2008-09-19 02:29 3337formpanel 的 标准提交 有 bug? var ta ... -
ComboBox 传值问题
2008-09-19 00:07 5299field -> textfield->trigg ... -
Ext 2 概述
2008-09-18 01:11 2299欢迎来到Ext 2.0。 在下列各章节中,你将会接触到E ...
相关推荐
而Long Polling则是一种模拟实时的解决方案,通过长时间保持HTTP连接,当有新消息时立即返回给客户端。 4. **事件监听**:ExtJS的事件模型非常强大,我们可以监听`Ext.Component`的各种事件,例如`click`、`keydown...
在聊天室的实现过程中,首先需要创建一个ExtJS的窗口或面板组件作为聊天界面。这个组件可能包含输入框(供用户输入消息)、发送按钮、以及一个滚动条支持的列表或网格(显示聊天记录)。使用ExtJS的FormPanel和...
4. **消息处理**:在客户端,ExtJS可以处理接收到的消息,将其展示在聊天窗口,并可能伴有声音提示等增强用户体验的功能。 5. **安全性**:在实际应用中,还需要考虑用户认证、消息加密等安全措施,以保护用户的...
2. **EXTJS前端**:通过EXTJS的组件库,构建用户界面,如聊天窗口、好友列表、消息输入框等,并通过WebSocket API与服务器保持实时通信。 3. **组织结构与工作组**:系统允许创建工作组并绑定组织结构,这可能涉及...
在这个聊天室实现中,ExtJS可能被用来创建用户界面,包括输入框、按钮和聊天窗口。 5. **WebSocket实现** 在服务器端,开发者可能会使用JSR 356提供的`javax.websocket`包来创建WebSocket端点,定义服务器如何处理...
在本项目中,EXTJS被用来构建聊天界面,如聊天窗口、用户列表、消息历史记录等。EXTJS的灵活性和可定制性使得聊天界面设计得既美观又实用。 3. **聊天功能实现** 聊天功能的实现通常包括实时消息传输、用户身份...
在实时聊天室中,ExtJS可以用来构建用户界面,如聊天窗口、消息列表、输入框等,同时提供数据绑定和组件管理功能,使得界面更新更加流畅和自然。 4. **FCKeditor**:FCKeditor(现已被CKEditor取代)是一个开源的富...
【描述】:这个聊天室应用的核心是通过Ajax实现长轮询技术,它能确保服务器与客户端之间保持持续的连接状态,当有新消息时,服务器会立即向客户端推送数据,而不是等待客户端发起新的请求。应用框架基于Spring MVC,...
在这个项目中,EXTJS被用来创建聊天窗口、联系人列表等界面元素,提供与原版QQ类似的用户体验。 【Struts】是Java EE中的一款MVC(Model-View-Controller)框架,用于组织和管理Web应用的架构。在JSP+EXT仿QQ聊天...
在这个项目中,ExtJS可能被用来创建聊天窗口、用户输入框、消息列表等UI元素,提供良好的用户体验。 5. **即时聊天(IM)**: 即时聊天系统允许用户实时地发送和接收消息。在这个项目中,WebSocket的特性使得聊天...