数据驱动与拼图游戏

数据驱动是 Vue 框架的核心特性之一,也是 Vue 响应式原理的具体体现,相信大家对其应该深有体会,尤其是在操作数据来触发页面更新的时候。

为了让大家更加了解数据驱动的理念,并解决使用过程中可能出现的一系列问题,本文将结合比较常见和简单的 “拼图游戏” 来展示 Vue 数据驱动的魅力所在。

效果展示

首先我们先来看一下实现的 “拼图游戏” 的动态效果:

image

在不操作 DOM 的情况下实现以上功能其实需要我们对 Vue 数据驱动及数据可视化有一个非常清楚的认知,在操作数据的同时驱动可视化界面的还原。

关键代码

接下来我们来看一下实现该拼图游戏的功能点及关键代码:

游戏面板的构建

<!-- HTML 部分 -->
<ul class="puzzle-wrap">
    <li 
        :class="{'puzzle': true, 'puzzle-empty': !puzzle}" 
        v-for="(puzzle, index) in puzzles"
        :key="index"
        v-text="puzzle"
    ></li>
</ul>
// 数据部分
export default {
    data() {
        return {
            puzzles: Array.from({ length: 15 }, (value, index) => index + 1)
        }
    },
}

上方我们使用 v-for 循环构建了从 1 ~ 15 按顺序排列的方块格子,也就是拼图完成时候的顺序,但是拼图游戏一开始数字的顺序应该是无序的,也是随机打乱的,那么我们怎么实现呢?

可以使用下方的随机排列函数:

function shuffle(arr) {
    let len = arr.length
    
    for (let i = 0; i < len - 1; i++) {
        let idx = Math.floor(Math.random() * (len - i))
        let temp = arr[idx]
        arr[idx] = arr[len - i - 1]
        arr[len - i - 1] = temp
    }
    
    return arr
}

该函数中我们使用 Math.random() 来返回 0 和 1 之间的伪随机数,可能为 0,但总是小于1,[0, 1),而通过这一特性我们可以实现生成 n-m,包含 n 但不包含 m 的整数,具体步骤如下:

  • 第一步算出 m-n 的值,假设等于 w

  • 第二步 Math.random() * w

  • 第三步 Math.random() * w + n

  • 第四步 Math.floor(Math.random() * w + n)

在 shuffle 函数中 n 值永远是 0,而 w(即 len - i) 值随着循环 i 值的变大而不断减小。

在上面的算法里,我们每一次循环从前 len - i 个元素里随机一个位置,将这个元素和第 len - i 个元素进行交换,迭代直到 i = len - 1 为止。

这一便实现了数组的随机打乱。最后我们需要在数组末尾追加一个空值来显示唯一一个空白格子:

this.puzzles.push('');

交换方块位置

实现随机数字后,当我们点击方块,如果其上下左右存在为空的格子就需要将其进行交换,而由于是数据驱动界面,这里我们便需要交换两者在数组中的位置来实现:

export default {
    methods: {
        
        // 点击方块
        moveFn(index) {
            let puzzles = this.puzzles

            // 获取点击位置上下左右的值
            let leftNum = this.puzzles[index - 1],
                rightNum = this.puzzles[index + 1],
                topNum = this.puzzles[index - 4],
                bottomNum = this.puzzles[index + 4]

            // 和为空的位置交换数值
            if (leftNum === '' && index % 4) {
                this.setPuzzle(index, -1)
            } else if (rightNum === '' && 3 !== index % 4) {
                this.setPuzzle(index, 1)
            } else if (topNum === '') {
                this.setPuzzle(index, -4)
            } else if (bottomNum === '') {
                this.setPuzzle(index, 4)
            }
        },

        // 设置数组值
        setPuzzle(index, num) {
            let curNum = this.puzzles[index]
            
            this.$set(this.puzzles, index + num, curNum)
            this.$set(this.puzzles, index, '')
        },
    }
}

由于是 16 宫格的拼图,所以我们在点击获取位置的时候需要考虑边界情况,比如第 4 个格子为空,我们点击第 5 个格子不应该交换它们,因为在界面上第 4 个格子不在第 5 个格子的左侧,所以我们使用 index % 4 的方法来进行边界的判断,同时使用 Vue 提供的 $set 方法来将响应属性添加到数组上。

校验是否过关

最后我们需要校验游戏是否过关,我们只需要在最后一个格子为空时去进行校验即可:

if (this.puzzles[15] === '') {
    const newPuzzles = this.puzzles.slice(0, 15)
    const isPass = newPuzzles.every((e, i) => e === i + 1)

    if (isPass) {
        alert ('恭喜,闯关成功!')
    }
}

我们使用数组的 every 方法来简化代码的复杂度,当所有数字大小和对应的数组下标 + 1 相吻合时即会返回 true。

如此我们便完成了一个简单拼图游戏的功能。

盲点及误区

在实现拼图游戏后,有些同学可能会存在一些疑惑,比如:数组赋值为什么要用 $set 方法?数组随机打乱为什么不用 sort 排序呢?下面便来进行讲解:

为什么要用 $set 方法

大家应该都知道如果不用 $set 方法我们可以直接通过操作数组索引的形式对数组进行赋值,从而交换拼图的中两者的数据:

// 设置数组值
setPuzzle(index, num) {
    let curNum = this.puzzles[index]
    
    this.puzzles[index + num] = curNum
    this.puzzles[index] = ''
    
    // this.$set(this.puzzles, index + num, curNum)
    // this.$set(this.puzzles, index, '')
}

但是你会发现这样做数据是改变了,但是页面并没有因此重新渲染,这是为什么呢?其实 Vue 官方已经给出了明确的答案:

由于 JavaScript 的限制,Vue 不能检测以下变动的数组:

  • 当你利用索引直接设置一个项时,例如:vm.items[indexOfItem] = newValue

  • 当你修改数组的长度时,例如:vm.items.length = newLength

我们这里使用的便是第一种利用索引的方式,由于 Vue 检测不到数组变动,因此页面便无法重绘。

同样 Vue 也不能检测对象属性的添加或删除,需要使用 Vue.set(object, key, value) 方法来实现。

其实还有一种比较取巧的方式便是强制重新渲染 Vue 实例来解决这一问题:

// 设置数组值
setPuzzle(index, num) {
    let curNum = this.puzzles[index]
    
    this.puzzles[index + num] = curNum
    this.puzzles[index] = ''
    
    this.$forceUpdate() // 迫使 Vue 实例重新渲染
    
    // this.$set(this.puzzles, index + num, curNum)
    // this.$set(this.puzzles, index, '')
}

上方我们使用了 Vue 提供的 $forceUpdate 方法迫使 Vue 实例重新渲染,这样改变的数据就会被更新的页面中去。

但是最好不要这样操作,因为这会导致 Vue 重新遍历此对象所有的属性,一定程度上会影响页面的性能。

为什么不用 sort 排序

其实 sort 方法也能够实现数组的随机排序,代码如下:

let puzzleArr = Array.from({ length: 15 }, (value, index) => index + 1);

// 随机打乱数组
puzzleArr = puzzleArr.sort(() => {
    return Math.random() - 0.5
});

我们通过使用 Math.random() 的随机数减去 0.5 来返回一个大于、等于或小于 0 的数,sort 方法会根据接收到的值来对相互比较的数据进行升序或是降序排列。

但是由于 JavaScript 内置排序算法的缺陷性,使用 sort 排序的结果并不随机分布,经过大量的测试你会发现越大的数字出现在越后面的概率越大。

由于本文并非是一篇介绍 sort 排序的文章,关于论证其缺陷性的话题这里就不进行详细展开了,感兴趣的同学可以进一步进行探究。

结语

本文实例是基于我之前写的一篇关于利用 Vue.js 实现拼图游戏的文章上进行了改进和优化,希望通过这样一个小游戏来强化大家对于 Vue 数据驱动的理解。

相比操作 DOM 元素,操作数据其实更加的便捷和快速,可以使用较少的代码来实现一些较为复杂的逻辑。

具体实例代码可以参考:puzzle

思考 & 作业

Vue 中监听数据变化的原理是什么?是通过何种方式实现的?

如何论证原生 JS 中 sort 排序后越大的数字出现在越后面的概率越大?

如何使用 Math.random() 生成 n-m,不包含 n 但包含 m 的整数?

参考资料

https://www.kancloud.cn/sllyli/vueproject/1244263