🏆 学习 JS,从忍者到杀手

2,146 阅读11分钟

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

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

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

前置说明:

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

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

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

do = {
  get exercise() {
    delete do.exercise
    return do.exercise
  }
}
do.exercise
请选择答案: 【A】undefined 【B】null 【C】栈溢出错误 【D】语法错误
答案和解析(别偷看哦~)

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

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

;[Number.isNaN('AK(AK-103)'), isNaN('Bullet(7.62 x 39mm BP)')]
请填写答案: 【A】true,true 【B】false,true 【C】false,false 【D】true,false
答案和解析(别偷看哦~)

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

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')
请选择答案: 【A】undefined,错误 【B】sunR,错误 【C】sunL,错误 【D】sunR,sunR
答案和解析(别偷看哦~)

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

4. 正在加速,坐稳了!

;(function(x = 1, undefined, y = 2) {
  return [...arguments].reduce((speed, acceleration) => speed + acceleration, 0)
})()
请选择答案: 【A】0 【B】1 【C】2 【D】3
答案和解析(别偷看哦~)

【正确答案:A】函数实例化时未传入参数,所以 arguments 为空,累加得 0。

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

;[
  function() {}.__proto__ === Function.prototype,
  Function.prototype === Object.prototype,
  Function.__proto__ === Object.__proto__,
  Function.prototype.__proto__ === Object.prototype
].filter(Boolean).length
请选择答案: 【A】0 【B】1 【C】2 【D】3
答案和解析(别偷看哦~)

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

6. 终于找到目标了!

var target = {
  seen() {
    console.log(this === target)
  }
}
;('haha', target.seen)()
请选择答案: 【A】true 【B】false 【C】错误 【D】undefined
答案和解析(别偷看哦~)

【正确答案:B】规范中提到,对 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)
请选择答案: 【A】undefined,undefined 【B】undefined,function 【C】function,undefined 【D】undefined,错误
答案和解析(别偷看哦~)

【正确答案:B】在全局代码中声明的变量,分两种情况:一是如果全局对象没有这个属性,就正常走变量声明流程,所以第二个输出为 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)
请选择答案: 【A】false,false 【B】true,true 【C】true,false 【D】false,true
答案和解析(别偷看哦~)

【正确答案:C】需要注意两点,运算符结合性和隐式转换。关系运算符是左结合性的而赋值运算符是右结合性的,所以 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
请选择答案: 【A】0 【B】1 【C】2 【D】3
答案和解析(别偷看哦~)

【正确答案:A】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}`)
请选择答案: 【A】错误 【B】undefined 【C】true 【D】false
答案和解析(别偷看哦~)

【正确答案:D】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)')
})()
请选择答案: 【A】dead alive dead 【B】dead alive alive 【C】alive dead alive 【D】alive dead dead 21
答案和解析(别偷看哦~)

【正确答案:B】简单来说,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)
  }
})()
请选择答案: 【A】输出 1,返回 2 【B】输出 1,不返回 【C】输出 1,返回 1 【D】不输出,返回 2
答案和解析(别偷看哦~)

【正确答案:A】 这个问题应该是最稀奇古怪的那个品种了。以正常的程序思维是不能理解这种代码的,还是得回到杀手训练营(语言规范)寻找解决办法。
一般会认为执行到 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
请选择答案: 【A】0 【B】1 【C】2 【D】3
答案和解析(别偷看哦~)

【正确答案:B】第一个问题非常经典,同时也不仅仅只有 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')
请选择答案: 【A】'go','home' 【B】'go' 【C】'home' 【D】Empty Log
答案和解析(别偷看哦~)

【正确答案:B】事件循环(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
请选择答案: 【A】0 【B】1 【C】2 【D】3
答案和解析(别偷看哦~)

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

阅读更多

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

虽说文章题戏谑规范是幻术,文中题目也和编程实践几乎毫不相干,但实际上我只是想引起你对语言规范的兴趣。语言规范太重要了,学习规范有助于你更深入理解这门语言,理解引擎的工作机理、AST 的具体表现以及各类框架代码中的黑科技,而不仅仅只是成为它的使用者。 共勉。

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