2018年6月30日

[Guide] 瞭解網頁中看不懂的編碼:Unicode 在 JavaScript 中的使用

keywords: unicode, utf-8, JavaScript
如果只是想要簡單知道 Unicode 是什麼,沒有要瞭解使用方式的話,可以觀看計算機科學速成課(第四集):二進制 ,在此教學影片的後半段有提到文字符號的編碼概念。
在學習網頁開發的過程中,一定會慢慢的碰到所謂的 Unicode, UTF-8 還有其他幾種不同的編碼方式,這麼說你可能不會太有感覺,讓我們以 Facebook 為例,你可以看到在資料傳遞的過程中,常常有這種看不太懂的東西...
Imgur
我們把它拿出來編排一下大概長這樣:
"profiles": {
  "768320183253420": {
    "name": "PJCHENder\u7db2\u9801\u524d\u7aef\u8cc7\u6e90\u7ad9",
    "firstName": "PJCHENder\u7db2\u9801\u524d\u7aef\u8cc7\u6e90\u7ad9",
    "businessID": 0,
    "businessName": null,
    "allBusinessData": [
    {
      "businessID": 0,
      "businessName": null
    }]
  },
  // ...
}
你可以看到裡面有 \u7db2\u9801\u524d\u7aef\u8cc7\u6e90\u7ad9 這種看不太懂的內容。
再舉一個例子來看,有些時候我們可能看到某些網路文章想要分享給別人,明明網址是:https://today.line.me/tw/pc/article/冰火之國+冰島-ownNlj,可是一分享出去卻變成一堆看似亂碼的東西 https://today.line.me/tw/pc/article/%E5%86%B0%E7%81%AB%E4%B9%8B%E5%9C%8B+%E5%86%B0%E5%B3%B6-ownNlj
上面的這些例子都是文字轉成編碼後的情況,你應該可以瞭解到這些編碼在瞭解網頁開發時的重要性的,如果不懂的話,就無法把這些內容解碼回去,那就會看不懂別人的內容阿...
Imgur
看到編碼卻不知道怎麼解碼,那該怎麼上車呢?
在這篇文章中會說明在網頁中常用的一些編碼方式,讓對網頁編碼沒有概念的捧油們可以對它有些概念,重點是對它不再畏懼,然後有興趣的話可以在透過延伸閱讀中的文章進一步瞭解更多細節。

什麼是字串編碼(String Encode)?

先來談談什麼是字串編碼,字串編碼簡單來說,就是把我們熟悉的文字用許多數字來代表它,你可以想像就像在當兵或坐牢的時候,大家不會直接叫你的名字,而是叫你的編號
例如,「阿童」在坐牢時的編號是「9453」,這時候只要廣播「9453」請到司令台集合,阿童就會知道這是在叫他,就會跑到司令台來了。
這個編號是不會重複的,也就是「9453」就只會是「阿童」的編號,不會同時有其他淡水阿婆、基隆阿公的編號也一樣是 9453。
能夠代表你的身份證字號也是一種編碼的概念,這個編號就可以用來代表它指稱的是你。

電腦只認得數字

接著你可能會好奇,在監獄裡面因為方便管理、去個人化等因素,所以把人名改成編碼似乎合理,但為什麼需要把我們常用的文字也都變成編碼呢?
這是因為電腦在運算的過程中,底層都還是數字來表示的,電腦只看得懂數字,但並不認得文字符號。也就是它只看的懂「9453」這個數字,但並不認得「阿童」這些字。
更精確的說,電腦只認得「二進制」這種用 0 和 1 組成的數字。
由於電腦只認得數字,但是我們人類看得懂、使用的卻是文字,那麼該怎麼辦呢?於是,美國最早定義了一套文字符號的編碼,能夠將每個英文的文字符號(Symbol)都對應到一個數字,這個最早統一的規定就稱為「ASCII 編碼」
文字符號(Symbol)指的是可以把文字拆成的最小單位,像是英文的「字母」 a, b, c 都是文字符號;而中文的「國字」,像是 , , 這些也都是文字符號。

ASCII 編碼(ASCII Code)

ASCII Code 是由美國制訂,最早用來統一英文文字符號和數字間的對應關係,除了 52 個大小寫英文字母外,其中也包含了常用的符號(如 !, <, = )和特殊字元,總共定義了 127 個關係。
舉例來說,從下面的 ASCII Table 中可以看到,最上面的地方寫了 Dec 和 Char,其中 Char 就是文字符號的部分,Dec 則是用來表示該文字符號的十進位數字,在這張表中可以看到大寫英文字母的 A 對應到的編號竟是十進位的 65 號,小寫英文字母 a 則是對應到十進位的 97 號:
Imgur
那麼 ASCII 的編碼方式就是我們最前面提到的, Facebook 中回傳的 \u9673\u67cf\u878d 內容嗎?答案是:「有關但不同」。

Unicode(萬國碼)

在最前面所提到 Facebook 中回傳的 \u9673\u67cf\u878d 其實用的是 Unicode 編碼?Unicode 編碼又是什麼呢?
你可以發現在 ASCII 中定義了 127 個文字符號和數字之間的關係,但是那是因為英文只需要有 26 個英文字母就可以組成各是各樣的單字,但是中文或者說許多國家的語言並不是這樣阿,很明顯的 ASCII Code 不適用於所有的語言
此外,如果不同國家使用不同的編碼來表徵自己的文字符號,又會變得非常不方便,例如,在台灣「9453」這個數字代表「阿童」,同樣的數字「9453」在美國代表「阿普」,在韓國「9453」則代表的是「金秘書」,這樣真的是非常的不方便阿!有沒有辦法讓全世界有一個共用的罪犯編號不會重複,讓 9453 就只能代表阿童呢?
於是,讓各語言的文字符號都能用一個數字代表它,而且這個數字是世界通用且不會重複的,就出現了所謂的 Unicode(萬國碼)這個編碼。Unicode 就像一個世界通用的大字典,收納了好幾百萬個全世界的文字符號,且每一個文字符號都有一個屬於自己的編號。不會同一個編號在台灣、美國、日本卻表示不同的文字符號。
Unicode 就像一個世界通用的大字典,收納了好幾百萬個全世界的文字符號,且每一個文字符號都有一個屬於自己的編號,在 Unicode 的官網 http://www.unicode.org/ 中列出了所有的 Unicode。

用來表示數值的不同方式:談談進位制

這裡我們要先稍微跳開一下文字編碼的部分,來談一下進位制。
因為電腦只認得數字(01),因此不論是 ASCII 或 Unicode,都是把數字和文字符號間做一對一的轉換,因此都可以把文字轉換成對應到的數字,但是用來表示數字的方式則有很多種,我們把它稱為進位制,像是二進制、八進制、十進制和十六進制等等。
由於在 Unicode 中多數使用十六進制的方式來表示一個數值,因此在這裡先簡單說明進位制的概念和轉換。

不同進位制的表示

在這裡我們不會說明進位制之間轉換的計算方式,而是說明如何用 JavaScript 或工具進行數字間不同進位制的轉換,先來簡單瞭解不同進位制的表示方式:
二進制(Binary;bin)只用 01 這兩個數字表示一個數值的方式就稱為二進制,因此最後要給電腦看的數值最終都將轉為二進制(Binary)。在 JavaScript 中,如果你要告訴程式這是一個二進制的數字,需在數字的最前面加上 0b
八進位(Octal;oct)只用 0, 1, ... 7 這八個數字來表示一個數值的方式稱為八進制。在 JavaScript 中,如果你要告訴程式這是一個八進制的數字,需在數字的最前面加上 00o
十進位(Decimal;dec):這是平常使用的數字表示法,只用 0, 1, ... 9 這十個數字來表示一個數值的方式稱為十進制。在 JavaScript 中,如果你要告訴程式這是一個十進制的數字,你什麼都不必做,直接輸入的數字就是表示十進制
十六進位(Hexadecimal;hex)只用 0, 1, ..., 9, A, B, C, D, E, F 這十六個是數字來表示一個數值的方式稱為十六進制,其中 A 表示十進制中的 10, B 表示十進制中的 11,F 表示十進制中的 15,以此類推。在色碼的表示上,也常使用十六進制的數字(如,#FF00AA )來表示一個顏色。在 JavaScript 中,如果你要告訴程式這是一個十六進制的數字,需在數字的最前面加上 0x
使用十六進制的好處是,可以使用較少的位數就能表示更多的數值,例如十六進制的兩位數的 FF 就表示在十進制的需要用三位數表示的 255
進位制(縮寫) 前綴法(JavaScript) 下標表示法
二進位(Binary;bin) 0b11111110 (11111110)2
八進位(Octal;oct) 0376, 0o376 (376)8
十進位(Decimal;dec) 254 (254)10
十六進位(Hexadecimal;hex) 0xfe (fe)16

使用 JavaScript 進行不同進位制間的轉換

將不同進位制的數值轉為十進位:Number()

在 JavaScript 中要把不同進位制的數值轉換成十進制非常容易,只需要在該數值前讓 JavaScript 知道該數值是什麼進位制(例如,0x, 0b)接著使用 Number() 函式即可
在下面的例子中,9453, 10010011101101, 22355, 24ed 這些不同進位制的數字在轉換後其實都是表示十進制的 9453
/* 使用 Number() 將不同進位制的數值轉為 10 進位 */

Number('9453')        // 回傳 9453,直接帶入就是 10 進制
Number('0b10010011101101')  // 回傳 9453,以二進制的的前綴法 0b 表示
Number('022355')     // 回傳 9453,以舊八進制的前綴法 0 表示
Number('0o22355')     // 回傳 9453,以新的八進制的前綴法 0o 表示
Number('0x24ed')      // 回傳 9453,以十六進制的的前綴法 0x 表示
你會發現,十六進位制(24ed)比起二進位制(0b10010011101101),明顯只需要較少的位數就能表示同一個數值。
除了使用 Number() 函式外,使用 Number.parseInt(string, radix) 也能達到轉換的效果:
// 指定該內容要使用的進位制

parseInt('fe', 16)    // 254, 0xfe = 254
parseInt('376', 8)    // 254, 0o376 = 254
parseInt('11111110', 2)   // 254, 0b11111110 = 254
若想進一步瞭解 Number.parseInt() 的用法,可參考 parseInt() 在 MDN 上的說明

將十進位轉換為不同進位制:toString()

如果想要將十進位的數值轉換成其他進位制的話,可以使用 Number.prototype.toString() 這個函式:
/* 使用 toString() 將十進位轉換成不同進位制 */

(9453).toString(2)     // 回傳 '10010011101101',將 9453 轉換成 二 進制
(9453).toString(8)     // 回傳 '22355',將 9453 轉換成 八 進制
(9453).toString(16)      // 回傳 '24ed',將 9453 轉換成 十六 進制

進位制轉換工具

如果你想要看看自己轉換的結果正不正確,可以使用進位換算計算機這套小工具,我們平常用的是 10 進位的數字,所以只需要在十進位的地方輸入「9453」按下計算後,就會出現二進位的結果是「10010011101101」。
Imgur

Unicode 的表示方式(U+<十六進制數值>)

在瞭解不同數值間的進位制轉換後,來看看一般會怎麼樣來表示 Unicode 的數值。
前面提到過Unicode 就像一個世界通用的大字典,收納了好幾百萬個全世界的文字符號,且每一個文字符號都有一個屬於自己的編號
而一般在 Unicode 中我們會用十六進制來表示某一個文字符號的編號,並且使用 U+<十六進制數值> 的方式來表示,例如 U+0061 就表示英文字母的 a,其中的 0061 是十六進位制的數值,轉換成 10 進位的話是 97,你可以發現這和當初的 ASCII 表示相對應的:
Number('0x0061')    // 回傳 97,將十六進制的 0061 轉成 10 進制
又例如,阿童的「阿」用 Unicode 來表示是 U+963f,「童」則是可用 Unicode U+7ae5 表示。

碼點(Code Point)

概念上,我們會把這些文字符號所對應到的編號,稱作是「碼點(Code Point)」,又稱作「編碼位置」。在 Unicode 中指的是 U+ 後面的十六進制數值,每個碼點都是唯一的
。例如 a 的碼點是 0061,「阿」的碼點是 963f,「灣」的碼點是 7ae5
根據碼點的編號範圍,又可以分成「基本平面」和「輔助平面」。

基本名面和輔助平面

Unicode 編碼中碼點的可能範圍從 U+0000 一直到 U+10FFFF 超過 110 萬個文字符號,因此又可分為基本平面(BMP, Basic Multilingual Plane)和輔助平面(SMP, Supplementary planes or Astral planes)。
  • 基本平面(BMP):碼點位置範圍從 U+0000U+FFFF,這個平面放了最常見的文字符號。
  • 輔助平面(SMP):碼點位置範圍從 U+010000 一直到 U+10FFFF,又稱為補充平面。
此外,原本 ASCII 中數字和文字符號的對應關係,可以直接沿用到 Unicode 中,也就是 ASCII 中 a 的編號會和 Unicode 中 a 的編號相同。

JavaScript 中提供的相關函式

keywords: String.fromCodePoint(), String.prototype.codePointAt()
若想要查看某一個字的 Unicode 碼點,或者根據碼點反查是某一個字,在 JavaScript ES6 提供了 String.fromCodePoint(), String.prototype.codePointAt() 這兩個函式,讓你可以在 Unicode 碼點和文字符號間相互轉換
str.codePointAt()String.fromCodePoint() 是 ES6 提供的函式,若要對應回 ES5 的用法,則分別是對應回 str.charCodeAt()String.fromCharCode()
大部分的情況下 ES5 和 ES6 會得到一樣的結果,但若有使用到輔助平面文字符號(U+010000 以上)時 ES5 則可能會產生錯誤,因此建議在支援 ES6 的情況下,使用 ES6 的函式以避免錯誤發生

文字符號 --> Unicode 碼點(10 進位)

透過 String.prototype.codePointAt() 可以將一個文字符號轉換成 Unicode 碼點,但回傳的是十進位,因此一般會需要轉成 16 進位來表示:
// 文字符號 --> Unicode 碼點(10 進位)
String.prototype.codePointAt(<index>)  // ES6 提供的方法
'A'.codePointAt()            // 回傳 '65'(這是十進制)
(65).toString(16)              // 回傳 41,將 65 轉成十六進制後,表示 A 為 U+0041

// 也可以一次把它轉成 16 進制
'A'.codePointAt().toString(16)      // '41',表示 A = U+0041
'童'.codePointAt().toString(16)      // '7ae5',表示童 = U+7ae5
'💩'.codePointAt().toString(16)      // '1f4a9',表示 💩 = U+1f4a9

Unicode 碼點 --> 文字符號

使用 JavaScript ES6 的方法 String.formCodePoint() 則可以把碼點轉換為文字符號。如同前面文章「]不同進位制的表示」所述,數字在表示時若為 16 進位,則開頭需加上 0x 來表示:
// Unicode 碼點 -> 文字符號
String.fromCodePoint(<num1> [, ...[, numN]])    // ES6 提供的方法

String.fromCodePoint(65)     // A,使用 10 進位
String.fromCodePoint(0x0041) // A,使用 16 進位
String.fromCodePoint(0o101)    // A,使用 8 進位

在 JavaScript 字串中顯示 Unicode 字元

除了透過 String.formCodePoint(<num>) 這種方式來將 Unicode 碼點轉換為文字符號外。在 JavaScript 中,可以直接使用 console.log() 函式就能將 Unicode 轉換成文字符號顯示出來
其中根據不同的適用時機或場合有幾種不同的表示方式,包括:
  1. 只使用到 Unicode 基本平面時(\u<碼點>
  2. 有使用到 Unicode 輔助平面時(\u{ <碼點> }
  3. 只使用到 ASCII 字符時(\x<碼點>

只使用到 Unicode 基本平面時(\u<碼點>)

  • 使用:\u<碼點>
  • 適用範圍:Unicode 基本平面字符,也就是 U+0000U+FFFF
console.log('\u0041\u0042\u0043')    // 'ABC'

'臺' === '\u81fa'             // true
console.log('\u81fa')          // 臺
console.log('\u81fa\u7063');      // 臺灣
console.log('\u2661 \u81fa\u7063');   // '♡ 臺灣'
❗️ 若在 \u 使用了輔助平面文字符號的碼點時會產生錯誤的結果

有使用到 Unicode 輔助平面時(\u{ 碼點 })

  • 使用:\u{碼點}
  • 適用範圍:所有 Unicode 字元,特別是有使用超過 U+FFFF 的輔助平面文字符號
為了支援輔助平面的字元,在 JavaScript ES6 中引進了新的 Unicode 碼點跳脫序列(Unicode code point escapes),透過將碼點放在大括號 {} 內,也就是 \u{碼點} 就能正確識別,許多常見的 emoji 符號都是屬於輔助平面內的文字符號:
// 使用 2 位數的十六進制(一個位元組)
console.log('\u{41}\u{42}\u{43}');      // 'ABC'

// 使用 4 位數的十六進制(兩個位元組)
console.log('\u{0041}\u{0042}\u{0043}')    // ABC

// 使用超過 4 位數以上的十六進制
console.log('\u{1F4A9}');         // '💩' U+1F4A9
console.log('\u{1F923}');         // '🤣' U+1F923
console.log('\u{1F436}')         // '🐶' U+1F436

只使用到 ASCII 字符時(\x)

  • 使用:\x<碼點>
  • 適用範圍: U+0000U+00FF,只用到 Unicode 兩位數的十六進制時,也就是一個位元組時
  • 舉例來說, U+0041 = \x41
console.log('\x41\x42\x43');          // 'ABC'

統整一下

A = U+0041 = \x41 = \u0041 = \u{41} = \u{0041}
也就是下面會得到一樣的結果
console.log('\x41')            // 'A'
console.log('\u0041')          // 'A'
console.log('\u{41}')          // 'A'
console.log('\u{0041}')          // 'A'

在 CSS 中使用 Unicode

如果想要在 CSS 中使用 Unicode,可以使用前輟 \<碼點> 來進行跳脫
.content{
    display: inline-block;
    background-color: steelblue;
    padding: 100px;
    color: white;
}
.content::before{
    content: '\0041';  // A, 輸入 16 進位制的 Unicode
    // content: '\570B'; // 國
    // content: '\00A9'; // ©
}

結語

在瞭解了 Unicode 的使用和轉換後相信你已經可以解碼本篇文章中最一開始的那串內容是什麼了吧?
{
  "name": "PJCHENder\u7db2\u9801\u524d\u7aef\u8cc7\u6e90\u7ad9"
}
試著用用看上面幾種不同的方法,像是 console.log()String.fromCodePoint() 的方法來解碼 Unicode 吧!
或者你也可以利用 str.codePointAt() 編碼打造一段屬於自己圈子內的低調碼,請有興趣的捧油們一起來解碼:
// 自製低調碼

[...'準備上車啦'].map(i => i.codePointAt().toString(16))
// [ '6e96', '5099', '4e0a', '8eca', '5566' ]

等等,那麼 UTF-8, UTF-16, UTF-32 是什麼?

透過 Unicode(萬國碼)讓所有國家的文字符號都有個唯一的碼點可以在電腦內被辨認,但是這個碼點(從 U+0000U+10FFFF)要如何儲存在電腦中則不是 Unicode 要解決的,也就是說,要用什麼樣的方式才能有效的把所需的碼點存在電腦中,但又不佔據太多的容量則沒有在 Unicode 中被規範。
UTF-8, UTF-16 或 UTF-32 則都可以視為是 Unicode 實做的一種方式,它們用不同的方式來規範要如何將 Unicode 中的碼點儲存在電腦中以被使用,如果直接把整個 Unicode 代碼搬進電腦裡儲存,可能會佔用太多不必要的空間(例如,UTF-32)。
在網際網路上最常被使用的則是 UTF-8 的編碼方式,而在 JavaScript 引擎中主要支援的則是 UTF-16

其他網際網路上常用編碼方式

除了用 Unicode 來為每個文字符號來進行編碼外,在網際網路上,也經常會使用 HTML Encode 或者是 URI Encode 來將內容或網址進行編碼。

HTML Encode

HTML Encode 的特點在於,它會用 & 開始 ; 結束,例如 &plus; 字串透過 HTML 轉換後會變成 +,它也有可以用十六進位或十進位的方式表示:
"+" = &plus; = &#x0002B; = &#43;
HTML Encode Character Reference :HTML 編碼對照表。

URI Encode

在網際網路上最常被使用的則是 UTF-8 的編碼方式,它的特點是會用 % 開頭,許多的網址或文字內容為了確保正確性,常常會先使用 URI Encode 後再顯示。
文章最前面所提到的例子 https://today.line.me/tw/pc/article/%E5%86%B0%E7%81%AB%E4%B9%8B%E5%9C%8B+%E5%86%B0%E5%B3%B6-ownNlj 就是因為把這些中文字轉換成 UTF-8 1編碼後的結果。
在 JavaScript 中提供 encodeURI()encodeURIComponent() 的方法可以將字串轉換成 UTF-8,這兩個函式的差別在於,encodeURI() 不會對 URI 具有特殊意義的字符編碼(例如,ASCII 碼、數字、- _ . ! ~ * ' ( ) ;/?:@&=+$,#),但 encodeURIComponent() 則是全部都會進行編碼,編碼完的內容會是 UTF-8
encodeURI('/')            //   '/' 是 URI 中有意義的字符,不會進行編碼
encodeURIComponent('/')       //   '%2F'

encodeURI('&')            //   '&' 是 URI 中有意義的字符,不會進行編碼
encodeURIComponent('&')       //   '%26'

encodeURIComponent('國')       // '%E5%9C%8B'
encodeURIComponent('💩')        // '%F0%9F%92%A9'
相對應的解碼方式則是 decodeURI()decodeURIComponent()
decodeURI('https://today.line.me/tw/pc/article/%E5%86%B0%E7%81%AB%E4%B9%8B%E5%9C%8B+%E5%86%B0%E5%B3%B6-ownNlj')   //"https://today.line.me/tw/pc/article/冰火之國+冰島-ownNlj"

延伸閱讀

參考工具