写在前面
- 掘金社区已经有一大堆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
createRendererAPI from@vue/runtime-coreto 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函数接受两个参数,props和ctx,得益于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为例,任务的状态发生了trueorfalse的切换,导致了逻辑上或数据的组织结构上发生了变化,即任务它所处的分组要及时改变,这个就是一个副作用。
在复杂业务下,我们有时候很难去通过某个具体方法去模拟这个副作用。这和我们常用的
computed和watch都不太一样,还是以前面的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👍
几个传送门
以上