一个简洁的 DIY 滚动动画解决方案(适用于任何框架)

2025-06-08

一个简洁的 DIY 滚动动画解决方案(适用于任何框架)

更新 4.10.19最近下面的一条评论提到了 Intersection Observer API,让我大吃一惊。它本质上就是为了本文的主题而构建的,允许你在元素位于屏幕上的某个位置时触发回调!文章后半部分关于使用 store 集中逻辑的内容仍然完全适用于此,但 Intersection Observer 比 requestAnimationFrame 更简洁,同时性能也更高。不过,请注意,它不支持 IE,Safari 也只是最近才支持。好了,享受这篇文章的剩余内容吧😊

我在互联网上寻找灵感,发现很多我喜欢的网站都加入了一些有趣的“显示”动画,每当我滚动到某些元素时就会出现。这些额外的元素虽然不起眼,但却让页面感觉不再那么静态,响应速度也更快。问题是……实现这些效果的最佳方法是什么?

只是浏览 CodePen 的示例,我就一次又一次发现,人们都在寻求能够处理这个问题的万能库。市面上有无数的滚动动画库,其中最流行的就是恰如其名的AOS 库。我自己也希望在我的网站上添加一些滚动动画,所以自然而然地想到了使用 AOS 库。然而,随着我的实现变得越来越具体(例如,如何在滚动到这个 iFrame 之前避免加载它?),我开始思考……

我不能自己建造它吗?

也许吧。让我们看看

从基础的原生 JS 开始,无需任何框架,方法其实非常简单。我们只需要一个onScroll处理程序以及我们想要动画的元素。从基础开始,假设我们有一个特定 ID 的元素,想要为其触发动画。正如你可能想象的那样,我们可以利用 DOM 窗口的onScroll事件来确定元素在屏幕上的位置,无论你何时滚动:

window.onScroll = ({target}) => {
    const element = document.getElementById('animate-me')
    const elementTop = element.getBoundingClientRect().top
    if (elementTop < document.body.clientHeight) {
        element.classList.add('scrolled-to')
    }
}
Enter fullscreen mode Exit fullscreen mode

为此,我们需要获取一些嵌套的对象属性。首先,我们需要获取元素顶部在屏幕上的位置的像素值。有几种有效的方法可以找到它,但通过快速的互联网搜索,似乎这getBoundingClientRect()是跨浏览器最可靠的方法。

这样,我们就应该与文档的固定高度进行比较。这实际上就是浏览器窗口的高度,也就是clientHeight。如果元素的顶部小于这个高度,那么它的某些部分肯定在屏幕上。现在,我们只需将关键帧添加到 CSS 中.animate-me.scrolled-to,就可以了👍

好的,太好了,我们基本上重新创建了一个 MDN 帮助页面示例……

搞定这些之后,我们来实际实现一下。首先,如果你好奇心强,就随便加了一句console.log,那么每次你转动滚轮的时候,很可能就会看到这个。

滚动处理程序的控制台日志

这反映出分析每个滚动事件的成本实际上有多么高昂。我们滚动每个像素都会执行一个函数,而当我们开始让这个函数变得更加健壮时,它可能会开始导致卡顿和卡顿。

解决这个问题的一种方法是使用requestAnimationFrame来决定何时触发回调。这是另一个窗口级函数,您可以在其中将回调排队以供浏览器调用。当浏览器认为可以执行这些函数且不会影响滚动体验时,它就会触发它们。值得庆幸的是,这种方法已被相对较高的浏览器采用。我们需要的只是一个围绕处理onScroll程序的包装器requestAnimationFrame,以及一个boolean标志,让我们知道上一个回调是否已执行完成:

let waitingOnAnimRequest = false

const animChecker = (target) => {
    // Our old handler
    const element = document.getElementById('animate-me')
    const elementTop = element.getBoundingClientRect().top
    if (elementTop < document.body.clientHeight) {
        element.classList.add('scrolled-to')
    }
}

window.onScroll = ({target}) => {
    if (!waitingOnAnimRequest) {
        window.requestAnimationFrame(() => {
            animChecker(target)
            waitingOnAnimRequest = false
        })
        waitingOnAnimRequest = true
    }
}
Enter fullscreen mode Exit fullscreen mode

太棒了!现在我们的调用应该更高效了。但让我们先解决一个更紧迫的问题:对于我们想要在滚动时添加动画的文档元素,如何让它正常工作?

为我们需要的每个可能的 ID 或 className 不断添加回调肯定是没有意义的,那么为什么不创建一个可以附加所有元素选择器的集中数组呢?

循环时间到了

这个添加相当简单querySelectorAll。只需创建一个全局数组,包含所有需要动画的选择器(ID 或类),然后像这样循环遍历它们:

let animationSelectors = ['#ID-to-animate', '.class-to-animate']

const animChecker = (target) => {
    // Loop over our selectors
    animationSelectors.forEach(selector => {
        // Loop over all matching DOM elements for that selector
        target.querySelectorAll(selector).forEach(element => {
            const elementTop = element.getBoundingClientRect().top
            if (elementTop < bodyHeight) {
                 element.classList.add('scrolled-to')
            }
        })
    })
}
...
Enter fullscreen mode Exit fullscreen mode

现在我们的滚动动画检查器应该能够处理我们抛出的任何元素!

太棒了!但我用的是X框架,我觉得因为Y的原因,我没法用这个。

现在就在这里。我知道每个人的工具都有自己的怪癖,所以让我们尝试解决其中的一些问题。

我使用组件系统,那么如何集中这个逻辑?

尽管最好有一个简洁的类和 ID 列表,我们想要动画,但组件,特别是使用范围 CSS 解决方案的组件,使得保持此列表的可读性和可扩展性变得困难。

值得庆幸的是,这个解决方案只需要一个字符串数组即可运行,因此我们可以使用一个全局存储,每个组件都可以使用它们想要动画的 DOM 选择器进行更新。我在最近基于 SvelteJS 构建的一个项目中使用了这种方法,该项目使用了一个基于订阅的全局存储。为了更新animationSelectors,我只需将其创建为一个存储即可……

export const animationTriggers = writable({})
Enter fullscreen mode Exit fullscreen mode

...并在创建组件时添加类名。

import { animationTriggers } from '../stores'

onMount(() => {
    animationTriggers.set([
      ...$animationTriggers,
      '.wackily-animated-class',
      '#section-id',
    ])
  })
Enter fullscreen mode Exit fullscreen mode

这对于 Redux 和 React Context 等常见的全局状态解决方案同样有效。Redux 的实现因中间件而异,因此这里就省略了多文件示例,但这里有一个使用 React Context 的选项(在原生 React 中有效):

// store.js
...
const AnimationTriggerContext = React.createContext()

class StoreWrapper extends React.Component {
    constructor() {
        super()
        this.state = {
            selectors: []
        }
    }
    render() {
        return (
            // create a provider to wrap our components in at the parent level
            <AnimationTriggerContext.Provider value={{
                // make our array of selectors accessible from all children
                selectors: this.state.selectors,
                // add a helper function to update our array
                addSelector: (selector) => {
                    this.setState({
                        selectors: [...this.state.selectors, selector],
                    })
                }
            }}>
                {this.props.children}
            </AnimationTriggerContext.Provider>
        )
    }
}

//childManyLayersDeep.js
...
class Child extends React.Component {
    componentDidMount() {
        this.context.addSelector('special-class')
    }
    render() {
        return <div className="special-class"></div>
    }
}

//wrap the child with a 'withContext' so it can be accessed
export default withContext(Child)
Enter fullscreen mode Exit fullscreen mode

当然,这种方法可以扩展到 VueJS、RxJS 可观察对象以及基本上任何可能使用全局存储的地方。

好吧,这真是太棒了……但我不会用基本的 CSS 选择器。这些是组件!

好吧,说得对;这在大多数基于组件的框架中会使事情变得复杂。最简单的折衷方案是在“add”函数中传递元素本身的引用而不是类名,这样就可以避免 DOM 查询。总的来说,refReact 或 Vue 中一个不起眼的属性,而不是一个类或 ID 选择器,应该可以解决这个问题。

另外,我正在使用 CSS-in-JS,并且不想在开始动画时检查类名。我该怎么做?

如今,这是一种相当常见的模式,并且更倾向于依赖 prop 传递而非类名切换。值得庆幸的是,我们几乎已经准备好了所有逻辑,可以根据 store 来识别这些 prop。我们只需要在传入的选择器上添加一个额外的对象属性,比如一个scrolledTo可以设置为“true”或“false”的 flag。

为此,我们将修改添加到存储中的内容,从字符串(或引用)更改为对象......

{
    selector: 'class-name',
    scrolledTo: false,
}
Enter fullscreen mode Exit fullscreen mode

...并在滚动到时更新其标志。

const animChecker = (target) => {
    ...
        if (elementTop < bodyHeight) {
            animationTriggers[currentIndex].scrolledTo = true
        }
    ...
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以订阅我们的 animationTriggers 数组(或获取上下文,取决于您的实现)并将我们的scrolledTo标志作为 prop 传递给组件的样式。

总之

所以,在你抱怨说你本来可以在读完这篇文章的时间内让你最喜欢的动画滚动库运行起来之前……我明白。但我想说,把这个功能当作一个有趣的小挑战来自己动手实现,对于理解如何制作简洁高效的 DOM 监听器非常有帮助。这也意味着你的包中可以少一个需要担心的依赖项,因此不会有重大更改,并且在添加新功能时拥有很大的灵活性!

想要了解这个解决方案的实际效果,可以看看我们佐治亚理工学院俱乐部主页 Golden Swarm Games 上到处可见它的身影。访问网站https://gsg.surge.sh代码库,了解我们的滚动动画是如何运作的。

学到一点东西吗?

太棒了!万一你错过了,我发布了一篇“网络魔法”简报,来探索更多类似的知识!

这东西探讨的是Web 开发的“首要原则”。换句话说,究竟是哪些糟糕的浏览器 API、扭曲的 CSS 规则以及半无障碍的 HTML 支撑着我们所有的 Web 项目?如果你想要超越框架,那么亲爱的 Web 魔法师,这东西就是为你准备的🔮

赶紧在这里订阅吧!我保证永远教书,绝不会发垃圾信息❤️

鏂囩珷鏉ユ簮锛�https://dev.to/bholmesdev/a-neat-diy-solution-to-animating-on-scroll-for-any-framework-24lc
PREV
离开 Notion,在 VS Code 中构建第二个大脑
NEXT
如何在手机上打开 Vite 开发服务器