你不知道的Javascript(中)读书笔记

180 阅读17分钟

最近在阅读《你不知道的JS》一书,以下是我的读书笔记,包括但不限于书中内容。本文内容仅供分享交流,转载请注明出处。若存在理解有误之处,还请各位不吝赐教!

第一部分:类型和语法

类型

  1. 定义:类型是值的内部特征,它定义了值的行为,以使其区别于其他值。在ES规范中,类型又被分为:语言类型和规范类型。

    • 语言类型:即我们在编写JS代码时可以操作的值的类型,包括 UndefinedNullBoolean 等。
    • 规范类型:即用于描述JS语言构造及类型的元值(meta-values),包括ReferenceProperty Descriptor 等。

    当然,我们在日常开发中所说的“JS的数据类型”,通常指的是“语言类型”。

  2. JS的数据类型由 原始值对象 组成,共 8 种。

    除了“对象”之外,其他统称为:“基本类型”。

  3. typeof关键字:

    • 该关键字常被用来查看各个值的类型,它返回的是类型的字符串值。在使用时,需注意以下几种特殊情况。

      • typeof null === 'object'; //true

        • 此处的期望结果本应该是“null”,但该bug是在JS设计之初就遗留下来的,一直未得以修复。因其涉及到大量的Web应用,若冒然修复,将会导致更多的bug产生。(戳我了解详细原理
      • typeof function a() { /* ... * / } === 'function'; //true

        • function实际上是object的一个“子类型”,且本质上是一个“可调用对象”,其内部属性[[Call]]使得自身可以被调用。
      • typeof [1, 2, 3] === 'object'; //true

        • array实际上也是object的一个“子类型”。
    • 在对变量执行typeof操作时,得到的结果并不是该变量的类型,而是改变量持有的值的类型,因为JS中的变量没有类型。

  4. undefinedundeclared

    • undefined:已在作用域中声明但还没有为其赋值的变量,是undefined的。
    • undeclared:未在作用域中声明过的变量,是undeclared的。

    浏览器对上述两种情况的反馈如下:

    var a;
    a; //undefined
    b; //ReferenceError: b is not defined
    

    typeof关键词对上述两种情况的反馈如下:

    var a;
    typeof a; //"undefined"
    typeof b; //"undefined"
    

    PS:上述代码中,b变量虽然是一个undeclared变量,但并不会报错,这是因为typeof内部有一套特殊的安全防范机制。利用该机制(阻止报错)来检查undeclared变量不失为一个不错的办法。具体场景如下:

    //场景:在程序中,使用全局变量DEBUG作为“调试模式”的开关,在做具体处理之前,需要先检查DEBUG变量是否已经被声明。而顶层的全局变量声明`var DEBUG = true`只存在于debug.js文件中,且该文件只在开发及测试环境中才会被加载,生产环境中不予加载。
    //问题:如何在程序中检查全局变量DEBUG才不会出现ReferenceError错误?//以下做法,会抛出错误
    if(DEBUG) {
        //...
    }
    ​
    //以下做法,安全且符合预期
    if(typeof DEBUG !== "undefined") {
        //...
    }
    ​
    //上述方案(后者),不仅对用户定义的变量有用,对内建的API也有帮助
    if(typeof atob === 'undefined') {
        atob = function () { /* ... */ };
    }
    

  1. 数组

    • “稀疏”数组:即含有空白或空缺单元的数组。例如:

      var arr = [1, 2, , , 5, 6];
      
    • 数组是通过数字进行索引的。但因其同时也是对象,所以也可以包含字符串键值和属性(但并不会被计算在数组长度内)。

    • 在对数组使用索引器时,如果字符串键值能够被强制类型转换为十进制数字的话,它就会被当做数字索引来处理。例如:

      var arr = [];
      arr['15'] = 'hhh';
      arr.length; //16
      
  2. 字符串

    • JS中,字符串是不可变的,而数组是可变的。字符串不可变,是指字符串的成员函数不会改变其原始值,而是创建并返回一个新的字符串;而数组的成员函数都是在其原始值上进行操作。
  3. 数字

    JS中的数字,采用的是“双精度浮点型”格式,即 64 位二进制,基于“IEEE 754”标准。

    • 语法:

      • JS中的数字常量一般用十进制表示,数字前面的 0 可以省略,小数部分也可以最后面的 0 也可以省略。如:

        var a = 15.6;
        var b1 = 0.35;
        var b2 = .35;
        var c1 = 42.0;
        var c2 = 42.; // 该写法没问题,但不常见,也不推荐
        
      • 默认情况下,大部分数字都以十进制显示,小数部分最后面的 0 被省略。如:

        var a = 15.6000;
        var b = 15.0;
        a; //15.6
        b; //15
        
      • 特别大或特别小的数字默认用指数格式显示,与数字.toExponential()函数的输出结果相同。

      • 使用.运算符时需注意:.会被优先识别为数字常量的一部分,然后才是对象属性访问运算符。

        35.toFixed(2); // 报错:SyntaxError
        ​
        (35).toFixed(2); // '35.00'
        0.3.toFixed(2); // '0.30'
        35..toFixed(2); // '35.00'
        
    • 较小的数值

      • 二进制浮点数的最大的问题(不仅JS,所有基于“IEEE 754”标准的语言均是如此),是会出现如下情况:

        0.1 + 0.2 === 0.3; // false
        

        之所以会出现上述情况,是因为二进制浮点数中的 0.1 和 0.2 并不是十分精确,它们相加的结果并非刚好等于 0.3,而是一个比较接近的数字 0.30000000000000004,所以条件判断结果为 false。(戳我了解详细原理

        • 应尽量避免使用 JS 处理带小数的数字,如果实在无法避免,可考虑将其放大一定倍数后作为整数进行处理,处理之后再缩小相应的倍数得到最终结果。例如:

          // 求 0.1 + 0.2 的结果?
          // 1. 将所有数统一放大10倍后再相加,即:1 + 2 = 3
          // 2. 将相加结果缩小10倍,得到最终结果,即:3 / 10 = 0.3
          
        • 怎么判断 0.1 + 0.20.3是否相等?

          设置一个误差范围值,通常称为“机器精度”,对于JS的数字来说,该值通常为 2^-52(2的-52次方)。从ES6开始,可以通过Number.EPSILON获取到。具体方法如下:

          function numCloseEnoughToEqual(n1, n2) {
              return Math.abs(n1 - n2) < Number.EPSILON;
          }
          
    • 相关静态属性及方法

      • Number.MAX_VALUE:1.7976931348623157e+308,JS中所能表示的最大数值

      • Number.MIN_VALUE:5e-324,JS中所能表示的最小的正值,且无限接近于 0

      • Number.MAX_SAFE_INTEGER:2^53 - 1,JS中最大的安全整数

      • Number.MIN_SAFE_INTEGER:-(2^53 - 1),JS中最小的安全整数

      “安全整数”是必须是符合以下条件的整数:

      • 可以准确地表示为一个 IEEE-754 双精度数字,
      • 其 IEEE-754 表示不能是舍入任何其他整数以适应 IEEE-754 表示的结果。.
      • Number.isInteger(...):判断给定的参数是否为一个整数
      • Number.isSafeInteger(...):判断传入的参数值是否为一个“安全整数”
  4. undefined 和 null

    undefined 类型只有一个值,即 undefined。null 类型也只有一个值,即 null。它们的名称既是类型也是值。两者的区别如下:

    • undefined:指没有值(missing value);或从未赋值
    • null:指空值(empty value);或曾赋过值,但目前没有值

    null 是一个特殊关键字,不是标识符,我们不能将其当做变量来使用和赋值。然而,undefined 却是一个标识符,可以被当做变量来使用和赋值。

    在非严格模式下,我们可以为全局标识符 undefined 赋值,但不提倡该行为!永远不要重新定义 undefined。

  5. void运算符

    undefined 是一个内置标识符(除非被重新定义),它的值为 undefined,通过void运算符即可得到该值。

    表达式void __没有返回值,因此返回结果为undefinedvoid并不会改变表达式的结果,只是让表达式不返回值。

    通常,我们使用void 0来获取undefined 值,此外还可以使用:void 1等。三者之间并无实质上的区别。

  6. 无穷数

    JS使用有限数字表示法,所以和纯粹的数字运算不同,JS的运算结果有可能溢出,此时结果为Infinity或者-Infinity

    规范规定,如果数学运算的结果超出处理范围,则由 IEEE 754 规范中的“就近取整”模式来决定最后的结果。

    var a = Number.MAX_VALUE; //1.7976931348623157e+308
    a + a; //Infinity
    a + Math.pow(2, 970); //与Infinity更为接近,则“向上取整”,即Infinity
    a + Math.pow(2, 969); //与Number.MAX_VALUE更为接近,则“向下取整”,即1.7976931348623157e+308
    

    计算结果一旦溢出为无穷数就无法再得到有穷数。

    • Infinity / Infinity是一个未定义操作,结果为NaN
    • 有穷正数 / Infinity,结果为 0
    • 有穷负数 / Infinity,结果为 -0
  7. 零值

    JS中的零值有两个,分别为:0-0

    根据规范,对数字-0进行字符串化,会返回'0';但反过来将'-0'转换为数字,则会得到数字-0

    JSON.stringify(-0)返回'0'JSON.parse('-0')返回-0

    JS中,为什么需要负零呢?

    有些应用程序中的数据需要以级数形式来表示(比如动画帧的移动速度),数字的符号位用来代表其他信息(比如移动的方向)。 此时如果一个值为 0 的变量失去了它的符号位,它的方向信息就会丢失。所以保留 0 值的符号位可以防止这类情况发生。

  8. 特殊等式

    • NaN不等于自身

    • -0 == 0、`-0 === 0``

    Object.is(..., ...):判断两个值是否绝对相等,可以用来处理上述特殊情况。即 “NaN等于自身”、“-0不等于0”。

    该方法的 polyfill 代码如下:

    if (!Object.is) {
    Object.is = function (x, y) {
       //判断是否为 -0
       if (x ===0 && y === 0) {
         return 1 / x === 1 / y;
       }
       //判断是否为 NaN
       if(x !== x) {
           return y !== y;
       }
       //其他情况
       return x === y;
     }
    });
    }
    

原生函数

包装类(函数):Number、String、Boolean

其他原生函数:Array、Object、Function、RegExp、Date、Error、Symbol

强制类型转换

显式、隐式

语法

......(略)

补充:

  1. 由于浏览器演进的历史遗留问题,在创建带有id属性的DOM元素时,也会自动创建同名的全局变量。
  2. 不要扩展原生原型。首先,不要扩展原生方法,除非你确信代码在运行环境中不会有冲突;其次,在扩展原生方法时需要加入判断条件,因为你可能无意中覆盖了原来的方法。
  3. polyfill 能有效地为不符合最新规范的老版本浏览器填补缺失的功能。让你能够通过可靠的代码来支持所有你想要支持的运行环境。
  4. ES规范中定义了一些“保留字”,我们不能将它们用作变量名。这些保留字分为四类:关键字、预留关键字、null常量、true/false布尔常量。
  5. 在ES5之前,“保留字”也不能用来作为对象常量中的属性名称或者键值,但是现在已经没有这个限制。

第二部分:异步和性能

异步 & 回调

......(略)

Promise

// 支持Promise的ajax工具(仅Get请求)
function ajaxPro(url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.onreadystatechange = () => {
      if(xhr.readyState === 4 && xhr.status === 200) {
        const data = JSON.parse(xhr.responseText);
        resolve(data);
      }
    }
    xhr.send();
  })
}

生成器

  1. “生成器”的实现原理

    // 需要转换的样例(ajaxPro(...)是支持Promise的ajax工具)
    function* foo(url) {
      try {
        console.log('requesting:', url);
        var val = yield ajaxPro(url);
        console.log(val);
      } catch (err) {
        console.log('oops:', err);
        return false;
      }
    }
    var it = foo('http://hechunxu.tech/api/blogs');
    
    • 手动转换

      经分析,先给上述代码中的各部分标记对应的状态值,如下:

      function* foo(url) {
        //状态1
        try {
          console.log('requesting:', url);
          var TEMP = ajaxPro(url);
          //状态2(TEMP为临时变量,方便状态分析)
          var val = yield TEMP;
          console.log(val);
        } catch (err) {
          //状态3
          console.log('oops:', err);
          return false;
        }
      }
      var it = foo('http://hechunxu.tech/api/blogs');
      

      结合生成器闭包switch-case语句等的特点,转换后的代码如下:

      function foo() {
        var state; //生成器的状态
        var val; //生成器的暂存值
      ​
        function process(v) { //处理不同的状态(对应源代码中各部分的内容)
          switch (state) {
            case 1:
              console.log('requesting:', url);
              return ajaxPro(url);
            case 2:
              val = v;
              console.log(val);
            case 3:
              var err = v;
              console.log('oops:', err);
              return false;
          }
        }
      ​
        return {
          next: function (v) {
            if(!state) { //初始状态
              state = 1;
              return {
                value: process(),
                done: false
              }
            }else if(state === 1) { //yield成功恢复
              state = 2;
              return {
                value: process(v),
                done: false
              }
            }else { //生成器已完成
              return {
                value: undefined,
                done: true
              }
            }
          },
          throw: function (e) {
            if(state === 1) { //唯一的显式错误处理在状态1(源代码中使用了try-catch)
              state = 3;
              return {
                value: process(e),
                done: true
              }
            }else {
              throw e;
            }
          }
        }
      }
      
    • 自动转换

      regenerator _ Facebook开发的JS库

  2. 补充

    • 每次调用生成器函数以构建一个迭代器时,实际上就隐式构建了生成器的一个实例,通过这个迭代器来控制的是这个生成器实例。同一个生成器的多个实例可以同时运行,甚至可以彼此交互。

    • for...of循环在每次迭代中会自动调用next(),但它不会向next()传入任何值,并且会在接收到done:true之后自动停止。这对于在一组数据上循环很方便。

    • 生成器yield暂停的特性,意味着我们不仅能够从异步函数调用得到看似同步的返回值,还可以同步捕获来自这些异步函数调用的错误。甚至可以捕获通过it.throw(...)抛入生成器的同一个错误,即给生成器处理它的机会;如果没有处理的话,迭代器代码就必须处理。

      function *main() {
        var x = yield 'text';
        console.log(x);
      }
      var it = main();
      it.next();
      ​
      try {
        it.throw('error')
      } catch (err) {
        console.log(err); //输出:error
      }
      
    • asyncawait的实现原理(生成器函数 & yield关键字)

      // 核心方法
      function run(gen, ...args) {
        const it = gen.apply(this, args);
        return Promise.resolve().then(function handleNext(value) {
          const next = it.next(value);
      ​
          return (function handleResult() {
            if(next.done) {
              return next.value;
            }else {
              return Promise.resolve(next.value).then(handleNext, function handleError(err) {
                return Promise.resolve(it.throw(err)).then(handleResult);
              });
            }
          }(next));
        })
      }
      ​
      // 使用示例
      function* demo() {
        try {
          const resFoo = yield ajaxPro(`http://hechunxu.tech/api/blogs`);
          console.log(resFoo);
        } catch (err) {
          console.log(err);
        }
      }
      run(demo);
      
    • yield委托

      • 目的:便捷在生成器函数内部调用其他生成器函数,已达到与普通函数调用类似的效果。

      • 使用示例:

        function* foo() { /** ... */ }
        function *bar() { /** ... */ }
        ​
        function* test() {
            yield* foo();
            yield *bar();
        }
        

yield / next(...)既一种控制机制,也是一种双向消息传递机制。

在异步控制流程方面,生成器的关键优点是:生成器内部的代码是以自然的“同步/顺序”方式表达任务的一系列步骤。

程序和性能

对于JS来说,“异步”为什么如此重要?性能

  1. Web Worker

    1. Worker 创建方法:(H5新增的API,依赖于浏览器,和JS本身并无关系)

      const w1 = new Worker('./extra.js'); //参数取值:指向外部JS文件的URL | Blob URL
      

      通过上述方法创建的 Worker 被称为“专用 Worker”

    1. Worker 之间以及它们和主程序之间,不会共享任何作用域或资源,那会把所用多线程编程的噩梦带到前端领域,而是通过一个基本的事件消息机制相互联系。使用方法如下:

      // index.js
      const w1 = new Worker('./extra.js');
      w1.addEventListener('message', evt => {
        console.log(evt.data);
      });
      w1.postMessage('Just a test!');
      // w1.terminate(); //终止Worker// extra.js
      importScripts('foo.js', 'bar.js'); //在Worker内部,可以使用 importScripts “同步”加载额外的JS脚本
      addEventListener('message', evt => {
          console.log(evt.data);
      })
      postMessage('Replay to main worker');
      

      “专用 Worker”是一个完全独立的线程,并且和创建它的程序之间是“一对一”的关系。

    1. Worker 的应用场景:

      • 处理密集型数学计算
      • 大数据集排序
      • 数据处理(压缩、音频分析、图像处理等)
      • 高流量网络通信
    1. 共享 Worker

      一种特定类型的 Worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 Worker。如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的(相同的协议、host 以及端口)。

      // index.js
      const w1 = new SharedWorker('./extra.js');
      w1.port.addEventListener('message', evt => {
        console.log(evt.data);
      })
      w1.port.postMessage('something cool');
      w1.port.start(); //初始化端口连接
      ​
      // extra.js
      addEventListener('connect', evt => {
          const port = evt.ports[0];
          port.addEventListener('message', e => {
              const workerResult = 'Result: ' + (e.data);
              port.postMessage(workerResult);
          });
          port.start(); //初始化端口连接
      });
      ​
      // 输出结果
      // Result: something cool
      

      如果有某个端口连接终止而其他端口仍然活跃,那么“共享 Worker”不会终止。而对于“专用 Worker”来说,只要实例化它的连接终止,它就会终止。

  1. SIMD & asm.js(略,不重要)

性能测试与调优

  1. Benchmark.js

    • 简介:一个统计学上有效的性能测试工具,它处理了为给定的一段JS代码建立公平、可靠、有效的性能测试的所有复杂性。
    • 适用:浏览器端 & Node端
    • 文档:github.com/bestiejs/be…
  1. jsPerf.com

    • 简介:依赖 Benchmark.js 库,并将测试结果放在一个公开可得的 URL 上以便分享。
    • 官网:jsperf.com
  2. 测试

    要写好测试,需要认真分析和思考两个测试用例之间有什么区别、以及这些区别是有意还是无意的。

    有意的区别当然是正常的,没有问题,可我们太容易造成会扭曲结果的无意的区别。你需要非常小心才能避免这样的扭曲。还有,你可能有意造成某个区别,但是,对于这个测试的其他人来说,你的这个意图可能不是别那么明显,所以他们可能会错误地怀疑(或信任)你的测试。如何解决这样的问题呢?

    编写更好更清晰的测试。 花一些时间来能编写文档,精确表达你的测试目的,甚至对于那些微小的细节也要如此。找出那些有意的区别,这会帮助别人和未来的你更好地识别出那些可能扭曲测试结果的无意区别。

    不要试图空化到真实代码的微小片段,以及脱离上下文而只测量这一小部分的性能, 因为包含更大(仍然有意义的)上下文时功能测试和性能测试才会更好。这些测试可能也会运行得慢一点,这意味着环境中发现的任何差异都更有意义。

  1. 微性能

    在考虑对代码进行测试时,你应该习惯的第一件事情就是:你所写的代码并不总是引擎真正运行的代码。

    当你把 JS 代码看做对引擎要做什么的提示和建议,而不是逐字逐句的要求时,你就会意识到,对于具体语法细节的很多执着迷恋就已经烟消云散了。此时,编写意义最明确的代码显得尤为重要。

    const arr = [..., ..., ...];
    // 用例1
    for(let i = 0; i < arr.length; i++) {
        //...
    }
    // 用例2
    const len = arr.length;
    for(let i = 0; i < len; i++) {
        //...
    }
    ​
    // 理论上说,这里应该在变量len中缓存arr数组的长度,因为表面上它不会改变,来避免在每个循环迭代中计算arr.length的代价。
    // 如果运行性能测试工具来比较上述两个用例,你会发现尽管理论听起来没错,但实际的可测差别在统计上是完全无关紧要的。
    ​
    // 实际上,在某些像V8这样的引擎中,可以看到(https://mrale.ph/blog/2014/12/24/array-length-caching.html),预先缓存长度而不是让引擎为你做这件事情,会使性能稍微下降一点。不要试图和JS引擎比谁聪明。对性能优化来说,你很可能会输。
    

    “过早优化是万恶之源” & “非关键路径上的优化是万恶之源”

  1. ★ 尾调用优化 ★

    尾调用优化:Tail Call Optimization(TCO)

    • 简单来说,尾调用就是一个出现在另一个函数“结尾”处的函数调用,这个调用结束后就没有其余事情要做了(除了可能要返回结果值)

      function foo(x) {
          return x;
      }
      function bar(y) {
          return foo(y + 1); // 尾调用
      }
      function func() {
          return 1 + f2(998); // 非尾调用
      }
      func(); // 999
      
    • 调用一个新的函数需要额外的一块预留内存来管理调用栈,称为“栈帧”。

    • 因此,上述代码一般会为foobarfunc各保留一个栈帧。然而,如果支持TCO的引擎能够意识到foo(y + 1)调用位于尾部,这意味着bar(...)基本上已经完成了,那么在调用foo(...)时它就不需要创建一个新的栈帧,而是可以重用已有的bar(...)的栈帧。这样不仅速度更快,也更节省内存。

      在简单的代码片段中,这类优化算不了什么,但是在处理递归时,这就解决了大问题,特别是如果递归可能会导致成百上千个栈帧的时候。有了TCO,引擎可以用同一个栈帧执行所有这类调用!

      递归是JavaScript中一个纷繁复杂的主题。因为如果没有TCO的话、引擎需要实现一个随意(还彼此不同! )的限制来界定递归栈的深度,达到了就得停止,以防止内存耗尽。有了TCO,尾调用的递归函数本质上就可以任意运行,因为再也不需要使用额外的内存!

    • ES6为什么要求引擎实现TCO,而不是将其留给引擎自由决定?

      因为缺乏TCO会导致一些JS算法因为害怕调用栈限制而降低了通过递归实现的概率。

      如果在所有的情况下引擎缺乏TCO只是降低了性能,那它就不会成为ES6所要求的东西。但是,由于缺乏TCO确实可以使一些程序变得无法实现,所以它就成为了一个重要的语言特性而不是隐藏的实现细节。