《你不知道的javascript》中卷

65 阅读22分钟

摘要

本书分为两大部分

  • 类型和语法

    • 本文基于这部分的重点聚焦于:隐式类型转换/类型判断
  • 异步和性能

    • 本文基于这部分的重点聚焦于:异步promise

类型

众所周知,JavaScript有八种内置类型:

  • 空值(null)
  • 未定义(undefined)
  • 布尔值(boolean)
  • 数字(number)
  • 字符串(string)
  • 对象(object)
  • 符号(symbol, ES6中新增)
  • bigInt(ES10新增)
  • null是“假值”,也是唯一一个用typeof检测会返回"object"的基本类型值。它返回"object"的原因是:
  • 1995年JavaScript语言的第一版,所有值都设计成32位,其中最低的3位用来表述数据类型,object对应的值是000。 当时,只设计了五种数据类型(对象、整数、浮点数、字符串和布尔值),完全没考虑null,只把它当作object的一种特殊值,32位全部为0。 所以typeof null返回object。
  • 大多数开发者倾向于将undefined等同于undeclared(未声明),但在JavaScript中它们完全是两回事。已在作用域中声明但还没有赋值的变量,是undefined的。相反,还没有在作用域中声明过的变量,是undeclared的。

第2章 值

字符串经常被当成字符数组。字符串的内部实现究竟有没有使用数组并不好说,但JavaScript中的字符串和字符数组并不是一回事,最多只是看上去相似而已。

,它们都是类数组,都有length属性以及indexOf(..)(从ES5开始数组支持此方法)和concat(..)方法。 字符串不可变是指字符串的成员函数不会改变其原始值,而是创建并返回一个新的字符串。

  • toPrecision(..)方法用来指定有效数位的显示位数
  • undefined指从未赋值• null指曾赋过值,但是目前没有值
  • null是一个特殊关键字,不是标识符,我们不能将其当作变量来使用和赋值。然而undefined却是一个标识符,可以被当作变量来使用和赋值。
  • 在非严格模式下,我们可以为全局标识符undefined赋值,按惯例我们用void 0来获得undefined。
  • 如果数学运算的操作数不是数字类型(或者无法解析为常规的十进制或十六进制数字),就无法返回一个有效的数字,这种情况下返回值为NaN。
  • 将NaN理解为“无效数值”“失败数值”或者“坏数值”可能更准确些。NaN意指“不是一个数字”(not a number),这个名字容易引起误会。NaN是一个特殊值,它和自身不相等,NaN ! = NaN为true,很奇怪吧,NaN是JavaScript中唯一一个不等于自身的值。
  • 很多JavaScript程序都可能存在NaN方面的问题,所以我们应该尽量使用Number.isNaN(..)这样可靠的方法,去检验 一个变量是不是 NaN.

JavaScript使用有限数字表示法(finite numeric representation,即IEEE 754浮点数),所以和纯粹的数学运算不同,JavaScript的运算结果有可能溢出,此时结果为Infinity或者-Infinity。规范规定,如果数学运算(如加法)的结果超出处理范围,则由IEEE 754规范中的“就近取整”(round-to-nearest)模式来决定最后的结果。在计算时,因为就近取整,可能会带来问题。

比如:无穷除以无穷会得到什么结果呢?

我们的第一反应可能会是“1”或无穷,可惜不是。因为从数学运算和JavaScript语言的角度来说,Infinity/Infinity是一个未定义操作,结果为NaN。

-0 除了可以用作常量以外,也可以是某些数学运算的返回值。 会由计算得到,-1/infiniy也是-0。

我们为什么需要负零呢?

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

  • ES6中新加入了一个工具方法Object.is(..),Object.is(..)用来处理所有的两个值是否相等,能使用==和===尽量不使用Object.is(..),因为前者效率高。

值传递

简单值(即标量基本类型值,scalar primitive)总是通过值复制的方式来赋值/传递,包括null、undefined、字符串、数字、布尔和ES6中的symbol。复合值(compound value)——对象(包括数组和封装对象,参见第3章)和函数,则总是通过引用复制的方式来赋值/传递。

JavaScript中不存在指向[]的指针,只存在指向数组的引用

        function foo(x) {
            x.push( 4 );
            x; // [1,2,3,4]
            x = [4,5,6];
            x.push( 7 );
            x; // [4,5,6,7]
        }
​
        var a = [1,2,3];
​
        foo( a );
​
        a; // 是[1,2,3,4],不是[4,5,6,7]

我们向函数传递a的时候,实际是将引用a的一个复本赋值给x,而a仍然指向[1,2,3]。在函数中我们可以通过引用x来更改数组的值(push(4)之后变为[1,2,3,4])。但x =[4,5,6]并不影响a的指向,所以a仍然指向[1,2,3,4]。

     function foo(x) {
            x.push( 4 );
            x; // [1,2,3,4]
​
            // 然后
            x.length = 0; // 清空数组
            x.push( 4, 5, 6, 7 );
            x; // [4,5,6,7]
        }
​
        var a = [1,2,3];
​
        foo( a );
​
        a; // 是[4,5,6,7],不是[1,2,3,4]

如果要将a的值变为[4,5,6,7],必须更改x指向的数组,而不是为x赋值一个新的数组。所以,有时候我们不希望a被改变,就需要传递a的副本。a.slice(),如果通过值复制的方式来传递复合值(如数组),这样传递的就不再是原始值。

第3章 原生函数

  • 对基本类型值,应该优先考虑使用"abc"和42这样的基本类型值,而非new String("abc")和new Number(42)。因为浏览器已经为.length这样的常见情况做了性能优化,直接使用封装对象来“提前优化”代码反而会降低执行效率。
  • 构造函数Array(..)不要求必须带new关键字。不带时,它会被自动补上。因此Array(1,2,3)和new Array(1,2,3)的效果是一样的。

虽然a1=Array.apply( null, { length: 3 } )在创建undefined值的数组时有些奇怪和繁琐,但是其结果远比a2=Array(3)更准确可靠。这是因为,a1实际上是用undefiend填充的,a2数组中的元素都是空。如下图:

image.png

  • 如果调用Date()时不带new关键字,则会得到当前日期的字符串值。
  • 对于简单标量基本类型值,比如"abc",如果要访问它的length属性或String.prototype方法,JavaScript引擎会自动对该值进行封装(即用相应类型的封装对象来包装它)来实现对这些属性和方法的访问。

第4章 强制类型转换 重点!!!!

  • 隐式的情况称为强制类型转换,类型转换发生在静态类型语言的编译阶段,而强制类型转换则发生在动态类型语言的运行时。
  • 强制类型转换常发生于 == ,所以在判断两个值的时候,我们尽量用 ===

== 的隐式类型转换

  • 首先,在 js 中有很多假值。有六个假值: undefined 、 null 、 NaN 、 0 、 "" (空字符串)和 false
  • 其他类型转化成布尔值
  • 如果是数字,除了0和NaN,都转化为true
  • 如果是字符串,除了空串,都转化为true
  • 如果是对象,都转化为true
  • 如果是null,undefined都转化为false

隐式类型转换规则:

  1. 等号两边有null或者undefined,他们只和对方以及自身相等,和其他的都不等。

image.png

基本类型-> 尽量转换成数字

  1. 如果等式两边有布尔类型,要将布尔类型转化成数字。
  2. 如果等式两边有数字,就要将非数字的转化成数字。如果一边是布尔值,一边是数字,把布尔值转数字。

引用类型 -> 尽量转化成字符串

  1. 为了将值转换为相应的基本类型值,抽象操作ToPrimitive(参见ES5规范9.1节)会首先(通过内部操作DefaultValue,参见ES5规范8.12.8节)检查该值是否有valueOf()方法。如果有并且返回基本类型值,就使用该值进行强制类型转换。如果没有就使用toString()的返回值(如果存在)来进行强制类型转换。

一些例子

false == "" // true

根据第2条,false => 0

再根据第3条,将 "" => 0

false == [] // true

根据第二条,false => 0

再根据第四条,将[] => ""

再根据第三条,将 "" => 0

! [ ] == '' // true

!的优先级高于 == ,而且!是一个转化成布尔值再取反的含义

所以这里[ ] => true ![ ] => false => 0

'' => 0

所以结果是true

![ ] == [ ] // true

通过上面的分析,这个就变得很容易了

![ ] => false => 0

[ ] => "" => 0

所以最后返回true

!{} == {} // false

!{} => false => 0

根据第4条,{}转化成字符串,调用toString,返回值是"[object Object]"

根据第3条, "[object Object]" => NaN

所以最后返回false

重新给{}封装一个toString方法,就可以使 !{} == {}相等

let obj = {
    toString() {
        return 0
    }
}
console.log(obj == !obj)  // true

在调用toString的时候,返回0,就和false相等

false == '0'

false => 0

'0' => 0

NaN===NaN // false

唯一一个不等于自身的值。判断一个数是不是NaN使用Number.isNaN()来判断。不要使用window.isNaN()来判断,因为window.isNaN('abc') 用这个方法来判断字符串,它也是true。这是一个老bug

如何负负得正?

由于--会被当作递减运算符来处理,所以我们不能使用--来撤销反转,而应该像- -"3.14"这样,在中间加一个空格,才能得到正确结果3.14。

在代码中,你是不是经常这样判断>= 0和== -1?

-1是一个“哨位值”,哨位值是那些在各个类型中(这里是数字)被赋予了特殊含义的值。在C语言中我们用-1来代表函数执行失败,用大于等于0的值来代表函数执行成功。JavaScript中字符串的indexOf(..)方法也遵循这一惯例,该方法在字符串中搜索指定的子字符串,如果找到就返回子字符串所在的位置(从0开始),否则返回-1。

这样的写法不是很好,称为“抽象渗漏”,意思是在代码中暴露了底层的实现细节,这里是指用-1作为失败时的返回值,这些细节应该被屏蔽掉。

如何屏蔽这些细节?

字位运算符(如|和~)和某些特殊数字一起使用时会产生类似强制类型转换的效果,返回另外一个数字。~x大致等同于-(x+1)。

说这个有什么用?

我们就可以靠~和indexOf()一起可以将结果强制类型转换(实际上仅仅是转换)为真/假值。

如果indexOf(..)返回-1, ~将其转换为假值-(-1+1)=0,其他情况一律转换为真值。

猜一下 parseInt(1/0, 19)等于什么?

答案是18.

ES5之前的parseInt(..)有一个坑导致了很多bug。即如果没有第二个参数来指定转换的基数, parseInt(..)会根据字符串的第一个字符来自行决定基数。

这里的基数是19

它的有效数字字符范围是0-9和a-i(区分大小写)。a->10,i->18

所以

parseInt(1/0, 19)实际上是parseInt("Infinity", 19)。第一个字符是"I",以19为基数时值为18。第二个字符"n"不是一个有效的数字字符,解析到此为止。

你可以再试试parseInt('j', 21)答案是19.

在if(..)和三元运算这样的布尔值上下文中,如果没有使用Boolean(..)和!!,就会自动隐式地进行ToBoolean转换。建议使用Boolean(..)和!!来进行显式转换以便让代码更清晰易读。

如下图所示,你知道a+b为何得出这个值吗?

image.png

根据规范,如果某个操作数是字符串或者能够通过以下步骤转换为字符串的话,+将进行拼接操作。如果其中一个操作数是对象(包括数组),则首先对其调用ToPrimitive抽象操作,该抽象操作再调用[[DefaultValue]],即调用valueOf方法。但因为数组的valueOf()操作无法得到简单基本类型值,a.valueOf得到的值还是[1,2],于是它转而调用toString()。因此上例中的两个数组变成了"1,2"和"3,4"。+将它们拼接后返回"1,23,4"。

image.png

如上图所示,你知道出现的原因吗?

第一行代码中,{}被当作一个独立的空代码块。相当于 + [],而 +[] -> +'' -> +0. 结果就是 0

第二行代码中,[] + {} -> '' + {} ->'' + '[object Object]' -> '[object Object]'

下面的情况会发生布尔值隐式强制类型转换。

(1) if (..)语句中的条件判断表达式。

(2) for ( .. ; .. ; .. )语句中的条件判断表达式(第二个)。

(3) while (..)和do..while(..)循环中的条件判断表达式。

(4) ? :中的条件判断表达式。

(5) 逻辑运算符||(逻辑或)和&&(逻辑与)左边的操作数(作为条件判断表达式)。

&&和||运算符的返回值并不一定是布尔类型,而是两个操作数其中一个的值。

在C和PHP中,&&和||的结果是true或false。

在JavaScript(以及Python和Ruby)中却是某个操作数的值。

对于||来说,如果条件判断结果为true就返回第一个操作数(a和c)的值,如果为false就返回第二个操作数(b)的值。&&则相反,如果条件判断结果为true就返回第二个操作数(b)的值,如果为false就返回第一个操作数(a和c)的值。

看个例子

        var a = 42;
        var b = "foo";
        var c = false;
​
        var d = a && b || c ? c || b ? a : c && b : a;
​
        d;      // ? ? 42
  1. a&&b -> 42
  2. 42 ||false -> 42
  3. c||b -> 'foo'
  4. a ->42
  5. 结果就是 42

你如何解释 == 和 === 的区别?

通常,你会说 == 只检查值是否相等。=== 检查类型是否相等。

实际上,==和===都会检查操作数的类型。区别在于操作数类型不同时它们的处理方式不同。

如何不同?

a)不同类型间比较,== 之比较"转化成同一类型后的值" 看"值" 是否相等,=== 如果类型不同,其结果就是不等。

b)同类型比较,直接进行"值" 比较,两者结果一样。

因为没有对应的封装对象,所以null和undefined不能够被封装(boxed),Object(null)和Object()均返回一个常规对象。

如何让a==2&&a==3?

你也许觉得这不可能,因为a不会同时等于2和3。但“同时”一词并不准确,因为a ==2在a == 3之前执行。如果让a.valueOf()每次调用都产生副作用,比如第一次返回2,第二次返回3,就会出现这样的情况

JSON-P的作用?

JSON-P能将JSON转换为合法的JavaScript语法。

js有else if吗?

事实上JavaScript没有else if,但if和else只包含单条语句的时候可以省略代码块的{ }。

换句话说,else if不符合前面介绍的编码规范,else中是一个单独的if语句。else if极为常见,能省掉一层代码缩进,所以很受青睐。但这只是我们自己发明的用法,切勿想当然地认为这些都属于JavaScript语法的范畴。

运算符优先级

&& > || > ?:

参见MDN

如果运算符优先级/关联规则能够令代码更为简洁,就使用运算符优先级/关联规则;而如果( )有助于提高代码可读性,就使用( )。

还有一个不太为人所知的事实是:由于浏览器演进的历史遗留问题,在创建带有id属性的DOM元素时也会创建同名的全局变量

Js文件和内联代码是相互独立的JavaScript程序还是一个整体呢?

答案(也许会令人惊讶)是它们的运行方式更像是相互独立的JavaScript程序,但是并非总是如此。

它们共享global对象(在浏览器中则是window),也就是说这些文件中的代码在共享的命名空间中运行,并相互交互。

意思是,在一个文件中书写的两个script中的代码可以相互交互,但是变量提升不适用于这两个分开的script.所以,如果一个script中先使用函数,再再另一个script定义函数,这样,代码无法运行.

字符串常量中允许的最大字符数(并非只是针对字符串值)

可以作为参数传递到函数中的数据大小(也称为栈大小,以字节为单位);

函数声明中的参数个数;

未经优化的调用栈(例如递归)的最大层数,即函数调用链的最大长度;

JavaScript程序以阻塞方式在浏览器中运行的最长时间(秒);

变量名的最大长度。

异步:现在与将来

实际上,直到最近(ES6), JavaScript才真正内建有直接的异步概念。异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。

什么是事件循环?

所有环境都提供了一种机制来处理程序中多个块的执行,且执行每块时调用JavaScript引擎,这种机制被称为事件循环。有一个用while循环实现的持续运行的循环,循环的每一轮称为一个tick。对每个tick而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是你的回调函数。一定要清楚,setTimeout(..)并没有把你的回调函数挂在事件循环队列中。它所做的是设定一个定时器。当定时器到时后,环境会把你的回调函数放在事件循环中,这样,在未来某个时刻的tick会摘下并执行这个回调。

这也解释了为什么setTimeout(..)定时器的精度可能不高。大体说来,只能确保你的回调函数不会在指定的时间间隔之前运行,但可能会在那个时刻运行,也可能在那之后运行,要根据事件队列的状态而定。

什么是任务队列?

因此,我认为对于任务队列最好的理解方式就是,它是挂在事件循环队列的每个tick之后的一个队列。在事件循环的每个tick中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前tick的任务队列末尾添加一个项目(一个任务)。

看看这个例子

        console.log( "A" );
​
        setTimeout( function(){
            console.log( "B" );
        }, 0 );
​
        // 理论上的"任务API"
        schedule( function(){
            console.log( "C" );
​
            schedule( function(){
              console.log( "D" );
            } );
        } );

可能你认为这里会打印出A B C D,但实际打印的结果是A C D B。因为任务处理是在当前事件循环tick结尾处,且定时器触发是为了调度下一个事件循环tick(如果可用的话!)。

一个骇人听闻的例子

假设你是一名开发人员,为某个销售昂贵电视的网站建立商务结账系统。

你已经做好了结账系统的各个界面。在最后一页,当用户点击“确定”就可以购买电视时,你需要调用(假设由某个分析追踪公司提供的)第三方函数以便跟踪这个交易。

你注意到,可能是为了提高性能,他们提供了一个看似用于异步追踪的工具,这意味着你需要传入一个回调函数。在传入的这个continuation中,你需要提供向客户收费和展示感谢页面的最终代码。

代码可能是这样:

image.png 很简单,是不是?

你写好代码,通过测试,一切正常,然后就进行产品部署。皆大欢喜!

六个月过去了,没有任何问题。你几乎已经忘了自己写过这么一段代码。某个上班之前的早晨,你像往常一样在咖啡馆里享用一杯拿铁。

突然,你的老板惊慌失措地打电话过来,让你放下咖啡赶紧到办公室。到了办公室,你得知你们的一位高级客户购买了一台电视,信用卡却被刷了五次,他很生气,这可以理解。

客服已经道歉并启动了退款流程。但是,你的老板需要知道这样的事情为何会出现。“这种情况你没有测试过吗?! ”你甚至都不记得自己写过这段代码。

首先需要解决一下这个问题,创建了一个latch来处理对回调的多个并发调用。

        var tracked = false;
​
        analytics.trackPurchase( purchaseData, function(){
            if (! tracked) {
              tracked = true;
              chargeCreditCard();
              displayThankyouPage();
            }
        } );

但是,你得深入研究这些代码,并开始寻找问题产生的原因。

最终发现,是因为 第三方对你的回调调用多次。

通过这个例子,你会发现,回调最大的问题是控制反转,将自己程序的控制权交给他人,它会导致信任链的完全断裂。

为什么别用回调?

因为常会导致回调地狱,会导致回调地狱的原因是,精确编写和追踪使用回调的异步JavaScript代码很难:因为这并不是我们大脑进行计划的运作方式。

第一,大脑对于事情的计划方式是线性的、阻塞的、单线程的语义,但是回调表达异步流程的方式是非线性的、非顺序的,这使得正确推导这样的代码难度很大。难于理解的代码是坏代码,会导致坏bug。我们需要一种更同步、更顺序、更阻塞的的方式来表达异步,就像我们的大脑一样。

第二,也是更重要的一点,回调会受到控制反转的影响,因为回调暗中把控制权交给第三方(通常是不受你控制的第三方工具!)来调用你代码中的continuation。这种控制转移导致一系列麻烦的信任问题,比如回调被调用的次数是否会超出预期。

Promise

但是,如果我们能够把控制反转再反转回来,会怎样呢?如果我们不把自己程序的continuation传给第三方,而是希望第三方给我们提供了解其任务何时结束的能力,然后由我们自己的代码来决定下一步做什么,那将会怎样呢?这时我们就需要使用promise.

        var p3 = new Promise( function(resolve, reject){
            resolve( "B" );
        } );
​
        var p1 = new Promise( function(resolve, reject){
            resolve( p3 );
        } );
​
        var p2 = new Promise( function(resolve, reject){
            resolve( "A" );
        } );
​
        p1.then( function(v){
            console.log( v );
        } );
                p2.then( function(v){
            console.log( v );
        } );
​
        // A B    <-- 而不是像你可能认为的B A

但目前你可以看到,p1不是用立即值而是用另一个promise p3决议,后者本身决议为值"B"。规定的行为是把p3展开到p1,但是是异步地展开。所以,在异步任务队列中,p1的回调排在p2的回调之后。所以结果是 A B

当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件

无论解决还是拒绝,都会创建一个新的promise,以便协调下一步动作

这使得错误可以继续沿着Promise链传播下去,直到遇到显式定义的拒绝处理函数。

让我们来简单总结一下使链式流程控制可行的Promise固有特性。

  • 调用Promise的then(..)会自动创建一个新的Promise从调用返回。
  • 在完成或拒绝处理函数内部,如果返回一个值或抛出一个异常,新返回的(可链接的)Promise就相应地决议。

如果完成或拒绝处理函数返回一个Promise,它将会被展开,这样一来,不管它的决议值是什么,都会成为当前then(..)返回的链接Promise的决议值。

尽管链式流程控制是有用的,但是对其最精确的看法是把它看作Promise组合到一起的一个附加益处,而不是主要目的。

Promise文献通常将其称为resolve(..)。这个词显然和决议(resolution)有关

本章前面已经介绍过,Promise.resolve(..)会将传入的真正Promise直接返回,对传入的thenable则会展开。如果这个thenable展开得到一个拒绝状态,那么从Promise. resolve(..)返回的Promise实际上就是这同一个拒绝状态。所以对这个API方法来说,Promise.resolve(..)是一个精确的好名字,因为它实际上的结果可能是完成或拒绝。

前面提到的reject(..)不会像resolve(..)一样进行展开。如果向reject(..)传入一个Promise/thenable值,它会把这个值原封不动地设置为拒绝理由。后续的拒绝处理函数接收到的是你实际传给reject(..)的那个Promise/thenable,而不是其底层的立即值。

这段为什么不会打印123?

image.png

因为try catch不能用于异步。同步代码能用try catch捕获错误。

只有在baz.bar()调用会同步地立即成功或失败的情况下,这里的try.. catch才能工作。如果baz.bar()本身有自己的异步完成函数,其中的任何异步错误都将无法捕捉到。

为什么上面的异步错误,try catch无法捕获?

因为setTimeout是异步函数,而try catch其实是同步顺序执行的代码,等setTimeout里面的事件进入事件队列的时候,主线程已经离开了try catch,所以try catch是无法捕获异步函数的错误的。

          var p = Promise.resolve( 42 );
​
          p.then(
              function fulfilled(msg){
                // 数字没有string函数,所以会抛出错误
                console.log( msg.toLowerCase() );
              }
          )
          .catch( handleErrors );

为了避免丢失被忽略和抛弃的Promise错误,一些开发者表示,Promise链的一个最佳实践就是最后总以一个catch(..)结束。 因为我们没有为then(..)传入拒绝处理函数,所以默认的处理函数被替换掉了,而这仅仅是把错误传递给了链中的下一个promise。因此,进入p的错误以及p之后进入其决议(就像msg.toLowerCase())的错误都会传递到最后的handleErrors(..)。

如果handleErrors(..)本身内部也有错误怎么办呢?谁来捕捉它?还有一个没人处理的promise:catch(..)返回的那一个。我们没有捕获这个promise的结果,也没有为其注册拒绝处理函数。你并不能简单地在这个链尾端添加一个新的catch(..),因为它很可能会失败。任何Promise链的最后一步,不管是什么,总是存在着在未被查看的Promise中出现未捕获错误的可能性,尽管这种可能性越来越低。