Skip to content

跨时区的时间组件实现攻略

首先,我们先来看看Ptengine中的时间组件,可以看到我们选择了今日显示我们选中了16日但是右上角本地时间显示的却是17日呢。那是因为我本地的时区为Asia/Shanghai,而我手动把时间组件的时区设置为了 Pacific/Honolulu。这就是所谓的跨时区的时间组件。

那么为了实现一个上图的时间组件,我们需要弄清楚时间戳还有时区以及日期所代表的含义,以及他们之间的关系。

一、时区

时区实际上就是由于我们所处位置不同地球自转引起的时间差

要处理时间问题,UTC 是一个不可忽略的概念。协调世界时(UTC)是全球通用的时间标准,它基于原子钟计时,但与国际原子时(TAI)不同。TAI 不考虑闰秒,而 UTC 会不定期插入闰秒,因此 UTC 和 TAI 的时间差在不断扩大。UTC 也接近格林威治标准时间(GMT),但两者并不完全相同。由于地球自转的不规则性和逐渐变慢,GMT 已基本被 UTC 取代。因为 UTC 是标准时间,我们常用 UTC+/-N 的方式来表示一个时区。例如,中国标准时间(Asia/Shanghai)通常可以表示为UTC+8。但英国的时区(Europe/London)则不能简单地用 UTC+N 表示。由于夏令时制度,Europe/London 在夏季等于 UTC+1,而在冬季等于 UTC 或 GMT。因此,某些地区的时区并不是固定的,无法用固定的时差来处理时间。虽然 UTC 理论上与 GMT 相同,但在有闰秒的情况下会有细微差别,不过在日常使用中可以忽略不计。

• UTC 时区

如果时间是以协调世界时(UTC)表示,则为 YYYY-MM-DDTHH:mm:ssZ 格式,其中 Z 表示该时间为 UTC 时间。“Z” 是协调世界时中 0 时区的标志。比如 09:30 UTC 相当于 09:30Z。

• UTC 偏移量

UTC 偏移量是表示某一时区相对于协调世界时(UTC)快多少小时或慢多少小时的时差。用 ±[hh]:[mm] 或者 ±[hh]:[mm] 或者±[hh] 的形式表示。比如北京时间的时区会表达成 +08:00 / +0800 / UTC+8 。

二、时间戳

指的是当前日期到1970年1月1日00:00:00 UTC对应的毫秒数(特指JavaScript中的时间戳

JavaScript 中的 Date 不包含时区信息,Date 对象表示的一定是当前时区。例如当前是中国时区,那么要如何知道东京当前的时间戳呢。 首先通过尝试:

js
new Date('1970-01-01T00:00:00Z');
// Thu Jan 01 1970 08:00:00 GMT+0800 (中国标准时间)
new Date('1970-01-01T00:00:00Z');
// Thu Jan 01 1970 08:00:00 GMT+0800 (中国标准时间)

可以发现JavaScript 运行时其实知道当前时区。那么,如何将本地时间转换为其他时区的时间呢?从 Date 对象的角度来看,这并不直接支持,因为我们无法设置一个 Date 对象的时区。但我们可以“投机取巧”:将 Date 对象的时间加上或减去对应的时差,尽管 Date 对象仍然认为自己在本地时区,但这样就可以正确显示其他时区的时间了!此外,我们还可以使用 toLocaleString() 方法,它可以接受时区参数并按指定时区格式化时间。

js
const convertTimeZone = (date, timeZone) => {
    return new Date(date.toLocaleString('en-Us', { timeZone }));
};
const now = new Date(); //Tue Jun 04 2024 11:35:16 GMT+0800(中国标准时间)
convertTimeZone(now, 'Asia/Tokyo'); //Tue Jun 04 2024 12:35:16 GMT+0800(中国标准时间)
const convertTimeZone = (date, timeZone) => {
    return new Date(date.toLocaleString('en-Us', { timeZone }));
};
const now = new Date(); //Tue Jun 04 2024 11:35:16 GMT+0800(中国标准时间)
convertTimeZone(now, 'Asia/Tokyo'); //Tue Jun 04 2024 12:35:16 GMT+0800(中国标准时间)

但是指定了区域设置和时区的 toLocaleString() 实际上每次调用都会在 JavaScript 运行时中创建新的 Intl.DateTimeFormat 对象,而后者会带来不小的性能开销。那么我们可以自己手动实现一个DateTimeFormat用来反复使用:

js
const timeZoneConverter = timeZone => {
    // 新建 DateTimeFormat 对象以供对同一目标时区重用
    // 由于时区属性必须在创建 DateTimeFormat 对象时指定,我们只能为同一时区重用格式化器
    const formatter = new Intl.DateTimeFormat('zh-CN', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
        hour12: false,
        timeZone
    });
    return {
        // 提供 conver 方法以将提供的 Date 对象转换为指定时区
        convert(date) {
            // zh-CN 的区域设置会返回类似 1970/01/01 00:00:00 的字符串
            // 替换字符即可构造出类似 1970-01-01T00:00:00 的 ISO 8601 标准格式时间字符串并被正确解析
            return new Date(formatter.format(date).replace(/\//g, '-').replace(' ', 'T').trim());
        }
    };
};

const toLondonTime = timeZoneConverter('Asia/Tokyo'); // 对于同一时区,此对象可重用

const now = new Date(); // Thu Jun 06 2024 16:13:33 GMT+0800 (中国标准时间)
toLondonTime.convert(now); // Thu Jun 06 2024 17:13:33 GMT+0800 (中国标准时间)
const timeZoneConverter = timeZone => {
    // 新建 DateTimeFormat 对象以供对同一目标时区重用
    // 由于时区属性必须在创建 DateTimeFormat 对象时指定,我们只能为同一时区重用格式化器
    const formatter = new Intl.DateTimeFormat('zh-CN', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
        hour12: false,
        timeZone
    });
    return {
        // 提供 conver 方法以将提供的 Date 对象转换为指定时区
        convert(date) {
            // zh-CN 的区域设置会返回类似 1970/01/01 00:00:00 的字符串
            // 替换字符即可构造出类似 1970-01-01T00:00:00 的 ISO 8601 标准格式时间字符串并被正确解析
            return new Date(formatter.format(date).replace(/\//g, '-').replace(' ', 'T').trim());
        }
    };
};

const toLondonTime = timeZoneConverter('Asia/Tokyo'); // 对于同一时区,此对象可重用

const now = new Date(); // Thu Jun 06 2024 16:13:33 GMT+0800 (中国标准时间)
toLondonTime.convert(now); // Thu Jun 06 2024 17:13:33 GMT+0800 (中国标准时间)

三、日期

说到日期,大家可能会觉得这没什么可解释的,无非就是 2024-06-03、2024-05-03 这样的日期。然而,问题在于跨时区时,我们如何获取另一个时区当地的真实日期,这个日期符合大家的普遍认知。其实,不难发现,这种日期就是通过格式化时间戳得到的。

js
export const formatDate = (t: number, format?: string): string => {
	return dayjs(t).format(format || "YYYY-MM-DD")
}
export const formatDate = (t: number, format?: string): string => {
	return dayjs(t).format(format || "YYYY-MM-DD")
}

那么时间戳、时区、与日期三者之间的关系就显而易见了,获取其他时区的时间除了用下图中的方法也可以使用dayjs中的时区相关API。

图1-1

四、具体实现

介绍完三者之间的关系后,我们就可以着手处理时间,基于Vue3+DayJS+TypeScript实现一个存在时区的时间组件。另外与DayJS相似的还有MonmentJS,那么为什么选择DayJS呢。DayJS 是一个轻量的处理时间和日期的 JavaScript 库。 MomentJS大小为280.9 kB ,DayJS 体积为7K,DayJS被设计为MomentJS的极简替代,API和用法几乎完全一致。另外MomentJS在将来已经不会被继续维护了。

1.计算出正确的每月天数

首先我们需要知道当前回显的这个月有几天。才行渲染正确的天数,如何获取正确的天数可以使用dayjs.daysInMonth()由于daysInMonth()方法不接受参数,所有的日期信息都从Day.js对象实例中获取。因此,要得到某个月份的天数,只需创建一个相应日期的Day.js对象,然后调用该对象的 daysInMonth()方法即可。这样我们就可以省去手动处理润年2月的时间了。

js
const specificDate = dayjs('2024-06-17');
console.log(specificDate.daysInMonth()); // 30
const specificDate = dayjs('2024-06-17');
console.log(specificDate.daysInMonth()); // 30

2.计算出当月的一号是星期几

我们获得当月的天数后,还需要知道当月的一号是星期几。通过下面方法就可以获取到当月是从周几开始,dayjs().day() 如果是周日的话会返回0所以我们稍微手动处理一下以便获得正确的值。

js
const startOfMonth = dayjs('2024-06-17').startOf('month');
const startOfMonthDay = startOfMonth.day() || 7; // 6
const startOfMonth = dayjs('2024-06-17').startOf('month');
const startOfMonthDay = startOfMonth.day() || 7; // 6

3.组装数据并渲染

在注册流程方面,重定向测试可以用于测试不同注册页面布局、信息收集方式、注册步骤等。这有助于降低用户的流失率,提高注册转化率。这也可以包括测试是否需要提供更少的信息来增加注册完成率。

js
const getRows = () => {
    const startOfMonth = props.date.startOf('month');
    const startOfMonthDay = startOfMonth.day() || 7; // month of first day
    const dateCountOfMonth = startOfMonth.daysInMonth();
    const rows_ = [[], [], [], [], [], []];
    let count = 1;
    for (let i = 0; i < 6; i++) {
        const row = rows_[i];
        for (let j = 0; j < 7; j++) {
            if ((i === 0 && j >= startOfMonthDay) || (i !== 0 && count <= dateCountOfMonth)) {
                let cell = row[j];
                if (!cell) {
                    cell = {
                        row: i,
                        column: j
                    };
                }
                if (i === 0 || i === 1) {
                    const numberOfDaysFromPreviousMonth =
                        startOfMonthDay + offset < 0
                            ? 7 + startOfMonthDay + offset
                            : startOfMonthDay + offset;
                    if (j + i * 7 >= numberOfDaysFromPreviousMonth) {
                        cell.text = count++;
                    }
                } else {
                    if (count <= dateCountOfMonth) {
                        cell.text = count++;
                    }
                }
                row[j] = cell;
            }
        }
    }
    return rows_;
};
const getRows = () => {
    const startOfMonth = props.date.startOf('month');
    const startOfMonthDay = startOfMonth.day() || 7; // month of first day
    const dateCountOfMonth = startOfMonth.daysInMonth();
    const rows_ = [[], [], [], [], [], []];
    let count = 1;
    for (let i = 0; i < 6; i++) {
        const row = rows_[i];
        for (let j = 0; j < 7; j++) {
            if ((i === 0 && j >= startOfMonthDay) || (i !== 0 && count <= dateCountOfMonth)) {
                let cell = row[j];
                if (!cell) {
                    cell = {
                        row: i,
                        column: j
                    };
                }
                if (i === 0 || i === 1) {
                    const numberOfDaysFromPreviousMonth =
                        startOfMonthDay + offset < 0
                            ? 7 + startOfMonthDay + offset
                            : startOfMonthDay + offset;
                    if (j + i * 7 >= numberOfDaysFromPreviousMonth) {
                        cell.text = count++;
                    }
                } else {
                    if (count <= dateCountOfMonth) {
                        cell.text = count++;
                    }
                }
                row[j] = cell;
            }
        }
    }
    return rows_;
};

通过上述代码我们可以得到如下数据结构的数据

接下来使用v-for循环的方式把我们刚刚得到的数据渲染到页面上,至于WEEKS只需要定义一个星期的字符串循环渲染就可以。

js
<tbody>
		<tr>
				<th v-for="(week, key) in WEEKS" :key="key">{{week }}</th>
		</tr>
		<tr
				v-for="(row, key) in rows"
				:key="key"
				class="p-date-table__row"
				:class="{ current: isWeekActive(row[1]) }"
		>
				<td v-for="(cell, key_) in row" :key="key_" :class="getCellClasses(cell)">
						<div>
								<span>
								{{ cell?.text ?? '' }}
								</span>
						</div>
				</td>
		</tr>
</tbody>
<tbody>
		<tr>
				<th v-for="(week, key) in WEEKS" :key="key">{{week }}</th>
		</tr>
		<tr
				v-for="(row, key) in rows"
				:key="key"
				class="p-date-table__row"
				:class="{ current: isWeekActive(row[1]) }"
		>
				<td v-for="(cell, key_) in row" :key="key_" :class="getCellClasses(cell)">
						<div>
								<span>
								{{ cell?.text ?? '' }}
								</span>
						</div>
				</td>
		</tr>
</tbody>

做完这些后再给它加上合适的样式我们就可以得到下图绿框中的内容。 接下来给每个日期绑上点击事件并传递自身的dom就可以拿到选中的日期。

js
function handleClick(e) {
    const startDayOfMonth = props.date.startOf('month'); //获取的日期为上个月最后一天
    const currentDate = startDayOfMonth.add(e.target.value, 'day'); //获取的dom内的内容
    return currentDate;
}
function handleClick(e) {
    const startDayOfMonth = props.date.startOf('month'); //获取的日期为上个月最后一天
    const currentDate = startDayOfMonth.add(e.target.value, 'day'); //获取的dom内的内容
    return currentDate;
}

但是还有一个问题,可以看到我们虽然有日期了但是并不知道今天是几号(今天的日期没有特殊样式),那么我们可以在级算日期的时候给今天的日期数据打上一个特殊标识,渲染时就可以给今天加上特殊样式啦。

这部分代码添加到 3.组装数据并渲染部分 const rows_ = [[], [], [], [], []]; 后面

js
let todayDate = '';
if (props.timeZone) {
    todayDate = dayjs()
        .tz(props.timeZone ?? '')
        .startOf('day')
        .format('DD');
}
let todayDate = '';
if (props.timeZone) {
    todayDate = dayjs()
        .tz(props.timeZone ?? '')
        .startOf('day')
        .format('DD');
}

将这部分代码添加到 3.组装数据并渲染部分row[j] = cell; 前面

js
cell.text == todayDate && (cell.type = 'today');
cell.text == todayDate && (cell.type = 'today');

4.跨时区获取日期

下图展示了一个快捷选择本周时间段的功能。然而,本文开头明确指出今天的日期是 2024-06-17,那么为什么下图中选中的时间段是 2024-06-10 到 2024-06-16 呢?这是因为我手动将时间组件设置为 UTC-10 时区,而我的本地时区是 UTC+8,因此需要手动处理跨时区的情况。接下来,我将展示我是如何处理这个问题的。

1.如何获取当天所在周的日期

js
const convertTimeZone = (date, timeZone) => {
    return new Date(date.toLocaleString('en-Us', { timeZone }));
};

function getThisWeek() {
    const time = convertTimeZone(new Date(), 'Pacific/Honolulu');
    let startDate = dayjs(time).startOf('week'); //默认情况下,dayjs 将星期天视为每周的第一天
    if (time == startDate.valueOf()) {
        //如果相等说明当前是周日dayjs获取的结果为今天,但是按照我们大众的认知周一才是一周的开始
        startDate = startDate.subtract(6, 'day');
    } else {
        startDate = startDate.add(1, 'day');
    }
    const endDate = startDate.add(6, 'day');
    return [startDate, endDate];
}

const shortcuts = () => {
    return [
        {
            text: '本周',
            type: 'today',
            value: getThisWeek()
        }
    ];
};
const convertTimeZone = (date, timeZone) => {
    return new Date(date.toLocaleString('en-Us', { timeZone }));
};

function getThisWeek() {
    const time = convertTimeZone(new Date(), 'Pacific/Honolulu');
    let startDate = dayjs(time).startOf('week'); //默认情况下,dayjs 将星期天视为每周的第一天
    if (time == startDate.valueOf()) {
        //如果相等说明当前是周日dayjs获取的结果为今天,但是按照我们大众的认知周一才是一周的开始
        startDate = startDate.subtract(6, 'day');
    } else {
        startDate = startDate.add(1, 'day');
    }
    const endDate = startDate.add(6, 'day');
    return [startDate, endDate];
}

const shortcuts = () => {
    return [
        {
            text: '本周',
            type: 'today',
            value: getThisWeek()
        }
    ];
};

2.拿到时间后给时间组件

js
<pt-date-picker
		:hasHelpText="true"
		v-model="dataTime"
    format="YYYY/MM/DD"
    :shortcuts="shortcuts()"    外部指定的时间点
    timeZone="Pacific/Apia"
    value-format="YYYY/MM/DD"
    type="date"
    placeholder="选择日期"
>
</pt-date-picker>
<pt-date-picker
		:hasHelpText="true"
		v-model="dataTime"
    format="YYYY/MM/DD"
    :shortcuts="shortcuts()"    外部指定的时间点
    timeZone="Pacific/Apia"
    value-format="YYYY/MM/DD"
    type="date"
    placeholder="选择日期"
>
</pt-date-picker>

3.传递进去过后用组件内部用v-for渲染并绑定点击事件

js
<div v-if="hasShortcuts" :class="nsDatePick.e('sidebar')">
     <button
        v-for="(shortcut, key) in shortcuts"
        :key="key"
        type="button"
        :class="nsDatePick.e('shortcut')"
        @click="handleShortcutClick(shortcut)"
     >
       {{ shortcut.text }}
     </button>
</div>
<div v-if="hasShortcuts" :class="nsDatePick.e('sidebar')">
     <button
        v-for="(shortcut, key) in shortcuts"
        :key="key"
        type="button"
        :class="nsDatePick.e('shortcut')"
        @click="handleShortcutClick(shortcut)"
     >
       {{ shortcut.text }}
     </button>
</div>

点击今天时就会触发点击事件,点击事件内部会向时间组件提交我们刚刚写的shortcuts内对应元素的value但是此时我们获得的不是一个格式化的时间。用户选择完时间肯定是要回显格式化后的时间。那么就是图1-1中的从format()方法。

五、总结

在本篇文章中,首先介绍了时区和时间戳等基础概念,以及如何通过toLocaleString()获取指定时区的时间戳,并通过format() 方法格式化日期,详细讲解了dayjs相关API的使用。为之后的组件计算原理奠定基础。之后详细地给出了时间组件中日期的计算方式。并辅以详尽的代码和实例demo进行说明。

附录

时间组件demo 此链接利用上述的核心代码实现了一个demo进行演示,并且支持代码调试。由于开发环境不同,此代码部分结构与本文演示的代码存在小部分区别。