> ## Content Index
> Fetch the complete content index at: https://blog.20061124.xyz/llms.txt
> Use this file to discover other available public pages before exploring further.

# 用原生 JS 写前端动效：不引入任何框架
- URL: https://blog.20061124.xyz/vanilla-js-motion/
- Published: 2026-09-28T02:00:00.000Z
- Updated: 2026-10-01T06:56:56.000Z
- Description: 不引入任何框架，用 requestAnimationFrame、IntersectionObserver 和 CSS 变量做出顺滑的前端动效。
- Author: Ray Jack
- Tags: 前端

给博客加动效的时候，考虑过用现成的动画库，最后还是决定用原生 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 只负责切换状态和传参数

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