# 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认为，原型的设计，一部分原因就是为了实现扩展。


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://frontend.mikey.wang/master.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
