【无标题】

一次"后端接口零改动"的多时区改造:Vue 2 老项目如何让海外用户不再看错时间

项目背景:一套运行多年的 Vue 2 + Element UI + iView + vxe-table 的客服 / 工单系统,客户开始出海。数据库和后端接口里的时间始终按北京时间存储,但海外用户看到的时间、筛选结果都对不上。这次改造没有动后端接口,只在 Vue 侧补了一层时区语义。

配图均为本文原创,源文件在 docs/blog/images/。

一、问题不是"格式不对",而是"语义不对"

先看一条真实数据:后端返回创建时间 2026-03-25 00:00:00,这是北京时间。

  • 北京用户看到 2026-03-25 00:00:00
  • 纽约用户应该看到 2026-03-24 12:00:00
  • 但如果这列是"合同日期",纽约用户必须看到 2026-03-25,而不是 3 月 24 日

同样的字符串,一个是时刻(Instant) ,一个是日历日期(Calendar Date)。

这个区别决定了整个方案的走向:

后端类型 含义 是否换算 原因
8 日期 yyyy-MM-dd 否 日历事实,全球应看到同一天
26 日期时间 yyyy-MM-dd HH:mm:ss 是 同一瞬间,不同时区的墙上时间不同
range8 日期区间 否 由两个日历日期拼成
range26 日期时间区间 是 逐段按时刻换算

如果一开始没把这条定清楚,后面每接一个页面都会反复讨论"这个到底要不要转",改造一定会返工。

补充一个反直觉的点:纯日期不但"不需要"换算,而且不能换算。后端日期列按"北京 0 点"存储,在偏移小于 +8 的所有时区(欧洲、美洲、印度、东南亚),一旦参与时刻换算,日期必然倒退一天。Jira、Redmine 对 date-only 字段也是不转换的。

二、设计:一个基准,三个出口

既然存储基准不能动,那就只在"进出"两端做文章:

场景 方向 出口方法
列表 / 详情展示 北京 → 用户 format(value, type)
编辑表单回显 北京 → 用户 toLocal(datetime)
查询 / 保存提交 用户 → 北京 toBeijing(datetime)

四条设计原则:

  1. 存储基准不变:数据库和后端接口始终是北京时间,后端零改动。
  2. 展示只改出口 :渲染时换算,不改原始数据,row.createTime 永远是后端值。
  3. 提交前转回:选择器里用户选的是当地时刻,组装请求参数时转回北京时间。
  4. 解析不了就原样返回 :空值、--、零日期、未知格式都不猜测,宁可显示原值,也不产出错误日期。

时区来源按优先级取:

  1. window.parent.bw8Zone(同源 iframe 嵌入主项目时,直接复用主项目的 moment-timezone 能力)
  2. Cookie i18n_timezone(与主项目、后端 PHP 共用)
  3. 浏览器 Intl.DateTimeFormat().resolvedOptions().timeZone
  4. Asia/Shanghai 兜底

为什么不引 moment-timezone:项目里本来没有这个依赖,为了一个展示功能引入 200KB+ 不划算;Intl.DateTimeFormat 原生支持 IANA 时区和夏令时。如果页面嵌在主项目里,则直接复用主项目的 bw8Zone,保证两边换算结果完全一致。

三、核心实现

3.1 类型表:把"要不要换算"变成配置

js 复制代码
// 时间类字段类型 -> 类型元数据(withTime: 输出是否含时分秒;convert: 是否参与换算)
const TIME_TYPES = {
    '8':       { withTime: false, convert: false }, // 纯日历日期,不换算
    '26':      { withTime: true,  convert: true  }, // 日期时间,按时刻换算
    'range8':  { withTime: false, convert: false }, // 日期区间,不换算
    'range26': { withTime: true,  convert: true  }  // 日期时间区间,按时刻换算
};

range8 / range26 是前端展示层专用标记 ,用于"开始--结束"拼接列,不与后端 dataType 数字字典冲突。连接符不限(--、-、至、~),实现上按固定宽度的日期片段提取,连接符原样保留。

3.2 解析:只接受不会歧义的格式

js 复制代码
// 兼容 yyyy-MM-dd[ T HH:mm[:ss[.fff]]],分隔符 - / . 均可
const DT_REG = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2})(?:\.\d+)?)?)?$/;
// 带时区标记的 ISO 串(结尾 Z 或 ±HH:mm)
const ISO_OFFSET_REG = /(?:Z|[+-]\d{2}:?\d{2})$/;

/**
 * 把后端返回的时间值统一转成毫秒时间戳
 * @param {string|number|Date} value 后端时间值
 * @returns {number|null} 毫秒时间戳,无法识别返回 null
 */
function parseToTimestamp(value) {
    if (value === null || value === undefined || value === '') return null;
    if (value instanceof Date) return isNaN(value.getTime()) ? null : value.getTime();

    const str = String(value).trim();
    if (/^\d{10}$/.test(str)) return Number(str) * 1000; // 秒级时间戳
    if (/^\d{13}$/.test(str)) return Number(str);        // 毫秒时间戳

    // 带 Z / +08:00 的 ISO 串自带时刻语义,交给原生 Date 精确解析,不能按北京时间猜
    if (ISO_OFFSET_REG.test(str)) {
        const ts = Date.parse(str);
        return isNaN(ts) ? null : ts;
    }

    const m = str.match(DT_REG);
    if (!m) return null;

    const mo = +m[2], d = +m[3], h = +(m[4] || 0), mi = +(m[5] || 0), s = +(m[6] || 0);
    // 零日期(0000-00-00)与越界分量:Date.UTC 会静默回卷成错误日期,必须拦截
    if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) return null;

    // 无时区标记的字符串按北京时间解析(北京无夏令时,固定 UTC+8)
    return Date.UTC(+m[1], mo - 1, d, h, mi, s) - 8 * 3600 * 1000;
}

两个刻意为之的决定:

  • 不识别 MM/DD/YYYY。月和日无法从格式本身区分,猜错就是错一个月。这种格式应该推动后端统一,而不是前端猜。
  • 拦截零日期和越界分量 。Date.UTC(0, -1, 0) 不会报错,会得到一个 1899 年的日期,比直接报错更难排查。

3.3 夏令时:两次修正

展示方向最简单:拿到时间戳后,用目标时区的 Intl.DateTimeFormat 格式化即可。真正麻烦的是反方向------把"用户时区的墙上时间"转回 UTC 时间戳,因为时区偏移会随夏令时变化。

js 复制代码
/**
 * 计算某时区在指定时刻的 UTC 偏移(毫秒),夏令时敏感
 */
function tzOffsetMs(tz, timestamp) {
    const parts = {};
    getFormatter(tz, true).formatToParts(new Date(timestamp)).forEach(p => {
        parts[p.type] = p.value;
    });
    const asUtc = Date.UTC(+parts.year, +parts.month - 1, +parts.day,
                          +parts.hour, +parts.minute, +parts.second);
    return asUtc - Math.floor(timestamp / 1000) * 1000;
}

/**
 * 指定时区的"墙上时间" -> UTC 毫秒时间戳(两次修正处理夏令时边界)
 */
function zoneToUtcTimestamp(y, mo, d, h, mi, s, tz) {
    const naive = Date.UTC(y, mo - 1, d, h, mi, s);
    const ts = naive - tzOffsetMs(tz, naive);
    // 第一次修正得到的时刻可能跨过夏令时边界,用真实时刻的偏移再修正一次
    return naive - tzOffsetMs(tz, ts);
}

为什么需要两次:

  1. 先把墙上时间当成 UTC,算出一个近似时刻;
  2. 这个近似时刻可能落在夏令时切换的另一侧,偏移取错了一小时;
  3. 用近似时刻的偏移修正后,再用修正后时刻的偏移算一次,边界附近才能稳定。

getFormatter 用 en-CA locale,是为了让 formatToParts 稳定输出 yyyy-mm-dd 的数字片段,避免 en-US 的 03/25/2026 顺序问题;hourCycle: 'h23' 则避免 24:00 这种边界值。

3.4 接到 Vue:一个响应式时区

算法层是纯函数,zonePlugin.js 只负责把它接到 Vue 的响应式体系上:

js 复制代码
import Vue from 'vue';
import zone from '@/utils/common/zone';

// 响应式时区状态:组件渲染期读取即被依赖收集,时区变化触发重渲染
const zoneState = Vue.observable({ timezone: zone.get() });

const api = {
    get() { return zoneState.timezone; },

    format(value, type) {
        // 渲染期读取 zoneState.timezone,切换时区后组件自动重渲染
        return zone.format(value, type, zoneState.timezone);
    },

    setTimezone(tz) {
        zone.set(tz);
        zoneState.timezone = tz || zone.get();
    },

    resetTimezone() {
        zone.reset();
        zoneState.timezone = zone.get();
    },

    toBeijing(datetime) { return zone.toBeijing(datetime); },
    toLocal(datetime) { return zone.toLocal(datetime); }
};

export default {
    install(Vue) {
        Vue.prototype.$zone = api;

        // 全局过滤器:{{ item.create_time | zoneTime('26') }}
        Vue.filter('zoneTime', (value, type) => {
            return api.format(value, type === undefined ? '26' : type);
        });

        // 暴露给主项目 / 控制台
        window.$bw8ZoneVue = {
            get: api.get,
            set: api.setTimezone,
            reset: api.resetTimezone,
            format: zone.format,
            debug: zone.debugInfo
        };
    }
};

关键点:zoneState.timezone 必须在 render 期间 读取(模板里调用 $zone.format),Vue 才能收集依赖。时区一变,所有展示时间的组件自动重渲染,不需要逐个页面刷新。

3.5 三种表格,三套薄适配器

老项目里同时存在三种表格组件,只改一个是不够的。

① 自研 DragTable2:列上写 type 即可

js 复制代码
methods: {
    // 是否为时间类列($zone 未注册时全部走原逻辑,保证 widget 页面不报错)
    isTimeType(type) {
        return !!(this.$zone && this.$zone.isTimeType(type));
    },
    // 时间列展示值:北京时间 -> 用户时区
    zoneText(row, th) {
        return this.$zone.format(row[th.key], th.type);
    }
}
html 复制代码
<div v-else-if="isTimeType(th.type)" :title="zoneText(row, th)">
    {{ zoneText(row, th) }}
</div>

页面接入成本就是给列加一个字段:

js 复制代码
{ title: $t('创建时间'), key: 'createTime', type: '26' }
{ title: $t('服务期'), key: 'serviceTime', type: 'range8' }

② vxe-table:注册全局 formatter

js 复制代码
VxeUi.formats.add('zoneTime', {
    tableCellFormatMethod({ cellValue }, type) {
        return zoneApi.format(cellValue, type === undefined ? '26' : type);
    }
});
js 复制代码
{ field: 'createDT', title: '创建时间', formatter: ['zoneTime', '26'] }

③ iView <Table>:列适配器

js 复制代码
export function withZoneRender(columns) {
    if (!Array.isArray(columns)) return columns;

    return columns.map(col => {
        let next = col;
        const timeType = resolveTimeType(col); // dataType 优先,type 兜底

        // 已有自定义 render 的列不覆盖,由页面自行处理
        if (timeType && typeof col.render !== 'function') {
            next = Object.assign({}, col, {
                render: (h, { row, column }) => {
                    // 渲染期读取响应式时区,切换时区后表格自动重渲染
                    const val = zoneApi.format(row[column.key], timeType);
                    const text = (val === null || val === undefined) ? '' : String(val);
                    return h('span', { attrs: { title: text } }, text);
                }
            });
        }

        // 支持 children 分组表头递归处理
        if (Array.isArray(next.children)) {
            next = Object.assign({}, next, { children: withZoneRender(next.children) });
        }
        return next;
    });
}

这里有个容易踩的细节:列上的类型字段可能叫 type,也可能叫 dataType。

  • DragTable2 是自研组件,type 没有冲突,接口原样保留;
  • vxe / iView 的 type 是保留属性(selection、index、expand 等),资产表模块为了避免冲突,把后端 EAV 的 type 改名成了 dataType。

所以适配器统一按 dataType 优先、type 兜底 取值,两种约定都能吃:

js 复制代码
function resolveTimeType(col) {
    const t = (col.dataType === undefined || col.dataType === null || col.dataType === '')
        ? col.type
        : col.dataType;
    return zoneApi.isTimeType(t) ? String(t) : null;
}

3.6 查询和编辑:反向换算

展示是"北京 → 用户",查询是"用户 → 北京"。选择器里用户选的是当地时刻,提交前必须转回去,否则海外用户"看到的"和"筛出来的"会差一个时区。

js 复制代码
/**
 * 删除时间搜索:选择器里的起止是用户当地时刻,提交前转回北京时间
 * @param {string} searchDate JSON 串 {"start":"...","end":"..."}
 */
zoneSearchDate(searchDate) {
    if (!searchDate) return searchDate;
    try {
        const range = JSON.parse(searchDate);
        return JSON.stringify({
            start: this.$zone.toBeijing(range.start),
            end: this.$zone.toBeijing(range.end)
        });
    } catch (e) {
        // 解析失败原样返回,不阻塞请求
        return searchDate;
    }
}

编辑回显同理:表单赋值时 toLocal,保存组装参数时 toBeijing。两端的转换必须成对出现,才能保证 toBeijing(toLocal(x)) === x。

四、踩过的坑

  1. 纯日期不能换算 (最重要)。北京 2026-03-25 00:00:00 换成纽约是 2026-03-24 12:00:00;如果这是一列"合同日期",海外用户就会看到错误的日期。判定标准只有一个:有没有真实的时分秒。

  2. 零日期会静默回卷 。MySQL 的 0000-00-00 不能交给 Date.UTC,否则会得到一个 1899 年的日期;越界分量(2026-13-45)同理。必须在解析入口显式拦截。

  3. 带时区的 ISO 串不能按北京时间解析 。2026-09-18T07:01:50Z 自带 UTC 语义,按北京时间猜会多减 8 小时。先判断结尾的 Z / ±HH:mm,再决定用哪条解析路径。

  4. vxe 的 #default 插槽优先级高于 formatter。列一旦走插槽,formatter 根本不会执行,必须在插槽的取数函数里处理。另外 vxe 会按行缓存格式化结果,应用内热切时区不生效;当前依赖"切换时区 → 全局刷新"的契约,如果以后要热切换,需要主动让表格缓存失效。

  5. iView 的 type 是保留字段 。写 type: '26' 不会被误判(它只认 index / selection / html / expand),但资产表模块为了避免和 vxe 的保留属性冲突,把接口字段类型存到了 dataType。适配器要两个都看。

  6. 别对已经格式化过的本地时间再标 26 。有的页面在数据层已经用 timeFormat 转成了本地时间,再换算一次就是二次偏移。标类型前先确认值语义:这是后端原始值,还是前端已经处理过的值?

  7. 26 字段的编辑控件必须是 datetime / datetimerange。原来用纯日期控件,用户选不到时分秒,提交时秒数直接丢失。这是类型和控件不匹配的典型问题。

  8. 查询转换只放在"组装请求参数"处 。选择器和表格的展示状态保持用户时区,不要提前改 v-model,否则用户刚选完时间,输入框里的值就跳了。

五、怎么验证

算法层 zone.js 不依赖 Vue、不依赖 DOM,可以直接用 Node 跑。这次实际跑出来的结果:

输入 类型 目标时区 输出
2026-03-25 00:00:00 26 America/New_York 2026-03-24 12:00:00
2026-03-25 00:00:00 8 America/New_York 2026-03-25 00:00:00
2026-07-01 12:00:00 26 America/New_York 2026-07-01 00:00:00
2026-01-01 00:00:00 26 Asia/Tokyo 2026-01-01 01:00:00
2026-08-07 10:00:00--2026-08-09 10:00:00 range26 America/New_York 2026-08-06 22:00:00--2026-08-08 22:00:00
2026-09-15--2026-09-30 range8 America/New_York 2026-09-15--2026-09-30
0000-00-00 00:00:00 26 America/New_York 0000-00-00 00:00:00
2026-13-45 00:00:00 26 America/New_York 2026-13-45 00:00:00
2026-09-18T07:01:50Z 26 America/New_York 2026-09-18 03:01:50
2026-09-18 07:01:50.000000 26 America/New_York 2026-09-17 19:01:50
1758157310(秒级) 26 America/New_York 2025-09-17 21:01:50
1758157310000(毫秒级) 26 America/New_York 2025-09-17 21:01:50

往返一致性(回显 → 提交):

北京值 用户看到 提交回北京 一致
2026-03-25 14:30:00 2026-03-25 02:30:00 2026-03-25 14:30:00 ✅
2026-07-01 12:00:00 2026-07-01 00:00:00 2026-07-01 12:00:00 ✅
2026-11-01 01:30:00 2026-10-31 13:30:00 2026-11-01 01:30:00 ✅
2026-01-01 00:00:00 2025-12-31 11:00:00 2026-01-01 00:00:00 ✅

验收标准:

  1. 非北京时区下,26 列正确换算为用户当地时刻;
  2. 北京时区下,所有页面展示与改造前逐字符一致;
  3. 8 / range8 列在任何时区下都原样展示;
  4. 无法解析的值(含 0000-00-00、越界分量)原样展示,控制台无新增报错;
  5. 编辑回显与保存往返一致:toBeijing(toLocal(x)) === x。

六、小结

这次改造真正难的并不是 Intl 的 API,而是三件事:

  1. 先定义语义:哪些字段是"时刻",哪些是"日历日期"。这个决定要在写代码之前和产品、后端对齐。
  2. 把换算收敛到一个纯函数出口,再给不同的表格组件做薄适配。页面只标记类型,不写转换逻辑。
  3. 对所有不可解析的值保持"原样返回",让改造可以按页面灰度接入,而不是一次性替换所有表格。

最终公共代码约 600 行(含注释,zone.js 算法层 + Vue 插件 + 三个表格适配器),20 多个页面完成了接入,大多数只需要在列上标一个 type,后端接口和数据库零改动。

附:调试入口

排查"这个页面到底用的哪个时区"时,控制台三行就够:

js 复制代码
window.$bw8ZoneVue.set('America/New_York'); // 切纽约(8 月夏令时 UTC-4 / 冬令时 UTC-5)
window.$bw8ZoneVue.reset();                 // 恢复浏览器时区
window.$bw8ZoneVue.debug();                 // { browser, cookie, parent, current }

debug() 会同时打印四个来源:浏览器时区、Cookie、主项目 bw8Zone、最终生效时区。时区问题排查不清时,先看这四个值是否一致。


发布信息(可删)

  • 备选标题:
    • 《海外用户总说时间不对?一次 Vue 2 多时区改造的完整复盘》
    • 《时刻还是日历日期?多时区改造最容易搞错的一件事》
    • 《后端接口零改动,前端如何支持多时区》
  • 建议标签:Vue 2、时区、Intl、国际化、前端工程
  • 配图:
    • images/timezone-architecture.png(1200×780)
    • images/timezone-date-vs-datetime.png(1200×640)
    • images/timezone-table-demo.png(1200×640)
    • 同名 .svg 为可编辑源文件
相关推荐
Hilaku41 分钟前
为什么每个前端都应该拥有一个自己的独立博客?
前端·javascript·程序员
燕落南枝1 小时前
CSS 后代选择器与子代选择器:从入门到实战
前端·css
Amos_Web1 小时前
Rspack 源码解析(二十八):HtmlRspackPlugin 如何把模板变成带资源标签的最终产物
前端
马可家的菠萝1 小时前
个人知识库怎么建?从一个正在学的技术主题开始
前端·笔记·知识管理·个人知识库
几何心凉1 小时前
端到端具身交互智能正式上线:让 AI 通过屏幕和机器人,真正走进现实世界
前端·人工智能·实时互动·机器人·交互
木圭的AI时代指南1 小时前
实测 MiniMax M3.1 Flash:同一道 Three.js 大题,两个 agent 差出 4.6 倍
开发语言·javascript·ecmascript
西红柿炖牛腩3541 小时前
3D 模型压缩脚本又报错又配环境?换个不用维护的方案
javascript·3d·性能优化
tryCbest2 小时前
Vue3——第 6 篇:事件处理与表单绑定——v-on 与 v-model
前端·javascript·vue.js
恋猫de小郭2 小时前
Flutter 的文字为什么比 SwiftUI 更细?问题主要在 Skia 的 256 项查找表里
android·前端·flutter