"ponytail"。如果单看这个名字,你可能想到的是发型。实际上在我们项目组里,它是内部一个前端折叠插件的代号,作用非常直白:把页面上一大团散着的内容,像扎马尾辫一样收成一束,想看的时候再松手展开,不想看的时候就安安静静待在角落。这个插件的使用方式、设计思路,以及我在接入过程中踩过的坑,值得单独写一篇记录下来。如果你也在做后台系统、内容型网站,或者正好在找一个"内容折叠"方案,这篇文章应该能帮你省下不少时间。
1. 为什么后台系统比内容网站更需要"扎起来"
1.1 起因:一个三千行日志的页面
事情要从一次后台日志页面的改版说起。当时的页面很简单:一个时间范围筛选器,下面直接铺一个<pre>,把服务端拉回来的日志往里面一塞。日志少的时候还好,一旦某个接口出了故障,一次返回几千行,整个页面直接卡到没法滚动。产品提的需求是:默认只显示日志的摘要和前 20 行,用户点一个"展开全部"才把完整内容显示出来。
我一开始想用原生<details>标签,几行代码就能做。结果一测试就发现两个问题:第一,展开和收起完全没有过渡动画,几万行内容瞬间出现又瞬间消失,视觉上非常生硬;第二,浏览器自带的三角图标和默认样式在不同内核里长得不一样,要统一样式得写不少覆盖代码。最麻烦的是,如果我想记住某个用户上次的展开状态,等下次进页面恢复,原生方案完全没有接口,只能自己再包一层逻辑。
1.2 ponytail 的定位与边界
ponytail 就是在这个背景下出现的。它不是一个手风琴组件库,也不是一套 UI 框架里的折叠面板,它只做一件事:让任意一块 DOM 内容能够平滑地展开、收起,并把状态管理这件事替你处理好。它当然也可以支撑手风琴模式,但那只是它能力的一部分。
用马尾辫来理解它的命名挺贴切:头发散着的时候占地方、不好打理,扎起来之后整洁、可控,想放下来随时可以放。ponytail 做的事情一模一样——它不会删除你的内容,也不会改变内容的结构层级,只是把内容"扎起来"。它不做任何视觉上的装饰,不给你画展开箭头,不给你配默认的背景色,这些全部由你自由发挥。这种克制是我后来觉得它最好用的地方,因为它不会跟项目里已有的设计规范打架。
1.3 什么情况下才真正需要它
不是所有折叠需求都要上插件。我后来总结了一套判断标准,符合任意两条就可以考虑用 ponytail 这类工具:
- 内容块的高度不确定,可能是 200px,也可能是 5000px,不能用固定高度去裁切
- 页面里存在多个可折叠区域,需要统一管理展开/收起状态
- 需要记住用户的折叠状态,刷新页面后恢复
- 展开和收起过程中需要触发其他行为,比如懒加载内容、埋点统计、联动滚动
- 需要在展开过程中播放过渡动画
反过来,如果只是一个静态的 FAQ 页面,几段常见问题,内容永远不变化,那原生<details>足够,不要再引入额外依赖。说白了,折叠本身不是难点,难点在于折叠状态和页面其他部分如何互动,这才是 ponytail 存在的意义。
2. 它的工作方式:三条数据约定加一条橡皮筋
2.1 三个角色,各管一段
ponytail 对 HTML 结构有一个固定但很宽松的约定,每个折叠单元由三个角色组成:
<div class="pt-group">npm install ponytail --save你在自己项目里完全可以按需放在utils目录下,或者把它当成一个普通的 ES Module 引入。初始化只要一行:
import ponytail from 'ponytail'; ponytail.init({ selector: '.pt-group', defaults: { duration: 240, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', }, });init会在 DOM 中查找所有匹配selector的折叠单元,并自动绑定触发事件。这里有一个小建议:selector尽量用类名而不是标签名,因为页面上可能出现嵌套结构,按标签选择容易误伤。
3.2 两种使用姿势
第一种是纯 HTML 姿势,适合服务端渲染出来的页面,你只需要写对 data 属性,JS 侧做一次全局初始化,所有折叠单元就都能用了。这种姿势的好处是后端同学也能轻松参与,不需要理解太多前端逻辑。
第二种是 JS 手动创建姿势,适合动态渲染的场景。比如前端框架里通过 v-for 循环出来的列表项:
const group = document.createElement('div'); group.setAttribute('data-pt-group', ''); const trigger = document.createElement('button'); trigger.setAttribute('data-pt-trigger', ''); trigger.textContent = '展开详情'; const content = document.createElement('div'); content.setAttribute('data-pt-content', ''); content.innerHTML = '动态加载的内容'; group.append(trigger, content); document.body.append(group); ponytail.create(group, { defaultOpen: false, onToggle: (state) => console.log(state), });ponytail.create的语义是"把这个节点注册成折叠单元",它和init的区别在于不需要等待选择器扫描,更适合在异步数据回来后立刻接管节点。
3.3 博客代码块"只看前几行"的完整例子
我自己的博客也在用 ponytail 处理一个经典需求:长代码块默认折叠,只显示前 12 行,点按钮展开全部。
HTML 结构可以这样组织:
<figure class="code-block">ponytail.create(orderGroup, { beforeOpen: async () => { if (!orderGroup.dataset.loaded) { const data = await fetchOrderDetail(orderId); renderDetail(content, data); orderGroup.dataset.loaded = 'true'; } }, });需要注意一个细节:beforeOpen是异步友好的,也就是说它返回一个 Promise 的时候,ponytail 会等它 resolve 之后才继续执行展开动画。这就避免了"内容还没渲染,动画先跑完了"的尴尬。
4.3 手风琴模式与状态记忆
手风琴模式是后台系统里高频出现的交互:多个面板,同一时间只能展开一个。比如设置页的"基本设置""安全设置""通知设置",一次只展示一类配置,避免页面太长。配置方式非常简单:
ponytail.init({ selector: '.settings-group', defaults: { accordion: true, }, });它会自动处理一个细节:当你在一个组里打开了第二个面板,之前展开的那个面板会先收起,等它收完,第二个才完全展开。这里如果不处理时序,两个面板同时做高度过渡,页面布局会出现短暂的双倍高度,滚动条会跳一下。
状态记忆我建议在需要"用户偏好"的地方再打开。比如文档站点的目录树,用户上次展开到二级目录的哪一节、哪一节是打开的,刷新之后恢复原样,体验会很好。但注意不要逢切换就写 localStorage。用户如果在一篇文章里反复展开收起十几次,每次都同步一次存储,纯属浪费。我的做法是在onToggle里做一个 500ms 的防抖,只保存最后一次状态。
5. 三个真实落地场景复盘
5.1 日志面板:折叠和虚拟列表如何共存
回到开头说的日志页面。当时我把页面分成了两半:上半部分是日志摘要信息,下半部分是完整日志。完整日志按行切分后,外层用 ponytail 折叠,内层用了虚拟滚动。这里有一个容易误解的点:折叠只是把整个滚动区域包起来,它不会替你优化渲染性能。几千行日志即使被折叠了,DOM 节点也还是存在的,只是看不见而已。
所以真正要优化的还是虚拟列表。我的做法是:折叠状态下,虚拟列表只渲染可视区域附近几十行;展开时触发一次重新计算,再渲染可视区域的数据。这两件事互不冲突,各干各的。最终效果是首屏渲染时间从原来的 800ms 左右降到了 200ms 以内,页面滚动也不卡了。
经验就是:折叠负责"内容体积的感知",虚拟列表负责"真实渲染的性能",别把两件事混成一个方案去解决。
5.2 长表单的分段收纳
第二个场景是售后工单系统的表单。原始表单有十几个字段,用户从顶部一路拖到底部,精神压力很大,填写完成率也不高。后来我把表单按逻辑分成三组:基础信息、问题描述、联系方式,每组放进一个 ponytail 折叠单元。只保留第一组默认展开,后面的默认收起。
这里最有价值的细节是"展开下一组时滚动到组顶"。在afterOpen回调里做:
ponytail.init({ selector: '.form-section', defaults: { duration: 300, }, afterOpen: (el) => { const top = el.getBoundingClientRect().top + window.pageYOffset - 80; window.scrollTo({ top, behavior: 'smooth' }); }, });减去的 80px 是给固定头部留的余量,不然滚动停靠的位置会被顶栏挡住一部分。这么一改,用户始终能感知到"当前该填哪一块",而不是对着一个超长表单迷失方向。这个场景的落地也让我意识到:折叠不只是为了省空间,更是一种交互节奏的设计工具。
5.3 移动端 FAQ 的触屏细节
第三个场景是移动端 FAQ 页面。FAQ 通常是一问一答的结构,答案默认收起,点击问题展开。移动端接入时有两个细节值得说。
一是触发元素一定要用<button>,不要用<div>加 click 事件。用<button>可以省掉移动端浏览器对非按钮元素点击的 300ms 延迟问题,而且对读屏软件更友好。如果你看到点击后反馈不够跟手,十有八九是把触发元素用错了。
二是内容区如果有关闭按钮,要小心事件穿透。用户在展开的内容里划动时,如果手指先落在关闭按钮上,很容易误触收起。我给关闭按钮加了touch-action: manipulation,并在按钮的 touchstart 事件里做了一个 10px 移动距离的判断,超过这个距离就认为用户是想滚动而不是点击。这个小调整近乎零成本,但移动端的误触率明显下降。
6. 踩坑排查实录:四个不算罕见的问题
ponytail 上线使用之后遇到过不少问题,我挑四个有代表性的记下来,每一个都经历了完整的排查过程。
6.1 字体加载导致的高度测量偏差
现象是:某个折叠区域展开时,动画先是很快跳到完整高度,然后又闪缩一下,接着再慢慢展开。初次看到这个表现一头雾水,高度明明是从scrollHeight读出来的,为什么会不准?
排查过程比较绕。我先在展开的时候打印了scrollHeight,发现它总是在上半区偏小,等字体资源加载完成后再测一遍,数值又变大了。原因找到了:页面用了自定义字体,在字体尚未加载完成时,浏览器用回退字体渲染文本,行高和实际字体不同,导致测量的高度偏小。等字体替换完成后,内容区实际高度变大,就出现了先展开后收缩的动画抖动。
解决方案分两层。第一层是在初始化前等待字体加载完成:
document.fonts.ready.then(() => ponytail.init(...));第二层是给内容区挂一个ResizeObserver,只要高度变化了就重新测量并更新缓存值。这样即使在页面运行过程中字体因为某种原因发生切换,折叠区的高度也不至于失真。
6.2 SSR 场景下的内容闪现
服务端渲染的页面遇到过一个很头疼的问题:明明设置了defaultOpen: false,首屏加载时折叠区域的内容还是闪了一下,然后才消失。问题出在初始化时机上:浏览器先渲染了完整的 HTML,JS 在之后才执行并把内容隐藏起来。这段时间窗口虽然不长,但用户眼睛能捕捉到。
解决思路是让内容在 HTML 输出阶段就直接处于隐藏状态。ponytail 约定:如果你的内容是默认收起的,服务端就应该输出hidden属性,或者先渲染一个带pt-collapsed类的容器。等 JS 初始化时再把这个状态接管过来。这样首屏就不会出现"先看到、再消失"的尴尬。
如果服务端输出的内容和 JS 默认状态不一致,还有一个更彻底的方案:在ponytail.init执行前,先扫描所有带>