尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

漂流瓶前端特效源码解析:从jQuery动画到事件驱动的完整实践

漂流瓶前端特效源码解析:从jQuery动画到事件驱动的完整实践 简介漂流瓶效果源码是一份面向Web前端学习者与互动效果开发者的完整实践项目模拟QQ漂流瓶的投掷、漂浮与捞取流程覆盖事件驱动、随机算法、动画反馈等关键交互设计且不依赖复杂后端环境适合本地直接调试。整套资源共39个文件主要以png美术素材、js脚本及gif动图组成丰富的皮肤图片可直接替换配合页面入口、样式表和jQuery脚本即可运行压缩包仅321KB结构轻量。已有557人学习适合用于理解JavaScript事件监听、DOM动画、随机捞取逻辑以及多皮肤切换的实现思路也可作为课堂演示或入门交互组件的拆解范例。在短小体积内呈现出完整的漂流瓶交互闭环用户点击投掷、瓶子漂入海洋、捞取时触发不同皮肤反馈这些流程都可在源码中逐行追踪源码中附带LICENSE与README.md结合salvage、skin、bg等系列图片命名可清晰对应不同漂流瓶被捞起时的反馈状态与场景皮肤方便二次开发或改造为独立社交组件。1. 漂流瓶效果源码一个把“随机”和“动画”玩明白的前端小项目这个资源包不大但非常值得参考。它用 jQuery 1.11.0 搭了一个完整的漂流瓶效果bottle.htm 是入口bottle.js 负责事件和动画bottle.css 控制层级与皮肤再加上一堆海浪、沙滩、瓶子、救生圈图片组成了“投掷—漂流—捞取”的完整交互链路。用户点击投掷后瓶子落到海面并随机漂走点击海面上的瓶子还能捞起来看到内容。这个 demo 把事件驱动、随机算法、分层动画、资源预加载和皮肤扩展都串在了一起正好是很多中小型前端项目都要面对的典型组合。适合想研究 jQuery 时代交互写法、或者想把这类效果移植到 Vue/React 里的开发者。2. 从文件清单到事件驱动漂流瓶的状态是怎么被管起来的2.1 先拆开 bottle.rar哪些文件是骨架哪些是皮肤打开压缩包先别急着双击 bottle.htm先把文件分成几组。下面是我对照 README 和文件命名习惯做的分类表分类文件作用入口bottle.htm页面结构加载 CSS 和 JS逻辑bottle.js事件绑定、投掷/捞取逻辑、随机算法样式bottle.css布局、层级、动画关键帧底层库jquery-1.11.0.min.js选择器、事件、动画封装背景资源bg_sand1e9c5d.png、bg_cloud1e9c5d.png、wave_bg1e9c5d.png沙滩、云朵、海浪分层背景瓶子资源salvage.png、salvage2.png、salvage4.png 等瓶子不同方向和状态加载与皮肤loading_cover1e9c5d.gif、skin_island31e9c5d.png 等加载遮罩、岛屿/螃蟹/圣诞船/贝壳皮肤提示文件里的1e9c5d是缓存指纹后缀改动图片内容后换一个后缀就能强制浏览器重新拉取这是我在旧项目里常用的做法。jQuery 1.11.0 对应 IE9 时代API 稳定on()、animate()、deferred都可用。现在的项目不一定需要引入它但读这份源码时要注意.bind()和.on()混用的情况事件绑定方式不同解绑的写法也不一样。这个版本里$.ajax的success/error回调已经可以替换成done/fail但源码中如果还在用旧写法迁移时要一并处理。2.2 事件绑定从点击按钮到“投掷”状态漂流瓶的核心不是瓶子图片而是点击这个动作触发的一组状态变化。bottle.js 里最基础的一段逻辑大概长这样$(function () { var state idle; // idle 空闲中, throwing 投掷中, drifting 漂流中, picked 已捞起 $(#btn_bottle_new).on(click, function () { if (state ! idle) return; // 防重复触发 state throwing; var $bottle $(div classbottle-item/div); $bottle.append(span classbottle-text/span); $(#sea_area).append($bottle); startDrift($bottle, function () { state drifting; }); }); // 事件委托点击海面中的瓶子 $(#sea_area).on(click, .bottle-item, function () { if (state picked) return; state picked; showMessage($(this)); }); });这里用state字符串做状态机比单纯用布尔值清晰idle允许投掷throwing锁住按钮drifting允许被点击picked防止重复捞取。$(#sea_area).on(click, .bottle-item, ...)是事件委托瓶子是动态生成的直接绑在#sea_area上只绑一次即可新瓶子自动生效。参数里click是事件类型.bottle-item是过滤条件回调中的this指向实际点击的瓶子。注意on(click)与$(#btn).click()的区别前者能传命名空间click.throw方便off(click.throw)定向解绑后者不能。这个细节在排查重复绑定时会省很多时间。2.3 数据与随机性瓶子内容从哪里来demo 里没有后端瓶子内容一般放在一个数组里用一个随机索引取出。为了模拟“别人扔的瓶子”我会在 localStorage 里存一份投掷记录捞取时优先捞别的用户留下的内容function getBottleData() { var localBottles JSON.parse(localStorage.getItem(bottles) || []); var seedPool [ { id: 1, msg: 今天海风很温柔 }, { id: 2, msg: 找到一个旧瓶子 } ]; var pool localBottles.length ? localBottles : seedPool; return pool[Math.floor(Math.random() * pool.length)]; }Math.floor(Math.random() * pool.length)保证索引落在数组范围内。Math.random()返回 [0,1)不乘长度会拿到 undefined。把本地投掷记录放进 localStorage 是为了让“捞到自己的瓶子”这个情况也能出现真实项目中这里应该是后端接口的返回值。数据字段要预留id、msg、timestamp以后接 API 时直接映射不需要改视图层代码。3. 海浪与瓶子的随机漂流动画层怎么搭才不会乱3.1 三层背景沙滩、海浪、云朵的层级与运动漂流瓶效果看起来“海在动”其实是由多个静态图片以不同速度平移拼出来的。常见的做法是三层沙滩固定在最底层海浪作为中间层循环移动云朵用更慢的速度飘。下面这个表格是我从 bottle.css 里提炼出的分层职责层元素定位方式动画时间沙滩#sea_area背景repeat-x bottom无静态海浪.waveabsolute 底部translateX循环8s云朵.cloudabsolute 顶部translateX视差30s瓶子.bottle-hitabsolute 百分比随机轨迹6~12s看代码。三层背景的关键在于不要把海浪和云朵都放在同一个元素里否则视觉上会粘在一起#sea_area { position: relative; overflow: hidden; background: url(bg_sand1e9c5d.png) repeat-x bottom; } .wave { position: absolute; left: 0; right: 0; height: 120px; background: url(wave_bg1e9c5d.png) repeat-x; animation: waveMove 8s linear infinite; } .cloud { position: absolute; background: url(bg_cloud1e9c5d.png) no-repeat; animation: cloudMove 30s linear infinite; } keyframes waveMove { from { transform: translateX(0); } to { transform: translateX(-200px); } }这里把背景拆成三个 DOM 层不直接改变left而是用transform: translateX移动。原因在于left变化会触发布局layout而transform只触发合成composite帧率更稳。repeat-x配合translateX(-200px)刚好露出一张图宽度的一半视觉上形成无缝循环。云朵的30s比海浪的8s慢很多制造近快远慢的视差效果。如果你在 bottle.css 里看到的是 jQuery.animate({left: -200})的写法效果一样但性能差一些换成 CSS animation 后JavaScript 只需要负责投掷和捞取动画交给浏览器合成器。3.2 瓶子投掷与漂浮随机坐标和时间延迟瓶子的运动路线不能每次都一样否则会显得很“假”。我一般会把随机范围拆成三段初始 X、漂浮 Y 抖动、延迟时间。这样每次生成的对象都不同但又不至于完全失控function getRandomArbitrary(min, max) { return Math.random() * (max - min) min; } function startDrift($bottle, callback) { var startX getRandomArbitrary(20, 80); // 投掷起点单位百分比 var driftY getRandomArbitrary(10, 30); // 上下浮动范围 var duration getRandomArbitrary(6000, 12000); // 漂流动画时长 $bottle.css({ left: startX %, top: 70% }); $bottle.animate( { top: 60%, left: -10% }, { duration: duration, easing: linear, step: function (now, fx) { if (fx.prop top) { // 在水平移动的同时叠加垂直正弦波 $(this).css(margin-top, Math.sin(Date.now() / 300) * driftY px); } }, complete: function () { $(this).remove(); if (callback) callback(); } } ); }参数说明getRandomArbitrary(min, max)把Math.random()映射到业务区间避免每次写一长串运算。startX用百分比所以不受屏幕宽度影响driftY是正弦波的振幅振幅太大会让瓶子飘出容器控制在正负 30 像素以内。duration6 到 12 秒小于 5 秒用户会来不及点击大于 15 秒又会失去等待耐心。step回调在动画每一帧执行fx.prop能区分当前变化的属性这里只对top加偏移避免干扰水平位置。这版手写正弦波是兼容 jQuery 1.11 的做法新项目中可以直接把margin-top换成transform: translateY()并且用requestAnimationFrame驱动。3.3 捞取交互点击命中区域与消息展示瓶子在海上漂点击区域不能只算图片本身。图片有透明边直接用img元素绑 click 会点不到。我习惯在外面套一个.bottle-hit的容器把可点击区域放大div classbottle-hit stylewidth:80px;height:90px; img srcsalvage.png altbottle /div.bottle-hit { position: absolute; cursor: pointer; z-index: 10; display: flex; align-items: center; justify-content: center; } .bottle-hit img { pointer-events: none; /* 事件穿透到容器 */ max-width: 70px; max-height: 80px; }pointer-events: none是关键点击图片上的任意一点都会落到容器上避免因为图片透明区域太小导致误触。做移动端适配时.bottle-hit的宽高可以再增加到 100×110提高触屏命中率。捞到瓶子后弹出的消息层记得在显示前清掉上一次的定时器function showMessage($bottle) { var data getBottleData(); var $modal $(#bottle_modal); $modal.find(.msg-content).text(data.msg); $modal.show(); clearTimeout(window.messageTimer); window.messageTimer setTimeout(function () { $modal.hide(); }, 3000); }clearTimeout放在showMessage开头防止用户快速连续捞两个瓶子时前一个消息还没消失就被后一个覆盖。text()而不是html()插入消息内容可以避免用户在瓶子里塞 HTML 造成 XSS这个习惯在接后端数据时尤其重要。4. 从 demo 到可复用组件预加载、性能与多皮肤扩展4.1 图片预加载避免海浪和瓶子“闪白”第一次打开页面时bg_sand、salvage 等 PNG 可能还在下载海面会出现白块。bottle.rar 里有loading_cover1e9c5d.gif说明原项目是有加载遮罩的。常见做法是先把所有图片预加载完再显示主界面var preloadList [ bg_sand1e9c5d.png, bg_cloud1e9c5d.png, wave_bg1e9c5d.png, salvage.png, salvage2.png ]; $.when.apply($, preloadList.map(function (src) { return $.Deferred(function (dfd) { var img new Image(); img.onload dfd.resolve; img.onerror dfd.resolve; // 加载失败也继续 img.src src; }).promise(); })).done(function () { $(#loading_cover).fadeOut(); });$.when.apply收集所有 Deferred 对象全部 resolve 后才执行done。注意onerror里也调用了resolve因为单个图片缺失不应该阻塞整个页面加载。资源列表如果很长可以只预加载首屏可见的图后续在需要时再懒加载loading_cover1e9c5d.gif负责兜底。这个模式比$(window).on(load)更可控因为 load 事件要等所有资源包括不用的字体和广告脚本。这里的 map 返回的是 promise 数组$.when.apply能把数组展开成多个参数如果用$.when(preloadList)会被当成一个参数无法正确等待所有图片。4.2 性能优化把 jQuery 动画换成 CSS transform原资源基于 jQuery 1.11 的.animate()写起来方便但动画性能在现代浏览器里不如 CSS。我们可以做一个小改造用 CSS 变量或 class 切换来驱动瓶子漂移下面是改造后的核心片段。function startDriftModern($bottle, callback) { var startX getRandomArbitrary(20, 80); var duration getRandomArbitrary(6000, 12000); $bottle.css({ left: startX %, transition: left duration ms linear, transform duration ms linear }); requestAnimationFrame(function () { $bottle.css({ left: -10%, transform: translateY( getRandomArbitrary(-30, 30) px) }); }); $bottle.one(transitionend, function () { $bottle.remove(); callback callback(); }); }这段代码的逻辑是先给瓶子设置初始位置然后通过requestAnimationFrame在下一帧把目标位置写进去让浏览器补间。transitionend事件结束时回调由于只需触发一次所以用one而不是on。注意requestAnimationFrame必须放在设置初始位置之后不然浏览器可能把两次样式合并成一次动画会直接跳到终点。对比一下两种方案方案帧率表现代码量兼容性适用场景jQuery.animate()30~50 fps依赖 JS 定时器少IE9旧项目、快速 demoCSStransition RAF60 fps合成器处理中IE10移动端 H5、现代浏览器Web Animations API60 fps可暂停回溯中新浏览器需要精确控制时间轴的复杂效果如果目标是重做一版优先选 CSS transition如果要保持源码原样可以只给.bottle-hit加上will-change: transform让浏览器提前做合成层优化。4.3 多皮肤把图片路径抽成配置压缩包里出现了skin_island31e9c5d.png、skin_crab1e9c5d.png、skin_boat_christmas1e9c5d.png、skin_shells1e9c5d.png说明原项目预留了换肤功能。与其把src写死在 HTML 里不如定义一套皮肤配置var SKINS { island: { name: 岛屿, bottle: salvage.png, skin: skin_island31e9c5d.png, bg: bg_sand1e9c5d.png }, crab: { name: 螃蟹, bottle: salvage4.png, skin: skin_crab1e9c5d.png, bg: bg_sand1e9c5d.png }, christmas: { name: 圣诞船, bottle: salvage8.png, skin: skin_boat_christmas1e9c5d.png, bg: bg1e9c5d.png }, shells: { name: 贝壳, bottle: salvage10.png, skin: skin_shells1e9c5d.png, bg: bg_sand1e9c5d.png } };切换皮肤时只需要操作两个 DOM 位置#sea_area的背景图以及当前瓶子的img标签。把皮肤配置挂在window.SKINS上可以作为组件对外暴露的接口。以后新增皮肤不用改主逻辑往SKINS里加一个对象就行。这也是“数据驱动视图”在原生 JS 里的最小示例所有可变内容都收敛成对象更新时只改状态DOM 由渲染函数负责。这里我故意用var而不是const是为了保持和 jQuery 1.11 时代的代码风格一致迁移到 ES6 时再统一替换。5. 验证动画性能与排查资源加载的三个实用技巧5.1 用 Chrome Performance 面板确认卡顿点在 bottle.htm 打开的状态下按 F12 切到 Performance 面板点击录制按钮后连续投掷 3 个瓶子停掉录制。观察时间轴里是否存在长任务超过 50ms 的块。如果能看到大量jQuery.animate产生的 setInterval 回调就说明动画跑在 JavaScript 线程里优化方向就是把动画迁移到 CSS transition。另一个更快的检查点是 Rendering 选项卡里勾选 “Paint flashing”然后让瓶子漂移。如果海面每一帧都有大片绿色高亮说明在重复绘制波动的背景需要检查.wave是否触发了重绘。正常的优化效果是只有瓶子区域在变化。5.2 常见问题排查表现象原因处理方式点击“投掷”没反应按钮 id 不存在或事件重复执行被 state 拦掉在控制台打印$(#btn_bottle_new).length再看 state 当前值瓶子位置随窗口缩放变乱横向百分比 纵向 px 混用统一用百分比定位或监听 resize 重新计算捞取时消息弹不出来点击区域被背景层挡住检查.bottle-hit的z-index和背景层的position图片出现闪烁大图没有预加载或加载失败在 Network 面板看 404 文件核对图片名称中的1e9c5d后缀重复动画造成内存上涨瓶子动画结束没移除 DOM在complete或transitionend里调用.remove()5.3 一段自检脚本确认所有图片资源完整把下面这段放到 console 执行可以快速找出缺失的图片(function checkImages() { var imgs document.querySelectorAll(img); var missing []; imgs.forEach(function (img) { if (!img.complete || img.naturalWidth 0) { missing.push(img.currentSrc || img.src); } }); console.log(missing.length ? 缺失资源: missing.join(\n) : 所有图片加载正常); })();img.complete为 true 只代表加载流程走完不表示成功naturalWidth 0才是判断失败的最可靠标准。这段脚本对任何图片站都适用改一下选择器就能复用。检查完图片后再看 Network 面板里有没有红色的失败请求如果文件名里的1e9c5d后缀和 HTML 里引用不一致也会出现同样的 404。本文还有配套的精品资源点击获取
返回列表