你不知道的JS(中):强制类型转换与异步基础
本文是《你不知道的JavaScript(中卷)》的阅读笔记,第二部分:强制类型转换与异步基础。 供自己以后查漏补缺,也欢迎同道朋友交流学习。
强制类型转换
值类型转换
将值从一种类型转换为另一种类型通常称为类型转换(type casting),这是显式的情况;隐式的情况称为强制类型转换(coercion)。
javascript
var a = 42;
var b = a + ""; // 隐式强制类型转换
var c = String( a ); // 显式强制类型转换
抽象值操作
ToString 基本类型值的字符串化规则为:null转换为"null",undefined转换为"undefined",true转换为 "true"。数字的字符串化则遵循通用规则,不过那些极小和极大的数字使用指数形式:
javascript
// 1.07 连续乘以七个 1000
var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000;
// 七个1000一共21位数字
a.toString(); // "1.07e21"
JSON字符串化 工具函数 JSON.stringify 在将 JSON 对象序列化为字符串时也用到了 ToString。但JSON.stringify(..) 在对象中遇到 undefined、function 和 symbol 时会自动将其忽略,在数组中则会返回 null(以保证单元位置不变)。
javascript
JSON.stringify(undefined); // undefined
JSON.stringify(function(){}); // undefined
JSON.stringify([1,undefined,function(){},4]); // "[1,null,null,4]"
JSON.stringify({ a:2, b:function(){} }); // "{"a":2}"
ToNumber 其中 true 转换为 1,false 转换为 0。undefined 转换为 NaN,null 转换为 0。
ToBoolean JS中的值可以分为俩类:
- 可以被强制类型转换为false的值
- 其他
以下是假值,假值的布尔强制类型转换结果为false:
- undefined
- null
- false
- +0、-0和NaN
- ""
假值对象是真值
javascript
var a = new Boolean( false );
var b = new Number( 0 );
var c = new String( "" );
Boolean( a && b && c ); // true
真值:假值列表之外的就是真值
javascript
var a = "false";
var b = "0";
var c = "''";
Boolean( a && b && c ); // true
var a = []; // 空数组------是真值还是假值?
var b = {}; // 空对象------是真值还是假值?
var c = function(){}; // 空函数------是真值还是假值?
Boolean( a && b && c ); // true
显式强制类型转换
日期显式转换为数字:
javascript
var d = new Date( "Mon, 18 Aug 2014 08:53:06 CDT" );
+d; // 1408369986000
奇特的~运算符 ~,它首先将值强制类型转换为 32 位数字,然后执行字位操作"非"(对每一个字位进行反转)。 ~ 返回 2 的补码
javascript
~42; // -(42+1) ==> -43
~ 的神奇之处在于进行检查字符串中是否有包含指定的字符串:
javascript
var a = "Hello World";
~a.indexOf( "lo" ); // -4 <-- 真值!
if (~a.indexOf( "lo" )) { // true
// 找到匹配!
}
~a.indexOf( "ol" ); // 0 <-- 假值!
!~a.indexOf( "ol" ); // true
if (!~a.indexOf( "ol" )) { // true
// 没有找到匹配!
}
显式解析数字字符串 解析字符串中的数字和将字符串强制类型转换为数字的返回结果都是数字。但解析和转换两者之间还是有明显的差别。
javascript
var a = "42";
var b = "42px";
Number( a ); // 42
parseInt( a ); // 42
Number( b ); // NaN
parseInt( b ); // 42
解析允许字符串中含有非数字字符,解析按从左到右的顺序,如果遇到非数字字符就停止。而转换不允许出现非数字字符,否则会失败bing返回 NaN。
解析非字符串:
javascript
parseInt( 1/0, 19 ); // 18
很多人想当然地以为"如果第一个参数值为 Infinity,解析结果也应该是 Infinity",返回 18 也太无厘头了。实际的 JavaScript 代码中不会用到基数 19,它的有效数字字符范围是 0-9 和 a-i(区分大小写)。parseInt(1/0, 19) 实际上是 parseInt("Infinity", 19)。第一个字符是 "I",以 19 为基数时值为 18。第二个字符 "n" 不是一个有效的数字字符,解析到此为止。 此外还有一些看起来奇怪但实际上解释得通的例子:
javascript
parseInt( 0.000008 ); // 0 ("0" 来自于 "0.000008")
parseInt( 0.0000008 ); // 8 ("8" 来自于 "8e-7")
parseInt( false, 16 ); // 250 ("fa" 来自于 "false")
parseInt( parseInt, 16 ); // 15 ("f" 来自于 "function..")
parseInt( "0x10" ); // 16
parseInt( "103", 2 ); // 2
显式转换为布尔值 显式强制类型转换为布尔值最常用的方法是!!。
隐式强制类型转换
字符串和数字之间的隐式强制类型转换 通过+运算符进行字符串拼接
javascript
var a = "42";
var b = "0";
var c = 42;
var d = 0;
a + b; // "420"
c + d; // 42
因为数组的valueOf() 操作无法得到简单基本类型值,于是它转而调用 toString()。因此下面例子中的两个数组变成了 "1,2" 和 "3,4"。+ 将它们拼接后返回 "1,23,4"。
javascript
var a = [1,2];
var b = [3,4];
a + b; // "1,23,4"
a + ""(隐式)和 String(a)(显式)之间有一个细微的差别需要注意。根据ToPrimitive 抽象操作规则,a + "" 会对 a 调用 valueOf() 方法,然后通过 ToString 抽象操作将返回值转换为字符串。而 String(a) 则是直接调用 ToString()。
javascript
var a = {
valueOf: function() { return 42; },
toString: function() { return 4; }
};
a + ""; // "42"
String( a ); // "4"
再来看看从字符串强制类型转换为数字的情况。- 是数字减法运算符,因此 a - 0 会将 a 强制类型转换为数字。
javascript
var a = "3.14";
var b = a - 0;
b; // 3.14
隐式强制类型转换为布尔值 相对布尔值,数字和字符串操作中的隐式强制类型转换还算比较明显。下面的情况会发生布尔值隐式强制类型转换。 (1) if (..) 语句中的条件判断表达式。 (2) for ( .. ; .. ; .. ) 语句中的条件判断表达式(第二个)。 (3) while (..) 和 do..while(..) 循环中的条件判断表达式。 (4) ? : 中的条件判断表达式。 (5) 逻辑运算符 ||(逻辑或)和 &&(逻辑与)左边的操作数(作为条件判断表达式)。
|| 和 && && 和 || 运算符的返回值并不一定是布尔类型,而是两个操作数其中一个的值。
javascript
var a = 42;
var b = "abc";
var c = null;
a || b; // 42
a && b; // "abc"
c || b; // "abc"
c && b; // null
|| 和 && 首先会对第一个操作数(a 和 c)执行条件判断,如果其不是布尔值(如上例)就先进行 ToBoolean 强制类型转换,然后再执行条件判断。 对于 || 来说,如果条件判断结果为 true 就返回第一个操作数(a 和 c)的值,如果为false 就返回第二个操作数(b)的值。 && 则相反,如果条件判断结果为 true 就返回第二个操作数(b)的值,如果为 false 就返回第一个操作数(a 和 c)的值。
符号的强制类型转换 ES6 中引入了符号类型,它的强制类型转换有一个坑,在这里有必要提一下。ES6 允许从符号到字符串的显式强制类型转换,然而隐式强制类型转换会产生错误:
javascript
var s1 = Symbol( "cool" );
String( s1 ); // "Symbol(cool)"
var s2 = Symbol( "not cool" );
s2 + ""; // TypeError
宽松相等和严格相等
常见的误区是"== 检查值是否相等,=== 检查值和类型是否相等"。听起来蛮有道理,然而 还不够准确。很多 JavaScript 的书籍和博客也是这样来解释的,但是很遗憾他们都错了。
正确的解释是:"== 允许在相等比较中进行强制类型转换,而 === 不允许。"
抽象相等 == 在比较两个不同类型的值时会发生隐式强制类型转换,会将其中之一或两者都转换为相同的类型后再进行比较。
- 字符串和数字之间的相等比较: (1) 如果 Type(x) 是数字,Type(y) 是字符串,则返回 x == ToNumber(y) 的结果。 (2) 如果 Type(x) 是字符串,Type(y) 是数字,则返回 ToNumber(x) == y 的结果
javascript
var a = 42;
var b = "42";
a === b; // false
a == b; // true
- 其他类型和布尔类型之间的相等比较: (1) 如果 Type(x) 是布尔类型,则返回 ToNumber(x) == y 的结果; (2) 如果 Type(y) 是布尔类型,则返回 x == ToNumber(y) 的结果。
javascript
var a = "42";
var b = true;
a == b; // false
- null 和 undefined 之间的相等比较 (1) 如果 x 为 null,y 为 undefined,则结果为 true。 (2) 如果 x 为 undefined,y 为 null,则结果为 true。
javascript
var a = null;
var b;
a == b; // true
a == null; // true
b == null; // true
a == false; // false
b == false; // false
a == ""; // false
b == ""; // false
a == 0; // false
b == 0; // false
- 对象 and 非对象之间的相等比较 (1) 如果 Type(x) 是字符串或数字,Type(y) 是对象,则返回 x == ToPrimitive(y) 的结果; (2) 如果 Type(x) 是对象,Type(y) 是字符串或数字,则返回 ToPromitive(x) == y 的结果。
javascript
var a = 42;
var b = [ 42 ];
a == b; // true
比较少见的情况:
- 返回其他数字:
javascript
Number.prototype.valueOf = function() {
return 3;
};
new Number( 2 ) == 3; // true
- 假值的相等比较:
javascript
"0" == null; // false
"0" == undefined; // false
"0" == false; // true
"0" == NaN; // false
"0" == 0; // true
"0" == ""; // false
false == null; // false
false == undefined; // false
false == NaN; // false
false == 0; // true
false == ""; // true
false == []; // true
false == {}; // false
"" == null; // false
"" == undefined; // false
"" == NaN; // false
"" == 0; // true
"" == []; // true
"" == {}; // false
0 == null; // false
0 == undefined; // false
0 == NaN; // false
0 == []; // true
0 == {}; // false
- 极端情况
根据 ToBoolean 规则,它会进行布尔值的显式强制类型转换。所以 [] == ![] 变成了 [] == false。前面介绍 of false == [],最后的结果就顺理成章了
javascript
[] == ![] // true
安全运用隐式强制类型转换:
- 如果两边的值中有 true 或者 false,千万不要使用 ==。
- 如果两边的值中有 []、"" 或者 0,尽量不要使用 ==。 这时最好用 === 来避免不经意的强制类型转换。这两个原则可以让我们避开几乎所有强制类型转换的坑
抽象关系比较
a < b 中涉及的隐式强制类型转换: 比较双方首先调用 ToPrimitive,如果结果出现非字符串,就根据 ToNumber 规则将双方强制类型转换为数字来进行比较。
javascript
var a = [ 42 ];
var b = [ "43" ];
a < b; // true
b < a; // false
如果比较双方都是字符串,则按字母顺序来进行比较:
javascript
var a = [ "42" ];
var b = [ "043" ];
a < b; // false
var a = [ 4, 2 ];
var b = [ 0, 4, 3 ];
a < b; // false
还有个特殊情况:
javascript
var a = { b: 42 };
var b = { b: 43 };
a < b; // false
a == b; // false
a > b; // false
a <= b; // true
a >= b; // true
因为 a 是 [object Object],b 也是 [object Object],所以按照字母顺序a < b 并不成立。
为什么 a == b 的结果不是 true ?它们的字符串值相同(同为 "[object Object]"),按道理应该相等才对?实际上不是这样,你可以回忆一下前面讲过的对象的相等比较。
但是 if a < b 和 a == b 结果为 false,为什么 a <= b 和 a >= b 的结果会是 true 呢?因为根据规范 a <= b 被处理为 b < a,然后将结果反转。因为 b < a 的结果是 false,所以 a <= b 的结果是 true。
这可能与我们设想的大相径庭,即 <= 应该是"小于或者等于"。实际上 JavaScript 中 <= 是"不大于"的意思(即 !(a > b),处理为 !(b < a))。同理 a >= b 处理为 b <= a。
强制类型转换小结
JS 的数据类型之间的转换,即强制类型转换:包括显式和隐式。
显式强制类型转换明确告诉我们哪里发生了类型转换,有助于提高代码可读性和可维护性。
隐式强制类型转换则没有那么明显,是其他操作的副作用。实际上隐式强制类型转换也有助于提高代码的可读性。在处理强制类型转换的时候要十分小心,尤其是隐式强制类型转换。
语法
语句和表达式
JS中语句相当于句子,表达式相当于短语,运算符则相当于标点符号和连接词。
语句的结果值 代码块的结果值就如同一个隐式的返回,即返回最后一个语句的结果值。
javascript
var b;
if (true) {
b = 4 + 38;
}
表达式的副作用 函数调用的副作用:
javascript
function foo() {
a = a + 1;
}
var a = 1;
foo(); // 结果值:undefined。副作用:a的值被改变
= 赋值运算符:
javascript
var a;
a = 42; // 42
a; // 42
运算符优先级
&& 先执行,然后是 ||:
javascript
(false && true) || true; // true
false && (true || true); // false
false && true || true; // true
那执行顺序是否就一定是从左到右呢?不妨将运算符颠倒一下看看:
javascript
true || false && false; // true
(true || false) && false; // false
true || (false && false); // true
这说明 && 运算符先于 || 执行,而且执行顺序并非我们所设想的从左到右。原因就在于运算符优先级。
短路 对于 && 和 || 来说,如果从左边的操作数能够得出结果,就可以忽略右边的操作数。我们将这种现象称为"短路"(即执行最短路径)。
更强的绑定 因为 && 运算符的优先级高于 ||,而 || 的优先级又高于 ? :。
javascript
a && b || c ? c || b ? a : c && b : a
// 等同于
(a && b || c) ? (c || b) ? a : (c && b) : a
关联 一般多个&&和||执行顺序是从左到右,也被称为左关联,但? : 是右关联
javascript
a ? b : c ? d : e;
// 等同于
a ? b : (c ? d : e)
另一个右关联组合的例子是 = 运算符:
javascript
var a, b, c;
a = b = c = 42;
// 等同于
a = (b = (c = 42))
自动分号
JS会自动为代码行补上缺失的分号,即自动分号插入(Automatic Semicolon Insertion,ASI)。
错误
JS不仅有各种类型的运行时错误(TypeError、ReferenceError、SyntaxError 等),它的语法中也定义了一些编译时错误。
提前使用变量 ES6 规范定义了一个新概念,叫作 TDZ(Temporal Dead Zone,暂时性死区)。TDZ 指的是由于代码中的变量还没有初始化而不能被引用的情况。
javascript
{
a = 2; // ReferenceError!
let a;
}
函数参数
在 ES6 中,如果参数被省略或者值为 undefined,则取该参数的默认值:
javascript
function foo( a = 42, b = a + 1 ) {
console.log( a, b );
}
foo(); // 42 43
foo( undefined ); // 42 43
foo( 5 ); // 5 6
foo( void 0, 7 ); // 42 7
foo( null ); // null 1
try finally
finally 中的代码总是会在 try 之后执行,如果有 catch 的话则在 catch 之后执行。也可以将 finally 中的代码看作一个回调函数,即无论出现什么情况最后一定会被调用。
javascript
function foo() {
try {
return 42;
}
finally {
console.log( "Hello" );
}
console.log( "never runs" );
}
console.log( foo() );
// Hello
// 42
这里 return 42 先执行,并将 foo() 函数的返回值设置为 42。然后 try 执行完毕,接着执行 finally。最后 foo() 函数执行完毕,console.log(..) 显示返回值。 try 中的 throw 也是如此:
javascript
function foo() {
try {
throw 42;
}
finally {
console.log( "Hello" );
}
console.log( "never runs" );
}
console.log( foo() );
// Hello
// Uncaught Exception: 42
switch
switch,可以把它看作 if..else if..else.. 的简化版本:
javascript
switch (a) {
case 2:
// 执行一些代码
break;
case 42:
// 执行另外一些代码
break;
default:
// 执行缺省代码
}
a 和 case 表达式的匹配算法与 === 相同。通常case语句中switch都是简单值,但有时可能会需要通过强制类型转换来进行相等比较,这时就需要做一些特殊处理:
javascript
var a = "42";
switch (true) {
case a == 10:
console.log( "10 or '10'" );
break;
case a == 42;
console.log( "42 or '42'" );
break;
default:
// 永远执行不到这里
}
// 42 or '42'
尽管可以使用 ==,但 switch 中 true and true 之间仍然是严格相等比较。即 if case 表达式的结果为真值,但不是严格意义上的 true,则条件不成立。
javascript
var a = "hello world";
var b = 10;
switch (true) {
case (a || b == 10):
// 永远执行不到这里
break;
default:
console.log( "Oops" );
}
// Oops
最后,default 是可选的,并非必不可少。break 相关规则对 default 仍然适用:
javascript
var a = 10;
switch (a) {
case 1:
case 2:
// 永远执行不到这里
default:
console.log( "default" );
case 3:
console.log( "3" );
break;
case 4:
console.log( "4" );
}
// default
// 3
上例中的代码是这样执行的,首先遍历并找到所有匹配的 case,如果没有匹配则执行default 中的代码。因为其中没有 break,所以继续执行已经遍历过的 case 3 代码块,直到 break 为止。
语法小结
JS的语法规则之上是语义规则,也称上下文。 JS还详细定义了运算符的优先级和关联。
异步:现在与将来
程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。
分块的程序
可以把 JavaScript 程序写在单个 .js 文件中,但是这个程序几乎一定是由多个块构成的。这些块中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。 大多数 JS 新手程序员都会遇到的问题是:程序中将来执行的部分并不一定在现在运行的部分执行完之后就立即执行。 从现在到将来的"等待",最简单的方法是使用一个通常称为回调函数的函数:
javascript
// ajax(..)是某个库中提供的某个Ajax函数
ajax( "http://some.url.1", function myCallbackFunction(data){
console.log( data ); // 耶!这里得到了一些数据!
});
异步控制台 在某些条件下,某些浏览器的 console.log 并不会把传入的内容立即输出。出现这种情况的主要原因是,在许多程序(不只是 JS)中,I/O 是非常低速的阻塞部分。所以浏览器在后台异步处理控制台 I/O 能够提高性能,这时用户甚至可能根本意识不到其发生。
事件循环
所有这些环境都有一个共同"点"(thread,也指线程。),即它们都提供了一种机制来处理程序中多个块的执行,且执行每块时调用 JS 引擎,这种机制被称为事件循环。 先通过一段伪代码了解一下这个概念 :
javascript
// eventLoop是一个用作队列的数组
// (先进,先出)
var eventLoop = [ ];
var event;
// "永远"执行
while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 拿到队列中的下一个事件
event = eventLoop.shift();
// 现在,执行下一个事件
try {
event();
} catch (err) {
reportError(err);
}
}
}
可以看到,有一个用 while 循环实现的持续运行的循环,循环的每一轮称为一个 tick。对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是你的回调函数。
并行线程
术语"异步"和"并行"常常被混为一谈,但实际上它们的意义完全不同。记住,异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。 并行计算最常见的工具就是进程和线程. 进程和线程独立运行,并可能同时运行:在不同的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存。
并发
两个或多个"进程"同时执行就出现了并发,不管组成它们的单个运算是否并行执行(在独立的处理器或处理器核心上同时运行)。可以把并发看作"进程"级(或者任务级)的并行,与运算级的并行(不同处理器上的线程)相对。
非交互 如果进程间没有相互影响的话,不确定性是完全可以接受的。
交互 并发的"进程"需要相互交流,通过作用域或 DOM 间接交互。正如前面介绍的,如果出现这样的交互,就需要对它们的交互进行协调以避免竞态的出现。
协作 还有一种并发合作方式,称为并发协作(cooperative concurrency)。这里的重点不再是通过共享作用域中的值进行交互。这里的目标是取到一个长期运行的"进程",并将其分割成多个步骤或多批任务,使得其他并发"进程"有机会将自己的运算插入到事件循环队列中交替运行。
任务
在 ES6 中,有一个新的概念建立在事件循环队列之上,叫作任务队列(job queue)。对于任务队列最好的理解方式就是,它是挂在事件循环队列的每个 tick 之后的一个队列。在事件循环的每个 tick 中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前 tick 的任务队列末尾添加一个项目(一个任务)。
语句顺序
代码中语句的顺序和js引擎执行语句的顺序并不一定要一致。
异步小结
JS 程序总是至少分为两个块:第一块现在运行;下一块将来运行,以响应某个事件。尽管程序是一块一块执行的,但是所有这些块共享对程序作用域和状态的访问,所以对状态的修改都是在之前累积的修改之上进行的。 一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个tick。用户交互、IO 和定时器会向事件队列中加入事件。
回调
到目前为止,回调是编写和处理 JavaScript 程序异步逻辑的最常用方式。确实,回调是这门语言中最基础的异步模式。
延续(continuation)
回调函数包裹或者说封装了程序的延续(continuation)。
javascript
// A
setTimeout( function(){
// C
}, 1000 );
// B
执行 A,设定延时 1000 毫秒,然后执行 B,然后定时到时后执行C
顺序的大脑
执行与计划 我们的大脑可以看作类似于单线程运行的事件循环队列,就像 JavaScript 引擎那样。这个比喻看起来很贴切。但是,我们的分析还需要比这更加深入细致一些。显而易见的是,在我们如何计划各种任务和我们的大脑如何实际执行这些计划之间,还存在着很大的差别。
嵌套回调和链式回调:
javascript
listen( "click", function handler(evt){
setTimeout( function request(){
ajax( "http://some.url.1", function response(text){
if (text == "hello") {
handler();
}
else if (text == "world") {
request();
}
} );
}, 500) ;
} );
这种代码常常被称为回调地狱(callback hell),有时也被称为毁灭金字塔。 让我们不用嵌套再把前面的嵌套事件 / 超时 /Ajax 的例子重写一遍吧:
javascript
listen( "click", handler );
function handler() {
setTimeout( request, 500 );
}
function request(){
ajax( "http://some.url.1", response );
}
function response(text){
if (text == "hello") {
handler();
}
else if (text == "world") {
request();
}
}
信任问题
javascript
// A
ajax( "..", function(..){
// C
} );
// B
在 JS 主程序的直接控制之下。而 // C 会延迟到将来发生,并且是在第三方的控制下------在本例中就是函数 ajax。从根本上来说,这种控制的转移通常不会给程序带来很多问题。 但是,请不要被这个小概率迷惑而认为这种控制切换不是什么大问题。实际上,这是回调驱动设计最严重(也是最微妙)的问题。它以这样一个思路为中心:有时候 ajax 不是你编写的代码,也不在你的直接控制下。多数情况下,它是某个第三方提供的工具。 我们把这称为控制反转,也就是把自己程序一部分的执行控制交给某个第三方。在你的代码和第三方工具之间有一份并没有明确表达的契约。
省点回调
为了更优雅地处理错误,有些 API 设计提供了分离回调(一个用于成功通知,一个用于出错通知):
javascript
function success(data) {
console.log( data );
}
function failure(err) {
console.error( err );
}
ajax( "http://some.url.1", success, failure );
在这种设计下,API 的出错处理函数 failure() 常常是可选的,如果没有提供的话,就是假定这个错误可以吞掉。
回调小结
回调函数是 JavaScript 异步的基本单元。但是随着 JavaScript 越来越成熟,对于异步编程领域的发展,回调已经不够用了。