🏆 学习 JS,从忍者到杀手

相信大家对《JS 忍者秘籍》一定非常熟悉。想要成为忍者,必须自宫对原型、闭包、函数、作用域等概念极其熟悉,对各种语言核心概念倒背如流。如果你有幸能将这些原理应用到实战,创建各种精巧的原型,并通过社区的考验,那才能获得忍者勋章。

不过,杀手和忍者不同,杀手天生就追求每一个细节。确切的说,JS 杀手追求回归 JS 语言本身(而不是编程实践)。

以下,我准备了一些 JS 难题,它们和某些 JS 核心概念相关。每一个 JS 杀手,或者想成为 JS 杀手的开发都可以来试试,看看自己对 JS 的掌握程度。如果回答不出来的话也请不要气馁,因为其中涉及的内容和日常编程实践相差甚远。

前置说明:

  • 代码执行以 ES6 语言规范为标准。
  • 每一个代码片段都以全局代码的形式运行。
  • 每一个题目都互相独立,其变量互不干涉。

准备好了吗?杀手试炼开始了!

1.通常,杀手在出门前会先做个热身运动~

do = {
  get exercise() {
    delete do.exercise
    return do.exercise
  }
}
do.exercise
undefined null 栈溢出错误 语法错误
热身运动完成~啊呀,大E了鸭,一不小心扭到腰了!

`do` 是 JS 中的关键字,一般用在 `do while` 语句中,这个例子里的代码会在静态分析阶段报语法错误“Uncaught SyntaxError: Unexpected token '='”。

保证自始而终的细心是成为杀手的必要素质,你做的很棒!在结束热身之前,也许你想增大一些锻炼强度使自己全面进入状态。请思考,如果把代码中的do替换为killer那么结果又是多少呢?

2.出门前别忘了检查一下防弹衣和弹药~

;[Number.isNaN('AK(AK-103)'), isNaN('Bullet(7.62 x 39mm BP)')]
true,true false,true false,false true,false
弹药补充完毕!弹夹空的,赶紧重新装填!

总的来说,isNaN 会对传入的值进行 toNumber 转换,而 Number.isNaN 不会。Number.isNaN 是一种比 isNaN 语义更“正确”的版本。具体算法见规范

背好 AKM,往荷包里塞几个弹匣。万事俱备,准备出发啦!

3.插上钥匙,打开车门~

;(function(where, undefined, where) {
  console.log(arguments[1] + where)
})('L', 'sun', 'R')
;('use strict')
;(function(where, undefined, where) {
  console.log(arguments[1] + where)
})('L', 'sun', 'R')
undefined,错误 sunR,错误 sunL,错误 sunR,sunR
汽车启动,坐稳了!太阳太刺眼了,再回去拿下太阳镜吧~

参数类型的绑定行为类似变量声明(VarDeclaration),后绑定的标识符会覆盖先绑定的标识符。此外,规范还提到,只有持有简单参数列表的函数(没有参数默认值)且在非严格模式下运行的代码才支持这种行为。不过需要注意的是 use strict 需要在代码环境开头才有用哦~

不管怎么说,你正确启动了汽车,接下来一脚把油门踩到底吧!

4.正在加速,坐稳了!

;(function(x = 1, undefined, y = 2) {
  return [...arguments].reduce((speed, acceleration) => speed + acceleration, 0)
})()
0 1 2 3
时速 80!糟糕,发动机好像在冒烟!

函数实例化时未传入参数,所以 arguments 为空,累加得 0。

时速 80 对专业的杀手——你来说太简单了,也许你在寻求一些额外挑战?请在阅读相关规范后,回答以下两行代码运行的结果~

/* First */
(function(){ var arguments; console.log(arguments) })();
/* Second */
(function(x = 1){ var arguments; console.log(arguments) })();

5.一边开车,一边寻找目标~

;[
  function() {}.__proto__ === Function.prototype,
  Function.prototype === Object.prototype,
  Function.__proto__ === Object.__proto__,
  Function.prototype.__proto__ === Object.prototype
].filter(Boolean).length
0 1 2 3
哈哈!风景不错~糟糕,好像迷路了!

原型链相关内容是忍者和杀手都要熟练掌握,并刻在脑子里的内容。具体可以康康 Hursh Jain 的 JS 原型链图片,非常清晰,能为你打开新世界的大门。

偶然间,你看到穿黑衣服的目标在巷子门口一闪而过。事不宜迟,赶紧跟上去~

6.终于找到目标了!

var target = {
  seen() {
    console.log(this === target)
  }
}
;('haha', target.seen)()
true false 错误 undefined
有了高倍镜,你能看清目标周围的情况。跟丢了!黑衣服的目标消失在了人群中!

规范中提到,对 target.seen 这种标识符,表达式将返回“值”。这个“值”不会携带计算时上下文信息。可以想象为先把 target.seen 赋值给一个变量,然后单独调用该变量,所以 this 指向 window 而不是 target。

定位目标后,你觉得还是应该先破坏掉周围的安保系统为妙。

7.破坏警铃~

/* 注意:浏览器环境,两块代码分开执行,防止变量提升 */
// 先执行这三行(破坏左边的警铃)
var alert = (...args) => console.log(args)
delete alert
console.log(typeof window.alert)
// 再执行这三行(破坏右边的警铃)
var alert = (...args) => console.log(args)
delete alert
console.log(typeof window.alert)
undefined,undefined undefined,function function,undefined undefined,错误
一枪打爆了警铃!没打中警铃,再补一枪吧!

在全局代码中声明的变量,分两种情况:一是如果全局对象没有这个属性,就正常走变量声明流程,所以第二个输出为 function;第二点则比较少的老哥知道了,如果全局对象有这个属性,则通过环境记录(Environment Record,可以理解为引擎提供的用于记录声明的变量这么一个东西)其内部属性 VarNames 将声明的标识符于其值的绑定记录下来。这个记录和正常变量声明不同,是可修改的,可以通过以下代码验证:

// 在新开的控制台测试
var alert = (...args) => console.log(args)
Object.getOwnPropertyDescriptor(window, 'alert')
// >>> {writable: true, enumerable: true, configurable: true, value: ƒ}

相关规范有:CreateGlobalVarBindingDelete Operator

多提一句,许多博客说函数的 length 属性具有元属性 DontDelete,DontDelete 为 true,所以不可删除,其实不是这样滴~ 从 ES5 开始 DontDelete 就被内部属性 Configurable 等价替换掉了。从最新的规范中可以找到,函数初始化时,length 属性当前的内部属性 Configurable 实为 true,所以是“可删除的”。你可以打开浏览器控制台,试试创建一个函数,并删除他的 length 属性,就会发现会返回 true。

想要成为杀手果然一定要先把细节操练一百遍呐!当你准备好了的时候,我们再继续吧。

杀手的世界,容不得半点马虎。不过雇主肯定很放心,因为你是个专业的杀手。

8.计算距离,瞄准目标!

var killer = (target = {
  y: 2,
  z: 3
})
target.x = target = 1

console.log(killer.x < killer.y < killer.z)
console.log(target.x < target.y < target.z)
false,false true,true true,false false,true
目标,锁定~风有点大,现在开枪子弹会偏得离谱,再等等吧。

需要注意两点,运算符结合性和隐式转换。关系运算符是左结合性的而赋值运算符是右结合性的,所以 1<2<3 的运算顺序是 (1<2)<3,而 x=y=z 的运算顺序是 x=(y=z)。从赋值语句得,target 和 killer 指向同一个对象,所以在赋值语句中 target.x 即 killer.x,killer.x 得到了“target 被赋值为 1”的“值(即 target)”,target 被赋为 1。所以赋值结束后,killer.x 值为 1,target 值为 1。最终的输出其实是在比较 1<2<3 以及 undefined<undefined<undefined,前者结果为 true,后者为 false。

锁定目标了,开枪!

9.BANG!

;[
  void "killer's target" === typeof down,
  delete void "killer's target" === delete undefined,
  delete undefined === delete null
].filter(Boolean).length
0 1 2 3
正中目标!Target Down!我们未能击穿他的装甲(指防弹衣)!

Void 运算符最后会返回 undefined,不过和 typeof down 返回的字符串 undefined 是不相等的,所以第一行 false 没跑了。从规范 DeleteUndefined 可以找到,删除 null(值)返回 true,删除 undefined 返回 false,第三行 false 没跑了。难的是第二行:delete undefined 不能直接删除,是因为 delete 运算符会先找到 undefined 的描述符,看到它的内部属性 Configurable 为 false,所以不能删。而 void 'string' 返回的 undefined,却是 undefined 的“值”。听起来有些抽象,我们以以下代码为例:

delete NaN; // false
+'string'; // NaN
delete +'string'; // true

NaN 是不可删除的,但是我们的表达式 +'string' 返回的是 NaN 这种“值”,所以 delete +'string' 回退为“删除某个值”这种操作,按照规范直接返回 true。题中的 delete void "killer's target" 与此原理相同,需要先清空一下大脑再去理解。

好,最难的部分解决了!不过按照三流小说的剧情套路来看,杀手总是在行动后,因为回忆起某些抑郁的往事,陷入恐慌与不安。接下来,需要你独自面对并解决这种情绪,也就是——你的“心魔”。

10.“邪恶”的情绪在内心滋生!

// 附:heart 和 ghost 是从未定义过的变量
eval(`typeof typeof ghost === typeof ${typeof heart}`)
错误 undefined true false
看来血腥场面甚至不能引起你的情绪波动。子弹击中目标的血腥场面使你有些反胃,你感觉自己的手在摇摇晃晃。

typeof 右侧表达式如果是无效引用(不可解决的引用,UnresolvableReference)或是 undefined,都会返回 "undefined"。详见规范:Typeof。不过请注意,这个 "undefined" 是字符串哇~ 模板字符串实例化的结果会变成 “typeof typeof ghost === typeof undefined”,所以结果为 false。

杀手成功战胜了心魔,继续前行~

11.前行路上,心魔又一次袭来 😈,它会淹没你吗?

var heart = 'alive'
;(function evalAttack() {
  var eval = window.eval
  var ghost = window.eval
  var heart = 'dead'
  eval('console.log(heart)')
  ;(0, eval)('console.log(heart)')
  ghost('console.log(heart)')
})()
dead alive dead dead alive alive alive dead alive alive dead dead 21
把“心魔”两个字从杀手的字典里划掉了。精神状态更糟糕了,你仿佛看到了目标的幽灵在身边晃动。

简单来说,eval 函数分为“直接调用”或“间接调用”两种形式,形如“eval()”的 eval 函数被称为“直接调用”,而“(0, eval)()”则是“间接调用”,两者的不同之处在于变量环境和 this 指向。kangax 的《Global eval. What are the options?》详细描述了如何区分两者,可以作为指南。规范中则有更详细的算法,见相关章节:Function Calls EvaluationRuntime Semantics: PerformEval

恭喜你,连续两次平复了内心的情绪波动。现在,你决定把目标搬上车带回家。出于好奇,你翻了翻他的钱包确认身份。等等!他的钱包里怎么会有一张你未婚妻的照片!?

12.SAN 值(理智)狂减!

// finally 中的 console.log 会输出么?
// return 语句会返回么?
;(function() {
  let san = 3
  try {
    --san
    return san
  } finally {
    --san
    console.log(san)
  }
})()
输出 1,返回 2 输出 1,不返回 输出 1,返回 1 不输出,返回 2
冷静下来,把手上的事儿办完再说。接连的打击使你眼前的世界逐渐转为黑白!

这个问题应该是最稀奇古怪的那个品种了。以正常的程序思维是不能理解这种代码的,还是得回到杀手训练营(语言规范)寻找解决办法。
一般会认为执行到 return 也就会“结束函数运行”,但是这样会破坏“finally”的概念完整性(即:无论如何都要运行)。所以,规范描述 try finally 语句的执行行为的前两步骤,就是分别执行 try 中语句以及 finally 中的语句,然后才是根据两者的返回类型(如抛错、Break、正常返回)来确定整个 try finally 最终返回啥。而执行时,try 中的 return 的值已经确定,所以 finally 中对 san 做出的修改并不会对 try 中的 return 有影响。更详细的内容请查看规范相关章节:TryStatement
额外插一句,JS 中不仅仅只有表达式有“值”的概念,其实语句也有(虽然两者的“值”不是同一个东西)。为了方便理解,你可以把 return 语句的“值”想象为“{ type: 'return', value: '...' }”,JS 本身并没有啥 API 能够获取语句的“值”中的 type 属性,你只可以通过形如“eval('"string"')”这种语句拿到语句的“值”的值(即 value 属性)。只要你能理解在 JS 中语句也有“值”这种概念,那么就可以想想引擎可以把 return 语句的结果保存下来并传来传去了。

来,深呼吸...慢慢地...平静下来。你得继续。

13.来做道数学题吧,恢复一下理智~

;[
  // kill have to be careful
  0.1 + 0.2 === 0.3,
  Number.MAX_SAFE_INTEGER === Number.MAX_SAFE_INTEGER + 1,
  Number.MAX_VALUE === Number.MAX_VALUE + 1
].filter(Boolean).length
0 1 2 3
“做数学题能恢复理智”曾经是数学老师告诉你的办法。啊不过... 不管怎么说,至少这个方法目前为止还是有用的。!(看到数学题,脑袋变得理智啦)~

第一个问题非常经典,同时也不仅仅只有 JS 中有这种问题。一句话解释就是:JS 的数值系统采用 IEEE 754 双精度浮点数标准来储存浮点数,共 64 位,所以某些十进制对应的二进制位数如果很长则不能存得下。这就意味着,我们在 REPL 中输入的十进制数对应的在引擎内中真实存储的值要偏大或偏小一些。所以我们实际上是在比较:一个约等于 0.1 的数加一个约等于 0.2 的数是否为一个约等于 0.3 的数。至于结果呢,你应该手动计算一下~
第二个、第三个问题也非常经典(废话),更多细节见 MDN Number.MAX_SAFE_INTEGERMDN Number.MAX_VALUE

14.把“行李”搬到后备箱,回家!

drivetime = 0
isArived = false
console.log('go')
setTimeout(() => (isArived = true), 0)
while (++drivetime && !isArived) {}
console.log('home')
'go','home' 'go' 'home' Empty Log
钥匙插上,回归 80 迈!街对面似乎传来警笛声,握紧方向盘的手心冒出了汗。

事件循环(Event Loop),老生常谈的话题了,网上能找到一堆解释:《JS 开发者应懂的33个概念:消息队列和事件循环》。有意思的一点是,它是 HTML5 规范定义的,而不是 ES 规范。细想一番倒也能理解,因为计时器是由宿主环境提供的,和 Javascript 语言本身无关。

目前为止你都很棒,杀手!再回答最后一个问题,你就能安全到家了。

15.开车时需要避开路上的井盖!

;[
  // DEX + 100!
  +0 === -0,
  String(+0) === String(-0),
  +0 * 'hole' === -0 * 'hole',
  1 / +0 === 1 / -0
].filter(Boolean).length
0 1 2 3
到家啦,任务完成!车子卡在了某个神奇的角落。不出一会儿,你就被警察逮住了~

数值比较时,正负零相等;转为字符串,不会带符号,所以第二条也为 true;NaN 不等于 NaN,第三条为 false;Infinity 不等于 -Infinity,最后一条为 false。如果你想在 JS 中区分中某个“零”是正零还是负零,那么就可以使用第四条代码演示的除法规则。

恭喜你,杀手,成功通过试炼!

阅读更多

Hi,还好吗,多喝热水感觉如何?有没有喜欢上这种风格别具一格的挑战呢?

虽然说试炼中的每道题背后的语言细节可能只是规范中一个段落,一句话,甚至一行标准所描述的内容,和编程实践几乎毫不相干,但实际上我只是想引起你对语言规范的兴趣。语言规范太重要了,学习规范有助于你更深入理解这门语言,理解引擎的工作机理、AST 的具体表现以及各类框架代码中的黑科技,而不仅仅只是成为它的使用者。 共勉。

如果你仍意犹未尽,可以继续试试这些难题(内内,别忘了先给我点赞投币关注三连呐):

本文最后更新于: December 20 2020 16:35