目录

🌕 Vue 脚手架案例

🔗 回顾组件化编程的流程:Vue 组件化编程

# 🌰 TodoList 基础框架

# 实现静态组件

拆分组件,按照局部功能或者位置划分:

  • TodoHeader.vue

  • TodoList.vue

  • TodoItem.vue

  • TodoFooter.vue

完成 TodoList 的静态页面效果。

image-20220402171901364

# 展示动态数据

TodoList 的项目数据的展示:

  • 一整个列表可以是一个数组;
  • 每个项目可以是一个对象,包括项目的编号、名称、状态。

数据保存在哪个位置的组件?

  • List 组件存储项目的数组。

# 初始化列表

  • 使用 TodoList 组件展示 Todo 项目,所以可以在 TodoList 中存放相关的数据:
data() {
  return {
    todos: [
      {id: '001', title: '吃饭', done: true},
      {id: '002', title: '学习', done: false},
      {id: '003', title: '睡觉', done: true},
    ]
  }
}
1
2
3
4
5
6
7
8
9

然后在 <Template> 中使用 v-for 循环:

<template>
  <ul class="todo-main">
    <TodoItem v-for="todo in todos" :key="todo.id" :todoObj="todo"/>
  </ul>
</template>
1
2
3
4
5

使用 :todoObj 传送 todo 对象数据到 TodoItem

  • 同时 TodoItem 接收 TodoList 中配置的数据:
props: ['todoObj']
1
<template>
  <li>
    <input type="checkbox" :checked="todoObj.done">
    <span>{{ todoObj.title }}</span>
  </li>
</template>
1
2
3
4
5
6

使用 :checked 动态根据数据发生变化。

# 添加项目功能

TodoHeader 中,输入框中使用 @keyup.enter 绑定按下回车键的事件为方法 add()

<input type="text" placeholder="请输入你的任务名称,按回车键确认" @keyup.enter="add"/>
1

将用户输入的数据包装为一个 todo 对象(使用 nanoid 库生成一个随机的 id

add(e) {
  const todo = {
    id: nanoid(), title: e.target.value, done: false
  }
  // console.log(todo)
}
1
2
3
4
5
6

由于 TodoHeaderTodoList 的关系是平行(兄弟)关系,迄今为止,所以在此先使用最初级的方法,将共同的数据 todos 放在共同的组件 App 中:

image-20220401233949017

点击查看
  • 首先将 todos 转移到 TodoHeader
export default {
  name: "App",
  components: {TodoHeader, TodoList, TodoFooter},
  data() {
    return {
      todos: [
        {id: '001', title: '吃饭', done: true},
        {id: '002', title: '学习', done: false},
        {id: '003', title: '睡觉', done: true},
      ]
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

使用 :todos=‘todos’ 传送:

<template>
  <div id="root">
    <div class="todo-container">
      <div class="todo-wrap">
        <TodoHeader/>
        <TodoList :todos="todos"/>
        <TodoFooter/>
      </div>
    </div>
  </div>
</template>
1
2
3
4
5
6
7
8
9
10
11
  • 然后在 TodoList 中使用 props 接收:
props:['todos']
1
<template>
  <ul class="todo-main">
    <TodoItem v-for="todo in todos" :key="todo.id" :todoObj="todo"/>
  </ul>
</template>
1
2
3
4
5
  • App 中写一个准备要传送到 TodoHeader 中要使用的方法:
 methods: {
  addTodo(todo){
    this.todos.unshift(todo);
  }
1
2
3
4
<TodoHeader :addTodo="addTodo"/>
1

参数 todo 表示要接收来自 TodoHeadertodo 对象,在 TodoHeader 中使用 props 接收这个方法,并且调用这个方法,传入输入的 todo 内容:

export default {
  name: "TodoHeader",
  props: ['addTodo'],
  methods: {
    add(e) {
      const todo = {
        id: nanoid(), title: e.target.value, done: false
      
      this.addTodo(todo)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
  • 随着在 App 的数据 todos 发生变化,模版中的内容会重新解析,此时 TodoList 收到的 todos 也会随之变化,此时 TodoList 中的模版也会重新解析,这样就实现了增加 todo 项目的功能。
  • 继续完善 TodoHeader 中的细节,给输入的内容是否为空进行判断,并且输入后将输入框清空,为了不操作原生 DOM 元素,把 title 数据进行绑定:

image-20220402000324574

点击查看
<input type="text" placeholder="请输入你的任务名称,按回车键确认" v-model="title" @keyup.enter="add"/>
1
export default {
  name: "TodoHeader",
  props: ['addTodo'],
  data() {
    return {
      title: ''
    }
  },
  methods: {
    add(e) {
      // 校验数据
      if (!this.title.trim()) return alert('输入的todo不能为空!')
      // 将数据包装为todo对象
      const todo = {
        id: nanoid(), title: e.target.value, done: false
      }
      // 通知App组件区添加一个todo对象
      this.addTodo(todo)
      this.title = '' // 请求输入框
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

注意

注意: App 组件传送的方法 addTodoTodoHeader 中的 methods 命名为 add 必须不能重名。因为 datamethodsprops 的数据届在 vc 实例对象中。

# 勾选状态功能

最初始的做法:

点击查看

由于根本上,要操作 todo 对象的组件应该是 App 组件,所以要在 App 中编写方法 checkTodo

checkTodo(id) {
  this.todos.forEach((todo) => {
    if (todo.id === id) todo.done = !todo.done
  })
}
1
2
3
4
5

并且,要获取到勾选的是哪一个项,就在哪一个项中调用此方法。组件的关系是 App > TodoList > TodoItem ,所以 App 要传送此方法给 TodoItem 组件使用,就要一层一层传。

先传给 TodoList ,然后通过 TodoList 传给 TodoItemTodoItem 通过 props 接收使用,在 TodoItem 中编写方法获取当前操作的 todo 项目的 id , 可以使用 @click 或者 @change (更方便),

<input type="checkbox" :checked="todoObj.done" @change="handleCheck(todoObj.id)">
1
props: ['todoObj', 'checkTodo'], // 声明接收todo对象
methods: {
  handleCheck(id){
    // 通知App组件将对应的todo对象的done值取反
    this.checkTodo(id)
  }
}
1
2
3
4
5
6
7

提示

上面 input 标签要实现动态更改 todo 项目的状态,可以想到将两个 :checked@change 结合,使用 v-model 绑定 done

<input type="checkbox" v-model="todo.done">
1

但是此时,在 TodoItem 中修改的值,是来自 props 获取的值,而 Vue 中原则上是不能修改 props 的值,但 Vue 只能监测浅层次的修改,这里修改的是对象里的值, Vue 无法检测得到,虽然可以实现功能,但是为了避免后期对象存储出问题,不建议如此实现。

# 删除项目功能

删除功能的实现,重中之重是在要实现删除功能的地方,获取到要删除的项目的 id

  • 在删除按钮处 TodoItem 组件中,获得 todo.id ,并且添加询问是否删除的判断:
<button class="btn btn-danger" @click="handleDelete(todoObj.id)">删除</button>
1
handleDelete(id) {
  if (confirm("是否删除本todo项目")){
    this.deleteTodo(id)
  }
}
1
2
3
4
5
  • 在 App 组件中编写实际删除的方法方法 deleteTodo :
deleteTodo(id) {
  this.todos = this.todos.filter((todo) => todo.id !== id)
}
1
2
3

与要实现勾选状态的功能相同,要将 App 组件中的方法传送给 todoitem ,要通过中间的 TodoList

# 底部统计与清除已完成项目功能

要统计已完成以及全部的个数:

  • 需要 App 组件将 todos 传递给 TodoFooter 组件, TodoFooter 组件使用 props 接收:
<TodoFooter :todos="todos"/>
1
  • 由于要计算已完成的项目的个数,因此可以使用 computed 计算属性:

    这里可以使用数组的方法 reduce 条件统计函数:

computed: {
  doneTotal() {
    return this.todos.reduce((pre, todo) => pre + (todo.done ? 1 : 0), 0)
  }
}
1
2
3
4
5
  • 完成细节部分:

    使代码更加规范,将统计项目的全部个数的数据也加入计算属性:

<span>已完成 {{ doneTotal }} / 全部 {{ total }}  </span>
1
total() {
  return this.todos.length
}
1
2
3
  • 展示当前是否全选或者全部未选,这时也使用计算属性,计算完成的数量是否与总数量相等的布尔值,同时注意当前所有项目数量是否大于 0 的情况:
isAll() {
  return this.doneTotal === this.total && this.total > 0
}
1
2
3
  • 完成勾选全选或者全不选的功能(全部完成或者全部未完成),此时涉及修改 todos 中的数据,所以在 App 组件中添加方法 checkAllTodo ,参数是 TodoFooter 中获取到的全选或者全不选的布尔值赋值给所有的 todo 项目:
checkAllTodo(done){
  this.todos.forEach(todo=>{
    todo.done = done
  })
}
1
2
3
4
5
  • 继续完善细节,由于在全选或者全不选框中涉及数据展示(展示当前是否有全选)与数据交互(全选或者全不选的功能),所以此时可以考虑使用 v-model ,这时 v-model 中可以绑定的数据是 isAll ,这是不能再使用简化版的计算属性,需要添加 gettersetter 方法,并且此时 set 方法获取的布尔值正是勾选框此时的 checked 值(不再需要获取原生 DOM 值):
isAll: {
  get() {
    return this.doneTotal === this.total && this.total > 0
  },
  set(checked) {
    this.checkAllTodo(value)
  }
}
1
2
3
4
5
6
7
8

(从中可以看出计算属性的用处)

  • 完成「清楚已完成任务」按钮功能,绑定一个方法 clearAll ,调用来自 App 组件的方法 clearAllTodo

App 组件中):

clearAllTodo() {
  this.todos = this.todos.filter((todo) => !todo.done)
}
1
2
3

TodoFooter 组件中):

<button class="btn btn-danger" @click="clearAll">清除已完成任务</button>
1
clearAll() {
  if (confirm("是否删除所有已完成的todo项目?")) {
    this.clearAllTodo()
  }
}
1
2
3
4
5

# TodoList 案例总结

  • Vue 组件化编程流程:

    • 拆分静态组件: 组件按照功能划分,注意组件的命名;

    • 实现动态的组件(动态的数据):

      • ❓ 数据的类型、名称;
      • ❓ 数据保存在哪个组件;
    • 考虑好数据的存放位置,数据是一个组件在用还是多个组件在用

      • 一个组件使用:放在组件自身
      • 多个组件使用:放在共同的父组件上状态提升);
    • 实现交互:从绑定事件监听开始;

  • 运用 props 配置实现组件间通信:

    • 父组件 ==> 子组件 间通信;
    • 子组件 ==> 父组件 间通信(要求父先给子一个函数,通过传参获取);
  • ⚠️ 使用 v-model 时要注意, v-model 绑定的值不能是 props 传过来的值(不能修改)。

    ⚠️ 若 props 传送过来的数值类型是对象,使用 v-model 修改对象中的属性 Vue 不会报错。

# 🌰 TodoList 案例完善

# 使用浏览器本地存储

🔗 🪐 Vue 浏览器本地存储

要使用浏览器本地存储,就要监测 todos 数据的增删改查的情况,这时可以使用 watch 监视,此时简写版默认监视只是监视对象的第一层;只有开启深度监视才能监视到修改对象中的数据。

  • 使用深度监视写入到本地存储:

    watch: {
      todos: {
        deep: true,
        handler(value) {
          localStorage.setItem('todos', JSON.stringify(value))
        }
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8

    只要操作的数据是 todos ,则参数 value 就是最新的 todos 数据,这时将最新的数据存储即可。

  • 为了读取存储在浏览器中的数据,在初始化中的 data(){} 配置项中读取:

    data() {
        return {
          todos: JSON.parse(localStorage.getItem('todos')) || []
        }
    },
    
    1
    2
    3
    4
    5

    注意要考虑到浏览器中没有保存任何数据的情况,此时 Vue 读取到 null 后,后续进行读取的一系列的操作会报错;

# 使用自定义事件

🔗 💫 Vue 组件自定义事件

TodoHeader 组件添加以及 TodoFooter 删除 todo 项目时,涉及到子组件向父组件通信,此时可以考虑通过使用自定义事件实现。

  • App 组件中,给 TodoHeader 绑定自定义事件 addTodo ,事件回调为 addTodo
<TodoHeader @addTodo="addTodo"/>
1
methods: {
    addTodo(todo) {
      this.todos.unshift(todo);
    },
}
1
2
3
4
5
  • TodoHeader 组件中,触发自定义事件 addTodo
methods: {
  add(e) {
    // 校验数据
    if (!this.title.trim()) return alert('输入的todo项目不能为空!')
    // 将数据包装为todo对象
    const todo = {
      id: nanoid(), title: e.target.value, done: false
    }
    // 通知App组件区添加一个todo对象
    this.$emit('addTodo', todo)
    this.title = '' // 请求输入框
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

  • App 组件中,给 TodoFooter 绑定自定义事件 checkAllTodoclearAllTodo ,事件回调为 checkAllTodoclearAllTodo
<TodoFooter :todos="todos" @checkAllTodo="checkAllTodo" @clearAllTodo="clearAllTodo"/>
1

TodoFooter 组件中,触发自定义事件 checkAllTodoclearAllTodo

computed: {
  isAll: {
      get() {
        return this.doneTotal === this.total && this.total > 0
      },
      set(checked) {
        this.$("checkAllTodo", checked)
      }
    }
}
1
2
3
4
5
6
7
8
9
10
methods: {
  clearAll() {
      if (confirm("是否删除所有已完成的todo项目?")) {
        this.$emit("clearAllTodo")
      }
    }
}
1
2
3
4
5
6
7

# 使用全局事件总线

TodoList 案例中,适合于全局事件总线的是组件 TodoItemApp 之间的通信。

  • 首先在入口文件 main.js 安装全局事件总线。
new Vue({
    el: '#app',
    render: h => h(app),
    beforeCreate() {
        Vue.prototype.$bus = this
    }
})
1
2
3
4
5
6
7
  • 不再使用 TodoList 作为中间组件通信。
  • 在要接收数据的 App 组件中绑定自定义事件,并且在 beforeDestroy 中解绑:
mounted() {
  this.$bus.$on('checkTodo', this.checkTodo)
  this.$bus.$on('deleteTodo', this.deleteTodo)
},
beforeDestroy() {
  this.$bus.$off('checkTodo')
  this.$bus.$off('deleteTodo')
}
1
2
3
4
5
6
7
8
  • 在要发送数据的 TodoItem 中,触发该自定义事件:
methods: {
  handleCheck(id) {
    // 通知App组件将对应的todo对象的值取反
    this.$bus.$emit('checkTodo', id)
  },
  handleDelete(id) {
    if (confirm("是否删除选择的todo项目?")) {
      this.$bus.$emit('deleteTodo', id)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11

# 使用消息订阅与发布

(其实消息的订阅与发布与全局事件总线相似,更加推荐直接使用在 Vue 中自带的全局事件总线)

App 组件与 TodoItem 组件之间的通信:

  • 首先在 App 组件引入 pubsub-js

mounted 中订阅消息:

mounted() {
  this.checkPubId = pubsub.subscribe('checkTodo', this.checkTodo)
  this.deletePubId = pubsub.subscribe('deleteTodo', this.deleteTodo)
}
1
2
3
4

methods 中编写回调函数:

methods: {
  	deleteTodo(_,id) {
      this.todos = this.todos.filter((todo) => todo.id !== id)
  }, 
    checkAllTodo(_,done) {
      this.todos.forEach(todo => {
        todo.done = done
      })
    }
}
1
2
3
4
5
6
7
8
9
10

这里的回调函数里应该第一个参数要为订阅的消息名称,为了不出现定义了参数而没有使用的情况而导致报错,这里可以使用 _ 占位。

beforeDestroy 中取消订阅:

beforeDestroy() {
  pubsub.unsubscribe(this.checkPubId)
  pubsub.unsubscribe(this.deletePubId)
}
1
2
3
4
  • TodoItem 发布消息:
methods: {
  handleCheck(id) {
    pubsub.publish('checkTodo', id)
  },
  handleDelete(id) {
    if (confirm("是否删除选择的todo项目?")) {
      pubsub.publish('deleteTodo', id)
    }
  }
}
1
2
3
4
5
6
7
8
9
10

# 编辑项目功能

要实现编辑功能,点击编辑按钮之后, todo 项目的标题变为输入框,要确认编辑则回车确认。

  • 既然要确认 todo 项是否处于编辑状态,所以每点一次编辑按钮就要在每个对象中添加一个新的属性 isEdit ,而添加属性不能直接添加,否则 Vue 不能监测到更新,模版不能重新渲染,所以要用 $set 添加属性。
handleEdit(todo) {
  if (Object.prototype.hasOwnProperty.call(todo, 'isEdit')) {
    todo.isEdit = true
  } else {
    this.$set(todo, 'isEdit', true)
  }
},
1
2
3
4
5
6
7

同时加入判断当前对象是否已经有了 isEdit ,以免在下一次点击编辑时重复设置,已有 isEdit 直接修改即可。

在原视频中,使用的方法是 todo.hasOwnProperty(‘isEdit’) 。在新的 ESLint 语法中,这种方法被禁止,对象不能直接调用这个方法。🔗 no-prototype-builtins - Rules - ESLint 中文 (opens new window) 所以利用原型对象调用。

  • 在模版中,要使原标题与输入框不同时显示,并且点击编辑按钮只显示输入框,简单实用 v-show 通过属性 isEdit 判断:
<span v-show="!todoObj.isEdit">{{ todoObj.title }}</span>
<input type="text"
       v-show="todoObj.isEdit"
       :value="todoObj.title">
1
2
3
4
  • 要实现应用修改后的项目标题,可以在输入框失去焦点时提交新的项目标题,使用 @Blur 绑定一个方法 handleBlur 处理提交功能:
<input type="text"
       v-show="todoObj.isEdit"
       :value="todoObj.title"
       @blur="handleBlur(todoObj, $event)">
1
2
3
4
handleBlur(todo, e) {
  todo.isEdit = false
  if (!e.target.value.trim()) return alert('输入的修改值不能为空!')
  this.$bus.$emit('updateTodo', todo.id, e.target.value)
}
1
2
3
4
5
  • 注意不能直接使用 todo.title 提交,因为此时输入框的值并没有绑定 todo.title (而不能使用 v-model ,因为不能直接修改 props 传来的值)。所以要使用调用原生 DOM 事件获取。
  • 要将提交的数据应用到原来的 todo 对象当中,使用全局事件总线,这里先触发了 updateTodo 这个事件,然后传入 id 和修改值。
  • App 组件中,绑定 updateTodo 这个事件接收修改值:
methods:{
  updateTodo(id, title) {
    this.todos.forEach((todo) => {
      if (todo.id === id) todo.title = title
    })
  } 
}
1
2
3
4
5
6
7
mounted() {
  this.$bus.$on('updateTodo', this.updateTodo)
},
beforeDestroy(){
  this.$bus.$off('updateTodo')
}
1
2
3
4
5
6
  • 继续添加细节,实现在添加按钮后,焦点会自动在输入框里的功能。

首先使用 ref 标识这个输入框:

<input type="text"
       v-show="todoObj.isEdit"
       :value="todoObj.title"
       @blur="handleBlur(todoObj, $event)"
       ref="inputTitle">
1
2
3
4
5

在处理点击编辑按钮后的方法中添加获取焦点代码:

handleEdit(todo) {
  if (Object.prototype.hasOwnProperty.call(todo, 'isEdit')) {
    todo.isEdit = true
  } else {
    this.$set(todo, 'isEdit', true)
  }
  this.$nextTick(function (){
    this.$refs.inputTitle.focus()
  })
}
1
2
3
4
5
6
7
8
9
10

注意直接在判断完后直接 this.$refs.inputTitle.focus() 是无效的,因为此时在运行完判断修改 isEdit 的值或者添加 isEdit 属性之后,Vue 不会重新解析模版。

使用 this.$nextTick 会在 DOM 节点更新完毕之后执行指定的回调函数。

# 添加动画与过渡效果

给添加 todo 项目和删除 todo 项目时,进入与退出时的动画效果。

第一种方法, 使用 <transition> 标签给每个项目单独添加动画:

  • TodoItem 中,使用 <transition> 标签给整个 todo 项目列表的标签 <li> 包裹起来:
<transition name="todo" appear>
  <li ...>
</transition>
1
2
3
  • 以使用第三方库 animate.js 为例,先在组件内引入库,然后配置 nameenter-active-classleave-active-class
<transition appear
            name="animate__animated animate__bounce"
            enter-active-class="animate__fadeInRight"
            leave-active-class="animate__fadeOutRight"
>
  <li ...>
</transition>
1
2
3
4
5
6
7

第二种方法,在 TodoList 组件中的引入 TodoItem 的标签部分,使用 <transition-group> ,因为这里使用 v-for 同时每个项目的有一个唯一的 key 符合条件:

引入 animate.js 库后:

<transition-group appear
            name="animate__animated animate__bounce"
            enter-active-class="animate__fadeInRight"
            leave-active-class="animate__fadeOutRight"
>
  <TodoItem v-for="todo in todos" :key="todo.id" :todoObj="todo"/>
</transition-group>
1
2
3
4
5
6
7
📢 上次更新: 2022/09/02, 10:18:16