# [Vue開發筆記] 一次踩齊new Date()的坑 :::info 語言:大部份TypeScript 框架:vue.js 套件:Element UI 需求:不管使用者在哪,固定顯示某個時區時間 ::: #### 【說明】 這次的需求是頁面日期時間統一顯示為東8區(GMT+8)時間,傳到後端的資料也必須是東8區時間,並指定時間格式為 <font color="#c7254e">`yyyy-MM-dd HH:mm:ss`</font> ~~,這什麼詭異的需求~~ #### 接下來就是踩雷時間… ## new Date()的時區問題 第一個雷是 `new Date()` 取得時間物件的時區問題,需求是必須固定顯示東8區時間,但因為每個user所在的時區不同,而 `new Date()` 是取得client端的本地時間,所以不能只用 `new Date()` 產生出來的時間物件 ~~,必須做點手腳~~。 一開始的想法很簡單,只要給定 `new Date()` 一個時區,這樣不就可以抓到指定那個時區的時間了嗎?先不論怎麼給定一個時區,尋找方法後得到的結果卻是 ==原生的JS時間對象無法做到這件事…== 。為什麼做不到?我們直接來看實際的例子。 #### 現在假設本地所在時區為<font color="#c7254e">```GMT+0(格林威治標準時間)```</font> ```javascript= new Date('2020-01-01 00:00:00') // Wed Jan 01 2020 00:00:00 GMT+0000 (格林威治標準時間) new Date('2020-01-01 00:00:00 +0800') // Tue Dec 31 2019 16:00:00 GMT+0000 (格林威治標準時間) ``` 當我直接給定 `new Date()` 一個 `yyyy-MM-dd HH:mm:ss` 這樣的格式時間,時間為2020年1月1日的0時0分0秒,他會直接返回一個我指定時間的時間物件,同樣是 `2020-01-01 00:00:00` ,還顯示時區為GMT+0,沒毛病。 但如果我指定這個時間的時區要是東8區(GMT+8),如 `#4` ,照這次的需求來看,我覺得它應該返回的是 ``` Wed Jan 01 2020 00:00:00 GMT+0800 (台北標準時間) // 幻想的結果 ``` 實際上返回的是 ``` Tue Dec 31 2019 16:00:00 GMT+0000 (格林威治標準時間) // 實際的結果 ``` #### WTF!!! 這是怎麼回事?我就要GMT+8的0時0分0秒,不行嗎?不行… 那它返回的是什麼鬼?怎麼跑到2019年12月31日去了? 其實不管你給 `new Date()` 的是哪裡的時間,縱使加了時區也一樣,當javaScript產生時間物件的時候都會將這個時間轉成 ==相應的本地時區時間== ,所以 <font color="#c7254e">`東8區的2020年1月1日0時0分0秒(GMT+8)`</font> ,以時差換算正是 <font color="#c7254e">`格林威治標準時間的2019年12月31日16時0分0秒(GMT+0)`</font> 。 #### 哇!!!真的好貼心喔!這種事情就是合用是貼心,不合用就等著煩心… ### 【解決方法】 時差換算 getTimezoneOffset() 這個時候可能要用計算時差的方式取得想要的時間了,要達成這個目的,使用的方法是 [getTimezoneOffset()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Date/getTimezoneOffset) ,其在 [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures) 的定義如下: > The getTimezoneOffset() method returns the time zone difference, in minutes, from current locale (host system settings) to UTC. 中文意思就是,**getTimezoneOffset()** 返回當地時間和UTC時間的時間差,其單位是以分(minutes)計算。 :::warning **UTC時間是什麼?** UTC時間即所謂的「世界協調時間(Coordinated Universal Time)」,是目前世界上最主要的時間標準,以原子時秒長為基礎,在時刻上儘量接近於格林威治標準時間(GMT),也有地區稱之為「協調世界時」。對於大多數用途來說,**UTC時間被認為能與GMT時間互換**,但GMT時間已不再被科學界所確定。 所以一般講「**UTC時間**」,可以當作「UTC+0」或「GMT+0」時間來看。 ::: #### 來看看實際的例子,並且再將本地時區拉回到 <font color="#c7254e">`GMT+8(東8區/台北標準時間)`</font> ```javascript= new Date('2020-01-01 00:00:00').getTimezoneOffset() // -480 new Date('2020-01-01 00:00:00 +0000').getTimezoneOffset() // -480 new Date('2020-01-01 00:00:00 +0800').getTimezoneOffset() // -480 new Date('2020-01-01 00:00:00 -0300').getTimezoneOffset() // -480 new Date('2020-01-01 00:00:00 +0500').getTimezoneOffset() // -480 ``` 就如同上述所說,javaScript將每一個時間物件都轉成相應的本地時區時間,所以 `new Date()` 產生出來的時間物件,不管指定在哪個時區,沒有意外的都會是目前所在的 `東8區(GMT+8)` ,所以 `getTimezoneOffset()` 方法下輸出的結果皆為 `-480`。 根據上面的定義,`getTimezoneOffset()` 輸出的是當地時間和UTC時間的差值,單位是分,所以可以理解為 <font color="#c7254e">`當地時間(GMT+8) - 480(分) = UTC時間`</font>(意即東8區時間比UTC時間快8小時)。 翻譯出來就是,我們所在的東8區(GMT+8)時間減去8小時,就是UTC時間。8 * 60 = 480(分),所以 `getTimezoneOffset()` 輸出 `-480`。 ### 所以怎麼讓時間固定顯示為東8區的時間呢? #### 本範例將本地時間設定為 <font color="#c7254e">`GMT+6(孟加拉標準時間)`</font>,與東8區時間時差為2小時。 ```javascript= // 設定顯示的預設時間(一律固定顯示GMT+8時間) const d = new Date() // Mon Apr 27 2020 11:54:53 GMT+0600 (孟加拉標準時間) const offset = d.getTimezoneOffset() / 60 let dt = d.setHours(d.getHours() + (offset + 8)) // 當地時間 + offset = UTC時間,UTC時間再+8,就是東8區(GMT+8)時間 console.log(new Date(dt)) // Mon Apr 27 2020 13:54:53 GMT+0600 (孟加拉標準時間) ``` `#2` 取得本地時區時間為 <font color="#c7254e">`Mon Apr 27 2020 11:54:53 GMT+0600 (孟加拉標準時間)`</font> `#6` 取得時差 <font color="#c7254e">`-480 / 60 = -8`</font> `#7` 以本地時間計算東8區(GMT+8)時間,得到結果為 <font color="#c7254e">`Mon Apr 27 2020 13:54:53 GMT+0600 (孟加拉標準時間)`</font> 這裡我們可以看到得到的結果是 <font color="#c7254e">`2020年4月27日的13點54分53秒`</font>,確實是與本地時間相差2小時的東8區時間,但後面還是清楚的寫著這個時間所在的時區依然是 <font color="#c7254e">`GMT+6`</font>,也證實原生的JS時間對象確實無法取得其他時區的時間,即使經過加加減減,得到的時間物件還是本地時間。 ::: danger 因此,嚴格來說,這種方式並不是取得某個時區的時間,只是利用本地時間進行的一個時差轉換,對 `new Date()` 來說,得到的時間依然是本地時區時間。 ::: ## Date.toJSON()的陷阱,難道只有我不知? #### 將指定時區的時間搞定後,~~把後面的時區刪掉,我看不到~~,再來就是要處理傳給後端資料的日期格式,必須是 <font color="#c7254e">`yyyy-MM-dd HH:mm:ss`</font>,因此我們必須將得到的時間物件進行格式轉換。 網上針對日期轉換的方式不少,使用的方法都是用 `getYear()`, `getMonth()`, `getDate()`, `getHours()`…等方法取得對應的年、月、日、時、分、秒,再組合成自己想要的格式,網上很多,這邊就不重複。 後來想想,乾脆直接用 `toJSON()` 將時間物件轉成字串的形式再進一步整理字串就好,當然也是因為 `toJSON()` 轉換出來的字串格式與目標相去不遠。 雖然是歪招,但就當作是學習一下 `toJSON()` ,接下來看一下 `toJSON()` 在 [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures) 的定義: > The toJSON() method returns a string representation of the Date object. 意思就是把時間物件轉成字串形式,另外,**IE8及之前的版本不支援這個方法**,請自行~~忽略~~注意。 #### 本地時區依然暫時設定在<font color="#c7254e">`GMT+6(孟加拉標準時間)`</font> ```javascript= const d = new Date() // Mon Apr 26 2020 15:03:20 GMT+0600 (孟加拉標準時間) d.toJSON() // 2020-04-26T09:03:20.443Z d.replace('T', ' ').slice(0, 19) // 2020-04-26 09:03:20 ``` `toJSON()` 轉出來的字串是一個 `ISO8601` 的日期格式標準,其格式為 <font color="#c7254e">`'yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'sss'Z`</font> 接下來再對日期字串做一些處理,就可以順利得到一個格式為 <font color="#c7254e">`'yyyy'-'MM'-'dd' 'HH':'mm':'ss'`</font> 的日期,問題順利解決了:tada:。 #### 等等!!!怎麼好像哪裡怪怪的? 看上面的範例, `#3` 輸出的日期是 <font color="#c7254e">`2020-04-26 15:03:20`</font>, `#5` 經過 `toJSON()` 字串化之後,輸出的日期變成 <font color="#c7254e">`2020-04-26 09:03:20`</font>,這是怎麼回事? 結果,我在 [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures) 裡 Date.prototype.toJSON() 的 [簡體中文](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Date/toJSON) 頁面上找到一個範例,清楚的說明了發生什麼事( `toJSON()` 的基本範例也說明了這件事,但我覺得這個範例更清楚)。 ```javascript= var date = new Date(); console.log(date); //Thu Nov 09 2017 18:54:04 GMT+0800 (中国标准时间) var jsonDate = (date).toJSON(); console.log(jsonDate); //"2017-11-09T10:51:11.395Z" var backToDate = new Date(jsonDate); console.log(backToDate); //Thu Nov 09 2017 18:54:04 GMT+0800 (中国标准时间) ``` `#1` 產生一個時間物件,時間為 <font color="#c7254e">`Thu Nov 09 2017 18:54:04 GMT+0800 (中国标准时间) `</font> `#4` 將此時間物件轉成字串後,得到是 <font color="#c7254e">`2017-11-09T10:51:11.395Z`</font> #### 明明是當下的時間,為什麼會有 `#1` 和 `#4` 的差異。因為 `Date.toJSON()` 轉出來的字串 ==會把本地時間轉成UTC時間==。 `#7` 如果再將轉換出來的字串丟進 `new Date()` 裡,就可以再得到本地時間 <font color="c2c2c2">或許這就是為什麼沒人用 `toJSON()` 做時間格式轉換的原因…(心虛)</font> #### 那 `toJSON()` 可以用在哪呢?其實寫入db的時候可以考慮。將本地時間經過 `toJSON()` 字串化後存入db中,取出之後再用 `new Date()` 重新轉成當地時間顯示。 ```javascript= // 將時間轉換後存入db const localTime = new Date() // Tue Apr 28 2020 09:45:51 GMT+0600 (孟加拉標準時間) let dateitme = localtime.toJSON() // "2020-04-28T03:45:51.032Z" // 從db取出時間使用 let newLocalTime = new Date(datetime) // Tue Apr 28 2020 09:45:51 GMT+0600 (孟加拉標準時間) // 如果user在 GMT+8 時區,同樣db出來的時間會顯示為本地時區時間 // Tue Apr 28 2020 11:45:51 GMT+0800 (台北標準時間) ``` ### [同場加映] `Date.toJSON()` 轉出來時間格式中的"T"和"Z"是什麼意思? "T"代表的是一個類似 ==連結的符號==,當日期和時間組合起來時,要在時間之前加一個大寫的"T"。 "Z"代表的其實是 ==零時區==,與UTC時間相同的情況下,可以在後面加一個大寫的"Z",做為時間0偏移的代號。所以 `toJSON()` 轉出來的時間格式都帶有一個"Z"也表示這個時間就是代表`零時區` / `GMT+0` / `UTC時間`。 既然 `ISO 8601` 格式下,"Z"代表UTC時間,那也意味著要表示其他時區可以把Z替換掉,例如表示GMT+8時區可以把"Z"代換為"+0800",或"+08:00",甚至可以直接寫"+08"。 所以,在了解 `toJSON()` 轉換的日期字串格式後,前面那個 ==怎麼讓時間固定顯示為東8區的時間呢?== 的問題,就可用更為簡便的方法達到,不需要用到 `getTimezoneOffset()` 這個算時差的方法,來看以下的示範。 #### 本地時區依然暫時設定在 <font color="#c7254e">`GMT+6`</font>,目標是固定顯示 <font color="#c7254e">`GMT+8`</font> 時間 ```javascript= const d = new Date() // Tue Apr 28 2020 13:14:20 GMT+0600 (孟加拉標準時間) d.setHours(d.getHours() + 8) // Tue Apr 28 2020 21:14:20 GMT+0600 (孟加拉標準時間) let dt = d.toJSON().slice(0, 19) // 2020-04-28T15:14:20 ``` `#1` 用 `new Date()` 取得本地時間 `#4` 因為要取得東8區時間,所以先設定"小時"+8 `#7` 將 `+8小時` 後的時間用 `toJSON()` 轉成字串,依據之前介紹過 `toJSON()` 這個方法,這裡 `d.toJSON()` 轉出來的時間字串應該會長成這樣 <font color="#c7254e">`2020-04-28T15:14:20.sssZ`</font>,==利用 `slice` 去掉後面的"Z",這個時間就從原本代表的UTC時間變為本地時間== ,也代表著原本6小時的時差跟著消失,在前面 `+8` 的作用下,這個時間就變成與東8區時間一致了,是不是很神奇呢! #### 但是因為Safari,我們的喜悅維持不了沒有太久 上面那個方法是利用 `ISO 8601` 日期格式後面的"Z"來達到時區轉換的功能,偏偏 `Safari` 又不吃這套,所以上述方法在 `Safari` 以外的瀏覽器是可行的,但在 `Safari` 就… #### 我們直接來看實際的例子,本地時區回到原本我們所在的 <font color="#c7254e">`東8區(GMT+8)`</font> 在 `Chrome` 上執行的結果(黑底) ![](https://i.imgur.com/QcpEjrA.jpg) 在 `Safari` 上執行的結果(白底) ![](https://i.imgur.com/iCpqHBV.jpg) #### 看兩個瀏覽器跑出來的結果, `Chrome` 上跟我們預期的一樣,大寫的"Z"代表的是UTC時間,所以轉成本地時間會是 <font color="#c7254e">`2020年1月1日 08:00:00`</font>,將"Z"拿掉後,就沒有時差的問題,所以顯示為 <font color="#c7254e">`2020年1月1日 00:00:00`</font>。 #### 但 `Safari` 上,似乎將沒有添加大寫"Z"的時間格式也直接視為UTC時間(`$1`, `$2`),如果要指定其他時區的寫法,是把原來"Z"的位置替換為 <font color="#c7254e">`+08:00`</font> (`$3` ,此處以東8區為例), 在時區與秒數間 ==不要加空白== ,而之前提過的其他時區格式,如: `+08`, `+0800` 則都不被 `Safari` 接受。 ## Safari上new Date()接受的日期字串格式 #### 繼續踩雷之旅… 由於這次的需求是輸出 <font color="#c7254e">`yyyy-MM-dd HH:mm:ss`</font> 的時間格式,一開始為了完美起見(?!),就將所有取到的日期格式全都轉化成 <font color="#c7254e">`yyyy-MM-dd HH:mm:ss`</font> ,結果就踩到了 `Safari` 在 `new Date()` 上鼎鼎有名的雷,也就是在 `Safari` 上, `new Date()` 接受的日期格式問題。 #### `Safari` 不接受時間格式為 <font color="#c7254e">`yyyy-MM-dd HH:mm:ss`</font> 的字串,所以將這樣的時間字串用 `new Date()` 轉化為時間物件時會出錯。 ```javascript= new Date('2020-01-01 00:00:00') // Chrome // Wed Jan 01 2020 00:00:00 GMT+0800 (台北標準時間) new Date('2020-01-01 00:00:00') // Safari // Invalid Date <-- 出錯了 ``` 同樣的日期格式在Chrome上顯示結果正確,在Safari上就表示為無效日期,主要是因為 `Safari` 在要求 `new Date()` 做物件化時僅接受 ==RFC2822== 或 ==ISO 8601== 的時間格式。 ==ISO 8601== 我們前述提到過,就是 `toJSON()` 字串化轉出的那個格式 <font color="#c7254e">'yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'sss'Z</font> ==RFC2822== 包含星期縮寫、三字母月份縮寫、數字日期、年份、時間和時區,格式範例如下 <font color="#c7254e">{Wed|星期縮寫} {Jan|三字母月份} {01|數字日期} {2020|年 份} {00:00:00|時間} {GMT+0800|時區}</font> `Safari` 只接受上述兩種時間格式,其他如 `Chrome` 、 `Firefox` 也同樣接受,所以為了兼容各家瀏覽器,在將日期字串物件化時可以使用上面兩種格式,以避免出錯。 【參考資料】: - [時區與JS中的Date對象](https://codertw.com/%E7%A8%8B%E5%BC%8F%E8%AA%9E%E8%A8%80/644589/) - [在iOS Safari,JavaScript使用Date.parse或Date()转换日期返回NaN或Invalid Date](https://majing.io/posts/10000005481218) - [[JS] JavaScript Date Time Method 日期時間](https://pjchender.github.io/2017/12/27/js-javascript-date-time-method-%E6%97%A5%E6%9C%9F%E6%99%82%E9%96%93/) - [[Javascript] 世界時間表](https://medium.com/@moojing/javascript-locale-%E8%BD%89%E6%8F%9B%E6%99%82%E5%8D%80%E6%99%82%E9%96%93-1c15fb4f8cc8) ###### tags: `Vue開發筆記` `JavaScript` `Frontend` `vue`