TypeScript类型兼容

先看代码:

let x = { a: 1 };
let y = { a: 1, b: 2 };
x = y; // OK
y = x; // Error

let xx = (a: number) => 0;
let yy = (a: number, b: number) => 0;

xx = yy; // Error
yy = xx; // OK

一开始看文档我很疑惑: 为什么对象允许携带额外属性赋值,而不允许缺少属性,而函数赋值则反之

结合实际使用场景我们很快就能发现这么做的好处,先讲函数,以Array.prototype.map为例,其回调函数的签名为:

 (value: any, index: number, array: any[]) => {}

而很多场景下,我们只需要用到回调函数的第一个参数,所以我们更期望是这样来调用:

[].map((item: any)=>{})

而不是每次都要带上多余的index与array参数。

更准确来讲,我们作为函数参数使用方,选择性使用传入的参数是安全的,所以函数赋值允许忽略额外参数。

而对象则不一样,我们作为对象提供方,赋值给其他类型,被赋值的类型可能在任何场合被我们不可控的计算过程所使用,如果缺少了必要的属性可能会出现不安全的取值。

记一次anti anti debug

去年在微博上看到某前端大佬提供的一种检测用户是否打开控制台的方式,后面自己也去探索了一种方式,同时也发现在StackOverflow上有关于这个话题的讨论。不过这些方式后面都失效了。

今天偶然打开了一个视频网站,好奇按了下F12,发现浏览器彻底卡死了,Chrome自带的控制台都无法被打开了,心中瞬间冒出两个疑点:
1. 他是通过什么新的方式知道我打开了控制台
2. 他是通过什么方式让我的浏览器挂掉的,chrome不是多进程的吗?我们代码里面出现死循环都只是这个标签死掉,完全可以打开Chrome的进程工具结束掉这个标签的进程

在不停重启浏览器,再打开这个网站开启控制台的过程中发现了问题出在地址栏,一旦我打开控制台,地址栏便由a.com变成了a.com/0123456789101112....然后浏览器就挂掉了。

那第二个疑惑解除了:原来Chrome的网址不停增长会导致浏览器主进程挂掉。

不刷新而改变了地址栏,那应该是用了history.pushState

先实验一把,打开浏览器输入网址,不着急打开控制台,直接在地址栏输入:javascript:history.pushState = function(){}回车,以覆盖pushState实现,然后打开控制台,果然没有再卡死。

然后在其引入的js代码中搜pushState关键字,找到关键代码:

eval(function(e, t, n, r, o, i) {
        if (o = function(e) {
            return e.toString(20)
        }
        ,
        !"".replace(/^/, String)) {
            for (; n--; )
                i[o(n)] = r[n] || o(n);
            r = [function(e) {
                return i[e]
            }
            ],
            o = function() {
                return "\\w+"
            }
            ,
            n = 1
        }
        for (; n--; )
            r[n] && (e = e.replace(new RegExp("\\b" + o(n) + "\\b","g"), r[n]));
        return e
    }("1 2=c.3('8');4.b(2,'5',{6:7(){1 a=\"\";9(1 i=0;i<d;i++){a=a+i.e();f.g(0,0,a)}}});h.j(2);", 0, 20, " var x createElement Object id get function div for  defineProperty document 1000000 toString history pushState console  log".split(" "), 0, {}))

去掉eval,我们即可得到源码:

var x = document.createElement('div');
Object.defineProperty(x, 'id', {
  get: function() {
    var a = "";
    for (var i = 0; i < 1000000; i++) {
      a = a + i.toString();
      history.pushState(0, 0, a)
    }
  }
});
console.log(x);

看来检查控制台打开的原理还和原来失效的new Image差不多,控制台打开后,会自动触发一些通过console输出的标签的getter,然后在getter中就可以为所欲为了。

至此两点疑虑解开。

不过,他都这么做了,完全可以防得更彻底:

// 页面一加载就先备份
var p = history.pushState.bind(history)
// 检测到控制台被打开再做地址栏填充
 var a = "";
for (var i = 0; i < 1000000; i++) {
    a = a + i.toString();
    p(0, 0, a)
}

这样,我最开始通过地址栏直接覆盖原生API验证想法的路子就行不通了。

不过,这还是太小儿科了,太简单粗暴的告知调试者:老子知道你在调试我的网页了,我现在要原地爆炸。

而猥琐流的做法应该是:发现后偷偷打下标记,然后埋雷,在一些本来应该走a分支的地方走到b分支,一些关键的中间数据也故意做处理,时不时来个大的循环卡几秒钟….

当然,这些方式都不是我创的,参考EtherDream大佬的前端加密与混淆 ,当时看完一阵感慨:原来反调试的套路原来这么多。

一种探测你近期是否访问过指定网站的方式

html的a标签有一个特性,当用户访问过其href指向的链接后,在任意其他网站出现一个a标签,链接也是一样的话,显示的标签内的内容颜色会跟默认的颜色不一样。当然,开发者也可以通过css的伪类选择器指定其为别的颜色:

a:visited {color: #00FF00}

那么,我们是不是可以在自己的网站插入一堆的隐藏a标签,诱导某用户访问,然后使用js去获取各个a标签颜色的值就能知道该用户是否访问过对应的网站呢?

实际上,在过去几年前,确实是可以的,但后面由于该特性泄露用户隐私而被浏览器给处理了,任何时候通过window.getComputedStyle(someATage).color拿到的值都是与用户访问前一样。那么我们还有没有方式可以达到类似的效果呢?

最近研究http缓存的时候,突然想到,用户既然访问过某网站,而某网站又配置了静态资源缓存的话,那么我们是不是可以通过判断用户加载指定资源的时长来大致确定用户之前是否访问过某网站呢?

思路:

  1. 找一个某网站首页加载的静态资源,越大越好,比如知乎的main.app.js
  2. 诱导用户访问evail.com,用户打开后,尝试创建script标签去加载该静态资源,并记录加载时长a
  3. 再次创建script标签,依然去加载该静态资源,并记录加载时长b
  4. 计算a-b的值,即可大致判断用户是否曾经(近段时间内)是否打开过知乎了
async function iKonwYouVisitedTheWebsite(resourceSrc){
      function getLoadTime(){
        return new Promise(r=>{
          const script = document.createElement('script')
          const start = Date.now()
          script.onload = () => {
            document.body.removeChild(script)
            r(Date.now() - start)
          }
          script.src = resourceSrc
          document.body.appendChild(script)
        })
      }
      const t1 = await getLoadTime()
      const t2 = await getLoadTime()
      return t1 - t2
}
  iKonwYouVisitedTheWebsite('https://static.zhihu.com/heifetz/main.app.a662d8a4162fcbc4916e.js').then(t=>{
    document.write(t>250 ? `您没上过知乎:${t}` : `您上过知乎:${t}`)
  })

互联网上随时可能泄露自己的隐私,浏览器隐私模式保平安。不是开发者不防,有时候连开发者都不知道某些机制会泄露用户隐私。举一个我最近研究跨域想到的例子:

如果我们的静态资源需要授权的话(比如有权限则正常响应200,无权限响应401之类的),那么用心人利用在evail.com插入一个img标签嵌入该图片,即可通过图片是否加载成功刺探到用户是否在该网站有注册。

斐波拉契数列计算之从递归到记忆到动态规划的演进

递归版本:

function fib(n) {
  if (n <= 1) return 1;
  return fib(n - 1) + fib(n - 2);
}

这样做的问题在哪呢? 简单粗暴的实验就是将fib(100)丢去执行基本就卡在那算不出来了,实际我们以fib(4)为例,看看是怎么计算的:
https://s2.ax1x.com/2019/11/24/MLFxfg.md.png
fib(2)被计算了2次,fib(1)被计算了4次,当计算规模变大时,重复的计算会越来越多,如果我们能将计算过得结果保留下来,下次直接使用,可以节省不少计算时间。

记忆递归版本:

function fib(n, arr = [1, 1]) {
  if (n <= 1) return 1;
  // 如果结果已经被保存则直接返回
  if (arr[n]) return arr[n];
  // 新增保存记录,并返回
  return (arr[n] = fib(n - 1, arr) + fib(n - 2, arr));
}

记忆优化去递归版本(时间O(n) 空间O(n)的动态规划):

function fib(n) {
  if (n <= 1) return 1;
  let arr = [1, 1];
  for (let i = 2; i <= n; i++) {
    arr[i] = arr[i - 1] + arr[i - 2];
  }
  return arr[n];
}

继续优化(时间O(n)空间O(1)的动态规划):

function fib(n) {
  if (n <= 1) return 1;
  let [a, b] = [1, 1];
  for (let i = 2; i <= n; i++) {
    [b, a] = [a + b, b];
  }
  return b;
}

当然,这是最简单的动态规划应用,后续继续学习背包问题、最长上升子序列、最短路径等问题。

Koa洋葱模型的另种实现

洋葱模型如下图:
onion model

middleware1而言,其next就是一个函数,返回middleware2的执行结果(一个promise对象),同理middleware2next就是一个函数,其返回middleware3的执行结果(又是一个pormise对象)…以此类推。

用函数表示为:middleware1(()=>middleware2(()=>middleware3(...))),类似函数式编程中的compose,同步情况下的简单写法就是这样:

function compose(middlewares){
    // 期望返回一个层层包裹的middleware函数
    // 这个函数接收一个next函数
    return middlewares.reduce((last, cur) => next => last(() => cur(next)));
}

// 这样就完成了一个同步的模型,测试一下

compose([
  function(next) {
    console.log("before a");
    next();
    console.log("after a");
  },
  function(next) {
    console.log("before b");
    next();
    console.log("after b");
  }
])(() => {});
/**
before a
before b
after b
after a
*/

基于上面这个基本版,稍加改造处理一些边界情况即可:

function compose(middlewares) {
  if (!Array.isArray(middlewares)) throw new TypeError('Middlewares must be an array!');

  middlewares.forEach(item => {
    if (typeof item !== 'function') throw new TypeError('Middleware must be componsed of function');
  });

  const noop = () => {};
  // next=noop work for 0 middleware
  const emptyMiddleware = async (_, next = noop) => next();

  // 保证在一个中间件中next只被调用一次
  const guard = ctx => next => {
    let runed = false;
    // next 返回promise,包装一层依然要返回promise
    return async () => {
      if (runed) throw new Error('next() should not be called multiple times in one middleware!');
      runed = true;
      // 这里保证 middlewareChain(ctx,middleware) 正常
      return next(ctx, noop);
    };
  };

  return middlewares.reduce(
    // middlewareChain可能没被传入next
    (last, cur) => (ctx, next = noop) =>
      // 不看guard的话很好理解  last(ctx,() => cur(ctx,next))
      // 白话就是 前一个middleware 的参数为一个函数 返回当前middleware的执行结果
      // 而当前middleware的执行需要传入next
      last(
        ctx,
        guard(ctx)(() => cur(ctx, guard(ctx)(next)))
      ),
    emptyMiddleware
  );
}

使用:

compose([
    async (ctx,next)=>{
        console.log('before a')
        await next()
        console.log('after a')
    },
    async (ctx,next)=>{
        console.log('before b')
        await next()
        console.log('after b')
    }
])()

/**
before a
before b
after b
after a
*/

已跑通全部用例: https://github.com/koajs/compose/blob/master/test/test.js

某数二进制中包含1的个数计算

这是一个来自社区的题目,表述如下:

实现一个函数countOne,计算给定的参数数值(自然数)的二进制包含“1”的个数,如:

  • countOne(10) => 2
  • countOne(16) => 1
  • countOne(3) => 2

如果不让使用 Number.prototype.toString 又该如何实现呢?

丢到公司技术群里沉没了,周末了自己来写下思考过程,实际上自己思考过程中不会这么顺利,写这篇文章只是为了梳理对比自己思考的过程,所谓:思考思考的过程。能帮助自己之后碰到一些问题能快速的切中要点而得出解法。


分割线


捷径

以10进制的27为例,二进制形式为: 11011,我们肉眼可以很快得出含有四个1,那么如何让计算机数出来呢?
当然,最容易想到的就是:

num.toString(2).replace(/0/g,"").length

当不让使用Number.prototype.toString的时候,我们依然最先想到的自己实现一个十进制转二进制的函数。然后按上面的方法来计算1的个数.

那么有没有更好的方式呢?

分析

我们分析下11011这个二进制数,如何一个个数1呢?试想一下,如果我们能通过某种方式一次性去掉一个1,直到这个数最终变为0,我们统计消除1的次数不就好了嘛?

我们先复习下几个位运算符:

  1. 与运算 &
  • 1 & 0 => 0
  • 1 & 1 => 1
  • 0 & 0 => 0
  1. 或运算 |
  • 1 | 0 => 1
  • 1 | 1 => 1
  • 0 | 0 => 0
  1. 异或运算 ^
  • 1 ^ 0 => 1
  • 1 ^ 1 => 0
  • 0 ^ 0 => 0

眼尖的小伙伴已经注意到上面的每种位运算符的三种运算情况,只有两个符合我们的需求(成功把前面的1变成了0):

1 & 0   // 0
1 ^ 1  // 0

尝试

我们注意力重新回到11011这个二进制数上来,我们需要挑一个怎样的数字来应用上面的两种运算符号与11011做运算从而消除一个1呢?

  1. 先分析异或运算

1.1 如果我们要消除第一个1

`11011` ^ X   => `01011`    (注意是二进制运算,不是十进制的11011)

我们很容易可以写出X应该为:10000

1.2 同理,我们要消除最后一个1

`11011` ^ X  => `11010`

依然很容易得出X应该为:00001 (为了方便你肉眼对齐验证,估计补了4个0)

无论我们是为了从前到后消除1而需要的二进制数10000,还是为了从后到前消除1而得到的二进制数1都与我们原来的数11011好像并没有什么明显的关联。

注意,我这里用了明显二字,而你要分析的话,还是有那么些规律的,比如我们多换几个数:

原数 左->右 右->左
10101 10000 101
10101010 10000000 11
111000 100000 1111
10000 10000 11111

从左至右消1的那个数与原数还是有关系的,那么我们的问题就会变为如何从诸如11011得到10000的问题了,其实也不难,但总归是麻烦的,我们不妨先按同样的套路分析下与运算,看看会不会更简单。

2.与运算分析

2.1 如果我们要消除第一个1

`11011` &  X   => `01011` 

我们很容易可以写出X应该为:0101101111

2.2 同理,如果我们要消除最后一个1

`11011` & X  => `11010`

依然很容易得出X应该为:1101011110

眼尖的小伙伴应该已经发现了,要通过与运算消除最后一个1用到的数11010与原数11011不是亲兄弟嘛(原数减1)。

我们用这个规律多验证下试试:

`11010` & `11001` => `11000`
`11000` & `10111` => `10000`
`1000` & `0111` => `0000`

再试下:

`1110010` & `1110001` => `1110000`
`1110000` & `1101111` => `1100000`
`1100000` & `1011111` => `1000000`
`1000000` & `0111111` => `0000000`

是不是完美?

Coding

function countOne(num){
  let count = num > 0 ? 1 : 0
  while(num = num & (num - 1))count++
  return count
}

真正诠释了分析一大堆,代码两三行。其实无论是解决实际问题,还是做题,真正有意思的是过程,分析的过程,而非这个结果。

基于webpack构建项目的SourceMap(伪)最佳实践

SourceMap是什么

像C++、OC等语言的编译器,在编译的时候会生成符号文件,对外无需发布这些符号文件,而当有异常上报或本地Debug二进制文件时,可以帮助开发人员将二进制尽可能还原成源码级别(好像无法完全还原)进行调试或进行错误分析。

而前端工程中的SourceMap也是类似的功能。我们构建前端工程时会做这么多动作:

  • 各类型文件处理,如vue、jsx、less、ts等
  • es6+ 转 es5
  • 代码压缩
  • 代码混淆

经过这些处理后,代码往往被处理得面目全非了,变量名也变成了无意义的字母,感受下:

点击查看大图

被处理成这样子了,要调试是很困难的,而SourceMap就是在编译阶段由构建工具生成的源码与目标代码的对照表,所以,我们可以通过SourceMap将打包后的代码完美还原为源代码:
点击查看大图

既然SourceMap有这么强大的作用,我们必然不能将其暴露到生产环境,不然我们辛辛苦苦开发的功能会被有心人直接下载,稍加修改就改头换面上线了。这也就是为什么会有本文章的原因。至于SourceMap的原理,大家可以参考阮一峰的JavaScript Source Map 详解

webpack下的SourceMap

webpack项提供了devtool选项来配置是否需要在构建的时候生成SourceMap,需要的话生成何种SourceMap。

咦,SourceMap不就是那个什么映射文件么,怎么在webpack这边还分品种了呢?实际上,不同的人对构建速度、SourceMap对编译后代码的还原程度、SourceMap的安全问题都有不同的要求,为了满足不同人的要求,同时又降低配置难度,webpack只给devtool提供了13个单选值,开发者根据需求选择就好了(说得好轻松呀:只有13个):
img

该用哪个?我好方!我好南!

不过其实静下心来仔细看看说明,搞清三个概念,再配合你对构建速度的要求、安全问题就能很快抉择了:

以SourceMap对js文件的还原度为例

  • 打包后的代码: 就是最开始那张截图,基本无法调试
  • 生成后的代码:经webpack处理了模块化依赖、经babel等loader处理了es6+转es5的代码,但保留了模块信息
  • 转换过的代码:经babel等loader处理了es6+转es5的代码,还未处理模块化(可以看到import之类的语法)
  • 原始源代码:和我们开发中的源代码一致,还原度非常高
  • 仅限行:由于SourceMap中没有列信息,所以调试只能在行级别,无法进行单行多表达式调试

记住上面几个概念,我们就能很快通过查表确定自己需要哪种SourceMap了。而我们若想通过这些配置项名称快速决定使用哪种SourceMap,请继续看。

我们仔细对比可以发现:

  • 名字中带eval的(重新)构建速度一般比其他的快
  • 名字中带cheap的没有列信息,只能进行行调试,同类型SourceMap带cheap比不带cheap速度要快一些
  • 名字中带module都能映射原始源代码
  • 名字中带inlineeval的都不能用于生产环境
  • 还原度最高的是source-mapeval-source-maphidden-source-map

注:带inlineeval的不能用于生产环境是因为这两者生成的SourceMap是内嵌在构建完成的js代码中的,会在生产环境直接暴露源代码。

怎么选?

开发环境,追求重新构建速度,同时也要高度还原代码,可选:eval-source-mapcheap-module-eval-source-map,这二者可以自己抉择,后者速度更快、生成的包体积更小,但无法进行行内调试。

生产环境,又要安全,又要高还原度,不在乎打包速度,那么可选source-maphidden-source-map

生产环境的SourceMap

为什么在生成环境需要SourceMap呢?试想下,如果生成环境出现什么Bug,而在其他环境不容易复现,最简单的方式就是直接在生产调试,如果没有SourceMap,这个调试过程势必十分痛苦。

上面我们提到了,生产环境可选的两种配置,他们的区别在于:source-map会在构建后的js文件末尾添加类似:

//# sourceMappingURL=jquery.min.map

的注释,告知浏览器该文件对应的SourceMap在何处下载,而hidden-source-map则仅仅生成SourceMap,不会告知浏览器该去哪寻找SourceMap还原代码。这里我们面临两个问题:

  1. 选择source-map,我们的SourceMap路径会暴露在外面,如果我们的浏览器能下载,那么别人一样可以下载
  2. 选择hidden-source-map,该怎么告诉浏览器我们的SourceMap在何处呢?

针对问题2,Chrome确实提供了针对单个js文件手动导入SourceMap的方式,方式是在开发者工具的Sources面板,找到需要调试的js文件,右键代码区域,在弹出的菜单中点击Add source map...,然后填入这个文件对应的SourceMap文件地址,弊端就是:得针对构建完成的文件一个个手动添加,而且一刷新就没了,需要再次手动添加。

ps: 实际上hidden-source-map更多的场景是用于类似于sentry这样的异常监控平台,在监控到线上的异常后上传对应的SourceMap文件,精确分析错误。

针对问题1,我们可以在构建后将SourceMap上传至一个仅内网可访问的地方,这样,同样打开控制台,我们自己的人可以进行源码调试,而其他人则没办法。但是,这样就没问题了嘛?没问题说明你的日子还是太安逸啊,居然没有在休假的时候被打电话告知线上出现了一个bug,需要紧急定位一下,这时候,我们用VPN拨号回公司确实可以解决问题,那么还有没有其他方式呢?

只要努力去想,方法的数量总是高过问题的。

我这里借助gitlab来实现,相信很多公司都部署了私有的gitlab,同时为了方便小伙伴在任何地方都方便提交代码,是允许外网授权访问的。gitblab中的项目权限有一项:“内部”,也就是仅内部员工登录后可查看,这就符合我们的需求了。
图片

借力gitlab

心路历程:其实我一开始是去尝试用gitlab的代码片段(类似于GitHub的gist)来做这个事情,但是后面发现其比gist的功能要弱很多,只支持单文件,只好放弃。

依据我们的需求,webpack提供的几种SourceMap已经无法满足我们的需求了,需要通过SourceMapDevToolPlugin插件来定制需求,该插件实现了对SourceMap生成内容进行更细粒度的控制,说白了,上面预制的一些选项都可以通过该插件配置而来。

先来确定下我们的需求:

  • 完整的SourceMap
  • SourceMap地址需要以自定义地址的形式注释在js文件的末尾

SourceMapDevToolPlugin很容易达成该要求:

new webpack.SourceMapDevToolPlugin({
    append: `\n//# sourceMappingURL=[url]`,
    filename: '[file].map',
    publicPath: 'https://gitlab.xxx.com/xxxx/sourcemap/raw/dev',
    fileContext: 'js',
    include: /\.jsx?$|\.vue$/,
})

这样,我们构建完成的js文件最后都会带上诸如如下的SourceMap路径注释了:

//# sourceMappingURL=https://gitlab.xxx.com/xxxx/sourcemap/raw/dev/xxxx.map

另一边,我们还需要将map文件给提交到项目xxxx/sourcemapdev分支才行,要拿到SourceMap文件并上传,需等到资源构建完成或者webpack的整个流程完成,我们这里选择用插件的形式在资源构建完成的时候push至gitlab,先完成插件:

const path = require('path');
const fs = require('fs');

class SourcemapWebpackPlugin {
  constructor(handler) {
    if (typeof handler !== 'function')
      throw new TypeError('SourcemapWebpackPlugin构造函数参数只能是函数');
    this.handler = handler;
  }

  apply(compiler) {
    // 监听`afterEmit`事件
    compiler.hooks.afterEmit.tapAsync('SourcemapPlugin', (compilation, callback) => {
     // 取得所有构建出来的资源
      const assets = compilation.assets;
      const files = [];
      Object.keys(assets).forEach(fileName => {
         // 我们只care map文件
        if (/\.map$/.test(fileName)) {
        // 拧出文件完整路径、文件名、文件内容
          files.push({
            path: assets[fileName].existsAt,
            name: path.basename(fileName),
            // 不用到的时候不要取出来
            get content() {
              return assets[fileName].source();
            },
          });
        }
      });
      // 丢给插件使用者自行做上传处理
      this.handler(files, err => {
        // map文件清理,防止发布到线上
        files.forEach(file => fs.unlink(file.path, () => {}));
        callback(err);
      });
    });
  }
}

gitlab文件的push需要用到这个API,然后事情就简单了:

const got = require('got');
const qs = require('qs');

new SourcemapWebpackPlugin((files, callback) => {
    console.log('开始上传SourceMap...');
    const actions = files.map(file => ({
    action: 'create',
    file_path: file.name,
    content: file.content,
  }));
  const data = {
    actions,
    // 事先创建一个空白的master分支,然后在此基础上分出dev
    // 以后每次都是以master为基础,对dev进行commit
    // 配合 force: true 选项,每次构建都能清理上次提交的map文件
    force: true,
    branch: "dev",
    start_branch: "master",
    commit_message: 'commit at sourcemap',
  };
  return got
    .post(`https://gitlab.xxx.com/api/v4/projects/xxx%2F/sourcemap/repository/commits`, {
      body: qs.stringify(data, { arrayFormat: 'brackets' }),
      headers: {
        'PRIVATE-TOKEN': "<YOUR_ACCESS_TOKEN>",
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    }).then(() => callback()).catch(err => callback(err.body));
    // plugin hook的callback正常情况下不能传参,传参代表失败
});

至此,全部工作完成,可以build构建试试,如果没得效果,请确认当前浏览器是否登录了gitlab,是否有权限打开xxx/sourcemap这个项目。

任何框架、方法都不是银弹,只有适合自己实际情况才是王道,并没有完美的最佳实践。

《完》

一起来洗牌

一副扑克牌:

const arr = ["A","2","3","4","5","6","7","8","9","10","J","Q","K","S"]

通过何种方式洗牌才是足够乱的呢?

如果单论“洗牌”二字,很多前端的伙计肯定想都不想就这么写了:

arr.sort(()=> Math.random() - 0.5)

但我这里强调了“足够乱”,是否让你驻足思考下,如何才能证明一个洗牌算法是足够乱的?

“足够乱”说明每张牌出现在任意位置的概率理论上应该是接近的,按照统计学的说法,当独立重复实现足够多的时候,实验数据越接近理论数据。

如何表示某张牌出现在某位置的概率?

有点费解,我们这里换下概念,把“概率”换成“次数”,表述换成:

如何表示实验xx次,某张牌出现在某位置的次数呢?

这好办,如下:

let ret = {
    "A-0": 12, // 表示A出现在0位置12次
    "A-1": 6, // 表示A出现在1位置6次
    "2-1": 9 // 表示2出现在1位置9次
    // ...
}

定好数据结构,我们就可以来实现我们的统计算法了:

function statistics(shuffle, loopCount = 2e5) {
    const arr = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "S"]
    let obj = {}
    let i = 0
    while (i++ < loopCount) {
        let copy = shuffle(arr)
        for (let j = 0; j < arr.length; j++) {
            // 打乱后 j 位置的字符
            let item = copy[j]
            // 以item与j组合 记录出现次数
            const key = `${item}-${j}`
            if (obj[key] === undefined) obj[key] = 0
            obj[key]++
        }
    }
    // 为了看出差异,我们按次数排序,并放到数组中
    return Object.keys(obj)
        .sort((a, b) => obj[b] - obj[a])
        .reduce((acc, cur) => [...acc, { [cur]: obj[cur] }], [])
}

我们把上面那个“洗牌算法”,稍微改造下,以适配这个统计函数:

function shuffle(arr) {
    let copy = [...arr]
    return copy.sort(() => Math.random() - 0.5)
}

测试一波:

// 测试20万次出结果
const ret = statistics(shuffle,  2e5)
console.log(ret)
// [{"A-0":30278},{"S-6":25056},{"S-13":24947},{"A-1":23413}...{"4-13":8261},{"2-12":7133},{"2-13":3190}]

注意到:20万次试验中,A出现在位置0处的次数达30278(概率约为15.14%),而2出现在位置13处的次数仅有3190次(概率约为1.60%),结果明显不均匀。

多重复几次的结果:

[{"A-0":30354},{"S-13":25075},{"S-6":24972},{"A-1":23243}...{"4-13":8115},{"2-12":7175},{"2-13":3182}]
[{"A-0":30421},{"S-13":24925},{"S-6":24847},{"A-1":23298}...{"4-13":8328},{"2-12":7124},{"2-13":2998}]
[{"A-0":30248},{"S-13":24944},{"S-6":24888},{"A-1":23321}...{"4-13":8348},{"2-12":7230},{"2-13":3134}]
[{"A-0":30151},{"S-6":25103},{"S-13":24851},{"A-1":23201}...{"4-13":8125},{"2-12":6950},{"2-13":3226}]
[{"A-0":29966},{"S-6":24984},{"S-13":24941},{"A-1":23181}...{"4-13":8354},{"2-12":7078},{"2-13":3032}]
// ...

我们还是得换种方式实现洗牌算法(Fisher–Yates shuffle):

function shuffle(arr) {
    let copy = [...arr]
    let i = copy.length, j;
    // 倒着循环
    while (i--) {
        // 从[0,i) 随机找一个位置j
        j = parseInt(Math.random() * i);
        // 交换位置i与位置j处的值
        [copy[i], copy[j]] = [copy[j], copy[i]]
    }
    return copy;
}

实现其实很简单,不会写记住就好了,总有你会用到的场合

再测试一波:

// 测试20万次出结果
const ret = statistics(shuffle,  2e5)
console.log(ret)
// [{"3-6":15716},{"Q-2":15715},{"A-12":15686},{"K-10":15681}...{"3-13":15152},{"5-5":15124},{"S-12":15101}]

已经肉眼可见的“均匀”了,我们算下概率:概率最高的A出现在位置13处的概率约为7.89%,而概率最低的6出现在位置10处的位置约为7.54%,差距已经在0.3%左右了。

多重复几次的结果

[{"5-13":15678},{"10-6":15667},{"3-8":15621},{"10-11":15612}...{"10-8":15127},{"8-11":15081},{"7-3":15033}]
[{"4-5":15756},{"6-4":15755},{"K-0":15655},{"4-6":15654}...{"4-0":15069},{"S-7":15006},{"A-0":1}]
[{"A-8":15741},{"8-1":15681},{"4-2":15656},{"K-7":15627}...{"J-9":15112},{"4-1":15100},{"A-0":1}]
[{"K-0":15705},{"5-6":15621},{"Q-2":15614},{"5-3":15596}...{"S-6":15119},{"5-12":15105},{"K-7":15088}]
[{"2-11":15804},{"7-1":15770},{"5-12":15745},{"K-7":15727}...{"S-0":15106},{"7-9":15075},{"K-3":15057}]
// ...

上面的数据,是跑了20w遍测试得到的,而我自己后面在尝试加到100w次试验时,这个差距降到了0.1%左右。

[{"5-12":77936},{"3-9":77495},{"J-13":77464},{"2-12":77445}...{"J-3":76355},{"2-11":76273},{"2-9":76233}]

你说会写测试代码重不重要?

一次自定义编码的探索

朋友给了个题:
对1-500000000(别数了,5亿)的整数,实现一个encode编码方法,要求编码后的字符串只包含数字和字母,然后对应实现一个decode方法,可以将该字符串还原回去。
比如:

  • encode(123) => ‘jh52a2’
  • decode(‘jh52a2’) => 123

[要求]

  1. 编码后的字符串尽可能的短
  2. 不能明显看出连续的两个数字编码后的字符也是连续的:
    1. 123=>’jh52a2′ 124=>’jh52a3′ 就不符合要求
    2. 123=>’jh52a2′ 124=>’jh58iu’ 勉强符合
    3. 123=>’jh52a2′ 125=>’iu92s1′ 这就很好了

编码的方式我们其实接触过许多,最常见的莫过于:Base64了。编码与加密的区别在于,在知道算法的前提下,可以很容易得出编码前的值,而加密则需要密钥方可解密。所以决定了编码与加密的应用场景不一样,在算法公开的情况下,加密用于数据安全,而编码用于数据传输与存储。而在算法不公开的情况下,编码在一定情况下也可以当加密使用。

言归正传,分析下上面的题:

  1. 使用数字、字母作为编码因子,意味着我们可以使用的因子数有62个:[0-9a-zA-Z],而涉及到数值的话,我们考虑使用62进制
  2. 5位62进制容量为 62x62x62x62x62 = 916132832,所以我们用5位编码即可表示1-5亿的整数
  3. 两个连续数字的二进制如:100110100111 需要通过何种变换让其结果不明显连续?考虑位运算:
    1. 异或运算:c=a^b;a=b^c;b=a^c;
    2. 自定义移位运算:1101001 右移三位:0011101

当然,自己一开始在做的过程中并不是一次性这么想的,经过了多次坎坷的调整。

首先,实现62进制与10进制互转的方法:

const radix62 = (function radix62() {
    // 打乱基本单位,不按常规的0-9a-zA-Z
    const code = 'tkI8oWKbxh0VcpuZO7yq3QwrRHPT9evsjlFzSGYXCm1AigUBd4LMJN6n2af5DE';
    return {
        encode(dec) {
            let str = ''
            do {
                let quotient = Math.floor(dec / 62)
                let remainer = dec - quotient * 62
                str = code[remainer] + str
                dec = quotient
            } while (dec)
            return str
        },
        decode(str) {
            return [...str].reduce((acc, cur, index) => {
                let pos = code.indexOf(cur)
                if (pos === -1) throw new TypeError(`字符串:“${str}”中含有非62进制字符:“${cur}”`)
                return acc += pos * 62 ** (str.length - index - 1)
            }, 0)
        }
    }
})()

实现方式很简单,参考10进制如何转2进制、如何转16进制的方法,依葫芦画瓢。

然后再来实现自定义的位运算,这里对题目要求扩展下,虽然我们计算了表示5亿只需5位62进制,我们可以设计传入任意位表示:

class BitHanler {
    static getBitHanle(bitCount) {
        return new this(bitCount)
    }

    // 在str前补零,直至长度达到 bitCount
    static pad0(str, bitCount) {
        return ('0'.repeat(bitCount) + str).slice(-bitCount)
    }

    constructor(bitCount) {
        this.bitCount = bitCount
        this.isOdd = bitCount % 2 !== 0
        this.cls = this.constructor
    }

    // 十进制整数dec转二进制,并补零至bitCount位
    toBitCountBinary(dec) {
        return this.cls.pad0(dec.toString(2), this.bitCount)
    }

    // 移动二进制位,不同于 <<、>>运算,此运算挪出的位置会补到头或尾
    bitMov(dec, x, toRight = false) {
        let str = this.toBitCountBinary(dec)
        const pos = toRight ? -x : x;
        str = str.slice(pos) + str.slice(0, pos)
        return parseInt(str, 2)
    }

    rightMov(dec, x) {
        return this.bitMov(dec, x, true)
    }

    leftMov(dec, x) {
        return this.bitMov(dec, x)
    }

    /**
     * 核心方法,dec转成bitCount位的二进制后,均匀划分为两段 a,b 
     * a,b异或运算得到c: c = a ^ b
     * 拼接得到新的二进制形式: ca
     * 解码的时候同样的操作流程不过需要进行两遍:
     * b = c ^ a  得到 bc
     * a = b ^ c  得到 ab
     * @param {Number} dec 
     */
    bitXorAndSwap(dec) {
        const bin = this.toBitCountBinary(dec)
        const splitPoint = Math.floor(this.bitCount / 2)
        const left = bin.slice(0, splitPoint)
        const right = bin.slice(this.isOdd ? splitPoint + 1 : splitPoint)
        const middle = this.isOdd ? bin[splitPoint] : '';
        const newLeft = (parseInt(left, 2) ^ parseInt(right, 2)).toString(2)
        return parseInt(newLeft + middle + left, 2)
    }
}

有了62进制转换方式和位运算操作方法,我们就可以来实现encodedecode了:


class MyEncoder { static getEncoder(targetLen, salt) { return new this(targetLen, salt) } // 计算targetLen长度的62进制最多用多少位二进制表示 static getBitCount(targetLen) { // targetLen长度的62进制数字容量 let volume = 62 ** targetLen let bitCount = volume.toString(2).length // bitCount位的二进制不能溢出 if (2 ** bitCount >= volume) bitCount-- return bitCount } constructor(targetLen, salt) { this.targetLen = targetLen this.bitCount = this.constructor.getBitCount(targetLen) this.bitHanler = BitHanler.getBitHanle(this.bitCount) this.max = 2 ** this.bitCount // 能处理的最大数值 this.moveBits = 3 this.loops = 6 //TODO 根据 salt 计算 moveBits 与 loops } encode(dec) { if (dec < 0 || dec >= this.max) throw new RangeError(`待编码的数值超出范围:[0, ${this.max})`) let i = 0 while (i++ < this.loops) { dec = this.bitHanler.rightMov(this.bitHanler.bitXorAndSwap(dec), this.moveBits) } return radix62.encode(dec) } decode(str) { if (str.length < 1 || str.length > this.targetLen) throw new RangeError(`待解码的字符长度超出了设定的长度: 0 < len < ${this.targetLen + 1}`) let dec = radix62.decode(str) if (dec >= this.max) throw new RangeError(`待解码字符解码过程中溢出了,请确认该字符是否正确:${str}`) let i = 0 while (i++ < this.loops) { dec = this.bitHanler.bitXorAndSwap( this.bitHanler.bitXorAndSwap( this.bitHanler.leftMov(dec, this.moveBits) ) ) } return dec } }

验证:

const encoder = MyEncoder.getEncoder(5)
const str0 = encoder.encode(965432)
const str1 = encoder.encode(965433)
const dec0 = encoder.decode(str0)
const dec1 = encoder.decode(str1)
console.log(str0, dec0)
console.log(str1, dec1)
// p4Eoy 965432
// hOrac 965433

字体文件信息读取

如何从一个字体文件中读取出需要的信息呢?比如字体名称(非文件名)、字体版权方等等,刚好最学习了浏览器里面的二进制,就尝试了用浏览器来解析字体文件信息。

首先介绍两种常见的字体文件,ttf(TrueType Font)与ttc(TrueType Collection)

TrueType是由美国苹果公司和微软公司共同开发的一种电脑轮廓字体(曲線描邊字)类型标准 – wiki

TrueType Collection (TTC) is an extension of TrueType format that allows combining multiple fonts into a single file, creating substantial space savings for a collection of fonts with many glyphs in common. – wiki

说白了,ttf表示单一字体,而ttc是多个ttf的合集,我们弄懂了ttf,那么ttc就非常简单了。

ttf

ttf文件主要由三大部分组成:

Header + N个Table Directory + N个Table Header + M个Table Record

我们以C类型的结构体形式表示各结构:

  • Header
typedef struct _tagTT_OFFSET_TABLE{
  uint16 uMajorVersion,
  uint16 uMinorVersion
  uint16 uNumOfTables, // 关注它--> Table的个数
  uint16 uSearchRange,
  uint16 uEntrySelector,
  uint16 uRangeShift
}TTF_HEADER_TABLE;

Header总共占据了2*6 = 12,我们从文件最开始读取12个字节即可得到如上信息,其中若uMajorVersion不为1且uMinorVersion不为0,我们可以认为该文件不为合法的ttf文件。

紧接着,我们关注uNumOfTables的值,其决定了接下来会有多少个连续的Table Directory

我们来看看Table Directory的结构:

  • Table Header
typedef struct _tagTT_TABLE_DIRECTORY{
  char szTag[4]; // 关注它 --> Table Name
  ULONG uCheckSum; // 校验和
  ULONG uOffset; // 关注它--> 对应的Table的绝对位置
  ULONG uLength; // 对应的Table长度
}TT_TABLE_DIRECTORY;

单个Table Directory的长度为4*3=12字节,从Header的末尾开始,按长度12为单位,循环读取至多uNumOfTables次,我们就可以读取到所有的Table Directory了。当然,本文只打算读取字体的基本信息,所以,我们只关注szTagnameTable Directory

一旦找到需要的Table Directory,那么就可以根据其uOffset的值找到Name Table Header了,先看看Table Header的结构:

  • Table Header
typedef struct _tagTT_NAME_TABLE_HEADER{
  uint16 uFSelector; // 始终为0
  uint16 uNRCount; // 关注它--> Name Record 的数量
  uint16 uStorageOffset; // Name Record相对于该结构起始位置的偏移值
}TT_NAME_TABLE_HEADER;

通过uStorageOffset,我们即可从uOffset+uStorageOffset开始读取uNRCount个连续的Name Record,其结构为:

typedef struct _tagTT_NAME_RECORD{
  uint16 uPlatformID;
  uint16 uEncodingID;
  uint16 uLanguageID;
  uint16 uNameID; // name id,我们暂时只取0-7的
  uint16 uStringLength;
  uint16 uStringOffset; //from start of storage area
}TT_NAME_RECORD;

每个Name Record的长度为2*6=12,我们要读取的信息为uNameID范围0-7(更多),分别对应如下:

{
    0: 'copyright',
    1: 'fontFamily',
    2: 'fontSubFamily',
    3: 'fontIdentifier',
    4: 'fontName',
    5: 'fontVersion',
    6: 'postscriptName',
    7: 'trademark',
}

另外考虑读取的编码为Unicode情况,要求:

uPlatformID === 0 && uEncodingID === (0,1,3)

uPlatformID === 3 && uEncodingID === 1

满足如上条件后,我们通过uOffset+uStorageOffset+uStringOffset分别获取每条Name Record中的字符串值,即可对应到上面列出的八个属性的值。

到这里,我们就得到了单个ttf文件中需要的信息了。

ttc是由多个ttf文件构成的,其header结构为:

typedef struct _tagTTC_HEADER_LE {
   uint32 tag;
   uint16 uMajorVersion;
   uint16 uMinorVersion;
   uint32 uNumFonts;
} TTC_HEADER_LE;

其中tag固定为ttcf,uNumFonts表明该文件包含了多少个ttf字体,同时,在紧挨着该结构后面是uNumFonts个4字节的offset,用于表示每个ttf相对于文件起始位置的便宜。一旦找到这个偏移,读取每个ttf的方式就和前面一样了。

完整实现代码见github

参考

  • https://docs.microsoft.com/en-us/typography/opentype/spec/otff
  • https://blog.csdn.net/kwfly/article/details/50986338

浏览器里的二进制

以下是几个主角的简介:

  • ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。大白话其实就是代表一块固定的连续内存数据,虽然名字中带有Array,其实和我们认知的数组没半毛钱关系。并不直接提供读取、写入接口,操作需要通类型数组 视图或过DataView 视图

  • TypedArray(类型数组),在JavaScript下可用的类型数组有:Int8ArrayUint8ArrayUint8ClampedArrayInt16ArrayUint16ArrayInt32ArrayUint32ArrayFloat32ArrayFloat64ArrayBigInt64ArrayBigUint64Array,提供内存数据的不同类数组(array-like)视图形式,底层还是ArrayBuffer存储数据。大白话就是用数组的形式表示内存数据,并提供按数组索引操作数据。什么作用呢?比如你的数据是单个字节为单位的,那么用Uint8Array描述一段内存,则可以通过索引操作每个单位的数据,同样,如果你的数据是双字节为单位的,那么你就可以通过Uint16Array描述这段内存。所以,new Uint8Array([255,255])new Uint16Array([255*255])表示的是一样的数据。

  • DataView,如果说TypedArray是以固定的单位读写二进制数据的话,DataView则灵活多了,在同一个视图中,可以使用多种字节混合的方式,甚至无需考虑不同平台的字节序问题。

  • Blob对象表示一个不可变、原始数据的类文件对象,不像之前的其他仨,Blob不属于ES标准,是浏览器API。Blob可以理解为ArrayBuffer + mime typeFile对象继承自Blob。

简单说,视图就是读、写二进制的形式。 ArrayBuffer对象代表原始的二进制数据,TypedArray视图用来读写简单类型的二进制数据,DataView视图用来读写复杂类型的二进制数据。

下图可以直观理解视图
image
上图出处

下面开始详细介绍以上几种数据类型。

ArrayBuffer

ArrayBuffer仅仅代表一块缓冲区域,并不能读取、写入数据

// 创建10字节的缓冲区,每个二进制位填充为0
const buffer = new ArrayBuffer(10)
// 如果有个现成的buffer,可以通过byteLength读取其大小
buffer.byteLength // 10

ArrayBuffer的来源非常多,举几个常见的场景:

  • 来自FileReader
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = function () {
    // 看这里看这里看这里
    const buffer = reader.result;
};
  • 来自XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('GET', someUrl);
// 声明需要的数据类型
xhr.responseType = 'arraybuffer';
xhr.onload = function () {
    // 看这里看这里看这里
    const buffer = xhr.response;
};

xhr.send();
  • 来自canvas
const canvas = document.createElement('canvas')
canvas.height = 1
canvas.width = 1
const ctx = canvas.getContext('2d')
const buffer = ctx.getImageData(0,0,canvas.width,canvas.height).data.buffer
  • 来自Response/Fetch
const buffer =  await new Response("Hello World!").arrayBuffer()
// 或者
const response = await fetch('/')
const buffer = await response.arrayBuffer()
  • 来自WebSockets
const socket = new WebSocket('ws://127.0.0.1:8081');
// 声明数据交换类型
socket.binaryType = 'arraybuffer';
// Wait until socket is open
socket.addEventListener('open', function (event) {
    socket.send(new ArrayBuffer(10));
});
// Receive binary data
socket.addEventListener('message', function (event) {
    // 看这里看这里看这里
    const buffer = event.data;
});

TypedArray

ArrayBuffer自身是个黑盒,不可读取、写入,那么就需要一种工具来操作ArrayBuffer,这种工具就叫视图。本节的TypedArray(类型数组)就是视图的一种。

我们知道二进制最小单位为bit,只能表示两种状态:01,绝大多数情况下,我们去操作二进制是以字节(byte字节,1byte=8bit)为单位,还有些情况甚至是以两个字节或四个字节,所以TypedArray提供了一些列针对特定数据类型的类型化数组的构造函数:Int8ArrayUint8ArrayUint8ClampedArrayInt16ArrayUint16ArrayInt32ArrayUint32ArrayFloat32ArrayFloat64ArrayBigInt64ArrayBigUint64Array,用于满足不同场景下的需求。

const buffer = new ArrayBuffer(10)
const u8Buffer = new Uint8Array(buffer)
u8Buffer[0] = 200
u8Buffer[1] = 300 // 超过8位最大值256 溢出,值为44

const u16Buffer = new Uint16Array(buffer)
u16Buffer[1] = 300 // 不会溢出,索引对应的最大值为256*256-1

特别的,在颜色计算的场景,我们希望在某值在溢出后不要截断,而直接使用255,这时Uint8ClampedArray就派上了用场:

const buffer = new ArrayBuffer(10)
const u8Buffer = new Uint8ClampedArray(buffer)
u8Buffer[0] = 200 // 正常 200
u8Buffer[1] = 300 //  溢出,值为 255

每个类型数组都有两个静态方法,用于从数组及不定的参数快速得到对象:

  • TypedArray.from()

使用类数组(array-like)或迭代对象创建一个新的类型化数组。

  • TypedArray.of()

通过可变数量的参数创建新的类型化数组。

并且,无论哪种类型数组的实例,都有普通数组拥有的许多方法(不包含改变buffer长度的方法),比如:mapforEachreduce

DataView

DataView是除了TypedArray之外的另一种操作ArrayBuffer的选择。但是不像TypedArray提供各种不同的对象来满足不同的需求,DataView只有一个构造函数,实例化后提供getInt8getUint8getInt16getUint16getInt32getUint32getFloat32getFloat64等读方法与setInt8setUint8setInt16setUint16setInt32setUint32setFloat32setFloat64等写方法。在复杂的场景下,可以用一个视图实例达到不同类型读写目的。

另外,DataView读、写多字节二进制数据默认使用大端序,可以通过指定诸如getInt16setInt32之类的多字节操作方法最后一个可选参数为falseundefined值来使用小端序

示例:

const buffer = new ArrayBuffer(6)
const view = new DataView(buffer)
view.setUint8(0, 11) // 1
view.setUint16(2, 22) // 2
view.setUint16(4, 33, true) // 3

console.log(new Uint8Array(buffer)) // [11, 0, 0, 22, 33, 0]
console.log(new Uint16Array(buffer)) // [11, 5632, 33, 0, 0]
console.log(view.getUint16(2)) // 22
console.log(view.getUint16(4)) // 8448
console.log(view.getUint16(4, true)) // 33

为什么//2写入的22,在Uint16Array视图中是5632呢?
因为Uint16Array视图是按小端序读取数据的,而//2中我们是按大端序写入的数据,我们计算验证下:
22的二进制为10110,后面再拼接8位0,得到1011000000000,转换为十进制刚好是5632

view.getUint16(4)得到8848也是一样的道理。

Blob

Blob对象表示一个不可变、原始数据的类文件对象。我们日常使用的File对象继承了它。

由于Blob的不可变性,需要将其转换成ArrayBuffer然后通过视图方可读、写数据。

通过其构造函数,我们可以从如下四种方式得到一个Blob实例:

// 从ArrayBuffer
const buffer = new ArrayBuffer(8)
const blob1 = new Blob([buffer])
// 从ArrayBufferView
const view = new DataView(buffer)
const blob2 = new Blob([view])
// 从另一个Blob对象
const blob3 = new Blob([blob2])
// 从字符串,顺便指定mime
const blob4 = new Blob(["<h1>Hello World</h1>"], {type:"text/html"})

Blob对象的存在,为我们在浏览器上自行构建file-like(类文件)对象提供了可能,我们可以通过一定转换将客户端数据通过Blob生成DataURLObjectURL,从而可以用于需要url的场景。比如:动态构建文件并下载、本地选择的图片预览等。

转换

各种数据转换如下图:
转换图

我们列举一些常见的场景来尝试转换。

本地选择图片实现预览

路径:本地图片->Blob->ObjectURL/DataURL


const fr = new FileReader fr.onload = e=>{ img.src = e.target.result } // 以dataURL形式读取file fr.readAsDataURL(file) // blob转成ObjectURL img.src = URL.createObjectURL(file)

下载一段自定义文本

路径:文本->Blob->ObjectURL/DataURL

const text = "Hello World"
const blob = new Blob(\[text\],{type:"text/plain"})
const url = URL.createObjectURL(blob)
// 省略下载过程

参考:

浏览器端简单实现多字节base64编码

由于浏览器内置的函数:btoa不支持多字节编码:

btoa("一")

执行会报错:Uncaught DOMException: Failed to execute 'btoa' on 'Window':
The string to be encoded contains characters outside of the Latin1 range.

mdn上给出的一种编码方案是:

window.btoa(unescape(encodeURIComponent('一'))) // 5LiA

然后反过来操作就是解码:

decodeURIComponent(escape(window.atob(str))) // 一

原理其实很简单:得到多字节字符的utf-8编码,然后每个字节转成ascii字符,然后再用btoa进行编码。

但是,escapeunescape都属于被废弃的函数,我们可以通过简单的转换来实现同样的功能:


function b64encode(str){ // 得到utf-8编码 const utf8 = encodeURIComponent(str) // %xx对应的每字节转换成字符串 const utf8Str = "a%E4%B8%80".replace(/%(\w{2})/g,(m,a)=>String.fromCharCode('0x' + a)) return btoa(utf8Str) } function b64decode(str){ // 每个字节处理成%xx形式 const utf8 = [...atob(str)].map(item => '%' + item.charCodeAt(0).toString(16)).join('') // 解码utf8 return decodeURIComponent(utf8) }

字节序理解示例

字节序的介绍文章网上非常多,这里不再赘述,可以参考MDN上的介绍。

在做底层网络编程的时候会遇到字节序转换的情况,因为我们现在接触的机器一般都是little-endian(小端序)而网络传输一般使用big-endian(大端序)

那么,站在前端的角度如何通过示例来验证小端序呢?且看代码:

const buffer = new ArrayBuffer(2) // 申请2字节缓冲区
const u16View = new Uint16Array(buffer) // 使用16位无符号视图,以双字节为单位读写buffer
u16View[0] = 300
const u8View = new Uint8Array(buffer) // 使用8位无符号视图,以单字节读写buffer
console.log(u8View) // 得到[44, 1]

[44, 1]对应的二进制位 [00101100, 00000001]

由于内存是连续的,我们如果按大端序解析这两个字节,得到的结果应该是:

parseInt("0010110000000001", 2) // 11265

并不是我们开始写入的300,而反过来:

parseInt("0000000100101100", 2) // 300

得到了我们想要的结果。

浏览器FPS计算

FPS的概念随手一搜有很多,简单来讲,就是一秒钟内渲染了多少次画面。

在浏览器里面,浏览器渲染一次页面是有回调的requestAnimationFrame

那么计算FPS就是计算: 1÷两次渲染的时差,这事好办呀:


function calcFps(){ let lastTime = Date.now(); (function loop(){ const now = Date.now() const fps = Math.round(1 / ((now - lastTime) / 1000)) console.log('fps:', fps) lastTime = now requestAnimationFrame(loop) })() }

现在的实现是每次渲染都计算下FPS是多少,变化太快,我们加入更新频率:


function calcFps(debounce = 1000){ let lastTime = Date.now(); let count = 0; // 记录decounce周期内渲染次数 (function loop(){ count++ const now = Date.now() if( now - lastTime > debounce){ const fps = Math.round(count / ((now - lastTime) / 1000)) lastTime = now count = 0 console.log('fps:', fps) } requestAnimationFrame(loop) })() }

一次性搞清字符编码

正文开始前的絮叨

不看并不影响后面的理解

弄清各种编码的念头在我脑海里面已经浮现过 N 回了,我相信很多人也一样,特别是接触过后端语言的人,会时不时碰到乱码,需要按正确的编码类型解码才能得到正常的文字。一提到编码,我们脑海里可能冒出了 N 多词汇:ASCII 码、Unicode、utf8、gbk、Latin1…他们分别是什么?在什么场合下使用?了解 js/node.js 的小伙伴,对如下两个函数并不陌生:

encodeURI('一'); // "%E4%B8%80"
escape('一'); // "%u4E00"

这两个函数运算的结果分别对应上面提到的哪种“编码”呢?

先说结论

  1. ASCII是给常见符号、字母、数字编码的规则
  2. Unicode是给所有文字编码的规则
  3. utf8是在计算机上对Unicode的实现
  4. gbkLatin1…是各种文字的使用国在Unicode成为世界通用标准之前,自己出的编码规则,一般兼容ASCII

不得不说的编码历史

我们知道,计算机最底层是用二进制表示的,那如何让这些二进制的组合有意义呢?比如我如何表示字母a,如何表示数字1呢?

ASCII 的诞生

计算机的先贤们给常用的书写字符、控制字符都分配了唯一的编号,从 0 到 127,总共 128 个字符,这个编号后面被美国国家标准学会制定成了国家标准,叫ASCII(American Standard Code for Information Interchange),中文翻译叫:美国标准信息交换码(谁叫人家走在计算机发展的前列呢)。所以,我们常见的英文字母也好、数字也好都可以在 ASCII 码表中找到它对应的编号,比如:a的编号十进制是97,数字1的编号十进制是49,标点符号!的编码十进制是33

这样,英语国家就可以愉快的使用计算机处理各种文本了,比如,读取一个文件得到的二进制是:011000010110001001100011,我们知道 8 位为一个字节,我们这里可以分成三个字节:

01100001
01100010
01100011

分别转成十进制就是979899:

parseInt('01100001', 2); // 97
parseInt('01100010', 2); // 98
parseInt('01100011', 2); // 99

查 ASCII 码表我们知道,这个文件的内容就是abc

Latin1,gbk

我们知道,一个字节有 8 个比特位,一个比特位又可以用01表示两种状态,那么,这 8 个比特位都用上的话,理论上来说可以表示 2^8 =256 个字符,而现在 ASCII 码才占用了 128 个,多浪费啊!!后来,欧洲一些使用拉丁字母的国家率先发现了这个“惊天秘密”,在 ASCII 码表的后面继续编码,从 161 开始 255 结束,加入 96 个字母及符号,还搞了个国际化标准名,叫ISO 8859-1,又叫 Latin-1,从此这些国家也可以愉快的使用计算机了。

中国人也不甘示弱,但这剩下的 128 个都不够我写首长诗的,中文汉字实在太多了(汉字总数接近十万,常用汉字也有好几千),一个字节是不够表示的了,至少得用两个字节(2^16 =65536)表示,制定了 GB2312 编码表,及后续的升级版GBK,当然还是得兼容 ASCII 码。(至于在两字节的 gbk 中,如何区分某一字节该识别为 ASCII 码还是和前、后字节组合识别成单独的一个字符,这超出本文的主题了,感兴趣可以去了解下。
比如 gbk 编码得到的十六进制:61d2bb,你怎么解码回去?)

Unicode

部分内容来自刨根究底字符编码之八——Unicode 编码方案概述

在那个各种语言编码独自发育的时代,终于有组织站了出来,着手统一全世界所有文字、符号的编码,这个编码就是Unicode,俗称万国码Unicode为每个字符分配唯一的字符编号(即码点编号、码点值、CodePoint)

Unicode是国际组织制定的可以容纳世界上所有文字和符号的字符编码方案。目前的Unicode字符分为 17 组编排,每组称为平面(Plane),而每平面拥有 65536 个码位,共 17*65536=1114112 个。然而目前只用了少数平面。

其中第 0 个平面 BMP(Basic Multilingual Plane 基本多语言平面、基本多文种平面、基本平面、平面 0),基本涵盖了当今世界上正在使用中的常用字符。我们平常用到的 Unicode 字符,一般都是位于 BMP 平面上的。BMP 平面以外其他的增补平面(也称为辅助平面)要么用来表示一些非常特殊的字符(比如不常用的象形文字、远古时期的文字等),且多半只有专家在历史和科学领域里才会用到它们;要么被留作扩展之用。目前 Unicode 字符集中尚有大量编号空间未被使用。

另外,BMP 平面有一个专用区(Private Use Zone):0xE000~0xF8FF(十进制 57344~63743),共 6400 个码点,被保留为专用(私用),因而永远不会被分配给任何字符;还有一个被称为代理区(Surrogate Zone)的特殊区域:0xD800-0xDFFF(十进制 55296~57343),共 2048 个码点,目的是用基本平面 BMP 中的两个码点“代理”表示 BMP 以外的其他增补平面的字符(UTF-16 会用到)。

现在倒是齐活了,100w 个字符,理论上是用不完了。然而,需要面临一个新问题了(这个问题 gbk 给绕过去了,还是上面那节最后的问题),0x0000-0x10FFFF这么多字符,必须得用至少4个字节来表示单个字符,比如字符a,若采用Unicode,则需要表示成00000000000000000000000001100001(知道为什么需要这样表示么?其实和 ASCII 码表为什么约定一个字节是 8 位是一样的道理,不约定某种编码需要按多少位作为一个单位解析的话,就会存在解析的多义性,比如你拿到a的二进制01100001,一个个比特位读是一回事,两个比特位读又是另一个意思…只有 8 个比特位一起读才能按 ASCII 码规则读成字符a)。而用 ASCII 码则是:01100001

原来 1 个字节表示的a,现在需要 4 个字节。意味着我原来存储的 ASCII 编码文档,换成 Unicode 编码存储,占用的空间需要翻 4 倍,这哪能忍。

UTF-8 编码

这时候UTF-8跳了出来:我觉得我该出现了。

UTF-8是一种针对Unicode的可变长度字符编码,用 1 到 6 个字节编码Unicode字符。

上面不是说变长编码有多义性么,UTF-8是怎么让计算机识别不同长度的编码呢?

两条规则:

  1. 单字节字符:第一位设置为0后面七位设置为对应的Unicode
  2. n(n>1)字节字符:第一字节的前n位均设置为1n+1位设置为0,后面的字节前两位均设置为10,剩下的二进制位从右至左用这个字符的Unicode码填充,不足的位置填充 0

那么,一个字符该编码为几个字节怎么定呢?

对照如下表:

Unicode 范围 编码格式
U+0000 – U+007F 0xxxxxxx
U+0080 – U+07FF 110xxxxx 10xxxxxx
U+0800 – U+FFFF 1110xxxx 10xxxxxx 10xxxxxx
U+10000 – U+1FFFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
U+200000 – U+3FFFFFF 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
U+4000000 – U+7FFFFFFF 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

我们拿个字符练练手,汉字:

我们查Unicode 表,知道它的Unicode编码是4E00,当然,如果你熟悉 JavaScript,也可以这样得到:'一'.charCodeAt(0).toString(16)escape('一'),但由于历史原因,这两种方法并不总是可用,比如说这个汉字:\uD842\uDFB7。感兴趣自己试下,然后查找相关资料解惑。

ps: ES6 下提供了"\uD842\uDFB7".codePointAt().toString(16)方法获得准确的 Unicode 码。

扯远了,我们回到手动编码的话题。

对照上面的编码表,我们知道4E00U+0800 - U+FFFF区间,所以其编码格式为1110xxxx 10xxxxxx 10xxxxxx,也就是三个字节,我们将4E00转成二进制,0x4E00.toString(2),得到结果:100111000000000,我们将其与编码格式的xxx位置从右至左对其:

1110xxxx 10xxxxxx 10xxxxxx
     100   111000   000000
填充所有的x位置得到结果:
111001001011100010000000

将其转为 16 进制parseInt('111001001011100010000000',2).toString(16),得到:e4b880即为汉字UTF-8编码了。

验证方式有二:

  1. node.js 安装iconv-lite库,然后:
const iconv = require('iconv-lite');
iconv.encode('一', 'utf8');
// 得到 <Buffer e4 b8 80>
  1. 通过 JavaScript 内置函数encodeURI:
encodeURI('一');
// 得到 %E4%B8%80

BOM

BOM(Byte Order Mark),字节顺序标记,出现在文本文件头部,Unicode 编码标准中用于标识文件是采用哪种格式的编码。

UTF-8一般用EFBBBF作为文件的开头,声明自己的编码类型,一般软件在读取文件的前三个字节的时候便知道了文件的编码类型。但某些编程语言,比如php,在读取文件的时候,会无视BOM,换句话说,它会将这三个字节当做可显示字符去读取,结果就会出现文件开头乱码的情况。

实际上,由于UTF-8编码的特殊性,多读取几个字节,软件是可以猜出当前文档的编码形式的,所以就出现了很多编辑器保存文件时会有UTF-8UFT-8-BOM两种形式的编码方式可选。

与前端相关的编码知识

\数值 deprecated

'\141' === 'a'; // true   \x   x为Unicode码中00~FF范围内字符的8进制编码

\u

'\u4E00' === '一'; // true  \u + Unicode码16进制
'\u{20BB7}' === '\uD842\uDFB7'; // true 超过两个字节的用花括号包围(ES6支持)

\x

'\x61' === 'a'; // true  \x + Unicode码中00~FF范围内字符的16进制编码

\c

仅限于正则表达式中,26 个字母对于开始的 26 个 Unicode 字符

/^\ca$/.test('\u0001'); // true
/^\cb$/.test('\u0002'); // true
//  ...省略中间23个字母组合
/^\cz$/.test('\u001A'); // true

&#x;html 实体

规则&# + 字符 Unicode 码十进制 + ‘;’

字符的 Unicode 十进制为:19968,所以有下面:

<span>&\#19968;</span>
<!-- 二者等同 -->
<span>一</span>

encodeURI/encodeURIComponent

转换U+0000 - U+007F以外的字符为 utf-8 形式:

encodeURIComponent('一'); // "%E4%B8%80"

escape

[a-zA-Z0-9]@*_+-./以外字符的UTF-16码

escape('一'); // %u4E00  => \u4E00
escape('!'); // %21 => \u0021
escape('\uD842\uDFB7'); // %uD842%uDFB7 => \uD842\uDFB7

unescape

解码形如%60%u4E00编码,由于以符号%为单位解析,所以,我们可以通过该函数很容易得到utf8字符串的单字节形式:

var encode = encodeURI('一');
unescape(encode) // "一"

TextDecoder

解码常见编码

const gbkDecoder = new TextDecoder('gbk')
console.log(gbkDecoder.decode(new Uint8Array([0xd2,0xbb]))) // 输出“一”

TextEncoder

转换字符为utf-8字节码

encoder = new TextEncoder()
console.log(encoder.encode('一')) // 输出 Uint8Array(3) [228, 184, 128]

参考文档

  1. 字符编码笔记:ASCII,Unicode 和 UTF-8
  2. 所谓编码–泛谈 ASCII、Unicode、UTF8、UTF16、UCS-2 等编码格式
  3. 刨根究底字符编码
  4. JavaScript character escape sequences