Element-UI里的date-picker是个优秀的时间选择器,支持的选项很多,定制型很强。不过date-picker在2.12版本之前并不支持自定义单元格样式,也就是2.12的cellClassName功能。所以如果使用了2.12之前的版本,那么你就无法直接去更改单元格的样式了,因此在日历上就无法标记出重要日期(比如放假安排)。

公司项目里用的Element-UI版本是2.3.9,但是需要使用2.12版本的那个cellClassName功能。如果你要问为什么不升级到最新版,那我只能说如果升级到了最新版就没有这篇文章了。

目的

  1. 传入一个数组里面存储YYYY-MM-DD格式的时间,在面板上为符合的数据加上对应的class
  2. 切换panel时已经标记的数据不会丢失
  3. 不能升级到2.12版本

源码解析

先直接看源码的结构。

elementui nodejs 版本_elementui nodejs 版本

date-picker的核心是picker.vue,用来操作整个picker的初始化、隐藏、显示等功能。具体每天的展示是date-table.vue来控制的。

elementui nodejs 版本_javascript_02


date-table的HTML源码如下,我们可以看出为每个TD,也就是单元格增加class是使用了getCellClasses这个方法。遍历数据使用了rows

<template>
    <table cellspacing="0" cellpadding="0" class="el-date-table" @click="handleClick" @mousemove="handleMouseMove" :class="{ 'is-week-mode': selectionMode === 'week' }">
        <tbody>
            <tr>
                <th v-if="showWeekNumber">{{ t('el.datepicker.week') }}</th>
                <th v-for="(week, key) in WEEKS" :key="key">{{ t('el.datepicker.weeks.' + week) }}</th>
            </tr>
            <tr class="el-date-table__row" v-for="(row, key) in rows" :class="{ current: isWeekActive(row[1]) }" :key="key">
                <td v-for="(cell, key) in row" :class="getCellClasses(cell)" :key="key">
                    <div>
                        <span>
                            {{ cell.text }}
                        </span>
                    </div>
                </td>
            </tr>
        </tbody>
    </table>
</template>
<script>
methods: {
        getCellClasses(cell) {
            const selectionMode = this.selectionMode;
            const defaultValue = this.defaultValue
                ? Array.isArray(this.defaultValue)
                    ? this.defaultValue
                    : [this.defaultValue]
                : [];

            let classes = [];
            if (
                (cell.type === 'normal' || cell.type === 'today') &&
                !cell.disabled
            ) {
                classes.push('available');
                if (cell.type === 'today') {
                    classes.push('today');
                }
            } else {
                classes.push(cell.type);
            }

            if (
                cell.type === 'normal' &&
                defaultValue.some((date) => this.cellMatchesDate(cell, date))
            ) {
                classes.push('default');
            }

            if (
                selectionMode === 'day' &&
                (cell.type === 'normal' || cell.type === 'today') &&
                this.cellMatchesDate(cell, this.value)
            ) {
                classes.push('current');
            }

            if (
                cell.inRange &&
                (cell.type === 'normal' ||
                    cell.type === 'today' ||
                    this.selectionMode === 'week')
            ) {
                classes.push('in-range');

                if (cell.start) {
                    classes.push('start-date');
                }

                if (cell.end) {
                    classes.push('end-date');
                }
            }

            if (cell.disabled) {
                classes.push('disabled');
            }

            if (cell.selected) {
                classes.push('selected');
            }
            console.log(classes);
            return classes.join(' ');
        }
}
</script>

我们看看这个方法有没有办法可以趁虚而入的机会。反复观察之后(差不多观察了一个小时),可以看出在第一个if语句里面,只要type的值不是"normal""today"并且不是disabled时,就会走到else里面,此时就会把type作为class。因此,我们是有机会去更改class的。

rows() {
            // TODO: refactory rows / getCellClasses
            const date = new Date(this.year, this.month, 1);
            let day = getFirstDayOfMonth(date); // day of first day
            const dateCountOfMonth = getDayCountOfMonth(
                date.getFullYear(),
                date.getMonth()
            );
            const dateCountOfLastMonth = getDayCountOfMonth(
                date.getFullYear(),
                date.getMonth() === 0 ? 11 : date.getMonth() - 1
            );

            day = day === 0 ? 7 : day;

            const offset = this.offsetDay;
            const rows = this.tableRows;
            let count = 1;
            let firstDayPosition;

            const startDate = this.startDate;
            const disabledDate = this.disabledDate;
            const selectedDate = this.selectedDate || this.value;
            const now = clearHours(new Date());

            for (let i = 0; i < 6; i++) {
                const row = rows[i];

                if (this.showWeekNumber) {
                    if (!row[0]) {
                        row[0] = {
                            type: 'week',
                            text: getWeekNumber(nextDate(startDate, i * 7 + 1))
                        };
                    }
                }

                for (let j = 0; j < 7; j++) {
                    let cell = row[this.showWeekNumber ? j + 1 : j];
                    if (!cell) {
                        cell = {
                            row: i,
                            column: j,
                            type: 'normal',
                            inRange: false,
                            start: false,
                            end: false
                        };
                    }

                    cell.type = 'normal';

                    const index = i * 7 + j;
                    const time = nextDate(startDate, index - offset).getTime();
                    cell.inRange =
                        time >= clearHours(this.minDate) &&
                        time <= clearHours(this.maxDate);
                    cell.start =
                        this.minDate && time === clearHours(this.minDate);
                    cell.end =
                        this.maxDate && time === clearHours(this.maxDate);
                    const isToday = time === now;

                    if (isToday) {
                        cell.type = 'today';
                    }

                    if (i >= 0 && i <= 1) {
                        if (j + i * 7 >= day + offset) {
                            cell.text = count++;
                            if (count === 2) {
                                firstDayPosition = i * 7 + j;
                            }
                        } else {
                            cell.text =
                                dateCountOfLastMonth -
                                (day + offset - (j % 7)) +
                                1 +
                                i * 7;
                            cell.type = 'prev-month';
                        }
                    } else {
                        if (count <= dateCountOfMonth) {
                            cell.text = count++;
                            if (count === 2) {
                                firstDayPosition = i * 7 + j;
                            }
                        } else {
                            cell.text = count++ - dateCountOfMonth;
                            cell.type = 'next-month';
                        }
                    }

                    let newDate = new Date(time);
                    cell.disabled =
                        typeof disabledDate === 'function' &&
                        disabledDate(newDate);
                    cell.selected =
                        Array.isArray(selectedDate) &&
                        selectedDate.filter(
                            (date) => date.toString() === newDate.toString()
                        )[0];

                    this.$set(row, this.showWeekNumber ? j + 1 : j, cell);
                }

                if (this.selectionMode === 'week') {
                    const start = this.showWeekNumber ? 1 : 0;
                    const end = this.showWeekNumber ? 7 : 6;
                    const isWeekActive = this.isWeekActive(row[start + 1]);

                    row[start].inRange = isWeekActive;
                    row[start].start = isWeekActive;
                    row[end].inRange = isWeekActive;
                    row[end].end = isWeekActive;
                }
            }

            rows.firstDayPosition = firstDayPosition;

            return rows;
        }

再看遍历的数据,我们可以看到是一个计算属性rows,这个计算属性使用了tableRows的数据。假如这里每次都需要重新new新的cell对象,那我们的路就走不通了。可惜这里恰好是cell为空时才会创建,所以我们只要可以更改tableRows的值就可以更改class了。

当然这里有一个很坑的地方,那就是不能触发计算属性的更新。这是因为计算属性触发之后会设置type为normal,这样就会让数据重新渲染,从而覆盖掉之前的type。所以这里给tableRows直接赋值,不能用Vue.$set()

另一个问题是,每个cell里存的text只是day,而不是一个完整的日期,因此还需要获取到当前date-table的日期。

解决方案

上面我们分析完了,实现需求我们需要完成下面的工作:

  1. 获取到tableRows,找出我们需要的值(通过当前日期判断)
  2. 修改tableRows的值,并且不能触发计算属性。
  3. 封装成单独的组件

获取tableRows我们需要使用$refs来获取到组件的数据。代码如下:

//获取tableRows
this.$refs.datePicker.picker.$children[0].tableRows;
//获取到panel的当前日期
this.$refs.datePicker.picker.$children[0].date;

datePicker是原生组件的ref,picker是组件内部的一个子组件。picker的内部分成了panel和input,$children[0]就是panel组件。

然后根据这两个我们可以写出一个修改tableRows的方法,代码如下:

/**
         * 根据datePicker的当前时间获取YYYY-MM-DD格式的时间
         * date-table是6*7的表格,因此最多会显示三个月份的数据
         * 此处是根据单元格的type计算所属月份
         */
        getFormatDate(val) {
            const date = this.$refs.datePicker.picker.$children[0].date;
            let formatDate = moment(date);
            formatDate.set('date', val.text);
            if (val.type == 'prev-month') {
                formatDate.subtract(1, 'M');
            } else if (val.type == 'next-month') {
                formatDate.add(1, 'M');
            }
            return formatDate.format('YYYY-MM-DD');
        },
        //检查单元格日期是否需要标记
        checkMarked(cell) {
            return this.mark.indexOf(this.getFormatDate(cell)) != -1;
        },
        //标记单元格
        markDate() {
            //获取到el-date-picker内部的数组
            const rows = this.$refs.datePicker.picker.$children[0].tableRows;
            //遍历修改数据为
            for (let i = 0; i < rows.length; i++) {
                for (let j = 0; j < rows[i].length; j++) {
                    let cell = rows[i][j];
                    if (this.checkMarked(cell)) {
                        cell.type = this.cellClassName;
                    }
                }
            }
            //el-date-picker内部使用了计算属性,如果此处使用Vue.$set将会调用计算属性从而覆盖掉设置的class
            this.$refs.datePicker.picker.$children[0].tableRows = rows;
        }

方法的作用我在代码的注释里写的很清楚了,其实里面重点在于不要让组件的计算属性触发,所以不要使用Vue.$set

在封装的组件内部,我还使用了定时器来保证切换页码的时候也能实时修改到class。这个解决方法不优雅,但是我在源码里没有看到翻页的回调事件。理论上我应该捕捉鼠标的行为,鼠标点击之后触发markDate()方法,但是暂时没法实现。如果你有更好的实现方案,可以在评论区留言。

组件源码

下面给出完整的组件源码:

<template>
    <el-date-picker v-model="bindingDate" :align="align" :default-value="defaultDate" :type="type" :placeholder="placeholder" :picker-options="pickerOptions" ref='datePicker' @focus="handleFocus">
    </el-date-picker>
</template>

<script>
import moment from 'moment';
export default {
    props: {
        value: {
            default: Date.now()
        },
        //type
        type: {
            default: () => {
                return 'date';
            }
        },
        placeholder: {
            default: () => {
                return '请选择日期';
            }
        },
        //是否可编辑
        editable: {
            type: Boolean,
            default: true
        },
        //需要标记的数组(YYYY-MM-DD格式)
        mark: {
            type: Array
        },
        //默认时间
        defaultDate: {
            default: () => {
                return new Date();
            }
        },
        //自定义的单元格标记
        cellClassName: {
            type: String,
            default: 'marked'
        },
        align: {
            type: String,
            default: 'left'
        },
        pickerOptions: {
            default: {}
        },
        //是否可筛选
        filterable: {
            default: () => {
                return true;
            }
        }
    },
    data() {
        return {
            //定时器
            timer: ''
        };
    },

    mounted() {
        let _this = this;
        //强制datePicker初始化
        this.$refs.datePicker.mountPicker();
        //使用定时器刷新单元格
        this.timer = window.setInterval(() => {
            _this.markDate();
        }, 1000);
    },
    //销毁timer
    beforeDestroy() {
        clearInterval(this.timer);
    },
    computed: {
        bindingDate: {
            get: function() {
                return this.value;
            },
            set: function(value) {
                this.$emit('input', value);
            }
        }
    },
    watch: {
        mark: function(val) {
            if (val && val.length > 0) {
                this.markDate();
            }
        }
    },
    methods: {
        /**
         * 根据datePicker的当前时间获取YYYY-MM-DD格式的时间
         * date-table是6*7的表格,因此最多会显示三个月份的数据
         * 此处是根据单元格的type计算所属月份
         */
        getFormatDate(val) {
            const date = this.$refs.datePicker.picker.$children[0].date;
            let formatDate = moment(date);
            formatDate.set('date', val.text);
            if (val.type == 'prev-month') {
                formatDate.subtract(1, 'M');
            } else if (val.type == 'next-month') {
                formatDate.add(1, 'M');
            }
            return formatDate.format('YYYY-MM-DD');
        },
        //检查单元格日期是否需要标记
        checkMarked(cell) {
            return this.mark.indexOf(this.getFormatDate(cell)) != -1;
        },
        //focus事件
        handleFocus() {
            this.markDate();
        },
        //标记单元格
        markDate() {
            //获取到el-date-picker内部的数组
            const rows = this.$refs.datePicker.picker.$children[0].tableRows;
            //遍历修改数据为
            for (let i = 0; i < rows.length; i++) {
                for (let j = 0; j < rows[i].length; j++) {
                    let cell = rows[i][j];
                    if (this.checkMarked(cell)) {
                        cell.type = this.cellClassName;
                    }
                }
            }
            //el-date-picker内部使用了计算属性,如果此处使用Vue.$set将会调用计算属性从而覆盖掉设置的class
            //故此处为直接赋值
            this.$refs.datePicker.picker.$children[0].tableRows = rows;
        }
    }
};
</script>

总结

总结一下,本篇的目的是在不升级Element-UI版本的前提下,为DatePicker增加标记重要日期的功能(这里再次建议你,能升级的前提下优先考虑升级)。主要利用了date-table内部获取class的一个判断语句的漏洞以及直接给对象赋值不会触发计算属性这个特性。封装的组件内部使用了定时器来保证翻页的时候也能修改class。