某数二进制中包含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 = utf8.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进制编码

在css中,则对应的是16进制的Unicode码,以下显示字符“一”

body:after{
    content: '\4e00' ; 
}

\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>

当然,也支持如下的16进制形式:

<span>&\#x4e00;</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

二进制位操作对多状态组合的应用

这其实是属于前辈级程序员们玩剩下的东西,去写win32的程序,调用win32 api的时候,经常会碰到属性状态的组合,比如这个例子

int DisplayResourceNAMessageBox()
{
    int msgboxID = MessageBox(
        NULL,
        (LPCWSTR)L"Resource not available\nDo you want to try again?",
        (LPCWSTR)L"Account Details",
        MB_ICONWARNING | MB_CANCELTRYCONTINUE | MB_DEFBUTTON2
    );

    switch (msgboxID)
    {
    case IDCANCEL:
        // TODO: add code
        break;
    case IDTRYAGAIN:
        // TODO: add code
        break;
    case IDCONTINUE:
        // TODO: add code
        break;
    }

    return msgboxID;
}

MessageBox这个函数的第四个参数,用于定义窗体的显示形式,可以是多种形式的组合,就像上面的例子,传入的就是MB_ICONWARNING | MB_CANCELTRYCONTINUE | MB_DEFBUTTON2

这种形式的好处就在于,多个属性组合时,不需要为函数定义一大堆的入参,通过巧妙的属性值选取,配合二进制位操作,无论多少个属性,都可以通过一个参数完成。

假如某函数可接受4种状态的组合:a,b,c,d
我们定义四个常量:

const STATUS_A = 0b1 // 二进制 1
const STATUS_B = 0b10 // 二进制 10
const STATUS_C = 0b100 // 二进制 100
const STATUS_D = 0b1000 // 二进制 1000

为什么这么定义? 因为二进制的或运算(|)有如下特征:

0b1000 | 0b10  // ==> 0b1010
0b10 | 0b101  // ==> 0b111
...

二进制位右对齐后,两个同位置的值有一个为1,则结果为1,否则结果为0。

对于我们的例子:

let status = STATUS_B | STATUS_D // 得到 status 为二进制 1010

很明显STATUS_B对应的第二位与STATUS_D对应的第四位被置为了1,而另两位依然是0

人眼是很容易看明白哪位为0,哪位为1,程序有什么快速的方式知道呢?

答案,依然是位运算,不过这次用与运算(&),特征如下:

0b101 & 0b11  // ==> 0b101
0b111 & 0b10  // ==> 0b110
...

二进制位右对齐后,两个同位置的值同时为1,则结果为1,其中一项不为1,则结果为0。

利用这个特性,当我们需要知道某状态是否被设置时,只需要入参的值与该状态的值做与运算就好了,因为该状态的值对应位的值肯定为1,所以就有:

let status = STATUS_B | STATUS_D // 得到 status 为二进制 1010

console.log(status & STATUS_A) // 输出0
console.log(status & STATUS_B) // 输出十进制2 对应二进制 10
console.log(status & STATUS_C) // 输出0
console.log(status & STATUS_D) // 输出十进制8 对应二进制 1000

聪明的你,应该已经知道答案了吧。

完整示例:

// 采用parseInt中转一下,直接使用二进制定义更直观
const STATUS_A = 0b1
const STATUS_B = 0b10
const STATUS_C = 0b100
const STATUS_D = 0b1000

function statusHandle(statuMask){
    if(statuMask & STATUS_A){
        console.log('状态A被设置')
    }
    if(statuMask & STATUS_B){
        console.log('状态B被设置')
    }
    if(statuMask & STATUS_C){
        console.log('状态C被设置')
    }
    if(statuMask & STATUS_D){
        console.log('状态D被设置')
    }
}
/*      test       */
statusHandle(STATUS_C)
// 状态C被设置
statusHandle(STATUS_A | STATUS_D)
// 状态A被设置
// 状态D被设置

结构化复制

我一直以为的深度复制就是这样:
* for…in循环
* 判断属性是否是自己的
* typeof结果为object还要额外区分具体是普通对象、还是array、还是map…
* 函数、Error无法被深度复制
* 注意循环引用
* JSON API满足大部分需求

昨天看到某篇又是关于深度复制的文章,惯性思维告诉我,不就是深度复制么,写来写去有啥好写的。还好,看在作者是前端大佬的份上,倒是看他能不能把这份冷饭炒出鲜味来。这一看不得了,一个占据大篇幅的新概念冒了出来:结构化克隆算法

具体的介绍直接看MDN,我这里只搬运几种常见的使用结构化克隆方式实现深度拷贝的方法:

  • MessageChannel
function structuralClone(obj) {
  return new Promise(resolve => {
    const {port1, port2} = new MessageChannel();
    port2.onmessage = ev => resolve(ev.data);
    port1.postMessage(obj);
  });
}
const obj = /* ... */;
const clone = await structuralClone(obj);

特点:异步、兼容性ok

  • History API
function structuralClone(obj) {
  const oldState = history.state;
  history.replaceState(obj, document.title);
  const copy = history.state;
  history.replaceState(oldState, document.title);
  return copy;
}

const obj = /* ... */;
const clone = structuralClone(obj); 

特点:Safari对replaceState调用的限制数量为 30 秒内 100 次。

  • Notification API
function structuralClone(obj) {
  return new Notification('', {data: obj, silent: true}).data;
}

const obj = /* ... */;
const clone = structuralClone(obj);

特点:简洁、兼容性不佳(safari全系列不支持)

当然,由于是whatwg提出的东西,nodejs是沾不上边的,这三个方法都只能用于浏览器端。

绿灯思维

周末看书的时候,书的作者举了个例子,说春节在家的时候与一个亲戚哥哥聊到读书的问题,那个哥哥开门见山提到自己的观点:读书与基因有关。而作者表达的观点是读书这事儿其实与基因没什么太大关系时,引起了那个哥哥的强烈反驳,并试图用他所知道的例子来证明,而并不打算听作者的观点。

对世界的认知,很大程度上影响了人们对于人生意义与价值的看法,进而影响其价值观,最终整个人生的走向也大不一样。

你对世界怎么看,你相信什么,深深影响着你的行动。

怎么改变自己的世界观?我觉得很重要的一点就是要有绿灯思维

什么叫绿灯思维?我们通过一个例子先看看与之相反的更容易理解的红灯思维

上周放假前让同事拟一个xx计划给我,周一按时给我了,但我打开发现每项任务只有工时没有具体的开始日期与结束日期,于是就跑去跟他说,每项任务需要具体的时间节点。他说最开始不是写了一个时间吗,从那个时间开始执行第一项任务,顺着往后推就行了,每完成一项写一个时间。这对我来说显然是不可接受的,我跟他说了我的观点,可他似乎都没在听就开始反驳,你这是多此一举,我这个明明没有问题…

这就是我那位同事红灯思维的体现,遇到与自己观念相悖的观点时,习惯性地就开始反驳。这是一种比较常见的心理现象:习惯性防卫。当我们的观点、思维、一直以来的深刻认知可能受到挑战时,第一时间不是思考对方的挑战与质疑是否合理,而是:有人在反对我的合理性,我需要对峙。这时候,人们的心理就开始亮红灯,习惯性防卫就产生了。

红灯思维的结果,就是把我们自己设置了一个屏障,与外界隔绝开来,过早过滤了大部分信息,让我们只能学到或利用到一小部分外界信息,阻碍了思维的开阔的可能性。久而久之就成了别人眼中的老顽固

看到有人说,红灯思维不就是批判性思考么?仔细想想,其实二者完全不一样:

  • 红灯思维拒绝与自己意见向左的观点
  • 批判性思考是带着质疑的态度去试图接纳别人的观点

二者毫无可比性。

红灯思维弄明白之后,与之相反的绿灯思维就好理解了:以一颗包容的心先认可别人的观点,思考:他的观点可能是对的,最终我也可能同意他的观点,我从哪些角度思考会同意他的观点呢?这个观点的价值在哪里呢?说白了就是首先认可你尚未接受的观点是有价值的,然后站在对方的角度来推演这个观点,最终再决定是否吸纳该观点为自己所有。

绿灯思维的好处不言而喻,我们可以集百家之长,而自成体系。

写这篇文章的目的在于我们如何从红灯心态转变为绿灯心态,这是相当困难的一步,因为很多人在受到别人对自己观点质疑的时候,首先想到的不是别人在质疑他的观点,而是觉得别人在针对他这个人。直接导致了习惯性防卫。

要改变这个情况,就一定要明确”我”与”我的观点/行为结果”是分开的,我的成长来自于”我的观点/行为结果”的改进与持续提升,而别人对于”我的观点/行为结果”提出意见,正是我们从不同角度获得启发和成长的机会。对”我的观点/行为结果”的否定并不是对我本人的否定。

有了这个心理预设之后,就迈进了绿灯思维的大门,多倾听他人的观点、多看书,增加自己接收外界信息的途径,不断更新自己对于世界的认知。

这样,虽然我以为我以为的以为依然还不是我以为的,但我依然将不断成为更好的我。

JavaScript的数据类型

变量名与变量值

var age = 15;

变量名只是标识符,可以理解为内部指针,指向变量值所在的内存地址。变量名没有类型一说。

在一个姓名唯一的星球上,张三用于指代某个人,张三就是变量名,那个人就是变量值。当然,这个人还可以同时拥有多个其它名字:阿三、阿三哥、小三、小三三等。
由于无名之人会挂掉。所以,当把张三、阿三…这些名字都分配给其它人之后,这个人就没有存在的意义了,别人无法描述这个人,也就无法再次给他分配名字(垃圾回收机制)。

后面我们依然会用这个设定来解释变量的引用。

PS:以上仅仅提到了垃圾回收机制中最简单的一种,复杂的,比如:

var o = {};
var arr = [];
arr.push(o);
o = undefined;

由于arr依然持有原来变量o指向的对象,所以就算标识符o不再引用该对象,但该对象依然被标记引用,不能被作为垃圾回收

类型

  • 7 种基本类型
    • null
    • undefined
    • string
    • number
    • boolean
    • symbol
    • bigint
  • 1 种复合类型
    • object

看到这里很多人就会发问了,那么我们用的数组、函数都算什么呢?

ky4eJg.gif

又有机智的小伙伴会问了,那 JSON 又是什么? JSON_互动百科

看很多人的简历写了:精通 JavaScript、JSON 等技术。我就一直在纳闷,JSON 有什么好精通的。

基本类型

基本类型直接代表了最底层的语言实现,具有如下特点:

  • 不可变
  • 无方法/属性

不可变:

var s = 'hello world';
console.log(s[1]); // 输出 e
s[1] = 'a';
console.log(s); // 依然是 hello world

扩展概念:字符串驻留

可以通过内存快照的方式验证如下变量ab是否指向同样的内存地址:

var a = 'hello';
var b = 'hello';

无方法/属性:

初看有些难以理解,毕竟我们经常这样用:

'Hello World'.split(' ');

我们首先要搞明白两个事情:

  • 上面的split方法就是String.prototype.split,验证:
'Hello World'.split === String.prototype.split; // true
  • "Hello World" 并不是 String 的实例,验证:
'Hello World' instanceof String; // false

上面这个例子要铭记,原始类型不能用instanceof判断。

那么为什么可以这样调用:"Hello World".split(" ")

答案呼之欲出:

百度:JavaScript 装箱

Google:JavaScript boxing

所以,在调用"Hello World".split(" ")时,实际调用的是"Hello World"对应的包装类String,实例化后的方法,等同于:

new String('Hello World').split(' ');

如下几种基本类型:stringnumberbooleansymbol都有对于的包装类,分别是StringNumberBooleanSymbol

除了Symbol外,另外三个包装器类均可以被显示实例化,即new Stringnew Numbernew Boolean均合法,但这并不好,而且任何时候都不鼓励显示创建包装器对象,这会模糊原始值与包装器对象的差异。所以在 ES6 中引入的Symbol不让被显示实例化(参考):

new Symbol(); // 报错: Symbol is not a constructor

但如果有别的原因,需要创建symbol的包装器对象,还是有方式的:

var a = Object(Symbol());
typeof a; // object

复合类型

复合类型仅一种object

typeof []; // object
typeof {}; // object
typeof Promise.resolve(); // object
typeof function() {}; // function    咦!我们中出了叛徒

莫慌,尽管在判断函数的时候返回了function,但就算烧成灰,在 JavaScript 中函数依然是属于object类型,一种在底层实现了[[Call]]内部方法的特殊object

复合类型可变,可添加属性/方法,另外一大特点是按引用传值,怎么理解呢?看示例:

var arr = [1, 2, 3];
var arr1 = arr;
arr1.push(4);
console.log(arr1); // [1,2,3,4]
console.log(arr); // [1,2,3,4]

发现异常没?明明我对arr1进行的操作,arr也跟着变了。我们这里不扯高深的内存操作,还是用那外星球的张三来做类比:

var arr = [1, 2, 3]; // 某个人([1,2,3])一出生就被分配了名字:arr(张三)
var arr1 = arr; // 后面他又得到一个名字:阿三(arr1)
arr1.push(4); // 阿三(arr1)被人揍了一顿,毁了容,变成了 [1,2,3,4]
console.log(arr); // 你说张三毁容没毁容?

搞清数据类型的情况下,什么时候是按值传递,什么时候按引用传递是不是一目了然?

大声说出来,以下代码的输出结果:

function fn() {}
fn.hehe = 'hehe';
console.log(fn.hehe); // 输出啥?
function factory(o) {
  o.hehe = 'haha';
}
factory(fn);
console.log(fn.hehe); // 输出啥?

另外,复合类型还有一大特性,不存在两个一样的值:

{} === {} // false
[] === [] // flase
function a(){} === function a(){} // false

类型判断

  • typeof
  • instanceof
  • Object.prototype.toString.call

typeof

有 7 种数据类型,typeof 也返回了 7 个不同的类型值,然鹅这两个 7 并不一一对应:

typeof 123 // number
typeof 'str' // string
typeof true // boolean
typeof Symbol() // symbol
typeof undefined // undefined
typeof fucntion(){} // function
typeof {} // object
typeof null // object
typeof new String("hello") // object

typeof null === 'object'是一开始设计 JavaScript 的时候产生的bug,只能将错就错一直沿用下来,这是客户端语言的无奈(向下兼容)。

所以,当使用typeof判断一个数据是否为object时一定要排除null的情况:

function isObject(obj) {
  return obj && typeof obj === 'object';
}

至于typeof fucntion(){}返回function,是标准规定的。

另外另外另外,还有一种特例

typeof document.all === 'undefined';

至于原因,看看document.all与 IE 的爱恨纠葛

所以,除了null,其他任意的数据我们均可以通过typeof操作符得到其类型。

至于,我们需要知道某对象具体是什么对象,比如:

  • 我们怎么知道变量arr是否是Array的实例?
  • 我们怎么知道变量pro是否是Promise的实例?

那么就要请出instanceof

instanceof

instanceof 顾名思义:a instanceof A的意义就是判断对象a是否是函数(也可以叫类)A的实例。

当然,以上表述不是完全准确,准确的表述应该是:判断A.prototype是否出现在了对象a的原型链上。(这边表述实际还是有问题,见后面)

下面简述__proto__的背景,方便代码演示,会在上面提到的文章中详细阐述。

最早由 Firefox 实现了通过属性__proto__访问对象的原型,这一特性如今几乎被所有的现代浏览器所实现,甚至还写入了 ES6 标准(尽管强烈不推荐在生产环境使用它,而是使用 Object.getPrototypeOf 方法),为方便,我们在演示代码中均用该特性读取原型,比如a.__proto__则是对象a的原型。

回到instanceof

var arr = [];
// 因为,以下表达式的结果为 true
arr.__proto__ === Array.prototype;
// 所以,以下表达式的结果也为 true
arr instanceof Array;

// 同样,由于以下表达式结果为 true
arr.__proto__.__proto__ === Object.prototype;
// 所以,以下表达式的结果也为 true
arr instanceof Object;

使用instanceof去判断对象的子类型有一些弊端,比如:

var o = Object.create(null);
o.__proto__; // undefined
typeof o; // object
o instanceof Object; // false
  • 强行嫁接原型的情况
var obj = {};
obj.__proto__ = Array.prototype;
obj instanceof Array; // true
  • instanceof的行为可被自定义
class MyClass {
  static [Symbol.hasInstance](instance) {
    return true;
  }
}

[] instanceof MyClass; // true

Object.create(null) instanceof MyClass; // true   完全不顾原型了

'sss' instanceof MyClass; // 连基本类型也不管了

var a;
// 理论上来讲,a 是任意值,下面表达式都为 true
a instanceof MyClass; // true
  • bind过的函数无prototype属性,依然可以被实例化
function A(){}
const B = A.bind(null)
const b = new B()
b instanceof A  // true
b.__proto__ === A.prototype // true
b instanceof B // true
b.__proto__ === B.prototype // false

所以,除非你很确定的情况,不然不要轻易使用instanceof去判断一个值是否属于某个类型。

Object.prototype.toString.call

人狠话不多,先看效果:

Object.prototype.toString.call([]); // [object Array]
Object.prototype.toString.call({}); // [object Object]
Object.prototype.toString.call(''); // [object String]
Object.prototype.toString.call(new Date()); // [object Date]
Object.prototype.toString.call(1); // [object Number]
Object.prototype.toString.call(function() {}); // [object Function]
Object.prototype.toString.call(/test/i); // [object RegExp]
Object.prototype.toString.call(true); // [object Boolean]
Object.prototype.toString.call(null); // [object Null]
Object.prototype.toString.call(); // [object Undefined]

这些形如:”[object Array]”的值是从哪冒出来的呢?

依据ES6 规范中关于Object.prototype.toString的实现要求,我们翻译成伪代码就一目了然了:

// 以下代码仅表示逻辑,不可作为js代码运行
function toString(val){
  if(val === undefined)return "[object Undefined]"
  if(val === null)return "[object Null]"
  let O = ToObject(val)
  let isArr = IsArray(val)
  ReturnIfAbrupt(isArr) // 如果 isArr 不是一个正常值,比如抛出一个错误,则中断执行
  let builtingTag;
  if(isArr) builtingTag = "Array";
  else if(O 是 exotic String object) builtingTag = "String";
  else if(O 有内部插槽 [[ParameterMap]]) builtingTag = "Arguments";
  else if(O 有内部方法 [[Call]]) builtingTag = "Function";
  else if(O 有内部插槽 [[ErrorData]]) builtingTag = "Error";
  else if(O 有内部插槽 [[BooleanData]]) builtingTag = "Boolean";
  else if(O 有内部插槽 [[NumberData]]) builtingTag = "Number";
  else if(O 有内部插槽 [[DateValue]]) builtingTag = "Date";
  else if(O 有内部插槽 [[RegExpMatcher]]) builtingTag = "RegExp";
  else builtingTag = "Object";
  let tag = O[Symbol.toStringTag];
  ReturnIfAbrupt(tag);
  if(Type(tag) 不是 String)tag = builtingTag
  return `[object ${tag}]`
}

上述伪代码用到了内部方法ToObjectIsArrayTypeReturnIfAbrupt,内部类型String Exotic Objects

特别说明下,很多书籍、博客里面提到的内部属性[[Class]],在 ES6 规范中的Object.prototype.toString不再读取。

通过上面的伪代码,我们可以知道,用户可以控制Object.prototype.toString的返回值:

let o = [];
Object.prototype.toString.call(o); // [object Array]
o[Symbol.toStringTag] = 'MyClass';
Object.prototype.toString.call(o); // [object MyClass]

至此,我们对三种判断类型的方式都已经做了介绍。

但是,你还需要搞清楚,类型判断的初衷是什么?

什么场景下使用什么方法你也需要仔细思量。

鸭子类型(duck typing)

鸭子类型这个概念来自鸭子测试,当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。而在程序设计中:

鸭子类型(英语:duck typing)是动态类型的一种风格。在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口,而是由”当前方法和属性的集合”决定。在鸭子类型中,关注点在于对象的行为,能作什么;而不是关注对象所属的类型。

鸭子类型在原生的 JavaScript 中最显著的代表莫过于Promise/A+中的thenable对象了。

thenable就是一个特殊的 js 对象,特殊在于这个对象本身或其原型链上存在then方法。所以,你懂的,当你在原型链的顶端添加了then方法,js 下任何的对象都成为了thenable对象:

Object.prototype.then = function(resolve, reject) {};

如上的代码被添加以后,一个正常依赖Promise的项目就无法工作正常了。这是鸭子类型一直被诟病的地方。

但,凡事具有两面性,既然引入这个概念,而且在各大编程语言特别是动态语言中作为一种设计模式,具有不可替代的作用。

依然拿thenable来说,在Promise正式进入规范之前,已经有很多库实现了类似思想,比如QjQuery.Deferred,如何保证已经使用了这些库的既有项目如何快速过度到标准的Promise呢?噢!感谢上帝,这些类库有一个共同特点,就是它们的对象都有then方法,在实现Promise时只需要这样判断一个值是否是thenable

var isThenable = obj =>
  (typeof obj === 'function' || (obj && typeof obj === 'object')) &&
  'then' in obj;

如果用判断鸭子类型的方式来判断一个数组,我们可能需要这样判断:

var obj = {};
var isArr = false;
if('splice' in obj && 'push' in obj && 'pop' in obj....){
  isArr = true;
}

更有甚者,我们可能还需要判断pushpop操作以后,数组的长度是否自动发生了变化…一切的判断取决于你的需求。

你需要知道一个值是否如你所期望的那样,你得去察其外貌观其行为,如果都符合你的预期,你就把它当你所需类型使用,这就是鸭子类型。

无论是使用 js 提供的类型判断,还是使用鸭子类型去判断,在动态语言下面都不存在绝对的靠谱,动态提供了便利的同时,对于开发人员的素质要求也会更高。多人协作的项目、中大型项目、频繁迭代的项目,也许拥抱强类型语言是更稳妥的选择。

个人觉得,从语言层面上来讲,鸭子类型比较像是强类型语言里面的接口(interface),接口的约束性更强,实现一个接口必须实现接口的所有方法与属性,甚至方法的签名都需要一模一样,减少了很多的不确定性。

下面以 typescript 的接口做演示结束本文:

interface Animal {
  name: string;
  say();
}

class Cat implements Animal {
  name = '';
  constructor(name: string) {
    this.name = name;
  }
  say() {
    console.log(`我是猫,我叫${this.name}`);
  }
}

class People implements Animal {
  name = '';
  constructor(name: string) {
    this.name = name;
  }
  say() {
    console.log(`我是人,我叫${this.name}`);
  }
}

function factory(animal: Animal) {
  animal.say();
}

const tom = new Cat('tom');
const zs = new People('张三');

factory(tom);
factory(zs);

// 我是猫,我叫tom
// 我是人,我叫张三

细数JavaScript中一些不能被new的函数

最近发现组内的很多人基础太不牢固,打算整一个关于JavaScript的思维导图,搞一次技术分享。结果发现原来自己也遗漏了相当多得细节。接下来,我就来细数下那些不能被new的函数,这些函数被new的时候会抛出错误:

Uncaught TypeError: xxx is not a constructor

在js中,function类型是一种特殊的对象,特殊在何处呢?特殊在于它是一个在引擎内部实现了[[Call]]方法的对象,所以,在使用typeof取值的时候被特殊对待,返回了function
对于new操作符也是一样,会去查看该对象内部是否实现了[[Construct]]方法,未实现则抛出上述的TypeError

var obj = {
    a(){},
    b: ()=>{},
    c: function* (){},
    d: async ()=>{},
    e: async function d(){},
    f: async function* e(){},
    g: function f(){}
}

以上列出的这些函数中仅仅obj.g可以被实例化。

另外,内置对象上的一些静态方法大多(未验证全部)不可实例化,如:Math.powJSON.parse等。

内置对象的一些实例方法也是同样,如:[].splice"hello".split等。

内置的一些函数也是无法被实例化的,如:encodeURISymbol等。

最后还有Function.prototype,这个特殊的函数也不可被实例化。

ps:
以上提及的这些不能被实例化的函数有一个功能特点(除了Symbol、obj.c、obj.f),那就是它们都没有prototype属性,然鹅,并不能说明一个类能不能被实例化与这个构造函数有没有prototype属性有必然联系,比如:

var A = (function test(){}).bind(this);

bind得到的函数A就不存在prototype属性,但A依然可以被实例化。所以,还是开头的那个结论,一个函数能不能被实例化,就看其内部是否有实现[[Construct]]方法。

当然,话说回来,prototype属性决定了一个可被实例化的函数能否作为父类被继承。还是看这个代码:

var A = (function test(){}).bind(this);
class B extends A{}   // 报错 Uncaught TypeError: Class extends value does not have valid prototype property undefined

A.prototype = null;
class C extends A{}  // 正常

当当当然,null又是个特例,ES6规范规定了,跟在extends后面的只能是一个可被实例化且其prototype属性为对象(包括函数)或null的对象(包括函数),所以下面的代码是合法的:

class D extends null{}

但是这样声明的D无法被实例化,因为找不到超类的构造函数,需要这样处理:

class D extends null{
    constructor(){
        return Object.create(new.target.prototype)
    }
    otherMethod(){}
}