前言
愚人节的时候,看到了清华镜像愚人节的小彩蛋,觉得挺有意思的,然后简单复刻了一下。没有100%还原,轻喷交流。 创作不易, 收藏点赞。
代码仓库以及效果演示
码上掘金
建议收起代码块使用
代码仓库
throw_tomatoes: vue3 + vite6.0,复刻2025清华源愚人节扔番茄砸中元素的彩蛋。
动图演示
愚人节彩蛋
清华大学开源软件镜像站 | Tsinghua Open Source Mirror 在愚人节当天更新了一个小彩蛋: 当点击页面时,会扔出一个完整的番茄,类似坠落位移一段距离后砸在页面上变成烂番茄,在砸落点触发点击事件, 而原本位置的点击事件失效。
思路分析
需要复刻这个彩蛋,涉及到毫无疑问是点击事件、动画以及元素定位。
F12打开控制台扔几个番茄可以看到:
思考一下,应该是点击时确定起始位置, 再通过一定的公式持续计算更新番茄的运动路径和大小更新transform模拟出坠落的感觉。
核心代码以及思路
throwTomatoes.vue 扔番茄页面
<template>
<div class="layout-ctn" @click="handleClick">
<div style="display: flex; flex-wrap: wrap;">
<TargetEl style="width: calc(10% - 2px) ;" v-for="(i) in 1000"></TargetEl>
</div>
<Tomato v-for="(node, nodeIndex) in list" :position="node.position" :key="node.id"></Tomato>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Tomato from '../../components/tomato.vue';
import TargetEl from '../../components/targetEl.vue';
const list = ref([]);
const handleClick = (event) => {
console.log(event)
// 如果不是由扔番茄引起的点击事件,则是人为的人点事件,新增一个番茄
if (event.detail !== 1002) {
list.value.push({
id: event.timeStamp,
position: {
clientX: event.clientX,
clientY: event.clientY,
pageX: event.pageX,
pageY: event.pageY
// x: event.pageX, y: event.pageY
}
});
}
}
</script>
这块主要做的处理:
-
布置靶子元素,用于显示扔出的番茄有没有命中触发点击事件。
-
handleClick监听点击事件,记录点击时候的位置,抛掷番茄。 此处记录了两种位置, 一种是pageX和pageY, 一种是clientX和clientY。 这两者的区别可以看一下 1.clientX和clientY- 定义:
clientX和clientY表示鼠标指针相对于浏览器视口(viewport)的水平和垂直坐标。视口是指浏览器中实际显示网页内容的区域,不包括浏览器的滚动条、地址栏等界面元素。
2.
pageX和pageY- 定义:
pageX和pageY表示鼠标指针相对于整个文档(document)的水平和垂直坐标,包括由于滚动而隐藏在视口之外的部分。也就是说,pageX和pageY会考虑页面的滚动位置。
简单总结
clientX、clientY和pageX、pageY最大的差别其实就是加不加上横向滚动条和纵向滚动条的距离。 - 定义:
tomato.vue 番茄组件
<template>
<div class="tomato-ctn"
:style="{ left: `${props.position.pageX}px`, top: `${props.position.pageY}px`, transform: transformText }">
<GoodTomato v-if="!isBroken" class="good-tomato">
</GoodTomato>
<BadTomato class="bad-tomato" v-else :style="{ transform: `scale(${scale * 3})` }"></BadTomato>
<div v-if="isMiss" class="unhit-tips" :style="{ transform: `scale(${1 / scale})` }">
未命中!!
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref, toRaw } from 'vue';
import GoodTomato from '../assets/tomato_good.svg'; // 完好的番茄
import BadTomato from '../assets/tomato.svg'; // 坏掉的番茄
const props = defineProps({
position: {
default: () => {
return {
clientX: -100,
clientY: -100,
pageX: -100,
pageY: -100
}
}
}
})
const x = ref(props.position.pageX); // 元素当前的pageX
const y = ref(props.position.pageY); // 元素当前的pageY
const eX = ref(0); // 元素最终的位置pageX
const eY = ref(0); // 元素最终的位置pageY
const pageX = ref(0); // 点击时候的pageX
const pageY = ref(0); // 点击时候的pageY
const scale = ref(1); // 缩放倍率
const isBroken = ref(false); // 控制番茄样式
/**
* 模拟从点击点到目标点的抛物线轨迹(可调节弯度)
* @param {number} y1 - 当前纵向坐标
* @param {number} startY - 起始点的纵坐标 (y)
* @param {number} targetX - 目标点的横坐标 (x ± 20)
* @param {number} startX - 起始点的横坐标 (x)
* @param {number} [curvature=4] - 控制抛物线弯度的参数(值越大,弯度越明显)
* @returns {number} x1 - 对应纵坐标 y1 的横坐标
*/
function parabolicTrajectory(y1, startY, targetX, startX, curvature = 4) {
// 计算纵向总距离
const totalYDistance = 100; // y + 200 - y = 200
// 计算当前纵向位置占总距离的比例 (0 到 1)
const t = (y1 - startY) / totalYDistance;
// 如果 y1 不在起始点和目标点之间,返回边界值
if (t <= 0) return startX;
if (t >= 1) return targetX;
// 计算横向总距离(可能是正或负,取决于目标点在左还是右)
const totalXDistance = targetX - startX;
// 使用抛物线方程 x = startX + totalXDistance * t + (调整弯度的二次项)
// 二次项系数 curvature 控制弯度(默认 4,值越大弯度越明显)
const x1 = startX + totalXDistance * t + (-curvature * totalXDistance * t * (1 - t));
return x1;
}
// 位置偏移量
const transformText = computed(() => {
// 偏移量为: 当前坐标 - 起始坐标 - 32(元素的长宽为64,以中心为基点,所以减去32)
return `translate(${pageX.value -props.position.pageX - 32}px, ${pageY.value -props.position.pageY - 32}px) scale(${scale.value})`;
})
const timer = ref(); // 动画的timer
const targetEl = ref(); // 最终集中的目标元素
const isMiss = ref(false); // 是否命中
// 命中
const attack = () => {
// 当元素存在的时候
if (targetEl.value) {
// 创建一个原生的鼠标点击事件
const clickEvent = new MouseEvent('click', {
bubbles: true, // 事件是否冒泡
cancelable: true, // 事件是否可以取消
view: window, // 关联的窗口对象
clientX: x.value, // 指定的 X 坐标(相对于视口)
clientY: y.value, // 指定的 Y 坐标(相对于视口)
detail: 1002 // 用于区分时间是 代码触发 还是 人为触发
});
// 触发原生点击事件
targetEl.value.dispatchEvent(clickEvent);
} else {
console.log('没有找到对应坐标的元素');
isMiss.value = true; // 没有命中
}
}
// 开始动画
const startAnimate = () => {
const oneStep = (eY.value - y.value) / 200; // 分 200步 执行
const oneScaleStep = (0.6) / 200; // 分 200步 执行
timer.value = setInterval(() => {
// 当没有到达终点位置的时候持续更新
if (pageY.value + oneStep < eY.value) {
pageY.value = pageY.value + oneStep;
scale.value = scale.value - oneScaleStep;
pageX.value = parabolicTrajectory(pageY.value, props.position.pageY, eX.value, props.position.pageX, 15);
}
else {
timer.value && clearInterval(timer.value);
timer.value = null;
isBroken.value = true; // 更新为烂番茄
attack(); // 触发命中事件
}
}, 10);
}
onMounted(() => {
// 初始化pageX和pageY
pageX.value = props.position.pageX;
pageY.value = props.position.pageY;
//let randomX = parseInt(Math.random() * 200 - 100);
// x的随机偏移量, ±30 偏移一下
let randomXMius = parseInt(Math.random() * 60 - 30);
// 终止位置的pageX和pageY的值
eX.value = pageX.value + randomXMius;
eY.value = pageY.value + 100;
// 计算当前终止的clientX和ClientY, 用于获取点击时最终击中的目标元素
const currentEndClientX = props.position.clientX + randomXMius;
const currentEndClientY = props.position.clientY + 100;
targetEl.value = document.elementFromPoint(currentEndClientX, currentEndClientY);
// 开始动画
startAnimate();
})
</script>
<style lang="less" scoped>
.good-tomato {
width: 64px;
height: 64px;
animation: rotateScale .3s infinite ease;
}
.bad-tomato {
width: 64px;
height: 64px;
}
.tomato-ctn {
width: 64px;
height: 64px;
/* 应用动画 */
position: absolute;
}
.unhit-tips {
color: red;
position: absolute;
top: -24px;
text-align: center;
width: 100px;
user-select: none;
animation: tipAnimation .8s ease-in-out forwards;
}
@keyframes tipAnimation {
0% {
top: -24px;
opacity: 1;
}
40% {
top: -38px;
opacity: 0.5;
}
80% {
top: -50px;
opacity: 0.1;
}
100% {
display: none;
}
}
/* 定义动画 */
@keyframes rotateScale {
0% {
transform: rotate(0deg);
/* 初始状态:无旋转,正常大小 */
}
50% {
transform: rotate(180deg);
/* 中间状态:旋转180度,放大1.5倍 */
}
100% {
transform: rotate(360deg);
/* 结束状态:旋转360度,恢复原大小 */
}
}
</style>
主要思路是:在初始化的时候, 以传入的点击事件的位置(pageX, pageY)为起始值, 并提前决定好落点位置 eX 和 eY, 再把落点位置的Y值和起始位置的差值分200份, 即 const oneStep = (eY.value - y.value) / 200; // 分 200步 执行, 再使用setInterval每10毫秒执行一次更新番茄位置的Y值, 即 pageY.value = pageY.value + oneStep; 再通过曲线函数方法parabolicTrajectory函数,输入pageY获取到对应曲线路径pageX, 重复执行直至到达终点位置更新isBroken变量为true,将 完整番茄的SVG图像 置换为 烂番茄(破碎番茄)的SVG图像, 最后手动触发一个MouseEvent表示砸中后点点击事件, 即
// 创建一个原生的鼠标点击事件
const clickEvent = new MouseEvent('click', {
bubbles: true, // 事件是否冒泡
cancelable: true, // 事件是否可以取消
view: window, // 关联的窗口对象
clientX: x.value, // 指定的 X 坐标(相对于视口)
clientY: y.value, // 指定的 Y 坐标(相对于视口)
detail: 1002 // 用于区分时间是 代码触发 还是 人为触发
});
// 触发原生点击事件
targetEl.value.dispatchEvent(clickEvent);
这个组件主要做了以下的注意点:
- 因为使用了
document.elementFromPoint(x,y)获取最终下落点击中的元素, 坐标(x, y)是基于视口的,而不是整个页面。 在组件初始化的时候就通过点击事件的(clientX, clientY)加上偏移量提前获取到番茄击中的元素targetEl, 并在击中后触发点击事件。 - 番茄组件 应该使用
absolte和(pageX,pageY) 来确定番茄的位置, 而不是使用fixed和(clientX,clientY)来确定,两者的区别是一个是基于整个页面的定位,一个是基于视口(viewpoint) 的定位。 当页面存在滚动条的时候, 就能看到问题了。 - 在创建番茄砸中的点击事件 MouseEvent 的时候, 传入了参数
detail: 1002, 用表示是番茄砸中触发的点击事件,与用户点击的事件区分开来。 为什么需要区分呢? 番茄的生成是由用户发起的点击事件生成的, 如果不区分, 番茄砸中的点击事件也会生成一个番茄,砸中后再触发生成一个,无限循环,可以注释一下试试看。 parabolicTrajectory函数确定了番茄 弧形运动的曲线, 可以通过参数来调整弧度, 这个我是让deepSeek帮我写的。- 使用了一个动画
rotateScale作为 完整番茄的SVG图像 的旋转动画。
targetEl.vue 靶子元素
主要用于响应番茄砸中触发的点击事件
<!--
* @Author: Damon Liu
* @Date: 2025-04-07 10:59:20
* @LastEditors: Damon Liu
* @LastEditTime: 2025-04-09 13:46:46
* @Description: 用于被标记是否砸中的元素
-->
<template>
<div :class="['target-el-ctn', isAttacked ? 'attcked-target-ctn' : ''] " @click="handleClick">
{{ !isAttacked ? '未命中' : '命中!' }}
</div>
</template>
<script setup>
import { ref } from 'vue'
const isAttacked = ref(false); // 是否被命中
const handleClick = (event) => {
console.log(event)
if(event.detail === 1002) {
// 当事件为扔番茄击中时停止冒泡
// 但其实不停止也无所谓,在最顶层的时候做判断也一个样
event.stopPropagation();
isAttacked.value = true
}
}
</script>
<style lang="less" scoped>
.target-el-ctn {
border: 1px solid #eee;
color: #fff;
background: #7bcda8;
user-select: none;
}
.attcked-target-ctn {
background: #42b883;
color: #fff;
}
</style>
只响应 detail: 1002 的点击事件, 即番茄砸中触发的点击事件。
总结和优化
挺好玩的一个小彩蛋,包含动画(transform,setInterval)、点击事件(MouseEvent:click)、视口距离(clientX, clientY) 和页面距离(pageX, pageY), 曲线运动等内容和知识点。 实际做下来也挺好玩, 就是在曲线上没有很好的模拟到那种坠落感,也想过可以劫持元素的点击事件,而不需要做成这种形式, 动画形式也许也可以尝试使用requestAnimationFrame的形式复写一下。还是有很多可以进一步优化的空间。
创作不易,点赞收藏!
参考来源
完整番茄的SVG来源: iconfont-阿里巴巴矢量图标库
破碎番茄的SVG来源: throw_tomatoes: vue3 + vite6.0,复刻2025清华源愚人节扔番茄砸中元素的彩蛋。