简介:这是一款基于JavaScript实现的响应式网站右侧悬浮在线客服插件,面向前端开发者与中小网站运营者,解决移动端与PC端客服入口常驻及自适应布局问题。压缩包共3个文件,包括一个主HTML页面、一个kefu.js脚本及一张二维码图片,整体仅12KB,结构精简便于直接部署或二次改造。目前已有712人学习下载。插件核心利用固定定位实现右侧悬浮,结合CSS媒体查询适配窄屏,并借助滚动监听保证滚动页面时始终可见;JavaScript脚本中封装了交互逻辑,可在此基础上扩展实时聊天、快捷回复等功能。通过阅读源码能够掌握响应式布局、DOM操作与前端交互组件的常见写法,适合希望快速给自己的网站添加在线客服能力、并想深入理解前端组件化思路的读者参考。
1. 右侧悬浮在线客服插件:为什么自己写 JS 版本更划算
“右侧悬浮在线客服”是个看起来很小、做起来却特别容易翻车的需求。很多站点不愿意为了一个聊天入口就去接入整套第三方客服系统,只是想要一个不挡内容、能适配手机端、又能在几分钟内上线的 JS 方案。难点不在按钮本身,而在响应式:桌面端可以放下一个侧边面板,手机端如果沿用相同布局,聊天区会被挤压到没法用。下面这套做法用原生 JS + CSS 实现,把布局、断点、状态和参数分开处理,既适合个人博客也适合公司官网,看过之后可以直接在项目里跑起来,并清楚参数怎么配、哪些位置最容易踩坑。
2. 右侧悬浮布局选型与响应式断点设计:fixed 和两套形态
2.1 布局方案对比:fixed 才是右侧悬浮的正确起点
在写任何代码之前,布局选型要先定死。右侧悬浮客服一共就三个候选:float、absolute、fixed。有人习惯用 float 把按钮推到右侧,但 float 元素仍然参与文档流,它会让后面的内容绕排,必须额外用 clear 处理,而且页面往下滚动时它不会跟着视口走。想要让它一直悬停,还得监听滚动事件去重算位置,这个成本完全没必要。
absolute 比 float 可控一些,但它相对最近的定位祖先定位。这意味着页面每个路由结构里都要有一个带 position: relative 的包裹层,漏一个页面,客服按钮就不知道跑到哪里去了。在多页面站点或者路由较多的单页站点里,这类遗漏很难在视觉上第一时间发现,经常是上线之后被用户截图投诉才知道。
fixed 直接相对视口定位,右侧悬浮的本质就是“无论滚动到哪里,都在同一个屏幕位置”,这是 fixed 天然解决的问题。配合 right、top、transform 可以控制它挂在右边、偏上还是偏下。常见实现会把插件节点直接放在 body 下,不和业务容器嵌套,这样定位基准最干净,后续动画也不容易被父级样式连累。
| 方案 | 是否脱离文档流 | 滚动时位置 | 实现成本 | 主要问题 |
|---|---|---|---|---|
| float | 否 | 需要监听滚动重算 | 高 | 绕排、漂移 |
| absolute | 是,相对最近定位祖先 | 随祖先位置移动 | 中等 | 依赖祖先定位 |
| fixed | 是,相对视口 | 始终固定 | 低 | 父级 transform 会破坏定位 |
表格里最后一行的坑会在避坑章节展开。做题选型时先记住一个结论:除了特殊情况,后续代码一律用 fixed,插件根节点直接放 body 下。
2.2 响应式断点:宽屏侧边栏、窄屏底部抽屉
布局选型解决之后,第二个决策是响应式怎么做。很多新手会把桌面端面板等比缩小,放到手机端继续当侧边栏用,结果聊天文字一行只能显示四五个字,输入框小到很难点。正确的思路是在断点处换形态,而不是换尺寸。
桌面端用侧边面板:面板固定在视口右侧,宽度 360px 左右,高度不低于 480px,适合放一段完整对话。移动端改成底部抽屉:面板铺满整个宽度,高度占视口的 60%~70%,从底部滑入。这样聊天区保持完整宽度,输入框也能做够 44px 左右的点击高度,拇指操作空间足够。
断点阈值一般取 768px 或 767.98px。用 767.98px 是为了避开临界值 768px 可能出现的双份样式冲突。切换逻辑放在 CSS 媒体查询里,JS 只维护“打开/关闭”状态,不要用 resize 监听去判断宽窄屏。原因很简单:CSS 能做的事不该让 JS 重复做,横竖屏切换、窗口拖拽、浏览器缩放,CSS 会自己收敛;监听 resize 还要做防抖,收益却很低。
移动端还有一个容易忽略的细节:输入框的 font-size 必须大于等于 16px。移动端浏览器在聚焦表单时,如果字号小于 16px,会自动放大页面来保证可读性,用户输完字页面突然放大,体验非常断裂。这个坑会在避坑章节详细展开,第三章代码里已经按 16px 处理。
2.3 交互状态管理:用 body 上的一个 class 驱动全部 UI
交互设计上,我倾向于“点击按钮展开面板”,而不是让聊天面板常驻。常驻面板会遮挡移动端本就紧张的内容区,关闭成本也高。插件只保留一个悬浮入口,点击后从右侧或底部滑出面板,再点关闭按钮收起。这个交互简单可靠,符合用户对客服入口的预期。
状态管理不需要引入任何状态库,原生 JS 方案里一个 body.chat-open class 就够了。打开时给 body 挂这个类,收起时移除,CSS 里所有动画和显隐都围绕它写。好处是面板、按钮、红点可以各自响应同一个状态,不会出现“面板开了但按钮没切状态”这种不同步问题。
数据流也尽量保持单向:点击按钮 → 改变 body 类 → CSS 渲染 → 若面板打开则清零未读。后续要扩展登录态或客服在线状态,只需要在数据层改,不要直接在多个节点的 class 上反复操作。这样做的代价是初看有点绕,但维护一个插件级组件时,它会帮你省掉大量“状态不同步”的排查时间。
3. 最小可运行版本:HTML/CSS/JS 三段代码直接跑起来
3.1 HTML 结构:tab、badge、panel 三层分离
一个完整的最小结构只需要四块:悬浮入口按钮、未读红点、面板头部、聊天消息区和发送表单。红点挂在按钮内部,面板和按钮作为兄弟节点放在同一个根节点下,根节点直接放 body 末尾,不要包进业务布局容器。
<!-- 把这段放到 body 末尾,script 之前;不要包进带 transform 的容器 --> <div id="csRoot"> <button class="cs-tab" id="csTab" type="button" aria-expanded="false"> <span>在线客服</span> <span class="cs-badge" id="csBadge" hidden>0</span> </button> <section class="cs-panel" id="csPanel" aria-hidden="true"> <header class="cs-head"> <strong>在线客服</strong> <button type="button" class="cs-close" id="csClose" aria-label="关闭">×</button> </header> <main class="cs-msgs" id="csMsgs"> <p class="cs-item cs-item--service">您好,请问有什么可以帮您?</p> </main> <form class="cs-form" id="csForm"> <input type="text" id="csInput" placeholder="请输入您的问题" autocomplete="off"> <button type="submit">发送</button> </form> </section> </div>按钮用 button 标签且带上 type="button",避免它意外触发表单提交。aria-expanded 和 aria-hidden 初始值跟面板收起状态保持一致,这是给读屏用户看的;hidden 属性控制红点显隐,默认隐藏。聊天记录用 main 标签承载,语义上比 div 更清晰。
3.2 CSS:桌面侧边栏与移动端抽屉的切换逻辑
CSS 分成两层:全局样式和媒体查询。全局样式负责桌面形态,同时把收起状态通过 transform 平移出视口;媒体查询里覆盖定位、宽度、高度和圆角,实现底部抽屉。整个切换完全依赖 media query,不依赖 JS 判断宽窄屏。
/* 插件根节点直接挂在 body 下,避免定位被业务容器影响 */ :root { --cs-primary: #3b82f6; --cs-radius: 10px; --cs-panel-w: 360px; --cs-z: 99999; } .cs-tab { position: fixed; right: 20px; bottom: 120px; z-index: var(--cs-z); display: flex; align-items: center; gap: 6px; padding: 10px 16px; border: none; border-radius: 999px; background: var(--cs-primary); color: #fff; font-size: 14px; cursor: pointer; box-shadow: 0 6px 20px rgba(59, 130, 246, 0.35); touch-action: manipulation; user-select: none; } .cs-badge { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: #ef4444; color: #fff; font-size: 12px; line-height: 18px; text-align: center; } .cs-badge[hidden] { display: none; } .cs-panel { position: fixed; top: 50%; right: 20px; z-index: calc(var(--cs-z) + 1); display: flex; flex-direction: column; width: var(--cs-panel-w); max-width: calc(100vw - 40px); height: 480px; max-height: calc(100vh - 40px); transform: translateY(-50%) translateX(105%); border-radius: var(--cs-radius); background: #fff; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); transition: transform 0.28s ease; } body.chat-open .cs-panel { transform: translateY(-50%) translateX(0); } .cs-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #eee; font-weight: 600; } .cs-close { border: none; background: none; font-size: 18px; cursor: pointer; padding: 4px; } .cs-msgs { flex: 1; overflow-y: auto; padding: 14px 16px; overscroll-behavior: contain; background: #f8fafc; } .cs-item { margin: 0 0 10px; padding: 8px 12px; border-radius: 8px; max-width: 85%; word-break: break-word; } .cs-item--service { background: #fff; border: 1px solid #e5e7eb; align-self: flex-start; } .cs-item--visitor { background: var(--cs-primary); color: #fff; margin-left: auto; } .cs-form { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #eee; } .cs-form input { flex: 1; min-width: 0; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; } .cs-form button { padding: 10px 16px; border: none; border-radius: 8px; background: var(--cs-primary); color: #fff; font-size: 14px; cursor: pointer; } /* 移动端:底部抽屉形态,面板打开时盖住右侧按钮 */ @media (max-width: 767.98px) { .cs-tab { right: 16px; bottom: max(24px, env(safe-area-inset-bottom, 0px)); } .cs-panel { top: auto; left: 0; right: 0; bottom: 0; width: 100%; max-width: none; max-height: none; height: 65vh; transform: translateY(102%); border-radius: 14px 14px 0 0; } body.chat-open .cs-panel { transform: translateY(0); } .cs-form { padding-bottom: max(12px, env(safe-area-inset-bottom, 0px)); } }核心点有两个:transform 同时承担垂直居中和隐藏动画,收起时 105% 平移到视口外,展开时回到原位,不依赖 display 切换,动画才顺畅;移动端重置 top、left、right、bottom 后,底部抽屉的自然高度优先,radius 只保留顶部圆角。env(safe-area-inset-bottom) 适配全面屏底部横条区域。overscroll-behavior: contain 则防止消息列表滚动到底之后,把滚动动作传给后面的页面,这个在后面避坑章节会再提。
3.3 JS:开关、发送、模拟回复与未读红点
JS 用 IIFE 包裹,不污染全局。事件监听集中在 tab、close、form 上,状态通过 body.chat-open 管理,消息渲染统一走 appendMsg。未读逻辑是:面板打开时清零,面板关闭时如果模拟服务端回复到达,就累加并显示红点。
(function () { 'use strict'; var tab = document.getElementById('csTab'); var panel = document.getElementById('csPanel'); var close = document.getElementById('csClose'); var badge = document.getElementById('csBadge'); var msgs = document.getElementById('csMsgs'); var form = document.getElementById('csForm'); var input = document.getElementById('csInput'); var unread = 0; function isOpen() { return document.body.classList.contains('chat-open'); } function renderBadge() { badge.hidden = unread <= 0; badge.textContent = unread > 99 ? '99+' : String(unread); } function setOpen(open) { document.body.classList.toggle('chat-open', open); tab.setAttribute('aria-expanded', String(open)); panel.setAttribute('aria-hidden', String(!open)); if (open) { unread = 0; renderBadge(); } } function appendMsg(content, who) { var item = document.createElement('p'); item.className = 'cs-item cs-item--' + who; item.textContent = content; msgs.appendChild(item); msgs.scrollTop = msgs.scrollHeight; } function fakeReply() { setTimeout(function () { appendMsg('收到,正在为您转接人工,请稍候。', 'service'); // 面板收起时,这条消息记为未读 if (!isOpen()) { unread += 1; renderBadge(); } }, 1200); } tab.addEventListener('click', function (e) { e.stopPropagation(); setOpen(!isOpen()); }); close.addEventListener('click', function (e) { e.stopPropagation(); setOpen(false); }); form.addEventListener('submit', function (e) { e.preventDefault(); var text = input.value.trim(); if (!text) return; appendMsg(text, 'visitor'); input.value = ''; // 预留对接点:真实项目里把 text 交给后端接口, // 收到应答后再调用 appendMsg() 渲染服务端消息。 if (window.__csSend) { window.__csSend(text); } else { fakeReply(); } }); })();appendMsg 用 createElement 而不是 innerHTML,目的是用户输入的文本不会被当作 HTML 解析,这是基本的安全习惯。1200ms 的延迟只是模拟网络耗时,接入真实后端后应移除。window.__csSend 是预留注入点,不注入时自动走 fakeReply,方便本地直接打开 HTML 文件测试。
4. 参数化改造:从写死 Demo 变成能接后端的可配置插件
4.1 init 配置项与主题参数表
最小版本能跑,但它所有文案和颜色都是写死的。改造第一步是把配置抽成 createOnlineChat(cfg),让外部可以传主题色、标题、回调函数。主题参数用 CSS 变量下发,而不是在 JS 里逐条修改节点样式,这样切换皮肤时不需要重新编译,作用域也更干净。
function createOnlineChat(cfg) { var options = Object.assign({ title: '在线客服', badgeMax: 99, primary: '#3b82f6', radius: '10px', onSend: null, onOpen: null, onClose: null }, cfg); // 主题通过 CSS 变量下发,插件内部不写死颜色 var root = document.documentElement; root.style.setProperty('--cs-primary', options.primary); root.style.setProperty('--cs-radius', options.radius); var panelTitle = document.getElementById('csPanelTitle'); if (panelTitle) { panelTitle.textContent = options.title; } function setOpen(open) { document.body.classList.toggle('chat-open', open); // 既有逻辑略…… if (open && typeof options.onOpen === 'function') { options.onOpen(); } if (!open && typeof options.onClose === 'function') { options.onClose(); } } // 返回句柄,外部可以调用 open() / close() return { open: function () { setOpen(true); }, close: function () { setOpen(false); } }; } // 初始化示例 createOnlineChat({ primary: '#4a5df0', title: '咨询客服' });| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| primary | string | '#3b82f6' | 主题色,影响按钮、访客消息背景 |
| radius | string | '10px' | 面板圆角大小 |
| title | string | '在线客服' | 面板头部标题 |
| badgeMax | number | 99 | 未读数上限,超过显示 99+ |
| onSend | function | null | 用户发送消息时调用 |
| onOpen | function | null | 面板打开时触发,适合埋点 |
| onClose | function | null | 面板收起时触发,适合埋点 |
返回值里预留 open 和 close 方法,页面上其他入口按钮也可以控制客服面板,比如文章页底部“有问题点这里”的锚点就能复用同一个组件。
4.2 消息结构统一:visitor/service/system 三类消息
最小版本里 appendMsg 只接收文本和角色,聊天记录没有历史数组,刷新页面就丢了。真实场景需要消息结构统一,便于渲染、排序、加载历史以及以后对接服务端推送。
var messages = []; function pushMessage(msg) { messages.push(msg); appendMsg(msg.content, msg.from); } // 消息格式统一 var sampleMessage = { id: 'svc_001', from: 'service', // visitor | service | system content: '你好,请问有什么可以帮您?', time: Date.now() };用数组管理历史记录后,渲染和发送分离:服务端返回历史时,按数组顺序循环 pushMessage 即可;新增消息时也只做一次 push。三类消息里,visitor 是用户发的,service 是客服回复,system 适合“客服已离线”“正在转接”这类提示,样式上可以单独做成居中灰字。
4.3 离线兜底、打开/关闭埋点
onSend 参数是接入后端的关键点。它接收用户输入,返回一个 Promise,成功后渲染服务端回复,失败则显示离线兜底文案。这个设计把演示模式和真实模式统一了:没传 onSend 时走本地 fakeReply,传了就走真实通道。
// 在提交处理的最后一段 var task = options.onSend && options.onSend(text); if (task && typeof task.catch === 'function') { task .then(function (replyText) { if (replyText) { pushMessage({ from: 'service', content: replyText }); } }) .catch(function () { pushMessage({ from: 'system', content: options.offlineTip || '客服暂时离线,您的留言会转交处理。' }); }); } else { // 没有配置 onSend,说明还在本地演示阶段 fakeReply(); }onOpen 和 onClose 埋点则放在 setOpen 里,每次面板打开或收起都会触发。埋点数据用于统计客服入口的真实点击率,是后面进阶优化的基础。这里建议离线文案也做成配置项,不同站点对离线的说法差异很大,写死会让业务方来回找你改代码。
另外,客服在线状态不要靠前端猜。前端能判断的空闲或忙碌意义有限,准确状态应该由后端推送;插件只接收一个状态值,离线时把按钮文本从“在线客服”换成“留言”,消息区提示“客服离线”。这样逻辑单一,状态来源清晰。
5. 避坑指南:响应式在线客服最常见的翻车现场
5.1 移动端软键盘把 fixed 面板顶飞
现象:在移动端聚焦输入框后,底部抽屉被键盘顶到屏幕中间,聊天区被压缩,甚至输入框被键盘完全遮住,点不到发送按钮。
原因:移动端输入法弹出时,可视视口高度发生变化,浏览器为了保证聚焦元素可见,会重新计算 fixed 元素的位置。底部抽屉在这种场景下经常表现得像“漂”起来一样。
解决:监听 visualViewport 的 resize 事件,键盘弹出时让面板高度跟随可见视口。
var viewport = window.visualViewport; if (viewport) { viewport.addEventListener('resize', function () { var panel = document.getElementById('csPanel'); panel.style.height = Math.min(0.65 * viewport.height, viewport.height - 12) + 'px'; }); }visualViewport 在移动端浏览器上支持度已经很稳定,桌面浏览器调用时无副作用。还有一个省事思路是把输入框固定到面板底部用 absolute 模拟 fixed,但成本高、兼容性差,不建议上项目。我是直接用 visualViewport 方案,代码量少,效果直接。
5.2 transform 和 filter 让 fixed 变成 absolute
现象:客服按钮在大部分页面正常,某个页面里却跑到了页面底部,甚至完全消失,按钮定位跟抽风一样。
原因:fixed 的包含块默认是视口,但祖先元素只要设置了 transform、filter、perspective、contain: paint 这些属性,包含块就会变成那个祖先元素,fixed 表现退化成 absolute。很多页面根节点为了做动画或模糊效果,挂一个 filter(0) 之类属性,就把悬浮按钮坑了。
解决:插件根节点永远挂到 body 下,不要嵌进带 transform 的动画容器。如果插件在工程化项目里被组件树包住了,启动时动态创建一个独立根节点再挂载。
(function bootstrap() { if (document.getElementById('cs-root')) return; var host = document.createElement('div'); host.id = 'cs-root'; document.body.appendChild(host); // 然后执行 createOnlineChat 初始化,把节点挂到 host 下 })();这段代码看起来只是创建了一个 div,实际价值是让插件 DOM 脱离业务组件树,避免被任意祖先样式影响。注意执行时机要早,要在任何可能使用 transform 的动画初始化之前完成。
5.3 输入框字号过小、双击缩放与滚动穿透
这条里藏了三个相邻的坑,经常一起出现。
第一个是输入框聚焦后整页放大。现象是用户点输入框,页面瞬间放大,失焦后又缩回。原因是移动端浏览器对小于 16px 的输入框会自动放大以保证可读性。解决就是把输入框 font-size 设为 16px 或更大,第三章 CSS 里已经处理。
第二个是用户快速点按钮触发双击缩放。现象是面板打开瞬间页面缩放了一下,布局错位。原因是移动端双击缩放是默认行为。解决是在悬浮按钮和发送按钮上加上 touch-action: manipulation,这个属性会明确告诉浏览器别做双击缩放延迟。
第三个是滚动穿透。现象是消息列表滚到底后,继续上滑,整个背景页面跟着滚。原因是子元素滚动到边界后,浏览器把滚动事件传递给了父级。解决是给消息列表加 overscroll-behavior: contain,这个 CSS 属性专门治理这种边界滚动传递。
这三个坑都不需要复杂 JS 处理,但少了任何一个,移动端体验都会明显打折。
5.4 在组件化框架工程里 DOM 被接管
现象:插件在纯 HTML 页面一切正常,迁移到工程化项目后,某个路由切换后客服按钮消失,或出现两个客服按钮。
原因:组件化框架管理的是渲染结果,业务组件重渲染时会按照框架规则重建或复用节点。把自管理的 DOM 子树直接写进业务组件模板里,框架无法感知它的内部状态,一旦父组件更新,插件节点就可能被清理或重复挂载。
解决:插件内部节点完全自管,不要交给框架的模板渲染。页面启动时创建独立根节点挂到 body 下,框架只通过回调转发消息和状态。实践中我一般会在入口文件最早的位置执行 bootstrap,再初始化对话组件,这样不管路由怎么切,客服按钮都稳定存在。
6. 把插件变成转化工具:几个立竿见影的进阶用法
6.1 未读红点持久化:刷新页面不要丢消息
当前红点存在内存里,刷新即清零。真实场景里用户可能隔几分钟再回来,红点丢失会漏掉有效咨询。常见做法是把最后阅读时间或未读数存进 localStorage,每次页面恢复时读取。
function restoreUnread() { var saved = localStorage.getItem('csUnread'); unread = saved ? Number(saved) : 0; renderBadge(); } function saveUnread() { localStorage.setItem('csUnread', String(unread)); } // 在 fakeReply 触发未读累计后调用 saveUnread()注意,未读数只能作为本地提示,不能替代服务端的消息未读状态。多端登录场景下,本地估算很容易和真实未读不一致,后端数据才是最终依据。
6.2 上线前必测三种尺寸
响应式布局最容易在真机上翻车,浏览器开发者工具拖出来的宽度模拟和真实渲染还是有差别。我习惯在发布前固定走一遍三个尺寸:窄屏全面屏手机看底部安全区、普通平板宽度看断点切换是否有闪跳、宽屏桌面看面板收起时是否残留阴影。
测试路径也建议固定成一条操作链:打开面板 → 输入一条消息 → 弹出键盘 → 发送 → 收起面板 → 刷新页面。这条路径覆盖了软键盘、红点持久化、fixed 偏移这几个高危点。我吃过一次亏,就是漏了键盘弹起后的面板偏移,线上被用户截图反馈,后来才把这条链路写进自己的检查清单。
实体框架和第三方客服系统的对比其实更多是取舍问题:自己用 JS 写,数据在自家接口里,文案和样式随站点主题走,不依赖外部脚本加载速度。只要需要的是一个轻量入口,这套方案的性价比明显高于引入完整客服系统。希望帮到你。
本文还有配套的精品资源,点击获取