🎧 羽森 / Kent

todoList for vue3 CompositionAPI2022-08-10


项目预览:点我

Github地址:点我


写在前面

麻雀虽小,五脏俱全,小小的一个todoList,只需要一页,具备增删查改,交互也相对简单,不依赖视觉设计,想必各位前端er在学习各种框架的过程中写了无数次todoList,这次学习compositionAPI第一个想到的也是做一个todoList,就顺势给摸了出来。

完成todoList的方式五花八门,可以很简单,也可以很复杂,从入门学习某个框架来说,用尽可能原始、不掺和其他依赖库(像什么vuex、axios、pinia 等等,在还没学习到框架本身的同时还增加了学习依赖库的成本)、同时代码还精简的方式就好了。

本项目你只需喝个茶的时间,就能掌握compositionAPI(setup语法糖)的入门、父传子(props)、子传父(emit)的使用,以及comoposables的抽离:smiley_cat:

依赖和src目录

依赖

 "dependencies": {
    "vue": "^3.2.45"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.0.0",
    "typescript": "^4.9.3",
    "vite": "^4.0.0",
    "vue-tsc": "^1.0.11"
  }

vite是当下很火热的前端开发与构建工具,意在提供开箱即用的配置,同时它的插件API和JavaScript API 带来了高度的可扩展性,并有完整的类型支持。如果你还在使用webpack,不如尝试一下vite吧!

由于Typescript的趋势不可阻挡,我也使用了vue3-typescript,对于学过java这种强类型语言的我来说,上手并不难,如果你在这之前还未看过Typescript,可以先预览一下文档

主要代码目录结构

   src
   ├─App.vue
   ├─global.d.ts    全局interface
   ├─main.ts
   ├─composables    组合式函数
   |      ├─useFilteredTodos.ts
   |      ├─useTodos.ts
   |      └userEmitAddTodo.ts
   ├─components     组件文件
   |     ├─TodoAdd.vue
   |     ├─TodoFilter.vue
   |     ├─TodoList.vue
   |     └TodoListItem.vue
   |
   index.html

组合式函数(composables function)是vue3新出的概念的,功能是用composition API来封装可复用有状态逻辑的函数,类似与react里的hooks。

当函数返回组件时,和.vue文件差不多,只是另一个种写法,也注重逻辑处理,更加灵活。

当它用于封装方法时,可以看成以前我们封装的utils类的加强版(得益于composition API的写法和ref等语法支持,使得其数据等也变成响应式),为此组件方法等抽离变得更加灵活了

虽然提到了可复用,但项目里这几个comoposables就只用到一次,看上去变复杂了,确实修改一下就可以放进去对应的组件里,这里是为了更好的学习vue3 Composition API,初识了composables,目的是让学习者意识到还有这种开发方式,从而对vue3 Composition API开发有个更加完整的认识

逻辑解释

这整个程序都是用vue3的Composition API(组合式)写的,<script>标签使用了setup语法糖,让程序更加简洁。

程序主要分成三个组件:

  1. 添加组件 TodoAdd
  2. 过滤组件 TodoFilter
  3. 显示待办事项的列表组件 TodoList

    1). item组件 TodoListItem

结构图示如下:

5QA9J7

App.vue使用了上面三个组件,和两个composables,关于和代码分别如下:

OVMPif

<script setup lang="ts">
// 略写
import TodoAdd,TodoFilter,TodoList,useTodos,useFilteredTodos from 'xxx';

// 获取函数暴露的属性和方法(通过ref包裹,使得他们都是响应式的)
const todos = useTodos();
const { filter, filteredTodos } = useFilteredTodos(todos);
</script>

<template>
   <div>
      <h2>欢迎使用待办事项!</h2>
      <todo-add :tid="todos.length" @add-todo="todos.push($event)" />
      <todo-filter :selected="filter" @change-filter="filter = $event" />
      <todo-list :todos="filteredTodos" />
  </div>
</template>

todos是一个列表,每个对象长下面这样

type Todo =
  {
    id: number,
    content: string,
    completed: boolean;
  }

TodoAdd组件

将已导入的todos数据的长度当作props传给todoAdd,目的在于后面点击添加的时候,可设置要添加的todo对象id为长度。

<script setup lang="ts">
export interface Props = { tid?: Number }
import useEmitAddTodo from '../composables/userEmitAddTodo';

const emit = defineEmits(['add-todo'])

const props = withDefaults(defineProps<Props>(), { tid: Number })

const { todoContent, emitAddTodo } = useEmitAddTodo(props, emit);

</script>
<template>
  <div>
    <input type="text" v-model="todoContent" @keyup.enter="emitAddTodo" />
    <button @click="emitAddTodo"/>
  </div>
</template>

export interface Props = { tid?: Number },解释一下这句,由于项目使用typescript,传进来的tid进行解构后,对象类型变成了ReadOnly,vue官网解释如下: tECMF1

所以这里才重新定义了一个interface和下面使用的withDefaults()

现在已经拿到长度了,那怎么通过点击事件,将todo添加到app里的todos里呢?答案是defineEmit(),上面代码可以看到,除了传了todos对象的长度,我们还自定义了一个emit事件名为add-todo,当触发的时候子组件会把todo值传给父组件也就是app.vue,然后再通过@add-todo="todos.push($event)"将todo放进todos,就可以将接收到的todo添加到todos里了。

将输入内容和点击事件抽离出来当作一个composables,名为useEmitAddTodo,内容如下:

export default function useEmitAddTodo(props: Props, emit: any)
{
  const todoContent = ref("");
  const emitAddTodo = () =>
  {
    if (todoContent.value != null && todoContent.value.length >= 1)
    {

      const todo = {
        id: props.tid,
        content: todoContent.value,
        completed: false,
      };
      emit("add-todo", todo);

      todoContent.value = "";
    } else
    {
      alert("提交值不能为空")
    }
  };

  return {
    todoContent,
    emitAddTodo,
  };
}

iPS4Ok

TodoFilter/TodoList组件

App里可以看到list组件并没有直接传todos,而是传了通过filter组件过滤之后的filteredTodos,且通过computed包裹着,这样子检测filter发生改变时,列表也会随着改变,接下来只需要监听状态改变了

// app.vue中的html
<todo-filter :selected="filter" @change-filter="filter = $event" />
// filter组件主要代码
<script setup lang="ts">
defineProps(['selected'])
const filters = [
  { label: "全部", value: "all" },
  { label: "已完成", value: "done" },
  { label: "未完成", value: "todo" },
];
</script>
<template>
  ...
    <span v-for="filter in filters" :key="filter.value" 
    @click="$emit('change-filter', filter.value)">{{ filter.label }}</span>
  ...
</template>
// useFilteredTodos
export default function useFilteredTodos(todos: Ref<Todo[]>)
{
  const filter = ref("all");
  // 过滤 todo
  const filteredTodos = computed(() =>
  {
    switch (filter.value)
    {
      case "done":
        return todos.value.filter((todo: Todo) => todo.completed);
      case "todo":
        return todos.value.filter((todo: Todo) => !todo.completed);
      default:
        return todos.value;
    }
  });
  return { filter, filteredTodos };
}

他们直接的关系如下:

zcea7C

import { computed, ref, Ref } from "vue";

export default function useFilteredTodos(todos: Ref<Todo[]>)
{
  const filter = ref("all");
  // 过滤 todo
  const filteredTodos = computed(() =>
  {
    switch (filter.value)
    {
      case "done":
        return todos.value.filter((todo: Todo) => todo.completed);
      case "todo":
        return todos.value.filter((todo: Todo) => !todo.completed);
      default:
        return todos.value;
    }
  });

  return { filter, filteredTodos };
}

TodoList/Item组件

最后这个就相对容易了

数据现在从useTodos()拿到之后,经filter组件处理后传给list组件,组件通过v-for之后再把各个todo传给Item组件,Item组件渲染出来,同时绑定defineEmit,当状态改变时,子传父后修改状态,因为是双向印象,completed的改变也会导致重新渲染,这部分代码比较简单,可以自己查看源码。

能看懂这个项目之后,你就又了基础能力能够做到数据绑定、遍历、条件判断等各种逻辑了,同时也对vue3 composition API的setup语法糖的开发有了个大概了了解,恭喜🎉

下载运行

可以手动下载,或者通过git clone到本地

git clone https://github.com/ooooshino/TodoList.git

成功之后再通过下面的命令下载依赖包

# 进入项目根目录
cd TodoList

# 安装依赖
npm install

# 或者你也可以使用pnpm
pnpm install

# 使用 npm 或者 pnpm 取决于你,用两条命令执行其中一条就好了,需要注意的是,如果你使用pnpm,你需要提前安装好pnpm

让你的项目跑起来

#npm
npm run dev

#pnpm
pnpm dev

当上面的指令你都执行完了,即可通过 http://localhost:3000访问了

:exclamation:当端口被占用时请通过修改vite.config.ts下的port字段来修改端口:exclamation:

  server: {
    port: 3000,
  }