手风琴组件的那些坑我替你踩过了Accordion实现全攻略

❤瑞静 组件 阅读 2,389
赞 13 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

最近做的那个知识库项目,里面有很多FAQ页面和产品说明文档,内容比较长但是结构相对固定。一开始想用传统的折叠面板,但客户觉得太单调了,想要那种能够平滑展开收缩的效果。调研了一下,最后选择了手风琴组件,主要是因为它支持多个面板同时展开、动画效果自然,而且用户的交互体验确实比普通折叠更好。

手风琴组件的那些坑我替你踩过了Accordion实现全攻略

开始没想到这个看似简单的手风琴组件会有这么多坑,以为就是几个div加上CSS transition搞定的事,结果真正深入开发才发现事情没那么简单。

基本结构搭建

手风琴的基础结构其实挺简单的,就是头部标题和内容区域两部分。我在实际项目中用了React + TypeScript的组合:

// AccordionItem.tsx
import React, { useState, useRef, useEffect } from 'react';

interface AccordionItemProps {
  title: string;
  children: React.ReactNode;
  defaultOpen?: boolean;
}

const AccordionItem: React.FC<AccordionItemProps> = ({ 
  title, 
  children, 
  defaultOpen = false 
}) => {
  const [isOpen, setIsOpen] = useState(defaultOpen);
  const contentRef = useRef<HTMLDivElement>(null);
  const [contentHeight, setContentHeight] = useState('0px');

  useEffect(() => {
    if (contentRef.current) {
      setContentHeight(isOpen ? ${contentRef.current.scrollHeight}px : '0px');
    }
  }, [isOpen]);

  return (
    <div className="border-b border-gray-200">
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="w-full py-4 px-6 text-left font-medium hover:bg-gray-50 flex justify-between items-center"
      >
        <span>{title}</span>
        <svg 
          className={w-5 h-5 transform transition-transform ${isOpen ? &#039;rotate-180&#039; : &#039;&#039;}} 
          fill="none" 
          stroke="currentColor" 
          viewBox="0 0 24 24"
        >
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
        </svg>
      </button>
      
      <div
        ref={contentRef}
        style={{ height: contentHeight }}
        className="overflow-hidden transition-all duration-300 ease-in-out"
      >
        <div className="py-4 px-6 bg-white">
          {children}
        </div>
      </div>
    </div>
  );
};

export default AccordionItem;

最大的坑:动态高度计算

这个手风琴组件的核心难点就在于动态高度计算。最开始我想当然地直接设置height为auto,结果发现transition动画根本不起作用——CSS的transition不能对auto值进行动画处理。这个问题折腾了我一个下午,各种搜索才明白需要用具体的数值。

后来尝试了多种方案,最后采用了scrollHeight来获取内容的实际高度。这里要注意的是,当面板关闭时,需要将height设为0,但这样会导致内容区域被完全隐藏,用户看不到滚动条信息。所以我在项目中加了个判断:

useEffect(() => {
  if (contentRef.current) {
    const element = contentRef.current;
    // 获取真实内容高度
    const scrollHeight = element.scrollHeight;
    
    // 面板打开时设置为真实高度,关闭时设置为0
    setContentHeight(isOpen ? ${scrollHeight}px : '0px');
  }
}, [isOpen]);

不过这里有个小问题,就是如果内容在展开过程中发生变化(比如异步加载数据),高度可能会不准确。项目中碰到过一次这种情况,用户在面板展开后点击了一个加载按钮,新内容导致高度计算错误。最后的解决方案是在内容更新后重新计算一次高度:

const updateHeight = () => {
  if (contentRef.current) {
    const newHeight = isOpen ? ${contentRef.current.scrollHeight}px : '0px';
    setContentHeight(newHeight);
  }
};

// 在内容变化的地方调用
useEffect(() => {
  // 内容更新后重新计算高度
  setTimeout(updateHeight, 0);
}, [dynamicContent]);

性能优化和边缘情况

项目中遇到的最大性能问题就是在移动端设备上,频繁切换面板时会有卡顿现象。特别是当内容比较复杂,包含大量DOM元素或者图片的时候。一开始我以为是渲染问题,后来发现主要是CSS动画造成的。

经过测试发现,在iPhone SE这种屏幕较小的设备上,复杂的transition动画确实会影响性能。所以我给动画时间做了适配:

.accordion-content {
  overflow: hidden;
  transition: height 0.3s ease-in-out;
}

/* 移动端优化 */
@media (max-width: 768px) {
  .accordion-content {
    transition: height 0.2s ease-in-out;
  }
}

还有个坑就是键盘无障碍访问的支持。项目中期做无障碍检测时发现了问题,键盘用户无法正常操作这些面板。加了keydown事件监听后解决了这个问题:

const handleKeyDown = (e: React.KeyboardEvent) => {
  if (e.key === 'Enter' || e.key === ' ') {
    e.preventDefault();
    setIsOpen(!isOpen);
  }
};

// 在button上绑定
<button
  onKeyDown={handleKeyDown}
  aria-expanded={isOpen}
  aria-controls={panel-${Math.random().toString(36).substr(2, 9)}}
>
  {title}
</button>

项目中遇到的具体业务场景

这个手风琴组件用在了好几个地方,最有挑战性的是FAQ页面,里面的内容长短不一,有的只有一两句话,有的可能有好几段文字加列表。为了保证视觉统一,我设置了最小高度限制,避免太短的内容看起来很突兀。

另一个场景是产品特性展示,那里的内容包含了一些交互元素,比如内嵌的按钮和链接。这里需要注意阻止事件冒泡,否则点击内部按钮也会触发面板的开关:

const handleInnerClick = (e: React.MouseEvent) => {
  e.stopPropagation();
  // 处理内部按钮逻辑
};

还有一个细节就是在同时打开多个面板的场景下,需要控制最多同时打开的数量。项目中有个需求是限制最多只能打开3个,超过了就要自动关闭最早的那个。这个逻辑写起来不难,但要考虑用户体验:

// 在父级组件中维护打开状态
const [openIndices, setOpenIndices] = useState<number[]>([]);

const handleToggle = (index: number) => {
  if (openIndices.includes(index)) {
    setOpenIndices(openIndices.filter(i => i !== index));
  } else {
    if (openIndices.length >= 3) {
      // 超过限制,移除最早打开的
      setOpenIndices(prev => [...prev.slice(1), index]);
    } else {
      setOpenIndices([...openIndices, index]);
    }
  }
};

最终的解决方案

经过多次迭代,最终版本的手风琴组件基本满足了所有需求。在性能方面,通过合理的高度计算和动画优化,基本上没有明显的卡顿问题。兼容性方面,在各个主流浏览器都能正常工作,移动端也做了专门的触摸优化。

代码组织上,我把单个面板封装成独立组件,然后提供了一个容器组件来管理多个面板的状态。这样既保证了组件的复用性,又方便了状态管理。

回顾与反思

回过头看,这个手风琴组件的开发过程比预期复杂多了。主要问题集中在动态高度的处理上,虽然原理简单,但在实际应用中会遇到各种边缘情况。特别是内容动态变化的场景,需要额外处理高度重计算。

还有一些细节方面的问题,比如SEO友好性、搜索引擎是否能正确抓取内容等,这些在项目后期才考虑到。不过对我们的项目来说影响不大,因为主要内容还是放在了其他地方。

总的来说,这个组件现在运行得很稳定,用户反馈也不错。唯一不太满意的就是代码量比预想的多了不少,主要是为了处理各种边界情况。如果有下次机会的话,可能会考虑引入现成的UI库,减少自己造轮子的时间成本。

以上是我踩坑后的总结,希望对你有帮助。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论