关于字符编码,有三个核心概念:
反斜杠(\)在字符串内有特殊含义,用来表示一些特殊字符,所以又称为转义符。
需要用反斜杠转义的特殊字符,主要有下面这些:
上面这些字符前面加上反斜杠,都表示特殊含义。
console.log('1\n2')
// 1
// 2
上面代码中,\n表示换行,输出的时候就分成了两行。
反斜杠还有三种特殊用法。
\HHH
反斜杠后面紧跟三个八进制数(000到377),代表一个字符。HHH对应该字符的Unicode码点,比如\251表示版权符号。显然,这种方法只能输出256种字符。
\xHH
\x后面紧跟两个十六进制数(00到FF),代表一个字符。HH对应该字符的Unicode码点,比如\xA9表示版权符号。这种方法也只能输出256种字符。
\uXXXX
\u后面紧跟四个十六进制数(0000到FFFF),代表一个字符。HHHH对应该字符的Unicode码点,比如\u00A9表示版权符号。
下面是这三种字符特殊写法的例子。
'\251' // "©"
'\xA9' // "©"
'\u00A9' // "©"
'\172' === 'z' // true
'\x7A' === 'z' // true
'\u007A' === 'z' // true
如果在非特殊字符前面使用反斜杠,则反斜杠会被省略。
'\a'
// "a"
上面代码中,a是一个正常字符,前面加反斜杠没有特殊含义,反斜杠会被自动省略。
如果字符串的正常内容之中,需要包含反斜杠,则反斜杠前面需要再加一个反斜杠,用来对自身转义。
"Prev \\ Next"
// "Prev \ Next"
最常见的进制表达形式有,二进制,八进制,十进制,十六进制。他们之间的区别仅体现在标识符的不同。
ASCII
(American Standard Code for InformaTion Interchange,美国信息交换标准代码)是基于拉丁字母的一套电脑编码规范,主要用于显示现代英语和其他西欧语言。它是现今最通用的单字节编码规范,并等同于国际标准ISO/IEC 646。
请注意,ASCII是American Standard Code for InformaTion Interchange缩写,而不是ASCⅡ(罗马数字2),有很多人在这个地方产生误解。
计算机中,所有的数据在存储和运算时都要使用二进制数表示(因为计算机用高电平和低电平分别表示1和0),例如,像a、b、c、d这样的52个字母(包括大写)、以及0、1等数字还有一些常用的符号(例如*、#、@等)在计算机中存储时也要使用二进制数来表示,而具体用哪些二进制数字表示哪个符号,当然每个人都可以约定自己的一套(这就叫编码),而大家如果要想互相通信而不造成混乱,那么大家就必须使用相同的编码规则,于是美国有关的标准化组织就出台了ASCII编码,统一规定了上述常用符号用哪些二进制数来表示。
美国标准信息交换代码是由美国国家标准学会(American NaTIonal Standard InsTItute , ANSI )制定的,标准的单字节字符编码规范,用于基于文本的数据。起始于50年代后期,在1967年定案。它最初是美国国家标准,供不同计算机在相互通信时用作共同遵守的西文字符编码标准,它已被国际标准化组织(International Organization for Standardization, ISO)定为国际标准,称为ISO 646标准。适用于所有拉丁文字母。
ASCII 码使用指定的7位或8位二进制数组合来表示128 或256 种可能的字符。标准ASCII 码也叫基础ASCII码,使用7位二进制数(剩下的1位二进制为0)来表示所有的大写和小写字母,数字0 到9、标点符号,以及在美式英语中使用的特殊控制字符。
其中:0~31及127(共33个)是控制字符或通信专用字符(其余为可显示字符),如控制符:LF(换行)、CR(回车)、FF(换页)、DEL(删除)、BS(退格)、BEL(响铃)等;通信专用字符:SOH(文头)、EOT(文尾)、ACK(确认)等。
ASCII值为8、9、10 和13 分别转换为退格、制表、换行和回车字符。它们并没有特定的图形显示,但会依不同的应用程序,而对文本显示有不同的影响。
32~126(共95个)是字符(32是空格),其中48~57为0到9十个阿拉伯数字。
65~90为26个大写英文字母,97~122号为26个小写英文字母,其余为一些标点符号、运算符号等。
同时还要注意,在标准ASCII中,其最高位(b7)用作奇偶校验位。所谓奇偶校验,是指在代码传送过程中用来检验是否出现错误的一种方法,一般分奇校验和偶校验两种。
奇校验规定:正确的代码一个字节中1的个数必须是奇数,若非奇数,则在最高位b7添1。
偶校验规定:正确的代码一个字节中1的个数必须是偶数,若非偶数,则在最高位b7添1。
后128个称为扩展ASCII码。许多基于x86的系统都支持使用扩展(或“高”)ASCII。扩展ASCII 码允许将每个字符的第8位用于确定附加的128个特殊符号字符、外来语字母和图形符号。
由于每个ASCII字符占用1个字节,因此,ASCII 编码可以表示的最大字符数是255(00H—FFH)。这对于英文而言,是没有问题的,一般只什么用到前128个(00H–7FH,最高位为0)。而最高位为1的另128个字符(80H—FFH)被称为“扩展ASCII”,一般用来存放英文的制表符、部分音标字符等等的一些其它符号。
但是对于中文等比较复杂的语言,255个字符显然不够用。于是,各个国家纷纷制定了自己的文字编码规范,其中中文的文字编码规范叫做“GB2312—80”,它是和ASCII兼容的一种编码规范,其实就是利用扩展ASCII没有真正标准化这一点,把一个中文字符用两个扩展ASCII字符来表示,以区分ASCII码部分。
但是这个方法有问题,最大的问题就是中文的文字编码和扩展ASCII码有重叠。而很多软件利用扩展ASCII码的英文制表符来画表格,这样的软件用到中文系统中,这些表格就会被误认作中文字符,出现乱码。另外,由于各国和各地区都有自己的文字编码规则,它们互相冲突,这给各国和各地区交换信息带来了很大的麻烦。
按照ASCII的编码规范,则可知当字符编码大于255时,可认为是非单字节字符。在后文中可知js使用Unicode字符集,由于历史的局限性,最多只会使用两个字节。
我们为 String
扩展原型方法 byteLength(),该方法将枚举每个字符,按照ASCII字符编码规范,判断当前字符是单字节还是双字节,然后统计字符串的字节长度。
String.prototype.byteLength = function() {
let b = 0; l = this.length; //初始化字节数递加变量并获取字符串参数的字符个数
if(l) { //如果存在字符串,则执行计划
for(let i = 0; i < l; i ++) { //遍历字符串,枚举每个字符
if(this.charCodeAt(i) > 255) { //字符编码大于255,说明是双字节字符
b += 2 //则累加2个
} else {
b++ //否则递加一次
}
}
return b //返回字节数
} else {
return 0 //如果参数为空,则返回0个
}
}
应用原型方法:
const s = "String 类型长度"; //定义字符串直接量
console.log(s.byteLength()); //返回15
要真正解决这个问题,不能从扩展ASCII 的角度入手,Unicode作为一个全新的编码规范应运而生,它可以将中文、法文、德文……等等所有的文字统一起来考虑,为每一个文字都分配一个单独的编码。
Unicode(统一码、万国码、单一码)是计算机科学领域里的一项业界标准,包括字符集、编码规范等。Unicode 是为了解决传统的字符编码规范的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。1990年开始研发,1994年正式公布。
Unicode最初产生时是只有UCS-2编码的,但是后来发现如果把中国故纸堆里的罕用字以及各种小语种的所有文字都收录进去的话,16位UCS-2仍然不够用。于是Unicode才升级成了UCS-4。不过应用最广泛的仍然是UCS-2。
目前实际应用的Unicode版本对应于UCS-2,使用16位的编码空间。也就是每个字符占用2个字节。这样理论上一共最多可以表示2^16(即65536)个字符。基本满足各种语言的使用。实际上当前版本的统一码并未完全使用这16位编码,而是保留了大量空间以作为特殊使用或将来扩展。
UCS-4是一个更大的尚未填充完全的31位字符集,加上恒为0的首位,共需占据32位,即每个字符占用4个字节。理论上最多能表示2^31个字符,完全可以涵盖一切语言所用的符号。
UCS-4可以看做UCS-2的扩展,双字节的UCS-2编码对应的四字节的UCS-4编码后两位相同,前两个字节的所有位都为0。
UCS-4分为多个平面(plane),其中所有UCS-2的字符构成基本多文种平面(Basic Multilingual Plane,BMP,也叫平面0)。或者说UCS-4中,高两个字节为0的码位被称作BMP。将UCS-4的BMP去掉前面的两个零字节就得到了UCS-2。在UCS-2的两个字节前加上两个零字节,就得到了UCS-4的BMP。而目前的UCS-4规范中还没有任何字符被分配在BMP之外。
实际用来存储和传输的方式,即对Unicode编码进行二次编码。
Unicode的编码方式定义了每个字符的编码,但是如果就使用比如说UCS-2编码来存储和传输字符,对于英文字母来说,其第一个字节都是0,没有任何意义,而且还会造成空间的巨大浪费。这是不可接受的。所以Unicode的实现方式并不同于其编码方式。Unicode的实现方式称为Unicode转换格式(Unicode Transformation Format,简称UTF)。常用的UTF有:UTF-8,UTF-16,UTF-32。
UTF-8: 使用一至四个字节为每个字符编码。且编码自带简单的校验功能。
汉字的unicode区间是\u4E00到\u9FA5;\u0800-\uFFFF 这个区间内的unicode,以utf-8编码存储,都是占3个字节,所以上述区间内的汉字,全部都是3个字节。
备注:UTF8mb4:utf8mb4比utf8多了对emoji编码支持。
UTF-16: 对于UCS-2来说,UTF-16这种Unicode的实现方式和UCS-2的编码是一致的,即用两个字节表示一个字符(即对于UCS-2来说,UTF-16是定长编码)。但对UCS-4来说,UTF-16是不定长编码,在部分范围是用四个字节标识一个字符。
UTF-32: 这个实现方式是对于UCS-4来说也是定长编码(4个字节),因为它已经足够直接保存UCS-4编码了。
上面讲到字符的实际存储和传输用的都是Unicode的实现方式,比如在保存和传输文本的时候,用UTF-8很多,因为对于大量以拉丁字母等ASCII字符为主的文献,UTF-8非常节省空间。但计算机处理文本的时候,内存中一般使用UTF-16。因为UTF-8是变长编码,字符不定长会给算法带来麻烦,不从头扫描一遍,就不知道第几个字符在哪个位置上,这在处理的时候非常浪费时间。
举个例子:“ZH药丸”是一个四个字符的字符串
UTF-8编码是"5A 48 e88daf e4b8b8" UTF-16 编码是 “005a 0048 836f 4e38” (这里还有字节序的问题,但是我们先忽略)
如果我想让你找到第四个字符是啥,UTF-8 必须扫过整个字节流,而使用 UTF-16 的话,直接取出第四个16位整形(4e38)就好了
所有很多语言/程序的处理办法是在内存中使用UTF-16编码处理字符(这里指的是针对UCS-2的UTF-16,只有针对UCS-2的UTF-16编码才是定长编码,Windows系统中用的便是针对UCS-2的UTF-16,微软当时用的时候Unicode还只有UCS-2,所以UTF-16是定长的。现在Unicode扩展到了UCS-4之后,UTF-16是不定长的了,但WIndows并没有更新,依然还使用的UCS-2),这对绝大多数字符而言足够用了。当然对于扩充后的Unicode(UCS-4中多于UCS-2的)字符是不支持的。
计算机内存中,统一使用Unicode编码,需要保存或者传输时,一般转换成UTF8编码。
UTF-8以字节为编码单元,没有字节序的问题。UTF-16以两个字节为编码单元,在解释一个 UTF-16文本前,首先要弄清楚每个编码单元的字节序。例如“奎”的Unicode编码是594E, “乙”的Unicode编码是4E59。如果我们收到UTF-16字节流“594E”,那么这是“奎” 还 是“乙”?
Unicode规范中推荐的标记字节顺序的方法是BOM(Byte Order Mark)
。
BOM是一个有点小聪明的想法:在UCS编码中有一个叫做"ZERO WIDTH NO-BREAK SPACE"的字符,它的编码是FEFF。而FFFE在UCS中是不存在的字符,所以不应该出现在实际传输中。
UCS规范建议我们在传输字节流前,先传输字符"ZERO WIDTH NO-BREAK SPACE"。
这样如果接收者收到FEFF,就表明这个字节流是Big-Endian(大端法)的;如果收到FFFE,就表明这个字节流是Little-Endian(小端法)的。因此字符"ZERO WIDTH NO-BREAK SPACE"又被称作BOM。
UTF-8不需要BOM来表明字节顺序,但可以用BOM来表明编码方式。所以如果接收者收到以U+FEFF开头的字节流,就知道这是UTF-8编码了。Windows就是使用BOM来标记文本文件的编码方式的。
基于Unicode编码规范,ASCII编码范围为 U+0000 至 U+007F,使用正则表达式进行字符编码验证
整体封装与上文byteLength函数一致
for (let i = 0; i < l; i ++) {
const c = this.charAt(i);
if (/^[\u0000-\u007f]$/.test(c)) {
b++
} else {
b += 2
}
}
当进行字符存储时,基于UTF-8(默认)或UTF-16编码规范进行字节计算
/**
* 计算字符串所占的内存字节数
*
* UTF-8 是一种可变长度的 Unicode 编码格式,使用一至四个字节为每个字符编码
*
* 000000 - 00007F(128个代码) 一个字节
* 000080 - 0007FF(1920个代码) 两个字节
* 00E000 - 00FFFF(61440个代码) 三个字节
* 010000 - 10FFFF(1048576个代码) 四个字节
*
* 注: Unicode在范围 D800-DFFF 中不存在任何字符
*
* UTF-16 大部分使用两个字节编码,编码超出 65535 的使用四个字节
* 000000 - 00FFFF 两个字节
* 010000 - 10FFFF 四个字节
*
* @param {String} str
* @param {String} charset utf-8(默认), utf-16
* @return {Number}
*/
const sizeof = function(str, charset = 'utf8'){
let total = 0,
charCode,
i,
len;
if(charset === 'utf16'){
for(i = 0, len = str.length; i < len; i++){
charCode = str.charCodeAt(i);
if (charCode <= 0xffff){
total += 2;
} else {
total += 4;
}
}
} else {
for(i = 0, len = str.length; i < len; i++){
charCode = str.charCodeAt(i);
if (charCode <= 0x007f) {
total += 1;
} else if (charCode <= 0x07ff){
total += 2;
} else if (charCode <= 0xffff){
total += 3;
} else {
total += 4;
}
}
}
return total;
}
JavaScript默认使用Unicode字符集,内部的所有字符都是使用Unicode字符来表示
不仅JavaScript内部使用Unicode储存字符,而且还可以直接在程序中使用Unicode,所有字符都可以写成”\uXXXX”的形式,其中XXXX代表该字符的Unicode编码。比如,\u00A9代表版权符号。
const s = '\u00A9';
s // "©"
每个字符在JavaScript内部都是以16位(即2个字节)的UTF-16格式储存。也就是说,JavaScript的单位字符长度固定为16位长度,即2个字节。
但是JavaScript对utf-16的支持是不完整的,由于历史原因,只支持两个字节的字符,不支持四字节的字符,因为第一版utf-16发表的时候只编写到了U+FFFF,因此两个字节就足够了,后来Unicode的字符越来越多,出现了四个字节的字符,但是JavaScript已经定型,统一将字符长度规定为两个字符,导致现在不能识别四字节的字符
因此,JavaScript在识别四个字节字符的时候会把它认为是两个字符,因此JavaScript返回的字符串长度可能是不对的。
UTF-16有两种长度:
而且前两个字节在0xD800到0xDBFF之间,后两个字节在0xDC00到0xDFFF之间。举例来说,U+1D306对应的字符为,它写成UTF-16就是0xD834 0xDF06。浏览器会正确将这四个字节识别为一个字符,但是JavaScript内部的字符长度总是固定为16位,会把这四个字节视为两个字符。
const s = '\uD834\uDF06';
s // ""
s.length // 2
/^.$/.test(s) // false
s.charAt(0) // ""
s.charAt(1) // ""
s.charCodeAt(0) // 55348
s.charCodeAt(1) // 57094
上面代码说明,对于于U+10000到U+10FFFF之间的字符,JavaScript总是视为两个字符(字符的length属性为2),用来匹配单个字符的正则表达式会失败(JavaScript认为这里不止一个字符),charAt方法无法返回单个字符,charCodeAt方法返回每个字节对应的十进制值。
所以处理的时候,必须把这一点考虑在内。对于4个字节的Unicode字符,假定C是字符的Unicode编号,H是前两个字节,L是后两个字节,则它们之间的换算关系如下。
// 将大于U+FFFF的字符,从Unicode转为UTF-16
H = Math.floor((C - 0x10000) / 0x400) + 0xD800
L = (C - 0x10000) % 0x400 + 0xDC00
// 将大于U+FFFF的字符,从UTF-16转为Unicode
C = (H - 0xD800) * 0x400 + L - 0xDC00 + 0x10000
下面的正则表达式可以识别所有UTF-16字符。
([\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF])
由于JavaScript引擎(严格说是ES5规范)不能自动识别辅助平面(编号大于0xFFFF)的Unicode字符,导致所有字符串处理函数遇到这类字符,都会产生错误的结果。如果要完成字符串相关操作,就必须判断字符是否落在0xD800到0xDFFF这个区间。
下面是能够正确处理字符串遍历的函数。
function getSymbols(string) {
const length = string.length;
let index = -1;
const output = [];
let character;
let charCode;
while (++index < length) {
character = string.charAt(index);
charCode = character.charCodeAt(0);
if (charCode >= 0xD800 && charCode <= 0xDBFF) {
output.push(character + string.charAt(++index));
} else {
output.push(character);
}
}
return output;
}
const symbols = getSymbols('');
Object.entries(symbols) // ['0', '']
替换(String.prototype.replace)、截取子字符串(String.prototype.substring, String.prototype.slice)等其他字符串操作,都必须做类似的处理。
MIME 是“多用途网际邮件扩充协议”的缩写,在 MIME 协议之前,邮件的编码曾经有过 UUENCODE 等编码方式 ,但是由于 MIME 协议算法简单,并且易于扩展,现在已经成为邮件编码方式的主流,不仅是用来传输 8 bit 的字符,也可以用来传送二进制的文件 ,如邮件附件中的图像、音频等信息,而且扩展了很多基于MIME 的应用。从编码方式来说,MIME 定义了两种编码方法Base64
与QP(Quote-Printable)
按照RFC2045的定义,Base64被定义为:Base64内容传送编码被设计用来把任意序列的8位字节描述为一种不易被人直接识别的形式。
为什么要使用Base64?
在设计这个编码的时候,我想设计人员最主要考虑了3个问题:
加密是肯定的,但是加密的目的不是让用户发送非常安全的Email。这种加密方式主要就是“防君子不防小人”。即达到一眼望去完全看不出内容即可。
基于这个目的加密算法的复杂程度和效率也就不能太大和太低。和上一个理由类似,MIME协议等用于发送Email的协议解决的是如何收发Email,而并不是如何安全的收发Email。因此算法的复杂程度要小,效率要高,否则因为发送Email而大量占用资源,路就有点走歪了。
但是,如果是基于以上两点,那么我们使用最简单的恺撒法即可,为什么Base64看起来要比恺撒法复杂呢?这是因为在Email的传送过程中,由于历史原因,Email只被允许传送ASCII字符,即一个8位字节的低7位。因此,如果您发送了一封带有非ASCII字符(即字节的最高位是1)的Email通过有“历史问题”的网关时就可能会出现问题。网关可能会把最高位置为0!很明显,问题就这样产生了!因此,为了能够正常的传送Email,这个问题就必须考虑!所以,单单靠改变字母的位置的恺撒之类的方案也就不行了。关于这一点可以参考RFC2046。 基于以上的一些主要原因产生了Base64编码。
Base64编码要求把3个8位字节(3*8=24)转化为4个6位的字节(4*6=24),之后在6位的前面补两个0,形成8位一个字节的形式。
有时候文本里包含一些不能用来打印的符号,例如ASCII码0到31的符号都无法打印。这个时候可以使用Base64编码,将他们转变成可以打印的字符,另一个场景是有时候需要使用文本传递二进制数据,这个时候也会使用Base64转化。
Base64是一种编码格式,可以把任意字符转化成0
到9
,a
到z
,A
到Z
,+
,/
这64个特殊字符组成的可打印字符,他的主要目的不是为了加密,而是为了不出现特殊字符,简化程序的处理。
JavaScript原生提供了两个Base64函数
这两个方法只适用于ASCII码的字符,例如汉字就不支持,如果想使用的话可以进行转码
const string = 'Hello World!';
btoa(string) // "SGVsbG8gV29ybGQh"
atob('SGVsbG8gV29ybGQh') // "Hello World!"
// 这两个方法不适合非ASCII码的字符,会报错。
btoa('你好')
// Uncaught DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
// 要将非ASCII码字符转为Base64编码,必须中间插入一个转码环节,再使用这两个方法。
function b64Encode(str) {
return btoa(encodeURIComponent(str));
}
function b64Decode(str) {
return decodeURIComponent(atob(str));
}
b64Encode('你好') // "JUU0JUJEJUEwJUU1JUE1JUJE"
b64Decode('JUU0JUJEJUEwJUU1JUE1JUJE') // "你好"
整体封装与上文byteLength函数一致
for (let i = 0; i < l; i++) {
const c = this.charAt(i);
if (encodeURIComponent(c).length > 4) {
b += 2;
} else if (c != '\r') {
b++;
}
}
通常缩写为“Q”方法,其原理是把一个 8 bit 的字符用两个16进制数值表示,然后在前面加“=”。所以我们看到经过QP编码后的文件通常是这个样子:=B3=C2=BF=A1=C7=E5=A3=AC=C4=FA=BA=C3=A3=A1。
最后,我们希望你看了这篇文章之后不要混淆字符集和字符编码的概念,还有对以上谈到的各种编码方式的原因有大致的了解,像utf-8这类是为了解析unicode这种字符集而制定,而base64这类是为了解决实际的网络应用而制定。