9道JS面试题

暂时性死区、编程范式、原型继承

这9道题来自于Medium的一篇文章《9 JavaScript Interview Questions》。作者把它分为两部分,第一部分是考察JS中的一些quirks,作者称其为 Curveball Questions,第二部分是一般性问题,Common Questions。

CURVEBALL QUESTIONS

1. 为什么Math.max()小于Math.min()

JS中,Math.max() > Math.min()返回false,这看起来不符合常理,但如果仔细分析后,你会发现false这个结果在逻辑上无懈可击。

在没有参数传入的情况下,调用Math.min()Math.max()分别返回infinity-infinity。为什么会返回这样的值呢?让我们看一段代码:

Math.min(1) 
// 1
Math.min(1, infinity)
// 1
Math.min(1, -infinity)
// -infinity

如果-infinity作为Math.min()的默认参数,那么所有结果都会是-infinity,那么这个函数就没有用了!而如果infinity是它的默认参数,那么会返回入参中最小的一个,这才是我们所期望的结果。

2. 为什么0.1 + 0.2 === 0.3会返回false

简单地讲,这与JS如何精确地用二进制存储浮点数有关。如果你在Chrome的控制台中输入下面的代码,你会看到:

0.1 + 0.2
// 0.30000000000000004
0.1 + 0.2 - 0.2
// 0.10000000000000003
0.1 + 0.7
// 0.7999999999999999

如果你没有高精度的计算要求,只需要执行一些简单的计算,那么这是没有问题的。但如果在一些简单的等值比较中,这个问题依然令人头疼。下面是一些解决方案。

固定小数位数。如果你准确的知道你需要的最大精度,比如有关货币的计算,你可以用整型来存储值。例如¥4.99,你可以转换成499来存储和运算,最后在面向用户的展示层,用类似这样的表达式处理,result = (value / 100).toFixed(2),返回一个字符串。

Number​.prototype.toFixed([digits])

这个方法是的入参是一个0~20的数字,表示小数点后的位数,如果不传,默认0。注意会对末位进行四舍五入处理

Number(0.1+0.7).toFixed(1) // '0.8'

3. 为什么018减去017等于3?

018 - 017返回3,这个问题与类型隐式转换有关。我们先来讨论一下八进制(octal)。

在计算机领域中二进制(binary)与十六进制(hexadecimal)很常用,但事实上,八进制在上世纪50到60年代扮演着十分重要的角色,它被用来缩写二进制,降低成本。之后十六进制便迅速的出现并应用。详见Quora

在现代计算机领域八进制有什么作用呢?八进制在某些场景中比十六进制更有优势,因为它不需要用字母来计数(不用A-F这样的字母)。一个常见的应用是在Unix系统中关于文件许可的表示上,一共有八种许可权限:

   4 2 1
0  - - - no permissions
1  - - x only execute
2  - x - only write
3  - x x write and execute
4  x - - only read
5  x - x read and execute
6  x x - read and write
7  x x x read, write and execute

出于类似的原因,它也用于数字显示器。

回到这个问题本身,在JS中,在任何数字前加0都会被视作八进制数字。但是8这个数字在八进制中并不存在,所以包含8的数字又会被隐式转换为一个常规的十进制数字。这样017是八进制,018是十进制,所以如果用十进制表达018 - 017,就相当于18 - 15

COMMON QUESTIONS

4. 函数表达式和函数声明有什么不同?

函数声明以function开头,后边函数名。而函数表达式以varlet或者const开头,后边接函数名和赋值操作符=。下面是一些例子:

// Function Declaration
function sum(x, y) {
  return x + y;
};

// Function Expression: ES5
var sum = function(x, y) {
  return x + y;
};

// Function Expression: ES6+
const sum = (x, y) => { return x + y };

使用上,它们最重要的区别是函数声明可以被提升,函数表达式不可以。函数声明被JS解释器提升到作用域顶部,所以你可以在任何地方调用函数声明。相对的,你只能顺序调用函数表达式,也就是说,你必须在调用前定义函数表达式。

如今,很多开发者更加偏好函数表达式,有一些原因可以解释:

  • 首要原因是,函数表达式可以很好的书写更加可预测、结构化的代码。当然,函数声明也可以实现结构化的代码。

  • 其次,我们可以用ES6的语法去定义函数表达式,这通常会更加简洁,并且使用let和const可以让我们更好的控制一个变量能否被重新赋值,你将会在下一个问题中深入体会到。

5. var、let和const之间有什么不同?

这个问题在ES6发布后,是一个会经常被问到的面试题。从JS第一个版本开始,var就作为变量声明的关键字,但是它的缺陷导致ES6采用了两个新的关键字来代替:letconst

这三个关键字对于赋值、变量提升和作用域有不同的处理方式,下面我们分别来看。

赋值。最基本的不同是,letvar可以被重新赋值,而const是不可以的。如果一个变量是不需要改变的,最好声明成const,这会避免一些意外重新赋值的失误。需要注意的是,const是允许变量(常量)变异的,这意味着如果变量(常量)指向的是一个类似数组或对象的引用数据类型,那么你可以改变引用中所保存的值,只是不能修改引用本身。letvar都可以被重新赋值,但是需要明白,let要比var更优。

6. 如果你在赋值变量时,不用关键字声明会怎样?

如果在为x赋值时,不用varletconst这样的关键字声明,在x还没有被定义的情况下,x = 1就相当于window.x = 1。原作者在一篇文章中讨论过JS中内存管理的问题,其中提到,这样的写法会导致内存泄露

为了防止这样的错误发生,你可以使用ES5引入的严格模式,通过在文档或某个函数顶部写一句use strict来实现。此时,如果你在赋值时不使用关键字时,你会得到一个错误提示:Uncaught SyntaxError: Unexpected indentifier

7. 面向对象(OOP)和函数式编程(FP)有什么区别?

JS是一个多范式语言,也就是说,它支持很多种不同的编程风格,包括事件驱动、函数式和面向对象。

在计算机编程中,有很多种编程范式,但函数式编程和面向对象编程是现在最为流行的,而JS对于这两种风格都支持。

面向对象编程(Object-Oriented Programming

OOP是基于“对象”这个概念的。“对象”是由一系列属性和方法构成的。

JS中有一些内建对象,比如MathJSON和一些原始数据类型,像StringArrayNumberBoolean

无论如何,你都会用到一些内建对象中的方法、或者原型和类,实质上,这就是在实践面向对象编程。

函数式编程(Functional Programming

FP是基于“纯函数”这个概念的。它提倡避免使用共享状态、可变数据和一些副作用。这看起来有一堆术语限定,但其实你有大把的机会在代码中写纯函数。

对于相同的输入,纯函数总会有不变的输出。纯函数不允许有任何副作用,比如在控制台中打印日志,或者修改外部变量的值,这些都超越了返回结果应有的影响。

对于共享状态,下面这个例子展示了在相同输入的条件下,改变了函数的输出。我们写了两个方法,一个是加5,一个是乘以5。

const num = {
  val: 1
}; 
const add5 = () => num.val += 5; 
const multiply5 = () => num.val *= 5;

如果我们先调用add5,后调用multiply5,那么结果是30。但如果我们以相反的顺序调用函数,那么我们有会得到10。

这与函数式编程的原则相违背,因为函数改变了上下文。下面我们重写了上边的例子,使得结果变得可预测:

const num = {
  val: 1
};
const add5 = () => Object.assign({}, num, {val: num.val + 5}); 
const multiply5 = () => Object.assign({}, num, {val: num.val * 5});

现在num.val不再会被改变,而且不管上下文如何,add5()multiply()将总是返回前后一致的结果。

8. 命令式编程和声明式编程有何不同?

说到声明式(declarative programming)命令式编程(imperative programming)的区别,我们同样可以思考OOP与FP之间的区别。

这是对不同编程范式中,一些共同特点的总称。函数式编程是声明式风格的典型代表,而面向对象编程则是命令式风格的范例。

基本上,命令式编程关心的是你怎样做某事。它会拼装每一步关键逻辑,处处都是for或while循环,if或者switch语句。

const sumArray = array => {
  let result = 0;
  for (let i = 0; i < array.length; i++) { 
    result += array[i]
  }; 
  return result;
}

对比之下,声明式编程更关心你要做什么,把要做的逻辑通过表达式,从怎样做的过程中抽象出来。这样的代码风格会更加简洁,但是在大规模应用中,由于它对开发者的透明度更低,会导致难以调试。

这是对上边sumArray()函数声明式编程风格的改写:

const sumArray = array => { return array.reduce((x, y) => x + y) };

9. 什么是基于原型的继承?

JS通过“prototype”来实现面向对象编程的继承特性。事实上,JS中内建的操作数组的方法mapreducesplice等等,都是继承自Array.prototype。同样像StringBoolean这样的对象也都有原型,但是像InfinityNaNnullundefined这样的值是没有属性和方法的。

是否应该重写或者扩展原型上的行为呢?

JS中几乎所有的对象,在原型链最上游都是Object.prototype。你可以轻易的通过prototype关键字为一个对象添加属性和方法,或者改变内建对象的行为,但是大多数公司反对这样做。

function Person() {};
Person.prototype.forename = "John";
Person.prototype.surname = "Smith";

如果你想要让几个对象共享一些相同的行为,那么你可以创建一个基类或者子类,通过它们继承内建对象的原型,而不必改变内建对象本身。在你与其他开发者协作的时候,对于JS默认行为的结果预计,应该是确定的,修改这些默认行为极易引发错误。

然而,值得注意的是,不是所有人都同意禁止扩展内建对象原型的。在这篇文章中,Eich认为,原型的设计,一部分原因就是为了实现扩展。

Last updated