前言
使用Vue3最新的Composition API,开发一个Todo List应用。
Todo List应用比较简单,不会使用其他复杂的Vue3组件,但这也使得当前应用与真实的应用脱钩,所以本文重点是体会Vue3的最新语法,下篇文章会阅读element-plus-admin开源项目的源码,从而掌握Vue3目前最近技术栈的使用方式。
所谓Todo List应用主要用于记录你需要完成的工作,完成后,将其打钩,最终效果如下:
写点HTML+CSS吧
先用yarn基于vite构建出vue-ts的项目目录,命令如下。
yarn create vite TodoList --template vue-ts
yarn && yarn dev
在开始编码前,最好弄一下原型稿,我个人为求方便,通常使用PPT来构建原型稿,专业的前端可能会使用专门的原型设计软件,与后端开发前写功能设计一个道理,前端开发前,弄好原型稿,从而从整体把握开发出网页的样式、配色以及可能需要需要的组件。
在弄原型稿时,要刻意关注页面由那几部分组成,一个复杂的业务,拆分到最后,很发现都是一个简单的HTML元素的组合使用,此外,原型稿对CSS编写也很有帮助,复杂的样式效果,拆分到最后也是一些简单的CSS属性。
首先,我们基于原型稿,通过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>
运行项目,会得到如下效果这个阶段,不需要考虑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;
}
然后效果如下:
拆分成组件
目前,我们将所有的代码都写在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文件夹目录结构:
我们使用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!下篇文章见。