轻量级折叠组件 ponytail:告别长文本截断与展开难题
1. 为什么会有 ponytail它到底在解决什么问题1.1 拿“展开全文”当需求的人都经历过什么先说一个场景网站首页要放十几条资讯卡片每条卡片展示 200 字的摘要超出部分折叠。需求听起来很简单但真做起来十次有九次会翻车。用line-clamp做截断文本确实能变成三行省略号但用户点不到“展开”按钮想看完整内容只能跳详情页体验很不连贯。改用 JS 判断scrollHeight再手动加按钮又要处理字体加载完成后高度变化、图片加载后高度变化、不同浏览器默认行高差异这几件事测完总能被设计同事挑出“按钮位置忽上忽下”的毛病。更麻烦的是列表页重复渲染。同一个折叠组件在循环里出现几十次如果每个实例都去计算高度、监听 window resize性能就会变得很难看。我们之前的做法是给每个卡片塞一段setTimeout轮询高度代码臭问题还多用户往下滑得快一点新卡片的内容还没渲染完高度测量就已经结束了折行位置明显不对。后来我在内部工具链里把这种交互统一成一个叫 ponytail 的轻量插件思路很简单与其想尽办法截断文本不如把超出部分“扎”成一束马尾需要时拉出来看一眼不需要时它自动收回去。就像女生扎马尾辫头发不用剪短用一根皮筋在底部收住就好散开和扎起都是同一把头发。长内容也是同样道理不该被粗暴地裁掉而是应该有一个可以随时收起和展开的“尾巴”。1.2 ponytail 的设计理念把长内容“扎”起来而不是“切”掉传统的折叠组件核心逻辑是“裁切”设一个最大高度超出就隐藏再叠加一个渐变遮罩最后在遮罩上放一个按钮。这种方案最大的问题是遮罩和按钮之间的间距很尴尬按钮一旦点击展开遮罩消失内容跳动明显。ponytail 改用了“收束”的思路。它不会硬性地把内容裁到某个固定高度而是计算出当前内容的真实高度后把底部一段区域设计成“马尾区”。这段区域保留少量内容露出后面跟着渐隐效果用户一眼就能看出“后面还有内容”同时还能看到一小段正文而不是只看到一个光秃秃的按钮。这样做的最大优势是把“判断该不该折叠”这件事交给插件本身而不是让开发者手动写 CSS 或调整高度数值。插件最核心的约束只有两个一个maxHeight阈值决定内容超过多高才需要收起一个tailHeight决定马尾区露出的视觉高度。其他样式全部通过 CSS 变量对外暴露接入方可以自由控制渐隐强度、马尾条的颜色和高度不必改写插件源码。这也是我在项目里反复迭代后得出的结论折叠组件的业务样式差异太大插件只需要提供稳定的测高能力和统一的状态管理把样式留给大家自己定。2. 快速上手五步完成安装与初始化2.1 选装包npm、CDN 还是源码引入如果你已经用了打包工具我建议直接用 npm 安装体积很小压缩后大约 3KB没有依赖不引入 jQuery 之类的运行时这对现网性能很友好。npm install ponytail如果不方便走构建流程比如做活动页或后端模板项目可以直接用 CDN 引入插件会在全局注册一个Ponytail构造函数。script srchttps://unpkg.com/ponytail/dist/ponytail.umd.js/script script // 全局拿到 Ponytail const tail new Ponytail(.js-fold); /script还有一个小细节源码目录里同时保留 ESM 和 UMD 两个版本。ESM 给 Vite、Webpack 这类现代构建工具用能触发 tree-shakingUMD 留给 CDN 和其他场景。不要图省事一律在 React 项目里引 UMD虽然能用但会把全局环境搞得很乱也不利于按需加载。2.2 最小可用 demo假设我们有一张卡片里面是一段很长的商品描述需要让前两行可见多余内容折叠起来。DOM 结构非常简单插件只需要一个容器节点。div classgoods-card h3无线降噪耳机/h3 div classjs-fold desc 这是一段非常长的商品介绍包含续航、音质、降噪深度、充电方式、佩戴舒适度等大量用户关心的信息。 实际业务中这里的文字可能来自后台编辑器长度不可控有时候只有一行有时候能超过二十行。 所以我们希望插件自动判断内容短就不折叠内容长就自动加上马尾区。 /div /div然后只需要一行初始化代码import Ponytail from ponytail; const foldBox new Ponytail(.js-fold, { maxHeight: 80, tailHeight: 24, });如果你希望某个卡片强制不折叠也不用单独写逻辑只要在容器上加>class Ponytail { constructor(el, options) { this.el el; this.options options; this.refresh(); } refresh() { requestAnimationFrame(() { const actualHeight this.el.scrollHeight; if (actualHeight this.options.maxHeight) { this.el.classList.add(pt-collapsed); } else { this.el.classList.remove(pt-collapsed); } }); } }关键点是scrollHeight与clientHeight的区别。scrollHeight是元素内容的总高度包括没有显示出来的部分clientHeight是元素可见区域的高度。折叠前clientHeight等于内容全部展示的高度也就是说两者相等折叠后clientHeight会被 CSS 限制成maxHeight比scrollHeight小。所以插件必须把初始化的时机放在内容渲染完成后最好等到图片加载完成、字体加载完成后否则测出来的高度是错的。素材如果你遇到“明明内容很长但不折叠”的情况十有八九就是初始化时机太早别忘了这一步。3. 核心配置说明真正影响观感的几个参数3.1 参数速查表我习惯在项目文档里放一张参数表后面不管是谁接手先看表再去翻源码工作效率高不少。参数默认值说明maxHeight160内容超过该高度才启用折叠单位 px设为 0 可以强制不折叠tailHeight32马尾区露出高度单位 px数值越大底部露出的正文越多defaultStatecollapsed初始状态展开或收起duration200展开收起的动画时长单位 mseasingease-in-out动画缓动函数需要兼容老浏览器时避免使用 cubic-bezier 花哨参数expandText展开展开按钮的文案collapseText收起收起按钮的文案这里面最容易被忽略的是maxHeight和tailHeight的关系。很多人会理解成“内容超过 maxHeight 就截断到 maxHeight”其实插件真正的做法是“内容超过 maxHeight 就进入折叠状态但视觉上通过 tailHeight 控制底部渐隐区域的高度”。举个例子maxHeight: 100, tailHeight: 24折叠后用户停留在可见区域的内容就不止 100px而是 100px 加上尾巴露出的 24px。这两个参数需要配合调不要一个设得很大一个设得很小否则会出现“内容看不到但尾巴占了大半个卡片”的怪效果。3.2 文案和动画细节展开和收起的按钮文案默认值叫“展开”“收起”实际项目里建议根据场景修改。商品评价列表可以用“查看全部 36 条评价”资讯摘要可以用“继续阅读”文档中心可以用“展开完整说明”。好的按钮文案能让用户在没有看到完整内容的情况下提前知道点击后能得到什么。设置方法很简单在初始化参数里传入即可。new Ponytail(.js-fold, { maxHeight: 96, expandText: 查看全文, collapseText: 收起全文, });动画时长建议不要太长200ms 左右最合适。曾经有设计师同事把它改成 600ms页面看起来就有点“粘手”用户连续快速点击时动画播到一半又得折返视觉上会闪一下。如果业务方坚持要长动画建议在回调里做防抖或者把duration设置成 0彻底禁用动画。3.3 事件回调与业务埋点折叠按钮的点击在很多业务场景里是重要的行为埋点。尤其运营想看“有多少用户点开了全文有多少人看完了指数没点开”这两个数据是内容调整的重要依据。ponytail 提供了一组事件钩子onBeforeExpand展开前触发返回false可以中断展开onAfterExpand展开后触发适合埋点onBeforeCollapse收起前触发onAfterCollapse收起后触发埋点示例new Ponytail(.js-fold, { onAfterExpand: (el) { // 上报到数据平台 trackEvent(fold_expand, { contentId: el.dataset.id, }); }, onAfterCollapse: (el) { trackEvent(fold_collapse, { contentId: el.dataset.id, }); }, });有一个细节我觉得很值得分享展开后用户如果没再点“收起”下次滚动到别处再回来状态应该保持原样还是自动收起从数据埋点角度保持原样能让用户安心阅读但会占更多屏幕。插件里提供defaultState选项传入expanded即可让组件默认展开传入collapsed默认收起。在实际业务中运营想突出某条内容时就用expanded状态可以省去用户一步操作。4. 真实项目中的整合笔记4.1 资讯卡片场景图片加载导致高度突跳第一次上线后我收到一个反馈首页前三条资讯是折叠的但第四条——刚好带了一张压题图的资讯——打开页面时整个内容是展开的过了大概 0.5 秒才突然折叠界面跳了一下。排查后发现问题出在图片高度上。初始化时scrollHeight被计算成纯文本高度图片还没加载完成图片区域高度是 0。等几毫秒后图片网络返回图片有了实际渲染高度整个卡片高度增加但插件已经按照加载前的高度判断为“未超过阈值”所以没有折叠。解决办法有两种。第一种初始化前主动预加载图片比较麻烦。第二种在refresh()之前先给容器里的图片设置loadinglazy以外的固定占位宽高比这种方式对 CSS 无损推荐优先使用。.goods-card .cover { aspect-ratio: 16 / 9; background: #f0f0f0; }如果某些图片来自第三方 CDN材质不定宽高比不统一那就在refresh()时直接把容器里的img全部监听一遍load事件图片加载完后再重新测高。const imgs container.querySelectorAll(img); let loaded 0; if (imgs.length 0) { doRefresh(); } else { imgs.forEach((img) { if (img.complete) { loaded 1; } else { img.addEventListener(load, () { loaded 1; if (loaded imgs.length) doRefresh(); }); } }); }4.2 列表多实例场景性能与重复创建一个资讯流页面往往有 20 到 30 张卡片如果每张都new Ponytail()初始化性能还好真正的问题是页面滚动时元素被虚拟滚动回收、DOM 被重新创建这些实例还残留着内存泄漏风险很大。我习惯的路径是列表渲染完成后批量取到所有.js-fold节点集中初始化并为每个实例绑定一个唯一 id便于后续销毁。const instances []; const nodes document.querySelectorAll(.js-fold); nodes.forEach((el) { const ins new Ponytail(el, { maxHeight: 80, tailHeight: 24, }); instances.push(ins); }); // 页面卸载或列表重置时 instances.forEach((ins) ins.destroy());有经验的读者会问为什么不直接使用事件委托只维护一个全局的折叠实例我试过这个方案能减少实例数量但展开状态也要全局统一管理列表项一多状态判断反而变复杂。目前看每个实例轻量地管理自己的状态是更容易理解和维护的方式。真正要注意的是“集中销毁”避免重复渲染与残留监听。4.3 Vue/React 中的接入与销毁在前端框架里最优雅的接入方式不是手动执行new Ponytail()而是封装成指令或组件让它随着框架生命周期自动初始化和销毁。以 Vue 2 为例可以用自定义指令实现Vue.directive(pony, { // 插入节点时初始化 inserted(el, binding) { el.__ponytail__ new Ponytail(el, { maxHeight: binding.value?.maxHeight || 96, tailHeight: 32, expandText: 查看全文, collapseText: 收起全文, }); }, // 节点销毁前释放实例 unbind(el) { if (el.__ponytail__) { el.__ponytail__.destroy(); delete el.__ponytail__; } }, });使用时直接写div v-pony{ maxHeight: 80 }页面组件销毁时也不用再手动处理折叠实例。React 16 可以用useEffect来封装重点仍然是 cleanup。useEffect(() { const ins new Ponytail(ref.current, options); return () ins.destroy(); }, []);这里有个很朴素的道理插件提供了功能但如果你不管理生命周期再好的功能也撑不到上线后的第六个月。5. 常见问题与排查技巧实录5.1 折叠不生效或所有卡片全部选入折叠态遇到“折叠不生效”先打开控制台看容器上有没有pt-collapsed这个类。如果完全没有说明初始化就失败了要么是选择器写错了要么是脚本执行太早DOM 还没渲染完成。把初始化放到DOMContentLoaded或者requestAnimationFrame里问题通常能解决。如果所有卡片全部进入折叠态则多半是maxHeight设得太小导致一行文字也超过阈值。这类问题还有一个隐性原因项目里写死了body { font-size: 20px }导致同样 80px 的阈值在默认 14px 字号下能显示 5 行文字在 20px 字号下只能显示 3 行。建议不要只看设计稿里的像素值要在真实手机、真实浏览器字号下检查一遍。5.2 马尾区与 fixed 悬浮元素层级冲突长页面通常会有底部悬浮的操作栏或者左下角的客服气泡它们的定位层级一般很高比如客服气泡z-index: 9999。此时马尾区的渐隐层如果也用了position: fixed或position: absolute很容易出现覆盖在按钮上面导致点击事件被截获。排查方法是点击文本或出现消失的问题——马尾渐隐层挡住了按钮。解决办法是。给马尾区内部的按钮设一个更高的z-index。.pt-tail-btn { position: relative; z-index: 1; }不要给整个马尾区设置过大的z-index那会导致“马尾”遮住页面里其他悬浮组件。一般来说局部层级高于同类兄弟节点即可没必要参与全局层级的争夺。5.3 窗口 resize 后折叠状态错乱桌面浏览器窗口宽度变了某段文字从一行变两行或从两行变一行原来不需要折叠的内容可能现在需要折叠反过来也一样。插件内部有 resize 监听但默认是 200ms 防抖。如果你还同时在使用锚点定位做页面滚动监听window resize 时两者的时序可能打架就会看到页面闪一下。我自己的处理习惯是在resize结束后再手动调用一次refresh()并且只在页面宽度跨过断点时执行避免频繁触发。let lastWidth window.innerWidth; window.addEventListener(resize, () { const current window.innerWidth; if (Math.abs(current - lastWidth) 120) { instances.forEach((ins) ins.refresh()); lastWidth current; } });还有一种比较坑的情况移动端浏览器地址栏隐藏和显示也会触发 resize但窗口宽度完全没变。这个场景下用宽度判断就能过滤掉大部分无效刷新。5.4 无障碍与 SEO 侧的建议内容折叠对无障碍并不友好展开按钮如果只是个div屏幕阅读器用户没法感知它是一个可交互控件。插件默认会生成一个按钮标签但你仍然需要在容器上补充aria-expanded和aria-controls让辅助技术能获知折叠状态。插件在使用时会为你注入这两个属性不过请关注自定义 DOM 结构时不要剥掉它们。SEO 方面如果你的站点主要依赖搜索引擎收录页面内容要特别谨慎折叠状态下的正文仍然在 DOM 里一般搜索引擎能爬取到但这不是绝对的。部分搜索引擎会以“首屏可见内容”为主折叠区域的内容可能被判断为次要内容影响关键词权重。稳妥的做法是重要的 SEO 内容不要依赖折叠组件展示把它放到详情页或者单独的全文容器中ponytail 只承担“提升页面视觉整洁度”的职责而不是藏内容的工具。症状可能原因解决方式高度始终不变初始化发生在 DOM 渲染前延后到 DOMContentLoaded 或图片加载后所有卡片文字都被隐藏maxHeight 设置过小调大阈值检查全局字号覆盖按钮点击无反应渐隐层遮住按钮调整层级或pointer-events: none页面滚动时折叠状态闪烁虚拟滚动重建 DOM及时 destroy 旧实例重新初始化展开状态刷新后丢失页面路由切换组件未卸载在组件销毁钩子里调用 destroy6. 把 ponytail 沉淀成团队的 “skill”6.1 为什么一个插件还需要“技能化”这些年我的一个体会是单纯的插件文档不一定能让人真正用起来。团队里来了新同事他打开 README看到 API 列表可能还是不知道怎么处理自家业务里的那些复杂场景。所以我在团队内做了“技能化”沉淀也就是把使用经验和配置模板变成一个可以反复调用、可以快速复制的能力包。这个“skill”不是一个高级概念本质上就是三样东西一份经过验证的配置模板、一份代码片段库、一条验收清单。团队里任何一个前端用起来不需要从头摸索直接照着技能包做十分钟就能上线一个合格的个人资料页面、资讯列表、评论折叠区。6.2 我的封装套路配置模板 组件 验收清单第一步把 ponytail 的公共配置抽到一个独立文件避免每个页面都复制一份不同参数。// ponytail.config.js export const ponytailPreset { maxHeight: 96, tailHeight: 24, duration: 200, expandText: 展开, collapseText: 收起, };第二步在 VS Code 里配置用户代码片段让组员输入ptail四个字符就能快速生成规范的折叠容器结构。ponytail fold container: { prefix: ptail, body: [ div class\fold\>