React + GSAP + ScrollTrigger + Vite ==> 炫酷3D动画网页 -- 中篇

该项目由React + vite 搭建 基于GSAP和ScrollTrigger实现炫酷3D动画网页

动画效果可以看gif图或者查看 线上地址仓库

本期接着 上篇 继续实现炫酷3D动画网页

这篇文章主要讲解以下部分

  • 安装 介绍 使用依赖库 GSAP ScrollTrigger lenis
  • code可复用hooks initSmoothScrolling.js useImagePreloader.js
  • 响应式设计 chooseAnimation

安装依赖

GSAP + ScrollTrigger

  • GSAP(GreenSock Animation Platform)是Web动画开发的工具库,可使CSS 属性、SVG、React、画布、通用对象等动画化,并解决不同浏览器上存在的兼容问题

  • ScrollTrigger 是 GSAP 的插件,ScrollTrigger控制滚动动画,而真正处理动画是GSAP,二者组合使用才能实现丝滑滚动动画

  • 安装 使用

zsh 复制代码
npm i -D gsap
jsx 复制代码
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);

lenis库介绍

  • lenis也是动画库 可以实现惯性滚动 给鼠标滚轮增加阻尼感 纵享丝滑
  • 查看 lenis官方效果

当我们在移动终端上滑动页面,手指离开屏幕后,页面的滚动并不会马上停止,而是在一段时间内继续保持惯性滚动,并且滑动阻尼感和持续时间与滑动手势的幅度成正比

zsh 复制代码
npm i @studio-freight/lenis

简单实例

jsx 复制代码
import Lenis from '@studio-freight/lenis'

const lenis = new Lenis()
lenis.on('scroll', (e) => {
  console.log(e)
})
function raf(time) {
  lenis.raf(time)
  requestAnimationFrame(raf)
}
requestAnimationFrame(raf)

封装hooks !!

lenis + ScrollTrigger 初始化丝滑滚动效果

  • 创建 Lenis实例
    • 配置平滑滚动的速度 值越小平滑效果越明显lerp: 0.1
    • 启用了鼠标滚轮事件的平滑滚动 smoothWheel: true
  • 用户滚动时更新 ScrollTrigger
    • lenis.on 监听 'scroll' 事件
    • 用户滚动页面时更新 ScrollTrigger
  • 定义动画帧函数
    • crollFn在每一帧动画执行时被调用
    • 通过调用 Lenis 实例的 raf 方法来实现平滑滚动
    • requestAnimationFrame 来实现递归调用
  • 启用动画帧更新
    • 调用requestAnimationFrame(scrollFn) 监听和执行 scrollFn 函数,以维护平滑滚动效果
js 复制代码
//  utils/initSmoothScrolling.js
import Lenis from '@studio-freight/lenis'
import { ScrollTrigger } from 'gsap/ScrollTrigger'

export const initSmoothScrolling = () => {
	const lenis = new Lenis({
		lerp: 0.1,
		smoothWheel: true
	})
	lenis.on('scroll', () => ScrollTrigger.update())
	const scrollFn = time => {
		lenis.raf(time)
		requestAnimationFrame(scrollFn)
	}
	requestAnimationFrame(scrollFn)
}

预加载图片 useImagePreloader

  • 导入图片: 图片都在src/img目录之下 由于vite不支持require 我们可以通过import.meta.globEager方法获取所有以'.jpg'结尾的图片模块
  • 创建imagePromises[] 容纳导出image promise对象,每个image创建了一个 Image 对象,设src属性为module.default
  • 使用 Promise.all 等待所有图片加载完成 然后储存在images中 return出去
jsx 复制代码
// utils/useImagePreloader.js
import { useEffect, useState } from 'react'

export const useImagePreloader = () => {
	const [images, setImages] = useState([])
	useEffect(() => {
		async function preloadImages() {
			const imageModules = import.meta.globEager('../img/*.jpg')
			const imagePromises = Object.values(imageModules).map(module => {
				const image = new Image()
				image.src = module.default
				return image
			})

			const loadedImages = await Promise.all(imagePromises)
			setImages(loadedImages)
		}
		preloadImages()
	}, [])
	return images
}

结合上期文章说的检测浏览器是否支持css 函数supportsCssVars 可以将代码组织如下

  • useLayoutEffect在浏览器绘制 渲染真正dom之前初始化initSmoothScrolling
  • 之后还会将选择动画chooseAnimation 在其中组织
jsx 复制代码
// src/GridAnimation.jsx
import { useLayoutEffect, useState } from 'react'
import { initSmoothScrolling, useImagePreloader, supportsCssVars } from './utils'
function GridAnimation() {
	const [loading, setLoading] = useState(true)
	const images = useImagePreloader()
	useLayoutEffect(() => {
		supportsCssVars() || alert('请在支持CSS变量的现代浏览器中查看此演示')
        initSmoothScrolling()
        // chooseAnimation(gridType)
        setLoading(false)
	}, [images])
	if (loading || !images.length) return <div className="loading"></div>
	return (
		<div>
			<main>
				<div className="intro">
					<h1 className="intro__title">
						<span className="intro__title-pre">On-Scroll</span>
						<span className="intro__title-sub">Perspective Grid Animations</span>
					</h1>
					<span className="intro__info">Scroll moderately to fully experience the animations</span>
				</div>
			</main>
		</div>
	)
}
export default GridAnimation

可以看到图片也已经加载好了 差不多 ! 前期准备工作基本完成 进入正题!

进入正题 ! Animation!

整体布局优化

我们整体的结构应该是这样的

  • 先关注其中一个section结构 其他的循环出来就好了
  • 其中要注意grid-wrap之下里面套了一层循环渲染image 因为很大所有没有展开 具体可以看下面的结构
html 复制代码
<div>
    <main>
        <div className="intro">...</div>
        <section key={index} className='content'>
            <div className={'grid'}>
                <div className="grid-wrap">
                    {images.map((item, index) => (
                        <div className="grid__item" key={index}>
                            <img className="grid__item-inner" src={item.src} />
                        </div>
                    ))}
                </div>
            </div>
            <h3 className='content__title'>{children}</h3>
        </section>
        <section>...<section/>
    </main>
</div>

美化样式

  • 采用BEM命名规范
    • 中划线(-) :仅作为连字符使用,表示某个块或者某个子元素的多单词之间的连接记号。
    • 双下划线(__):双下划线用来连接块和块的子元素
    • 单下划线(_):单下划线用来描述一个块或者块的子元素的一种状态
  • 采用var自定义属性增强复用性 动态可扩展
  • vh(视窗高度单位) rem(根元素字体大小单位)clamp(min, preferred, max)实现响应式设计
css 复制代码
.content {
	position: relative;
	margin-bottom: 20vh;
}

.grid {
	display: grid;
	place-items: center;
	padding: 2rem;
	width: 100%;
	perspective: var(--perspective);
}

.grid-wrap {
	height: var(--grid-height);
	width: var(--grid-width);
	display: grid;
	grid-template-columns: repeat(var(--grid-columns), 1fr);
	gap: var(--grid-gap);
	transform-style: preserve-3d;
}

.grid__item {
	aspect-ratio: var(--grid-item-ratio);
	width: 100%;
	height: auto;
	overflow: hidden;
	position: relative;
	border-radius: 8px;
	display: grid;
	place-items: center;
}

.grid__item-inner {
	position: relative;
	width: calc(1 / var(--grid-inner-scale) * 100%);
	height: calc(1 / var(--grid-inner-scale) * 100%);
	background-size: cover;
	background-position: 50% 50%;
}
.content__title {
	position: absolute;
	height: 100vh;
	width: 100vw;
	top: 50%;
	left: 50%;
	margin: -50vh 0 0 -50vw;
	padding: 0 10vw;
	display: grid;
	place-items: center;
	text-align: center;
	font-weight: 300;
	font-size: clamp(1.5rem, 15vw, 6.5rem);
}

效果

现在差不多能够看了 但效果只能说四平八稳 没什么特点 上Animation!

chooseAnimation 响应式设计

getGrid函数 -- 操作奇偶数行grid

是由GreenSock官方 提供的可以动态地计算网格的行/列 操作奇数或偶数行设置动画 当库函数直接拿过来用就行

js 复制代码
const getGrid = selector => {
	let elements = gsap.utils.toArray(selector),
		bounds,
		getSubset = (axis, dimension, alternating, merge) => {
			let a = [],
				subsets = {},
				onlyEven = alternating === 'even',
				p
			bounds.forEach((b, i) => {
				let position = Math.round(b[axis] + b[dimension] / 2),
					subset = subsets[position]
				subset || (subsets[position] = subset = [])
				subset.push(elements[i])
			})
			for (p in subsets) {
				a.push(subsets[p])
			}
			if (onlyEven || alternating === 'odd') {
				a = a.filter((el, i) => !(i % 2) === onlyEven)
			}
			if (merge) {
				let a2 = []
				a.forEach(subset => a2.push(...subset))
				return a2
			}
			return a
		}
	elements.refresh = () => (bounds = elements.map(el => el.getBoundingClientRect()))
	elements.columns = (alternating, merge) => getSubset('left', 'width', alternating, merge)
	elements.rows = (alternating, merge) => getSubset('top', 'height', alternating, merge)
	elements.refresh()
	return elements
}

chooseAnimation

列举其中一个AnimationType grid--1来举例子 其他的type可以看源码

js 复制代码
export const chooseAnimation = (animationType, grid) => {
	const gridWrap = grid.querySelector('.grid-wrap')
	const gridItems = grid.querySelectorAll('.grid__item')
	const gridItemsInner = [...gridItems].map(item => item.querySelector('.grid__item-inner'))
	const timeline = gsap.timeline({
		defaults: { ease: 'none' }, 
		scrollTrigger: {
			trigger: gridWrap, 
			start: 'top bottom+=5%',
			end: 'bottom top-=5%',
			scrub: true,
		},
	})
	switch (animationType) {
		case 'grid--1':
			grid.style.setProperty('--perspective', '1000px')
			grid.style.setProperty('--grid-inner-scale', '0.5')
			timeline
                .set(gridWrap, { rotationY: 25 })
                .set(gridItems, { z: () => gsap.utils.random(-1600, 200) })
                .fromTo(gridItems, { xPercent: () => gsap.utils.random(-1000, -500) }, { xPercent: () => gsap.utils.random(500, 1000) }, 0)
                .fromTo(gridItemsInner, { scale: 2 }, { scale: 0.5 }, 0)
			break
        case 'grid--2':
        ... 
        break
        ...
		default:
			console.error('未知animation type')
			break
    }
}

函数详细注释

  • chooseAnimation接收俩个参数 animationType(动画类型的名称) 和 grid(包含网格元素的DOM元素)

  • 获取 操作dom元素

    • 在grid元素之下查找grid-wrap和gridItem
    • 将gridItems 转换为数组,遍历查找每个子元素中具有类名'grid__item-inner' 的元素储存在gridItemsInner中
  • 创建gsap(GreenSock Animation Platform)的时间线(timeline)

    • 在时间线配置对象中,设置了默认的缓动函数为 'none'
    • 配置scrollTrigger 指定gridWrap 元素作为滚动触发器的触发器
    • start: 'top bottom+=5%'触发器的开始位置被设置在 gridWrap 的顶部,然后再下移5%的距离触发动画
    • end: 'bottom top-=5%'触发器的结束位置被设置在 gridWrap 的底部,然后再上移5%的距离触发动画。
    • scrub: true动画会根据滚动位置进行缓慢播放
  • 根据传入的 animationType选择不同的动画类型

    • 对每个grid重载不同css自定义属性--perspective--grid-inner-scale等等
  • 具体化时间线设置

    • timeline.set方法将 gridWrap 元素的 rotationY 属性设置为 25timeline.set(gridWrap, { rotationY: 25 })
    • 使用随机函数设置 gridItems 元素的 z 属性,范围在 -1600 到 200 之间timeline.set(gridItems, { z: () => gsap.utils.random(-1600, 200) })
    • 使用 fromTo 方法来设置 gridItems 元素的 xPercent 属性,从一个随机值过渡到另一个随机值。 0 表示从时间线的开始位置执行这个动画
    • 设置gridItemsInner 元素的 scale 属性,从 2倍缩小到 0.5倍

chooseAnimation使用

jsx 复制代码
useLayoutEffect(() => {
    const grids = document.querySelectorAll('.grid')
    const promises = Array.from(grids).map((grid, i) => chooseAnimation(`grid--${i + 1}`, grid))
    await Promise.all(promises)
}

那么现在就可以重新组织一下代码啦

jsx 复制代码
import { useLayoutEffect, useState } from 'react'
import { initSmoothScrolling, useImagePreloader, supportsCssVars, chooseAnimation } from './utils'
import './style/index.less'
function GridAnimation() {
	const [loading, setLoading] = useState(true)
	const images = useImagePreloader()
	useLayoutEffect(() => {
		const animateGrids = async () => {
			supportsCssVars() || alert('请在支持CSS变量的现代浏览器中查看此演示')
			initSmoothScrolling()
			const grids = document.querySelectorAll('.grid')
			const promises = Array.from(grids).map((grid, i) => chooseAnimation(`grid--${i + 1}`, grid))
			await Promise.all(promises)
			setLoading(false)
		}
		animateGrids()
	}, [images])
	if (loading || !images.length) return <div className="loading"></div>
	return (
		<div>
			<main>
				<div className="intro">
					<h1 className="intro__title">
						<span className="intro__title-pre">On-Scroll</span>
						<span className="intro__title-sub">Perspective Grid Animations</span>
					</h1>
					<span className="intro__info">Scroll moderately to fully experience the animations</span>
				</div>
				<section className={'content'}>
					<div className={'grid'}>
						<div className="grid-wrap">
							{images.map((item, index) => (
								<div className="grid__item" key={index}>
									<img className="grid__item-inner" src={item.src} />
								</div>
							))}
						</div>
					</div>
					<h3 className={'content__title'}>
						{'Fleeting moments,'} <br />
						{`existence's dance.`}
					</h3>
				</section>
				<section>...</section>
				<section>...</section>
				<section>...</section>
			</main>
		</div>
	)
}
export default GridAnimation

如何优化代码结构 ?

假设我们有很多个AnimationType 而每个section结构都高度耦合 同时还会设置不同的css属性 不可能直接就粘贴复制改改className刷刷刷完成任务吧。。虽然这个小项目没啥人看 但也还是坚守职业修为!! 所以!!必须做一些抽取封装!那么下一期就是重新组织代码结构 + 部署上线咯

今天的任务就圆满结束啦 有收获可以点赞收藏哦~

推荐文章

参考

相关推荐
小镇程序员7 分钟前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐9 分钟前
前端图像处理(一)
前端
程序猿阿伟16 分钟前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒18 分钟前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪26 分钟前
AJAX的基本使用
前端·javascript·ajax
力透键背29 分钟前
display: none和visibility: hidden的区别
开发语言·前端·javascript
程楠楠&M40 分钟前
node.js第三方Express 框架
前端·javascript·node.js·express
盛夏绽放1 小时前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
想自律的露西西★1 小时前
用el-scrollbar实现滚动条,拖动滚动条可以滚动,但是通过鼠标滑轮却无效
前端·javascript·css·vue.js·elementui·前端框架·html5
白墨阳1 小时前
vue3:瀑布流
前端·javascript·vue.js