Skip to content

滚动时文字渐入效果

我们来实现一个还比较不错的前端显示效果。目前已完成一个简单demo,大家可以在本站切换切换文章,大家可以滚动下看看。

前言

在上次直播时,猫哥给我看了一个网站,说这个效果挺不错的,直播现在也没啥人看,要不研究一下怎么实现它给博客也加一下吧。

效果网站 https://isux.tencent.com/articles/incremental-innovation.html

效果就是随着我们滚动鼠标向下滚动时,文字会由下往上的有个缓慢的渐出效果,在这种文字比较多的网站里,这种效果还是相对比较优雅的。所以我们今天也来实现一下。

🐱哥是一个群友,欢迎大家加入我的一个前端群呀~

我这实现的是按照我的思路来实现的,可能不能做到1比1还原效果,不过应该是八九不离十

思路

主要有这么几个需要思考的点

动画

首先文字渐出的效果这个应该是一个动画,所以我们得实现这个动画。

第一想法是这个东西我们找个动画库就好了,看看有没有类似的,用一下就好了,于是我上了 Animate.css ,找到了好几个类似的效果,最终我们选择 animate__fadeInUp 这个效果。

当我们想要有这个效果的元素进入可视窗口时,加上这个动画类

实现

  • scroll

    监听滚动条事件,判断元素在视口了就加上这个类

    这个方案被比较早就被排除了,因为滚动时触发比较频繁的事件,性能会比较差,如果加上了防抖或者节流,整个效果又延迟,所以不行。

  • IntersectionObserver

    使用js比较新的观察者api,api优雅,且性能更高

综合下来使用这个IntersectionObserver 这个api是最佳选择。

image-20231023110210478

怎么介入 vitepress 工程

因为博客是基于vitepress来实现的,它最终的打包效果是将每个markdown文件都打包成单独的html,最糟糕的视线方式是给打包后的每个html文件都加上一些脚本。

上面的方案肯定是不可行的,麻烦,且耗时,不益于后续的扩展。

利用vue的机制

Vitepress 也是有基于vue3进行开发的,所以我们只要能有接口来介入vue的工程里面,就可以比较高效的视线了。思路大概就是:

  • 页面初始化时,执行脚本,页面元素在滚动到视口时加上我们动画类。

    onMounted

  • 当路由变化时,再次执行脚本,最新的页面元素在滚动到视口时加上我们动画类。

    Watch

我们在docs/.vitepress/theme/index.js 这个文件下进行代码注入即可。

这个文件是vitepress暴露给我们的接口,可以写我们写setup()函数,有了它就能用 onMountedwatch

哪些元素是我们需要效果的呢

这里我们就需要分析一下网站的dom结构了。

image-20231023112200288

经过我们分析,发现其实就是 .vp-doc > div 下的所有子元素是需要加这个效果的,所以我们只需要给这些元素加上观察者即可,当元素滚动到视口时,加上我们的动画类。

代码

具体的一些代码实现

一些工具函数

  • 检查元素是否在视口

    ts
    const isElementInViewport = element => {
      var rect = element.getBoundingClientRect()
      const isInViewport =
        rect.top >= 0 &&
        rect.bottom <=
          (window.innerHeight || document.documentElement.clientHeight)
      return isInViewport
    }
    const isElementInViewport = element => {
      var rect = element.getBoundingClientRect()
      const isInViewport =
        rect.top >= 0 &&
        rect.bottom <=
          (window.innerHeight || document.documentElement.clientHeight)
      return isInViewport
    }

    刚打开页面时,本来就在视口的元素是不需要加入动画类的,只有通过滚动才出现的元素才需要加上这个类

  • 检查元素是否需要加动画类

    ts
    const checkHasAttribute = element => {
      return !!element.getAttribute('snow_is_show')
    }
    const checkHasAttribute = element => {
      return !!element.getAttribute('snow_is_show')
    }

    这里我们通过给元素添加自定义属性的方式,来实现,当元素有这个属性了,就不需要加动画类了。

    这个操作主要是为了防止一直上线滚动,频繁出现动画的问题,动画只需要加一次就好。

全部代码

ts
const observers = [] // 用于存储所有观察者 -> 收集起来主要是为了当路由变化时效果之前的观察者。

export default {
	// ... 省略
	setup() {
		const route = useRoute()
		onMounted(() => {
			initFirstScreen() // 初始化 -> 给首次渲染就在视口的元素加上自定义属性,这些元素永远不用加动画类
			animateFn() // 执行核心脚本
		})
		
    // 元素是否在视口
		const isElementInViewport = element => {
			var rect = element.getBoundingClientRect()
			const isInViewport =
				rect.top >= 0 &&
				rect.bottom <=
					(window.innerHeight || document.documentElement.clientHeight)
			return isInViewport
		}
	
    // 检查是否有自定义属性
		const checkHasAttribute = element => {
			return !!element.getAttribute('snow_is_show')
		}
		
    // 初始化函数
		const initFirstScreen = () => {
			const main = document.querySelector('.vp-doc>div') || []
			const paragraphs = [...(main?.children || [])]
			paragraphs.forEach(item => {
				if (isElementInViewport(item)) {
					item.setAttribute('snow_is_show', true)
				}
			})
		}
		
    // 核心脚本 
		const animateFn = () => {
			const main = document.querySelector('.vp-doc>div') || []
			const paragraphs = [...(main?.children || [])]
			paragraphs.forEach(item => {
				const observer = new IntersectionObserver(entries => {
					entries.forEach(entry => {
						if (entry.isIntersecting && !checkHasAttribute(item)) {
							// 元素进入视口
							item.classList.add('animate__animated')
							item.classList.add('animate__fadeInUp')
							item.setAttribute('snow_is_show', true)
						}
					})
				})
				observer.observe(item)
				observers.push(observer)
			})
		}
		
    // 清空所有 observer 的函数
		const destructionObserver = () => {
			observers.forEach(observe => {
				observe.disconnect()
			})
			observers.length = 0
		}
    
		watch(
			() => route.path,
			() =>
				nextTick(() => {
					destructionObserver() // 先清空所有的观察者
					initFirstScreen() // 再初始化一次 类似onMounted
					animateFn() // 再次执行核心函数
				})
		)
	},
	Layout,
}
const observers = [] // 用于存储所有观察者 -> 收集起来主要是为了当路由变化时效果之前的观察者。

export default {
	// ... 省略
	setup() {
		const route = useRoute()
		onMounted(() => {
			initFirstScreen() // 初始化 -> 给首次渲染就在视口的元素加上自定义属性,这些元素永远不用加动画类
			animateFn() // 执行核心脚本
		})
		
    // 元素是否在视口
		const isElementInViewport = element => {
			var rect = element.getBoundingClientRect()
			const isInViewport =
				rect.top >= 0 &&
				rect.bottom <=
					(window.innerHeight || document.documentElement.clientHeight)
			return isInViewport
		}
	
    // 检查是否有自定义属性
		const checkHasAttribute = element => {
			return !!element.getAttribute('snow_is_show')
		}
		
    // 初始化函数
		const initFirstScreen = () => {
			const main = document.querySelector('.vp-doc>div') || []
			const paragraphs = [...(main?.children || [])]
			paragraphs.forEach(item => {
				if (isElementInViewport(item)) {
					item.setAttribute('snow_is_show', true)
				}
			})
		}
		
    // 核心脚本 
		const animateFn = () => {
			const main = document.querySelector('.vp-doc>div') || []
			const paragraphs = [...(main?.children || [])]
			paragraphs.forEach(item => {
				const observer = new IntersectionObserver(entries => {
					entries.forEach(entry => {
						if (entry.isIntersecting && !checkHasAttribute(item)) {
							// 元素进入视口
							item.classList.add('animate__animated')
							item.classList.add('animate__fadeInUp')
							item.setAttribute('snow_is_show', true)
						}
					})
				})
				observer.observe(item)
				observers.push(observer)
			})
		}
		
    // 清空所有 observer 的函数
		const destructionObserver = () => {
			observers.forEach(observe => {
				observe.disconnect()
			})
			observers.length = 0
		}
    
		watch(
			() => route.path,
			() =>
				nextTick(() => {
					destructionObserver() // 先清空所有的观察者
					initFirstScreen() // 再初始化一次 类似onMounted
					animateFn() // 再次执行核心函数
				})
		)
	},
	Layout,
}

总结

效果我们已经实现完成啦,其实也还是有优化点的:

  • 初始化时可以加一些其他的动画
  • 直接获取.vp-doc > div 的所有一级子元素有点粗暴,可能它的下面还有子元素,细分下能让动画能加优雅一点
  • ......