用JS实现钉钉官网的一部分动画效果
前言
最近在抖音刷到渡一前端袁老师的一个视频,视频中袁老师用原生的 js
实现了钉钉官网的一个动画效果,我把这个视频看了很多遍,觉得实现这个效果特别能锻炼人的思维,在不依赖框架、第三方库的情况下,自己使用原生的 js
去实现一遍,对自己的提升会非常大,因此我也试着去实现该动画效果。

钉钉官网的动画效果如上,当然我这个只是实现了其中的一部分,其实其他的部分用一种思维都是可以实现的,由于时间关系,我只实现了下面的这部分:
固定动画区域
我们仔细观察页面的滚动时可以发现,当列表的动画在播放的时候,我一直在滑动滚动条,但是列表区域是固定的,并不会往下滑,而当动画播放结束之后,列表区域才可以被带走,也就是要固定一块区域用于播放动画,我们先来看下页面的布局结构:
HTML结构:
<template>
<div class="main-conteiner">
<div class="logo-title">
<div class="logo">
<img src="../static/images/logo.svg" alt="" />
</div>
<div class="title">
<img src="../static/images/title.png" alt="" />
</div>
</div>
<div class="playground" ref="playground">
<div class="animation-container" ref="animationRef">
<div class="list" ref="listRef">
<div
class="list-item"
:data-order="orderMap[index]"
v-for="(item, index) in list"
:key="index"
:class="[
index == 6 || index == 13 ? 'mr-0' : '',
index > 6 ? 'mt-100' : '',
]"
>
<img :src="item.url" alt="" />
</div>
</div>
</div>
</div>
<div class="last-area">
<img
src=" https://gw.alicdn.com/imgextra/i4/O1CN013OYoqp1foK6tig7lJ_!!6000000004053-2-tps-864-168.png"
alt=""
/>
<span>企业数字化一个钉钉就解决</span>
</div>
</div>
</template>
playground
(蓝色区域)的高度要尽可能大些,要留有足够的时间给列表区域播放动画;同时 animation-container
(红色区域)要设置为粘性定位,当它的上边沿碰到其父元素 playground
时开始固定住,并播放动画,当它的下边沿碰到其父元素 playground
时的下边沿时动画播放结束,固定取消,随着滚动条的滚动,它会被父元素带走。样式代码如下:
<style lang="scss" scoped>
.main-conteiner {
width: 100%;
height: 100%;
}
.logo-title {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 800px;
margin-top: 150px;
.logo {
width: 200px;
height: 200px;
img {
display: block;
width: 100%;
}
}
.title {
width: 800px;
height: 100px;
margin-top: 50px;
img {
display: block;
width: 100%;
background: #040506;
}
}
}
.playground {
width: 100%;
height: 3000px;
.animation-container {
position: sticky;
top: 0;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
background: #040506;
height: 710px;
.list {
display: flex;
flex-wrap: wrap;
width: 1200px;
height: 460px;
background: #15171b;
border-radius: 20px;
padding: 100px;
box-sizing: border-box;
}
.list-item {
width: 80px;
height: 80px;
margin-right: 73.333px;
img {
display: block;
width: 80px;
height: 80px;
border-radius: 20px;
}
}
.mr-0 {
margin-right: 0;
}
.mt-100 {
margin-top: 100px;
}
}
}
.last-area {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 800px;
background: #0a0610;
img {
display: block;
width: 200px;
margin-right: 20px;
}
span {
display: inline-block;
line-height: 60px;
font-size: 23px;
font-weight: 700;
color: #fff;
}
}
</style>
实现动画
动画区域以及布局结构、样式处理完之后,接下来该思考如何去实现动画
首先,我们要理解动画的本质,我个人的理解是 坐标系
,横坐标是时间,纵坐标是某个值,也就是随着时间的变化,某个样式的值(透明度、偏移量...)在发生均匀
的改变,可以看下下面的这张图,当然我这里的横坐标是 scroll
(滚动的距离)
这里可以写一个函数来计算对应的值
// 动画曲线 - 根据传入的横坐标计算对应的纵坐标(value)
function createAnimation(
scrollstart: number,
scrollEnd: number,
valueStart: number,
valueEnd: number
) {
return function (scroll: number) {
if (scroll <= scrollstart) {
return valueStart;
}
if (scroll >= scrollEnd) {
return valueEnd;
}
return (
valueStart +
((valueEnd - valueStart) * (scroll - scrollstart)) /
(scrollEnd - scrollstart)
);
};
}
由于列表中的每个元素的动画程度都不一样,因此需要准备一个 map
结构做映射:其中 key 为 dom 元素,value 为一个对象,里面是一些需要变化的样式,而且每个样式值都是一个函数,根据当前滚动条的位置设置不同的值:
监听页面的滚动事件,更新列表中每个 dom 元素的样式
// 映射 - dom => {}
const animationMap = new Map();
// 更新dom的样式,遍历map给每个dom元素设置样式(透明度、偏移量、放缩....)
function updatestyles() {
const scroll = window.scrollY;
for (let [dom, value] of animationMap) {
for (const cssProp in value) {
dom.style[cssProp] = value[cssProp](scroll);
}
}
}
// 初始时调用一次
updatestyles();
// 监听滚动条事件
window.addEventListener("scroll", updatestyles);
关键是要搞定 animationMap
,另外写个函数来进行处理,在页面挂载的时候调用一次
onMounted(() => {
updateMap();
});
scrollStart、scrollEnd 分别表示滚动距离为多少时开始动画、结束动画,这两个值都需要进行一些计算,涉及到的距离比较多,大家可以自行画图来进行分析
// 更新map
function updateMap() {
// 每次调用时先将之前的清除掉,在窗口大小等发生改变时重新处理
animationMap.clear();
const playGroundRect = playground.value.getBoundingClientRect();
const scrollStart = playGroundRect.top + window.scrollY;
const scrollEnd = playGroundRect.bottom + window.scrollY - window.innerHeight;
for (const item of listRef.value.children) {
animationMap.set(item, getDomAnimation(scrollStart, scrollEnd, item));
}
}
getDomAnimation
方法也就是实现上面所说的 map
结构中的 value
,其返回两个
函数,用于计算对于的样式属性值,整个过程中透明度(opacity)由 0 -> 1,放缩比例(scale)由 0.5 -> 1,这里比较难处理的是偏移量(translate),每个元素都会往中间靠(水平、垂直方向上);由于每个元素这些属性值的变化不完全一样,因此需要给每个 dom 元素加个自定义属性 data-order
,同时使动画具有对称性。
const orderMap = {
0: 0,
1: 1,
2: 2,
3: 3,
4: 2,
5: 1,
6: 0,
7: 0,
8: 1,
9: 2,
10: 3,
11: 2,
12: 1,
13: 0,
};
function getDomAnimation(scrollStart: number, scrollEnd: number, dom: any) {
scrollStart = scrollStart + dom.dataset.order * 600;
const opacityAimation = createAnimation(scrollStart, scrollEnd, 0, 1);
const opacity = function (scroll: number) {
return opacityAimation(scroll);
};
const xAnimation = createAnimation(
scrollStart,
scrollEnd,
animationRef.value.clientWidth / 2 - dom.offsetLeft - dom.clientWidth / 2,
0
);
const yAnimation = createAnimation(
scrollStart,
scrollEnd,
animationRef.value.clientHeight / 2 - dom.offsetTop - dom.clientHeight / 2,
0
);
const scaleAnimation = createAnimation(scrollStart, scrollEnd, 0.5, 1);
const transform = function (scroll: number) {
return `translate(${xAnimation(scroll)}px, ${yAnimation(
scroll
)}px) scale(${scaleAnimation(scroll)})`;
};
return {
opacity,
transform,
};
}
实现效果
代码的话基本就完成了,下面我们看下最终的效果:

完整代码
DingTalk.vue
<template>
<div class="main-conteiner">
<div class="logo-title">
<div class="logo">
<img src="../static/images/logo.svg" alt="" />
</div>
<div class="title">
<img src="../static/images/title.png" alt="" />
</div>
</div>
<div class="playground" ref="playground">
<div class="animation-container" ref="animationRef">
<div class="list" ref="listRef">
<div
class="list-item"
:data-order="orderMap[index]"
v-for="(item, index) in list"
:key="index"
:class="[
index == 6 || index == 13 ? 'mr-0' : '',
index > 6 ? 'mt-100' : '',
]"
>
<img :src="item.url" alt="" />
</div>
</div>
</div>
</div>
<div class="last-area">
<img
src=" https://gw.alicdn.com/imgextra/i4/O1CN013OYoqp1foK6tig7lJ_!!6000000004053-2-tps-864-168.png"
alt=""
/>
<span>企业数字化一个钉钉就解决</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
const listRef = ref();
const playground = ref();
const animationRef = ref();
onMounted(() => {
updateMap();
});
const list = [
{
name: "即时沟通",
url: "https://img.alicdn.com/imgextra/i3/O1CN01TVWH501KYCEaUxPgv_!!6000000001175-0-tps-480-480.jpg",
},
{
name: "组织",
url: "https://img.alicdn.com/imgextra/i1/O1CN01vqBxIP1L5d98G9xJD_!!6000000001248-0-tps-480-480.jpg",
},
{
name: "智能人事",
url: "https://img.alicdn.com/imgextra/i3/O1CN01AKJU3T1tSi2NAFHFP_!!6000000005901-0-tps-480-480.jpg",
},
{
name: "组织大脑",
url: "https://img.alicdn.com/imgextra/i3/O1CN01AKJU3T1tSi2NAFHFP_!!6000000005901-0-tps-480-480.jpg",
},
{
name: "OA审批",
url: " https://img.alicdn.com/imgextra/i1/O1CN01sphiAp1Fj7bOKEppz_!!6000000000522-2-tps-480-480.png",
},
{
name: "邮箱",
url: "https://img.alicdn.com/imgextra/i2/O1CN015T9BdZ28Ns5n1A82W_!!6000000007921-0-tps-480-480.jpg",
},
{
name: "Teambition",
url: " https://img.alicdn.com/imgextra/i3/O1CN01bc6FKX1OCuHkDC2J7_!!6000000001670-0-tps-480-480.jpg",
},
{
name: "文档",
url: "https://img.alicdn.com/imgextra/i1/O1CN01Y936lS1whluhvos2E_!!6000000006340-0-tps-480-480.jpg",
},
{
name: "音视频会议",
url: "https://img.alicdn.com/imgextra/i3/O1CN017Wk2Cp1lp8VaSd22U_!!6000000004867-0-tps-480-480.jpg",
},
{
name: "开放平台",
url: "https://img.alicdn.com/imgextra/i4/O1CN016JIoXg1lMHYbmErdR_!!6000000004804-0-tps-240-240.jpg",
},
{
name: "宜搭",
url: "https://img.alicdn.com/imgextra/i3/O1CN011GyvMe1of0bAveV4u_!!6000000005251-0-tps-480-480.jpg",
},
{
name: "钉闪会",
url: "https://img.alicdn.com/imgextra/i2/O1CN01zFSNcP26wYrM09A4S_!!6000000007726-0-tps-480-480.jpg",
},
{
name: "连接器",
url: "https://img.alicdn.com/imgextra/i2/O1CN0142F9wc23s261dItxf_!!6000000007310-0-tps-480-480.jpg",
},
{
name: "酷应用",
url: "https://img.alicdn.com/imgextra/i4/O1CN012wi8vZ1xt3HbO0ttd_!!6000000006500-0-tps-480-480.jpg",
},
];
const orderMap = {
0: 0,
1: 1,
2: 2,
3: 3,
4: 2,
5: 1,
6: 0,
7: 0,
8: 1,
9: 2,
10: 3,
11: 2,
12: 1,
13: 0,
};
// 动画曲线 - 根据传入的横坐标计算对应的纵坐标(value)
function createAnimation(
scrollstart: number,
scrollEnd: number,
valueStart: number,
valueEnd: number
) {
return function (scroll: number) {
if (scroll <= scrollstart) {
return valueStart;
}
if (scroll >= scrollEnd) {
return valueEnd;
}
return (
valueStart +
((valueEnd - valueStart) * (scroll - scrollstart)) /
(scrollEnd - scrollstart)
);
};
}
// 映射 - dom => {}
const animationMap = new Map();
function getDomAnimation(scrollStart: number, scrollEnd: number, dom: any) {
scrollStart = scrollStart + dom.dataset.order * 600;
const opacityAimation = createAnimation(scrollStart, scrollEnd, 0, 1);
const opacity = function (scroll: number) {
return opacityAimation(scroll);
};
const xAnimation = createAnimation(
scrollStart,
scrollEnd,
animationRef.value.clientWidth / 2 - dom.offsetLeft - dom.clientWidth / 2,
0
);
const yAnimation = createAnimation(
scrollStart,
scrollEnd,
animationRef.value.clientHeight / 2 - dom.offsetTop - dom.clientHeight / 2,
0
);
const scaleAnimation = createAnimation(scrollStart, scrollEnd, 0.5, 1);
const transform = function (scroll: number) {
return `translate(${xAnimation(scroll)}px, ${yAnimation(
scroll
)}px) scale(${scaleAnimation(scroll)})`;
};
return {
opacity,
transform,
};
}
// 更新map
function updateMap() {
// 每次调用时先将之前的清除掉,窗口大小等发生改变时重新处理
animationMap.clear();
const playGroundRect = playground.value.getBoundingClientRect();
const scrollStart = playGroundRect.top + window.scrollY;
const scrollEnd = playGroundRect.bottom + window.scrollY - window.innerHeight;
for (const item of listRef.value.children) {
animationMap.set(item, getDomAnimation(scrollStart, scrollEnd, item));
}
}
// 更新dom的样式,遍历map给每个dom元素设置样式(透明度、偏移量、放缩....)
function updatestyles() {
const scroll = window.scrollY;
for (let [dom, value] of animationMap) {
for (const cssProp in value) {
dom.style[cssProp] = value[cssProp](scroll);
}
}
}
// 初始时调用一次
updatestyles();
// 监听滚动条事件
window.addEventListener("scroll", updatestyles);
</script>
<style lang="scss" scoped>
.main-conteiner {
width: 100%;
height: 100%;
}
.logo-title {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 800px;
margin-top: 150px;
.logo {
width: 200px;
height: 200px;
img {
display: block;
width: 100%;
}
}
.title {
width: 800px;
height: 100px;
margin-top: 50px;
img {
display: block;
width: 100%;
background: #040506;
}
}
}
.playground {
width: 100%;
height: 3000px;
.animation-container {
position: sticky;
top: 0;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
background: #040506;
height: 710px;
.list {
display: flex;
flex-wrap: wrap;
width: 1200px;
height: 460px;
background: #15171b;
border-radius: 20px;
padding: 100px;
box-sizing: border-box;
}
.list-item {
width: 80px;
height: 80px;
margin-right: 73.333px;
img {
display: block;
width: 80px;
height: 80px;
border-radius: 20px;
}
}
.mr-0 {
margin-right: 0;
}
.mt-100 {
margin-top: 100px;
}
}
}
.last-area {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 800px;
background: #0a0610;
img {
display: block;
width: 200px;
margin-right: 20px;
}
span {
display: inline-block;
line-height: 60px;
font-size: 23px;
font-weight: 700;
color: #fff;
}
}
</style>
转载自:https://juejin.cn/post/7258810540939411514