1. 关键词
  2. 修订记录
  • 问题
  • 相关规范
    1. 标准的 ISO-8601 格式
      1. 时区标记符
    2. 简化版的 ISO-8601 格式
    3. JS 中Date.parse()支持的时间格式
  • 解决
  • 完整代码
    1. 检测工具
  • 参考
  • 修正 Chrome 50 中关于 Date.parse 的问题

    关键词

    • ISO-8601 日期时间字符串
    • Date.parse

    修订记录

    • 2018.12.15, 增加对标准ISO-8601的解释, 明确了时区标记符的概念
    1
    2
    3
    4
    5
    // 用于打印 Unix 时间戳和其结构化的 Date 对象
    function logDate (dateString) {
    const time = Date.parse(dateString)
    console.log(time, new Date(time))
    }

    问题

    最近在项目开发中遇到一个问题,在 Chrome 63 中Date.parse和 Chrome 50 中Date.parse在解析形如 "2018-01-20T00:29:18" 格式的字符串时,行为不一致。

    chrome50 中的 Date.parse
    chrome63 中的 Date.parse

    相关规范

    标准的 ISO-8601 格式

    ISO-8601格式的字符串包含以下这些:

    • YYYY (eg 1997)
    • YYYY-MM (eg 1997-07)
    • YYYY-MM-DD (eg 1997-07-16)
    • YYYY-MM-DDThh:mmTZD (eg 1997-07-16T19:20+01:00)
    • YYYY-MM-DDThh:mm:ssTZD (eg 1997-07-16T19:20:30+01:00)
    • YYYY-MM-DDThh:mm:ss.sTZD (eg 1997-07-16T19:20:30.45+01:00)

    注意后面三种, 是带有T 分割符号(用于划分包括日在内以上的数值和日以下的数值) 和时区标记符(TZD)

    时区标记符

    跟在秒之后, 合法的时区标记符包括:
    • +01:00表示 UTC+1
    • Z表示当地

    简化版的 ISO-8601 格式

    Js 中完全可能收到这样的时间字符串

    1. 2011-10-10这种是标准的ISO-8601格式字符串
    2. 2011-10-10T14:48:00这种是简化版的ISO-8601格式字符串.

    可以看到, 和协议中建议的格式相比, 第 2 种格式省去了时区标记符. 对于这种格式?Date.parse()应该理所当然地认为它表示"UTC0"时间么? 答案是否定的. 实际上, ES5 对此有规范:

    当一个时间字符串包含了完整的年月日时分秒信息, 却不带时区标记符时, 它会Date.parse()当成本地时间.

    下一节会解释这一点:

    JS 中Date.parse()支持的时间格式

    MDN: Date.parse中的关于 es5 对 ISO-8601 格式的字符串的支持的描述如下:

    The date time string may be in a simplified ISO-8601 format. For example, "2011-10-10" (just date) or "2011-10-10T14:48:00" (date and time) can be passed and parsed. Where the string is ISO-8601 date only, the UTC time zone is used to interpret arguments. If the string is date and time in ISO-8601 format, it will be treated as local.

    简单翻译:

    时间字符串可能是以简化版 ISO-8601 格式传入的,比如 "2011-10-10"(仅有日期) 或者 "2011-10-10T14:48:00"(含有日期和时间)被传给Date.parse时:

    1. 如果只包含了日期信息,则 UTC 时区 会用于被作为解释器的参数。即会认为传入的字符串是UTC 0时间
    2. 如果字符串同事包含了日期和时间信息,则会被当做是本地时间处理。

    根据这条规则(假设系统所在地为东八区)

    1. Date.parse("2011-10-10")应该被当做格林威治时间的2011-10-10T00:00:00,对于东八区而则是2011-10-10T08:00:00
    2. Date.parse("2011-10-10T14:48:00")应该被当做本地时间,对应格林威治时间的2011-10-10T06:48:00,即这是东八区的 2011-10-10T14:48:00

    用这个时间执行一遍logDate,结果如下:

    chrome50: Date.parse
    chrome63: Date.parse

    显然,在 Chrome 50 中,对于同时包含日期和时间(形如 "2018-01-20T00:29:18") 的字符串并没有正确处理,本应将其看作本地时间,却将其当做了 UTC 0 时间。猜想这是 Chrome 50 对应版本的 v8 的锅。

    Chrome 60 Chrome 53
    2011-10-10 认为是 UTC 0 时间 (✔) 认为是 UTC 0 时间 (✔)
    2011-10-10T14:48:00 认为是本地时间 (✔) 认为是 UTC 0 时间 (❌)

    解决

    回到问题本身,在项目中有这样的一个dateFormat函数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    // helper: padStart
    function padStart (str = '', len = 2, padContent = '0') {
    while (str.length < len) {
    str = padContent + str
    }

    return str
    }

    function dateFormat (value, format = 'YYYY-MM-dd HH:mm:ss') {
    const time = new Date(value).getTime()

    const dateObj = new Date(time)
    const year = dateObj.getFullYear()
    const month = dateObj.getMonth() + 1
    const date = dateObj.getDate()
    const hours = dateObj.getHours()
    const minutes = dateObj.getMinutes()
    const seconds = dateObj.getSeconds()
    const rs = format
    .replace('YYYY', padStart(year + '', 4))
    .replace('MM', padStart(month + ''))
    .replace('dd', padStart(date + ''))
    .replace('HH', padStart(hours + ''))
    .replace('mm', padStart(minutes + ''))
    .replace('ss', padStart(seconds + ''))

    return rs
    }

    在 Chrome 50 中,dateFormat("2018-01-20T00:29:18")这段代码将无法在系统地区为非 GMT 0 时区的环境下得到预期的结果

    在解析同时包含日期和时间的 ISO-8601 字符串时,Chrome 50 的表现不符合预期

    为了解决这个问题,我们需要检测浏览器对 GMT +0 以外的时区是否遵循了MDN: Date.parse中所描述的规则,并且在不遵守规则的情况下,将误差值计算出来。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    // 分析环境中的 Date 对象信息
    function parseDateEnvInfo () {
    // new Date(numberOrString) 时, 如果传入的是字符串, 内部会调用 Date.parse 解析传入的参数
    // 为了对比检测环境对 ISO-8601 格式字符串的解析是否正确,我们
    // 使用 new Date(0) 来创建系统初始时间
    const accurate = new Date(0)
    const iso8601 = new Date("1970-01-01T00:00:00")

    // 从系统中获取时间差, 判定环境是否属于 GMT 0 时区
    const offsetMs = accurate.getTimezoneOffset() * 6e4
    const in_gmt_0 = offsetMs === 0
    const offsetMsCalculated = iso8601 - accurate

    // 对比两个值:
    // 1. 正确的时区 millisecond 值: offsetMs;
    // 2. 解析 ISO-8601 日期时间字符串得到的"本地时间"和 UTC 0 时间之间的 millisecond 值: offsetMsCalculated
    //
    // 如果两个值不相等, 说明环境不遵守解析 ISO-8601 日期时间字符串的规则, 在解析字符串的时候,
    // 总会产生一个误差值; 反之, 说明环境正确解析了 ISO-8601 日期时间字符串
    const follow_iso_8601_outside_gmt0_zone = offsetMs === offsetMsCalculated

    return {
    in_gmt_0,
    follow_iso_8601_outside_gmt0_zone,
    // 这个值便是环境中解析 ISO-8601 日期时间字符串时的误差值, 如果没有误差, 这个值为 0
    error_offset_ms_when_date_parse: offsetMsCalculated - offsetMs,
    offsetMs,
    offsetMsCalculated
    }
    }

    对之前的formatDate 略作修改

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // 该正则表达式并不能匹配出所有的 ISO_8601 格式的字符串, 此处仅用作示例
    const ISO_8601_REG = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/

    function dateFormat (value, format = 'YYYY-MM-dd HH:mm:ss') {
    // 消除环境误差
    const errorValue = ISO_8601_REG.test(value) ? parseDateEnvInfo().error_offset_ms_when_date_parse : 0
    const time = new Date(value).getTime() - errorValue

    const dateObj = new Date(time)
    const year = dateObj.getFullYear()
    const month = dateObj.getMonth() + 1
    const date = dateObj.getDate()
    const hours = dateObj.getHours()
    const minutes = dateObj.getMinutes()
    const seconds = dateObj.getSeconds()
    const rs = format
    .replace('YYYY', padStart(year + '', 4))
    .replace('MM', padStart(month + ''))
    .replace('dd', padStart(date + ''))
    .replace('HH', padStart(hours + ''))
    .replace('mm', padStart(minutes + ''))
    .replace('ss', padStart(seconds + ''))

    return rs
    }

    在浏览器里尝试运行:

    chrome63: Date.parse

    完整代码

    完整代码view raw
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    function padStart (str = '', len = 2, padContent = '0') {
    while (str.length < len) {
    str = padContent + str
    }

    return str
    }

    // 分析环境中的 Date 对象信息
    function parseDateEnvInfo () {
    // new Date(numberOrString) 时, 如果传入的是字符串, 内部会调用 Date.parse 解析传入的参数
    // 为了对比检测环境对 ISO-8601 格式字符串的解析是否正确,我们
    // 使用 new Date(0) 来创建系统初始时间
    const accurate = new Date(0)
    const iso8601 = new Date("1970-01-01T00:00:00")

    // 从系统中获取时间差, 判定环境是否属于 GMT 0 时区
    const offsetMs = accurate.getTimezoneOffset() * 6e4
    const in_gmt_0 = offsetMs === 0
    const offsetMsCalculated = iso8601 - accurate

    // 对比两个值:
    // 1. 正确的时区 millisecond 值: offsetMs;
    // 2. 解析 ISO-8601 日期时间字符串得到的"本地时间"和 UTC 0 时间之间的 millisecond 值: offsetMsCalculated
    //
    // 如果两个值不相等, 说明环境不遵守解析 ISO-8601 日期时间字符串的规则, 在解析字符串的时候,
    // 总会产生一个误差值; 反之, 说明环境正确解析了 ISO-8601 日期时间字符串
    const follow_iso_8601_outside_gmt0_zone = offsetMs === offsetMsCalculated

    return {
    in_gmt_0,
    follow_iso_8601_outside_gmt0_zone,
    // 这个值便是环境中解析 ISO-8601 日期时间字符串时的误差值, 如果没有误差, 这个值为 0
    error_offset_ms_when_date_parse: offsetMsCalculated - offsetMs,
    offsetMs,
    offsetMsCalculated
    }
    }
    // 该正则表达式并不能匹配出所有的 ISO_8601 格式的字符串, 此处仅用作示例
    const ISO_8601_REG = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/

    function dateFormat (value, format = 'YYYY-MM-dd HH:mm:ss') {
    // 消除环境误差
    const errorValue = ISO_8601_REG.test(value) ? parseDateEnvInfo().error_offset_ms_when_date_parse : 0
    const time = Date.parse(value) - errorValue

    const dateObj = new Date(time)
    const year = dateObj.getFullYear()
    const month = dateObj.getMonth() + 1
    const date = dateObj.getDate()
    const hours = dateObj.getHours()
    const minutes = dateObj.getMinutes()
    const seconds = dateObj.getSeconds()
    const rs = format
    .replace('YYYY', padStart(year + '', 4))
    .replace('MM', padStart(month + ''))
    .replace('dd', padStart(date + ''))
    .replace('HH', padStart(hours + ''))
    .replace('mm', padStart(minutes + ''))
    .replace('ss', padStart(seconds + ''))

    return rs
    }

    检测工具

    这里有一个检测工具可以用来检测您阅读这篇文章的时候使用的浏览器的Date.parse在解析 ISO-8601 日期时期字符串的时候是否有正确的行为.

    (完)

    参考

    首页归档简历