简介:这是一款基于JavaScript的响应式网站右侧悬浮在线客服插件,适合前端初学者或需要快速为网站接入客服入口的开发者参考使用。压缩包体积精简,仅12KB,共3个文件:一个HTML主页面、一个JavaScript脚本和一张PNG图标,分别对应页面结构、交互逻辑与视觉素材,便于直接套用或二次修改。目前已有711人学习下载。插件借助固定定位与z-index实现客服模块在页面右侧常驻,并通过JS监听滚动与屏幕变化,配合CSS3媒体查询让图标在手机端自动调整位置或样式,避免遮挡主体内容。同时示例还提供了聊天窗口的常见交互思路,可帮助读者快速理解在线客服组件的前端实现要点,是学习响应式布局与侧边悬浮组件的不错范例。
1. 动手前先给需求画条边界线,客服插件不是越大越好
做官网改版的时候,运营提了个需求:页面右下角放一个在线客服入口,桌面端点开是聊天窗,手机端收起成小圆点,不能挡内容,还得让人一眼知道可以咨询。这段话翻译成技术语言,就是“基于 JS 的响应式右侧悬浮在线客服插件”。听起来不复杂,但它常驻在每一个页面里,任何一个小问题都会被放大,所以我对这类需求从来不急着写代码。
1.1 先把功能拆成“必须有”和“可以砍”
去技术社区搜“客服插件”,能找到很多功能堆得满满的库:坐席列表、工单、知识库、数据分析……这些功能适合产品型客服系统,但不一定适合企业官网。我习惯先把真实使用场景列出来,再决定哪些功能要做:
- 悬浮触发器:固定入口,展示在线、忙碌、离线三种状态;
- 展开面板:点开后显示聊天气泡区或者留言表单;
- 未读消息角标:数字气泡,超过 99 显示 99+;
- 会话承接:转人工、留言、常见问题列表;
- 多端适配:桌面端右侧悬浮,移动端收成圆形按钮或底部抽屉。
对大多数官网项目,真正硬性的是前三个。后两个可以先做简化版:留言表单直接调一个现成接口,常见问题写死在配置里,让运营自己改标题和答案。功能边界划清楚以后,代码量至少少一半,上线后也不会出现一堆没人维护的死功能。
1.2 自研还是接第三方 SDK:先回答一个真问题
“在线客服”这四个字很容易让团队想复杂。如果你的核心业务是实时聊天、消息漫游、历史记录、多坐席排班,我开篇就先劝一句:别从零自研,直接接成熟的第三方客服 SDK,或者把第三方客服页面嵌进 iframe。一套即时通信的底子包含消息可靠投递、离线推送、消息时序、多端同步,这些不是前端一个悬浮层能背得动的。前端弹窗做得再好看,消息发不出去,业务方照样找你。
反过来,如果只是想“让访客知道有人在,能留电话,能发起回拨”,那 3KB 的自研插件远比几百 KB 的 SDK 划算,首屏更快,也不会被第三方平台绑定样式。我一般用这张表做判断:
| 方案 | 适合什么场景 | 付出的代价 |
|---|---|---|
| 第三方客服 SDK | 完整即时通信、工单、多渠道客服台 | 体积大、UI 定制受限、数据在第三方平台 |
| iframe 嵌第三方客服页 | 没有开发人力,先用现成系统顶住 | 体验割裂、通信和样式难以深度定制 |
| 自研轻量插件 | 入口、状态展示、留言、电话回拨 | 实时聊天要自己接底层通道,工作量大 |
我自己有一条硬性分界线:如果业务要的是“访客和客服在聊天框里你来我往”,就接平台;如果只是“给访客一个发起联系的口子”,就自研。这篇文章后续的实现路径,以自研轻量插件为主线。
2. 悬浮结构、定位策略与响应式断点,第一步做扎实能省一半事
2.1 最小可用 DOM 与 fixed 定位的隐藏规则
悬浮窗的 DOM 不复杂,但层级关系必须干净。我最常用的做法是把根容器挂在 body 下面,不放进页面某个子容器,避免父级的 transform、overflow、filter 这些属性干扰 fixed 定位。一份最精简的结构差不多是这样:
<div class="cs-widget" id="csWidget">.cs-widget { position: fixed; right: 16px; bottom: 24px; z-index: 9999; } .cs-panel { position: absolute; right: 0; bottom: calc(100% + 12px); width: 360px; max-height: 70vh; transform-origin: bottom right; transition: transform 0.25s ease, opacity 0.25s ease; }一个隐蔽的坑值得单独说:如果任意一层祖先设置了 transform、filter 或 will-change,fixed 的参照物就会从视口“退化”成那个祖先,按钮可能跑到页面中间,甚至跟着页面一起滚动。遇到这种“灵异现象”,先检查祖先链,而不是盯着 z-index 死磕。
2.2 响应式的核心不是“缩小”,而是换一套交互
桌面端右侧悬浮面板展开后,是一个带箭头的小窗,用户目光很自然就聚焦过去了。但到了手机端,同样的小窗展开后会非常局促,触控体验也差。所以移动端应该换一种思路:触发器收成圆形小按钮,点击后从底部滑出一个抽屉式面板或全屏会话页,而不是把桌面窗口等比缩小。
实际开发里我用 768px 作为断点,同时用 matchMedia 在 JS 里同步切换状态,而不是只在 resize 回调里去猜。CSS 侧大致这样:
@media (max-width: 767px) { .cs-widget { right: 12px; bottom: calc(12px + env(safe-area-inset-bottom)); } .cs-trigger { width: 52px; height: 52px; border-radius: 50%; } .cs-trigger-text { display: none; } .cs-panel { position: fixed; left: 0; right: 0; bottom: 0; width: 100%; max-height: calc(100vh - 20px); transform: translateY(100%); } .cs-widget[data-state="expanded"] .cs-panel { transform: translateY(0); } }桌面端面板展开在按钮上方,移动端是底部全宽抽屉,两种布局不该共用同一套定位和展开逻辑。我建议用>panel.addEventListener('transitionend', () => { if (state === 'expanded' && !iframeEl.getAttribute('src')) { iframeEl.setAttribute('src', config.iframeUrl); } });
用户真的点开按钮,再去加载 iframe,首屏体验和整体加载速度都会好很多。不要过度依赖 loading="lazy",隐藏容器里的 iframe 懒加载行为并不统一,动态赋值反而更可控。
3. 状态机、事件委托与异步并发:让插件经得住真实点击
3.1 用状态机约束交互,不要在回调里堆判断
很多客服插件的 Bug 都出在“连点”上:按钮快速点两下,面板展开动画还没走完又开始收起,动画层级全乱。解决方法不是加一堆 if,而是先定状态机。我常用的状态只有五个:collapsed、expanding、expanded、collapsing、connecting。规则很简单:
| 当前状态 | 允许的动作 | 进入状态 |
|---|---|---|
| collapsed | 点击触发器 | expanding |
| expanding | 等待动画结束 | expanded |
| expanded | 点击触发器 / 按 Escape | collapsing |
| expanded | 输入消息并提交 | connecting |
| connecting | 等待接口返回 | expanded |
状态之间用>window.addEventListener('message', (e) => { if (!allowedOrigins.includes(e.origin)) return; const { type } = e.data || {}; if (type === 'CS_SUBMIT_SUCCESS') { refreshBadge(); // 需要整体刷新时再 window.location.reload() } });
刷新父页面算高危操作,必须有明确的消息类型白名单,不能任何消息都触发刷新。
3.3 Promise.all 并发请求与数据清洗
客服状态、未读数、客服列表三个接口彼此独立,用 Promise.all 并发跑,比串行快不少,体验提升是实打实的:
const [status, unread, agents] = await Promise.all([ fetch('/api/consult/status').then(r => r.json()), fetch('/api/consult/unread').then(r => r.json()), fetch('/api/consult/agents').then(r => r.json()) ]);接口数据到手后先做清洗再渲染。比如用 includes 判断字符串时,先 trim 再去比较大小写;显示满意度评分时要保留两位小数,用 toFixed(2) 就好。这里提醒一个老坑:浮点运算不是“看起来的数学”,0.1 + 0.2 不等于 0.3。凡是涉及金额或精确分数累计,优先把小数转成整数,按“分”来算,最后展示时再除以 100。客服面板里这些看似边角的问题,被用户截图反馈出来时就很尴尬。
4. 集成期最容易翻车的三个地方:堆叠上下文、iframe 和移动端键盘
4.1 z-index 不是万能钥匙,堆叠上下文才是
“不就在最上面吗?把 z-index 拉满。”这是很多新人写这种悬浮组件的本能反应。但 z-index 只在同一个层叠上下文内比较有效。一旦祖先里有人通过 transform、opacity、will-change 或 filter 创建了新的层叠上下文,你的 z-index 99999 也只能在那个祖先的小世界里排第一,整体上还是会被更大的层盖住。
排查这类问题最有效的办法不是改数字,而是打开 DevTools 看层叠上下文树,一层一层找到底是哪个祖先劫持了比较逻辑。有些团队习惯给悬浮根容器单独创建一个独立上下文,比如设置 isolation: isolate,这能在一定程度上隔离外部干扰,但它解决不了所有问题。真实的线上排查还是得靠上下文树说话。
4.2 iframe 遮挡、跨域通信和父页面刷新
页面里嵌了地图、评论模块或第三方小工具时,很容易出现一个奇怪问题:按钮明明 z-index 很高,但鼠标点上去没反应。原因往往是 iframe 把按钮盖住了,而且某些内核版本里 iframe 天然就是最高层,根本不参与 z-index 对比。
处理顺序建议这样:先调整页面结构,别让 iframe 和悬浮按钮重叠;实在无法避免,再考虑在 iframe 外层加一层透明的拦截遮罩,或者给按钮容器单独提一个更高的层叠上下文。如果内容本来就是客服面板的一部分,那就把 iframe 留在面板内部,用 postMessage 单向通信,不要让它和悬浮按钮在同一个屏幕区域抢层级。前面提过的父页面刷新,也必须限定在消息白名单里,防止任意来源的 iframe 触发高危操作。
4.3 移动端键盘、滚动穿透和安全区
移动端的问题集中在三个点。第一个是键盘弹起:iOS 上输入框聚焦后,fixed 元素可能被顶起来偏移,或者输入框被键盘挡住一半。现在比较可靠的做法是监听 visualViewport 的 resize 事件,拿到真实可视高度后动态调整面板位置,注意用 requestAnimationFrame 或节流包裹回调,避免频繁触发。
第二个是滚动穿透。面板打开时手指上下滑,背后页面也跟着滚,非常影响体验。简单加 body overflow:hidden 在部分 iOS WebView 里无效,我习惯用滚动量锁定的方式:
let scrollTop = 0; function lockScroll() { scrollTop = window.scrollY; document.body.style.position = 'fixed'; document.body.style.top = `-${scrollTop}px`; document.body.style.left = '0'; document.body.style.right = '0'; } function unlockScroll() { document.body.style.position = ''; document.body.style.top = ''; window.scrollTo(0, scrollTop); }第三个是安全区。iPhone 底部有 Home 指示条,如果悬浮按钮固定 bottom 24px,会把按钮压住一部分。给底部留出 env(safe-area-inset-bottom) 距离很关键,尤其当移动端按钮收成圆形后,原始位置更靠近右下角,这个距离就更重要了。这些细节在笔记本上完全看不出问题,但移动端用户很容易感知到“这个网站是不是没测过手机”。
5. 后端通信、消息安全与上线前清单
5.1 轮询起步,WebSocket 增强,关键时刻能降级
未读数、客服上下线状态这类数据,实时性要求没那么极致,直接轮询最稳。简单用 setInterval 调 fetch,3 分钟一次,同时在页面从隐藏切换到可见时主动刷新一次。这样既能保证角标基本实时,又不会给服务器造成太大压力。
等业务真需要秒级消息,再换 WebSocket。上 WebSocket 别只写一个 new WebSocket 就完事,至少要处理三件事:心跳、断线重连、失败降级。心跳建议 15 到 30 秒 ping 一次;重连要做指数退避,第一次等 1 秒,第二次等 2 秒、4 秒……最多 30 秒;连续失败几次后自动切回轮询,让功能先保持可用。很多时候,稳定降级比花哨的推送更能保住业务口碑。
5.2 消息渲染、XSS 和链接校验
不管是访客留言还是客服回复,进入前端的消息默认都不可信。渲染用户内容优先用 textContent 而不是 innerHTML,必须做富文本时,先按白名单过滤标签,再去掉所有事件属性。链接地址也要校验,绝不能让用户输入 javascript: 伪协议:
function safeLink(url) { try { const u = new URL(url, window.location.origin); return ['http:', 'https:'].includes(u.protocol) ? url : '#'; } catch (e) { return '#'; } }消息长度、发送频率也要限制,防止有人把留言接口当刷子使。未读角标用 reduce 统计总数,超过 99 显示 99+,逻辑虽然简单,但它每天曝光量极大,做错了用户一眼就能看到。
5.3 性能、可访问性与最终配置
最后是上线前检查。性能上,首屏不加载聊天面板的全部逻辑,等用户首次点击触发器时用动态 import() 加载模块,或者用 requestIdleCallback 错峰拉取配置。面板收起时,用 visibility: hidden 可以保留状态和布局,display: none 则会销毁内部状态,如果面板里有 iframe,每次展开都会重载,选型时要想清楚。
可访问性方面:触发器加 role="button"、aria-expanded、aria-controls;面板打开后焦点收进去,按 Escape 关闭,关闭后焦点退回触发器。在线状态图标的配色也要检查对比度,不能为了好看让用户分不清是不是在营业。
把下面这条清单放进团队上线模板,每次做相似需求都能少走弯路:
- 断点切换后状态是否正确,动画是否有跳帧;
- 移动端键盘弹起后输入框是否可见;
- iframe 跨域消息是否校验了 origin;
- 接口异常时是否有兜底提示,而不是留白屏;
- 角标只在有未读数时显示;
- 安全区是否已用 env 处理。
我自己的一个习惯是,上线后前三天一定打开控制台看请求错误和点击埋点。用户的使用方式往往和设想的不一样,有人会把悬浮按钮当“返回顶部”用,有人会在报错前几秒狂点按钮。这些真实行为比任何静态测试都能说明问题,也更能帮你判断下一个迭代该优化什么。
本文还有配套的精品资源,点击获取