Appearance
实践:Vue3.0 —— ToDoList 案例
通过ToDoList 去熟悉 Composition API 的使用
功能演示
代办事项清单
- 添加代办事项
- 在文本框中输入待办事项,回车之后会显示到下面的列表中
- 删除代办事项
- 列表中的代办事情可以删除
- 可以清除所有已完成的代办事项。
- 编辑代办事项
- 可以点击前面的标记已经完成,下面会显示有两项未完成。
- 有一个全选按钮,点击所有的待办事项全部完成。
- 标记可以进行取消。
- 切换代办事项
- 可以切换列表的状态,可以查看全部列表,可以单独查看未完成列表,也可以查看已完成列表
- 存储代办事项
- 刷新浏览器,待办事项还在,对数据进行了本地存储。
项目结构
我们使用vue脚手架来创建项目,首先要升级vue-cli,升级到4.5以上的版本,新版本在创建项目的时候可以选择Vue3.0.
两种方法创建结构:
- 将模板下载下来 vue3-todolist-demo-temp
- 如果想要重新创建项目,那么全局安装好vue-cli之后,使用
vue -V
查看版本,在4.5以上的就可以创建项目vue create todolist
,完成之后将上面模板中的index.css拷贝到assets中,将Vue.app的文件进行粘贴,成为初始状态。
然后启动npm run serve
可以看到项目初始的样子:
添加代办事项
- 文本框双向绑定input
- 绑定keyup事件,按回车的时候执行addTodo方法
html
<input
class="new-todo"
placeholder="What needs to be done?"
autocomplete="off"
autofocus
v-model="input"
@keyup.enter="addTodo"
>
- 添加useAdd方法,传入代办列表数组todos,返回input和addTodo方法
- 里面创建响应式数据input,并对用户输入的input进行判断处理
- 定义执行函数addTodo,执行之后将数据添加到数组中
- 添加完成之后清空文本框
js
import { ref } from 'vue'
import './assets/index.css'
// 添加代办事项 useAdd
// 这个函数需要一个参数,这个参数是存放所有代办事项的数组,我们最终要把新增的代办事项添加到数组中
const useAdd = todos => {
// v-model绑定的input,所以通过ref创建响应式数据,默认值是空字符串
// 这里输出ref后添加Tab键就可以自动导入,
const input = ref('')
// 回车之后执行函数addTodo
const addTodo = () => {
// 因为我们绑定的是input,input是ref创建的响应式对象,所以这里要判断其value值
// 这里如果input的value有值就去除前后空格
const text = input.value && input.value.trim()
// 判断如果没有输入内容直接返回
if(text.length === 0) return
// 给数组添加一个新值,并且放到其最前面
// 因为todos是响应式数据,所以需要给其value属性添加
todos.value.unshift({
// 事项内容
text,
// 是否完成,初始值都是未完成
completed: false
})
// 输入完成之后input框自动清空
input.value = ''
}
// 将input和addTodo返回
return {
input,
addTodo
}
}
- 在setup中创建响应式数据todos
- 将useAdd调用并将其内容结构到返回的数据中
js
export default {
name: 'App',
setup () {
// 定义一个响应式数据todos,默认空数组
const todos = ref([])
// setup返回的成员在模板和组件的其他位置都可以使用
return {
// 因为todos在列表中使用,所以这里也返回
todos,
// 将input和addTodo直接解构返回
...useAdd(todos)
}
}
}
- 循环遍历todos,将内容展示到列表中
html
<!-- 代办事项列表 -->
<ul class="todo-list">
<li
v-for="todo in todos"
:key="todo.text"
>
<div class="view">
<input class="toggle" type="checkbox">
<label>{{ todo.text }}</label>
<button class="destroy"></button>
</div>
<input class="edit" type="text">
</li>
</ul>
这样,我们就可以在浏览器中进行测试
删除代办事项
- 找到每一列的删除按钮,绑定remove事件,并把当前的todo传入
html
<li
v-for="todo in todos"
:key="todo.text"
>
<div class="view">
<!-- checkbox -->
<input class="toggle" type="checkbox">
<!-- content -->
<label>{{ todo.text }}</label>
<!-- 删除,点击之后删除这一项,传入当前todo -->
<button class="destroy" @click="remove(todo)"></button>
</div>
<input class="edit" type="text">
</li>
- 定义一个单独的方法useRemove,里面定义一个remove方法并返回
- 在remove方法中,找到删除项对于整个数组的索引,并删除
- 在setup的return中解构返回
js
// 删除代办事项 useRemove
// 需要一个参数,从todos数组中删除
const useRemove = todos => {
// 定义一个方法remove,传入参数要删除的那一项
const remove = todo => {
// 找todo在todos数组中的索引
const index = todos.value.indexOf(todo)
// 将todos中的指定项删除
todos.value.splice(index, 1)
}
// 返回remove方法
return {
remove
}
}
export default {
name: 'App',
setup () {
const todos = ref([])
return {
todos,
...useAdd(todos),
// 将remove解构返回
...useRemove(todos)
}
}
}
编辑代办事项
- 双击待办事项,出现文本框,获取焦点。
- 编辑回车或者失去焦点,修改成功
- 编辑之后ESC,不修改
- 编辑清空数据,意味着这项删除
先写出编辑的函数
js
// 编辑代办事项 useEdit
// 需要一个参数 remove函数在编辑的时候使用,所以这里传入
const useEdit = remove => {
// 定义两个数据
// 编辑之前的文本
let beforeEditingText = ''
// 编辑状态,响应式的,变化的时候界面上要控制文本框的显示和隐藏
const editingTodo = ref(null)
// 进入编辑 —— 记录当前的编辑状态和text属性
// 接收参数todo,编辑的todo对象
const editTodo = todo => {
// 之前的文本
beforeEditingText = todo.text
// 当前进入的编辑状态
editingTodo.value = todo
}
// 完成编辑 —— 接收编辑的todo对象
const doneEdit = todo => {
// 如果value没有值,直接返回
if (!editingTodo.value) return
// text去掉前后空格
todo.text = todo.text.trim()
// 如果没有内容就删除当前对象
todo.text || remove(todo)
// 编辑完成之后将状态取消,设置为null
editingTodo.value = null
}
// 取消编辑 —— 接收编辑的todo对象
const cancelEdit = todo => {
// 将状态取消
editingTodo.value = null
// 将文本还原成之前的文本
todo.text = beforeEditingText
}
// 返回使用的数据
return {
// 记录编辑状态
editingTodo,
// 三个函数
editTodo,
doneEdit,
cancelEdit
}
}
然后在setup函数中调用,并改变remove的调用位置
js
setup () {
const todos = ref([])
// 将remove函数解构出来
const { remove } = useRemove(todos)
return {
todos,
// 这里单独返回
remove,
...useAdd(todos),
// 将remove传入
...useEdit(remove)
}
}
之后在模板中使用
html
<!-- 当前是编辑状态的时候,给li设置editing的类样式,
当前的todo是编辑项的时候,就可以显示文本框 -->
<li
v-for="todo in todos"
:key="todo.text"
:class="{ editing: todo === editingTodo }"
>
<div class="view">
<input class="toggle" type="checkbox">
<!-- content,双击的时候进入编辑 -->
<label @dblclick="editTodo(todo)">{{ todo.text }}</label>
<button class="destroy" @click="remove(todo)"></button>
</div>
<!-- 编辑:
双向绑定text属性
键盘输入enter和失去焦点的时候编辑完成
键盘输入esc的时候取消编辑 -->
<input
class="edit"
type="text"
v-model="todo.text"
@keyup.enter="doneEdit(todo)"
@blur="doneEdit(todo)"
@keyup.esc="cancelEdit(todo)">
</li>
打开浏览器发现,输入内容的时候,输入内容焦点就自动消失了,每次输入内容li重新生成导致文本框也重新生成了。这是因为key绑定的是todo.text,todo.text的值重新改变了,那么vnode就会比较不一样就更新了,所以这里将key改为todo,因为每一个todo都是不一样的。
html
<li
v-for="todo in todos"
:key="todo"
:class="{ editing: todo === editingTodo }"
>
下面实现一下编辑文本框获取焦点的功能。需要用到自定义指令。
html
<li
v-for="todo in todos"
:key="todo"
:class="{ editing: todo === editingTodo }"
>
<div class="view">
<input class="toggle" type="checkbox">
<label @dblclick="editTodo(todo)">{{ todo.text }}</label>
<button class="destroy" @click="remove(todo)"></button>
</div>
<!-- 编辑:
如果当前的文本框正在编辑,那么就通过自定义指令获取焦点 -->
<input
class="edit"
type="text"
v-editing-focus="todo === editingTodo"
v-model="todo.text"
@keyup.enter="doneEdit(todo)"
@blur="doneEdit(todo)"
@keyup.esc="cancelEdit(todo)">
</li>
下面我们去注册自定义指令
js
export default {
name: 'App',
setup () {
...
},
// 注册指令
directives: {
// v- 可以省略,el是指令修饰的元素,binding可以获取一些参数
editingFocus: (el, binding) => {
// binding.value是true说明当前文本框是编辑文本框,让其获取焦点
binding.value && el.focus()
}
}
}
打开浏览器可以看到功能完成。
切换代办事项状态
- 点击全局checkbox,改变所有待办状态(用计算属性更好)
- all/active/completed三个栏切换待办状态
- 其它
- 显示未完成代办项个数
- 移除所有完成的项目
- 如果没有代办项,隐藏main和footer
全局checkbox改变代办状态
js
// 切换代办项完成状态 useFilter —— 接收参数todos
const useFilter = todos => {
// 创建计算属性allDone,computed然后按Tab键上面可以自动导入
// 计算属性中传get和set,
const allDone = computed({
// 有未完成的代办项返回false,所有代办项为完成的返回true
get () {
// filter方法过滤未完成的待办事项并获取其个数
return !todos.value.filter(todo => !todo.completed).length
},
set (value) {
// 遍历所有的todos,然后找到待办事项的completed的属性设置为传过来的value(true/false)
todos.value.forEach(todo => {
todo.completed = value
})
}
})
// 返回allDone
return {
allDone
}
}
然后在setup中注册返回
js
setup () {
const todos = ref([])
const { remove } = useRemove(todos)
return {
todos,
remove,
...useAdd(todos),
...useEdit(remove),
// 将切换状态返回,并传入todos
...useFilter(todos)
}
},
在模板中绑定allDone和completed,并且修改class类样式。
html
<section class="main">
<!-- 控制所有的checkbox,下面的label就是控制这个本地的checkbox,绑定allDone -->
<input id="toggle-all" class="toggle-all" v-model="allDone" type="checkbox">
<label for="toggle-all">Mark all as complete</label>
<!-- 代办事项列表 -->
<ul class="todo-list">
<!-- 如果选中,当前的completed可以控制完成没完成的样式 -->
<li
v-for="todo in todos"
:key="todo"
:class="{ editing: todo === editingTodo, completed: todo.completed }"
>
<div class="view">
<!-- checkbox,当前代办项是否完成,双向绑定completed -->
<input class="toggle" type="checkbox" v-model="todo.completed">
<label @dblclick="editTodo(todo)">{{ todo.text }}</label>
<button class="destroy" @click="remove(todo)"></button>
</div>
<input
class="edit"
type="text"
v-editing-focus="todo === editingTodo"
v-model="todo.text"
@keyup.enter="doneEdit(todo)"
@blur="doneEdit(todo)"
@keyup.esc="cancelEdit(todo)">
</li>
</ul>
</section>
all/active/completed三个栏切换待办状态
- 先定义事件对应的处理函数
js
// 定义hashChange对应的事件处理函数
const onHashChange = () => {}
// 挂载事件
onMounted(() => {
// 监听hashChange事件
window.addEventListener('hashchange', onHashChange)
// 页面首次加载的时候需要传todos事件
onHashChange()
})
// 移除事件
onUnmounted(() => {
window.removeEventListener('hashchange', onHashChange)
})
- 在onHashChange中去除
#/
,并定义对应的过滤函数
js
// 和hash的all、active、completed同名的过滤函数
const filter = {
// 所有的
all: list => list,
// 所有未完成的
active: list => list.filter(todo => !todo.completed),
// 所有已完成的
completed: list => list.filter(todo => todo.completed)
}
// 定义hashChange对应的事件处理函数
const onHashChange = () => {
// 将 #/ 替换成空字符串
const hash = window.location.hash.replace('#/', '')
}
- 定义一个响应式的变量type,每次修改hash就重新渲染函数,所以还需要定义一个计算属性filteredTodos,获取对应的过滤后的列表。
js
// 定义一个响应式对象all,在onHashChange中获取值,触发更新操作,默认是all
const type = ref('all')
// 提供一个计算属性,type的值发生变化的之后,执行filter方法修改列表
const filteredTodos = computed(() => filter[type.value](todos.value))
// 定义hashChange对应的事件处理函数
const onHashChange = () => {
// 将 #/ 替换成空字符串
const hash = window.location.hash.replace('#/', '')
// 根据hash值如果能找到对应方法,就赋值给type
if (filter[hash]) {
type.value = hash
// filter中找不到对应的方法,可能是页面首次加载,可能是hash不合法
} else {
// 默认为all,并把hash清空
type.value = 'all'
window.location.hash = ''
}
}
- 将其return返回,看一下全部的js代码
js
// 切换代办项完成状态 useFilter —— 接收参数todos
const useFilter = todos => {
const allDone = computed({
...
})
// 和hash的all、active、completed同名的过滤函数
const filter = {
// 所有的
all: list => list,
// 所有未完成的
active: list => list.filter(todo => !todo.completed),
// 所有已完成的
completed: list => list.filter(todo => todo.completed)
}
// 定义一个响应式对象all,在onHashChange中获取值,触发更新操作,默认是all
const type = ref('all')
// 提供一个计算属性,type的值发生变化的之后,执行filter方法修改列表
const filteredTodos = computed(() => filter[type.value](todos.value))
// 定义hashChange对应的事件处理函数
const onHashChange = () => {
// 将 #/ 替换成空字符串
const hash = window.location.hash.replace('#/', '')
// 根据hash值如果能找到对应方法,就赋值给type
if (filter[hash]) {
type.value = hash
// filter中找不到对应的方法,可能是页面首次加载,可能是hash不合法
} else {
// 默认为all,并把hash清空
type.value = 'all'
window.location.hash = ''
}
}
// 挂载事件
onMounted(() => {
// 监听hashChange事件
window.addEventListener('hashchange', onHashChange)
// 页面首次加载的时候需要传todos事件
onHashChange()
})
// 移除事件
onUnmounted(() => {
window.removeEventListener('hashchange', onHashChange)
})
return {
allDone,
filteredTodos
}
}
- 将li标签的v-for换一下变量
html
<li
v-for="todo in filteredTodos"
:key="todo"
:class="{ editing: todo === editingTodo, completed: todo.completed }"
>
- 这里打开浏览器,就完成了我们需要的需求。
下面重新捋一下执行过程:
- 点击过滤的超链接会触发hashChange事件,会调用onHashChange函数。
- onHashChange里面将
#/
去掉,之后将hash值去找过滤方法,如果找到了就直接把hash值赋值给type,type的值发生变化的时候会重新渲染模板,重新渲染模板当执行到v-for的时候会调用计算属性 - 计算属性里面type是响应式的,所以会去filter中重新执行方法找到对应的列表渲染。
- 如果hash没有找到,可能是第一次加载也可能hash输入错误,这时type默认是all,然后将hash值设为空。
其他 —— 显示待办事项个数
在useFilter函数中定义一个计算属性remainingCount
js
// 计算属性,直接使用filter的active方法获取个数
const remainingCount = computed(() => filter.active(todos.value).length)
return {
allDone,
filteredTodos,
// 返回计算属性
remainingCount
}
然后在模板中引用
html
<span class="todo-count">
<!-- 显示未完成代办项个数,大于两个有复数形式 -->
<strong>{{ remainingCount }}</strong> item{{ remainingCount > 1 ? 's' : ''}} left
</span>
其他 —— 删除已完成的代办事项功能
模板中添加点击事件
html
<!-- 给清空已完成事项添加点击事件removeCompleted -->
<button class="clear-completed" @click="removeCompleted">
Clear completed
</button>
因为是和删除相关的,所以这里就写到useRemove函数中
js
const useRemove = todos => {
// 定义一个方法remove,传入参数要删除的那一项
const remove = todo => {
...
}
// 删除已完成的代办事项
const removeCompleted = () => {
// 将过滤了已完成之后的列表赋值给todos
todos.value = todos.value.filter(todo => !todo.completed)
}
return {
remove,
removeCompleted
}
}
然后在setup中返回
js
setup () {
const todos = ref([])
// 将remove和removeCompleted函数解构出来
const { remove, removeCompleted } = useRemove(todos)
// setup返回的成员在模板和组件的其他位置都可以使用
return {
todos,
remove,
// 将removeCompleted返回
removeCompleted,
...useAdd(todos),
...useEdit(remove),
...useFilter(todos)
}
},
其他 —— 没有代办实现的时候的样式
- 首先是如果没有列表的时候,只显示代办事项的文本框。
html
<!--count是代办事项的个数,如果是0就不展示,其他数字就展示-->
<section class="main" v-show="count">
...
</section>
<footer class="footer" v-show="count">
...
</footer>
count必须是一个计算属性,因为其依赖响应式数据todos,所以在useFilter里面添加count的计算属性
js
// 计算属性,所有列表的个数
const count = computed(() => todos.value.length)
// 返回count
return {
allDone,
filteredTodos,
remainingCount,
count
}
- 如果没有已完成代办事项的时候,删除已完成的按钮不能显示,所以这里比较未完成和总数的和即可
html
<!-- 给清空已完成事项添加点击事件removeCompleted
如果总数比未完成的待办事项多,就说明有已完成代办事项,就展示 -->
<button class="clear-completed" @click="removeCompleted" v-show="count > remainingCount">
Clear completed
</button>
存储代办事项
把代办事项存储到localStorage中,防止刷新的时候丢失数据。
操作本地存储的模块,在src下面创建utils文件夹,里面创建useLocalStorage.js的文件,先将JSON.parse和JSON.stringify
js
// 将字符串转换成对象
function parse (str) {
let value
try {
value = JSON.parse(str)
} catch {
value = null
}
return value
}
// 将对象转换成字符串
function stringify (obj) {
let value
try {
value = JSON.stringify(obj)
} catch {
value = null
}
return value
}
然后我们要导出一个函数useLocalStorage,这个函数用于设置本地存储和从本地存储获取值
js
// 设置本地存储和从本地存储获取值
export default function useLocalStorage () {
// 设置本地存储
function setItem (key, value) {
value = stringify(value)
window.localStorage.setItem(key, value)
}
// 获取本地存储
function getItem (key) {
let value = window.localStorage.getItem(key)
// 如果有值就parse转换成对象,否则返回null
if (value) {
value = parse(value)
}
return value
}
return {
setItem,
getItem
}
}
之后在App.vue中导入这个模块
js
// 导入模块,这个模块返回一个函数,调用的结果里面存储了setItem和getItem
import useLocalStorage from './utils/useLocalStorage'
const storage = useLocalStorage()
我们在添加,修改,删除的时候都要修改存储,那样太麻烦了,我们可以简单点,使用watchEffect,他可以监视数据的变化,如果数据变化可以执行相应的操作,当页面首次加载的时候,先从本地获取数据,没有数据就初始化一个空数组。这些可以封装成一个函数
js
// 存储待办事项
const useStorage = () => {
// 定义一个常量KEY,本地存储对应的键
const KEY = 'TODOKEYS'
// 创建响应式的todos,首先从本地去获取数据,没有就初始化一个空数组
const todos = ref(storage.getItem(KEY) || [])
// watchEffect会监视todos数据,如果todos数据改变了,
// 就会调用这个函数,我们就存储数据
watchEffect(() => {
storage.setItem(KEY, todos.value)
})
return todos
}
这里将setup中的todos修改一下,改成useStorage中获取的数据
js
setup () {
// 开始的时候要去storage中查找数据
const todos = useStorage()
...
}
这个时候刷新浏览器,就会发现数据并没有消失,状态都进行了保存。