为什么 escape 可以使 btoa 正确处理 UTF-8 编码的字符串?

浏览器在 window 上提供了 atobbtoa 两个 API 用于处理 base64 编码的字符串,然而他们不能处理非 Latin1 字符集的文本:

btoa('hello') // "aGVsbG8="
btoa('你好') // error!
// Firefox: InvalidCharacterError: String contains an invalid character
// Google Chrome: Uncaught DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.

然而,经常看到有人会这么处理 base64 编码的字符串:

function encodeBase64(str) {
    return btoa(unescape(encodeURIComponent(str)))
}
function decodeBase64(encoded) {
    return decodeURIComponent(escape(atob(encoded)))
}

令人讶异的是,通过组合 escapeencodeURIComponent 两个函数,可以正确地将字符串编码为 UTF-8 的 base64 编码:

encodeBase64('你好') // 5L2g5aW9
decodeBase64('5L2g5aW9') // 你好

这其中的原理其实并不复杂。

古老的 escape / unescape

escape 和 unescape 是 ECMA-262 第一版标准中定义(P60 15.1.2.4)的 API,是 The global object 下的一个函数,直到现在也还是 ES 标准中的一部分(虽然已经不是 Web 标准了)。这里摘录最新版的 escape 标准 如下:

The escape function is a property of the global object. It computes a new version of a String value in which certain code units have been replaced by a hexadecimal escape sequence.

For those code units being replaced whose value is 0x00FF or less, a two-digit escape sequence of the form %xx is used. For those characters being replaced whose code unit value is greater than 0x00FF, a four-digit escape sequence of the form %uxxxx is used.

为了避免问题变得复杂,我们在这里先只讨论 Unicode BMP(0号平面)的问题。

根据标准定义,每个 code point 在 0x00~0xff 的(非字符数字、@*_+-./ 的) Unicode 字符将被编码为 %xx,其中 xx 为这个字符的码点 16 进制的表示;而每个在 0x0100 ~ 0xffff 之间的 Unicode 字符都会被编码成 %uxxxx。这在 0x00~0xff 区间和我们所熟悉的 URL 百分号编码(RFC3986)有些相似——都是 %XX 的形式;而在更广的范围内编码结果并不是我们所熟悉的样子。

举个例子:

escape(',') // %2C
encodeURIComponent(',') // %2C

当然,unescape 就是上述规则的反向,就不再赘述。

encode / decodeURIComponent

encodeURIComponent 相对 escape 可是年轻多了,它是在 ES3 被加入标准的。这个 API 和 escape 的最大不同在于,它将 JavaScript 字符串进行 UTF-8 编码,再进行百分号编码。例如一个汉字:

escape('你') // % u4F60
encodeURIComponent('你') // %E4%BD%A0

可以看到,两个编码截然不同,encodeURIComponent 并不是简单的将 % u4F60 拆解为 %4F%60,而是引入了另一套规则。要解释其中的差别,我们就不得不了解字符的“编码”。

编码?

一个 Unicode 字符(更确切地说,已编码字符)的表达方式多种多样。比如我们上文中提到的汉字“你”,它的字符码点(可以理解为编号)是 U+4F60,也可以简单理解为它是 Unicode 的第 20320 个字符。但在计算机中需要对字符进行存储时,则不得不将它编码。常见的编码有 UTF-8, UTF-16 等;而我们的 JavaScript 使用的是一个类似 UTF-16 的编码——UCS-2。UCS-2 可以视为 UTF-16 的前身(或者讲,子集),在我们没有引入码点大于 0xffff 的字符时,可以视为它就是 UTF-16。

UCS-2 表达一个字符时,直接使用 16 位存储它的 Unicode 码点。例如,刚刚我们看到,escape('你') 得到的编码 % u4F60 正是“你”这个字符的码点 U+4F60。

而 UTF-8 作为变长编码,会更加复杂一些。UTF-8 使用 1~3 个字节来表达其 BMP 平面(前面说过了,不讨论别的平面的事情)的所有字符,其编码规则可以参考UTF-8 的编码方式一文。对于汉字“你”,UTF-8 编码后的字节序列为 E4 BD A0,经过百分号编码就变成了 %E4%BD%A0 ,十分合乎逻辑。

Latin-1

那么我们这里又要说说 Latin-1 这个编码了。Latin-1 正式的名字叫 ISO/IEC 8859-1:1998,这个标准使用了 ASCII 留下的 128 个空位,加入了一堆西欧的字符——可能那时候的人类认为有英文字母再加上西欧字母就足够用了吧。通常我们说的 Latin-1 编码,其实还包括了所谓 C0 / C1 控制字符,所以下文说 Latin1 其实是指这个包括了控制字符的字符集。总之,Latin1 加上 C1 控制字符,刚好够把一个字节从 0x00~0xff 一共 256 种可能性全数填满。

这样一种一个字节的所有可能性全部填满的编码,有一个显而易见的特性:任意字节序列都是合法的 Latin-1 编码的字符串,说人话就是,啥都可以被解读成 Latin-1,即使看起来是乱码。

说回 atob

普及了这么多编码常识,终于可以回到重点了。让我们先来看看 Unicode 的编码区块:其 0x00 ~ 0x7F 和 ASCII 一致,而 0x80 ~ 0xFF 和 Latin-1 一致;而 Latin-1 的 0x00 ~ 0x7F 和 ASCII 又一致,那么可以得出结论:Unicode 的前 256 个字符,和 Latin-1 的字符集完全一致!

而 btoa 这个 API 是基于 Latin-1 字符集处理的;它将字符串中的每个字符,转换成 Latin-1 编码,然后按字节对其进行 base64。

这样一来,我们就有思路从 UTF-8 转化到 Base64 了:

  1. 先用 encodeURIComponent 把 js 的字符串转成 UTF-8 的百分号编码形式;
  2. 再用 unescape 把百分号编码按字节转化成对应的含有 Latin-1 字符集字符的 js 字符串 (即使它是乱码);
  3. 最后用 btoa 把只含有 Latin-1 的 js 字符串转换成 Base64 编码。

回过头来看看文章开头的代码,和这个思路一样嘛!

小结

其实说实在的,这个小技巧成功的主要原因还是多亏了 encodeURIComponent;它会按照 UTF-8 编码来处理字符串,给我们带来了很大的操作空间。事实上,MDN 的样例代码就没有使用 unescape,而是使用了正则和 String.fromCharCode 来将百分号编码转换为 Latin-1 字符。在这个页面上,也有一些其他的解决方案能够达到同样的问题。

写这篇文章主要是想讲一些和编码相关的事情,可能平时大家不太在意,或者懂个大概,希望能够比较详细的讲解一下。

另外,为了方便讨论起见,本文仅仅讨论了字符串中只含有 BMP 平面的情况。事实上,由于种种原因,即使字符串中包含了非 BMP 平面的字符,本文的所有结论依然成立——主要还是因为 encodeURIComponent 会按类似 UTF-16 的方式去解析 js 底层实际上是 UCS-2 的字符串,并按 UTF-8 编码起来。

扩展阅读:UTF-8 遍地开花 宣言

Safari 下同页面创建的视频超过 16 个则无法播放

最近在做一些视频相关的东西。在 Safari 下发现这样一个问题:<video> 标签创建多了,会导致同一个页面内后续的视频都无法播放。不管是 iOS 的 Safari,还是 macOS 下的,都存在同样的问题。尤其是 iOS 下,超过 16 个的视频几乎必定无法播放;效果就是显示一个播放按钮并且有一条斜线。

无法播放的视频截图

通过 video.error来检查最后的错误,得到的是 MEDIA_ERR_DECODE;同时,video.play() 方法会 reject 一个 AbortError。在 Google 检索了很久也没有得到有用的信息。

为了演示这个 bug,一个简单的示例的源码被我放在了 gist 上。你可以通过在 Safari 上打开 RawGit 提供的链接 来测试这个 bug。你只需要点击“测试”,并在视频开始播放后再次点击“测试”,如此循环 16 次,便能得到和我上一张图片一样的结果。

示例的代码如此简单,以至于我认为这个 bug 是几乎不可能发生的;它仅仅是调整了一个 DOM 的 innerHTML 来创建一个 video 元素(事实上无论通过何种方法创建,都会有这个问题;playsinline 属性似乎是必要的),然后再删除它,循环 16 次而已。

hello.innerHTML = '';
var src = 'https://cdn.rawgit.com/mediaelement/mediaelement-files/4d21a042/big_buck_bunny.mp4';
hello.innerHTML = '<video id="video" webkit-playsinline playsinline controls src="' + src + '">';
setTimeout(function () {
    document.getElementById('video').play();
}, 500);

目前,我没有发现任何能够解决这个 bug 的方法。可能唯一的办法就是,播放 15 个视频之后,刷新一下整个页面吧。

使用 InstantClick 时 piwik 、 MathJax 的配置

InstantClick 是一个利用 hover 和 click 的时间差,预加载网页的黑科技。原理主要是在 hover 的时候预读网页,然后点击的时候就替换整个页面,让你的网站看起来整个都 PJAX 过~很酷炫。

不过造成的问题也很显然:换页的时候很多统计工具会失效;像我博客用的 piwik 就无法统计到页面的切换。同样,MathJax 这类渲染页面的插件也失效了。

解决方法很简单,只需在初始化之前,监听 InstantClick 对象的 change 事件,在事件中处理统计和渲染操作。代码如下:

InstantClick.on('change', function() {
    //piwik
    _paq.push(['setDocumentTitle', document.title]);
    _paq.push(['trackPageView']);
    //mathjax
    MathJax.Hub.Queue(["Typeset",MathJax.Hub]);
});
InstantClick.init();

Enjoy ~

innerTEXT,outerHTML,outerTEXT和firefox

Firefox 不支持 DOM 对象的 outerHTML innerText outerText 属性……这是个一直困扰大家(包括我)很久的问题,网上也有不少的解决方法,这里摘录如下,收藏备用。(源链接:http://www.w3help.org/zh-cn/causes/SD9017)

解决方案

在 Firefox 中,可通过扩展 HTMLElement 的原型 (prototype) 来实现相关属性。

  1. 扩展 Firefox 中 DOM 元素的 outerHTML 属性:

    if (typeof(HTMLElement) != "undefined") {
       HTMLElement.prototype.__defineSetter__("outerHTML", function(s) {
            var r = this.ownerDocument.createRange();
            r.setStartBefore(this);
            var df = r.createContextualFragment(s);
            this.parentNode.replaceChild(df, this);
            return s;
        });
       HTMLElement.prototype.__defineGetter__("outerHTML", function(){
            var a = this.attributes, str = "<" + this.tagName, i = 0;
            for (; i < a.length; i++)
                if (a[i].specified)
                    str += " " + a[i].name + '="' + a[i].value + '"';
            if (!this.canHaveChildren)
                return str + " />";
            return str + ">" + this.innerHTML + "</" + this.tagName + ">";
        });
    
        HTMLElement.prototype.__defineGetter__("canHaveChildren", function(){
            return !/^(area|base|basefont|col|frame|hr|img|br|input|isindex|link|meta|param)$/.test(this.tagName.toLowerCase());
        });
    }
  2. 扩展 Firefox 中 DOM 元素的 innerText 属性:

    if (!!document.getBoxObjectFor || window.mozInnerScreenX != null) {
        HTMLElement.prototype.__defineSetter__("innerText", function(sText) {
            var parsedText = document.createTextNode(sText);
            this.innerHTML = "";
            this.appendChild(parsedText);
            return parsedText;
        });
        HTMLElement.prototype.__defineGetter__("innerText", function() {
            var r = this.ownerDocument.createRange();
            r.selectNodeContents(this);
            return r.toString();
        });
    }
  3. 扩展 Firefox 中 DOM 元素的 outerText 属性:

    if (!!document.getBoxObjectFor || window.mozInnerScreenX != null) {
        HTMLElement.prototype.__defineSetter__("outerText", function(sText) {
            var parsedText = document.createTextNode(sText);
            this.parentNode.replaceChild(parsedText, this);
            return parsedText;
        });
        HTMLElement.prototype.__defineGetter__("outerText", function() {
            var r = this.ownerDocument.createRange();
            r.selectNodeContents(this);
            return r.toString();
        });
    }