头图

在上一篇 Vite + Vue3 初体验 —— Vite 篇 博客中,我感受到了 Vite 带来的运行时效率提升,这一期再来感受感受 Vue3 带来的新变化 —— 关注点分离。

Todo List 设计

这次体验 Vue3,我想做一个能体验(部分) Vue3 新特性的功能模块。

想了想,用一个 Todo List 应该是比较合适的。

我们来规划一下它的功能清单吧。

  1. 输入 Todo,按下回车即可添加一条新的 Todo Item
  2. 以列表的形式显示所有的 Todo Item
  3. 可以将 Todo Item 标记为完成,标记完成后的 Todo Item 会置灰,并且排序处于最下面。
  4. 可以将 Todo Item 删除,删除后在列表中不展示。
  5. 可以将 Todo Item 置顶,高亮显示,以提高优先级。

OK,接下来,我们先把基础页面搭建出来吧。

搭建基础 UI 界面

配置 UI 库

目前支持 Vue3 的 UI 框架有下面几种:

  1. Ant Design Vue
  2. Element Plus
  3. Ionic
  4. Native UI

其中 ant-designelementui 是从 Vue2 一路走来的老 UI 库了,我在体验 Vue3 的时候决定还是使用轻风格的 ant-design

先安装支持 Vue3ant-design-vue 吧。

yarn add ant-design-vue@next

然后,再配置一下按需加载,这样的话,只有被使用到的组件才会被打包,可有效减小生产包的体积。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [
        AntDesignVueResolver(),
      ],
    }),
  ]
});

最后,在 main.ts 中引入样式文件。

// main.ts
import 'ant-design-vue/dist/antd.css';

基础布局

现在,我们的布局需要一个输入框和一个列表,我们先在页面把这两个元素画出来吧。

在此之前,在 App.vue 中引入了我们的 TodoList 组件。
// TodoList.vue
<script setup lang="ts">
import { DeleteOutlined, CheckOutlined, CheckCircleFilled } from '@ant-design/icons-vue';
import { Input } from "ant-design-vue";


</script>

<template>
  <section class="todo-list-container">
    <section class="todo-wrapper">
      <Input class="todo-input" placeholder="请输入待办项" />
      <section class="todo-list">
        <section class="todo-item">
          <span>Todo Item</span>
          <div class="operator-list">
            <DeleteOutlined />
            <CheckOutlined />
          </div>
        </section>
        <section class="todo-item">
          <span>Todo Item</span>
          <div class="operator-list">
            <DeleteOutlined />
            <CheckOutlined />
          </div>
        </section>
        <section class="todo-item todo-checked">
          <span>Todo Item</span>
          <div class="operator-list">
            <CheckCircleFilled />
          </div>
        </section>
      </section>
    </section>
  </section>
</template>

<style scoped lang="less">
.todo-list-container {
  display: flex;
  justify-content: center;
  width: 100vw;
  height: 100vh;
  box-sizing: border-box;
  padding-top: 100px;
  background: linear-gradient(rgba(93, 190, 129, .02), rgba(125, 185, 222, .02));
  .todo-wrapper {
    width: 60vw;
    .todo-input {
      width: 100%;
      height: 50px;
      font-size: 18px;
      color: #F05E1C;
      border: 2px solid rgba(255, 177, 27, 0.5);
      border-radius: 5px;
    }
    .todo-input::placeholder {
      color: #F05E1C;
      opacity: .4;
    }
    .ant-input:hover, .ant-input:focus {
      border-color: #FFB11B;
      box-shadow: 0 0 0 2px rgb(255 177 27 / 20%);
    }
    .todo-list {
      margin-top: 20px;
      .todo-item {
        box-sizing: border-box;
        padding: 15px 10px;
        cursor: pointer;
        border-bottom: 2px solid rgba(255, 177, 27, 0.3);
        color: #F05E1C;
        margin-bottom: 5px;
        font-size: 16px;
        transition: all .5s;
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding-right: 10px;
        .operator-list {
          display: flex;
          justify-content: flex-start;
          align-items: center;
          :first-child {
            margin-right: 10px;
          }
        }
      }
      .todo-checked {
        color: rgba(199, 199, 199, 1);
        border-bottom-color: rgba(199, 199, 199, .4);
        transition: all .5s;
      }

      .todo-item:hover {
        box-shadow: 0 0 5px 8px rgb(255 177 27 / 20%);
        border-bottom: 2px solid transparent;
      }
      .todo-checked:hover {
        box-shadow: none;
        border-bottom-color: rgba(199, 199, 199, .4);
      }
    }
  }
}
</style>

这次我选了一套黄橙配色,我们来看看界面的效果吧。

image

处理业务逻辑

处理输入

现在,我们来处理一下我们的输入逻辑,在按下回车键时,将输入的结果收集起来添加到 Todo 数组中,并且将输入框清空。

这里需要用到双向绑定,定义一个 引用 变量,与输入框进行绑定。

<script setup lang="ts">
import { ref } from "vue";

// 创建一个引用变量,用于绑定 Todo List 数据
const todoList = ref<{
  title: string,
  is_completed: boolean
}[]>([]);

// 创建一个引用变量,用于绑定输入框
const todoText = ref('');
const onTodoInputEnter = () => {
  // 将 todo item 添加到 todoList 中
  todoList.value.unshift({
    title: todoText.value,
    is_completed: false
  });
  // 添加到 todoList 后,清空 todoText 的值
  todoText.value = '';
}
</script>
<template>
   //...
  <!-- v-model:value 语法是 vue3 的新特性,代表组件内部进行双向绑定是值 key 是 value -->
  <Input v-model:value="todoText" @keyup.enter="onTodoInputEnter" class="todo-input" placeholder="请输入待办项" />
</template>

现在打开本地开发界面,输入一个值,然后按下回车,输入框的值就被清空了 —— 将这一项添加到了 todoList 数组中!

渲染列表

在处理好了输入之后,现在需要将列表渲染出来。

这里还是用经典的 v-for 语法,同时需要加上一些状态的判断。

<section class="todo-list">
  <section v-for="item in todoList" class="todo-item" :class="{'todo-completed': item.is_completed}">
    <span>{{item.title}}</span>
    <div class="operator-list">
      <CheckCircleFilled v-show="item.is_completed" />
      <DeleteOutlined v-show="!item.is_completed" />
      <CheckOutlined v-show="!item.is_completed" />
    </div>
  </section>
</section>

这个语法相信用过 vue2 的都清楚,就不做过多介绍了。

有一说一,vscode + volarvue3 + ts 的支持是真不错,代码提示和错误提示都非常完善了。在开发过程中,简直是事半功倍。

处理删除和完成逻辑

最后,我们来处理一下删除和完成的逻辑吧。

<script setup lang="ts">
// 创建一个引用变量,用于绑定 Todo List 数据
const todoList = ref<{
  title: string,
  is_completed: boolean
}[]>([]);
// 删除和完成的逻辑都与 todoList 放在同一个地方,这样对于逻辑关注点就更加聚焦了
const onDeleteItem = (index: number) => {
  todoList.value.splice(index, 1);
}
const onCompleteItem = (index: number) => {
  todoList.value[index].is_completed = true;
  // 重新排序,将已经完成的项目往后排列
  todoList.value = todoList.value.sort(item => item.is_completed ? 0 : -1);
}
</script>
<template>
   //...
  <DeleteOutlined v-show="!item.is_completed" @click="onDeleteItem(index)" />
  <CheckOutlined v-show="!item.is_completed" @click="onCompleteItem(index)" />
</template>

最后,来看看我们界面的效果吧。(如下图)

image

加入置顶逻辑

我们需要先给数组元素添加一个字段 is_top,用于判断该节点是否置顶。

然后,再加入置顶函数的逻辑处理以及样式显示。(如下)

<script setup lang="ts">
// 创建一个引用变量,用于绑定 Todo List 数据
const todoList = ref<{
  title: string,
  is_completed: boolean,
  is_top: boolean
}[]>([]);
const onTopItem = (index: number) => {
  todoList.value[index].is_top = true;
  // 重新排序,将已经完成的项目往前排列
  const todoItem = todoList.value.splice(index, 1);
  todoList.value.unshift(todoItem[0]);
}
</script>
<template>
   //...
  <section class="todo-list">
    <section v-for="(item, index) in todoList" 
      class="todo-item" 
      :class="{'todo-completed': item.is_completed, 'todo-top': item.is_top}">
      <span>{{item.title}}</span>
      <div class="operator-list">
        <CheckCircleFilled v-show="item.is_completed" />
        <DeleteOutlined v-show="!item.is_completed" @click="onDeleteItem(index)" />
        <ToTopOutlined v-show="!item.is_completed" @click="onTopItem(index)" />
        <CheckOutlined v-show="!item.is_completed" @click="onCompleteItem(index)" />
      </div>
    </section>
  </section>
</template>

然后,我们来看看我们的界面效果吧!(如下图)

image

这样一来,我们的 Todo List 就完成了!

现在再来看看我们的代码,主要是有两块逻辑关注点:

  1. todoList 相关逻辑,负责列表的渲染以及列表的相关操作(删除、置顶、完成)。
  2. todoText 相关逻辑,负责处理输入框的输入。

在分离了逻辑关注点后带来的好处时,如果我想要修改列表相关的处理逻辑,我只需要关注和调整 todoList 相关的代码即可;如果我想要调整输入相关的逻辑,我只需要关注和调整 todoText 相关的逻辑即可。

如果这两块的逻辑后面随着业务发展而变得越来越复杂了,我可以选择将其拆分成更小块的业务逻辑来进行维护,还可以将这些逻辑都拆分到单文件中进行维护管理,这样对于后续的维护和升级都能够有更好的把控。

处理前后端交互逻辑

我们之前所有的逻辑都是在本地做的处理,现在我们来接入服务端的逻辑,将我们的所有数据及变更进行持久化。同时,我们也来看看在 Vue3 中,如何处理有前后端交互逻辑的场景。

假设我们有下面这么几组接口(如下图)

image

那么,基于这几组接口的后端交互逻辑,我们还是用经典的 axios 来做吧。

使用 yarn add axios 添加依赖。

这里,我们先在 src 目录下新建一个 service,用于初始化我们用于网络请求的 service。(如下)

// src/service/index.ts
import axios from "axios";

const service = axios.create({
  // 设置 baseURL,这个地址是我部署的后端服务
  baseURL: "https://hacker.jt-gmall.com"
});

export default service;

用户身份信息

我们设计的 Todo List 是一个在线网页,我们希望每个用户进来看到的都是自己的 Todo List

我们来看看后台的接口设计,他使用 key 来给 Todo Item 做分组,所以我们需要在进入页面时,为每一个用户生成一个独一无二的 user key

我们先设计一个用来获取 key 的函数吧。

这里使用 uuid 来生成唯一的 user key
// service/auth.ts
import { v4 as uuid } from "uuid";

const getUserKey = () => {
  if (localStorage.getItem('user_key')) return localStorage.getItem('user_key');

  const userKey = uuid();
  localStorage.setItem('user_key', userKey);
  return userKey;
}

export {
  getUserKey
}

获取 Todo List

然后,我们回到我们的 TodoList.vue 文件,我们先写一个获取远端 Todo 列表的逻辑。(如下)

// TodoList.vue
import service from "@/service";
import { getUserKey } from '@/service/auth';

// 创建一个引用变量,用于绑定 Todo List 数据
const todoList = ref<{
  title: string,
  is_completed: boolean,
  is_top: boolean
}[]>([]);
// 初始化 todo list
const getTodoList = async () => {
  const reply = await service.get('/todo/get-todo-list', { params: { key: getUserKey() } });
  todoList.value = reply.data.data;
}
getTodoList();

这里加上网络请求后,页面也是不会有什么变化的,因为这个用户目前是没有数据的。

接下来,我们把剩下的几个逻辑都补全。

注意:这里使用到了 alias 别名功能,需要在 vite.config.tstsconfig.json 中进行配置。
import path from 'path';

// vite.config.ts
export default defineConfig({
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "src"),
    }
  },
  // ...
})
// tsconfig.json

{
  "compilerOptions": {
    // ...
    "baseUrl": "./",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

新增、置顶、完成、删除 Todo

由于用户进入 Todo List 查看的都是自己的数据,并且该数据只有自己可操作。

所以,也是为了能有更好的用户体验,在我们所有的操作逻辑完成后,回显数据还是用原有的逻辑。

当然,新增数据时,还是需要重新获取列表数据,因为我们操作数据时需要用到每一项的 id

综上所述,我们重构后的四个函数长这样。

// 删除、完成、置顶的逻辑都与 todoList 放在同一个地方,这样对于逻辑关注点就更加聚焦了
const onDeleteItem = async (index: number) => {
  const id = todoList.value[index].id;
  await service.post('/todo/delete', { id });

  todoList.value.splice(index, 1);
}

const onCompleteItem = async (index: number) => {
  const id = todoList.value[index].id;
  await service.post('/todo/complete', { id });

  todoList.value[index].is_completed = true;
  // 重新排序,将已经完成的项目往后排列
  const todoItem = todoList.value.splice(index, 1);
  todoList.value.push(todoItem[0]);
}

const onTopItem = async (index: number) => {
  const id = todoList.value[index].id;
  await service.post('/todo/top', { id });

  todoList.value[index].is_top = true;
  // 重新排序,将已经完成的项目往前排列
  const todoItem = todoList.value.splice(index, 1);
  todoList.value.unshift(todoItem[0]);
}

// 新增 Todo Item 的逻辑都放在一处
// 创建一个引用变量,用于绑定输入框
const todoText = ref('');
const addTodoItem = () => {
  // 新增一个 TodoItem,请求新增接口
  const todoItem = {
    key: getUserKey(),
    title: todoText.value
  }
  return service.post('/todo/add', todoItem);
}
const onTodoInputEnter = async () => {
  if (todoText.value === '') return;

  await addTodoItem();
  await getTodoList();

  // 添加成功后,清空 todoText 的值
  todoText.value = '';
}

逻辑修改完成后,我们回到页面查看一下效果吧!我们做一些操作后,刷新页面查看一下。(如下图)

image

刷新页面后,我们的数据依然是可以展示出来的,说明数据已经成功做了服务端持久化啦!

小结

这次,我们用 Vue3 来完成了一个简单的 Todo List 系统。

可以看出,Vue3ts 的支持变得更友好了,而新的 vue 单文件语法和 组合式 API 给我的体验也有点接近 React + JSX。 —— 我的意思是,给开发者的体验更好了。

我们再来看看我们用 组合式 API 实现的逻辑部分(如下图)。

image

从上图可以看出,我们的逻辑关注点被分成了两大块,分别是列表相关逻辑(渲染、操作)和新增 Todo Item。

这种清晰的职责划分使得我们需要维护某一部分的功能时,与之相关的内容都被圈在了一个比较小的范围,能够让人更加聚焦到需要调整的功能上。

如果现在让我给 Vue3Vue2 的(开发)体验打个分的话,我会分别给出 8分6分

好啦,我们这次的 Vue3 体验就到此为止了,Vue3 给我的体验还是非常不错的!

最后附上本次体验的 Demo 地址

最后一件事

如果您已经看到这里了,希望您还是点个赞再走吧~

您的点赞是对作者的最大鼓励,也可以让更多人看到本篇文章!

如果觉得本文对您有帮助,请帮忙在 github 上点亮 star 鼓励一下吧!


晒兜斯
1.8k 声望534 粉丝