[Vue3实操] 开发Todo List

前言

使用Vue3最新的Composition API,开发一个Todo List应用。

Todo List应用比较简单,不会使用其他复杂的Vue3组件,但这也使得当前应用与真实的应用脱钩,所以本文重点是体会Vue3的最新语法,下篇文章会阅读element-plus-admin开源项目的源码,从而掌握Vue3目前最近技术栈的使用方式。

所谓Todo List应用主要用于记录你需要完成的工作,完成后,将其打钩,最终效果如下:

1b5db7a58b9afd8147f501793abb65a7.png

写点HTML+CSS吧

先用yarn基于vite构建出vue-ts的项目目录,命令如下。

yarn create vite TodoList --template vue-ts
yarn && yarn dev

在开始编码前,最好弄一下原型稿,我个人为求方便,通常使用PPT来构建原型稿,专业的前端可能会使用专门的原型设计软件,与后端开发前写功能设计一个道理,前端开发前,弄好原型稿,从而从整体把握开发出网页的样式、配色以及可能需要需要的组件。

在弄原型稿时,要刻意关注页面由那几部分组成,一个复杂的业务,拆分到最后,很发现都是一个简单的HTML元素的组合使用,此外,原型稿对CSS编写也很有帮助,复杂的样式效果,拆分到最后也是一些简单的CSS属性。

60fe789181d000574ae5660e0ccba3e0.png首先,我们基于原型稿,通过HTML搭建类似的结构,直接全部写到App.vue的template中,代码如下:

<div class="main">
<div class="container">
    <h1>欢迎使用Todo List</h1>
    <div class="add-todo">
      <input type="text" name="todo" />
      <button>
        <!-- 按钮上的加号元素 -->
        <i class="add-icon"></i>
      </button>
    </div>

    <div class="filters">
      <span class="filter active">全部</span>
      <span class="filter">已完成</span>
      <span class="filter">未完成</span>
    </div>

    <div class="todo-list">
      <div class="todo-item">
        <!-- 使用label包裹input与span,后续用户无论点击input还是span,都会触发checkbox事件,从而实现勾选了input的效果 -->
        <label>
          <input type="checkbox" />
          写开发Todo List的文章
          <span class="check-button"></span>
        </label>
      </div>
      <div class="todo-item">
        <label>
          <input type="checkbox" />
          写开发Todo List的文章
          <span class="check-button"></span>
        </label>
      </div>
      <div class="todo-item">
        <label>
          <input type="checkbox" />
          写开发Todo List的文章
          <span class="check-button"></span>
        </label>
      </div>
    </div>
  </div>
</div>

运行项目,会得到如下效果d7b0c2a3e015964cbcd9ae009a5555e6.png这个阶段,不需要考虑HTML元素的样式,只需要将原型图里的组件一步步通过HTML搭建出来就好了,然后再写CSS去美化它。

CSS对很多后端同学来说,是难啃的骨头,其原因在于CSS属性多,而且就算记住了这些属性,也不知道如何配合使用,从而构建出美丽的样式。

我对CSS的思考是,要掌握CSS中比较核心的内容,比如CSS的布局与位置相关的内容,然后多看一些项目,去掌握感觉,当然最重要的是,你要先画原型图,在脑海中,比较清晰的知道自己需要的样子,然后再利用CSS属性一点点组合并配合Chrome的可见可得的特性,一点点组合出来,后续我也会写一篇文章记录我认为比较重要的CSS属性。

回到本文,Todo List的CSS样式如下:

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  font-family: Helvetica, "PingFang SC", "Microsoft Yahei", sans-serif;
}

/* 整个页面 */
.main {
  width: 100vw;
  min-height: 100vh;
  display: grid;
  align-items: center;
  justify-items: center;
  background: rgb(216, 243, 214);
}

.container {
  width: 60%;
  max-width: 400px;
  box-shadow: 0px 0px 24px rgba(26, 25, 25, 0.15);
  border-radius: 24px;
  padding: 48px 28px;
  background-color: rgb(229, 230, 235);
}

/* 标题 */
h1 {
  margin: 24px 0;
  font-size: 28px;
  color: #384280;
}

/* 添加框 */
.add-todo {
  position: relative;
  display: flex;
  align-items: center;
}

.add-todo input {
  padding: 16px 52px 16px 18px;
  border-radius: 48px;
  border: none;
  outline: none;
  box-shadow: 0px 0px 24px rgba(0, 0, 0, 0.08);
  width: 100%;
  font-size: 16px;
  color: #626262;
}

.add-todo button {
  width: 46px;
  height: 46px;
  border-radius: 50%;
  background: linear-gradient(#b6f1a2, #6df86d);
  border: none;
  outline: none;
  
  color: white;
  position: absolute;
  right: 0px;

  cursor: pointer;
}

.add-todo .add-icon {
  display: block;
  width: 100%;
  height: 100%;
  background: linear-gradient(#fff, #fff), linear-gradient(#fff, #fff);
  background-size: 50% 2px, 2px 50%;
  background-position: center;
  background-repeat: no-repeat;
}

/* 过滤选项 */
.filters {
  display: flex;
  margin: 24px 2px;
  color: #c0c2ce;
  font-size: 14px;
}

.filters .filter {
  margin-right: 14px;
  transition: 0.8s;
}

.filters .filter.active {
  color: #6b729c;
  transform: scale(1.2);
}

/* todo 列表 */
.todo-list {
  display: grid;
  row-gap: 14px;
}

.todo-item {
  background: white;
  padding: 16px;
  border-radius: 8px;
  color: #626262;
}

.todo-item label {
  position: relative;
  display: flex;
  align-items: center;
}

.todo-item label span.check-button {
  position: absolute;
  top: 0;
}

.todo-item label span.check-button::before,
.todo-item label span.check-button::after {
  content: "";
  display: block;
  position: absolute;
  width: 18px;
  height: 18px;
  border-radius: 50%;
}

.todo-item label span.check-button::before {
  border: 1px solid #7ad86d;
}

.todo-item label span.check-button::after {
  transition: 0.4s;
  background: #7ad86d;
  transform: translate(1px, 1px) scale(0.8);
  opacity: 0;
}

.todo-item input {
  margin-right: 16px;
  opacity: 0;
}

.todo-item input:checked + span.check-button::after {
  opacity: 1;
}

然后效果如下:

6f54e2b508b66421cc145ed7ff1bbc43.png

拆分成组件

目前,我们将所有的代码都写在App.vue中,这显然是不合适的,特别是项目大起来时,就很难维护,而且也没有发挥Vue组件化开发的优点,在进一步开发Todo List功能逻辑前,先将前面写的HTML、CSS拆分到不同的组件中。

组件拆分的目的是为了减少重复功能(组件复用)、增加可读性(方便维护),具体的拆分过程有一些讲究,这里我按功能将页面中不同元素进行组件的拆分。

简单思考Todo List应用的功能,无非就是添加Todo、展示不同Todo、完成时勾选掉Todo,简单思索,这里将其拆成成4个组件:

  • TodoAdd:添加Todo

  • TodoFilter:展示不同状态的Todo

  • TodoList:todo列表

  • TodoListItem:todo列表中的元素

在components文件夹中创建相应的Vue文件,然后将App.vue中的HTML和CSS分别复制到这些组件中。

components文件夹目录结构:

499b00bcb33bffcd3457e4833dcb5dfa.png

我们使用Vue3最新的语法糖<script setup>来实现不同的组件,目前网上很多文章还是基于setup()函数的形式去实现项目的,虽说都可以达到最终的目的,但<script setup>会轻松很多(你可能需要先熟悉<script setup>写法,可先阅读Vue3文档)。

先编写TodoAdd.vue,将App.vue中添加Todo相关的代码都复制到TodoAdd.vue中,html复制到template中,css复制到style中,然后来编写TS代码,如下:

// src/components/TodoAdd.vue

<script setup lang="ts">
  import {ref} from "vue"
  const todoContent = ref("")
  // 接收父元素的传递下来的值
  const props = defineProps<{
    tid: number
  }>()
  
  const emit = defineEmits(['add-todo'])
  const emitAddTodo = () => {
    const todo = {
      id: props.tid,
      content: todoContent.value,
      completed: false
    }
    // 发出add-todo事件给父组件
    emit('add-todo', todo);
    // 置空,让input元素中的内容清空
    todoContent.value = ''
  }
</script>

上述代码中,通过defineProps来获取父组件传递下来的tid(number类型),然后通过defineEmits定义出emit,defineEmits参数中的add-todo也与父组件中使用该组件时对应起来,为了方便理解这两句话,将App.vue(父组件)中使用TodoAdd组件的形式粘贴出来:

// src/App.vue
<script setup lang="ts">
  import {reactive , ref, computed, Ref} from "vue"
  import TodoAdd from "./components/TodoAdd.vue"
  import {Todo} from './composables/iTodo'

  
  const todos = reactive<Array<Todo>>([])
  const addTodo = (todo: any) => todos.push(todo)
</script>

<!-- :tid 会传递给TodoAdd组件,@add-todo会接受TodoAdd组件的事件并调研addTodo函数 -->
<TodoAdd :tid="todos.length" @add-todo="addTodo"/>

App.vue代码中,除了引入TodoAdd.vue外,还引入了iTodo.ts。因为我们使用TypeScript来编写代码,所以会受到TypeScript类型检测的限制,在通过defineProps函数获取父组件传递的todos时,需要指明类型,因为Todo是我们自定义的对象结构,在TS中申明自定义类型的一种方法是定义interface,如果该类型会被多次使用,通常会将对象结构定义到单独的文件在export出来,就如上述代码中的iTodo.ts,该文件代码如下:

// src/composables/iTodo.ts

export interface Todo {
    id: number,
    content: string,
    completed: boolean
}

看回TodoAdd.vue的template,代码如下:

// src/components/TodoAdd.vue

<template>
<div class="add-todo">
  <input type="text" name="todo" v-model="todoContent" @keyup.enter="emitAddTodo"/>
  <button @click="emitAddTodo">
    <!-- 按钮上的加号元素 -->
    <i class="add-icon"></i>
  </button>
  </div>
</template>

其中input标签,通过v-model将input标签展示的内容与todoContent绑定,todoContent变量由ref创建,todoContent变量后续的变化会通过Vue的双向绑定,直接显示在input中。@keyup.enter用于获取用户在键盘敲击Enter键时的事件,当用户敲击Enter或点击button时,都会触发emitAddTodo函数。

接着实现TodoList.vue,代码如下:

<script setup lang="ts">
import TodoListItem from './TodoListItem.vue';
import {Todo} from '../composables/iTodo'

const props = defineProps<{todos: Array<Todo>}>()

</script>

<template>
<div class="todo-list">
    <TodoListItem 
    v-for="todo in todos" 
    :key="todo.id" 
    :todo-item="todo"
    >
    </TodoListItem>
</div>
</template>

上述代码引入了TodoListItem组件,该组件用于显示列表中具体的Todo元素,然后通过defineProps获得父组件传递的todos对象,在template中渲染Todo List时,使用v-for遍历todos变量,App.vue与之对应的代码如下:

<script setup lang="ts">
  const todos = reactive<Array<Todo>>([])
  const addTodo = (todo: any) => todos.push(todo)
</script>

<TodoList :todos="todos"/>

结合TodoAdd.vue组件一起看,通过TodoAdd组件,将todo元素添加到todos这个列表中,再将todos传递给TodoList子组件,还有个细节,todos变量使用reactive创建而没有使用ref,Vue3官方文档中,要创建简单的响应式变量,建议使用ref,对于复杂的响应式变量,建议使用reactive,因为这里的是比较复杂的结构(Array),所以使用了reactive。

TodoListItem.vue才是具体展示的对象,代码如下:

// src/components/TodoListItem.vue

<script setup lang="ts">
import { Todo } from "../composables/iTodo";
// 接收父组件 todo-item 的数据
const props = defineProps<{todoItem: Todo}>()

</script>

<template>
<div class="todo-item" :class="{done: todoItem.completed}">
<!-- 用 label 包裹后,点击里边任何一个元素都能触发 checkbox 的事件 -->
<label>
    <input
     type="checkbox" 
    :checked="todoItem.completed"
    @click="$emit('change-state', $event)"
    />
     {{ todoItem.content }}
    <span class="check-button"></span>
</label>
</div>
</template>

上述代码中,input元素通过@click监听用户的点击事件,当用户点击时,通过emit函数将事件向上传递给TodoList父组件,TodoList.vue中将代码修改一下:

// src/components/TodoList.vue
<template>
<div class="todo-list">
    <TodoListItem 
    v-for="todo in todos" 
    :key="todo.id" 
    :todo-item="todo"
    @change-state="todo.completed = $event.target.checked"
    >
    </TodoListItem>
</div>
</template>

TodoList.vue中通过@change-state来接收子组件传递的数据,如果用户点击了,那么$envent.target.checked为true,将true值赋值给todo.completed,todo.completed的改变会影响到TodoListItem子组件。

// src/components/TodoListItem.vue
<template>
<!-- :class 动态添加done这个class -->
<div class="todo-item" :class="{done: todoItem.completed}">

<label>
    <!-- :checked 动态改变input是否可点击的状态 -->
    <input
     type="checkbox" 
    :checked="todoItem.completed"
    @click="$emit('change-state', $event)"
    />
     {{ todoItem.content }}
    <span class="check-button"></span>
</label>
</div>
</template>

<style>
  
  .todo-item.done label {
  /* 划线 */
  text-decoration: line-through;
  /* 斜体 */
  font-style: italic;
}
</style>

最后,还有个过滤Todo组件,选择未完成时,让TodoList只暂时未完成的Todo Item,选择已完成,也是类似的结果,TodoFilter.vue主要代码如下:

// src/components/TodoFilter.vue
<script setup lang="ts">
import { ref } from 'vue';

const filters = [
  {label: "全部", value: "all"},
  {label: "已完成", value: "done"},
  {label: "未完成", value: "todo"},
]

const props = defineProps<{
  selected: string
}>()

</script>

<template>
 <div class="filters">
    <span 
    v-for="filter in filters"
    :key="filter.value"
    class="filter"
    :class="{active: selected === filter.value}"
     @click="$emit('change-filter', filter.value)"
    >{{filter.label}}
    </span>
</div>
</template>

上述代码中,通用通过defineProps获得父组件传递的数据,父组件会选择某个状态,TodoFilter.vue需要过滤出相应的数据,实现方式还是那些,首先通过v-for来循环处理,展示相应的内容,然后:class来控制样式,如果选择了是什么样式,没选择是什么样式,@click用于监听点击事件,如果点击了,通过emit函数将数据向上传递给父组件,而父组件是App.vue,相应代码如下:

<script setup lang="ts">
import {reactive , ref, computed, Ref} from "vue"
const todos = reactive<Array<Todo>>([])
const addTodo = (todo: any) => todos.push(todo)


const filter = ref<string>("all");
// 过滤
const filteredTodos = computed(() => {
  switch(filter.value) {
    case "done":
      return todos.filter((todo) => todo.completed);
    case "todo":
      return todos.filter((todo) => !todo.completed);
    default:
      return todos;
  }
})
</script>

<template>
<div class="main">
<div class="container">
    <h1>欢迎使用Todo List</h1>
    
    <TodoAdd :tid="todos.length" @add-todo="addTodo"/>
    <TodoFilter 
    :selected="filter"
     @change-filter="filter=$event"
     />
    <TodoList :todos="filteredTodos"/>
  </div>
</div>
  
</template>

至此,Todo应用就实现好了。

拆分逻辑

为了进一步提高复用性,通常会将Vue中的TS逻辑抽离出来,当然是将比较通用的部分抽离,或单纯为了方便阅读,将功能相近的代码聚合在一起,这里我们也将Todo应用中TS代码拆分一下,放在src/composables中。

首先,将App.vue中部分代码抽到useTodos.ts中,代码如下:

import {onMounted, reactive} from "vue"
import TodoListItemVue from "../components/TodoListItem.vue"
import {Todo} from "./iTodo"

export default function useTodos() {
    const todos = reactive<Array<Todo>>([])
    const addTodo = (todo: any) => todos.push(todo)

    // 请求数据
    const fetchTodos = async () => {
        const response = await fetch(
            "http://127.0.0.1:8000/todos"
        )
        const rawTodos = await response.json() 
        // 数据添加到todos中
        for (let i=0; i < rawTodos.length;i++) {
            let rawtodo = rawTodos[i]
            todos.push(
                {
                    id: rawtodo.id,
                    content: rawtodo.content,
                    completed: rawtodo.completed
                }
            )
        }
    }
    // 生命周期函数 - 组件加载后,Vue会自动调用onMounted()
    onMounted(() => {
        fetchTodos();
    })

    return {
        todos,
        addTodo
    }

}

通过上述代码可知,所谓抽离,就是就App.vue中部分代码复制到useTodos.ts中,抽离后TodoAdd.vue干爽很多。

<script setup lang="ts">
import {reactive , ref, computed, Ref} from "vue"
import TodoAdd from "./components/TodoAdd.vue"
import TodoFilter from "./components/TodoFilter.vue";
import TodoList from "./components/TodoList.vue"
import useTodos from "./composables/useTodos"
import useFilteredTodos from "./composables/useFilteredTodos"
import {Todo} from './composables/iTodo'

const {todos , addTodo} = useTodos();
const {filter, filteredTodos} = useFilteredTodos(todos);

</script>

<template>
<div class="main">
<div class="container">
    <h1>欢迎使用Todo List</h1>
    
    <TodoAdd :tid="todos.length" @add-todo="addTodo"/>
    <TodoFilter 
    :selected="filter"
     @change-filter="filter=$event"
     />
    <TodoList :todos="filteredTodos"/>
  </div>
</div>
  
</template>

其他组件的抽离的过程也是类似的,文末会有本项目的github地址,就不多赘述了。

在useTodos.ts中新增了fetchTodos函数,在onMounted函数中调用fetchTodos函数,实现在组件加载完成后,通过接口后的Todo List数据,通过类似的逻辑,我们可以将Todo应用改造成后端可存储数据的应用。

与fetchTodos函数配套的后端接口使用Python Flask编写,直接请求会有跨域问题,使用flask-cros解决跨域问题,完整代码如下:

from flask import Flask, jsonify
from flask_cors import CORS

app = Flask(__name__)
CORS(app)

@app.route("/todos")
def hello_world():
    data = [
        {
            "id": 1,
            "content": "阅读书籍",
            "completed": False
        },
        {
            "id": 2,
            "content": "写基于Vue3开发Todo List的文章",
            "completed": True
        },
        {
            "id": 3,
            "content": "看电影",
            "completed": False
        },
    ]
    return jsonify(data)


if __name__ == '__main__':
    app.run(port=8000)

结尾

Todo应用就编写完成了,我们使用了Vue3最新的语法实现了Todo,而目前网上多数文章都是使用Vue3旧的语法,前端变化真的太快,几个月前的文章,现在可能就不太一样了,后续会尝试剖析Vue3源码,做到掌握其中不变的东西。

Todo List Github: https://github.com/ayuLiao/TodoListVue3

Enjoy Coding!下篇文章见。

  • 5
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

懒编程-二两

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值