# 9道JS面试题

这9道题来自于Medium的一篇文章[《9 JavaScript Interview Questions》](https://medium.com/@bretcameron/9-javascript-interview-questions-48416366852b)。作者把它分为两部分，第一部分是考察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`。为什么会返回这样的值呢？让我们看一段代码：

```javascript
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的控制台中输入下面的代码，你会看到：

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

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

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

{% hint style="info" %}
**`Number​.prototype.toFixed([digits])`**

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

`Number(0.1+0.7).toFixed(1) // '0.8'`
{% endhint %}
{% endtab %}

{% tab title="BCD码" %}
**Binary Coded Decimals（二-十进制代码）。一般称为BCD码。**&#x8FD9;种编码形式使二进制和十进制之间的转换能够快捷进行。采用BCD码，既可保存数值的精确度，又可避免使电脑作浮点运算时所消耗的时间。缺点是**每一个十进制数都分别存储在一个字节中**，即占用8比特，这在内存利用效率上无疑是低效的。但如果你对精度的要求很苛刻，那么值得权衡。（[JS的BCD库](https://formats.kaitai.io/bcd/javascript.html)）

> BCD码可分为有权码和无权码两类：有权BCD码有8421码、2421码、5421码，其中8421码是最常用的；无权BCD码有余3码、格雷码等。
>
> 计算机中的BCD码，经常使用的有两种格式，即分离BCD码，组合BCD码。
>
> 所谓分离BCD码，即用一个字节的低四位编码表示十进制数的一位，例如数字82的存放格式为：  ————*1 0 0 0* *————* *0 0 1 0 其中—*&#x8868;示无关值，一般会用0填充。
>
> 填充BCD码，是将两位十进制数，存放在一个字节中，比如82的存放格式是10000010

*以上整理来源于*[*CSDN* ](https://blog.csdn.net/Firefly_cjd/article/details/51921654)
{% endtab %}
{% endtabs %}

### 3. 为什么018减去017等于3？

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

在计算机领域中二进制（binary）与十六进制（hexadecimal）很常用，但事实上，八进制在上世纪50到60年代扮演着十分重要的角色，它被用来缩写二进制，降低成本。之后十六进制便迅速的出现并应用。详见[Quora](https://www.quora.com/Is-it-true-that-early-computers-such-as-the-PDP-8-ICL-1900-and-IBM-mainframes-use-octal-base-instead-of-hexadecimal-base-to-represent-words)。

在现代计算机领域八进制有什么作用呢？八进制在某些场景中比十六进制更有优势，因为它不需要用字母来计数（不用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`开头，后边函数名。而函数表达式以`var`，`let`或者`const`开头，后边接函数名和赋值操作符`=`。下面是一些例子：

```javascript
// 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采用了两个新的关键字来代替：`let`和`const`。

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

{% tabs %}
{% tab title="赋值" %}
**赋值。**&#x6700;基本的不同是，`let`和`var`可以被重新赋值，而`const`是不可以的。如果一个变量是不需要改变的，最好声明成const，这会避免一些意外重新赋值的失误。需要注意的是，**`const`是允许变量（常量）变异的**，这意味着如果变量（常量）指向的是一个类似数组或对象的引用数据类型，那么你可以改变引用中所保存的值，只是不能修改引用本身。`let`和`var`都可以被重新赋值，但是需要明白，`let`要比`var`更优。
{% endtab %}

{% tab title="变量提升" %}
**变量提升。**&#x4E0E;函数声明与函数表达式提升类似（参见上一个问题），用`var`声明的变量会被提升到各自作用域的顶部，而用`const`和`let`声明的变量虽然有提升，但如果你在声明前访问的话，会触发‘’‘暂时性死区’的错误。（此处原文有一个例子，来证明`let`在变量提升上的优势可以减少错误发生，但并不是十分恰当，遂略过，后边补充一下暂时性死区的知识）

**暂时性死区**

**关于`const`和`let`对变量是否有提升作用**，社区争议一直很大，我更倾向于肯定的答案。原因如下：

```javascript
x = "global";
// 函数作用域
(function() {
    x; // undefined

    var x = 1;
}());
// 块作用域
{
    x; // x is not defined
    let/const x = 1;
}
```

很显然，如果`let`和`const`不存在变量提升的话，第十行不应该报错，而是应该打印出`"global"`。

**要搞清楚提升的本质，需要理解 JS 变量的创建（create）、初始化（initialize） 和赋值（assign）。**

假设有如下代码：

```javascript
function fn(){
  var x = 1
  var y = 2
}
fn()
```

在执行 `fn` 时，会有以下过程（不完全）：

1. 进入 `fn`，为 `fn` 创建一个环境。
2. 找到 `fn` 中所有用 `var` 声明的变量，在这个环境中「**创建**」这些变量（即 `x` 和 `y`）。
3. 将这些变量「**初始化**」为 `undefined`。
4. 开始**执行代码**
5. `x = 1` 将 `x` 变量「**赋值**」为 `1`
6. `y = 2` 将 `y` 变量「**赋值**」为`2`

也就是说 `var` 声明会在代码执行之前就将创建变量，并将其初始化为 `undefined`。

这就解释了为什么在 `var x = 1` 之前 `console.log(x)` 会得到 `undefined`。

**接下来看 let 声明的「创建、初始化和赋值」过程**

假设代码如下：

```javascript
{
  let x = 1
  x = 2
}
```

我们只看`{}` 里面的过程：

1. 找到所有用 `let` 声明的变量，在环境中「**创建**」这些变量
2. 开始**执行代码**（**注意现在还没有初始化**）
3. 执行 `x = 1`，将 `x` 「**初始化**」为 `1`（**这并不是一次赋值**，如果代码是 `let x`，就将 `x` 初始化为 `undefined`）
4. 执行 `x = 2`，对 `x` 进行「**赋值**」

这就解释了为什么在 let x 之前使用 x 会报错：

```javascript
let x = 'global'
{
  console.log(x) // Uncaught ReferenceError: x is not defined
  let x = 1
}
```

原因有两个：

1. `console.log(x)` 中的 `x` 指的是下面的 `x`，而不是全局的 `x`
2. 执行 log 时 `x` **还没「初始化」**，所以不能使用（也就是所谓的**暂时性死区**）

看到这里，你应该明白了 `let` 到底有没有提升：

1. **`let` 的「创建」过程被提升了，但是「初始化」没有提升。**
2. **`var` 的「创建」和「初始化」都被提升了。**

最后看 const，其实 const 和 let 只有一个区别，那就是 const 只有「创建」和「初始化」，没有「赋值」过程。（如果只写`const foo;`，解释器会报错提示`Missing initializer in const declaration`）

**所以，所谓暂时性死区，就是不能在初始化之前，使用变量。**\
\
\&#xNAN;*以上暂时性死区部分，整理来源于*[*简书*](https://www.jianshu.com/p/0f49c88cf169)*。*
{% endtab %}

{% tab title="作用域" %}
`var`变量存在函数作用域，`let`和`const`变量有块级作用域。一般地，大括号`{}`、函数、条件和循环语句都可以形成块级作用域。通过下面的例子可以更好的说明它们的差异：

```javascript
var a = 0; 
let b = 0;
const c = 0;
if (true) {
  var a = 1;
  let b = 1; 
  const c = 1;
}
console.log(a); // 1
console.log(b); // 0
console.log(c); // 0
```

我们看到，在条件语句中，全局作用域的`var a`被重新赋值，而`let b`和`const c`却没有。所以局部作用域中的变量最好声明为局部块级的，这样会使代码更加清晰，并减少错误的发生。
{% endtab %}
{% endtabs %}

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

如果在为`x`赋值时，不用`var`、`let`、`const`这样的关键字声明，在`x`还没有被定义的情况下，`x = 1`就相当于`window.x = 1`。原作者在一篇文章中讨论过[JS中内存管理的问题](https://medium.com/@bretcameron/what-javascript-developers-can-learn-from-c-3cdb93ab8658)，其中提到，**这样的写法会导致内存泄露**。

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

### 7. 面向对象（OOP）和函数式编程（FP）有什么区别？

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

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

#### 面向对象编程（**Object-Oriented Programming**）

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

JS中有一些内建对象，比如`Math`、`JSON`和一些原始数据类型，像`String`，`Array`，`Number`和`Boolean`。

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

#### 函数式编程（**Functional Programming**）

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

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

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

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

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

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

```javascript
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）**&#x548C;**命令式编程（imperative programming）**&#x7684;区别，我们同样可以思考OOP与FP之间的区别。

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

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

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

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

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

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

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

JS通过“prototype”来实现面向对象编程的继承特性。事实上，JS中内建的操作数组的方法`map`、`reduce`、`splice`等等，都是继承自`Array.prototype`。同样像`String`、`Boolean`这样的对象也都有原型，但是像`Infinity`，`NaN`，`null`和`undefined`这样的值是没有属性和方法的。

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

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

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

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

然而，值得注意的是，不是所有人都同意禁止扩展内建对象原型的。[在这篇文章中](https://brendaneich.com/2005/06/javascript-1-2-and-in-between/)，Eich认为，原型的设计，一部分原因就是为了实现扩展。
