【新人向】vue3实战项目-仿mac自带应用《提醒事项》

1,565 阅读27分钟

先上菜

这篇文档的要素

  • 有代码
  • 甚至还把需求简单列了一遍
  • 甚至还有实现细节

所以如果你是想学习vue实战的新人,可以按照我的需求文档从0到1实现一个todo项目

简单介绍下我自己:写Android的、会js。没用过前端框架、刚学vue三天。由于咱就是个急性子,刚看完官网文档、直接上手写代码了。所以当初写这个项目我也是边学边写的,用到的第三方库很少:主要就一个UI组件库和数据共享的pinia

虽然用的是vue3,由于还没学过ts,所以用的是js。虽然我到后面发现没有类型写得真不顺手、很想换到ts、但是考虑到时间就没换。

最开始是代码风格是option、后面感觉compisition更好点、就都改了

写这个todo、我这个老年人都断断续续花了大半个月。所以对于一个新人来说、如果你能从0到1写完这个todo应用,我觉得你vue可以算是入门了

vue官网真的很友好、是我见过官网里对新人最友好的了。基本上项目里的问题、大部分我都能很快在官网文档里查到并解决

代码:github.com/nppp1990/ea…

咱写Android的。就是个前端练习生、前端代码写的一般、请轻喷

飞书文档:easy-todo

gif1.gif

前言

前段时间由于某些原因、学了下vue,感觉得搞个练手的前端项目做做,网上搜了一堆感觉都是那种后台管理系统、说白了就是一堆框架堆砌、觉得对新人并不怎么友好,所以我准备自己从零写个项目搞搞

写什么项目呢?我选择了mac上自带的应用《提醒事项》,为啥选这个呢?当初设想的是这几个原因

  • UI够简单(这个自带应用真的有够丑的(⊙o⊙)…)
  • 功能不那么复杂(实际上一点也不复杂,我当时太肤浅了
  • 有不少交互和逻辑、对于新人学习vue挺好的
  • 颜色我可以自己量啊(我是真不知道rgba该用哪个)

这个《提醒事项》的功能看起来很简单,可是当我后来实现的时候、发现处处是玄机、全是隐藏功能。所以我现在的这个代码最多也就是实现了他功能的30%

需求列表

暂时我只写了这么多需求、如果以后有时间还可以再修修补补

task1-项目初搭

内容

  • 初始化项目代码
  • 首页整体布局
    • 左侧固定宽度的菜单
      • 适配:当页面宽度过窄时,不显示左侧菜单
    • 右侧显示todo的内容
i1.png

实现细节

  • 很简单、没啥好说的

知识点

难度:⭐️

task2-左侧菜单UI

内容

i2.png
  • 顶部搜索框
    • 左侧搜索图标、默认的placeHolder、右侧删除图标
    • 交互
      • focus、hover时的不同状态
      • 点击搜索框时、外边框由小变大再变小的动画
      • 有文字时显示右侧删除图标
  • "今天"、"计划"、"全部"的卡片
    • 左上角圆形背景色图标:其中今天卡片的中间还有数字代表今天的日期
    • 右上角的数字:对应卡片的todo个数
    • 下方文字:分别为"今天"、"计划"、"全部"
    • 交互
      • hover时:卡片背景色变化
      • 选中卡片时:卡片背景色发生变化、文字颜色变化、圆形背景色变化、图标颜色变化
  • 类型列表
    • 顶部title文字:"我的列表"
    • 列表内容:圆形背景色图标、类型名、数字
    • 交互
      • 列表长度超出屏幕时
        • 可滑动
        • 并且底部出现分割线(我没实现,太麻烦)
      • 选中item时:背景色、文字颜色发生变化
      • 列表滑动时
        • 列表顶部出现一个带阴影的分割线、滑动停止分割线消失(这个我没实现)
        • 列表底部同样出现分割线、停止消失(我就写死了分割线一直在)
  • 底部添加列表
    • 代表添加的图标+文字

实现细节

  • input搜索框选中的动画不能用border来做、因为border是占空间的;而outline和shadow则不占
  • 卡片考虑复用可以抽象为组件
  • 日期获取用dayjs来实现简单点
  • 列表item也需要抽象为组件
  • 列表数据刚开始可以随便写点假数据来做
  • 可以考虑sass等css预编译框架增强可读性

知识点

难度:⭐️⭐️

  • 搜索框的动画虽然很丑、但是实现起来我花了很久、毕竟css真难啊
  • 卡片交互的css代码还是挺多的

随便说说

  • css太牛了!这些交互我用Android写肯定没那么优雅
  • 这《提醒事项》的UI风格太非主流了,都没什么组件库长他这样的

task3-类型列表拖拽

内容

  • 列表item支持点击按住拖拽

i3.png

  • 拖拽时在对应位置上展示横线:按照item区间分为三种状态:item的上半部分、中间部分、下半部分
    • 鼠标处于item的上半部分[0, 1/4]时:横线出现在item的上面
    • 鼠标处于item的上半部分[3/4, 1]时:横线出现在item的下面

i4.png

  • 鼠标处于item的上半部分[1/4, 3/4]时:横线消失、但是选中该item(这个功能我暂时还没做,就打个log)

i5.png

  • 鼠标在类型列表以外的部分:横线、选中都消失

i6.png

  • 鼠标处于最上面、最下面时:横线分别位于第一个item的最上面、最后一个item的最下面

i7.png

  • 拖拽结束时对应的操作
    • 选中状态:合并两个item,相当于创建type组
  • 横线状态:拖拽的item移到对应的位置
    • 支持移动动画

实现细节

  • 画横线:增加一个变量overIndex来判断横线在哪个item显示
  • 监听drag相关事件实现拖拽
    • @drag:监听拖拽时鼠标的位置,判断滑出列表、是否需要隐藏横线
    • @dragstart :监听拖拽的开始
    • @dragover:监听在哪一个item内拖动、通过**event.offsetY和item的高度来判断在item那一部分**
    • @dragend :监听拖拽的结束,通过当前位置来判断最终把拖拽的item移动到哪里
    • 这里尽量不要频繁调用getBoundingClientRect,否则可能影响性能?
  • TransitionGroup来实现列表变化的动画

知识点

难度:⭐️⭐️⭐️⭐️

随便说说

  • 拖拽的逻辑看起来很复杂,但是捋清楚每一个步骤需要干什么、每一个监听回调做什么也没那么麻烦的

task4-新建列表

内容

i9.png

  • 入口
    • 点击菜单底部"新建列表"、弹出新建类型列表的弹框
  • 弹框内容
    • 标题:新建列表、靠左
    • 名称:input框、类型的名字
    • 颜色:12个颜色可供选择、对应菜单列表里图标的背景颜色
    • 图标:对应菜单列表里的图标
    • 转换:checkbox(这个我没实现)
    • 笑脸:这个我没实现
    • 底部:取消、确认(好)按钮
  • 交互
    • input要自动聚焦、并且不会失去焦点
    • 颜色默认选择蓝色、图标默认选择图标列表第一个
    • 颜色选中后:改变颜色选择的状态、同时改变图标的背景色
    • 点击图标按钮:向右弹出选择图标的浮层
      • 这个浮层弹出后自动选中当前的图标
      • 浮层弹出有动画、并且当右边没有足够空间展示时、向左弹出浮层
      • 点击浮层外部、关闭浮层
      • 选中浮层中任意一个图标、关闭浮层、并且修改当前图标
    • 名称为空时:确认按钮不可点的状态;名称非空时、确认按钮可点状态
    • 弹窗可点击外部关闭

实现细节

  • 可以封装多个组件来实现
  • 弹窗和浮层自己实现很麻烦、引用element-plus来实现
    • 由于样式有些不同、需要通过覆盖css来实现,我认为这里尽量不要污染全局的css
    • 使用el-popover实现的弹出浮层、点击触发浮层的圆形图标以外的位置都会关闭,这个并不满足要求,可以考虑自己实现click-outside指令来做
    • vue3里深度选择器的写法是:deep(xxxx)

知识点

难度:⭐️⭐️⭐️

随便说说

  • 修改element里的样式、咱真的花了很久尝试、如果有系统的学习css选择器优先级应该会好很多
  • 以我写Android的经验来看、style是要尽量内敛的、前端的css也是这样么?但是我看很多源码里,是会把css写到全局的文件里的呀?
  • 样式穿透的概念一定要搞清楚、不然就像我一样向覆盖css就是瞎子过河

task5-创建列表后的结果

内容

  • 创建一个新的type到列表的最后一列
  • 自动选中新创建的type
  • 交互
    • 当列表很长、最后一个item看不见时、需要自动滑动列表到最后

实现细节

  • type的数据至少包括下面四个字段
{
  id: 1, // 唯一id
  name: 'type1', // type名字
  colorIndex: 0, // type的背景色下标、固定写死12个颜色,通过index访问
  svgIndex: 0 // type的图标下标
}
  • type的id要保证唯一
    • 全局自增变量incrementId保证唯一
    • incrementId需要持久化、保证下次进来仍然是递增的:持久化可以用storage实现、也可以直接用pinia-plugin-persistedstate
  • 滑动:ref获取list,然后用scrollTo实现,时机不对可考虑nextTick

知识点

难度:⭐️⭐️

随便说说

  • 数据共享我最开始用的vuex,感觉要写一堆重复的代码很没必要,后面改成了pinia
  • 题外话:为啥前端喜欢把dependencies叫插件?我觉得第三方库更贴切点呀?

task6-列表右键菜单弹框

内容

i10.png i11.png
  • 列表的右键不弹出系统弹框、而是弹出自定义框ContextMenu
  • 自定义ContextMenu的部分item还存在子菜单
    • 子菜单的item还支持记录上次的选择:item左侧有图标表示选中
  • 右键的列表item需要改变样式、说明是哪一个item被右键
    • 如果当前item为选中状态、则在背景色外面加一层白色边框
    • 如果当前item为未选中状态,则在item外面加一层蓝色边框
    • menu消失时:边框也要消失
  • 选中结果
    • 返回选择的parentIndex、childIndex
    • 处理对应item的效果:例如重命名、删除等等
  • ContextMenu交互
    • item的hover状态、进入子menu后的选中态、子menu的item的hover状态
    • 点击menu外部menu消失
    • 这里和弹框是有区别的、类似气泡:允许外围的其他元素相应点击事件
    • ContextMenu支持自动调整位置
      • 默认情况:menu的左上角为鼠标点击处
      • 当鼠标右下角的空间无法完整展示menu时:menu会调整左上角位置、以便展示完整的contextMenu
      • 子child同样支持自动调整位置

i12.png

实现细节

  • 给每个列表item添加右键弹出自定义menu的事件: @contextmenu
    • 通过index给对应的item加白框或者蓝框
    • 通过鼠标位置弹出自定义ContextMenu
  • 设计合适的数据结构标记:item的内容、分割线的位置、是否有子菜单
  • 点击外部隐藏菜单可以用element内置的指令*v-click-outside*实现,当然需要用app.directive注册
  • 动态调整菜单和子菜单位置的实现
    • 布局为absolute、通过修改left和top来确定左上角位置
    • 需要提前计算菜单的高度、宽度
    • 通过鼠标位置(x,y)和窗口的最底部位置(bottom,right)计算出diff
    • 通过diff和高度、宽度比较判断是否需要调整位置和调整的距离
  • ContextMenu可以做成带slot的自定义组件、slot为menu的内容,这样就是和业务无关的通用组件了

知识点

难度

⭐️⭐️⭐️⭐️

随便说说

  • 我水平有限、当时并没有把ContextMenu做成通用的,这算个todo吧
  • 点击外部隐藏这个功能、当时遇到个很奇怪的bug,忘了是啥bug了,最终解决方案是:ContextMenu的左上角坐标是和鼠标额位置有一点点偏移的
  • 可以修改element的*v-click-outside*加个参数来配置外部哪些元素不响应outside

task7-列表右键菜单选择结果

内容

  • 显示已完成项目、新窗口打开、排序、添加到群组、共享等功能我都暂时没实现
  • 重命名
    • 对应item的名字部分展示input框
    • 自动选中旧的名字并且聚焦

i13.png

  • 删除
    • 如果数字count等于0:则直接删除对应item
    • 如果数字count大于0:则弹框提示是否删除
    • 删除的item如果是选中状态、需要找相邻的item选中
      • 优先选中index-1
      • 如果index=0,则选中index+1
      • 如果list.length=1,则选中全部卡片
    • 弹出的对话框
      • 回车相当于点击删除、esc相当于点击取消
      • 注意修改弹框的圆角、边框的阴影

i14.png

  • 显示列表信息
    • 会弹出和新建列表类似的弹框、内容有以下不同
      • 标题为xxx简介
      • 名称不再为空,而是xxx
      • 颜色不再为默认的,而是选中item对应的颜色和图标
    • 点击确认后、修改对应item的名称、颜色、图标

i15.png

实现细节

  • 重命名编辑框的聚焦和选中实现:focus()、selectionStart、selectionEnd
  • 删除的确认框用element的对话框即可,但是需要修改对应的默认样式
  • 确认删除的对话框可封装成自定义组件、最好的调用方式通过util传递、而不是把对话框写在template里
  • 新建类型的弹框组件需要增加对应属性props和回调emit

知识点

难度:⭐️⭐️

随便说说

  • 这部分难度不大,主要是要细心、各种逻辑和ui

task8-content顶部title

内容

i16.png

  • 右侧todo列表的顶部title
    • 名字:对应type的名字
    • 数量:对应type的todo列表的数量(未完成)
    • 添加按钮:点击添加一个新的todo
  • 交互
    • 选择不同的type,title的文字颜色会随之改变

实现细节

  • 可以加一个全局变量currentType表示当前选择的type、来做响应式的变化

知识点

难度:⭐️

随便说说

  • 此时我已经把vue选项式的写法改成了组合式了,感觉这样用pinia更方便

task9-todoItem的UI

内容

  • item布局
    • 左侧radio
    • 右侧依次为:名字(input)、备注(textarea)、日期选择器、时间选择器、分组按钮#、flag按钮旗帜

i17.png

  • radio实现
    • 选中和未选中的不同状态
    • 不同状态的切换动画

i18.png

  • 名字编辑框
    • 什么背景都没有
  • 备注
    • 什么背景都没有
    • placeHolder
    • 多行输入框、最多5行
  • 日期选择器
    • 支持选择日期
      • 点击弹出日历选择日期
      • 特殊日期:今天、昨天、前天、明天在日历上会显示

i19.png

  • 支持编辑日期
    • 直接在编辑框输入日期
    • 格式要求yyyy/mm/dd
    • 不满足格式要求、修改不生效,会自动改为之前的日期
  • 右侧有清空按钮
  • 左侧有标志日历的图标、并且区分选中和未选中两种状态
  • 时间选择器
    • 只有日期有值时、才显示
    • 支持选择时间
      • 固定的时间:9点、12点、15点、18点、21点
      • 支持键盘上下移动、支持回车选择
      • 弹出下拉框,选中状态和非选中状态颜色不同、背景色不同
      • 不同的时间应该是不同的时钟图标、但是我不会画、就没做哈

i20.png

  • 支持编辑时间
    • 格式要求:hh:mm
    • 不满足格式要求、修改不生效,会自动改为之前的时间

i21.png

实现细节

  • radio动画通过transform: scale中间的蓝点就可以实现
  • 自带的input可以实现textarea,但是不支持设置最大行数,所以用的element的textarea
  • 日期选择器
    • 使用el-date-picker时chrome一直报错preventDefault inside passive event,网上抄一个polyfill来解决
    • 功能很复杂、直接在组件库里找就行,需要修改下默认style即可
    • element设置格式的办法:value-format="YYYY-MM-DD"
    • change事件监听是否要展示特殊日期
    • el-date-picker动态切换左右两侧的icon的办法(我不知道这样是不是最佳实践、暂时只试出来这样可以)
:prefix-icon="datePrefix"


const isActive = ref(false)
const datePrefix = computed(() => {
  return h('img', {
    src: `src/assets/svg/ic_calendar_${ isActive.value ? '' : 'un' }selected.svg`,
    class: "icon",
    alt: ""
  })
})
  • 时间选择器
    • 下拉框用组件库的el-popover实现
    • input事件判断输入框内容、更新下拉框的提示内容
    • @keydown来实现键盘事件对应的操作
    • @change事件最后确定格式是否ok,是否生效
    • 这里要搞清楚change和input事件的区别
    • 我写了个很搓的正则:/^([0-1][0-9]|2[0-4]|[0-9]):([0-5][0-9]|[0-9])$/

知识点

难度:⭐️⭐️⭐️

随便说说

  • 最终日期选择器的样式和《提醒事项》相差有点大,因为感觉《提醒事项》里的日期选择器太丑了,也太难还原了,所以直接用element的datePicker
  • 前端textArea的最大高度实现为什么这么复杂?我最开始是按照Android的思路写的、设置一个maxHeight就行,但是放在前端里并不是我期望的效果

task10-todoItem交互和逻辑

内容

i22.png

  • 展开和收起
    • 默认为收起状态:显示名字、时间等信息
      • 如果时间早于当前时间:文字显示红色;否则显示灰色
      • 某些情况下还要展示所属type
    • 点击item会展开item
    • 展开和收起需要一个collapse的动画
    • 展开时、自动聚焦到name input
  • item展示的名字等信息,来自传入的参数model
    • 未收起item时:修改item上的信息时、不会实时更新model
    • 收起item时:才会修改model
    • radio切换时:会触发更新model的操作
    • 提供item展开、收起的回调
    • 点击item的checkbox可以控制item是否完成、提供done变化的回调、方便后面的功能实现

实现细节

  • 时间判断的逻辑可以用dayjs来简化代码
  • 展开收起的动画可以用el-collapse-transition来实现
  • item数据的更新、类似双向绑定,但是只有收起item、或者主动触发update时才更新model
const props = defineProps({
  name: {
    type: String,
    required: true
  },
  note: {
    type: String,
    default: ''
  },
  date: {
    type: String,
    default: ''
  },
  timer: {
    type: String,
    default: ''
  },
  isFlag: {
    type: Boolean,
    default: false,
  },
  done: {
    type: Boolean,
    default: false,
  },
  showExtra: {
    type: Boolean,
    default: false,
  },
  typeName: {
    type: String,
    default: '',
  },
  addInfo: {
    type: Object,
  },
  showAdd: {
    type: Boolean,
    default: false,
  }
})

const emit = defineEmits([
  'update:name',
  'update:note',
  'update:date',
  'update:timer',
  'update:isFlag',
  'update:done',
  'update:showExtra',
  'update:addInfo',
  'itemChange',
])
  • 数据更新的逻辑
    • 需要一个变量cacheData记录当前item的信息
    • 当item收起时、通过cacheData和props的数据比较、来触发对应属性的update
    • radio的done是不通过cacheData存储、而是直接双向绑定的

知识点

难度:⭐️⭐️

随便说说

  • TodoItem的最终设计是我经过不断理解需求和试错的过程得到的

task11-todo列表的查询和展示

内容

  • 列表的展示
    • 添加一些测试数据、显示在todo列表里
[
  { "id": 109, "typeId": 10, "name": "name1", "note": "", "date": "2023-04-20", "timer": "12:00", "isFlag": false, "done": false},
  { "id": 110, "typeId": 10, "name": "name2", "note": "", "date": "", "timer": "12:00", "isFlag": false, "done": false},
  { "id": 111, "typeId": 10, "name": "name3", "note": "", "date": "", "timer": "12:00", "isFlag": false, "done": false},
  { "id": 112, "typeId": 10, "name": "name4", "note": "", "date": "2023-04-20", "timer": "12:00", "isFlag": false, "done": false},
  { "id": 113, "typeId": 10, "name": "name5", "note": "", "date": "2023-04-20", "timer": "12:00", "isFlag": false, "done": false},
]
  • 部分交互
    • 点击任意item、展开item
    • 点击另外一个item、收起上一个item、展开当前item
  • 列表的查询
    • 点击任一个type展示当前type对应的todo列表
    • 如果todo列表为空、则展示没有提醒事项的提示
  • 点击item做成radio的交互
    • 状态由未完成变为完成后:该item会移动到下方
      • 顶部count减一、对应type的count减一
    • 状态由完成改为未完成后:该item会移动到上方原本的位置
      • 顶部count加一、对应type的count加一
    • 这个上下移动需要支持动画

i23.png

  • 列表header

i24.png

  • 位置:todo列表上方
  • 用途:显示已完成的todo数量、清除和隐藏已完成的todoItem
  • 显示隐藏的逻辑
    • 第一次进入type对应的todo列表时隐藏
    • 往下滑动则可以看到header
  • header交互
    • 点击左边的清除文本:弹窗确认是否删除已完成的item,确认删除则从存储中删除对应item
    • 点击右边的隐藏文本:不显示已完成的todoItem、文本变为显示
    • 点击右边的显示文本:显示已完成的todoItem、文本变为隐藏
    • 隐藏、清除的文本颜色跟随type设置的颜色变化

i25.png

  • 其他细节

    • 一开始进入应用没有数据时、选中全部卡片
    • 记录上一次所选择的type,进入应用后展示上次的type和type对应的todo列表

实现细节

  • todoItem组件的展开和收起要用v-if,否则第一次渲染会卡顿
  • 记录上次选中的type持久化代码可以直接用pinia-plugin-persistedstate配置path实现持久化哪个变量
defineStore('storeId', ()=>{}, {
  persist: {
    paths: ['currentTypeId', 'allTodoTypeList'],
  }
})
  • 列表的数据获取方式(我多次尝试后的最终结果)
    • 全局数据allTodoMap:type作为key,todoList作为value
    • 全局数据allTodoTypeList:type的列表
    • 每次切换type就从type中获取对应todoList即可
    • 然后通过筛选和排序得到showList
  • 如何保证done切换后,todoItem还能回到原本的位置
    • 给每个todoItem都设置一个sortId的字段、根据这个sortId来排序即可
    • 每次更新完item的数据,就对showList进行排序、就能保证位置是ok的
    • 下面是按照sortId和done来排序的代码、由于sortId要保证唯一性,所以省略了相等的判断
       export function idSortCompare(item1, item2) {
         if (item1.done !== item2.done) {
           if (item1.done) {
             return 1
           }
           return -1
         }
         if (item1.sortId < item2.sortId) {
           return -1
         }
         return 1
       }
       ```
    
    
  • css布局相关问题
    • 列表使用flex布局、分为三部分
      • header:固定高度、flex-shrink: 0;
      • itemList:自适应高度
      • 底部空白:占满剩余高度、flex:1;flex-shrink: 0;
    • 列表的高度需要设置为height: calc(100% + 41px);否则当item很多时、无法显示完整

知识点

难度:⭐️⭐️⭐️

随便说说

  • 本章以后的内容基本都是复杂的数据通信和逻辑交互了
  • chrome的vue插件查看页面和pinia的数据真的很方便、对于我这种小白来说真的起了大作用

task12-列表操作

内容

  • 新增todo的触发情况
    • 点击右上角的加号:在最底部新增一个空todo
    • 点击底部空白处:最底部新增一个空todo
    • 某一个todoA的name的input框按回车:在todoA下面新增一个空todo
  • 编辑完todoA的保存触发情况
    • 必要条件:name非空
    • 点击底部空白处:保存展开的todoA
    • name的input框处按回车:保存当前编辑的todoA
    • 点击任意一个非todoA的item:保存展开的todoA
    • todoA在展开的情况下,点击任意其他type(例如当前为type1、且todoA展开,点击type2需要保存todoA)
  • 上面任意一种保存触发情况下,如果name为空、则表示删除操作

下面按照点击元素、有哪些相应操作,来整理列表操作的流程

  • 点击空白处
    • 如果当前没有item展开:则在最底下新建一个空item、并展开
    • 如果当前有item展开
      • 当前item的name为空时:删除对应item
        • 当前item为新创建的:只删除列表item
        • 当前item为已有item:更新type的count、更新存储
      • 当前item为非空时:收起item、更新item存储
        • 当前item为新创建的:更新type的count
        • 当前item为已有item:不做其他操作
  • 点击某一个未展开的itemA :展开这个itemA
    • 如果当前没有item展开:不做其他操作
    • 如果当前有其他item展开
      • 如果这个item的name为空:触发删除流程(同点击空白处)
      • 如果这个item的name非空:出发保存流程(同点击空白处)
  • 在某个itemA、name的input框按回车:在itemA底下新建一个空的todo
    • 如果name为空
      • 如果当前item为新创建的:删除该item
      • 如果当前item为已有的item:触发删除流程(同点击空白处)
    • 如果name非空
      • 如果当前item为新创建的:保存item、更新count
      • 如果当前item为已有的item:保存item
  • 点击itemA的radio
    • 触发当前展开的item的删除、保存流程(同点击空白处)
    • 同时改变itemA的done状态

实现细节

  • 看起来逻辑很复杂、但是我们要明白以下两点就不麻烦
    • 页面来自响应式数据
    • 各种操作就是操作数据
  • 为了实现上述的判断需要增加几个变量和字段
    • item增加saved字段来判断是否保存
    • item增加done状态来判断是否完成
    • count通过computed来实现
      • 只需要遍历当前type的todoList、计算!done && saved的数量即可
    • 只有saved状态的item才需要更新存储
  • 每次操作都需要更新原始数据todoList
  • 计算属性showList用来展示列表UI:showList=todoList.filter()
  • 如何保证item的顺序呢:即怎么生成sortId、才能保证插入的新item始终在上一个item之后
    • 每次插入时:找到前一个preItem、后一个nextItem
    • 当前插入的item的sortId=(preItem.sortId + nextItem.sortId) / 2
// 保证插入的id满足pre<id<next
export function generateSortId(list, preId) {
  console.log('----preId', list, preId)
  if (preId === undefined || preId === null) {
    return 1
  }
  // next的值不一定是list[index+1],所以需要排序得到next
  let ids = list.map(item => item.sortId).sort((v1, v2) => {
    if (v1 < v2) {
      return -1
    } else if (v1 > v2) {
      return 1
    }
    return 0
  })
  let preIndex = ids.indexOf(preId)
  let nextId = ids[preIndex + 1]
  if (!nextId) {
    // 没有next时
    return preId + 1
  }
  return (preId + nextId) / 2
}

最后贴上我写的几个核心函数

// item展开收起的回调
function collapseChanged(item, index) {}
// 处理上一个展开的item
function handleLastItem(lastIndex = currentShowIndex, next) {}
// 创建新的item
function createItem(preIndex) {}
// done状态发生变化的回调
function onDoneStatusChanged(item) {}
function updateSortList()

知识点

难度:⭐️⭐️⭐️⭐️⭐️

随便说说

  • mvvm的思想来实现这个功能会简单清晰很多。如果是按照事件触发某些流程的方式来实现则会非常复杂
  • 我当时没捋清楚这个列表操作的流程,导致没捋清楚怎么操作数据的,所以花了很多时间
  • 为了实现上面的功能、我做了很多watch和computed、由于不怎么熟悉vue原理、我也不清楚会不会对性能有影响

task13-今天列表

内容

i26.png

今天列表基本同普通列表

  • 展示数据
    • 今天和今天以前的todo
    • item比普通列表多展示一个type、表示所属列表
  • 交互和逻辑
    • 没有列表header、即没有控制是否显示已完成todo的功能
    • 创建的新item
      • 默认带上时间为今天
      • 默认type属于type列表的第一个(图中的t1)
    • 所以创建一个新的todo后:今天列表和列表第一个type的count都需要+1
    • 删除某一个todo后:今天列表、所属type的count都需要-1
    • 当菜单左侧删除某一个类型后,列表数据要实时变化

实现细节

  • 今天、计划、全部的count可以用全局的计算属性来实现
  • 今天列表的数据本质上来自普通列表数据、所以只要是一个引用、修改今天列表的item等同于修改普通列表对应的item
  • 通过allTodoMap生成todayList的代码如下
const getTodayList = () => {
  // 我试了下、这里不用reactive也是ok的
  let list = reactive([])
  for (const itemList of allTodoMap.value.values()) {
    for (const item of itemList) {
      if (item.done) {
        continue
      }
      if (item.date && isBeforeToday(item.date)) {
        item.showExtra = false
        list.push(item)
      }
    }
  }
  return list
}
  • 和普通列表相比需要修改的代码如下
    • 创建item要自带type=typeList[0]和date=today
    • 排序代码要改成按时间排序
    • TodoItem组件的传入属性、用来显示type
    • 列表header不显示、所以高度不能是calc(100% + 41px)
  • 监听列表数据增加、删除变化的方法
    • 需要监听菜单item的增删:来触发todayList的更新;
    • watch(list, ()=>{})在vue3中只有深度监听才能检测list增删的变化,也就任何一个todo的修改都会触发todayList的更新, 显然这不满足要求
    • 但是这么写实可以监听list的增删的
        watch(()=>list.map(item=>item.id), ()=>{console.log('list changed')})```
    
    

难度:⭐️⭐️⭐️⭐️

随便说说

  • 理解引用和基本数据类型的区别、才能理解这个全局数据怎么变化的

task14-计划列表

内容

计划列表:展示所有设置了时间的todo

i27.png

  • 按日期维度展示所有设置了时间的todo
    • tite显示日期+周几、特殊日期显示(昨天、今天、明天、后天)
    • 对应日期的todo列表
    • 底部有一个添加item
      • 点击添加item:新建item、date默认为对应日期、type默认为typeList[0]
      • 保存item后:添加按钮重置数据
    • 任意一个item变化后
      • 如果日期不变:仍然在该日期下
      • 如果日期发生变化:将该item移动到对应日期里

i28.png

  • 必定展示今天维度的todo列表,如果todo数据为空、只展示title和添加按钮
  • 其他逻辑和全部列表基本相同

实现细节

  • 如何复用之前的代码实现加title、加按钮的功能呢?
    • 日期title、todoItem、添加按钮都认为是列表的item,只是用type区分
    • 所以实现getPlanList方法得到todoList
      • 给每个日期添加一个headItem、footItem分别表示日期title、添加按钮
      • footItem本质上还是对应一个todoItem组件
       const getPlanList = () => {
         const list = []
         const daySet = new Set()
    
         function addExtraItem(day) {
           const headItem = {
             sortInfo: {
               type: LIST_TYPE_HEADER,
               date: day
             },
           }
           const footItem = new TodoDoc(-1, undefined)
           footItem['sortInfo'] = {
             type: LIST_TYPE_FOOTER,
             date: day
           }
           footItem['date'] = day
           list.push(headItem)
           list.push(footItem)
         }
    
         for (const itemList of allTodoMap.value.values()) {
           for (const todoItem of itemList) {
             if (todoItem.date && todoItem.date) {
               daySet.add(todoItem.date)
               todoItem['sortInfo'] = {
                 type: LIST_TYPE_ITEM
               }
               todoItem.showExtra = false
               list.push(todoItem)
             }
           }
         }
         let hasToday = false
         for (const day of daySet) {
           if (!hasToday) {
             hasToday = isToday(day)
           }
           addExtraItem(day)
         }
         if (!hasToday) {
           addExtraItem(getTodayStr())
         }
         return list
       }
       ```
    
    -   然后用` <template  `*`v-for`*` ="(item, index)  `**`in`** `showList">`**代码渲染三种type的数据**
    
    
  • 比较特殊的是保存item时:需要判断日期是否发生变化, 如果变化这要更新整个列表的原始数据todoList,因为有可能日期变化导致item、header、footer都发生变化
    • 例如item日期原本是2023-04-04。如果日期改为2023-05-05,此时如果04-04只有这一个todo,04-04的header和footer都会被删除
    • 这么做的后果就是大概率会频繁的更新todoList,注意会不会影响性能呢

知识点

难度:⭐️⭐️⭐️⭐️⭐️

随便说说

  • 计划列表的实现方案,和Android ListViewgetItemType非常类似
  • 实际上之前我是有方案二的:把header、todoList、footer封装成一个组件A,然后列表里显组件A的list,这样貌似更清晰点、但是实现起来很麻烦、我就放弃了
  • 代码里为了复用列表操作的逻辑、写了很多if else代码。如果能像Android一样写抽象类、抽象方法的话会清晰很多
  • 在做日期是否发生变化的判断代码时,本来是把这段代码写在todoItem的组件内部、通过emits暴露给父组件调用的,但是父组件在template v-for中,并且vue3是不支持template里使用ref的,所以没有这么实现。有没有办法解决这个在template里使用ref的问题呢
  • 感觉用watchEffect写起来更清晰点、但是我不怎么会用

task15-全部列表

内容

按照type维度展示所有todo

i29.png

  • 展示内容
    • type+todo列表+添加按钮
    • type维度按照左侧菜单顺序,相同type的todoItem按照sortId排序、即和普通type列表的顺序保持一致
  • 交互
    • 增删item都会影响到左边menu对应type的数量
    • 新增的item默认添加对应type
  • 其他细节
    • 当我的列表为空时:点击新增会谈框提示新建一个type
    • 点击创建会走menu底部创建列表的逻辑

i30.png

实现细节

  • 代码逻辑基本同计划列表
  • todo列表编辑组件调用menu组件的创建type代码可考虑使用依赖注入 | Vue.js来简化代码

知识点

难度:⭐️⭐️⭐️

随便说说

  • todoItem列表组件的代码由于糅合了全部列表、今天列表、计划列表、普通列表的代码有太多的if else了,这真的是太糟糕了

task16-搜索列表

内容

i33.png

  • 入口
    • 搜索框输入内容非空时(输入内容动态更新搜索结果)
    • 焦点在搜索框、内容非空按回车时(例如从其他type切换到搜索框、搜索框内容非空此时按回车)
  • 顶部title:隐藏数量、隐藏右上角+号按钮、颜色为#5b626a
  • 搜索结果列表
    • 搜索逻辑:搜索名字或备注包含搜索关键字的todo
    • 显示逻辑基本同全部列表
    • 无法新增item、可以修改、删除item
      • 点击空白处、只能收起当前item、不会创建新的todo
      • 在item的name编辑框回车:只做保存操作、不会在下一行新建todo
  • 其他交互
    • 搜索需要做节流:即搜索框内容更新不会立即更新搜索结果、需要等待一定时间值没有变化才搜索
    • 搜索框内容为空时、类别自动选中上一次的type
      • 例如当前为type1;点击搜索框编辑内容后、展示搜索内容;此时如果清空搜索框、会自动选中type1,并且type1对应的todoList
      • 如果在搜索列表的页面退出应用、则下次进来选中全部type

实现细节

  • 搜索框的节流用setTimeout实现
  • 排序和筛选直接复用全部列表的代码
  • 需要修改创建item代码:即当为搜索列表时、阻止创建item

知识点

难度:⭐️⭐️⭐️

随便说说

  • 实现完前面几个列表、这个搜索列表的代码就轻松多了;但是if else又多了一层