Element-UI里的date-picker
是個優秀的時間選擇器,支持的選項很多,定制型很強。不過date-picker
在2.12版本之前並不支持自定義單元格樣式,也就是2.12的cellClassName
功能。所以如果使用了2.12之前的版本,那么你就無法直接去更改單元格的樣式了,因此在日歷上就無法標記出重要日期(比如放假安排)。
公司項目里用的Element-UI版本是2.3.9,但是需要使用2.12版本的那個cellClassName
功能。如果你要問為什么不升級到最新版,那我只能說如果升級到了最新版就沒有這篇文章了。
目的
- 傳入一個數組里面存儲
YYYY-MM-DD
格式的時間,在面板上為符合的數據加上對應的class - 切換panel時已經標記的數據不會丟失
- 不能升級到2.12版本
源碼解析
先直接看源碼的結構。
date-picker
的核心是picker.vue
,用來操作整個picker的初始化、隱藏、顯示等功能。具體每天的展示是date-table.vue
來控制的。
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的日期。
解決方案
上面我們分析完了,實現需求我們需要完成下面的工作:
- 獲取到
tableRows
,找出我們需要的值(通過當前日期判斷) - 修改
tableRows
的值,並且不能觸發計算屬性。 - 封裝成單獨的組件
獲取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。