普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月8日首页

JS 打造丝滑手风琴

2025年9月8日 11:54

手风琴菜单是后台与官网的常客,但 90% 的实现依赖第三方库或 CSS Transition。今天原生 JS 手写一条「高度动画 + 状态机」的完整链路,打造丝滑手风琴效果。

效果预览

JS 打造丝滑手风琴.gif

一、核心思路

  • 高度动画:把 height: 0 ↔ 实际高度 交给逐帧函数 createAnimation
  • 状态机:用自定义属性 status="closed|opened|playing" 避免并发点击
  • 复用:任意html代码结构插上即用,零配置

二、代码速览

1.动画引擎(animate.js)

function createAnimation({ from, to, totalMS = 300, onmove, onend }) {
  const dis = (to - from) / (totalMS / 15);
  let cur = 0;
  const timer = setInterval(() => {
    from += dis;
    if (++cur >= totalMS / 15) {
      from = to;
      clearInterval(timer);
      onend && onend();
    }
    onmove(from);
  }, 15);
}

实现从一个初始值到目标值的平滑过渡效果,每帧更新一次视图。

函数参数(配置项)

  • from:动画起始值(如初始位置、初始透明度等)
  • to:动画目标值(最终要达到的数值)
  • totalMS:动画总时长(默认 300 毫秒,即 0.3 秒)
  • onmove:每帧更新时的回调函数(接收当前动画值,用于实时更新视图,比如 DOM 位置、样式等)
  • onend:动画结束时的回调函数(可选,动画完成后执行)

2.交互逻辑(index.js)

const titles = document.querySelectorAll('.menu h2');
const itemHeight = 30;

titles.forEach(title =>
  title.addEventListener('click', () => {
    const submenu = title.nextElementSibling;
    const before = document.querySelector('.submenu[status="opened"]');
    before && before !== submenu && closeSubmenu(before);
    toggleSubmenu(submenu);
  })
);

function openSubmenu(el) {
  if (el.getAttribute('status') !== 'closed') return;
  el.setAttribute('status', 'playing');
  createAnimation({
    from: 0,
    to: el.children.length * itemHeight,
    onmove: h => (el.style.height = h + 'px'),
    onend: () => el.setAttribute('status', 'opened'),
  });
}

function closeSubmenu(el) {
  if (el.getAttribute('status') !== 'opened') return;
  el.setAttribute('status', 'playing');
  createAnimation({
    from: el.children.length * itemHeight,
    to: 0,
    onmove: h => (el.style.height = h + 'px'),
    onend: () => el.setAttribute('status', 'closed'),
  });
}

function toggleSubmenu(el) {
  const status = el.getAttribute('status');
  status === 'opened' ? closeSubmenu(el) : openSubmenu(el);
}

关键设计思路

  • 用 status 属性(closed/opened/playing)管理子菜单状态,避免动画过程中重复触发点击事件
  • 子菜单高度通过 “选项数量 × 单个高度” 动态计算,适配不同数量的子菜单
  • 点击新菜单时自动关闭已打开的菜单,保证同一时间只有一个子菜单处于展开状态

3.样式骨架

<ul class="menu-container">
  <li class="menu">
    <h2>菜单1</h2>
    <ul class="submenu">
      <li>菜单1</li>
      <li>菜单2</li>
      <li>菜单3</li>
      <li>菜单4</li>
    </ul>
  </li>
  <li class="menu">
    <h2>菜单2</h2>
    <ul class="submenu">
      <li>菜单1</li>
      <li>菜单2</li>
      <li>菜单3</li>
      <li>菜单4</li>
    </ul>
  </li>
  <li class="menu">
    <h2>菜单3</h2>
    <ul class="submenu">
      <li>菜单1</li>
      <li>菜单2</li>
      <li>菜单3</li>
      <li>菜单4</li>
    </ul>
  </li>
  <li class="menu">
    <h2>菜单4</h2>
    <ul class="submenu">
      <li>菜单1</li>
      <li>菜单2</li>
      <li>菜单3</li>
      <li>菜单4</li>
    </ul>
  </li>
</ul>

三、状态机流程图

click ──► status=playing ──► height 0→n ──► status=opened
               │                        ▲
               └─► 再次 click ──► height n→0 ──► status=closed

总结

高度动画 + 状态机 + 事件委托,让手风琴在任何项目里「开箱即合」。

昨天以前首页

JS实现丝滑文字滚动

2025年9月6日 11:48

公告栏、弹幕、股票 ticker……垂直文字滚动是前端最常见的动效之一。本文用纯原生代码拆解一条「克隆 + 逐帧滚动」的实现思路,涵盖布局、滚动、边界衔接三大要点,开箱即用,零依赖零配置。

效果预览

JS实现丝滑文字滚动.gif

一、布局:一行标题 + 一条列表

HTML 极简骨架:

<div class="container">
  <h1 class="title">最新公告</h1>
  <ul class="list">
    <li>把大象装冰箱总共分几步?</li>
    <li>1. 邓哥打开冰箱门</li>
    <li>2. 邓哥把大象放进去</li>
    <li>3. 邓哥关上冰箱门</li>
  </ul>
</div>

CSS 只做两件事:

  • .container 负责背景色与水平排版
  • .list 固定高度并隐藏溢出内容,为滚动做准备

二、核心算法:克隆 + 逐帧滚动

1.克隆首项 —— 无缝衔接的秘诀

当最后一项滚动到可视区后,必须立即回到第一项而不闪屏。最简单做法:把第一项克隆一份追加到列表末尾。这样「最后一项」和「第一项」之间永远有一条平滑过渡。

const list = document.querySelector('.list');
const firstItem = list.children[0].cloneNode(true);
list.appendChild(firstItem);

2.逐帧滚动 —— 手写 Easing

浏览器原生 scrollTop 没有动画,我们用 setInterval 在 300 ms 内完成一次 30 px 的位移动画:

let curIndex = 0;
const itemHeight = 30;
const duration = 300; // 动画耗时
const interval = 10;  // 每帧间隔
const dis = itemHeight / (duration / interval);

function moveNext() {
  let from = curIndex * itemHeight;
  let to = (curIndex + 1) * itemHeight;
  let timer = setInterval(() => {
    from += dis;
    if (from >= to) {
      clearInterval(timer);
      curIndex++;
      // 到达克隆项时瞬间归零,形成无缝循环
      if (curIndex === list.children.length - 1) {
        curIndex = 0;
        list.scrollTop = 0;
      }
    } else {
      list.scrollTop = from;
    }
  }, interval);
}

三、边界处理:回到起点不闪屏

curIndex 等于克隆项索引时,立即把 scrollTop 设为 0,人眼几乎察觉不到跳变,从而形成无限循环。

四、一行命令替换内容

只要保持每条文本高度一致,替换 <ul>innerHTML 即可热插播新公告,无需重启动画逻辑。

总结

「克隆第一项 + 逐帧滚动」是公告栏场景下最轻量的无限循环方案,可让文字像胶片一样永远滚动,不闪、不抖、不掉帧。

❌
❌