开发者笔记本屏幕上打开的代码编辑器
前端 4 分钟

用原生 JS 写前端动效:不引入任何框架

不引入任何框架,用 requestAnimationFrame、IntersectionObserver 和 CSS 变量做出顺滑的前端动效。

给博客加动效的时候,考虑过用现成的动画库,最后还是决定用原生 JS 写。原因有三个:动效就那么几种,用不着一个几十 KB 的依赖;不引入构建步骤,主题文件改完直接生效;以及最重要的是加载速度和可控性。

这里记录几个实际用到的做法和其中的注意点。

一、滚动相关的都走 requestAnimationFrame

最常见的错误是直接在 scroll 事件里算位置、改样式。滚动事件触发非常频繁,一帧里可能触发很多次,每次都去读 getBoundingClientRect 就会强制浏览器反复重排。

简单的节流写法就够了:

var ticking = false;

function onScroll(fn) {
    window.addEventListener('scroll', function () {
        if (ticking) return;
        ticking = true;
        window.requestAnimationFrame(function () {
            ticking = false;
            fn();
        });
    }, { passive: true });
}

关键是两点:用 requestAnimationFrame 把工作压到一帧只做一次;加上 { passive: true }——告诉浏览器这个监听器不会调用 preventDefault(),浏览器就不用等它执行完才开始滚动。

二、视差:只改 transform

首页大图的视差就是滚动时给它一个位移:

var shift = Math.min(y * 0.24, media.parentNode.offsetHeight * 0.1);
media.style.transform = 'translate3d(0,' + shift.toFixed(1) + 'px,0)';

有两点要注意。translate3d 而不是 top——transform 的动画可以由 GPU 合成,不会触发重排。Math.min 那个上限是必要的,否则滚动到页面底部时图片会移出容器,露出上方的空白。

三、淡入:交给 IntersectionObserver

元素进入视口时淡入,以前要自己算滚动位置,现在直接用 IntersectionObserver:

var observer = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
        if (entry.isIntersecting) {
            entry.target.classList.add('is-in');
            observer.unobserve(entry.target);
        }
    });
}, { rootMargin: '0px 0px -6% 0px', threshold: 0.04 });

Array.prototype.forEach.call(
    document.querySelectorAll('.reveal'),
    function (el) { observer.observe(el); }
);

动画本身还是 CSS 做的,JS 只负责加一个 class。这样做的好处是动画由浏览器自己调度,比用 JS 一帧一帧改样式流畅。

unobserve 那行别漏——元素已经出现过了就取消监听,不然列表长了会有几百个元素一直挂在观察器里。

想让列表里的卡片依次出现,用 CSS 变量传延迟就行,不用为每个元素写一条规则:

card.style.setProperty('--d', (i % 3) * 80 + 'ms');
.card { transition-delay: var(--d, 0ms); }

四、鼠标聚光:把坐标写进 CSS 变量

卡片跟随鼠标的高光效果,做法是把鼠标位置换算成百分比,写进自定义属性:

card.addEventListener('pointermove', function (e) {
    var rect = card.getBoundingClientRect();
    card.style.setProperty('--mx', ((e.clientX - rect.left) / rect.width * 100) + '%');
    card.style.setProperty('--my', ((e.clientY - rect.top) / rect.height * 100) + '%');
});

CSS 那边读这两个变量画径向渐变:

.card__glow {
    background: radial-gradient(
        circle at var(--mx, 50%) var(--my, 50%),
        rgba(66, 133, 244, .16), transparent 60%);
}

好处是 JS 只负责传数值,具体的视觉效果完全归 CSS 管,调样式不用碰 JS。

五、一定要尊重 prefers-reduced-motion

有些人系统里开着「减少动态效果」,可能是因为前庭功能的问题,动画会让ta们真的不舒服。所以每个动效前面都要加一道判断:

var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

if (reduceMotion) {
    el.classList.add('is-in');   // 直接显示最终状态
} else {
    observer.observe(el);        // 交给观察器做动画
}

CSS 里也补一条兜底:

@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: .01ms !important;
        transition-duration: .01ms !important;
    }
}

六、小结

原生写动效的核心其实就三条:

  • 滚动相关的工作压到 requestAnimationFrame 里,一帧一次
  • 能改 transform / opacity 就不要改会引起重排的属性
  • 把动画交给 CSS,JS 只负责切换状态和传参数

做到这三点,不引入任何库也能做出挺顺滑的效果,而且代码量比想象中少——上面这些加起来也就一百多行。

评论