先上菜
这篇文档的要素
- 有代码
- 甚至还把需求简单列了一遍
- 甚至还有实现细节
所以如果你是想学习vue实战的新人,可以按照我的需求文档从0到1实现一个todo项目
简单介绍下我自己:写Android的、会js。没用过前端框架、刚学vue三天。由于咱就是个急性子,刚看完官网文档、直接上手写代码了。所以当初写这个项目我也是边学边写的,用到的第三方库很少:主要就一个UI组件库和数据共享的pinia
虽然用的是vue3,由于还没学过ts,所以用的是js。虽然我到后面发现没有类型写得真不顺手、很想换到ts、但是考虑到时间就没换。
最开始是代码风格是option、后面感觉compisition更好点、就都改了
写这个todo、我这个老年人都断断续续花了大半个月。所以对于一个新人来说、如果你能从0到1写完这个todo应用,我觉得你vue可以算是入门了
vue官网真的很友好、是我见过官网里对新人最友好的了。基本上项目里的问题、大部分我都能很快在官网文档里查到并解决
咱写Android的。就是个前端练习生、前端代码写的一般、请轻喷
飞书文档:easy-todo
前言
前段时间由于某些原因、学了下vue,感觉得搞个练手的前端项目做做,网上搜了一堆感觉都是那种后台管理系统、说白了就是一堆框架堆砌、觉得对新人并不怎么友好,所以我准备自己从零写个项目搞搞
写什么项目呢?我选择了mac上自带的应用《提醒事项》,为啥选这个呢?当初设想的是这几个原因
- UI够简单(这个自带应用真的有够丑的(⊙o⊙)…)
- 功能不那么复杂(实际上一点也不复杂,我当时太肤浅了)
- 有不少交互和逻辑、对于新人学习vue挺好的
- 颜色我可以自己量啊(我是真不知道rgba该用哪个)
这个《提醒事项》的功能看起来很简单,可是当我后来实现的时候、发现处处是玄机、全是隐藏功能。所以我现在的这个代码最多也就是实现了他功能的30%
需求列表
暂时我只写了这么多需求、如果以后有时间还可以再修修补补
task1-项目初搭
内容
- 初始化项目代码
- 首页整体布局
- 左侧固定宽度的菜单
- 适配:当页面宽度过窄时,不显示左侧菜单
- 右侧显示todo的内容
- 左侧固定宽度的菜单
实现细节
- 很简单、没啥好说的
知识点
难度:⭐️
task2-左侧菜单UI
内容
- 顶部搜索框
- 左侧搜索图标、默认的placeHolder、右侧删除图标
- 交互
- focus、hover时的不同状态
- 点击搜索框时、外边框由小变大再变小的动画
- 有文字时显示右侧删除图标
- "今天"、"计划"、"全部"的卡片
- 左上角圆形背景色的图标:其中今天卡片的中间还有数字代表今天的日期
- 右上角的数字:对应卡片的todo个数
- 下方文字:分别为"今天"、"计划"、"全部"
- 交互
- hover时:卡片背景色变化
- 选中卡片时:卡片背景色发生变化、文字颜色变化、圆形背景色变化、图标颜色变化
- 类型列表
- 顶部title文字:"我的列表"
- 列表内容:圆形背景色图标、类型名、数字
- 交互
- 列表长度超出屏幕时
- 可滑动
- 并且底部出现分割线(我没实现,太麻烦)
- 选中item时:背景色、文字颜色发生变化
- 列表滑动时
- 列表顶部出现一个带阴影的分割线、滑动停止分割线消失(这个我没实现)
- 列表底部同样出现分割线、停止消失(我就写死了分割线一直在)
- 列表长度超出屏幕时
- 底部添加列表
- 代表添加的图标+文字
实现细节
- input搜索框选中的动画不能用border来做、因为border是占空间的;而outline和shadow则不占
- 卡片考虑复用可以抽象为组件
- 日期获取用dayjs来实现简单点
- 列表item也需要抽象为组件
- 列表数据刚开始可以随便写点假数据来做
- 可以考虑sass等css预编译框架增强可读性
知识点
难度:⭐️⭐️
- 搜索框的动画虽然很丑、但是实现起来我花了很久、毕竟css真难啊
- 卡片交互的css代码还是挺多的
随便说说
- css太牛了!这些交互我用Android写肯定没那么优雅
- 这《提醒事项》的UI风格太非主流了,都没什么组件库长他这样的
task3-类型列表拖拽
内容
- 列表item支持点击按住拖拽
- 拖拽时在对应位置上展示横线:按照item区间分为三种状态:item的上半部分、中间部分、下半部分
- 鼠标处于item的上半部分[0, 1/4]时:横线出现在item的上面
- 鼠标处于item的上半部分[3/4, 1]时:横线出现在item的下面
- 鼠标处于item的上半部分[1/4, 3/4]时:横线消失、但是选中该item(这个功能我暂时还没做,就打个log)
- 鼠标在类型列表以外的部分:横线、选中都消失
- 鼠标处于最上面、最下面时:横线分别位于第一个item的最上面、最后一个item的最下面
- 拖拽结束时对应的操作
- 选中状态:合并两个item,相当于创建type组
- 横线状态:拖拽的item移到对应的位置
- 支持移动动画
实现细节
- 画横线:增加一个变量
overIndex来判断横线在哪个item显示 - 监听drag相关事件实现拖拽
@drag:监听拖拽时鼠标的位置,判断滑出列表、是否需要隐藏横线@dragstart:监听拖拽的开始@dragover:监听在哪一个item内拖动、通过**event.offsetY和item的高度来判断在item那一部分**@dragend:监听拖拽的结束,通过当前位置来判断最终把拖拽的item移动到哪里- 这里尽量不要频繁调用
getBoundingClientRect,否则可能影响性能?
TransitionGroup来实现列表变化的动画
知识点
难度:⭐️⭐️⭐️⭐️
随便说说
- 拖拽的逻辑看起来很复杂,但是捋清楚每一个步骤需要干什么、每一个监听回调做什么也没那么麻烦的
task4-新建列表
内容
- 入口
- 点击菜单底部"新建列表"、弹出新建类型列表的弹框
- 弹框内容
- 标题:新建列表、靠左
- 名称:input框、类型的名字
- 颜色:12个颜色可供选择、对应菜单列表里图标的背景颜色
- 图标:对应菜单列表里的图标
- 转换:checkbox(这个我没实现)
- 笑脸:这个我没实现
- 底部:取消、确认(好)按钮
- 交互
- input要自动聚焦、并且不会失去焦点
- 颜色默认选择蓝色、图标默认选择图标列表第一个
- 颜色选中后:改变颜色选择的状态、同时改变图标的背景色
- 点击图标按钮:向右弹出选择图标的浮层
- 这个浮层弹出后自动选中当前的图标
- 浮层弹出有动画、并且当右边没有足够空间展示时、向左弹出浮层
- 点击浮层外部、关闭浮层
- 选中浮层中任意一个图标、关闭浮层、并且修改当前图标
- 名称为空时:确认按钮不可点的状态;名称非空时、确认按钮可点状态
- 弹窗可点击外部关闭
实现细节
- 可以封装多个组件来实现
- 弹窗和浮层自己实现很麻烦、引用
element-plus来实现- 由于样式有些不同、需要通过覆盖css来实现,我认为这里尽量不要污染全局的css
- 使用el-popover实现的弹出浮层、点击触发浮层的圆形图标以外的位置都会关闭,这个并不满足要求,可以考虑自己实现click-outside指令来做
- vue3里深度选择器的写法是
:deep(xxxx)
知识点
- Dialog 对话框 | Element Plus
- Popover 气泡卡片 | Element Plus
- 优先级 - CSS:层叠样式表 | MDN
- 聊聊样式穿透 vue 中的 scoped - 掘金
- css子元素控制父元素样式的方法::has() - CSS:层叠样式表 | MDN
难度:⭐️⭐️⭐️
随便说说
- 修改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
知识点
- 模板引用 | Vue.js
- Element.scrollTo() - Web API 接口参考 | MDN
- 开始 | Vuex
- Pinia | Pinia
- Getting Started | pinia-plugin-persistedstate
难度:⭐️⭐️
随便说说
- 数据共享我最开始用的
vuex,感觉要写一堆重复的代码很没必要,后面改成了pinia - 题外话:为啥前端喜欢把dependencies叫插件?我觉得第三方库更贴切点呀?
task6-列表右键菜单弹框
内容
- 列表的右键不弹出系统弹框、而是弹出自定义框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同样支持自动调整位置
实现细节
- 给每个列表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的父容器最好应该是body,当时为了图简单没这么做
- 可以考虑用渲染函数 API | Vue.js
知识点
难度
⭐️⭐️⭐️⭐️
随便说说
- 我水平有限、当时并没有把ContextMenu做成通用的,这算个todo吧
- 点击外部隐藏这个功能、当时遇到个很奇怪的bug,忘了是啥bug了,最终解决方案是:ContextMenu的左上角坐标是和鼠标额位置有一点点偏移的
- 可以修改element的*
v-click-outside*加个参数来配置外部哪些元素不响应outside
task7-列表右键菜单选择结果
内容
- 显示已完成项目、新窗口打开、排序、添加到群组、共享等功能我都暂时没实现
- 重命名
- 对应item的名字部分展示input框
- 自动选中旧的名字并且聚焦
- 删除
- 如果数字count等于0:则直接删除对应item
- 如果数字count大于0:则弹框提示是否删除
- 删除的item如果是选中状态、需要找相邻的item选中
- 优先选中
index-1 - 如果index=0,则选中
index+1 - 如果list.length=1,则选中全部卡片
- 优先选中
- 弹出的对话框
- 回车相当于点击删除、esc相当于点击取消
- 注意修改弹框的圆角、边框的阴影
- 显示列表信息
- 会弹出和新建列表类似的弹框、内容有以下不同
- 标题为xxx简介
- 名称不再为空,而是xxx
- 颜色不再为默认的,而是选中item对应的颜色和图标
- 点击确认后、修改对应item的名称、颜色、图标
- 会弹出和新建列表类似的弹框、内容有以下不同
实现细节
- 重命名编辑框的聚焦和选中实现:
focus()、selectionStart、selectionEnd - 删除的确认框用element的对话框即可,但是需要修改对应的默认样式
- 确认删除的对话框可封装成自定义组件、最好的调用方式通过util传递、而不是把对话框写在template里
- 新建类型的弹框组件需要增加对应属性
props和回调emit
知识点
难度:⭐️⭐️
随便说说
- 这部分难度不大,主要是要细心、各种逻辑和ui
task8-content顶部title
内容
- 右侧todo列表的顶部title
- 名字:对应type的名字
- 数量:对应type的todo列表的数量(未完成)
- 添加按钮:点击添加一个新的todo
- 交互
- 选择不同的type,title的文字颜色会随之改变
实现细节
- 可以加一个全局变量
currentType表示当前选择的type、来做响应式的变化
知识点
难度:⭐️
随便说说
- 此时我已经把vue选项式的写法改成了组合式了,感觉这样用pinia更方便
task9-todoItem的UI
内容
- item布局
- 左侧radio
- 右侧依次为:名字(input)、备注(textarea)、日期选择器、时间选择器、分组按钮#、flag按钮旗帜
- radio实现
- 选中和未选中的不同状态
- 不同状态的切换动画
- 名字编辑框
- 什么背景都没有
- 备注
- 什么背景都没有
- placeHolder
- 多行输入框、最多5行
- 日期选择器
- 支持选择日期
- 点击弹出日历选择日期
- 特殊日期:今天、昨天、前天、明天在日历上会显示
- 支持选择日期
- 支持编辑日期
- 直接在编辑框输入日期
- 格式要求yyyy/mm/dd
- 不满足格式要求、修改不生效,会自动改为之前的日期
- 右侧有清空按钮
- 左侧有标志日历的图标、并且区分选中和未选中两种状态
- 时间选择器
- 只有日期有值时、才显示
- 支持选择时间
- 固定的时间:9点、12点、15点、18点、21点
- 支持键盘上下移动、支持回车选择
- 弹出下拉框,选中状态和非选中状态颜色不同、背景色不同
- 不同的时间应该是不同的时钟图标、但是我不会画、就没做哈
- 支持编辑时间
- 格式要求:hh:mm
- 不满足格式要求、修改不生效,会自动改为之前的时间
实现细节
- 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的办法(我不知道这样是不是最佳实践、暂时只试出来这样可以)
- 使用el-date-picker时chrome一直报错
: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])$/
知识点
- DatePicker 日期选择器 | Element Plus
- Popover 气泡卡片 | Element Plus
- 总结oninput、onchange与onpropertychange事件的使用方法和差别 - 掘金
- 插槽 Slots | Vue.js
- 渲染函数 API | Vue.js
难度:⭐️⭐️⭐️
随便说说
- 最终日期选择器的样式和《提醒事项》相差有点大,因为感觉《提醒事项》里的日期选择器太丑了,也太难还原了,所以直接用element的datePicker
- 前端textArea的最大高度实现为什么这么复杂?我最开始是按照Android的思路写的、设置一个maxHeight就行,但是放在前端里并不是我期望的效果
task10-todoItem交互和逻辑
内容
- 展开和收起
- 默认为收起状态:显示名字、时间等信息
- 如果时间早于当前时间:文字显示红色;否则显示灰色
- 某些情况下还要展示所属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加一
- 这个上下移动需要支持动画
- 状态由未完成变为完成后:该item会移动到下方
- 列表header
- 位置:todo列表上方
- 用途:显示已完成的todo数量、清除和隐藏已完成的todoItem
- 显示隐藏的逻辑
- 第一次进入type对应的todo列表时隐藏
- 往下滑动则可以看到header
- header交互
- 点击左边的清除文本:弹窗确认是否删除已完成的item,确认删除则从存储中删除对应item
- 点击右边的隐藏文本:不显示已完成的todoItem、文本变为显示
- 点击右边的显示文本:显示已完成的todoItem、文本变为隐藏
- 隐藏、清除的文本颜色跟随type设置的颜色变化
-
其他细节
- 一开始进入应用没有数据时、选中全部卡片
- 记录上一次所选择的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;
- header:固定高度、
- 列表的高度需要设置为
height: calc(100% + 41px);否则当item很多时、无法显示完整
- 列表使用flex布局、分为三部分
知识点
难度:⭐️⭐️⭐️
随便说说
- 本章以后的内容基本都是复杂的数据通信和逻辑交互了
- 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:不做其他操作
- 当前item的name为空时:删除对应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
- 如果name为空
- 点击itemA的radio
- 触发当前展开的item的删除、保存流程(同点击空白处)
- 同时改变itemA的done状态
实现细节
- 看起来逻辑很复杂、但是我们要明白以下两点就不麻烦
- 页面来自响应式数据
- 各种操作就是操作数据
- 为了实现上述的判断需要增加几个变量和字段
- item增加saved字段来判断是否保存
- item增加done状态来判断是否完成
- count通过computed来实现
- 只需要遍历当前type的todoList、计算
!done && saved的数量即可
- 只需要遍历当前type的todoList、计算
- 只有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-今天列表
内容
今天列表基本同普通列表
- 展示数据
- 今天和今天以前的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
- 按日期维度展示所有设置了时间的todo
- tite显示日期+周几、特殊日期显示(昨天、今天、明天、后天)
- 对应日期的todo列表
- 底部有一个添加item
- 点击添加item:新建item、date默认为对应日期、type默认为typeList[0]
- 保存item后:添加按钮重置数据
- 任意一个item变化后
- 如果日期不变:仍然在该日期下
- 如果日期发生变化:将该item移动到对应日期里
- 必定展示今天维度的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
ListView的getItemType非常类似 - 实际上之前我是有方案二的:把header、todoList、footer封装成一个组件A,然后列表里显组件A的list,这样貌似更清晰点、但是实现起来很麻烦、我就放弃了
- 代码里为了复用列表操作的逻辑、写了很多if else代码。如果能像Android一样写抽象类、抽象方法的话会清晰很多
- 在做日期是否发生变化的判断代码时,本来是把这段代码写在todoItem的组件内部、通过emits暴露给父组件调用的,但是父组件在template v-for中,并且vue3是不支持template里使用ref的,所以没有这么实现。有没有办法解决这个在template里使用ref的问题呢
- 感觉用watchEffect写起来更清晰点、但是我不怎么会用
task15-全部列表
内容
按照type维度展示所有todo
- 展示内容
- type+todo列表+添加按钮
- type维度按照左侧菜单顺序,相同type的todoItem按照sortId排序、即和普通type列表的顺序保持一致
- 交互
- 增删item都会影响到左边menu对应type的数量
- 新增的item默认添加对应type
- 其他细节
- 当我的列表为空时:点击新增会谈框提示新建一个type
- 点击创建会走menu底部创建列表的逻辑
实现细节
- 代码逻辑基本同计划列表
- todo列表编辑组件调用menu组件的创建type代码可考虑使用依赖注入 | Vue.js来简化代码
知识点
难度:⭐️⭐️⭐️
随便说说
- todoItem列表组件的代码由于糅合了全部列表、今天列表、计划列表、普通列表的代码有太多的if else了,这真的是太糟糕了
task16-搜索列表
内容
- 入口
- 搜索框输入内容非空时(输入内容动态更新搜索结果)
- 焦点在搜索框、内容非空按回车时(例如从其他type切换到搜索框、搜索框内容非空此时按回车)
- 顶部title:隐藏数量、隐藏右上角+号按钮、颜色为
#5b626a - 搜索结果列表
- 搜索逻辑:搜索名字或备注包含搜索关键字的todo
- 显示逻辑基本同全部列表
- 无法新增item、可以修改、删除item
- 点击空白处、只能收起当前item、不会创建新的todo
- 在item的name编辑框回车:只做保存操作、不会在下一行新建todo
- 其他交互
- 搜索需要做节流:即搜索框内容更新不会立即更新搜索结果、需要等待一定时间值没有变化才搜索
- 搜索框内容为空时、类别自动选中上一次的type
- 例如当前为type1;点击搜索框编辑内容后、展示搜索内容;此时如果清空搜索框、会自动选中type1,并且type1对应的todoList
- 如果在搜索列表的页面退出应用、则下次进来选中全部type
实现细节
- 搜索框的节流用setTimeout实现
- 排序和筛选直接复用全部列表的代码
- 需要修改创建item代码:即当为搜索列表时、阻止创建item
知识点
难度:⭐️⭐️⭐️
随便说说
- 实现完前面几个列表、这个搜索列表的代码就轻松多了;但是if else又多了一层