成为高级工程师的必经之路——前端需要掌握的字符集与编码知识点

前言

字符集与编码这个问题,相信很多前端同学都是属于略知一二,但是如果深挖的话可能掌握程度就不怎么样了。

在我们最开始学习HTML语言时,如果使用Windows自带的文本编辑器创建html文件,保存的编码格式如果不是显式的指定UTF-8编码的话,中文就无法正常的展示,但是很多同学其实都并不明白为什么保存成了UTF8格式之后就能正常展示了(包括我自己在之前也是),本着反正既然问题已经解决,那就这样呗。随着编程年限的增长,我们必须要突破自己的技术瓶颈,所以我们对很多东西不能只停留在能用就行,浅尝辄止,我们必须要搞明白这些问题的底层原理,才能为成长为高级工程师夯实技术基础,千里之行,始于足下。

本文就从我遇到的一个问题入手,结合一道面试题,跟大家聊聊我近期学习到的关于前端处理字符集与编码的知识点。

一个困扰我很久的问题

我们公司的埋点系统使用的是神策埋点,对于负责产品的同事,经常会有一个让我们帮他们查看埋点事件详细信息的需求。 神策的埋点体系是使用的是将明文进行base64编码之后追加到请求体发送到它的服务端,我们需要对这个数据请求体进行解密。

上面这个请求,它的请求方式是ping,神策其实使用的是Navigator.sendBeacon()这个方法实现的,它不是本文的重点,有兴趣的同学可以移步MDN的文档进行学习。

在我之前的技术积累中,关于base64编码和解码,我知道浏览器端常用的是btoaatob,在Nodejs端可以用全局的Buffer对象。

所以,就尝试着对神策的数据进行解码,我编写出了以下方法:

js 复制代码
function extract(string) {
  // 提取 data=和&ext= 中间部分的字符,无此关键词也不会处理
  return string.replace('data=', '').split('&ext=')[0]
}

function decrypt(input) {
  const str = decodeURIComponent(extract(input))
  // 使用浏览器端提供的base64解码方法
  let base64String = atob(str)
  let result
  try {
    result = JSON.parse(base64String)
  } catch (error) {
    result = base64String
  }
  return result
}

这段代码,不是不能用,存在一个问题,汉字无法正常的解码。但是,如果在Nodejs端,用Buffer对象来处理的话,却是可以解码中文的。

js 复制代码
function extract(string) {
  // 提取 data=和&ext= 中间部分的字符,无此关键词也不会处理
  return string.replace('data=', '').split('&ext=')[0]
}

function decrypt(input) {
  const str = decodeURIComponent(extract(input))
  // 使用全局的Buffer对象
  let base64String = Buffer.from(str, 'base64').toString('utf-8')
  let result
  try {
    result = JSON.parse(base64String)
  } catch (error) {
    result = base64String
  }
  return result
}

这个差异到底存在哪儿呢?这是一个困扰了我一个很久的问题。

根据我长期的开发经验,我知道问题肯定是出在编码的问题上,(于是我通过在Nodejs上使用Buffer对象的编码格式逐一尝试,发现atob使用的编码格式是Latin-1),我就逐步的缩小问题范围最终解决问题。

什么是Base64?

Base64的应用场景我们已经非常熟悉了,在Webpack的插件中,我们可以配置一个limit来处理size小于多少的时候,将多媒体资源以base64的形式打包进css文件中;在前后端交互中,我们可以通过把一个二进制流转化成base64字符串,然后以data:URL的形式展示。

但是我们从来没有探索过Base64是怎么把二进制转化成我们可读的字符串的,那Base64的原理究竟是什么?

Base64 是一组相似的二进制到文本(binary-to-text)的编码规则,使得二进制数据在解释成64进制的表现形式后能够用 ASCII 字符串的格式表示出来。

Base64 编码使用一组 64 个字符(A-Z, a-z, 0-9, +, /)来表示二进制数据,因此得名 "Base64"。

Base64 编码将输入的二进制数据分成每组 6 位(bit)。由于每组 6 位最多可以表示 2^6=64 种不同的值,因此可以使用一个 64 字符集来表示这些值,然后把6 bit再添两位高位0,组成四个8bit的字节,也就是说,转换后的字符串理论上将要比原来的长1/3。。

这64个字符包括大写字母 A-Z(26 个字符)、小写字母 a-z(26 个字符)、数字 0-9(10 个字符),以及两个额外的符号(通常是 '+' 和 '/')。 每个6位的组合被映射到这 64 个字符中的一个,从而将原始二进制数据转换为文本格式。

Base64编码要求输入数据的位数必须是8的倍数。如果原始数据不符合这个要求,编码过程会在数据末尾添加填充字符(通常是 '='),直到其长度满足要求。这种填充确保了编码后的数据可以正确地分成每组6位进行处理。

btoa与atob

这两个方法的名字如果不知道其含义的话,看起来真的是怪怪的。他们是浏览器提供的处理base64的方法。

btoa: 从二进制数据"字符串"创建一个 Base-64 编码的 ASCII 字符串("btoa"应读作"binary to ASCII")

btoa() 方法可以将一个二进制字符串 (例如,将字符串中的每一个字节都视为一个二进制数据字节)编码为 Base64 编码的 ASCII 字符串。

btoa() - Web API 接口参考 | MDN (mozilla.org)

atob: 解码通过 Base-64 编码的字符串数据("atob"应读作"ASCII to binary")

atob() 对经过 base-64 编码的字符串进行解码。你可以使用 window.btoa() 方法来编码一个可能在传输过程中出现问题的数据,并且在接受数据之后,使用 atob() 方法再将数据解码。例如:你可以编码、传输和解码操作各种字符,比如 0-31 的 ASCII 码值。

atob() - Web API 接口参考 | MDN (mozilla.org)

目前这两个方法都已经不再推荐使用,可以看到这是VSCode给我们的提示信息:

我们进入到这个buffer.d.ts这个文件中查看atob方法的定义,它给出了我们替换atob的方式: 并且,在这个图中,它提到了atob使用的编码方式是Latin-1

到这里,我其实已经解决我的问题了,Latin-1编码是不包含中文的字符集,所以自然是无法正常展示中文了

在浏览器端使用Buffer对象

在写这篇文章之前,我一直以为只有在Nodejs端才能使用Buffer对象,其实在浏览器端也提供了Buffer对象,只不过它是第三方支持的。

buffer - npm (npmjs.com)

在Nodejs环境下,我们可以直接使用Buffer对象,在浏览器下,我们需要从这个buffer包中导入即可,这个包不是node的原生包,因此我需要我们手动进行安装。

bash 复制代码
npm install buffer -S

所以,之前我们的解码代码,仅仅只需要增加一行代码即可:

js 复制代码
// 增加Buffer对象的导入
import { Buffer } from 'buffer';

function extract(string) {
  // 提取 data=和&ext= 中间部分的字符,无此关键词也不会处理
  return string.replace('data=', '').split('&ext=')[0]
}

function decrypt(input) {
  const str = decodeURIComponent(extract(input))
  // 使用全局的Buffer对象
  let base64String = Buffer.from(str, 'base64').toString('utf-8')
  let result
  try {
    result = JSON.parse(base64String)
  } catch (error) {
    result = base64String
  }
  return result
}

事情到这儿,如果你觉得结束了,那你就真的太肤浅了,它只是一个开始,哈哈哈,接着往下看你的知识积累将会更上一层楼。

一道让人无语的面试题

这道面试题来源掘金论坛上的掘友孟祥_成都两道面试题(阿里和腾讯)直接劝退同事! - 掘金 (juejin.cn) 以下是我在他的博文上的截图: 在没有写这篇文章之前,如果面试官要问我这两个字符串的length是多少,我根据经验是能回答的出来的,但是要问我为什么,我肯定就不知道了,接下来我们就一起搞懂为什么。

字符集编码

本文的标题是把字符集和编码拆开了的,我根据我的理解,来解释一下拆开与不拆开的区别。

字符集:表示一个符号的集合,比如65(二进制1000001)代表A,97代表a,它是一个集合,表示了数字(数字到二进制就比较直接了,计算机肯定只认识二进制)与字符之间的映射关系,但是最开始的计算机问世的时候肯定不会管中国人(或者别的国家)用不用计算机的这个场景,所以只需要把西方常见的字符囊括进去就行了,这在计算机问世的最初的一段时间就够用了。但是,随着中美关系的改善,还有硅谷科技公司们商业版图的扩张,他们就想把这些硬件软件产品卖给中国人,中国人不是所有的人都掌握英语,要让普通人能够在电脑里面输入汉字,那就得把汉字用数字也能表示,但是中国的汉字很多,原来适用西方的字符集肯定就不行了,于是就有了新的字符集,这也就是为什么某些编码方式(ASCII)中文乱码的原因,根本没有收录,谈何解释,嘿嘿。

编码:在之前我们聊字符集的时候提到了,这些字符是数字跟字符串之间的映射,计算机是只能认识0和1的,那么把字符对应的数字存储(或者从硬盘读取,反正就是从内存到磁盘,从磁盘到内存的这种意思)下来的过程,这就是编码,因为在存储的过程中还需要考虑到存储大小和读写性能,因此就存在很多的编码方式。

字符集编码:我个人的理解就是,将常见的字符和数字(二进制)关系映射确定的集合,并且规定了怎么在计算机内存储的规则。

上面是我个人的理解,我在学习的过程中参考了这篇文章,我觉得不错,大家也可以移步学习:字符集编码(一):Unicode 之前 - 知乎 (zhihu.com)

常见的字符集编码有ASCIIISO 8859(较为出名的是ISO 8859-1,也称为 Latin-1),简体中文字符集GB2312GBK(是GB2312的扩展),繁体中文字符集Big5Unicode,国际标准,旨在为全球所有的字符提供一个唯一的字符集(包含中文)。

在大学的第一课我们在C语言就已经接触到的字符集编码就是ASCII,后来有的同学学习了Java.NET,又接触到了gb2312GBK,而我学习的方向是Web前端,所以接触到了UTF-8编码,这也导致我的知识点出现了跳跃,本来应该先知道Unicode字符集再了解什么是UTF-8编码的,直接来了个鸡蛋已经把🐔孵化出来了,回过来探索鸡蛋是怎么来的,哈哈哈。

不同的公司推出了不同的字符集编码,这样的状况不就是秦始皇统一六国之前的混乱局面吗?所以,计算机界就提出了一种统一的编码规范,它就是UnicodeUnicode是一种规范,它为全球不同语言和符号的每个字符提供了唯一的标识(称为代码点),比如A对应的数字是65,Unicode的码点就是U+0041。而我们常提到的UTF-8UTF-16UTF-32就是对这套编码规范的实现(具体怎么存,三种编码规范有各自的实现)。

  1. UTF-8:

    • UTF-8(8-bit Unicode Transformation Format)是一种变长的字符编码方式,用于编码 Unicode 字符集中的字符。
    • UTF-8 使用 1 到 4 个字节来表示每个Unicode代码点,这取决于字符的复杂性。ASCII 字符(如英文字母和数字)只用一个字节表示,而某些汉字和表情符号可能需要三个或四个字节。
    • UTF-8 兼容 ASCII 编码,使得原有的 ASCII 文本无需转换即可作为 UTF-8 文本处理。
    • 它是网络上最常用的编码方式。
  2. UTF-16:

    • UTF-16(16-bit Unicode Transformation Format)是另一种用于编码Unicode字符的方式,它使用2个或4个字节来表示每个Unicode代码点。
    • UTF-16 中,常用的字符(如基本多文种平面内的字符)使用两个字节编码,而更少见的字符(比如某些古文字符和表情符号)使用一对(称为代理对)来编码,每个代理项占用两个字节。
    • UTF-16 在某些系统和环境(如 Windows 和 Java 中)中较为常见。
  3. UTF-32:

    • UTF-32(32-bit Unicode Transformation Format)是一种使用固定长度的方式来编码Unicode字符,每个 Unicode 代码点都使用四个字节表示。
    • 由于其固定长度的特性,UTF-32 使得字符的定位和计数变得更加直观和简单,但这也导致了更高的空间占用,特别是对于包含大量简单字符(如英文文本)的文档。
    • UTF-32 不如 UTF-8 或 UTF-16 那么常用,但在某些需要快速随机访问字符的应用中可能会用到。

html网页使用UTF-8编码,这是因为对于以英文为主的文本(即使是中文网页Html标签占的权重绝大多数时候也比较高)更加高效,因为它只使用一个字节来表示常用的ASCII字符,且与ASCII兼容,JavaScript使用的是UTF-16进行编码。

(注:代码点,后文也有称码点

学到这里,我们大概就已经能够解释这小节开头提到的面试题了。

因为JS采用的是UTF-16编码,如果你对正则表达式足够熟悉的话,你应该能记得住正则表示的匹配汉字的范围:[\u4e00-\u9fa5],所以,"你"这个汉字是常用的汉字,肯定是在这个范围内的,1个代码点就能表示,因此:'你'.length的值是1,而表情符号不常用,它使用一对代理对来编码,每个代理项占用1个代码点,因此,'😁'.length为2。

我们可以用代码来证明这个结论:

js 复制代码
const smile = '😁';
console.log(smile.charCodeAt(0),smile.charCodeAt(1))
// 55357 56833
const you = '你';
console.log(smile.charCodeAt(0),smile.charCodeAt(1))
// 20320 NaN

不仅这样,我们还可以把这个emoji给拼出来。

js 复制代码
const smile = '\ud83d\ude01';
console.log(smile)
// 😁

前端处理字符串需要注意的问题

在上面我们已经看到了,JS采用UTF-16编码,因此有些字符用的是2个代码点来存储,那这就可能存在被截断的问题了,正则匹配的时候,我们某些匹配的场景就可能不符合预期了。

正则表达式处理需要注意的问题

注:以下几个点摘录自阮一峰老师的网络博客。

正则的扩展 - ECMAScript 6入门 (ruanyifeng.com)

ES6 对正则表达式添加了u修饰符,含义为"Unicode模式",用来正确处理大于\uFFFF的 Unicode 字符。也就是说,会正确处理四个字节的 UTF-16 编码。

(1)点字符

点(.)字符在正则表达式中,含义是除了换行符以外的任意单个字符。对于码点大于0xFFFF的 Unicode 字符,点字符不能识别,必须加上u修饰符。

js 复制代码
const s = '𠮷';
// s.length 为 2
/^.$/.test(s) // false
/^.$/u.test(s) // true

上面代码表示,如果不添加u修饰符,正则表达式就会认为字符串为两个字符,从而匹配失败。

(2)Unicode 字符表示法

ES6 新增了使用大括号表示 Unicode 字符,这种表示法在正则表达式中必须加上u修饰符,才能识别当中的大括号,否则会被解读为量词。

bash 复制代码
/\u{61}/.test('a') // false
/\u{61}/u.test('a') // true
/\u{20BB7}/u.test('𠮷') // true

(3)量词

使用u修饰符后,所有量词都会正确识别码点大于0xFFFF的 Unicode 字符。

bash 复制代码
/a{2}/.test('aa') // true
/a{2}/u.test('aa') // true
/𠮷{2}/.test('𠮷𠮷') // false
/𠮷{2}/u.test('𠮷𠮷') // true

(4)预定义模式 u修饰符也影响到预定义模式,能否正确识别码点大于0xFFFF的 Unicode 字符。

bash 复制代码
/^\S$/.test('𠮷') // false
/^\S$/u.test('𠮷') // true

上面代码的\S是预定义模式,匹配所有非空白字符。只有加了u修饰符,它才能正确匹配码点大于0xFFFFUnicode字符。

(5)i 修饰符

有些Unicode字符的编码不同,但是字型很相近,比如,\u004B\u212A都是大写的K

less 复制代码
/[a-z]/i.test('\u212A') // false
/[a-z]/iu.test('\u212A') // true

上面代码中,不加u修饰符,就无法识别非规范的K字符。(注:\u212A代表的是开尔文温度的单位,只不过看起来大家都是K,但是实际意义是不一样的)

(6)转义

没有u修饰符的情况下,正则中没有定义的转义(如逗号的转义,)无效,而在u模式会报错。

javascript 复制代码
/,/ // /,/
/,/u // 报错

上面代码中,没有u修饰符时,逗号前面的反斜杠是无效的,加了u修饰符就报错。

加上了u标识符之后,改变了很多正则表达式的行为,在实际项目开发中是否允许用户输入emoji表情确实还是一个值得考虑的问题(比如用户昵称限制10个字符,用户说我输入了5个emoji你们怎么就不让我输入了,你们的程序设计的有bug),这就取决于我们的产品需求了,这是我们作为前端开发者需要知道的问题。

字符串分割需要注意的问题

String.prototype.split() - JavaScript | MDN (mozilla.org)

如果用户一旦输入了码点大于U+FFFF字符,那我们分割的字符的行为就会发生变化,所以,为了避免前端展示出现乱码,也需要针对我们的项目实际进行考量。

结语

本文从我遇到的一个实际项目出发,结合一道面试题向大家阐述了前端开发者需要掌握的字符集与编码的知识点,让向大家阐述了中文编码中为什么出现乱码的更底层原理。

在实际开发中处理base64编码解码务必不再使用atobbtoa这组方法而改用Buffer对象处理,Buffer对象在浏览器端也能通过三方包支持。

在前端开发中,处理用户输入和展示用户的输入是每个前端必须掌握的内容,掌握了这些字符集编码的知识点不至于让我们谈到中文就会联想到乱码的这种恐惧感(如中文做对象的Key),也可以为我们避免一些低级bug,拉低软件的专业性。

总之,掌握本文的内容对我们的实际开发有一定的指导意义,这些知识我们不一定会高频率的用到,但是我们得知道,那我们就好比稳坐钓鱼台了,让bug都离我们远远的。

对于本文阐述的内容有任何疑问的同学可以在评论区留言或私信我。

如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。

相关推荐
掘金者阿豪34 分钟前
把业务数据变成共享仪表盘:Metabase可视化与远程访问实践
前端·后端
kyriewen1 小时前
折腾了半年 AI 编程工作流,最后发现效率瓶颈是桌上那块屏幕
前端·javascript·ai编程
蜗牛前端1 小时前
codex 全流程开发上线的高颜值礼簿小程序
前端·微信小程序
大龄秃头程序员2 小时前
我在图文流 App 里落地双层缓存、弱网降级与 OOM 治理
前端
老王以为2 小时前
React Renderer 分离的多平台架构
前端·react native·react.js
hunterandroid2 小时前
Kotlin Coroutines 与 Flow:让异步任务更清晰
前端
Bigger3 小时前
从零搭建 AI 代码审查服务:一份前端也能看懂的 Python 学习笔记
前端·ci/cd·ai编程
lichenyang4533 小时前
JSAPI、NAPI、Biz、Imp:ASCF Demo 如何真正调用系统能力和 C++ 能力
前端
自由路飞3 小时前
RAG 混合检索深挖:BM25 和向量分数为什么不能直接相加?
面试
lichenyang4533 小时前
IPC、JSVM、UIThread、libuv:ASCF 架构图里最容易混的几个词
前端