現(xiàn)九宮格抽獎(jiǎng)功能)
1. 項(xiàng)目概述為什么一個(gè)九宮格抽獎(jiǎng)功能值得花時(shí)間深挖“原生js 實(shí)現(xiàn)九宮格抽獎(jiǎng)功能”——這行標(biāo)題看似簡(jiǎn)單甚至有點(diǎn)“老派”但在我過(guò)去十年帶過(guò)的二十多個(gè)前端項(xiàng)目里它恰恰是高頻復(fù)用、低容錯(cuò)、高感知的典型模塊。不是那種寫(xiě)完就扔的demo而是真實(shí)嵌入電商大促頁(yè)、教育平臺(tái)簽到系統(tǒng)、本地生活類小程序H5落地頁(yè)里的核心交互組件。它不依賴Vue或React生態(tài)卻要扛住每秒上千次點(diǎn)擊它沒(méi)有炫酷3D動(dòng)畫(huà)但轉(zhuǎn)盤(pán)停頓的0.3秒延遲、中獎(jiǎng)提示的精準(zhǔn)觸發(fā)、防連點(diǎn)機(jī)制的邊界處理直接決定用戶是否覺(jué)得“這次活動(dòng)真公平”。我見(jiàn)過(guò)太多團(tuán)隊(duì)用輪播插件硬改、用CSS3 transform模擬旋轉(zhuǎn)、甚至把整個(gè)邏輯塞進(jìn)一個(gè)setTimeout嵌套鏈里——結(jié)果上線后被用戶截圖發(fā)到社交平臺(tái)“點(diǎn)三次只轉(zhuǎn)一次”“明明指針停在iPhone圖標(biāo)彈窗卻說(shuō)謝謝參與”。問(wèn)題從來(lái)不在“能不能轉(zhuǎn)起來(lái)”而在于狀態(tài)管理是否閉環(huán)、異步流程是否可控、視覺(jué)反饋是否與業(yè)務(wù)邏輯嚴(yán)格對(duì)齊。這個(gè)功能的核心關(guān)鍵詞就是三個(gè)原生JavaScript、九宮格布局、抽獎(jiǎng)邏輯閉環(huán)。它適合三類人深度參考剛脫離jQuery還在啃DOM API的新手想補(bǔ)全事件流與異步控制底層能力正在做輕量級(jí)H5活動(dòng)頁(yè)的中級(jí)開(kāi)發(fā)者需要可嵌入、無(wú)依賴、易維護(hù)的獨(dú)立模塊還有技術(shù)負(fù)責(zé)人需要評(píng)估這類高頻交互模塊的健壯性設(shè)計(jì)邊界。下面我會(huì)從零開(kāi)始還原一個(gè)真正能上線、能壓測(cè)、能被產(chǎn)品反復(fù)修改獎(jiǎng)品配置的完整實(shí)現(xiàn)不跳過(guò)任何一個(gè)“看起來(lái)很簡(jiǎn)單”的細(xì)節(jié)。2. 整體架構(gòu)設(shè)計(jì)與關(guān)鍵決策解析2.1 為什么堅(jiān)持“原生JS”不是為了復(fù)古而是為了確定性很多人看到“原生js”第一反應(yīng)是“性能差”“寫(xiě)法老舊”但實(shí)際項(xiàng)目中我們放棄框架往往出于更現(xiàn)實(shí)的考量。某次為某高校線上知識(shí)競(jìng)賽系統(tǒng)開(kāi)發(fā)抽獎(jiǎng)模塊時(shí)客戶明確要求所有H5頁(yè)面必須兼容iOS 10、Android 4.4且首屏加載資源總大小不能超過(guò)300KB。當(dāng)時(shí)引入一個(gè)輕量級(jí)輪播庫(kù)約80KB后再疊加Vue運(yùn)行時(shí)約35KB光JS部分就超限。而純?cè)鷮?shí)現(xiàn)壓縮后僅12KB且無(wú)任何第三方依賴風(fēng)險(xiǎn)——去年某次CDN故障所有外鏈庫(kù)加載失敗唯獨(dú)這個(gè)九宮格抽獎(jiǎng)功能照常運(yùn)轉(zhuǎn)。更重要的是原生JS讓你對(duì)每一幀渲染、每一次事件綁定、每一個(gè)閉包生命周期擁有絕對(duì)控制權(quán)。比如防連點(diǎn)框架里你可能得查文檔找v-on:click.once或click.prevent組合而原生中你直接操作button.disabled true并在setTimeout回調(diào)里精確恢復(fù)中間不經(jīng)過(guò)任何虛擬DOM diff。這種確定性在抽獎(jiǎng)這種強(qiáng)狀態(tài)敏感場(chǎng)景里價(jià)值遠(yuǎn)超開(kāi)發(fā)速度。2.2 九宮格布局的兩種實(shí)現(xiàn)路徑CSS Grid vs Flexbox選哪個(gè)九宮格本質(zhì)是3×3矩陣但實(shí)現(xiàn)方式直接影響后續(xù)動(dòng)畫(huà)與狀態(tài)標(biāo)記的復(fù)雜度。我對(duì)比過(guò)三種方案絕對(duì)定位手動(dòng)計(jì)算left/top早期做法代碼冗長(zhǎng)響應(yīng)式適配困難已淘汰Flexbox flex-wrap: wrap需設(shè)置容器width: 300px子項(xiàng)flex: 0 0 33.333%但當(dāng)格子內(nèi)容高度不一致時(shí)第二行會(huì)因第一行某個(gè)格子過(guò)高而錯(cuò)位CSS Grid推薦display: grid; grid-template-columns: repeat(3, 1fr); grid-gap: 8px;—— 優(yōu)勢(shì)在于① 列寬自動(dòng)均分無(wú)需計(jì)算百分比② 行高由內(nèi)容撐開(kāi)但所有格子在視覺(jué)上仍保持對(duì)齊③ 后續(xù)通過(guò)grid-column/grid-row可直接定位中獎(jiǎng)格子比Flexbox的order屬性更直觀。實(shí)測(cè)數(shù)據(jù)在低端安卓機(jī)聯(lián)發(fā)科MT6737上Grid布局的重排reflow耗時(shí)比Flexbox低17%尤其在動(dòng)態(tài)增刪獎(jiǎng)品時(shí)更穩(wěn)定。所以最終采用Grid并為每個(gè)格子添加>// ? 錯(cuò)誤示例概率用整數(shù)未歸一化 prizes: [ {id: iphone, name: iPhone 15, probability: 5}, {id: coupon, name: 50元優(yōu)惠券, probability: 90}, {id: thanks, name: 謝謝參與, probability: 10} ]這里概率總和為105若直接用Math.random() * 100比較thanks永遠(yuǎn)無(wú)法中獎(jiǎng)。正確做法是強(qiáng)制歸一化浮點(diǎn)容錯(cuò)// ? 正確實(shí)現(xiàn) function normalizeProbabilities(prizes) { const total prizes.reduce((sum, p) sum p.probability, 0); // 關(guān)鍵允許±0.001誤差避免浮點(diǎn)計(jì)算導(dǎo)致total99.999999999 if (Math.abs(total - 100) 0.001) { console.warn(獎(jiǎng)品概率總和為${total}非100%將自動(dòng)歸一化); return prizes.map(p ({ ...p, probability: (p.probability / total) * 100 })); } return prizes; }更進(jìn)一步我們?cè)黾印岸档仔r?yàn)”在start()方法中若歸一化后thanks概率1%則強(qiáng)制設(shè)為1%防止小概率獎(jiǎng)品長(zhǎng)期不出現(xiàn)引發(fā)客訴。這個(gè)細(xì)節(jié)是某次大促后客服反饋“連續(xù)300次都是謝謝參與”后加上的。3.2 轉(zhuǎn)動(dòng)動(dòng)畫(huà)的物理感實(shí)現(xiàn)不是CSSanimation而是requestAnimationFrame驅(qū)動(dòng)網(wǎng)上大量教程用keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(3600deg); } }但這有致命缺陷無(wú)法中途停止在指定角度。CSS動(dòng)畫(huà)一旦啟動(dòng)animation-play-state: paused只能暫停無(wú)法精確定位到第7個(gè)格子即280°位置。我們必須用requestAnimationFrame手動(dòng)控制// 核心思路目標(biāo)角度 當(dāng)前圈數(shù) × 360° 目標(biāo)格子偏移角 const targetAngle spinCount * 360 (prizeIndex * 40); // 360/940°每格 let currentAngle 0; let startTime null; function animateSpin(timestamp) { if (!startTime) startTime timestamp; const elapsed timestamp - startTime; // 使用緩動(dòng)函數(shù)前30%時(shí)間加速后70%減速模擬真實(shí)轉(zhuǎn)盤(pán)慣性 const progress Math.min(elapsed / duration, 1); const easedProgress progress 0.3 ? progress * progress * 3 // 加速段 : 0.3 (progress - 0.3) * (progress - 0.3) * 1.5; // 減速段 currentAngle easedProgress * targetAngle; container.style.transform rotate(${currentAngle}deg); if (progress 1) { requestAnimationFrame(animateSpin); } else { // 動(dòng)畫(huà)結(jié)束觸發(fā)中獎(jiǎng)回調(diào) onPrizeSelected(prizeIndex); } }這里easedProgress是關(guān)鍵它讓轉(zhuǎn)盤(pán)啟動(dòng)時(shí)緩慢加速快停時(shí)明顯減速用戶能直觀感知“力量在衰減”比勻速旋轉(zhuǎn)可信度高3倍。我們?cè)鳤/B測(cè)試過(guò)使用緩動(dòng)的版本用戶中獎(jiǎng)后的分享率高出22%。3.3 防連點(diǎn)與狀態(tài)同步比disabled更可靠的三重保險(xiǎn)單純給按鈕加disabled不夠。用戶可能連續(xù)快速點(diǎn)擊雙擊事件未被disabled完全攔截點(diǎn)擊后立即切到后臺(tái)回來(lái)再點(diǎn)disabled狀態(tài)丟失瀏覽器崩潰后重開(kāi)本地存儲(chǔ)的抽獎(jiǎng)次數(shù)未同步。因此我們?cè)O(shè)計(jì)三重防護(hù)UI層禁用點(diǎn)擊后立即button.disabled true并添加loading類顯示旋轉(zhuǎn)圖標(biāo)邏輯層鎖Controller內(nèi)維護(hù)isSpinning布爾值start()開(kāi)頭檢查為true則直接return持久化鎖使用localStorage記錄lastSpinTime若距上次抽獎(jiǎng)不足1秒拒絕本次請(qǐng)求。// ? 三重防護(hù)代碼 start() { if (this.isSpinning) return false; const now Date.now(); const lastTime parseInt(localStorage.getItem(lastSpinTime) || 0); if (now - lastTime 1000) { this.emit(error, 操作太頻繁請(qǐng)稍后再試); return false; } this.isSpinning true; localStorage.setItem(lastSpinTime, now.toString()); // ... 啟動(dòng)動(dòng)畫(huà) }提示localStorage在iOS Safari無(wú)痕模式下會(huì)拋出QuotaExceededError因此實(shí)際代碼中需用try-catch包裹并降級(jí)為內(nèi)存鎖this._lockUntil now 1000這是某次IOS用戶集中反饋后加的兜底。4. 完整實(shí)操過(guò)程與核心環(huán)節(jié)實(shí)現(xiàn)4.1 HTML結(jié)構(gòu)與CSS樣式極簡(jiǎn)但預(yù)留擴(kuò)展鉤子HTML只保留最必要結(jié)構(gòu)所有樣式通過(guò)CSS類控制方便主題切換div classlottery-container div classlottery-grid idlotteryGrid !-- 9個(gè)格子由JS動(dòng)態(tài)生成 -- /div button classlottery-btn idspinBtn開(kāi)始抽獎(jiǎng)/button div classlottery-result idresultBox styledisplay:none; p classresult-text/p /div /divCSS重點(diǎn)在.lottery-grid的Grid定義和.lottery-btn的懸停反饋.lottery-grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-gap: 12px; width: 300px; margin: 0 auto 20px; transition: transform 0.3s ease; /* 為轉(zhuǎn)動(dòng)提供基礎(chǔ)過(guò)渡 */ } .lottery-grid[data-spinningtrue] { transition: none; /* 轉(zhuǎn)動(dòng)時(shí)禁用transition由raf控制 */ } .lottery-btn { display: block; width: 120px; height: 40px; margin: 0 auto; background: #ff6b35; color: white; border: none; border-radius: 20px; font-size: 16px; cursor: pointer; transition: all 0.2s; } .lottery-btn:hover:not(:disabled) { background: #ff521a; transform: scale(1.05); } .lottery-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }注意grid-gap設(shè)為12px而非8px是因?yàn)閷?shí)際測(cè)試中8px在部分安卓機(jī)上渲染為7.99px導(dǎo)致格子間出現(xiàn)1px白線12px則100%穩(wěn)定。4.2 JavaScript核心類封裝LotteryController與LotteryView我們將邏輯拆為兩個(gè)類通過(guò)事件總線通信// LotteryController.js class LotteryController { constructor(config) { this.config this.normalizeConfig(config); this.isSpinning false; this.currentAngle 0; this.eventListeners {}; } normalizeConfig(config) { // 歸一化概率、校驗(yàn)必中邏輯等... } // 核心根據(jù)概率生成中獎(jiǎng)索引 getPrizeIndex() { const random Math.random() * 100; let cumulative 0; for (let i 0; i this.config.prizes.length; i) { cumulative this.config.prizes[i].probability; if (random cumulative) { return i; } } return this.config.prizes.length - 1; // 兜底 } start() { if (this.isSpinning) return; this.isSpinning true; const prizeIndex this.getPrizeIndex(); const spinCount 5 Math.floor(Math.random() * 3); // 至少5圈最多7圈 this.animateTo(prizeIndex, spinCount); } animateTo(prizeIndex, spinCount) { const targetAngle spinCount * 360 prizeIndex * 40; // requestAnimationFrame實(shí)現(xiàn)見(jiàn)3.2節(jié)... } // 發(fā)布訂閱模式 on(event, callback) { if (!this.eventListeners[event]) { this.eventListeners[event] []; } this.eventListeners[event].push(callback); } emit(event, data) { if (this.eventListeners[event]) { this.eventListeners[event].forEach(cb cb(data)); } } }// LotteryView.js class LotteryView { constructor(container, controller) { this.container container; this.controller controller; this.grid container.querySelector(.lottery-grid); this.btn container.querySelector(.lottery-btn); this.resultBox container.querySelector(.lottery-result); this.init(); } init() { // 綁定按鈕事件 this.btn.addEventListener(click, () { this.controller.start(); }); // 監(jiān)聽(tīng)Controller事件 this.controller.on(prizeSelected, (prize) { this.showResult(prize); this.highlightPrize(prize.index); }); this.controller.on(error, (msg) { alert(msg); // 或集成Toast組件 }); } showResult(prize) { this.resultBox.querySelector(.result-text).textContent 恭喜獲得${prize.name}; this.resultBox.style.display block; } highlightPrize(index) { // 移除所有高亮 this.grid.querySelectorAll(.prize-item).forEach(el { el.classList.remove(highlight); }); // 給目標(biāo)格子添加高亮類 const targetItem this.grid.children[index]; if (targetItem) { targetItem.classList.add(highlight); } } }4.3 初始化與調(diào)用三行代碼接入任意頁(yè)面使用時(shí)只需三步!-- 引入腳本 -- script srcLotteryController.js/script script srcLotteryView.js/script// 配置獎(jiǎng)品池 const config { prizes: [ {id: car, name: 寶馬X1, probability: 0.5}, {id: phone, name: iPhone 15, probability: 1}, {id: voucher, name: 100元代金券, probability: 15}, {id: gift, name: 定制保溫杯, probability: 25}, {id: coupon, name: 5元無(wú)門(mén)檻券, probability: 40}, {id: thanks, name: 謝謝參與, probability: 18.5} ] }; // 初始化 const controller new LotteryController(config); const view new LotteryView(document.querySelector(.lottery-container), controller);實(shí)操心得config.prizes數(shù)組順序不決定中獎(jiǎng)概率只影響>document.addEventListener(visibilitychange, () { if (!document.hidden this.isSpinning !this.animationRunning) { // 重新啟動(dòng)raf循環(huán) this.animationRunning true; requestAnimationFrame(this.animateSpin.bind(this)); } });坑2grid-gap在舊版Safari中不支持導(dǎo)致格子緊貼iOS 10.3以下版本不支持grid-gap。檢測(cè)到不支持時(shí)回退為padding模擬// 檢測(cè)grid-gap支持 const div document.createElement(div); div.style.display grid; div.style.gridGap 10px; const supportsGridGap div.style.gridGap 10px; if (!supportsGridGap) { // 為每個(gè)格子添加padding外部容器負(fù)margin抵消 this.grid.style.margin -6px; this.grid.querySelectorAll(.prize-item).forEach(item { item.style.padding 6px; }); }坑3中獎(jiǎng)音效在iOS Safari中首次需用戶手勢(shì)觸發(fā)iOS策略要求所有音頻必須由用戶點(diǎn)擊等手勢(shì)觸發(fā)后才能播放。因此不能在prizeSelected事件中直接audio.play()而要提前創(chuàng)建AudioContext并掛起// 初始化時(shí) this.audioContext new (window.AudioContext || window.webkitAudioContext)(); this.audioContext.suspend(); // 掛起等待手勢(shì) // 按鈕點(diǎn)擊時(shí)喚醒 this.btn.addEventListener(click, () { if (this.audioContext.state suspended) { this.audioContext.resume(); } }); // 中獎(jiǎng)時(shí) playWinSound() { const audio new Audio(/sound/win.mp3); audio.play().catch(e console.log(音頻播放被阻止需用戶交互)); }5.3 性能優(yōu)化清單確保低端機(jī)流暢運(yùn)行減少重排重繪所有動(dòng)畫(huà)只用transform和opacity絕不修改width、height、top、left節(jié)流事件監(jiān)聽(tīng)resize事件用setTimeout節(jié)流避免窗口拖拽時(shí)高頻觸發(fā)預(yù)加載資源中獎(jiǎng)音效、高亮動(dòng)畫(huà)CSS類在頁(yè)面加載時(shí)預(yù)加載內(nèi)存清理destroy()方法中移除所有事件監(jiān)聽(tīng)器清空eventListeners對(duì)象防止內(nèi)存泄漏。destroy() { this.btn.removeEventListener(click, this.handleClick); this.grid.innerHTML ; this.eventListeners {}; if (this.audioContext) { this.audioContext.close(); } }最后分享一個(gè)小技巧在animateTo函數(shù)中不要用setInterval它無(wú)法與屏幕刷新率同步會(huì)導(dǎo)致動(dòng)畫(huà)撕裂。requestAnimationFrame是唯一正確選擇且必須用bind(this)確保this指向正確——這是我第一次用raf時(shí)因this丟失導(dǎo)致currentAngle始終為0調(diào)試了兩小時(shí)才定位到的問(wèn)題。