65
与时俱进吧,看着 vue3vite,虽然不会用,但还是心痒痒,然后就把原先基于 vue@2 的实现做了重构。不周之处,大家见谅!下面关于过期的内容,我就用删除线标记了。

2016 注定不是个平凡年,无论是中秋节问世的angular2,还是全面走向稳定的React,都免不了面对另一个竞争对手vue2。喜欢vue在设计思路上的“先进性”(原谅我用了这么一个词),敬佩作者尤小右本人的“国际范儿”,使得各框架之间的竞争略显妖娆(虽然从已存在问题的解决方案上看,各框架都有部分相似之处)。

2022 年了,根据最新版的 vue 指南,我再改一发给大家瞧瞧。

因为vue3已经正式 release,本教程做了一些修改(针对vue3)

所谓设计上的先进性,以下几点是我比较喜欢的:

数据驱动的响应式编程体验

不同于AngularJS里基于digest cycle的脏检查机制,执行效率更高。内部基于Object.defineProperty特性做漂亮的 hack 实现(而且不支持 IE8,大快人心)。更多细节,看这里

因为这个机制的出现,我们再也也不需要顾虑双向绑定的效率问题;亦或是像React那样搞什么immutability(对这块感兴趣可以看(译)JavaScript 中的不可变性),因为Object.definePropery洞悉你的一切,妈妈再也不用担心你忘记实现shouldComponentUpdate了.

到这里你可能还不能体会vue的精妙,是时候来个栗子了!

假设我们有一个字段fullName,它依赖其他字段的变化,在AngularJS里,我们或许会用命令式这样写道:

// 这里不要看了,太老了
$scope.user = {
  firstName: '',
  lastName: ''
}

$scope.fullName = ''

//告诉程序主动“监视”user的变化,然后修改fullName的值
$scope.$watch(
  'user',
  function(user) {
    $scope.fullName = user.firstName + ' ' + user.lastName
  },
  true
)

若是vue,改用声明式,写法如何?

<script lang="ts" setup>
import { computed ref } from 'vue'

const firstName = ref('world')
const lastName = ref('hello')

// 声明一个fullName的计算属性,并告诉程序它是由firstName和lastName组成。
// 至于具体是什么时候/如何完成数据拼装的,你就不用管了
const fullName = computed(() => lastName + firstName)
</script>

相对于AngularJS里命令式的告诉框架,fullName一定要监视user对象的变化(注意里面还是 deepWatch,效率更差),并且随之改变;vue以数据驱动为本质,声明式的定义fullName就是由firstNamelastName组成,无论怎么变化,都是如此。这种写法,更优雅有没有?

如果有兴趣看看用angular6如何实现相同的小游戏,走这里

单文件组件模式

还在为一堆代码文件,到底哪个是JavaScript逻辑部分、哪个是css/less/sass样式部分、哪个是html/template模板部分;他们又该如何组织,怎么“编译”、如何发布?

有了单文件组件范式,配合vite,组件逻辑自洽,完美、没毛病!还有强大的开发工具支持,看着都赏心悦目,来个效果图:

用了这么多版面,说了一些好处,那么当我们真正需要面对一个应用,需要上规模开发时,vue又能带来怎样的变化呢?憋了几天,我想今天就写一个小游戏来试试整体感觉,先来看看我们今天的目标:

demo

完整源码在这里:vue-memory-game

看了效果,知道源码在哪里了,那我们继续?

组件分解

Break the UI into a component hierarchy,相信写过React的朋友对这句话都不陌生,在使用一种基于组件开发的模式时,最先考虑,而且也尤为重要的一件事,就是组件分解。下面我们看看组件分解示意图:

architecture

我们根据分解图,先把未来要实现的组件挨个儿列出来:

  1. MemoryGame, 最外层的游戏面板
  2. Scoreboard, 上面的logo游戏进度最佳战绩的容器
  3. GameLogo,左上角的logo
  4. Progress, 正中上方的游戏进度组件
  5. GameScore, 右上角的最佳战绩组件
  6. ChessBoard, 正中大棋盘
  7. GameCard, 中间那十六个棋牌
  8. GameStatus, 最下方的游戏状态信息栏

带薪搭环境(又来了?^^)

# 创建项目
npm create vue@3

# 然后根据提问,按如下内容进行回答
Vue.js - The Progressive JavaScript Framework

✔ Project name: … vue-memory-game
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes
✔ Add Pinia for state management? … No / Yes
✔ Add Vitest for Unit Testing? … No / Yes
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … No / Yes
✔ Add Prettier for code formatting? … No / Yes

# 进入项目目录
cd vue-memory-game

# 安装依赖
pnpm i
这里我用了pnpm

> 这里开发环境依赖内容有点多,但不要害怕,大部分时候你不太关心里面的东西(当然,如果你要进阶,你要升职、加薪、迎娶白富美,那你最好搞清楚他们每一项都是什么东西)

另外在运行时依赖里不仅看到了vue,还看到了vuex。这又是个什么鬼?先不要慌,也别急着骂娘,我们来考虑一个问题,试想下,整个游戏按照上面分解的组件开发时,各个组件之间想必在逻辑上多少是有关系的,譬如:CardChessBoard中的翻牌、配对,当然会影响到上方的ScoreBoard和下面的GameStatus。那么“通信”,就成了待解决问题。

以前我们试图用事件广播来做,但随之而来的问题是,在应用不断的扩展、变化中,事件变得越来越复杂,越来越不可预料,以至于越来越难调试,越来越难追踪错误的 root cause。这当然不是我们想要的,我们希望应用的各个部分都易维护、可扩展、好调试、能预测。

于是一种叫单向数据流的方式就冒了出来,用过React的人想必也不陌生,各组件的间的数据走向永远是单向、可预期的:

图片描述

这当然也不是facebook的专利,都说vue牛逼了,那一定也有一个单向数据流的实现,就是我们这里用到的vuex

掌握目录结构

vue-memory-game
├── src
|   ├── assets
|   |   ├── 8-ball.png
|   |   ├── ...
|   |   └── zeppelin.png
│   │
│   ├── components
│   │   ├── ChessBoard
│   │   │   ├── GameCard.vue
│   │   │   └── index.vue
│   │   ├── ScoreBoard
│   │   │   ├── index.vue
│   │   │   ├── GameLogo.vue
│   │   │   ├── ProgressBar.vue
│   │   │   └── GameScore.vue
│   │   ├── GameStatus.vue
│   │   └── index.vue
│   │
│   ├── stores
│   │   ├── CountTimer.ts
│   │   ├── GameStore.ts
│   │   └── index.ts
│   │
│   ├── MemoryGame.vue
│   ├── constants.ts
│   ├── IType.ts
│   └── main.ts
│
├── index.html
├── env.d.ts
├── package.json
├── tsconfig.config.json
├── tsconfig.json
└── vite.config.ts

配置tsconfig.json

看了上面的文件目录结构图,要配置tsconfig.json,已经没有难度了,直接上代码:

{
  "extends": "@vue/tsconfig/tsconfig.web.json",
  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "types": ["node"]
  },

  "references": [
    {
      "path": "./tsconfig.config.json"
    }
  ]
}
脚手架里已经包含,只需加一个 types: ["node"] 就好

配置vite.config.ts

因为在 tsconfig.json 里增加了 paths 别名,那么为了能让 vite 正常工作,我们需要对 vite 进行相同的配置。

vite.config.ts

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: { port: 3000 },
  base: '/vue-memory-game/',
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})
我在这儿没有过多的涉及vite的基本使用,有兴趣的少年,翻源码去吧(~逃)

修改应用入口

这也是本章整个vue应用的入口:

import { createApp } from 'vue'
import App from './MemoryGame.vue'

createApp(App).mount('#game')

本章代码本采用 >= ES2015 语法编写,譬如:components: {Game},相当于components: {Game: Game},这是enhanced-object-literals

我在这里没有过多介绍vue3的基本使用,不过我尽量列出可能涉及的知识点,便于学习

全局初始化样式

全局初始化的样式,我们添加到根组件 MemoryGame.vue 里,为其单独分配一个全局的<style></style>

* {
  box-sizing: border-box;
  padding: 0;
  margin: 0;
}

html,
body {
  width: 100%;
  height: 100%;
}

body {
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>
本章大量使用flexbox来布局排版,不了解的可以学习一下(虽然我也是半吊子)

第一个组件 MemoryGame.vue

刚才的入口 src/main.ts 里,我们注入了游戏主界面组件src/MemoryGame,下面就来创建它吧:

<template>
  <div class="game-panel">
    hello
  </div>
</template>

<script lang="ts" setup>

</script>

<style scoped>
  .game-panel {
    width: 450px;
    height: 670px;
    border: 4px solid #bdbdbd;
    border-radius: 2px;
    background-color: #faf8ef;
    padding: 10px;
    display: flex;
    flex-direction: column;
  }

  @media screen and (max-width: 450px) {
    .game-panel {
      width: 100%;
      height: 100%;
      justify-content: space-around;
    }
  }
</style>

单文件组件的魅力,到这里终于可以瞄一眼了,第一部分是模板<template></template>,第二部分是逻辑<script></script>,第三部分是样式<style></style>

这里<style>上还有个scoped属性,表示样式仅对当前组件以及其子组件的模板部分生效。

所以我们可以在.vue文件中使用ES2015语法进行开发。

写了这么多,不运行一下,都说不过去了, 然后在项目根目录调用:

#启动调试
pnpm start

浏览器访问:http://localhost:3000/,可以看到如下效果:

hello

注意src/components/Game.vue里的两个"TBD"部分,我们现在来补齐:

<template>
  <div class="game-panel">
    <ScoreBoard />
    <ChessBoard />
    <GameStatus />
  </div>
</template>

<script lang="ts" setup>
import { onMounted } from 'vue'
import { useStore } from 'vuex'
import { ScoreBoard, ChessBoard, GameStatus } from '@/components'
import { GameStoreKey } from '@/stores'

const { commit } = useStore(GameStoreKey)
onMounted(() => {
  commit('reset')
})
</script>

<style>
* {
  box-sizing: border-box;
  padding: 0;
  margin: 0;
}

html,
body {
  width: 100%;
  height: 100%;
}

body {
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

<style scoped>
.game-panel {
  width: 450px;
  height: 670px;
  border: 4px solid #bdbdbd;
  border-radius: 2px;
  background-color: #faf8ef;
  padding: 10px;
  display: flex;
  flex-direction: column;
}

@media screen and (max-width: 450px) {
  .game-panel {
    width: 100%;
    height: 100%;
    justify-content: space-around;
  }
}
</style>
关于 vuex 使用详情,查看 官网基本教程 读完理解无障碍。

因为功能比较简单,大部分组件仅样式有差别,为了节省时间,我只挑一个最具代表性的 components/ChessBoard/index.vue 来讲讲

components/ChessBoard/index.vue

<template>
  <div class="chessboard">
    <Card v-for="card of cards" :key="card.id" :card="card" @onFlip="onFlip" />
  </div>
</template>

<script lang="ts">
export default {
  name: 'ChessBoard'
}
</script>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { useStore } from 'vuex'
import { IStatus } from '@/constants'
import { GameStoreKey } from '@/stores'
import type { ICard } from '@/IType'
import Card from './GameCard.vue'

// 一个用来存储 “上次翻的牌” 的变量
let lastCard = ref<ICard | null>(null)
const { state, dispatch, commit } = useStore(GameStoreKey)
const realtimeStatus = computed(() => state.status)
const realtimeNonMatchedPairs = computed(() => state.nonMatchedPairs)
const cards = computed(() => state.cards)

const onFlip = (e: ICard) => {
  // 翻牌时,如果当前游戏的状态是 ready,则认为游戏刚开始,所以设置状态为 playing
  if (realtimeStatus.value === IStatus.READY) {
    dispatch('updateStatus', IStatus.PLAYING)
  }
  // 如果上次翻牌为空,则把当前牌存进去,结束翻牌流程
  if (!lastCard.value) {
    lastCard.value = e
    return
  }

  // 如果上次翻牌和当前翻牌相同,则认为配对成功,两张牌都翻过来,然后重置上次翻牌为空
  if (lastCard.value !== e && lastCard.value.name === e.name) {
    lastCard.value = null
    commit('updateNonMatchedPairs', -1)
    // 如果发现所有牌都已翻完,直接结束游戏
    if (!realtimeNonMatchedPairs.value) {
      dispatch('updateStatus', IStatus.PASSED)
    }
    return
  }

  // 延迟动画,配对失败,1 秒后把上次翻牌和当前翻牌再恢复回背面
  const savedLastCard = lastCard.value
  lastCard.value = null
  dispatch('flipsDelay', {
    timeout: 1000,
    cards: [savedLastCard, e]
  })
}
</script>

<style scoped>
.chessboard {
  margin-top: 20px;
  width: 100%;
  background-color: #fff;
  height: 530px;
  border-radius: 4px;
  padding: 10px 5px;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  align-content: space-around;
}

.container:nth-child(4n) {
  margin-right: 0px;
}

@media screen and (max-width: 450px) {
  .chessboard {
    height: 480px;
    padding: 10px 0px;
  }
}
@media screen and (max-width: 370px) {
  .chessboard {
    height: 450px;
  }
}
</style>

写在最后,整体写完的效果,可以在这里把玩。

整个项目结构清晰,尤其单文件组件的表现力尤为突出,使得每个组件的逻辑都没有过于复杂,而且在vuex的统筹下,action -> mutation -> state的单向数据流模式使得所有的变化都在可控制、可预期的范围内。这点非常利于大型、复杂应用的开发。

vue作为一个仅7000多行的轻量级框架而言,无论生态系统、社区、工具的发展都非常均衡、成熟,完全可以适应多业务场景以及稳定性需求。而且,vue2中对服务器端渲染的支持(而且是前所未有的流式支持),使得你不必再为单页应用的SEO问题、首屏渲染加速问题而担忧。欲知详情,看SSR

总的来说,2016 年,vue让你的编程生涯,又多了一丝情怀(原谅我实在找不到什么好词儿了)。

如果关于代码有疑问,欢迎issue,也欢迎start


leftstick
27.3k 声望1.5k 粉丝

沙滩一卧两年半,今日浪打我翻身