基于react的九宫格抽奖组件

4,010 阅读5分钟

关于九宫格抽奖组件

通常我们在做调查问卷,购买商品的时候,商家为了吸引客户,增加与客户之间的互动,会引导客户前往一个九宫格抽奖界面,点击中间的抽奖按钮后,整个抽奖池会循环地给奖品添加动态样式,实现跑马灯的效果,最终停在某一个奖品格子上,确定抽奖结果。

如下所示:

现在网上流行的解决方案,要么使用canvas实现,要么一开始就会限定格子的个数,在此基础上再进行开发,扩展性和灵活性都得不到保障。因此,经过调研之后,本文基于react,设计了一套采用css grid布局的抽奖组件。组件不限制奖品个数,且能根据宫格数的不同自适应地改变中间按钮的大小。(9宫格、12宫格、16宫格等都不在话下)。组件实现了跑马灯的缓动效果,可以通过传参指定最终抽中的奖品(适用于后端通过计算得出目标奖品,前端直接执行UI动画的场景)。也可以直接传入奖品数组,组件内部实现了一套依照概率进行随机抽奖的算法,这样便能从最大程度上满足开发者的定制化需求。

实现思路

由于涉及到UI及动画的实现,同时还要管理一定数量的内部状态,因此只能基于框架开发对应的react或vue组件。

环境配置

如果在组件开发中不想引入create-react-app,则可以参照我自己写的webpack的开发和打包配置,能够满足基本的开发需求。

webpack通用配置

// webpack.common.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: [{ loader: 'babel-loader' }],
      },
      {
        test: /\.less$/,
        use: [
          'style-loader',
          'css-loader',
          'less-loader'
        ]
      },
      {
        test: /\.(png|jpg|gif|svg)$/,
        use: [{ 
          loader: 'url-loader',
          options: {
            limit: '8192',
            name: 'imgs/[name].[hash].[ext]',
            publicPath: './dist/'
          }
        }],
      }
    ]
  },
}

webpack开发环境配置

// webpack.dev.js
const {merge} = require('webpack-merge');
const common = require('./webpack.common');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = merge(common, {
  mode: 'development',
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: '[name].[hash].js',
    path: path.resolve('./dist'),
  },  
  plugins: [
    new HtmlWebpackPlugin({
      template: 'index.html'
    })
  ],
  devServer: {
    host: 'localhost',
    port: 3000,
    open: true
  }
});

webpack生产环境配置

// webpack.prod.js
const {merge} = require('webpack-merge');
const common = require('./webpack.common');
const path = require('path');

module.exports = merge(common, {
  mode: 'production',
  entry: {
    main: './src/components/index.js'
  },
  output: {
    filename: 'lotteryPool.js',
    path: path.resolve('./dist'),
    libraryTarget: 'umd',
    library: 'lotteryPool',
  },  
});

.babelrc配置

{
  "presets": ["env", "react", "stage-2"]
}

脚本启动命令

"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "start": "webpack-dev-server --open --config webpack.dev.js",
  "build": "webpack --config webpack.dev.js",
  "release": "webpack --config webpack.prod.js",
  "clean": "shx rm -rf dist"
},

UI布局及基本样式

<div className="lottery-container">
  <div 
    className="lottery-pool-wrapper"
    style={{
      width: `${this.state.width}px`,
      height: `${this.state.height}px`,
      gridTemplateColumns: `repeat(${this.state.repeatNum}, ${this.state.reapeatPercentage}%)`,
      gridTemplateRows: `repeat(${this.state.repeatNum}, ${this.state.reapeatPercentage}%)`,
    }}
  >
    {
      this.state.giftList.map((item,index) => (
        <div className="gift" key={index} style={this.giftStyle}>
          {item.ele || <div>{item.name}</div>}
        </div>
      ))
    }
  <div 
    id="shoot" 
    className="btn active"
    onClick={this.state.canClick ? () => this.start() : () => {}} 
    style={{ 
      gridColumnStart: 2,
      gridColumnEnd: this.state.repeatNum,
      gridRowStart: 2,
      gridRowEnd: this.state.repeatNum,
    }}
  >
    {this.state.btnText}
  </div>
  </div>
</div>

在确定好使用grid布局之后,首先我们要给外层容器lottery-pool-wrapper设置display:grid,然后设置相应的widthheight,这两个属性同时也作为api接口暴露出去,可由开发者自定义(为了保证最好的视觉效果,建议两者大小始终保持一致)。

gridTemplateColumnsgridTemplateRows则是实现grid自适应布局的关键。

在阮一峰的CSS Grid 网格布局教程中(www.ruanyifeng.com/blog/2019/0…

提到grid-template-columns属性定义每一列的列宽,grid-template-rows属性定义每一行的行高。

.container {
  display: grid;
  grid-template-columns: 100px 100px 100px;
  grid-template-rows: 100px 100px 100px;
}

实现的效果如下

由于组件每一行排列的个数需要根据奖品数组的长度进行自适应的变化(每行三个、每行四个、每行五个.....),所以为了省去频繁修改px参数的麻烦,这里更适宜采用repeat + %的形式。

.container {
  display: grid;
  grid-template-columns: repeat(3, 33.33%);
  grid-template-rows: repeat(3, 33.33%);
}

重复的次数及重复的值当然不能写死,我们需要this.state.repeatNumthis.state.reapeatPercentage来管理各自的状态。initDom函数规定了两者的赋值规则。

// initDom

const tempArr = [];

for(let i = 0; i < this.state.giftList.length + 1; i++) {
  tempArr.push('a' + i)
}

// 通过找规律发现的每行重复个数与礼物数组长度的关系
const num  = (tempArr.length + 3) / 4;

this.setState({
  repeatNum: num,
  reapeatPercentage: (100/num).toFixed(2)
})

最后,抽奖按钮游离于奖品数组渲染的方块之外,它必须时刻保持处于抽奖池的中心,且长度和宽度要随着奖品数组的长度进行自适应的改变。好在grid项目属性grid-column-start,grid-column-end,grid-row-start,grid-row-end的组合可以让我们实现这一点。四个属性指定项目的四个边框,分别定位在哪根网格线。

  style={{ 
    gridColumnStart: 2,
    gridColumnEnd: this.state.repeatNum,
    gridRowStart: 2,
    gridRowEnd: this.state.repeatNum,
  }}

稿纸上画几个图,对比着看,结果就很明显了。

按概率随机抽取目标元素的算法

该算法的目的,是在外部调用时未明确指定目标奖品id的情况下,根据传入数组各元素的rate,重新生成一个长度为100的新数组,里面按照概率均匀分布了各个奖品。再将新数组重新打乱,然后利用Math.random()进行随机抽取,就可以实现按照概率进行抽奖的效果。

指定范围获取随机数

randomFrom(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min);
};

根据元素概率生成新数组

generateNewGiftList(giftList) {
  const newGiftList = [];
  giftList.map((item) => {
    for(let i = 0; i < item.rate; i++) {
      newGiftList.push({name: item.name, id: item.id})
    }
  })
  // console.log('newGiftList', this.shuffle(newGiftList));
  this.newGiftList = this.shuffle(newGiftList);
};

打乱数组

// Fisher–Yates shuffle 洗牌算法
shuffle(arr) {
  for (var i = arr.length-1; i >=0; i--) {

    var randomIndex = Math.floor(Math.random()*(i+1));
    var itemAtIndex = arr[randomIndex];

    arr[randomIndex] = arr[i];
    arr[i] = itemAtIndex;
  }
  return arr;
}

随机抽取

getTargetFromInside(arr) {
  const randomIndex = this.randomFrom(0, arr.length - 1);
  this.targetBlock = arr[randomIndex].id
}

完整调用流程

componentDidMount() {
  this.init(); //舒适化dom结构及样式
  this.generateNewGiftList(this.state.giftList); 
  (!this.targetBlock) && this.getTargetFromInside(this.newGiftList); //如果传入了目标数组则略过此步
}

计算奖品在奖池中的实际定位

假设传入的奖品数组如下

[{
    name: '手机',
    id: 1,
    rate: 1.2,
  },{
    name: '谢谢参与',
    id: 2,
  },{
    name: '游戏机',
    id: 3,
    rate: 5.8543,
  },{
    name: '谢谢参与',
    id: 4,
  },{
    name: '谢谢参与',
    id: 5,
  },{
    name: '电视机',
    id: 6,
    rate: 7.454,
  },{
    name: '谢谢参与',
    id: 7,
  },{
    name: '傻不拉几',
    id: 8,
    rate: 2,
    ele: <div style={{display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center'}}>
      <div>拖拉机</div>
      <div>200元代金券</div>
    </div>
  }]

则实际渲染出来的九宫格奖池为:

奖品item是根据map一行行填满对应的block,但抽奖却是按照顺时针的方向一圈圈地走,这就意味着我们需要基于顺时针的顺序,重新理一套保存奖品实际行走路线的index数组出来。

首先,根据每行的元素个数,我们可以分别确定左上角,右上角,右下角,左下角元素的index,对应的关系为:

// num代表每行排满的元素个数
const leftTopIndex = 0;
const leftRightIndex = num - 1;
const leftBottomIndex = 3 * num - 4;
const rightBottomIndex = 4 * num - 5;

然后根据四个角元素的index,倒推出四条边上其余剩余元素的index

for(let i = 0; i < num-1; i++) {
  arr1.push(leftTopIndex + i);
  arr2.push(leftRightIndex + i*2);
  arr3.push(leftBottomIndex - i*2);
  arr4.push(rightBottomIndex - i);

  arr5.push(arr[leftTopIndex + i]);
  arr6.push(arr[leftRightIndex + i*2]);
  arr7.push(arr[leftBottomIndex - i*2]);
  arr8.push(arr[rightBottomIndex - i]);

}

最后分别把数组连接起来,注意这里保存了两个数组。realPositionArr保存的是元素的index,realPositionArrWithItem保存的是顺时针排列对应的完整元素。

完整的定义方法如下

getRealPosition(arr, num) {
  const leftTopIndex = 0;
  const leftRightIndex = num - 1;
  const leftBottomIndex = 3 * num - 4;
  const rightBottomIndex = 4 * num - 5;
  const arr1 = [];
  const arr2 = [];
  const arr3 = [];
  const arr4 = [];

  const arr5 = [];
  const arr6 = [];
  const arr7 = [];
  const arr8 = [];

  for(let i = 0; i < num-1; i++) {
    arr1.push(leftTopIndex + i);
    arr2.push(leftRightIndex + i*2);
    arr3.push(leftBottomIndex - i*2);
    arr4.push(rightBottomIndex - i);

    arr5.push(arr[leftTopIndex + i]);
    arr6.push(arr[leftRightIndex + i*2]);
    arr7.push(arr[leftBottomIndex - i*2]);
    arr8.push(arr[rightBottomIndex - i]);

  }
  const positionArray = arr1.concat(arr2, arr4, arr3);
  const positionArrayWithItem = arr5.concat(arr6, arr8, arr7);
  this.realPositionArr = positionArray;
  this.realPositionArrWithItem = positionArrayWithItem;
}

抽奖执行

这一块的逻辑主要分为三个部分:计算实际抽奖结束走过的格子数realDistance、执行抽奖、添加缓动效果。

计算realDistance

一开始,我们声明了三个变量durationunitTimespeed,可直接计算出已经走完的格子数。

const distance = this.duration / this.unitTime * this.speed;

但这个时候选中的奖品不一定是我们期待的目标奖品,所以我们把当下这个奖品在realPositionArrWithItem中的index称为falseStopPosition。然后继续计算我们的targeBlockrealPositionArrWithItem中的实际index。

const falseStopPosition = distance % this.state.giftList.length - 1;
const targetIndex = this.realPositionArrWithItem.findIndex(item => item.id === this.targetBlock);

如果是用户传入的targetBlock,最好判断其是否在奖品数组内,不是则需要抛出错误。谨防手误。

if(targetIndex !== -1) {
  // TODO
} else {
  throw('target is not in gifts array!')
}

计算extraDistance,加上distance得到realDistance

if(targetIndex > falseStopPosition) {
  extraDistance = targetIndex - falseStopPosition
} else {
  extraDistance = this.state.giftList.length - falseStopPosition + targetIndex
}
const realDistance = distance + extraDistance;

执行抽奖,用setTimout来来实现跑马灯效果。

      for(let i = 0; i <= realDistance; i++) {
        this.timer1 = setTimeout(()=> {
          this.setState({
            canClick: false,
          })
          btn.classList.remove('active');
          btn.classList.add('disable');

          Array.from(gifts).map((item) => {
            item.classList.remove('selected')
          })
          const currentGift = gifts[this.realPositionArr[this.currentNum % this.state.giftList.length]];
          currentGift.classList.add('selected');
          this.currentNum++;
          clearTimeout(this.timer1)
          if(this.currentNum === realDistance) {
            this.timer2 = setTimeout(() => {
              // 定义抽奖结束后的回调函数,向外层抛出targetBlock
              const _onFinish = this.props.onFinish && typeof(this.props.onFinish) === 'function' ? this.props.onFinish: () => {}
              _onFinish(this.targetBlock);
              if(this.lotteryDrawTime !== 0) {
                btn.classList.add('active');
                btn.classList.remove('disable');
                this.setState({
                  canClick: true,
                })
              }
              this.currentNum = 0;

              clearTimeout(this.timer2);
            }, 200)
          }

        }, this.easeInOutQuint(i / distance) * 8000);
      }

缓动

定义缓动函数,调整上文每一个setTimeout的time值

easeInOutQuint(x) {
  return 1 - Math.sqrt(1 - Math.pow(x, 2));
}

this.easeInOutQuint(i / distance) * 8000);

组件调用方式

import React, { useState } from 'react'
import LotteryPool from './lottery-pool';

const App  = () => {
  const [gifts, setGifts] = useState([{
    name: '手机',
    id: 1,
    rate: 1.2,
  },{
    name: '谢谢参与',
    id: 2,
  },{
    name: '游戏机',
    id: 3,
    rate: 5.8543,
  },{
    name: '谢谢参与',
    id: 4,
  },{
    name: '谢谢参与',
    id: 5,
  },{
    name: '电视机',
    id: 6,
    rate: 7.454,
  },{
    name: '谢谢参与',
    id: 7,
  },{
    name: '傻不拉几',
    id: 8,
    rate: 2,
    ele: <div style={{display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center'}}>
      <div>拖拉机</div>
      <div>200元代金券</div>
    </div>
  }])

  const handleFinish = (selectedTarget) => {
    console.log('selectedTarget', selectedTarget)
  }


  return(
    <div style={{display: 'flex', justifyContent:'center', alignItems:'center', height: '500px'}}>
      <LotteryPool
        gifts={gifts}
        onFinish={handleFinish}
        lotteryDrawTime={3}
      >
      </LotteryPool>
    </div>
  )
}

export default App;

即可完美实现抽奖效果。

示例图

九宫格

十二宫格

十六宫格