一个扫雷小游戏带你初识VUE3和typescript

一个扫雷小游戏带你初识VUE3和typescript

阅读本文你会了解到:

  • vue3的部分新特性
  • typescript的基本使用
  • 部分es6语法

基础部分

为什么要使用refreactive来声明变量和对象,下面是官方文档的示意图片

请添加图片描述

但是我相信很多初学者看了也不会特别理解,下面我们用简单的vue2和vue3代码来展示两者的区别

// vue2
export default {
    created () {
        let cup = 0
        let fillCup = cup
        cup = 100
        console.log(fillCup);  // 0
    },
}

下面是使用ref的情况

// vue3
import { defineComponent, ref } from "vue";

export default defineComponent({
    setup() {
        let cup = ref(0);
        let fillCup = cup
        cup.value = 100
        console.log(fillCup.value);  // 100

    },
});

上述代码如果换成对象,你会发现二者的行为又变的一致了,最终结果也会是相同的,所以使用了ref就能够让numberstring的行为与其他类型的行为一致。

我们在vue3中直接打印一下cup,会发现它只是被封装成了对象而已{value: 0},那么既然是对象,我们是否可以直接使用解构赋值呢?来试一下:

setup() {
    let cup = ref(0);
    let fillCup = cup
    // 注意这里使用了解构赋值
    let {value} = cup
    console.log(value);	// 0
    value = 100
    console.log(value); // 100
    console.log("cup", cup);	// {value: 0}
    console.log("fillCup", fillCup);	// {value: 0}
},

很明显,这和我们的期望并不一样,所以官方文档也特意说明了,你所有的响应式对象,都不应该使用解构赋值,因为这会消除它的响应性

生命周期

如官网所示

选项式 APIHook inside setup
beforeCreateNot needed*
createdNot needed*
beforeMountonBeforeMount
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeUnmountonBeforeUnmount
unmountedonUnmounted
errorCapturedonErrorCaptured
renderTrackedonRenderTracked
renderTriggeredonRenderTriggered
activatedonActivated
deactivatedonDeactivated

请添加图片描述

所以在setup无法使用this

下面将结合typescript来写一个扫雷的小游戏

开始扫雷小游戏

规则

虽然是一款很古老的小游戏,但是我猜仍然有很多人不了解这个小游戏的规则,总结起来如下几点:

● 扫雷是一个矩阵,地雷随机分布在方格上。

● 方格上的数字代表着这个方格所在的九宫格内有多少个地雷。

● 方格上的旗帜为玩家所作标记。

● 踩到地雷,游戏失败。

● 打开所有非雷方格,并标记正确地雷位置,游戏胜利。

绘制矩阵

<template>
<div class="content">
    <table class="table">
        <tr v-for="(i,idx) in celldata" :key="idx">
            <td v-for="(j, index) in i" :key="index">{{j}}</td>
    	</tr>
    </table>
</div>
</template>

<script lang="ts">
    import { defineComponent } from "vue";

    export default defineComponent({
        name: "Home",
        setup() {
            // 生成9 X 9的矩阵数组
            const celldata = []
            for (let i = 0; i < 9; i++) {
                const jarr = []
                for (let j = 0; j < 9; j++) {
                    jarr.push(`${i},${j}`)
                }
                celldata.push(jarr)
            }
			// 暴露给template
            return {
                celldata
            }
        },
    });
</script>
<style lang="scss" scoped>
    .content {
        height: 100vh;
        width: 100vw;
        display: flex;
    }
    .table {
        margin: auto;
        td {
            height: 50px;
            width: 50px;
            border: 1px solid #000;
        }
    }
</style>

如果你运行,那么会出现如下效果

请添加图片描述

需要注意的是,在你的现实开发中,v-for并不建议像我这里这样使用索引,这里只是为了省事

由于后面还会有很多逻辑写到setup里,这样setup里的代码量会非常的庞大,这并不利于代码的维护,所以我这里会把一些不重要的逻辑单独写到一个ts文件里,上面修改一下:

// index.ts
import { toRefs, reactive } from "vue";

export function minues() {
    const gen = () => {
        const celldata = []
        for (let i = 0; i < 9; i++) {
            const jarr = []
            for (let j = 0; j < 9; j++) {
                jarr.push(`${i},${j}`)
            }
            celldata.push(jarr)
        }
        // reactive声明了响应式对象,目的是为了对celldata的后续操作
        return toRefs(reactive({ celldata }))
    }
    
    // 生成矩阵
  	const {celldata}  = gen();
    
    return {
        celldata
    }
}

// index.vue
<script lang="ts">
import { defineComponent } from "vue";
import {minues} from './index'

export default defineComponent({
  setup() {
    // 有了上面的操作,这里可以直接结构赋值,并且celldata也是具有相应性的
    const {celldata} = minues()
    
    return {
      celldata
    }
  },
});
</script>

这里可能会有疑问,上面不是说响应式对象不是不可以结构赋值么,为什么这里使用了结构赋值来获取celldata,这就要看一下index.ts里,我们最后return的时候,使用了toRefs进行了封装。但是如果你自己看了API,会疑问toReftoRefs有什么区别?怎么看起来这么像?这里就稍微解释一下。

toReftoRefs

在上面我们说了响应式对象使用结构赋值会破坏对象的响应性,那么是否意味着不能在VUE3中使用这么方便的语法了?答案是否定的,所以才会提供了这两个api。下面就来看一下二者的区别。

// 不使用上述两个api的情况
const obj = reactive({
    name: '张三',
    age: 18
})
let {age} = obj
age = 200
console.log(obj);	// {name: '张三',age: 18}
// 使用 toRefs
const obj = reactive({
    name: '张三',
    age: 18
})
let {name, age} = toRefs(obj)
// 注意这里是age.value,说明age已经是响应式的了
age.value = 200
console.log(obj);	// {name: '张三',age: 200}
// 使用 toRef
const obj = reactive({
    name: '张三',
    age: 18
})
// toRef第二个参数是对象里的key,链接的属性名称可以随便起
let objage = toRef(obj, 'age')
objage.value = 200
console.log(obj);

通过上面的示例很容易看出来,toRef是针对对象的某一个特定属性进行响应式的链接。而toRefs是对所有的属性进行响应式的链接,在二者的使用上还是要注意有些不同的,当然,最简单粗暴的办法就是任何情况都使用toRefs搭配解构赋值。

注意:二者接受的对象必须是响应式对象,不能是普通对象

点击事件

有了上面的矩阵,我们来添加一下点击事件,大体上有下面几点要求:

  • 鼠标左键可以点击出现数字(雷的数量)
  • 鼠标右键可以进行标记
  • 左键不能二次点击,右键连续点击会进行普通与标记的切换,标记状态也可以直接左键点开

首先在html部分添加鼠标点击事件:

<table class="table" :key="key">
    <tr v-for="(i, idx) in celldata" :key="idx">
        <td
            v-for="(j, index) in i"
            @mousedown="tdclick(idx, index, $event)"
            :key="index"
            >
            {{ j.content }}
        </td>
    </tr>
</table>

mousedown事件需要传入三个参数,二维数组的两个坐标,以及鼠标的点击事件,其类型为MouseEvent,注意我在table标签上也加了一个key,作用我们下面再说。

然后上面的渲染矩阵的方法需要稍作修改,因为我们要记录格子是否已经打开了

// 定义一个类型接口
interface ITd {
    // 记录格子内容,是雷数(数字)还是雷(圆点的字符串)
    content: string | number
    // 记录格子是否已经被打开
    isOpen: boolean
    // 记录是否被旗子标记
    tag: string
    // 本格子是否为雷
    isMines: boolean
}
// ...
// 这里新增了cell的类型,是个二维数组,内部是ITd类型的对象
const celldata = <Array<Array<ITd>>>[]
for (let i = 0; i < 9; i++) {
    // 这里可以不添加类型,下面push的时候会自动进行类型推断
    const jarr = <Array<ITd>>[]
    for (let j = 0; j < 9; j++) {
        // 这里修改为push进去一个ITd类型的对象
        jarr.push(<ITd>{
            content: 0,
            tag: '',
            isOpen: false,
            isMines: false
        })
    }
    celldata.push(jarr)
}
//...

最后来看点击事件部分:

// index.ts

export function minues(){
	// ...
    // 声明一个数字类型的key
    const key = ref(0);
    // 阻止右键菜单
    window.oncontextmenu = () => false;
    // 鼠标点击事件,注意参数的类型
    tdclick = (i: number, j: number, e: MouseEvent) => {
        // 根据二维坐标获取当前的值,注意这里是结构赋值,不是对象。是为了下面能够让各位区分清楚属性名称
        // 熟悉逻辑的话可以不用这么麻烦
        const {content: oldcontent, isOpen: oldisOpen} = celldata.value[i][j];
        // 如果已经打开了,直接返回
        if (oldisOpen) return;
        // 这里利用策略模式,创建一个对象,key其实是鼠标点击事件的按键:0:左键,1:滚轮,2:右键
        // 由于不需要更改此对象,所以也只创建了普通对象
        const cellvalue = {
            // 左键打开即可
            0: () => {
                return {
                    isOpen: true,
                };
            },
            // 右键来控制标记的切换
            2: () => {
                return {
                    tag: oldtag === "▲" ? "" : "▲",
                };
            },
        }; 
        // 根据鼠标的按键, 直接对格子进行赋值,Object.assign会使用第二个参数强制覆盖同key属性
        celldata.value[i][j] = Object.assign(
            celldata.value[i][j],
            cellvalue[e.button]()
        );
        // 组件强制更新
        key.value++;
    }
	// index.vue中的setup
    return {
        // ...
        key,
        tdclick,
    };
}

大体上需要说明的都写在注释里了,这里解释一下为什么需要为table单独加上key,由于table标签不能使用v-model进行数据的双向绑定,所以在每次点击事件之后,虽然数据变了,但是dom元素并不会刷新,你会发现不管怎么点击,都没有反应,所以这里需要对dom元素进行强制刷新,那么常用的强制刷新dom的方法有哪些呢?

  • v-if状态切换来让组件销毁后重新渲染
  • 利用虚拟dom的key值变化来通知刷新

以上也就是为什么不建议使用索引当做v-forkey值了,因为你的索引发生变化,就会导致dom重新渲染。

最后提示一点,你的key值只要在同一父元素下不重复即可

别忘了修改一下html部分

<table class="table" :key="key">
    <tr v-for="(i, idx) in celldata" :key="idx">
        <td
            v-for="(j, index) in i"
            @mousedown="tdclick(idx, index, $event)"
            :key="index"
            >
            <!-- 打开了就显示内容,否则显示标记 -->
            {{ j.isOpen ? j.content : j.tag }}
        </td>
    </tr>
</table>

那么为什么要使用三元运算符来判断显示内容,不用v-if或者v-show呢?

v-forv-if

vue2中,在一个元素上同时使用 v-if 和 v-for 时,v-for 会优先作用。但是在vue3中,同一个元素上不允许同时出现v-forv-if,但是v-show实际上只是相当于元素的css属性设置了display:none,所以不影响渲染,可以共同使用。但是使用了v-show,按F12查看页面元素,隐藏起来的内容还是可以看到的,为了防止作弊,所以决定不使用v-show

随机分布地雷

现在矩阵还没有生成随机地雷,我们需要在生成矩阵的函数里增加一点点内容:

const gen = () => {
	// ...
    // 用来记录雷的数量
    const minesNum = ref(0)
    // 死循环来创建地雷
    for (; ;) {
        // 随机坐标
        const x = Math.floor(Math.random() * 9)
        const y = Math.floor(Math.random() * 9)
        // 先判断这个坐标的格子是否已经是地雷,如果是的话,直接跳过,进入下一次循环
        const { isMines } = celldata[x][y]
        if (isMines) continue
        // 设定为雷
        celldata[x][y] = {
            content: "●",
            tag: '',
            isOpen: false,
            isMines: true
        }
        // 获取地雷格子周围8个格子的坐标范围,并且要判断防止越界,因为边缘的格子周围并没有8个那么多
        const xStart = x - 1 < 0 ? x : x - 1
        const xEnd = x + 1 >= 9 ? x : x + 1
        const yStart = y - 1 < 0 ? y : y - 1
        const yEnd = y + 1 >= 9 ? y : y + 1
        for (let xAxis = xStart; xAxis <= xEnd; xAxis++) {
            for (let yAxis = yStart; yAxis <= yEnd; yAxis++) {
                const { content, isMines } = celldata[xAxis][yAxis]
                // 周围的格子只要不是地雷,就让content数字加1
                // 由于接口中content是string | number类型, 这里进行 +1会报错,所以你要进行类型断言
                // 因为可以预料到,不是雷的一定是数字,所以我肯定这里的content是数字类型
                isMines !== true && (celldata[xAxis][yAxis].content = (content as number) + 1)
            }
        }
		// 累计雷的数量,这里设定是添加9个雷,然后跳出循环
        minesNum.value++
        if (minesNum.value >= 9) break;
    }


    return toRefs(reactive({ celldata }))
}

这里你需要了解一下typescript中的类型断言,正是因为引入了类型的原因,我们在写代码的过程中就很容易的发现问题所在

打开格子的事件

这里只需要左键来触发,那么应该要满足一下几点:

  • 点开为0的会把周围所有非雷方格打开
  • 否则显示数字
  • 将状态置为打开

听起来似乎挺简单,但是实现起来也是个麻烦的体力活:

const openMines = (x: number, y: number, celldata) => {
    // 不为0就直接返回,因为在上面左键的事件里我们已经做了显示的操作
    if(celldata[x][y].content !== 0) return
    // 为0 的时候就判断周围的8个格子
    const xStart = x - 1 < 0 ? x : x - 1;
    const xEnd = x + 1 >= 9 ? x : x + 1;
    const yStart = y - 1 < 0 ? y : y - 1;
    const yEnd = y + 1 >= 9 ? y : y + 1;
    for (let xAxis = xStart; xAxis <= xEnd; xAxis++) {
        for (let yAxis = yStart; yAxis <= yEnd; yAxis++) {
            const { isMines, tag ,isOpen} = celldata[xAxis][yAxis];
            // 没有打开,没有标记为旗子,并且不是雷就打开,如果打开的仍然是0,就递归
            if ( isOpen === false && tag !== '▲' && isMines === false) {
                celldata[xAxis][yAxis].isOpen = true
                openMines(xAxis, yAxis, celldata)
            }
        }
    }
}

你会发现这里的循环周围格子的逻辑与上面生成地雷的方法高度相同,你可以抽离出公共部分,不过为了方便各位阅读,这里就不做抽离了。

然后把方法放到左键的事件里:


const tdclick = (i: number, j: number, e: MouseEvent) => {
	// ...
    const cellvalue = {
        0: () => {
            openMines(i, j, celldata.value);
            return {
                isOpen: true,
            };
        },
		// ...
    }; 
	// ...
}

胜利条件

基本上主要逻辑已经完成,就剩下判断胜负条件了

胜利的条件:所有地雷的格子都被标记了旗子,即可胜利

失败的条件:只要点开了地雷,游戏结束

首先我们需要定义一个判断游戏是否还能继续进行的状态:

export function minues() {
    /...
    // 游戏是否结束,true:结束,false:未结束
    let isEnd = ref(false)
    //...
    const tdclick = (i: number, j: number, e: MouseEvent) => {
        // ...
        // 游戏结束了就不允许继续出发事件
        if (isEnd.value || oldisOpen) return;
        // ...
    }

    return {
        // ...
        isEnd
    }
}

然后我们编写判断是否胜利的方法:

// index.ts
// 需要一个返回boolean的函数
const success = (celldata):boolean => {
    let n = 0
    let isEnd = false
    celldata.forEach(i => {
        i.forEach(arr => {
            // 是雷并且被打开,失败
            if (arr.isMines && arr.isOpen) {
                alert("失败")
                isEnd = true
            }
            // 否则记录被标记的雷
            arr.isMines && arr.tag === '▲' && n++
        });
    });
    // 当地雷被全部标记,则游戏胜利
    if (n === 9) {
        alert("成功")
        isEnd = true
    }
    return isEnd
}

这个方法你可以写在点击事件里,但是这里演示一下vue3的生命周期:

我们顺便再看一下最终的代码结构:

// index.ts

export function minues() {
  // 生成矩阵
  const gen = () => {//...
  }

  const key = ref(0);
  // 游戏是否结束,true:结束,false:未结束
  const isEnd = ref(false)
  // 生成矩阵
  const {celldata}  = gen();
  // 阻止右键菜单
  window.oncontextmenu = () => false;
  // 鼠标点击事件
  const tdclick = (i: number, j: number, e: MouseEvent) => {//...
  }
  // 打开格子
  const openMines = (x: number, y: number, celldata) => {// ...
  }
  // 胜利条件
  const success = (celldata):boolean => {// ...
  }

  return {
    tdclick,
    success,
    key,
    celldata,
    isEnd
  }
}
// index.vue

<script lang="ts">
import { defineComponent, onUpdated } from "vue";
import { minues } from "./index";

export default defineComponent({
    setup() {
        const { tdclick, success, key, celldata, isEnd } = minues();
        // 将是否游戏结束赋值给isEnd,这样游戏失败,或者游戏胜利都不能继续出发点击事件了
        onUpdated(() => (isEnd.value = success(celldata.value)));
        return {
            key,
            celldata,
            tdclick,
        };
    },
});
</script>

总结

至此扫雷小游戏的主要逻辑已经完成,迫于时间和篇幅,我并不想继续拓展下去了,你在使用编写中也会发现还有很多问题:

  • 必须刷新才能重置游戏
  • 可以一直右键标记,标记完所有格子游戏胜利
  • 没有计时和记录的功能
  • 代码重复性比较高

在我看一些别人写的内容的时候,总是习惯直接复制下来看效果,效果出现了便觉得自己好像已经会了,实际上就是一看就会,一做就废。所以后面的拓展我希望读者可以自己继续进行编写。

但是通过上面,我们多多少少可以了解一些typescriptvue3的特性。

typescript最直观的感受就是加强了代码提示功能,你在使用点击事件的时候,会直接提示你MouseEven的内部属性,你在JavaScript中可能需要打印出来所有属性来自己去手写,包括我们声明的接口类型,也会帮你监测变量是否拼错,是否有遗漏或者类型错误的属性,可能初期写着会有点难受,但是当你习惯了之后,真的写起来舒服很多。

vue3的组合式API相较于vue2中的选项式API有什么好处呢,因为目前这个小功能,我们可能看的不是很明显,但是想必你也发现,我们这次将一个类型的功能统一提取到了index.ts里,如果有其他的功能,我们可以另外写一个ts文件,从而使功能间相互隔离。让我对比一下vue2中的写法:

请添加图片描述

我们将不同功能所相关的内容用不同的颜色标记,发现他们是间隔起来的,如果你的功能多了,就会出现官方文档上的图片那样

请添加图片描述

图片参考:介绍 | Vue.js (vuejs.org)

你在维护的时候,鼠标滚轮会一直上下翻动,都要滑出火星了。但是在vue3里面,通过组合式API我们将不同的功能分成了不同模块,就如本例这样,后续添加其他功能,我依然可以很清楚的知道方法、属性从哪来,到哪去

setup() {
    // 扫雷
    const { tdclick, success, key, celldata, isEnd } = minues();
    onUpdated(() => (isEnd.value = success(celldata.value)));
    // 其他
    const {other} = other()
    onUpdated(() => other())

    return {
        key,
        celldata,
        tdclick,
        other
    };
}

如有问题,欢迎反馈

  • 3
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

cRack_cLick

感谢你的支持!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值