vue3-todolist (自带撸个Table和Datepicker组件)

    科技2025-06-05  30

    vue3-todolist

    0. 前言

    之前已经学了一波vue3,笔记在vue3学习笔记

    想直接看代码的可以去github

    1. 实现效果

    2. 需求分析

    1. 模块功能分析

    列表展示(静态展示 + 状态修改) table静态展示table支持自定义列 (自定义slot的插入) 事件录入(控制部分) 自定义的input输入框日期选择器

    2. 组件分析

    通过上面的分析,我们需要撸的组件大概有这么几个:

    tablebuttoninputdatepicker

    好了需求明确了,废话不多说开干~冲冲冲

    3. 一个简单的Table组件

    1. 打算怎么使用?

    以前使用的React Table组件好像都是类似这么写的,就是指定对应的数据源,然后我们通过对每一列Column的配置,他会将对应列行的每一列去取得对应的字段填上去,emmmmmm我就要这种~

    <SimpleTable :dataSource="todoList" :columnConfig="column" /> <script> export default { setup() { return { column: [{ title: "待办事项", key: "title", }, { title: "目标", key: "target", }, { title: "起始时间", key: "startTime", width: "150px", }, { title: "结束时间", key: "endTime", width: "150px", }, // 下面这个两个添加slot的,我们2.0版本用,后面为了不重复复制这段先放着 { title: "状态", slot: "status", }, { title: "操作", slot: "action", width: "200px", }, ] } } } </script>

    2. 开始1.0版本

    目标:

    table列宽可定制 -> 通过colgroup指定对应的col的宽度每一列的title可定制 -> 通过thead 中的 th指定对应的header的名就好了每一列能根据传入的key从data中拿数据,在tbody的tr和td中遍历key就行了 <template> <table class="table"> <colgroup> <col v-for="item in columns" :key="item.key" :aria-colspan="item.span || 1" :width="item.width || '100px'" /> </colgroup> <thead class="table-head"> <th scope="col" v-for="(item, index) in columns" :key="'header -' + index"> {{ item.title }} </th> </thead> <tbody> <tr v-for="(data, index) in tableData" :key="'tabledata' + index"> <td v-for="(item, i) in columns" :key="'tabledata - index' + i"> {{ data[item.key] }} </td> </tr> </tbody> </table> </template> <script> import { reactive, watchEffect } from "vue"; export default { props: { dataSource: Array, columnConfig: Array, align: String, }, setup(props) { let columns = reactive([]); let tableData = reactive([]); watchEffect(() => { columns = props.columnConfig; tableData = props.dataSource; }); return { columns, tableData, }; }, }; </script> <style> .table { text-align: left; border-collapse: collapse; border-spacing: 0; border: 1px solid #e9e9e9; } .table th, .table td { padding: 6px 12px; border: 1px solid #e9e9e9; } .table-head { height: 20px; background: #d4d4d3; } </style>

    现有问题:

    目前我们已经可以支持简单的表单显示了,但是如果我们需要在后面增加自定义按钮要怎么弄的?

    Q1: 使用slot就可以啦~

    3. 开始2.0版本

    1. 我们可以这么用:

    <SimpleTable :dataSource="todoList" :columnConfig="column"> <template v-slot:action="{ item }"> <my-button :handleClick="handleChangeClick(item)" type="primary">修改状态</my-button> <my-button :handleClick="handleDeleteClick(item)" type="danger">删除</my-button> </template> <template v-slot:status="{ item }"> <span v-if="item.isFinished">已完成</span> <span v-else>未完成</span> </template> </SimpleTable>

    即: 如果我们那一行对应的slot是action,我们就加载我们自定义action的具名插槽,如果我们对应的是status的slot,我们就可以使用名为status的具名插槽,我们来看看怎么写这个v-dom的结构:

    其他代码略,我们仅改动tbody

    <tbody> <tr v-for="(data, index) in tableData" :key="'tabledata' + index"> <td v-for="(item, i) in columns" :key="'tabledata - index' + i"> <template v-if="item.slot"> <slot :name="item.slot" :index="index" :item="data"></slot> </template> <template v-else>{{ data[item.key] }}</template> </td> </tr> </tbody>

    记笔记:我们通过遍历column的时候去判断是否item中存在slot,如果存在的话,我们就加载对应的slot就行了

    4. 一个简单的input组件

    1. 新变动

    input组件的问题在于,我们需要了解v-model自定义组件中的新用法:

    vue 3 v-model官方文档中,说这里有一个breaking change:

    prop:value -> modelValueevent: input -> update:modelValue

    我们来看看现在要怎么用吧

    2. 我们想怎么用

    <my-input label="输入代办事项" v-model="todoForm.title" showCol="true" ></my-input>

    主要简单设计的api:

    label -> 输入框的标题showCol -> 是否显示冒号支持v-model

    3. 代码撸起来

    关键点:

    原来我们通过this.$emit来触发事件,现在这些东西都在context也就是setup的第二个参数中,解构参数可获得外层v-model其实传入的参数是modelValue,我们emit要触发父组件v-model监听的事件是 update:modelValue我们input的change其实还是改变的value这个prop和监听@input这个事件,所以这一层我们不需要变动 <template> <div class="input"> <span class="label">{{ label }}<span v-if="showCol">:</span></span> <input type="text" :value="modelValue" @input="handleInpuChange" class="input_bar" @focus="focus" @blur="blur" ref="inputRef" /> </div> </template> <script> import { onMounted, ref } from 'vue'; export default { props: { label: String, modelValue: String, showCol: Boolean, focus: { type: Function, default: () => {} }, blur: { type: Function, default: () => {} }, }, setup(props, { emit }) { const inputRef = ref() const handleInpuChange = (event) => { emit("update:modelValue", event.target.value); } onMounted(() => { console.log('ref = ', inputRef.value) }) const inputFocus = () => { inputRef.value && inputRef.value.focus() } return { handleInpuChange, inputFocus, inputRef } }, }; </script> <style> .input { font-size: 16px; line-height: 24px; display: flex; align-items: center; } .label { margin-right: 6px; font-weight: 600; } .input_bar { height: 24px; border: 1px solid #eee; outline: none; padding: 4px 8px; } .input_bar:focus { border-color: #1155bb; } </style>

    5. 一个简单的datepicker组件

    1. 打算怎么用

    <my-date-picker v-model="todoForm.startTime" label="输入起始时间" ></my-date-picker>

    提供的api能力:

    支持v-model获取选择的参数通过label指定对应的标题

    2. 思路分析

    datepicker其实分为两个部分:输入框 + 日期选择面板

    输入框我们通过刚才的input可以完美打成我们的需求

    日期选择面板的制作

    思路前言:其实我们看下一般windows的日历,他其实分为6行,每行一个礼拜一共六个礼拜,我们只需要,将这个月的天数补齐在对应的位置,然后其他的用前一个月和后一个月的日子补齐就好了~~~

    计算第一天是周几 -> 来计算前一个月需要补齐的天数

    添加本月天信息

    剩下6 * 7 - 前一个月的天数 - 本月天数 用后一个月补齐

    3. 代码撸起

    重点:

    我们通过input的blur事件来显示和隐藏我们的日期选择panel,但是其实你点击panel的时候input已经blur了,所以我们需要通过mouseover事件来监听是否在我们的panel上来使,点击的时候能获取到对应的日期在我们点击prev和next按钮的时候因为此时鼠标在panel内,因此我们再点击页面不会让panel收回,这是因为其实这个时候input已经是blur状态,后面点击页面其他位置不会再出发input的panel事件了,因此,我们需要通过ref去获取input,然后手动focus 一下input,解决这个bug在发生改变的时候,我们还是需要通过modelValue和update:modelValue来实现组件的双向绑定 <template> <div class="simle-datepicker"> <my-input :label="label" :focus="handleFocus" :blur="handleBlur" :showCol="true" :modelValue="modelValue" ref="inputRef" /> <div class="simple-datepicker-panel" v-if="showPanel" @mouseover="handleMouseOver" @mouseleave="handleMouseLeave"> <div class="simple-datepicker-panel-title"> <div class="simple-datepicker-panel-prev" @click="handleClickPrev()"></div> <div class="simple-datepicker-panel-next" @click="handleClickNext()"></div> <div class="simple-datepicker-panel-date"> {{ year }} 年 {{ month + 1 }} 月 </div> </div> <div class="simple-datepicker-panel-block"> <div class="simple-datepicker-panel-cell" :class=" item.year === pickDate.year && item.month === pickDate.month && item.date === pickDate.day ? ' active' : '' " v-for="(item, index) in panelMap" :key="'panel-' + index" @click="handleSelectTime(item)"> {{ item.date }} </div> </div> </div> </div> </template> <script> import moment from "moment"; import myInput from '../Input'; import { onMounted, reactive, ref, unref, watchEffect, } from "vue"; export default { props: { title: { type: String, default: '' }, label: String, modelValue: String }, setup(props, { emit }) { let initVal = moment(Date()); let panelMap = ref([]); let showPanel = ref(false); let year = ref(initVal.year()); let month = ref(initVal.month()); let day = ref(initVal.date()); let pickDate = reactive({ year: -1, month: -1, day: -1 }) let isOver = ref(false); let inputRef = ref(null); watchEffect(() => { console.log(props.modelVal) }) const getConfigByYearMonth = (year, month) => { const firstDay = moment() .year(year.value) .month(month.value) .date(1); const firstDayWeek = firstDay.day(); const lastMonthDay = firstDay.subtract(1, "days"); const prevDateMap = Array.from({ length: firstDayWeek - 1, }) .map((item, index) => ({ date: lastMonthDay.date() - index, isCurrent: false, year: month.value ? unref(year) : unref(year) - 1, month: month.value ? unref(month) - 1 : 0, })) .reverse(); const currDateMap = Array.from({ length: moment() .year(year.value) .month(month.value) .daysInMonth(), }).map((item, index) => ({ date: index + 1, isCurrent: true, year: unref(year), month: unref(month), })); const nextDateMap = Array.from({ length: 42 - (prevDateMap.length + currDateMap.length), }).map((item, index) => ({ date: index + 1, isCurrent: false, year: month.value === 11 ? unref(year) + 1 : unref(year), month: month.value === 11 ? 0 : unref(month) + 1, })); panelMap.value = [...prevDateMap, ...currDateMap, ...nextDateMap]; }; const handleMonthChange = (isPrev) => { if (isPrev) { year.value = month.value ? unref(year) : unref(year) - 1, month.value = month.value ? unref(month) - 1 : 0 } else { year.value = month.value === 11 ? unref(year) + 1 : unref(year) month.value = month.value === 11 ? 0 : unref(month) + 1 } }; onMounted(() => { console.log('inputRef = ', inputRef.value) }) const handleClickPrev = () => { handleMonthChange(true) inputRef.value.inputFocus() } const handleClickNext = () => { handleMonthChange(false) inputRef.value.inputFocus() } watchEffect(() => { getConfigByYearMonth(year, month) }) watchEffect(() => { console.log('update map') getConfigByYearMonth(year, month); }); const handleFocus = () => { showPanel.value = true; }; const handleBlur = () => { if (isOver.value) return; showPanel.value = false; }; const handleSelectTime = (select) => { const { year: selectYear, month: selectMonth, date: selectDate } = select; emit('update:modelValue', moment() .year(selectYear) .month(selectMonth) .date(selectDate) .format("YYYY-MM-DD")); year.value = selectYear; month.value = selectMonth; day.value = selectDate; pickDate.year = selectYear pickDate.month = selectMonth pickDate.day = selectDate handleMouseLeave(); handleBlur(); }; const handleMouseOver = () => { isOver.value = true; }; const handleMouseLeave = () => { isOver.value = false; }; return { handleFocus, year, month, day, panelMap, showPanel, handleBlur, handleSelectTime, handleMouseOver, handleMouseLeave, handleClickPrev, handleClickNext, inputRef, pickDate }; }, components: { myInput } }; </script> <style> .simle-datepicker { position: relative; } .simple-datepicker-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; position: absolute; width: 300px; top: 50px; left: 50%; transform: translateX(-50%); background: #fff; z-index: 100; } .simple-datepicker-panel-title { height: 32px; line-height: 32px; border: 1px solid #eee; width: 100%; box-sizing: border-box; text-align: center; color: #1587f5; position: relative; } .simple-datepicker-panel-prev, .simple-datepicker-panel-next { box-sizing: border-box; border: 8px solid #1587f5; position: absolute; top: 50%; transform: translateY(-50%); } .simple-datepicker-panel-prev { border-color: transparent #1587f5 transparent transparent; left: 0; } .simple-datepicker-panel-next { border-color: transparent transparent transparent #1587f5; right: 0; } .simple-datepicker-panel-block { display: flex; flex-wrap: wrap; } .simple-datepicker-panel-cell { display: inline-block; width: calc(100% / 7); height: 40px; border: 1px solid #eee; line-height: 40px; text-align: center; font-size: 16px; color: #1587f5; box-sizing: border-box; cursor: pointer; } .active, .simple-datepicker-panel-cell:hover { background: #1587f5; color: #eee; } </style>

    6.感谢

    感谢你们看到这里,蟹蟹蟹蟹

    Processed: 0.014, SQL: 8