实践:Vue3.0 —— ToDoList

Vue3Example
案例

通过ToDoList 去熟悉 Composition API 的使用

# 功能演示

代办事项清单

  • 添加代办事项
    • 在文本框中输入待办事项,回车之后会显示到下面的列表中
  • 删除代办事项
    • 列表中的代办事情可以删除
    • 可以清除所有已完成的代办事项。
  • 编辑代办事项
    • 可以点击前面的标记已经完成,下面会显示有两项未完成。
    • 有一个全选按钮,点击所有的待办事项全部完成。
    • 标记可以进行取消。
  • 切换代办事项
    • 可以切换列表的状态,可以查看全部列表,可以单独查看未完成列表,也可以查看已完成列表
  • 存储代办事项
    • 刷新浏览器,待办事项还在,对数据进行了本地存储。

# 项目结构

我们使用vue脚手架来创建项目,首先要升级vue-cli,升级到4.5以上的版本,新版本在创建项目的时候可以选择Vue3.0.

两种方法创建结构:

  • 将模板下载下来 vue3-todolist-demo-temp (opens new window)
  • 如果想要重新创建项目,那么全局安装好vue-cli之后,使用vue -V查看版本,在4.5以上的就可以创建项目vue create todolist,完成之后将上面模板中的index.css拷贝到assets中,将Vue.app的文件进行粘贴,成为初始状态。

然后启动npm run serve可以看到项目初始的样子:

ToDoList

# 添加代办事项

  • 文本框双向绑定input
  • 绑定keyup事件,按回车的时候执行addTodo方法
<input
    class="new-todo"
    placeholder="What needs to be done?"
    autocomplete="off"
    autofocus
    v-model="input"
    @keyup.enter="addTodo"
    >
1
2
3
4
5
6
7
8
  • 添加useAdd方法,传入代办列表数组todos,返回input和addTodo方法
  • 里面创建响应式数据input,并对用户输入的input进行判断处理
  • 定义执行函数addTodo,执行之后将数据添加到数组中
  • 添加完成之后清空文本框
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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  • 在setup中创建响应式数据todos
  • 将useAdd调用并将其内容结构到返回的数据中
export default {
  name: 'App',
  setup () {
    // 定义一个响应式数据todos,默认空数组
    const todos = ref([])
    // setup返回的成员在模板和组件的其他位置都可以使用
    return {
      // 因为todos在列表中使用,所以这里也返回
      todos,
      // 将input和addTodo直接解构返回
      ...useAdd(todos)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • 循环遍历todos,将内容展示到列表中
<!-- 代办事项列表 -->
<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

这样,我们就可以在浏览器中进行测试

ToDoList

# 删除代办事项

  • 找到每一列的删除按钮,绑定remove事件,并把当前的todo传入
<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • 定义一个单独的方法useRemove,里面定义一个remove方法并返回
  • 在remove方法中,找到删除项对于整个数组的索引,并删除
  • 在setup的return中解构返回
// 删除代办事项 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)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# 编辑代办事项

  • 双击待办事项,出现文本框,获取焦点。
  • 编辑回车或者失去焦点,修改成功
  • 编辑之后ESC,不修改
  • 编辑清空数据,意味着这项删除

先写出编辑的函数

// 编辑代办事项 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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

然后在setup函数中调用,并改变remove的调用位置

setup () {
    const todos = ref([])
    // 将remove函数解构出来
    const { remove } = useRemove(todos)
    return {
      todos,
      // 这里单独返回
      remove,
      ...useAdd(todos),
      // 将remove传入
      ...useEdit(remove)
    }
  }
1
2
3
4
5
6
7
8
9
10
11
12
13

之后在模板中使用

<!-- 当前是编辑状态的时候,给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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

打开浏览器发现,输入内容的时候,输入内容焦点就自动消失了,每次输入内容li重新生成导致文本框也重新生成了。这是因为key绑定的是todo.text,todo.text的值重新改变了,那么vnode就会比较不一样就更新了,所以这里将key改为todo,因为每一个todo都是不一样的。

<li
  v-for="todo in todos"
  :key="todo"
  :class="{ editing: todo === editingTodo }"
>
1
2
3
4
5

下面实现一下编辑文本框获取焦点的功能。需要用到自定义指令。

<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

下面我们去注册自定义指令

export default {
  name: 'App',
  setup () {
    ...
  },
  // 注册指令
  directives: {
    // v- 可以省略,el是指令修饰的元素,binding可以获取一些参数
    editingFocus: (el, binding) => {
      // binding.value是true说明当前文本框是编辑文本框,让其获取焦点
      binding.value && el.focus()
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

打开浏览器可以看到功能完成。

# 切换代办事项状态

  • 点击全局checkbox,改变所有待办状态(用计算属性更好)
  • all/active/completed三个栏切换待办状态
  • 其它
    • 显示未完成代办项个数
    • 移除所有完成的项目
    • 如果没有代办项,隐藏main和footer

# 全局checkbox改变代办状态

// 切换代办项完成状态 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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

然后在setup中注册返回

setup () {
    const todos = ref([])
    const { remove } = useRemove(todos)
    return {
      todos,
      remove,
      ...useAdd(todos),
      ...useEdit(remove),
      // 将切换状态返回,并传入todos
      ...useFilter(todos)
    }
},
1
2
3
4
5
6
7
8
9
10
11
12

在模板中绑定allDone和completed,并且修改class类样式。

<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# all/active/completed三个栏切换待办状态

  1. 先定义事件对应的处理函数
// 定义hashChange对应的事件处理函数
const onHashChange = () => {}

// 挂载事件
onMounted(() => {
    // 监听hashChange事件
    window.addEventListener('hashchange', onHashChange)
    // 页面首次加载的时候需要传todos事件
    onHashChange()
})

// 移除事件
onUnmounted(() => {
    window.removeEventListener('hashchange', onHashChange)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  1. 在onHashChange中去除#/,并定义对应的过滤函数
// 和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('#/', '')
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  1. 定义一个响应式的变量type,每次修改hash就重新渲染函数,所以还需要定义一个计算属性filteredTodos,获取对应的过滤后的列表。
// 定义一个响应式对象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 = ''
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  1. 将其return返回,看一下全部的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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
  1. 将li标签的v-for换一下变量
<li
  v-for="todo in filteredTodos"
  :key="todo"
  :class="{ editing: todo === editingTodo, completed: todo.completed }"
>
1
2
3
4
5
  1. 这里打开浏览器,就完成了我们需要的需求。

下面重新捋一下执行过程:

  1. 点击过滤的超链接会触发hashChange事件,会调用onHashChange函数。
  2. onHashChange里面将#/去掉,之后将hash值去找过滤方法,如果找到了就直接把hash值赋值给type,type的值发生变化的时候会重新渲染模板,重新渲染模板当执行到v-for的时候会调用计算属性
  3. 计算属性里面type是响应式的,所以会去filter中重新执行方法找到对应的列表渲染。
  4. 如果hash没有找到,可能是第一次加载也可能hash输入错误,这时type默认是all,然后将hash值设为空。

# 其他 —— 显示待办事项个数

在useFilter函数中定义一个计算属性remainingCount

// 计算属性,直接使用filter的active方法获取个数
const remainingCount = computed(() => filter.active(todos.value).length)

return {
    allDone,
    filteredTodos,
    // 返回计算属性
    remainingCount
}
1
2
3
4
5
6
7
8
9

然后在模板中引用

<span class="todo-count">
    <!-- 显示未完成代办项个数,大于两个有复数形式 -->
    <strong>{{ remainingCount }}</strong> item{{ remainingCount > 1 ? 's' : ''}} left
</span>
1
2
3
4

# 其他 —— 删除已完成的代办事项功能

模板中添加点击事件

<!-- 给清空已完成事项添加点击事件removeCompleted -->
<button class="clear-completed" @click="removeCompleted">
Clear completed
</button>
1
2
3
4

因为是和删除相关的,所以这里就写到useRemove函数中

const useRemove = todos => {
  // 定义一个方法remove,传入参数要删除的那一项
  const remove = todo => {
    ...
  }
  // 删除已完成的代办事项
  const removeCompleted = () => {
    // 将过滤了已完成之后的列表赋值给todos
    todos.value = todos.value.filter(todo => !todo.completed)
  }
  return {
    remove,
    removeCompleted
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

然后在setup中返回

setup () {
    const todos = ref([])
    // 将remove和removeCompleted函数解构出来
    const { remove, removeCompleted } = useRemove(todos)
    // setup返回的成员在模板和组件的其他位置都可以使用
    return {
      todos,
      remove,
      // 将removeCompleted返回
      removeCompleted,
      ...useAdd(todos),
      ...useEdit(remove),
      ...useFilter(todos)
    }
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 其他 —— 没有代办实现的时候的样式

  1. 首先是如果没有列表的时候,只显示代办事项的文本框。
<!--count是代办事项的个数,如果是0就不展示,其他数字就展示-->
<section class="main" v-show="count">
...
</section>
<footer class="footer" v-show="count">
...
</footer>
1
2
3
4
5
6
7

count必须是一个计算属性,因为其依赖响应式数据todos,所以在useFilter里面添加count的计算属性

// 计算属性,所有列表的个数
const count = computed(() => todos.value.length)

// 返回count
return {
    allDone,
    filteredTodos,
    remainingCount,
    count
}
1
2
3
4
5
6
7
8
9
10
  1. 如果没有已完成代办事项的时候,删除已完成的按钮不能显示,所以这里比较未完成和总数的和即可
<!-- 给清空已完成事项添加点击事件removeCompleted
  如果总数比未完成的待办事项多,就说明有已完成代办事项,就展示 -->
<button class="clear-completed" @click="removeCompleted" v-show="count > remainingCount">
Clear completed
</button>
1
2
3
4
5

# 存储代办事项

把代办事项存储到localStorage中,防止刷新的时候丢失数据。

操作本地存储的模块,在src下面创建utils文件夹,里面创建useLocalStorage.js的文件,先将JSON.parse和JSON.stringify

// 将字符串转换成对象
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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

然后我们要导出一个函数useLocalStorage,这个函数用于设置本地存储和从本地存储获取值

// 设置本地存储和从本地存储获取值
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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

之后在App.vue中导入这个模块

// 导入模块,这个模块返回一个函数,调用的结果里面存储了setItem和getItem
import useLocalStorage from './utils/useLocalStorage'
const storage = useLocalStorage()
1
2
3

我们在添加,修改,删除的时候都要修改存储,那样太麻烦了,我们可以简单点,使用watchEffect,他可以监视数据的变化,如果数据变化可以执行相应的操作,当页面首次加载的时候,先从本地获取数据,没有数据就初始化一个空数组。这些可以封装成一个函数

// 存储待办事项
const useStorage = () => {
  // 定义一个常量KEY,本地存储对应的键
  const KEY = 'TODOKEYS'
  // 创建响应式的todos,首先从本地去获取数据,没有就初始化一个空数组
  const todos = ref(storage.getItem(KEY) || [])
  // watchEffect会监视todos数据,如果todos数据改变了,
  // 就会调用这个函数,我们就存储数据
  watchEffect(() => {
    storage.setItem(KEY, todos.value)
  })

  return todos
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

这里将setup中的todos修改一下,改成useStorage中获取的数据

setup () {
    // 开始的时候要去storage中查找数据
    const todos = useStorage()
    ...
}
1
2
3
4
5

这个时候刷新浏览器,就会发现数据并没有消失,状态都进行了保存。

# 完整项目地址

https://github.com/a1burning/vue3.0-todolist

更新时间: 2022-01-10 09:52