Vue3实战 开发TaskList App Demo

1,138 阅读8分钟

写在前面

  • 掘金社区已经有一大堆Vue3入门了,所以本片文章不会侧重Vue3源码或Api分析,所以在实战前最好能看一下Vue3的特性和用法,本片文章主要记录一款无聊的App Demo😂,以及本人对Vue3的一些见解和踩坑点。

Vue2.x的日常

  • 在Vue2.x中,this应该是出现频率最高的关键词了,this类似一个黑盒,所有的属性、方法、Vue内置方法等等都挂载在this上。在没有TS的支持下,anyScript使开发体验极差、且一个功能只有当时的开发者知道逻辑是怎么嵌套的,后人别指望能看懂😂。
  • 对于复杂业务,有时候我们需要进行逻辑复用、有些简单函数可以抽出utils、但是有些方法和上下文强相关,无法抽离进utils。
  • 对于上面的问题,我们可以抽离出mixins,这主要会引发三个问题。数据来源不清晰(模板或方法中的变量在当前文件中找不到);命名冲突会发生覆盖(虽然一定程度上是可控的,毕竟冲突是写出来的);不符合编程直觉,使用者(那个组件)可以调用mixin中的数据和方法,mixin也可以调用假设使用者会创建的数据和方法,因为mixin其实是进行代码合并,所以它不会减少代码量、包体积、等等,也不会提升性能(啥也不是🐒)**
  • 同一个功能的相关数据方法被强行分离,可能data里的某个变量和methods里的某个使用方法隔500行,我称之为奥义·反复横跳

Option Api

Composition Api

Vue3

话不多说,直接上正片

import * as vue from 'vue'
console.dir('vue :>> ', vue)

可以看到Vue3将很多内置Api以函数的形式向外暴露,这为我们编写复杂业务提供了无限的可能。 Vue3中需要关注的一些新功能包括

  • Composition API 组合式Api
  • Teleport 传送门内置组件
  • Fragments 内置Fragments特性
  • Emits Component Option
  • createRenderer API from @vue/runtime-core to create custom renderers
  • SFC Composition API Syntax Sugar (<script setup>)
  • SFC State-driven CSS Variables (<style vars>)
  • SFC <style scoped> can now include global rules or rules that target only slottedcontent

当然,我们从简单、常用的Api讲起。

入口函数

import { defineComponent } from 'vue'
export default defineComponent({
  setup(props, ctx) {}
})

两个参数

  • setup函数接受两个参数,propsctx,得益于Vue3的TS支持,可以直接去看源文件xxx.d.ts

注意点

  • 执行时机

beforeCreate钩子之前,显然,这时组件还没有初始化,所以不能使用data内的数据、不能使用methods中的方法,约等于什么都取不到😂, 且在setup函数体内this取值为undefined,Vue3内部将setup函数体this指向修改为了undefined,直接不给你用,万一你肌肉记忆般地敲this呢。

  • props

在以前的js开发模式下props无法声明具体的接口类型,后来社区有了装饰器配合ts,够一定解决类型问题,但是Class配合装饰器依然有很多问题,感兴趣的可以去查资料,至少本人不看好。

Vue3在Ts的支持下,可以通过defineComponent泛型来重写函数,从而推导出props类型,不过现在似乎还没有明确的defineComponent最佳使用方式,本人尝试下来会发生类型推导错误,应该是打开方式不对😂。有哪位道友可以来指点迷津。

reactive

import { defineComponent, reactive } from 'vue'

export default defineComponent({
  setup() {
    const userInfo = reactive({ name: 'Mr Yang', age: 18 })
  }
})

我认为reactive是Vue3的很大的亮点,相关可以看社区的优质文。

一个包含增删改查的简单TaskList

包含以下功能

  • 加载进入页面自动请求一次数据
  • 分三个Tab页,全部、未完成、已完成
  • 统计当前页任务数
  • 点击列表项删除对应项
  • 点击列表项toogle任务状态(已完成和未完成)
  • 填写表单
  • 提交表单后向列表中追加一项

确定UI原型

这里直接放出大致的结果(就是本App Demo)

编写html

<template>
  <div>
    <!-- 标题 -->
    <header>Task App Demo</header>
    <!-- 工具栏区域 -->
    <div>
      <!-- 统计数量 -->
      <div>
        <span>10</span>
        tasks
      </div>
      <!-- tabs区域 -->
      <div>
        <button>All</button>
        <button>Active</button>
        <button>Completed</button>
      </div>
    </div>
    <!-- form表单区域 -->
    <div>
      <input type="text" placeholder="Add a new task···" />
    </div>
    <!-- 列表滚动区域 -->
    <div>
      <!-- 列表 -->
      <div>
        <!-- 列表项, v-for -->
        <div>
          <!-- checkbox -->
          <input type="checkbox" />
          <span>Task 文本</span>
          <!-- 删除按钮 -->
          <button></button>
        </div>
      </div>
    </div>
  </div>
</template>

Script

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

export default defineComponent({
  name: 'TaskList',
  setup(props, ctx) {
    return {}
  }
})
</script>

声明接口类型

interface TaskItem {
  id: string
  task: string
  isCompleted: boolean
}
type TaskState = 'All' | 'Active' | 'Completed'
type TaskItemMap = {
  [key in TaskState]: Set<TaskItem>
}

编写入口函数

创建Task数据集合

const taskItemMap = reactive<TaskItemMap>({
  All: new Set(),
  Active: new Set(),
  Completed: new Set()
})

request方法,并立即执行一次,模拟接口,请求本地json文件

const request = () => {
  fetch('src/TaskList.json')
    .then(res => res.json())
    .then((res: TaskItem[]) => {
      res.forEach(item => {
        taskItemMap.All.add(item)
        if (item.isCompleted) taskItemMap.Completed.add(item)
        else taskItemMap.Active.add(item)
      })
    })
    .catch(console.error)
}
request()

创建activeTab,用来维护当前选中页,默认选中第一项

const activeTab = ref<TaskState>('All')

创建选中页切换函数

const handleChangeTab = (key: TaskState) => {
  activeTab.value = key
}

创建计算属性,返回当前展示页

const activeSet = computed(() => taskItemMap[activeTab.value])

创建表单input框双向绑定值

const task = ref('')

创建表单提交函数、向列表中追加一项

const submit = () => {
  if (task.value.length) {
    const item = { id: Math.random().toString().substr(2), task: task.value, isCompleted: false }
    taskItemMap.All.add(item)
    taskItemMap.Active.add(item)
  }
}

创建删除Task函数

const handleDelItem = (item: TaskItem) => {
  taskItemMap.All.delete(item)
  if (item.isCompleted) taskItemMap.Completed.delete(item)
  else taskItemMap.Active.delete(item)
}

最后向模板暴露需要用的数据和方法

return { taskItemMap, activeTab, handleChangeTab, activeSet, task, submit, handleDelItem }

最终的模板

<template>
  <div class="task-app">
    <header class="header">TaskList App Demo</header>
    <div class="tools">
      <div class="tools-count">
        <span>{{ taskItemMap[activeTab].size }}</span>
        tasks
      </div>
      <div class="tools-filter">
        <button
          v-for="(item, key) in taskItemMap"
          :key="key"
          class="tools-filter__btn"
          :class="{ active: key === activeTab }"
          @click="handleChangeTab(key)"
        >
          {{ key }}
        </button>
      </div>
    </div>
    <div class="form">
      <input v-model="task" class="form__input" type="text" placeholder="Add a new task···" @keyup.enter="submit" />
    </div>
    <div class="list-scroll__wrapper">
      <transition-group class="list" tag="div" name="list" mode="out-in">
        <div v-for="item in activeSet" :key="item.id" class="list-item">
          <input v-model="item.isCompleted" class="list-item__checkbox" type="checkbox" />
          <span :class="{ completed: item.isCompleted }">{{ item.task }}</span>
          <button class="list-item__del" @click="handleDelItem(item)"></button>
        </div>
      </transition-group>
    </div>
  </div>
</template>

看一下页面 测试一下效果,可以增加;可以删除;切换tab页同时,列表数据跟随切换;左侧checkbox也可以toogle

存在的BUG

  • 在Active页时,任务都是未完成状态,toogle任务的状态后,checkbox取消了选中,但是这条任务还在Active页。预期效果:它应该立即从Active分组删除,同时添加到Completed分组。
  • Completed页的任务同理,只不过逻辑反过来,toogle状态后,应该立即从Completed分组删除,同时添加到Active分组。
  • 删除操作同样有类似的BUG,切换至Active页,toogle任务至完成状态后,点击删除,任务并没有被删除

上述所有问题可以归纳为一个原因,任务的所属分组发生混乱,只有在初次请求后的数据才是逻辑正确的。 任务的isCompleted字段直接决定了任务是进入已完成分组还是正在处理分组,但是现有逻辑没有及时更新分组的状态,导致已完成的任务还是在未完成分组,反之亦然。

解决问题

直观能想到的处理方式为,为任务增加toogle函数,抛弃直接v-model="item.isCompleted",或者为checkbox绑定change事件,及时将任务移到另外的分组。 最后的代码可能是这样的

const handleCompletedChange = (item: TaskItem) => {
  if (item.isCompleted) {
    taskItemMap.Active.delete(item)
    taskItemMap.Completed.add(item)
  } else {
    taskItemMap.Completed.delete(item)
    taskItemMap.Active.add(item)
  }
}

细心的同学可能发现了,这和一开始请求数据后的处理方式很像

有没有更好的方法,当然是有的,这就得用到Vue3新的Api,watchEffect,具体原理和用法请移步官方文档watchEffect和掘金质量贴,文档后面还有高阶指南。

watchEffect直译为什么,我理解的是观察副作用,什么是副作用。以前面的BUG为例,任务的状态发生了true or false的切换,导致了逻辑上或数据的组织结构上发生了变化,即任务它所处的分组要及时改变,这个就是一个副作用。

在复杂业务下,我们有时候很难去通过某个具体方法去模拟这个副作用。这和我们常用的computedwatch都不太一样,还是以前面的BUG为例。

  • 我们不需要一个可能存在的计算结果,也就不能去使用computed(有的同学可能会写一个computed动态计算返回一个taskItemMap,这个做法很不好,我相信没人会这么做)。
  • watch是侦听器,然而任务本身是动态创建的,他是数组或Set中的一项,和V2.x中常用的监听data中定义的某个数据不一样,虽然V2.x中也有$watch,但是不好用。

理解了副作用,就可以动手路撸代码了 最后的代码可能是这样的

const item = { id: Math.random().toString().substr(2), task: task.value, isCompleted: false }
const reactiveItem = reactive<TaskItem>(item)
taskItemMap.All.add(reactiveItem)
reactiveItem.watchStopHandle = watchEffect(() => {
  if (reactiveItem.isCompleted) {
    taskItemMap.Active.delete(reactiveItem)
    taskItemMap.Completed.add(reactiveItem)
  } else {
    taskItemMap.Completed.delete(reactiveItem)
    taskItemMap.Active.add(reactiveItem)
  }
})

回顾request中的task遍历处理,主要逻辑是一样的,所以我们可以抽离出一个函数,如下

const useWatchIsCompleted = (taskItemMap: TaskItemMap, item: TaskItem) => {
  const reactiveItem = reactive<TaskItem>(item)
  taskItemMap.All.add(reactiveItem)
  reactiveItem.watchStopHandle = watchEffect(() => {
    if (reactiveItem.isCompleted) {
      taskItemMap.Active.delete(reactiveItem)
      taskItemMap.Completed.add(reactiveItem)
    } else {
      taskItemMap.Completed.delete(reactiveItem)
      taskItemMap.Active.add(reactiveItem)
    }
  })
}

然后request和submit,请求函数和新增函数稍微修改一下

const request = () => {
  fetch('src/TaskList.json')
    .then(res => res.json())
    .then((res: TaskItem[]) => {
      res.forEach(item => useWatchIsCompleted(taskItemMap, item))
    })
    .catch(console.error)
}
const submit = () => {
  if (task.value.length) {
    const item = { id: Math.random().toString().substr(2), task: task.value, isCompleted: false }
    useWatchIsCompleted(taskItemMap, item)
  }
}

我认为两种处理有本质上的区别,至少对于开发者来说有却别。通过拦截change事件表现为以事件驱动,通过watch或者watchEffect表现为数据驱动,开发者只要在动手撸代码之前,滤清逻辑关系,就可以尽量少些需要主动触发的方法,很多情况下,通过事件触发的理念并不能枚举所有的情况,你一定会漏掉超出预期的边界情况,比如前面的BUG(没有合理的更改分组情况,只是v-model改了任务自己的的状态)。

再来聊一下两种实现形式的方法触发次数(change事件和watchEffect),你可以回想你熟悉的业务场景,并不局限于本Demo的简单场景。 如果task任务的状态isCompleted不只是由checkbox切换呢,比如我又请求了一堆数据,老板觉得我任务完成了,修改了isCompleted状态……,你会发现很多时候你无法枚举所有情况,最终的落地实现可能是一个相似度极高的isCompleted切换要写很多次,甚至isCompleted没有改变也会不小心触发了对应的方法。

综合分析,将逻辑和数据结构等的复杂处理交给Vue,由reactive和watchEffect去精确的控制,而不是由用户的操作等等去触发一些逻辑。由事件驱动的理念转变为数据驱动的理念(这里的数据驱动不是常说的数据变更引发Dom变更)。

拆分组件、函数

在落地实现前,以函数式编程的理念设计好业务逻辑,最后的setup入口函数是这样的

export default defineComponent({
  name: 'TaskList',
  setup: (props, ctx) => {
    const { taskItemMap, request, handleDelItem } = useTaskList()
    const { activeTab, activeSet, handleChangeTab } = useTab(taskItemMap)
    const { task, submit } = useForm(taskItemMap)
    return { taskItemMap, activeTab, activeSet, task, handleDelItem, handleChangeTab, submit }
  }
})

Vue3带来了什么

跳过性能等老生常谈的话题,Vue3对我来说,在理念和应用上升华的点有

  • reactive和Composition Api人如其名,可以任意组合,充分发挥响应式变量的能力,从简单的表现在Dom的响应式更进一步。
  • 自定义createRenderer带来了无限的可能。
  • 回归Js函数式编程的理念。
  • TS的支持

最后

Vue3内部代码重构了,并且在TS的加持下更方便开发者理解,所以尽量去研读源码,当你明白了Vue3内部是如何实现的,那时你的能力和思维都会提升一个档次。

这个Demo只是为了让大家能够理解Vue3 reactive + Composition Api的理念,Demo本身没有难以理解和炫技的地方,如果能够帮助你入手和理解Vue3,可以帮我点个赞💓。由于最佳实现方式还没有定论,如果文中哪里写得不好可以在评论区指出😂。

App Demo 最终效果 闪电GIF👍

TaskList Demo 效果图 几个传送门

以上