[译]JavaScript中Base64编码字符串的细节

本文作者为 360 奇舞团前端开发工程师

本文为翻译

原文标题:The nuances of base64 encoding strings in JavaScript

原文作者:Matt Joseph

原文链接:https://web.dev/articles/base64-encoding  

Base64编码和解码是一种常见的将二进制内容转换为适合Web的文本的形式。它通常用于data URLs,比如内嵌图片。

当你在JavaScript中对字符串应用base64编码和解码时会发生什么?这篇文章探讨了这些细节和需要避免的常见陷阱。

btoa() 和 atob() 函数

JavaScript中进行base64编码和解码的核心函数是btoa()atob()。btoa()用于将字符串转换为base64编码的字符串,而atob()则用于解码。

下面是一个快速示例:

// 一个非常简单的字符串,仅包含低于128的代码点。
const asciiString = 'hello';

// 这将会成功,它将打印:
// 编码后的字符串: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);

// 这也将会成功,它将打印:
// 解码后的字符串: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);

不幸的是,正如MDN文档所指出的,这只适用于包含ASCII字符的字符串,即可以用单个字节表示的字符。换句话说,这对于Unicode来说不起作用。

要理解发生了什么,请尝试以下代码:

// 示例字符串表示了小、中、大代码点的组合。
// 这个示例字符串是有效的UTF-16。
// 'hello' 的代码点都低于128。
// '⛳' 是一个16位代码单元。
// '❤️' 是两个16位代码单元,U+2764 和 U+FE0F(一个心形和一个变体)。
// '🧀' 是一个32位代码点(U+1F9C0),也可以表示为两个16位代码单元的替代对 '\ud83e\uddc0'。
const validUTF16String = 'hello⛳❤️🧀';

// 这将不会成功。它将打印:
// DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
try {
  const validUTF16StringEncoded = btoa(validUTF16String);
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
  console.log(error);
}

字符串中的任何一个表情符号都会导致错误。为什么Unicode会引起这个问题?

为了理解,让我们先退后一步,深入了解计算机科学和JavaScript中的字符串。

Unicode和JavaScript中的字符串

Unicode是当前的全球字符编码标准,它是将数字分配给特定字符的实践,以便在计算机系统中使用。有关Unicode的更深入了解,请访问W3C的文章。

  • h - 104

  • ñ - 241

  • ❤ - 2764

  • ❤️ - 2764 带有一个隐藏的修改编号65039

  • ⛳ - 9971

  • 🧀 - 129472

表示每个字符的数字被称为“代码点”。您可以将“代码点”视为每个字符的地址。在红心表情符号中,实际上有两个代码点:一个用于心形,另一个用于“变化”颜色并使其始终为红色。

深入了解变体选择器的概念。

Unicode有两种常见的方法将这些代码点转换为计算机可以一致解释的字节序列:UTF-8和UTF-16。

一个过于简化的视角是:

  • 在UTF-8中,一个代码点可以使用一到四个字节(每个字节8位)。

  • 在UTF-16中,一个代码点始终是两个字节(16位)。

重要的是,JavaScript处理字符串时使用的是UTF-16。这破坏了像btoa()这样的函数,这些函数实际上是基于这样一个假设:字符串中的每个字符映射到一个单字节。MDN上明确说明了这一点:

The btoa() method creates a Base64-encoded ASCII string from a binary string (i.e., a string in which each character in the string is treated as a byte of binary data).

现在您知道JavaScript中的字符通常需要不止一个字节,下一部分将演示如何处理这种情况下的base64编码和解码。

btoa()和atob()与Unicode

正如您现在所知,抛出的错误是由于我们的字符串包含位于单个字节之外的UTF-16字符。

幸运的是,MDN关于base64的文章包含了一些有用的示例代码来解决这个“Unicode问题”。您可以修改这些代码以适应前面的示例:

// 来自https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem。
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// 来自https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem。
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// 示例字符串表示了小、中、大代码点的组合。
// 这个示例字符串是有效的UTF-16。
// 'hello' 的代码点都低于128。
// '⛳' 是一个16位代码单元。
// '❤️' 是两个16位代码单元,U+2764 和 U+FE0F(一个心形和一个变体)。
// '🧀' 是一个32位代码点(U+1F9C0),也可以表示为两个16位代码单元的替代对 '\ud83e\uddc0'。
const validUTF16String = 'hello⛳❤️🧀';

// 这将会成功。它将打印:
// 编码后的字符串: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// 这将会成功。它将打印:
// 解码后的字符串: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);The following steps explain what this code does to encode the string:
  1. 使用TextEncoder接口将UTF-16编码的JavaScript字符串转换为UTF-8编码的字节流,可通过TextEncoder.encode()实现。

  2. 这将返回一个Uint8Array,这是JavaScript中较少使用的数据类型,是TypedArray的子类。

  3. 将这个Uint8Array提供给bytesToBase64()函数,该函数使用String.fromCodePoint()将Uint8Array中的每个字节作为代码点处理,并从中创建一个字符串,其结果为一个可以全部用单个字节表示的代码点的字符串。

  4. 使用btoa()对该字符串进行base64编码。

解码过程与此相同,但顺序相反。

这有效的原因是,Uint8Array和字符串之间的步骤保证了虽然JavaScript中的字符串是以UTF-16的两字节编码表示的,但每两个字节代表的代码点始终小于128。

这段代码在大多数情况下都工作良好,但在其他情况下会悄悄地失败。

静默失败的案例

使用相同的代码,但使用不同的字符串:

// 来自https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem。
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

//  来自https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem。
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// 示例字符串表示了小、中、大代码点的组合。
// 这个示例字符串是无效的UTF-16。
// 'hello' 的代码点都低于128。
// '⛳' 是一个16位代码单元。
// '❤️' 是两个16位代码单元,U+2764 和 U+FE0F(一个心形和一个变体)。
// '🧀' 是一个32位代码点(U+1F9C0),也可以表示为两个16位代码单元的替代对 '\ud83e\uddc0'。
// '\uDE75' 是代理对中的一半。
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

// 这将会成功。它将打印:
// 编码后的字符串: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// 这也将会成功。它将打印:
// 解码后的字符串: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);

如果您查看解码后的最后一个字符(�)的十六进制值,您会发现它是\uFFFD而不是原来的\uDE75。它没有失败或抛出错误,但输入和输出数据已经悄悄地改变了。为什么会这样?

JavaScript API中的字符串变化

如前所述,JavaScript将字符串处理为UTF-16。但是UTF-16字符串有一个独特的属性。

以奶酪表情为例。这个表情(🧀)的Unicode代码点是129472。不幸的是,16位数的最大值是65535!那么UTF-16是如何表示这个更高的数字的呢?

UTF-16有一个称为代理对的概念。您可以这样想:

  • 对中的第一个数字指定要搜索的“书籍”。这被称为 "surrogate"。

  • 对中的第二个数字是“书籍”中的条目。

您可以想象,有时仅拥有代表书籍的数字而没有实际书籍中的条目可能是有问题的。在UTF-16中,这被称为 lone surrogate

这在JavaScript中尤其具有挑战性,因为一些API尽管存在单独代理也能工作,而其他API则会失败。

在前面的例子中,您在从base64解码回来时使用了TextDecoder。特别是,TextDecoder的默认设置指定了以下内容:

它默认为false,这意味着解码器用替代字符替换格式错误的数据。

您之前观察到的那个�字符,用十六进制表示为\uFFFD,就是那个替代字符。在UTF-16中,带有单独代理的字符串被视为“格式错误的”或“不规范的”。

有各种Web标准(示例1, 2, 3, 4)准确指定了格式错误的字符串何时影响API行为,但值得注意的是TextDecoder是这些API之一。在进行文本处理之前确保字符串格式规范是一个好习惯

检查格式良好的字符串

最近版本的浏览器现在具有用于此目的的函数:isWellFormed().

浏览器支持: isWellFormed().

您可以通过使用encodeURIComponent()来实现类似的结果,如果字符串包含单独代理,则会抛出URIError错误。

以下函数在可用时使用isWellFormed(),如果不可用则使用encodeURIComponent()。类似的代码可用于创建isWellFormed()的polyfill。

// 由于旧版浏览器不支持isWellFormed(),可以快速创建polyfill。
// encodeURIComponent()对于单独代理会抛出错误,这本质上是相同的。
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // 使用更新的isWellFormed()功能。
    return str.isWellFormed();
  } else {
    // 使用较老的encodeURIComponent()。
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

将所有内容整合在一起

现在您已经知道如何处理Unicode和单独代理,您可以将所有内容整合在一起,创建能够处理所有情况并且不会进行静默文本替换的代码。

// 来自https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// 来自https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// 由于旧版浏览器不支持isWellFormed(),可以快速创建polyfill。
// encodeURIComponent()对于单独代理会抛出错误,这本质上是相同的。
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

const validUTF16String = 'hello⛳❤️🧀';
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

if (isWellFormed(validUTF16String)) {
  // 这将会成功。它将打印:
  // 编码后的字符串: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

  // 这将会成功。它将打印:
  // 解码后的字符串: [hello⛳❤️🧀]
  const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
  console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
  // 忽略
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // 忽略
} else {
  // 这不是一个格式良好的字符串,因此我们要处理这种情况。
  console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

这段代码可以进行许多优化,比如将其泛化为一个polyfill,将TextDecoder的参数更改为在单独代理处抛出而不是默默替换,以及其他。有了这些知识和代码,您还可以明确决定如何处理格式不正确的字符串,比如拒绝数据或明确启用数据替换,或者为以后分析而抛出错误。除了作为base64编码和解码的一个有价值的例子外,本文还提供了一个例子,说明仔细处理文本数据尤其重要,特别是当文本数据来自用户生成或外部来源时。

- END -

关于奇舞团

奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。

6b5509e5e1847bcb8bca4f12625b25f4.png

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/175678.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【剪枝】torch-pruning的基本使用

论文:DepGraph: Towards Any Structural Pruning 工程:https://github.com/VainF/Torch-Pruning 算法和库的使用介绍:CVPR 2023 | DepGraph 通用结构化剪枝 1 TP的简介 该算法介绍了DepGraph 如何建模结构化剪枝中的层依赖,实现任…

redis的集群

高可用方案 1、持久化 2、高可用 主从复制 哨兵模式 集群 主从复制: 主从复制是redis实现高可用的基础,哨兵模式和集群都是在主从复制的基础之上实现高可用 主从复制实现数据的多机备份,以及读写分离(主服务器负责写,从服务器…

云HIS系统源码,医院管理系信息统源码,融合B/S版四级电子病历系统

医院管理信息系统是以推进公共卫生、医疗、医保、药品、财务监管信息化建设为着力点,整合资源,加强信息标准化和公共服务信息平台建设,逐步实现统一高效、互联互通的管理系统。 SaaS模式Java版云HIS系统,在公立二甲医院应用三年…

代餐粉产业分析:中国市场销售额增长至116.94亿元

近年来,随着人们生活节奏的加快和健康意识的增强,代餐粉市场规模逐渐壮大。在这个忙碌的时代,快捷、营养而又方便的代餐粉成为了许多人选择的首选。 随着健康理念的不断普及和推广,人们开始更加重视日常饮食的健康与营养。代餐粉作…

Vellum —— 简介

目录 一,介绍 二,原理 三,PBD算法 一,介绍 Vellum是一个解算模拟框架,使用更高级的PBD(XPBD,extended position based dynamics),是2nd Order Integration&#xff08…

Go 实现网络代理

使用 Go 语言开发网络代理服务可以通过以下步骤完成。这里,我们将使用 golang.org/x/net/proxy 包来创建一个简单的 SOCKS5 代理服务作为示例。 步骤 1. 安装 golang.org/x/net/proxy 包 使用以下命令安装 golang.org/x/net 包,该包包含 proxy 子包&am…

2023亿发数字化智能工单,专业管理工单处理全流程,助力企业转型腾飞

伴随着智能化和信息化的不断深入,企业数字化转型势如腾飞。在这个过程中,工单管理成为生产、家电、后勤等多个管理场景下频繁应用的关键环节。如何满足管理方对设备、服务等智能化管理的需求,提升工单管理效率、规范管理流程,并实…

问题:vue2+elementui,tabs切换显示表格并设置表格选中行高亮失败

错误示范: 1.直接setCurrentRow失败(this.currentRow是之前保存的表格当前选中行的数据) this.$refs.table.setCurrentRow(this.currentRow);2.以为是表格没生成就执行了setCurrentRow导致设置不成功,所以使用了this.$nextTick&…

英国国家量子计算中心与IBM签署重要协议!英国进入实用量子时代

​(图片来源:网络) 近日,英国国家量子计算中心(NQCC)与IBM达成了一项重要协议。根据该协议,NQCC将为英国研究人员提供IBM量子高级计划的云访问权限,其中包括IBM的量子计算系统舰队。…

SpringBoot Admin

前言 Spring Boot Admin 是一个管理和监控 Spring Boot 应用程序的开源项目,它提供了一个简洁的 Web 界面来监控 Spring Boot 应用程序的状态和各种运行时指标。Spring Boot Admin 可以帮助开发者快速了解应用程序的状态,并快速定位错误或性能问题。下面…

赛氪荣幸受邀参与中国联合国采购促进会第五次会员代表大会

11 月21 日 (星期二) 下午14:00,在北京市朝阳区定福庄东街1号中国传媒大学,赛氪荣幸参与中国联合国采购促进会第五次会员代表大会。 2022年以来,联合国采购杯全国大学生英语大赛已经走上了国际舞台,共有来自…

HC32L110小华半导体SWD模式切换的问题

在将SWD配置为普通引脚并配置为输出后,如果需要重新配置为SWD,需要将其配置为输入才行,如下: Clk_SetFunc(ClkFuncSwdPinIOEn, TRUE); //配置SWD引脚为普通引脚模式 Gpio_InitIOExt(SWCLK_PORT, SWCLK_PIN, GpioDirOut, TRUE,…

垃圾收集器的种类及概述

1.JVM参数 1.1标准参数所有jdk版本通用参数 -version -help -server -cp 1.2-X参数 非标准参数,也就是在JDK各个版本中可能会变动 -Xint 解释执行 -Xcomp 第一次使用就编译成本地代码 -Xmixed 混合模式,JVM自己来决定 1.3 -XX参数 使用得最多…

一个测试驱动的Spring Boot应用程序开发

文章目录 系统任务用户故事搭建开发环境Web应用的框架Spring Boot 自动配置三层架构领域建模域定义与领域驱动设计领域类 业务逻辑功能随机的Challenge验证 表示层RESTSpring Boot和REST API设计API第一个控制器序列化的工作方式使用Spring Boot测试控制器 小结 这里采用面向需…

悄悄上线:CSS @starting-style 新规则

最近 Chrome 117,CSS 又悄悄推出了一个新的的规则,叫做starting-style。从名称上来看,表示定义初始样式。那么,具体是做什么的?有什么用?一起了解一下吧 一、快速了解 starting-style 通常做一个动画效果…

vue3引入vuex基础

一:前言 使用 vuex 可以方便我们对数据的统一化管理,便于各组件间数据的传递,定义一个全局对象,在多组件之间进行维护更新。因此,vuex 是在项目开发中很重要的一个部分。接下来让我们一起来看看如何使用 vuex 吧&#…

OpenLayers入门,OpenLayers6的WebGLPointsLayer图层样式和运算符详解,四种symbolType类型案例

专栏目录: OpenLayers入门教程汇总目录 前言 本章讲解使用OpenLayers6的WebGL图层显示大量点情况下,列举出所有WebGLPointsLayer图层所支持的所有样式运算符大全。 补充说明 本篇主要介绍OpenLayers6.x版本的webgl图层,OpenLayers7.x和OpenLayers8.x主要更新内容就是webgl…

任意文件下载漏洞(CVE-2021-44983)

简介 CVE-2021-44983是Taocms内容管理系统中的一个安全漏洞,可以追溯到版本3.0.1。该漏洞主要源于在登录后台后,文件管理栏存在任意文件下载漏洞。简言之,这个漏洞可能让攻击者通过特定的请求下载系统中的任意文件,包括但不限于敏…

单链表相关面试题--5.合并有序链表

5.合并有序链表 21. 合并两个有序链表 - 力扣(LeetCode) /* 解题思路: 此题可以先创建一个空链表,然后依次从两个有序链表中选取最小的进行尾插操作进行合并。 */ typedef struct ListNode Node; struct ListNode* mergeTwoList…

C++ Boost 实现异步端口扫描器

端口扫描是一种用于识别目标系统上哪些网络端口处于开放、关闭或监听状态的网络活动。在计算机网络中,端口是一个虚拟的通信端点,用于在计算机之间传输数据。每个端口都关联着特定类型的网络服务或应用程序。端口扫描通常是网络管理员、安全专业人员用来…