这些JavaScript 细节,你未必知道
大厂技术 高级前端 Node进阶
点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群
前言
本文主要给大家带来一些我读《你不知道的 JavaScript(中卷)》中遇到的一些有意思的内容,可以说是打开新世界的大门的感觉。希望能在工作之余,给大家带来一点乐趣。
JavaScript 是一门优秀的语言。只学其中一部分内容很容易,但是要全面掌握则很难。开发人员遇到困难时往往将其归咎于语言本身,而不反省他们自己对语言的理解有多匮乏。《你不知道的 JavaScript》旨在解决这个问题,使读者能够发自内心地喜欢上这门语言。
强制类型转换
值类型转换
var a = 42;
var b = a + ""; // 隐式强制类型转换
var c = String(a); // 显式强制类型转换
抽象值操作
document.all
是假值对象。也就是!!document.all
值为false
。
显示强制类型转换
日期显示转换为数字:
使用 Date.now()
来获得当前的时间戳,使用 new Date(..).getTime()
来获得指定时间的时间戳。
奇特的 ~ 运算符:
~x
大致等同于 -(x+1)
。很奇怪,但相对更容易说明问题:~42; // -(42+1) ==> -43
JavaScript 中字符串的 indexOf(..)
方法也遵循这一惯例,该方法在字符串中搜索指定的子 字符串,如果找到就返回子字符串所在的位置(从 0 开始),否则返回 -1。
~
和 indexOf()
一起可以将结果强制类型转换(实际上仅仅是转换)为真 / 假值:
var a = "Hello World";
~a.indexOf("lo"); // -4 <-- 真值!
if (~a.indexOf("lo")) { // true
// 找到匹配!
}
解析非字符串:
曾经有人发帖吐槽过 parseInt(..)
的一个坑:
parseInt( 1/0, 19 ); // 18
parseInt(1/0, 19)
实际上是 parseInt("Infinity", 19)
。第一个字符是 "I",以 19 为基数 时值为 18。
此外还有一些看起来奇怪但实际上解释得通的例子:
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
隐式强制类型转换
字符串和数字之间的隐式强制类型转换
例如:
var a = "42";
var b = "0";
var c = 42;
var d = 0;
a + b; // "420"
c + d; // 42
再例如:
var a = [1,2];
var b = [3,4];
a + b; // "1,23,4"
根据 ES5 规范 11.6.1 节,如果某个操作数是字符串或者能够通过以下步骤转换为字符串的话,+ 将进行拼接操作。如果其中一个操作数是对象(包括数组),则首先对其调用 ToPrimitive 抽象操作(规范 9.1 节),该抽象操作再调用 [[DefaultValue]]
(规范 8.12.8 节),以数字作为上下文。
你或许注意到这与 ToNumber
抽象操作处理对象的方式一样(参见 4.2.2 节)。因为数组的 valueOf()
操作无法得到简单基本类型值,于是它转而调用 toString()
。因此上例中的两个数组变成了 "1,2
" 和 "3,4
" 。+ 将它们拼接后返回 "1,23,4
" 。
简单来说就是,如果 + 的其中一个操作数是字符串(或者通过以上步骤可以得到字符串),则执行字符串拼接;否则执行数字加法。
符号的强制类型转换
ES6 允许从符号到字符串的显式强制类型转换,然而隐式强制类型转换会产生错误,具体的原因不在本书讨论范围之内。
例如:
var s1 = Symbol("cool");
String(s1); // "Symbol(cool)"
var s2 = Symbol("not cool");
s2 + ""; // TypeError
符号不能够被强制类型转换为数字(显式和隐式都会产生错误),但可以被强制类型转换为布尔值(显式和隐式结果都是 true
)。
由于规则缺乏一致性,我们要对 ES6 中符号的强制类型转换多加小心。
好在鉴于符号的特殊用途,我们不会经常用到它的强制类型转换。
宽松相等和严格相等
常见的误区是“==
检查值是否相等,===
检查值和类型是否相等”。听起来蛮有道理,然而还不够准确。很多 JavaScript 的书籍和博客也是这样来解释的,但是很遗憾他们都错了。
正确的解释是:“==
允许在相等比较中进行强制类型转换,而 ===
不允许。”
字符串和数字之间的相等比较:
-
如果 Type(x) 是数字,Type(y) 是字符串,则返回 x == ToNumber(y) 的结果。 -
如果 Type(x) 是字符串,Type(y) 是数字,则返回 ToNumber(x) == y 的结果。
其他类型和布尔类型之间的相等比较:
-
如果 Type(x) 是布尔类型,则返回 ToNumber(x) == y 的结果; -
如果 Type(y) 是布尔类型,则返回 x == ToNumber(y) 的结果。
null
和 undefined
之间的相等比较:
-
如果 x 为 null,y 为 undefined,则结果为 true。 -
如果 x 为 undefined,y 为 null,则结果为 true。
对象和非对象之间的相等比较:
-
如果 Type(x) 是字符串或数字,Type(y) 是对象,则返回 x == ToPrimitive(y) 的结果; -
如果 Type(x) 是对象,Type(y) 是字符串或数字,则返回 ToPromitive(x) == y 的结果。
语法
错误
提前使用变量
ES6 规范定义了一个新概念,叫作 TDZ(Temporal Dead Zone,暂时性死区)。
TDZ 指的是由于代码中的变量还没有初始化而不能被引用的情况。
对此,最直观的例子是 ES6 规范中的 let
块作用域:
{
a = 2; // ReferenceError!
let a;
}
a = 2
试图在 let a
初始化 a
之前使用该变量(其作用域在 { .. }
内),这里就是 a
的 TDZ,会产生错误。
有意思的是,对未声明变量使用 typeof 不会产生错误(参见第 1 章),但在 TDZ 中却会报错:
{
typeof a; // undefined
typeof b; // ReferenceError! (TDZ)
let b;
}
回调
省点回调
构造一个超时验证工具:
function timeoutify(fn, delay) {
var intv = setTimeout(function() {
intv = null
fn(new Error('Timeout!'))
}, delay)
return function() {
// 还没有超时?
if (intv) {
clearTimeout(intv)
fn.apply(this, arguments)
}
}
}
以下是使用方式:
// 使用 ‘error-first 风格’ 回调设计
function foo(err, data) {
if (err) {
console.error(err)
}
else {
console.log(data)
}
}
ajax('http://some.url.1', timeoutify(foo, 500))
如果你不确定关注的 API 会不会永远异步执行怎么办呢?可以创建一个类似于这个“验证概念”版本的 asyncify(..)
工具:
function asyncify(fn) {
var orig_fn = fn,
intv = setTimeout(function() {
intv = null
if (fn) fn()
}, 0)
fn = null
return function() {
// 触发太快,在定时器intv触发指示异步转换发生之前?
if (intv) {
fn = orig_fn.bind.apply(
orig_fn,
// 把封装器的this添加到bind(..)调用的参数中,
// 以及克里化(currying)所有传入参数
[this].concat([].slice.call(arguments))
)
}
// 已经是异步
else {
// 调用原来的函数
orig_fn.apply(this, arguments)
}
}
}
可以像这样使用 asyncify(..)
:
function result(data) {
console.log(a)
}
var a = 0
ajax('..pre-cached-url..', asyncify(result))
a++
不管这个 Ajax 请求已经在缓存中并试图对回调立即调用,还是要从网络上取得,进而在将来异步完成,这段代码总是会输出 1,而不是 0——result(..) 只能异步调用,这意味着 a++ 有机会在 result(..) 之前运行。
关于回调地狱的可以看:JS中优雅的使用async await
Promise
Promise 信任问题
回调未调用
提供一个超时处理的解决方案:
// 用于超时一个Promise的工具
function timeoutPromise(delay) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
reject('Timeout!')
}, delay)
})
}
// 设置foo()超时
Promise.race([
foo(),
timeoutPromise(3000)
])
.then(
function() {
// foo(..)及时完成!
},
function(err) {
// 或者foo()被拒绝,或者只是没能按时完成
// 查看err来了解是哪种情况
}
)
链式流
为了进一步阐释链接,让我们把延迟 Promise 创建(没有决议消息)过程一般化到一个工具中,以便在多个步骤中复用:
function delay(time) {
return new Promise(function(resolve, reject) {
setTimeout(resolve, time)
})
}
delay(100) // 步骤1
.then(function STEP2() {
console.log("step 2 (after 100ms)")
return delay(200)
})
.then(function STEP3() {
console.log("step 3 (after another 200ms)")
})
.then(function STEP4() {
console.log("step 4 (next Job)")
return delay(50)
})
.then(function STEP5() {
console.log("step 5 (after another 50ms)")
})
调用 delay(200)
创建了一个将在 200ms 后完成的 promise,然后我们从第一个 then(..)
完成回调中返回这个 promise,这会导致第二个 then(..)
的 promise 等待这个 200ms 的 promise。
Promise 局限性
顺序错误处理
Promise 的设计局限性(链式调用)造成了一个让人很容易中招的陷阱,即 Promise 链中的错误很容易被无意中默默忽略掉。
关于 Promise 错误,还有其他需要考虑的地方。由于一个 Promise 链仅仅是连接到一起的成员 Promise,没有把整个链标识为一个个体的实体,这意味着没有外部方法可以用于观察可能发生的错误。
如果构建了一个没有错误处理函数的 Promise 链,链中任何地方的任何错误都会在链中一直传播下去,直到在某个步骤注册拒绝处理函数。在这个特定的例子中,只要有一个指向链中最后一个 promise 的引用就足够了(下面代码中的 p),因为你可以在那里注册拒绝处理函数,而且这个处理函数能够得到所有传播过来的错误的通知:
// foo(..), STEP2(..)以及STEP3(..)都是支持promise的工具
var p = foo(42)
.then(STEP2)
.then(STEP3);
虽然这里可能令人迷惑,但是这里的 p
并不指向链中的第一个 promise(调用 foo(42)
产生的那一个),而是指向最后一个 promise,即来自调用 then(STEP3)
的那一个。
还有,这个 Promise 链中的任何一个步骤都没有显式地处理自身错误。这意味着你可以在 p
上注册一个拒绝错误处理函数,对于链中任何位置出现的任何错误,这个处理函数都会得到通知:
p.catch(handleErrors);
但是,如果链中的任何一个步骤事实上进行了自身的错误处理(可能以隐藏或抽象的不可见的方式),那你的 handleErrors(..)
就不会得到通知。这可能是你想要的——毕竟这是一个“已处理的拒绝”——但也可能并不是。不能清晰得到(对具体某一个“已经处理”的拒绝的)错误通知也是一个缺陷,它限制了某些用例的功能。
基本上,这等同于 try..catch 存在的局限:try..catch 可能捕获一个异常并简单地吞掉它。所以这并不是 Promise 独有的局限性,但可能是我们希望绕过的陷阱。
遗憾的是,很多时候并没有为 Promise 链序列的中间步骤保留的引用。因此,没有这样的引用,你就无法关联错误处理函数来可靠地检查错误。
关于Promise你还可以看这个:一道让人失眠的 Promise 试题深入分析
单一值
根据定义,Promise 只能有一个完成值或一个拒绝理由。在简单的例子中,这不是什么问题,但是在更复杂的场景中,你可能就会发现这是一种局限了。
一般的建议是构造一个值封装(比如一个对象或数组)来保持这样的多个信息。这个解决方案可以起作用,但要在 Promise 链中的每一步都进行封装和解封,就十分丑陋和笨重了。
-
分裂值
有时候,你可以把这一点,当作提示你应该把问题分解为两个或更多 Promise 的信号。
设想你有一个工具 foo(..)
,它可以异步产生两个值(x 和 y):
function getY(x) {
return new Promise(function(resolve, reject){
setTimeout(function(){
resolve((3 * x) - 1);
}, 100);
});
}
function foo(bar, baz) {
var x = bar * baz;
return getY(x).then(function(y){
// 把两个值封装到容器中
return [x, y];
});
}
foo(10, 20).then(function(msgs){
var x = msgs[0];
var y = msgs[1];
console.log(x, y); // 200 599
});
首先,我们重新组织一下 foo(..)
返回的内容,这样就不再需要把 x
和 y
封装到一个数组值中以通过 promise 传输。取而代之的是,我们可以把每个值封装到它自己的 promise:
function foo(bar, baz) {
var x = bar * baz;
// 返回两个 promise
return [
Promise.resolve(x),
getY(x)
];
}
Promise.all(
foo(10, 20)
).then(function(msgs){
var x = msgs[0];
var y = msgs[1];
console.log(x, y);
});
一个 promise 数组真的要优于传递给单个 promise 的一个值数组吗?从语法的角度来说,这算不上是一个改进。
但是,这种方法更符合 Promise 的设计理念。如果以后需要重构代码把对 x
和 y
的计算分开,这种方法就简单得多。由调用代码来决定如何安排这两个 promise,而不是把这种细节放在 foo(..)
内部抽象,这样更整洁也更灵活。这里使用了 Promise.all([ .. ])
,当然,这并不是唯一的选择。
-
传递参数
var x = ..
和 var y = ..
赋值操作仍然是麻烦的开销。我们可以在辅助工具中采用某种函数技巧:
function spread(fn) {
return Function.apply.bind(fn, null);
}
Promise.all(
foo(10, 20)
).then(spread(function(x, y){
console.log(x, y); // 200 599
}))
这样会好一点!当然,你可以把这个函数戏法在线化,以避免额外的辅助工具:
Promise.all(
foo(10, 20)
).then(Function.apply.bind(
function(x, y){
console.log(x, y); // 200 599
},
null
));
这些技巧可能很灵巧,但 ES6 给出了一个更好的答案:解构。数组解构赋值形式看起来是这样的:
Promise.all(
foo(10, 20)
).then(function(msgs){
var [x, y] = msgs;
console.log(x, y); // 200 599
});
不过最好的是,ES6 提供了数组参数解构形式:
Promise.all(
foo(10, 20)
)
.then(function([x, y]){
console.log(x, y); // 200 599
});
现在,我们符合了“每个 Promise 一个值”的理念,并且又将重复样板代码量保持在了最小!
单决议
Promise 最本质的一个特征是:Promise 只能被决议一次(完成或拒绝)。在许多异步情况中,你只会获取一个值一次,所以这可以工作良好。
但是,还有很多异步的情况适合另一种模式——一种类似于事件或数据流的模式。在表面上,目前还不清楚 Promise 能不能很好用于这样的用例,如果不是完全不可用的话。如果不在 Promise 之上构建显著的抽象,Promise 肯定完全无法支持多值决议处理。
设想这样一个场景:你可能要启动一系列异步步骤以响应某种可能多次发生的激励(就像是事件),比如按钮点击。
这样可能不会按照你的期望工作:
// click(..) 把"click"事件绑定到一个 DOM 元素
// request(..) 是前面定义的支持 Promise 的 Ajax
var p = new Promise(function(resolve, reject){
click("#mybtn", resolve);
});
p.then(function(evt){
var btnID = evt.currentTarget.id;
return request("http://some.url.1/?id=" + btnID);
}).then(function(text){
console.log(text);
});
只有在你的应用只需要响应按钮点击一次的情况下,这种方式才能工作。如果这个按钮被点击了第二次的话,promise p 已经决议,因此第二个 resolve(..)
调用就会被忽略。
因此,你可能需要转化这个范例,为每个事件的发生创建一整个新的 Promise 链:
click("#mybtn", function(evt){
var btnID = evt.currentTarget.id;
request("http://some.url.1/?id=" + btnID).then(function(text){
console.log(text);
});
});
这种方法可以工作,因为针对这个按钮上的每个 "click" 事件都会启动一整个新的 Promise 序列。
由于需要在事件处理函数中定义整个 Promise 链,这很丑陋。除此之外,这个设计在某种程度上破坏了关注点与功能分离(SoC)的思想。你很可能想要把事件处理函数的定义和对事件的响应(那个 Promise 链)的定义放在代码中的不同位置。如果没有辅助机制的话,在这种模式下很难这样实现。
感谢
如果本文对你有帮助,就点个赞支持下吧!感谢阅读。
关于本文
作者:gyx_这个杀手不太冷静
https://juejin.cn/post/6859133591108976648
- EOF -
我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。
“分享、点赞、在看” 支持一波👍