
简介面向Web前端初中级开发者及需要快速集成客服功能的站点维护者这份JavaScript响应式悬浮在线客服插件解决了网站在不同设备上右侧在线客服入口自适应显示、始终可见的问题。插件基于原生JavaScript与CSS3媒体查询通过固定定位将客服图标贴合页面右侧并借助z-index保证层级窄屏下可相应调整布局以免遮挡主内容。资源压缩包共3个文件包含1个html主页面、1个js脚本kefu.js及1个png图片二维码整体仅12KB结构轻量清晰便于直接复用或二次改造。已有711人学习下载。通过源码可以完整观察scroll事件监听、fixed定位与z-index配合、媒体查询断点设置、点击展开交互等前端常见实现手法同时也可借鉴二维码接入与响应式适配思路适合作为实际项目中的客服组件参考。1. 一个新站要加客服自己写 JS 悬浮插件到底省在哪一个快上线的官网产品经理说「加个在线客服」。第一反应是接第三方 SaaS但一瞧文档一坨 SDK、一堆异步加载、服务端还得配回调域名就为了在右下角挂个按钮。实际上大量企业官网、内部系统、活动落地页需要的功能很窄——右侧一个悬浮入口点开能聊几句客服在后台能收到、能回复。这个需求用一个 js 响应式网站右侧悬浮在线客服插件完全能扛住整个前端部分压缩到一个小脚本加一个消息转发接口不依赖任何第三方 SDK也不会把整个站点的样式拖垮。适合的场景很明确前端团队自己可控、不想为了一个客服入口引入整套服务、需要在多套页面里快速嵌入。这篇文章把从选型到落地、再到常见的坑完整讲一遍后面我会把可运行的代码骨架拆开给你看。2. 先想清楚再动手客服插件的通信模型与三个选型决定2.1 访客端与客服端的数据通道轮询、WebSocket 还是第三方推送写插件之前先解决一个问题访客发出去的消息客服怎么收到客服回的消息访客怎么拿到我见过不少团队一上来就上 WebSocket理由就一个字「实时」。但要知道WebSocket 意味着服务端要维护长连接、心跳、断线重连还要处理代理层对 Upgrade 请求的超时限制运维成本直接上一个台阶。而客服场景的特殊性在于客服端永远是电脑前的一个固定页面访客消息进来后早几秒晚几秒提示影响没那么大。通信方式实时性服务端成本前端复杂度适合场景短轮询2~3 秒延迟低普通接口即可低访客端拉取客服回复长轮询秒级中需要挂起请求中客服端实时收新消息WebSocket毫秒级高维护连接与心跳高消息量大、多端同步我一般会把通道拆成两段访客端用 2 到 3 秒一次的短轮询去拉取新回复客服端因为要「坐在那等消息」用请求挂起的长轮询或者如果服务端已经有现成的消息推送通道直接对接。小站点最省事的方案是两段都用短轮询——反正客服端页面打开着1 秒轮询一次也吃不了多少资源。真正决定你能不能上 WebSocket 的不是「酷不酷」而是服务端有没有人维护连接状态。2.2 客服端是谁控制台页面、企业微信通知还是邮件回复客服端形态直接影响插件要不要做「会话列表」「历史记录」这些重功能。如果客服就是站长自己最简单靠谱的方案一个极简后台页面登录后能看到未读消息点开回复。这个页面不需要什么框架一个 HTML 加一段轮询脚本就能跑。访客端插件只需要负责展示对话不碰会话管理。另一种常见做法是把新消息通过群机器人 webhook 推到企微或钉钉群客服在 IM 里看到「有新留言」的提醒点链接跳转到后台回复。这个方案对前端零负担插件做好「消息成功送达服务端」就算完成使命。我这里不推荐邮件回复——邮件轮询拉取本身要做 IMAP 解析纯属给项目加复杂度除非你完全没有实时性要求。无论选哪种插件本身只依赖一个事实服务端有一个能收消息、能按会话 ID 拉消息的接口。通信协议里固定两个字段会话 ID 和消息内容其余都让后台自己消化。2.3 插件形态选型IIFE、UMD 还是 ES Module右侧悬浮客服插件是要「嵌进别人页面里」的东西最忌讳的是跟宿主页面的脚本互相踩脚。老的 jQuery 插件写法在 2024 年的新站里已经不受待见但完全抛弃老思路也不对。我的建议发布形态用 IIFE立即执行函数把整个插件包在一个闭包里只在 window 上暴露一个全局构造函数。这样不管宿主页面是用 Vue、React 还是纯 HTML一个script标签引入就能用不依赖构建工具也不需要处理 ESM 的跨域问题。提示如果你维护的是一个大型前端项目可以同时输出一份 ESM 版本给自家构建链路用。但对外嵌入场景IIFE 永远是最稳的保底方案。UI 部分不引入框架原因很简单——插件嵌入陌生页面时无法保证宿主环境里有对应框架运行时。原生 DOM 操作虽然啰嗦但它零依赖任何页面都能跑这才是嵌入类组件的底线。3. 实现一个最小的右侧悬浮客服插件DOM 骨架、消息协议与消息渲染3.1 插件的 HTML 骨架与样式隔离先给出一个能直接运行的骨架。这个插件不做任何构建直接放进 HTML 就能看效果(function () { // IIFE避免污染宿主页面全局变量 class LiveChat { constructor(options) { this.options Object.assign({ apiSend: /api/chat/send, apiPoll: /api/chat/poll, sessionId: , pollInterval: 3000, title: 在线客服 }, options); this.messages []; this.unread 0; this.isOpen false; this.init(); } init() { // 挂载 DOM 到 body 最外层避免被页面内的 transform 容器影响定位 this.root document.createElement(div); this.root.className kf-root; this.root.innerHTML div classkf-btn title在线客服 span classkf-btn-icon/span span classkf-badge styledisplay:none0/span /div div classkf-panel styledisplay:none div classkf-header${this.options.title}/div div classkf-messages/div div classkf-input-area input classkf-input placeholder请输入问题... / button classkf-send-btn发送/button /div /div; document.body.appendChild(this.root); this.bindEvents(); this.startPolling(); } } window.LiveChat LiveChat; })(); new LiveChat({ sessionId: demo-session });这段代码做了几件关键事根节点直接挂在document.body下避免被页面现有容器的overflow或transform属性影响按钮和面板都在插件自己的根节点里样式作用域天然隔离通过Object.assign合并默认配置调用方只需要传自己关心的字段。实际接入时sessionId应该由服务端在页面加载时下发用来标识一次会话。样式部分要注意一点所有类名加kf-前缀这是嵌入类组件的基本素养。如果你偷懒用.btn、.panel这种类名在宿主页面样式稍复杂的情况下几乎必然被覆盖。我会把固定定位、z-index这些关键样式全部写在.kf-root下而不是依赖外部样式表覆盖。3.2 事件绑定与消息发送防抖、空消息校验与渲染策略按钮点击切换面板发送按钮触发消息发送输入框支持回车发送——这三个交互是插件的全部交互面。代码看这一段bindEvents() { // 按钮切换面板开关 this.root.querySelector(.kf-btn).addEventListener(click, () { this.isOpen ? this.close() : this.open(); }); // 发送按钮带状态的发送处理 this.root.querySelector(.kf-send-btn).addEventListener(click, () { this.sendMessage(); }); // 回车发送同时阻止 Enter 键触发其他诡异行为 this.root.querySelector(.kf-input).addEventListener(keydown, (e) { if (e.key Enter) { e.preventDefault(); this.sendMessage(); } }); } sendMessage() { const input this.root.querySelector(.kf-input); const text input.value.trim(); // trim 去掉首尾空格 if (text ) return; // 空消息直接丢弃 const msg { role: user, content: text, ts: Date.now() }; this.appendMessage(msg); // 先渲染用户无感知延迟 // 发送到服务端失败时标记状态 fetch(this.options.apiSend, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ sessionId: this.options.sessionId, content: text }) }).catch(() { msg.failed true; // 发送失败标记重新渲染展示 this.appendMessage(msg); }); input.value ; }sendMessage里藏着几个实用细节trim之后用includes相关思路判断空串能拦截掉纯空格消息消息先渲染再发送用户不会觉得点击后有延迟发送失败时把消息重新渲染成失败态而不是直接丢进黑匣子。我见过不少插件把消息渲染放在异步回调里结果网络一慢用户连发好几条体验直接崩。先渲染、后补状态是聊天类组件的基本功。appendMessage负责渲染它内部维护一个messages数组渲染前按时间戳做一次升序排序保证服务端乱序返回时界面不出现「新消息跑到旧消息上面」的怪象。排序用Array.prototype.sort就够没必要引工具库。3.3 与服务端对接的最小消息协议与轮询实现访客端要能拿到客服的回复轮询实现如下async startPolling() { // 轮询循环短轮询拉取新消息 while (true) { try { const resp await fetch( ${this.options.apiPoll}?sessionId${this.options.sessionId}after${this.lastTs || 0} ); const data await resp.json(); // 只追加新消息避免整段历史重复渲染 if (data Array.isArray(data.messages)) { data.messages.forEach(msg { if (msg.ts (this.lastTs || 0)) { this.appendMessage(msg); } }); } this.lastTs data.lastTs || this.lastTs; } catch (e) { // 轮询失败不做重试风暴等下一轮 console.warn([LiveChat] poll failed:, e); } await new Promise(r setTimeout(r, this.options.pollInterval)); } }轮询的关键参数是after它带上一次收到消息的最新时间戳服务端只返回这个时间之后的增量数据。这样做有三个好处网络传输量小、渲染不重复、服务端查询也轻。轮询间隔pollInterval默认 3 秒如果你想缩短到 1 秒也可以但注意服务端接口的并发压力会线性增长。注意while (true)在 async 函数里不会阻塞主线程因为每次循环都await了一个定时器。但如果页面销毁时没有清理这个循环它会一直跑到标签页关闭。在单页应用里需要提供一个destroy()方法把轮询关掉。4. 真正的响应式处理宽屏、窄屏与 iframe 嵌入门道4.1 断点策略面板宽度、按钮位置与移动端收起逻辑响应式网站里的悬浮组件最大的敌人不是屏幕小而是「设计稿只画了桌面端」。插件默认在宽屏下是一个 360px 宽的右侧面板加上一个悬浮按钮但手机屏幕本身只有 375px 宽面板横过来就把整个页面盖住了。我一般会在 768px 作为断点小于等于 768px 时面板变成全屏宽、从底部滑入按钮缩小并降低视觉比重。这个切换用 CSS 的media可以完成大部分工作但 JS 侧还需要配合一个行为变化——窄屏下面板打开时按钮应该自动隐藏否则按钮和面板会在右下角叠在一起。这里不能用window.resize事件反复判断标准做法是用matchMedia注册监听const mql window.matchMedia((max-width: 768px)); this.isMobile mql.matches; mql.addEventListener(change, (e) { this.isMobile e.matches; // 窄屏打开面板时收起按钮避免遮挡 if (this.isMobile this.isOpen) { this.root.querySelector(.kf-btn).style.display none; } else { this.root.querySelector(.kf-btn).style.display ; } });matchMedia比resize的优势在于它只在断点真正跨越时才触发回调不会因为滚动条出现、窗口拖动这类中间态疯狂执行。切换断点时只做「按钮显隐」「面板宽度」两个动作不做重绘代价极小。4.2 移动端悬浮位置安全区、手势冲突与触摸延迟移动端悬浮按钮的位置不能简单写成「右下角 20px」。iPhone 的底部横条Home Indicator会让按钮视觉上「陷进」屏幕里需要加上env(safe-area-inset-bottom)适配.kf-btn { bottom: calc(20px env(safe-area-inset-bottom)); }这行代码在普通安卓机上env()解析为 0不影响常规布局在 iPhone 上会自动抬高按钮位置避开手势区域。另外移动端浏览器对快速点击有 300ms 延迟的历史包袱虽说过度但在一些低端安卓 WebView 里依然存在。给按钮加上touch-action: manipulation能规避大部分点击延迟问题。还有一类容易被忽略的场景页面内嵌在 iframe 里。这时悬浮按钮是「插在 iframe 内部」还是「插在父页面」如果 iframe 高度不定插件插在 iframe 内部会出现按钮跟着 iframe 内容一起被切掉的情况。我遇到过最典型的案例是——一个嵌了地图 iframe 的页面客服按钮加在地图模块内部结果用户滚动时按钮也跟着地图滚走了。正确的做法如果 iframe 区域是页面主体客服按钮应该挂到父页面的 body 上iframe 内只保留触发入口通过postMessage通知父页面打开面板。4.3 老页面的集成姿势脚本引入顺序与全局实例暴露嵌入到不归你管的页面时脚本的加载顺序直接决定「它到底能不能跑」。我习惯把插件脚本放在/body之前并且把所有初始化动作都放在 IIFE 内部、等 DOMContentLoaded 之后再挂载根节点。插件的构造器LiveChat暴露在window上宿主页面随时可以拿它做二次操作。script srclive-chat.js/script script // 宿主页面自己的初始化逻辑 window.__liveChatInstance new LiveChat({ sessionId: visitor-12345, title: 售前咨询 }); /script这里有一点值得注意window.__liveChatInstance这个全局引用不是摆设它是给宿主页面留的「后悔药」。页面里其他地方需要主动打开客服面板比如点击「联系我们」按钮后自动弹出客服窗口直接调用window.__liveChatInstance.open()就行。你永远不知道接入方会在哪个角落需要触发客服面板公开实例方法是嵌入类组件最实在的扩展口。5. 常见问题与避坑悬浮被盖、iframe 穿透、键盘顶飞5.1 现象按钮被页面内容盖住怎么点都没反应插件明明设置了很高的z-index但页面某些区域还是能盖住按钮。这个坑的根源不是z-index数值不够大而是按钮被放在了一个创建了层叠上下文的父容器里。比如页面某个区块的父元素设置了transform: translateZ(0)或opacity: 0.99这类属性它的子元素再怎么调z-index也只在那个局部上下文里排序。解决方法是结构性的插件根节点必须直接挂在document.body下而不是随便appendChild到某个.page-wrap容器里。同时给根节点的z-index设成足够大的值比如2147483000这是 32 位有符号整数的上限附近普通页面的元素不会追到这个高度。5.2 现象iframe 页面里客服按钮不跟着视口走宿主页面嵌了第三方 iframe客服按钮放在父页面里但滚动父页面时按钮纹丝不动这是正常的因为 fixed 定位真正诡异的是按钮偶尔会「消失」滚动到特定位置才出现。原因是 iframe 区域在渲染时创建了自己的层叠上下文覆盖在父页面内容上方而父页面的悬浮按钮层级拼不过它。解决思路是「让开」把客服按钮放到 iframe 元素的兄弟节点而不是父子节点同时把两个区域都设为同级定位上下文让按钮的z-index和 iframe 的z-index在同一层比较。如果 iframe 必须盖在按钮上面那就在父页面滚动至 iframe 区域时收起按钮离开时再显示——这个用IntersectionObserver监听 iframe 与视口的交叉状态即可。5.3 现象苹果手机上键盘一弹起来输入框被顶出屏幕这是position: fixed在 iOS Safari 上的经典 bug软键盘唤起时可视视口visualViewport缩小但fixed元素依然相对布局视口定位结果是输入框要么被键盘遮住要么整个面板顶部被顶出屏幕。目前最靠谱的解决方案是监听输入框的focus和blur事件在获得焦点时把面板内的消息区滚动到底部并让输入框保持在可视范围内同时在键盘弹起期间暂时把面板的定位从fixed切换成absolute借助页面滚动把面板顶到可视区。键盘收起后复位。这招不优雅但管用比依赖visualViewport的兼容性更省心。5.4 现象页面 resize 之后按钮跑到屏幕中间去了常见于按钮定位用了百分比比如right: 30%配合窗口宽度变化按钮位置跟着跳动。或者按钮容器用了transform: scale()做动画transform 会把元素挪到新的渲染层视觉位置偏移。我的习惯按钮和面板的定位一律用固定像素配合calc()不参与百分比定位。比如right: 24px而不是right: 2%。对于动画只在面板内部的元素上使用 transform按钮本身的定位属性永远保持直接可计算的状态。这样任何 resize 都不会导致位置漂移。5.5 现象控制台报错xxx is not a function插件白屏九成是脚本加载顺序问题插件脚本在head里执行时document.body还不存在appendChild直接报错。如果你不想改 HTML 结构就在初始化逻辑里加一个守护条件if (!document.body) { // 页面还没准备好等 DOMContentLoaded 再挂载 document.addEventListener(DOMContentLoaded, () this.init()); return; } this.init();这个分支在浏览器解析到head里的脚本时几乎必然命中但它保证插件在任何位置引入都不会崩。我见过不少人为了这个 bug 被迫把脚本挪到页面底部其实一个条件判断就解决了。记住一句话写嵌入组件默认页面环境不可控一切外部条件都要兜底。6. 进阶把插件做成可配置、可上报的工程化组件到这里插件已经能稳定跑起来接下来要考虑的是怎么让它能交付给不同项目复用。我把常用的定制项收敛成一个配置表这是从零散需求里抽象出来的配置项类型默认值说明titlestring在线客服面板头部标题placeholderstring请输入问题...输入框占位文案pollIntervalnumber3000拉取消息间隔单位毫秒autoWelcomestring自动欢迎语为空则不发送localestringzh-CN时间显示语言onUnreadChangefunctionnoop未读数变化时的回调可上报埋点配置之外给插件留一个appendMessage(msg)的公开方法等于开放了消息流入口。客服端的回复也好、系统通知也好、自动欢迎语也好都走这一个方法渲染。接入 WebSocket 服务时只多一段代码// 假设已有 ws 连接收到服务端推送 ws.onmessage (event) { const data JSON.parse(event.data); if (data.type chat_reply) { window.__liveChatInstance.appendMessage({ role: agent, content: data.content, ts: Date.now() }); } };插件的核心逻辑完全不用动这也正是把渲染和通信解耦带来的收益。验证插件是否健壮我一般按这个清单过一遍桌面宽屏刷新、缩放到 320px 宽度、iPhone Safari 唤起键盘、快速连点发送按钮、断网重连、清缓存后首次加载。每一关都过了我才敢把它扔给别人接。最后说一个我自己的习惯接这类插件时消息内容不要直接拼进innerHTML要经过textContent挂载杜绝 XSS 注入风险。遇到自称「纯前端客服插件不需要服务端」的方案我基本直接跳过——没有消息通道的客服插件和一个摆设按钮没有区别。这些经验是从多次翻车里攒出来的照着做能让你比同行少踩半坑希望帮到你。本文还有配套的精品资源点击获取