Tiny'Wo | 小窝

网络中的一小块自留地

面试官:说说JavaScript中的数据类型?存储上的差别?

前言

JavaScript中,我们可以分成两种类型�?

  • 基本类型
  • 复杂类型

两种类型的区别是:存储位置不�?

一、基本类�?

基本类型主要为以�?种:

  • Number
  • String
  • Boolean
  • Undefined
  • null
  • symbol

Number

数值最常见的整数类型格式则为十进制,还可以设置八进制(零开头)、十六进制(0x开头)

1
2
3
let intNum = 55 // 10进制�?5
let num1 = 070 // 8进制�?6
let hexNum1 = 0xA //16进制�?0

浮点类型则在数值汇总必须包含小数点,还可通过科学计数法表�?

1
2
3
let floatNum1 = 1.1;
let floatNum2 = 0.1;
let floatNum3 = .1; // 有效,但不推�?let floatNum = 3.125e7; // 等于 31250000

在数值类型中,存在一个特殊数值NaN,意为“不是数值”,用于表示本来要返回数值的操作失败了(而不是抛出错误)

1
2
console.log(0/0); // NaN
console.log(-0/+0); // NaN

Undefined

Undefined 类型只有一个值,就是特殊�?undefined。当使用 var �?let 声明了变量但没有初始化时,就相当于给变量赋予�?undefined �?

1
2
let message;
console.log(message == undefined); // true

包含 undefined 值的变量跟未定义变量是有区别�?

1
2
3
4
let message; // 这个变量被声明了,只是值为 undefined

console.log(message); // "undefined"
console.log(age); // 没有声明过这个变量,报错

String

字符串可以使用双引号�?)、单引号�?)或反引号(`)标�?

1
2
3
let firstName = "John";
let lastName = 'Jacob';
let lastName = `Jingleheimerschmidt`

字符串是不可变的,意思是一旦创建,它们的值就不能变了

1
2
let lang = "Java";
lang = lang + "Script"; // 先销毁再创建

Null

Null 类型同样只有一个值,即特殊�?null

逻辑上讲�?null 值表示一个空对象指针,这也是给typeof 传一�?null 会返�?"object" 的原�?

1
2
let car = null;
console.log(typeof car); // "object"

undefined 值是�?null 值派生而来

1
console.log(null == undefined); // true

只要变量要保存对象,而当时又没有那个对象可保存,就可�?null 来填充该变量

Boolean

Boolean (布尔值)类型有两个字面值: true false

通过Boolean可以将其他类型的数据转化成布尔�?
规则如下�?

1
2
3
4
数据类型      				转换�?true 的�?     				转换�?false 的�? String        				 非空字符�?         					"" 
Number 非零数值(包括无穷值) 0 �?NaN
Object 任意对象 null
Undefined N/A (不存在�? undefined

Symbol

Symbol (符号)是原始值,且符号实例是唯一、不可变的。符号的用途是确保对象属性使用唯一标识符,不会发生属性冲突的危险

1
2
3
4
5
6
7
let genericSymbol = Symbol();
let otherGenericSymbol = Symbol();
console.log(genericSymbol == otherGenericSymbol); // false

let fooSymbol = Symbol('foo');
let otherFooSymbol = Symbol('foo');
console.log(fooSymbol == otherFooSymbol); // false

二、引用类�?

复杂类型统称为Object,我们这里主要讲述下面三种:

  • Object
  • Array
  • Function

Object

创建object常用方式为对象字面量表示法,属性名可以是字符串或数�?

1
2
3
4
5
let person = {
name: "Nicholas",
"age": 29,
5: true
};

Array

JavaScript数组是一组有序的数据,但跟其他语言不同的是,数组中每个槽位可以存储任意类型的数据。并且,数组也是动态大小的,会随着数据添加而自动增�?

1
2
let colors = ["red", 2, {age: 20 }]
colors.push(2)

Function

函数实际上是对象,每个函数都�?Function类型的实例,�?Function 也有属性和方法,跟其他引用类型一�?
函数存在三种常见的表达方式:

  • 函数声明
1
2
3
4
// 函数声明
function sum (num1, num2) {
return num1 + num2;
}
  • 函数表达�?

    1
    2
    3
    let sum = function(num1, num2) {
    return num1 + num2;
    };
  • 箭头函数

函数声明和函数表达式两种方式

1
2
3
let sum = (num1, num2) => {
return num1 + num2;
};

其他引用类型

除了上述说的三种之外,还包括DateRegExpMapSet�?…..

三、存储区�?

基本数据类型和引用数据类型存储在内存中的位置不同�?

  • 基本数据类型存储在栈�?
  • 引用类型的对象存储于堆中

当我们把变量赋值给一个变量时,解析器首先要确认的就是这个值是基本类型值还是引用类型�?
下面来举个例�?

基本类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let a = 10;
let b = a; // 赋值操�?b = 20;
console.log(a); // 10�?```

`a`的值为一个基本类型,是存储在栈中,将`a`的值赋给`b`,虽然两个变量的值相等,但是两个变量保存了两个不同的内存地址

下图演示了基本类型赋值的过程�?
![](https://static.vue-js.com/906ffb90-6463-11eb-85f6-6fac77c0c9b3.png)



### 引用类型

```js
var obj1 = {}
var obj2 = obj1;
obj2.name = "Xxx";
console.log(obj1.name); // xxx

引用类型数据存放在堆中,每个堆内存对象都有对应的引用地址指向它,引用地址存放在栈中�?
obj1是一个引用类型,在赋值操作过程汇总,实际是将堆内存对象在栈内存的引用地址复制了一份给了obj2,实际上他们共同指向了同一个堆内存对象,所以更改obj2会对obj1产生影响

下图演示这个引用类型赋值过�?

小结

  • 声明变量时不同的内存地址分配�? - 简单类型的值存放在栈中,在栈中存放的是对应的�? - 引用类型对应的值存储在堆中,在栈中存放的是指向堆内存的地址
  • 不同的类型数据导致赋值变量时的不同:
    • 简单类型赋值,是生成相同的值,两个对象对应不同的地址
    • 复杂类型赋值,是将保存对象的内存地址赋值给另一个变量。也就是两个变量指向堆内存中同一个对�?

面试官:什么是防抖和节流?有什么区别?如何实现�?

一、是什�?本质上是优化高频率执行代码的一种手�?

如:浏览器的 resizescrollkeypressmousemove 等事件在触发时,会不断地调用绑定在事件上的回调函数,极大地浪费资源,降低前端性能

为了优化体验,需要对这类事件进行调用次数的限制,对此我们就可以采�?*防抖(debounce�? �?*节流(throttle�? 的方式来减少调用频率

定义

  • 节流: n 秒内只运行一次,若在 n 秒内重复触发,只有一次生�?- 防抖: n 秒后在执行该事件,若�?n 秒内被重复触发,则重新计�?
    一个经典的比喻:

想象每天上班大厦底下的电梯。把电梯完成一次运送,类比为一次函数的执行和响�?
假设电梯有两种运行策�?debounce �?throttle,超时设定为15秒,不考虑容量限制

电梯第一个人进来后,15秒后准时运送一次,这是节流

电梯第一个人进来后,等待15秒。如果过程中又有人进来,15秒等待重新计时,直到15秒后开始运送,这是防抖

代码实现

节流

完成节流可以使用时间戳与定时器的写法

使用时间戳写法,事件会立即执行,停止触发后没有办法再次执�?

1
2
3
4
5
6
7
8
9
10
11
function throttled1(fn, delay = 500) {
let oldtime = Date.now()
return function (...args) {
let newtime = Date.now()
if (newtime - oldtime >= delay) {
fn.apply(null, args)
oldtime = Date.now()
}
}
}

使用定时器写法,delay毫秒后第一次执行,第二次事件停止触发后依然会再一次执�?

1
2
3
4
5
6
7
8
9
10
11
function throttled2(fn, delay = 500) {
let timer = null
return function (...args) {
if (!timer) {
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay);
}
}
}

可以将时间戳写法的特性与定时器写法的特性相结合,实现一个更加精确的节流。实现如�?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function throttled(fn, delay) {
let timer = null
let starttime = Date.now()
return function () {
let curTime = Date.now() // 当前时间
let remaining = delay - (curTime - starttime) // 从上一次到现在,还剩下多少多余时间
let context = this
let args = arguments
clearTimeout(timer)
if (remaining <= 0) {
fn.apply(context, args)
starttime = Date.now()
} else {
timer = setTimeout(fn, remaining);
}
}
}

防抖

简单版本的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
function debounce(func, wait) {
let timeout;

return function () {
let context = this; // 保存this指向
let args = arguments; // 拿到event对象

clearTimeout(timeout)
timeout = setTimeout(function(){
func.apply(context, args)
}, wait);
}
}

防抖如果需要立即执行,可加入第三个参数用于判断,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function debounce(func, wait, immediate) {

let timeout;

return function () {
let context = this;
let args = arguments;

if (timeout) clearTimeout(timeout); // timeout 不为null
if (immediate) {
let callNow = !timeout; // 第一次会立即执行,以后只有事件执行后才会再次触发
timeout = setTimeout(function () {
timeout = null;
}, wait)
if (callNow) {
func.apply(context, args)
}
}
else {
timeout = setTimeout(function () {
func.apply(context, args)
}, wait);
}
}
}

二、区�?

相同点:

  • 都可以通过使用 setTimeout 实现

  • 目的都是,降低回调执行频率。节省计算资�?
    不同点:

  • 函数防抖,在一段连续操作结束后,处理回调,利用clearTimeout �?setTimeout实现。函数节流,在一段连续操作中,每一段时间只执行一次,频率较高的事件中使用来提高性能

  • 函数防抖关注一定时间连续触发的事件,只在最后执行一次,而函数节流一段时间内只执行一�?
    例如,都设置时间频率�?00ms,在2秒时间内,频繁触发函数,节流,每�?500ms 就执行一次。防抖,则不管调动多少次方法,在2s后,只会执行一�?
    如下图所示:

三、应用场�?

防抖在连续的事件,只需触发一次回调的场景有:

  • 搜索框搜索输入。只需用户最后一次输入完,再发送请�?- 手机号、邮箱验证输入检�?- 窗口大小resize。只需窗口调整完成后,计算窗口大小。防止重复渲染�?
    节流在间隔一段时间执行一次回调的场景有:

  • 滚动加载,加载更多或滚到底部监听

  • 搜索框,搜索联想功能

面试官:说说JavaScript中的事件模型

一、事件与事件�?

javascript中的事件,可以理解就是在HTML文档或者浏览器中发生的一种交互操作,使得网页具备互动性, 常见的有加载事件、鼠标事件、自定义事件�?
由于DOM是一个树结构,如果在父子节点绑定事件时候,当触发子节点的时候,就存在一个顺序问题,这就涉及到了事件流的概念

事件流都会经历三个阶段:

  • 事件捕获阶段(capture phase)
  • 处于目标阶段(target phase)
  • 事件冒泡阶段(bubbling phase)

事件冒泡是一种从下往上的传播方式,由最具体的元素(触发节点)然后逐渐向上传播到最不具体的那个节点,也就是DOM中最高层的父节点

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Event Bubbling</title>
</head>
<body>
<button id="clickMe">Click Me</button>
</body>
</html>

然后,我们给button和它的父元素,加入点击事�?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var button = document.getElementById('clickMe');

button.onclick = function() {
console.log('1.Button');
};
document.body.onclick = function() {
console.log('2.body');
};
document.onclick = function() {
console.log('3.document');
};
window.onclick = function() {
console.log('4.window');
};

点击按钮,输出如�?

1
2
3
4
1.button
2.body
3.document
4.window

点击事件首先在button元素上发生,然后逐级向上传播

事件捕获与事件冒泡相反,事件最开始由不太具体的节点最早接受事�? 而最具体的节点(触发节点)最后接受事�?

二、事件模�?

事件模型可以分为三种�?

  • 原始事件模型(DOM0级)
  • 标准事件模型(DOM2级)
  • IE事件模型(基本不用)

原始事件模型

事件绑定监听函数比较简�? 有两种方式:

  • HTML代码中直接绑�?

    1
    <input type="button" onclick="fun()">
  • 通过JS代码绑定

1
2
var btn = document.getElementById('.btn');
btn.onclick = fun;

特�?

  • 绑定速度�?
    DOM0级事件具有很好的跨浏览器优势,会以最快的速度绑定,但由于绑定速度太快,可能页面还未完全加载出来,以至于事件可能无法正常运�?
  • 只支持冒泡,不支持捕�?
  • 同一个类型的事件只能绑定一�?
    1
    2
    3
    4
    <input type="button" id="btn" onclick="fun1()">

    var btn = document.getElementById('.btn');
    btn.onclick = fun2;

如上,当希望为同一个元素绑定多个同类型事件的时候(上面的这个btn元素绑定2个点击事件),是不被允许的,后绑定的事件会覆盖之前的事件

删除 DOM0 级事件处理程序只要将对应事件属性置为null即可

1
btn.onclick = null;

标准事件模型

在该事件模型中,一次事件共有三个过�?

  • 事件捕获阶段:事件从document一直向下传播到目标元素, 依次检查经过的节点是否绑定了事件监听函数,如果有则执行
  • 事件处理阶段:事件到达目标元�? 触发目标元素的监听函�?- 事件冒泡阶段:事件从目标元素冒泡到document, 依次检查经过的节点是否绑定了事件监听函数,如果有则执行

事件绑定监听函数的方式如�?

1
addEventListener(eventType, handler, useCapture)

事件移除监听函数的方式如�?

1
removeEventListener(eventType, handler, useCapture)

参数如下�?

  • eventType指定事件类型(不要加on)
  • handler是事件处理函�?- useCapture是一个boolean用于指定是否在捕获阶段进行处理,一般设置为false与IE浏览器保持一�?
    举个例子�?
    1
    2
    3
    var btn = document.getElementById('.btn');
    btn.addEventListener(‘click�? showMessage, false);
    btn.removeEventListener(‘click�? showMessage, false);

特�?

  • 可以在一个DOM元素上绑定多个事件处理器,各自并不会冲突
1
2
3
btn.addEventListener(‘click�? showMessage1, false);
btn.addEventListener(‘click�? showMessage2, false);
btn.addEventListener(‘click�? showMessage3, false);
  • 执行时机

当第三个参数(useCapture)设置为true就在捕获过程中执行,反之在冒泡过程中执行处理函数

下面举个例子�?

1
2
3
4
5
<div id='div'>
<p id='p'>
<span id='span'>Click Me!</span>
</p >
</div>

设置点击事件

1
2
3
4
5
6
7
8
9
10
11
var div = document.getElementById('div');
var p = document.getElementById('p');

function onClickFn (event) {
var tagName = event.currentTarget.tagName;
var phase = event.eventPhase;
console.log(tagName, phase);
}

div.addEventListener('click', onClickFn, false);
p.addEventListener('click', onClickFn, false);

上述使用了eventPhase,返回一个代表当前执行阶段的整数值�?为捕获阶段�?为事件对象触发阶段�?为冒泡阶�?
点击Click Me!,输出如�?

1
2
P 3
DIV 3

可以看到,pdiv都是在冒泡阶段响应了事件,由于冒泡的特性,裹在里层的p率先做出响应

如果把第三个参数都改为true

1
2
div.addEventListener('click', onClickFn, true);
p.addEventListener('click', onClickFn, true);

输出如下

1
2
DIV 1
P 1

两者都是在捕获阶段响应事件,所以divp标签先做出响�?

IE事件模型

IE事件模型共有两个过程:

  • 事件处理阶段:事件到达目标元�? 触发目标元素的监听函数�?- 事件冒泡阶段:事件从目标元素冒泡到document, 依次检查经过的节点是否绑定了事件监听函数,如果有则执行

事件绑定监听函数的方式如�?

1
attachEvent(eventType, handler)

事件移除监听函数的方式如�?

1
detachEvent(eventType, handler)

举个例子�?

1
2
3
var btn = document.getElementById('.btn');
btn.attachEvent(‘onclick�? showMessage);
btn.detachEvent(‘onclick�? showMessage);

面试官:解释下什么是事件代理?应用场景?

一、是什�?

事件代理,俗地来讲,就是把一个元素响应事件(clickkeydown……)的函数委托到另一个元�?
前面讲到,事件流的都会经过三个阶段: 捕获阶段 -> 目标阶段 -> 冒泡阶段,而事件委托就是在冒泡阶段完成

事件委托,会把一个或者一组元素的事件委托到它的父层或者更外层元素上,真正绑定事件的是外层元素,而不是目标元�?
当事件响应到目标元素上时,会通过事件冒泡机制从而触发它的外层元素的绑定事件上,然后在外层元素上去执行函�?
下面举个例子�?
比如一个宿舍的同学同时快递到了,一种笨方法就是他们一个个去领�?
较优方法就是把这件事情委托给宿舍长,让一个人出去拿好所有快递,然后再根据收件人一一分发给每个同�?
在这里,取快递就是一个事件,每个同学指的是需要响应事件的 DOM 元素,而出去统一领取快递的宿舍长就是代理的元素

所以真正绑定事件的是这个元素,按照收件人分发快递的过程就是在事件执行中,需要判断当前响应的事件应该匹配到被代理元素中的哪一个或者哪几个

二、应用场�?

如果我们有一个列表,列表之中有大量的列表项,我们需要在点击列表项的时候响应一个事�?

1
2
3
4
5
6
7
<ul id="list">
<li>item 1</li>
<li>item 2</li>
<li>item 3</li>
......
<li>item n</li>
</ul>

如果给每个列表项一一都绑定一个函数,那对于内存消耗是非常大的

1
2
3
4
5
6
7
8
// 获取目标元素
const lis = document.getElementsByTagName("li")
// 循环遍历绑定事件
for (let i = 0; i < lis.length; i++) {
lis[i].onclick = function(e){
console.log(e.target.innerHTML)
}
}

这时候就可以事件委托,把点击事件绑定在父级元素ul上面,然后执行事件的时候再去匹配目标元�?

1
2
3
4
5
6
7
8
// 给父层元素绑定事�?document.getElementById('list').addEventListener('click', function (e) {
// 兼容性处�? var event = e || window.event;
var target = event.target || event.srcElement;
// 判断是否匹配目标元素
if (target.nodeName.toLocaleLowerCase === 'li') {
console.log('the content is: ', target.innerHTML);
}
});

还有一种场景是上述列表项并不多,我们给每个列表项都绑定了事�?
但是如果用户能够随时动态的增加或者去除列表项元素,那么在每一次改变的时候都需要重新给新增的元素绑定事件,给即将删去的元素解绑事件

如果用了事件委托就没有这种麻烦了,因为事件是绑定在父层的,和目标元素的增减是没有关系的,执行到目标元素是在真正响应执行事件函数的过程中去匹配�?
举个例子�?
下面html结构中,点击input可以动态添加元�?

1
2
3
4
5
6
7
<input type="button" name="" id="btn" value="添加" />
<ul id="ul1">
<li>item 1</li>
<li>item 2</li>
<li>item 3</li>
<li>item 4</li>
</ul>

使用事件委托

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const oBtn = document.getElementById("btn");
const oUl = document.getElementById("ul1");
const num = 4;

//事件委托,添加的子元素也有事�?oUl.onclick = function (ev) {
ev = ev || window.event;
const target = ev.target || ev.srcElement;
if (target.nodeName.toLowerCase() == 'li') {
console.log('the content is: ', target.innerHTML);
}

};

//添加新节�?oBtn.onclick = function () {
num++;
const oLi = document.createElement('li');
oLi.innerHTML = `item ${num}`;
oUl.appendChild(oLi);
};

可以看到,使用事件委托,在动态绑定事件的情况下是可以减少很多重复工作�?

三、总结

适合事件委托的事件有:clickmousedownmouseupkeydownkeyupkeypress

从上面应用场景中,我们就可以看到使用事件委托存在两大优点�?

  • 减少整个页面所需的内存,提升整体性能
  • 动态绑定,减少重复工作

但是使用事件委托也是存在局限性:

  • focusblur 这些事件没有事件冒泡机制,所以无法进行委托绑定事�?
  • mousemovemouseout 这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的

如果把所有事件都用事件代理,可能会出现事件误判,即本不该被触发的事件被绑定上了事�

面试官:Javascript中如何实现函数缓存?函数缓存有哪些应用场景?

一、是什�?

函数缓存,就是将函数运算过的结果进行缓存

本质上就是用空间(缓存存储)换时间(计算过程�?
常用于缓存数据计算结果和缓存对象

1
2
3
4
const add = (a,b) => a+b;
const calc = memoize(add); // 函数缓存
calc(10,20);// 30
calc(10,20);// 30 缓存

缓存只是一个临时的数据存储,它保存数据,以便将来对该数据的请求能够更快地得到处�?

二、如何实�?

实现函数缓存主要依靠闭包、柯里化、高阶函数,这里再简单复习下�?

闭包

闭包可以理解成,函数 + 函数体内可访问的变量总和

1
2
3
4
5
6
7
8
9
(function() {
var a = 1;
function add() {
const b = 2
let sum = b + a
console.log(sum); // 3
}
add()
})()

add 函数本身,以及其内部可访问的变量,即 a = 1 ,这两个组合在⼀起就形成了闭�?

柯里�?

把接受多个参数的函数转换成接受一个单一参数的函�?

1
2
3
4
5
6
7
8
9
10
11
12
13
// 非函数柯里化
var add = function (x,y) {
return x+y;
}
add(3,4) //7

// 函数柯里�?var add2 = function (x) {
//**返回函数**
return function (y) {
return x+y;
}
}
add2(3)(4) //7

将一个二元函数拆分成两个一元函�?

高阶函数

通过接收其他函数作为参数或返回其他函数的函数

1
2
3
4
5
6
7
8
9
10
function foo(){
var a = 2;

function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz();//2

函数 foo 如何返回另一个函�?barbaz 现在持有�?foo 中定义的bar 函数的引用。由于闭包特性,a的值能够得�?

下面再看看如何实现函数缓存,实现原理也很简单,把参数和对应的结果数据存在一个对象中,调用时判断参数对应的数据是否存在,存在就返回对应的结果数据,否则就返回计算结果

如下所�?

1
2
3
4
5
6
7
8
9
10
const memoize = function (func, content) {
let cache = Object.create(null)
content = content || this
return (...key) => {
if (!cache[key]) {
cache[key] = func.apply(content, key)
}
return cache[key]
}
}

调用方式也很简�?

const calc = memoize(add);
const num1 = calc(100,200)
const num2 = calc(100,200) // 缓存得到的结�?```

过程分析�?
- 在当前函数作用域定义了一个空对象,用于缓存运行结�?- 运用柯里化返回一个函数,返回的函数由于闭包特性,可以访问到`cache`
- 然后判断输入参数是不是在`cache`的中。如果已经存在,直接返回`cache`的内容,如果没有存在,使用函数`func`对输入参数求值,然后把结果存储在`cache`�?


## 三、应用场�?
虽然使用缓存效率是非常高的,但并不是所有场景都适用,因此千万不要极端的将所有函数都添加缓存

以下几种情况下,适合使用缓存�?
- 对于昂贵的函数调用,执行复杂计算的函�?- 对于具有有限且高度重复输入范围的函数
- 对于具有重复输入值的递归函数
- 对于纯函数,即每次使用特定输入调用时返回相同输出的函�?


## 参考文�?
- https://zhuanlan.zhihu.com/p/112505577

面试官:说说你对事件循环的理�?

一、是什�?

首先,JavaScript 是一门单线程的语言,意味着同一时间内只能做一件事,但是这并不意味着单线程就是阻塞,而实现单线程非阻塞的方法就是事件循环

JavaScript中,所有的任务都可以分�?

  • 同步任务:立即执行的任务,同步任务一般会直接进入到主线程中执�?
  • 异步任务:异步执行的任务,比如ajax网络请求,setTimeout 定时函数�?
    同步任务与异步任务的运行流程图如下:

从上面我们可以看到,同步任务进入主线程,即主执行栈,异步任务进入任务队列,主线程内的任务执行完毕为空,会去任务队列读取对应的任务,推入主线程执行。上述过程的不断重复就事件循�?

二、宏任务与微任务

如果将任务划分为同步任务和异步任务并不是那么的准确,举个例子�?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
console.log(1)

setTimeout(()=>{
console.log(2)
}, 0)

new Promise((resolve, reject)=>{
console.log('new Promise')
resolve()
}).then(()=>{
console.log('then')
})

console.log(3)

如果按照上面流程图来分析代码,我们会得到下面的执行步骤:

  • console.log(1) ,同步任务,主线程中执行
  • setTimeout() ,异步任务,放到 Event Table�? 毫秒后console.log(2) 回调推入 Event Queue �?- new Promise ,同步任务,主线程直接执�?- .then ,异步任务,放到 Event Table
  • console.log(3),同步任务,主线程执�?
    所以按照分析,它的结果应该�?1 => 'new Promise' => 3 => 2 => 'then'

但是实际结果是:1=>'new Promise'=> 3 => 'then' => 2

出现分歧的原因在于异步任务执行顺序,事件队列其实是一个“先进先出”的数据结构,排在前面的事件会优先被主线程读�?
例子�?setTimeout回调事件是先进入队列中的,按理说应该先于 .then 中的执行,但是结果却偏偏相反

原因在于异步任务还可以细分为微任务与宏任�?

微任�?

一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

常见的微任务有:

  • Promise.then

  • MutaionObserver

  • Object.observe(已废弃;Proxy 对象替代�?

  • process.nextTick(Node.js�?

宏任�?

宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合

常见的宏任务有:

  • script (可以理解为外层同步代�?
  • setTimeout/setInterval
  • UI rendering/UI事件
  • postMessage、MessageChannel
  • setImmediate、I/O(Node.js�?

这时候,事件循环,宏任务,微任务的关系如图所�?

按照这个流程,它的执行机制是�?

  • 执行一个宏任务,如果遇到微任务就将它放到微任务的事件队列中
  • 当前宏任务执行完成后,会查看微任务的事件队列,然后将里面的所有微任务依次执行�?

回到上面的题�?

1
2
3
4
5
6
7
8
9
10
11
console.log(1)
setTimeout(()=>{
console.log(2)
}, 0)
new Promise((resolve, reject)=>{
console.log('new Promise')
resolve()
}).then(()=>{
console.log('then')
})
console.log(3)

流程如下

1
2
3
4
5
6
// 遇到 console.log(1) ,直接打�?1
// 遇到定时器,属于新的宏任务,留着后面执行
// 遇到 new Promise,这个是直接执行的,打印 'new Promise'
// .then 属于微任务,放入微任务队列,后面再执�?// 遇到 console.log(3) 直接打印 3
// 好了本轮宏任务执行完毕,现在去微任务列表查看是否有微任务,发�?.then 的回调,执行它,打印 'then'
// 当一次宏任务执行完,再去执行新的宏任务,这里就剩一个定时器的宏任务了,执行它,打印 2

三、async与await

async 是异步的意思,await 则可以理解为 async wait。所以可以理解async就是用来声明一个异步方法,�?await 是用来等待异步方法执�?

async

async函数返回一个promise对象,下面两种方法是等效�?

1
2
3
4
5
6
7
8
function f() {
return Promise.resolve('TEST');
}

// asyncF is equivalent to f!
async function asyncF() {
return 'TEST';
}

await

正常情况下,await命令后面是一�?Promise 对象,返回该对象的结果。如果不�?Promise 对象,就直接返回对应的�?

1
2
3
4
5
async function f(){
// 等同�? // return 123
return await 123
}
f().then(v => console.log(v)) // 123

不管await后面跟着的是什么,await都会阻塞后面的代�?

1
2
3
4
5
6
7
8
9
10
11
12
async function fn1 (){
console.log(1)
await fn2()
console.log(2) // 阻塞
}

async function fn2 (){
console.log('fn2')
}

fn1()
console.log(3)

上面的例子中,await 会阻塞下面的代码(即加入微任务队列),先执行 async 外面的同步代码,同步代码执行完,再回�?async 函数中,再执行之前阻塞的代码

所以上述输出结果为:1fn232

四、流程分�?

通过对上面的了解,我们对JavaScript对各种场景的执行顺序有了大致的了�?
这里直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(function () {
console.log('settimeout')
})
async1()
new Promise(function (resolve) {
console.log('promise1')
resolve()
}).then(function () {
console.log('promise2')
})
console.log('script end')

分析过程�?

  1. 执行整段代码,遇�?console.log('script start') 直接打印结果,输�?script start
  2. 遇到定时器了,它是宏任务,先放着不执�?3. 遇到 async1(),执�?async1 函数,先打印 async1 start,下面遇到await怎么办?先执�?async2,打�?async2,然后阻塞下面代码(即加入微任务列表),跳出去执行同步代�?4. 跳到 new Promise 这里,直接执行,打印 promise1,下面遇�?.then(),它是微任务,放到微任务列表等待执行
  3. 最后一行直接打�?script end,现在同步代码执行完了,开始执行微任务,即 await 下面的代码,打印 async1 end
  4. 继续执行下一个微任务,即执行 then 的回调,打印 promise2
  5. 上一个宏任务所有事都做完了,开始下一个宏任务,就是定时器,打�?settimeout

所以最后的结果是:script startasync1 startasync2promise1script endasync1 endpromise2settimeout

面试官:说说你对函数式编程的理解?优缺点�?

一、是什�?

函数式编程是一�?编程范式”(programming paradigm),一种编写程序的方法�?
主要的编程范式有三种:命令式编程,声明式编程和函数式编程

相比命令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而非设计一个复杂的执行过程

举个例子,将数组每个元素进行平方操作,命令式编程与函数式编程如下

1
2
3
4
5
6
// 命令式编�?var array = [0, 1, 2, 3]
for(let i = 0; i < array.length; i++) {
array[i] = Math.pow(array[i], 2)
}

// 函数式方�?[0, 1, 2, 3].map(num => Math.pow(num, 2))

简单来讲,就是要把过程逻辑写成函数,定义好输入参数,只关心它的输出结果

即是一种描述集合和集合之间的转换关系,输入通过函数都会返回有且只有一个输出�?

可以看到,函数实际上是一个关系,或者说是一种映射,而这种映射关系是可以组合的,一旦我们知道一个函数的输出类型可以匹配另一个函数的输入,那他们就可以进行组�?

二、概�?

纯函�?

函数式编程旨在尽可能的提高代码的无状态性和不变性。要做到这一点,就要学会使用无副作用的函数,也就是纯函数

纯函数是对给定的输入返还相同输出的函数,并且要求你所有的数据都是不可变的,即纯函�?无状�?数据不可�?

举一个简单的例子

1
let double = value=>value*2;

特性:

  • 函数内部传入指定的值,就会返回确定唯一的�?- 不会造成超出作用域的变化,例如修改全局变量或引用传递的参数

优势�?

  • 使用纯函数,我们可以产生可测试的代码
1
2
3
test('double(2) 等于 4', () => {
expect(double(2)).toBe(4);
})
  • 不依赖外部环境计算,不会产生副作用,提高函数的复用�?
  • 可读性更�?,函数不管是否是纯函�? 都会有一个语义化的名称,更便于阅�?
  • 可以组装成复杂任务的可能性。符合模块化概念及单一职责原则

高阶函数

在我们的编程世界中,我们需要处理的其实也只有“数据”和“关系”,而关系就是函�?
编程工作也就是在找一种映射关系,一旦关系找到了,问题就解决了,剩下的事情,就是让数据流过这种关系,然后转换成另一个数据,如下图所�?

在这里,就是高阶函数的作用。高级函数,就是以函数作为输入或者输出的函数被称为高阶函�?
通过高阶函数抽象过程,注重结果,如下面例�?

1
2
3
4
5
6
7
8
9
const forEach = function(arr,fn){
for(let i=0;i<arr.length;i++){
fn(arr[i]);
}
}
let arr = [1,2,3];
forEach(arr,(item)=>{
console.log(item);
})

上面通过高阶函数 forEach来抽象循环如何做的逻辑,直接关注做了什�?
高阶函数存在缓存的特性,主要是利用闭包作�?

1
2
3
4
5
6
7
8
9
10
11
const once = (fn)=>{
let done = false;
return function(){
if(!done){
fn.apply(this,fn);
}else{
console.log("该函数已经执�?);
}
done = true;
}
}

柯里�?

柯里化是把一个多参数函数转化成一个嵌套的一元函数的过程

一个二元函数如下:

1
let fn = (x,y)=>x+y;

转化成柯里化函数如下�?

1
2
3
4
5
6
7
8
9
const curry = function(fn){
return function(x){
return function(y){
return fn(x,y);
}
}
}
let myfn = curry(fn);
console.log( myfn(1)(2) );

上面的curry函数只能处理二元情况,下面再来实现一个实现多参数的情�?

1
2
3
4
5
6
7
8
9
10
11
12
13
// 多参数柯里化�?const curry = function(fn){
return function curriedFn(...args){
if(args.length<fn.length){
return function(){
return curriedFn(...args.concat([...arguments]));
}
}
return fn(...args);
}
}
const fn = (x,y,z,a)=>x+y+z+a;
const myfn = curry(fn);
console.log(myfn(1)(2)(3)(1));

关于柯里化函数的意义如下�?

  • 让纯函数更纯,每次接受一个参数,松散解�?- 惰性执�?

组合与管�?

组合函数,目的是将多个函数组合成一个函�?
举个简单的例子�?

1
2
3
4
5
6
7
8
9
function afn(a){
return a*2;
}
function bfn(b){
return b*3;
}
const compose = (a,b)=>c=>a(b(c));
let myfn = compose(afn,bfn);
console.log( myfn(2));

可以看到compose实现一个简单的功能:形成了一个新的函数,而这个函数就是一条从 bfn -> afn 的流水线

下面再来看看如何实现一个多函数组合�?

1
const compose = (...fns)=>val=>fns.reverse().reduce((acc,fn)=>fn(acc),val);

compose执行是从右到左的。而管道函数,执行顺序是从左到右执行的

1
const pipe = (...fns)=>val=>fns.reduce((acc,fn)=>fn(acc),val);

组合函数与管道函数的意义在于:可以把很多小函数组合起来完成更复杂的逻辑

三、优缺点

优点

  • 更好的管理状态:因为它的宗旨是无状态,或者说更少的状态,能最大化的减少这些未知、优化代码、减少出错情�?
  • 更简单的复用:固定输�?>固定输出,没有其他外部变量影响,并且无副作用。这样代码复用时,完全不需要考虑它的内部实现和外部影�?
  • 更优雅的组合:往大的说,网页是由各个组件组成的。往小的说,一个函数也可能是由多个小函数组成的。更强的复用性,带来更强大的组合�?
  • 隐性好处。减少代码量,提高维护�?

缺点�?

  • 性能:函数式编程相对于指令式编程,性能绝对是一个短板,因为它往往会对一个方法进行过度包装,从而产生上下文切换的性能开销

  • 资源占用:在 JS 中为了实现对象状态的不可变,往往会创建新的对象,因此,它对垃圾回收所产生的压力远远超过其他编程方�?

  • 递归陷阱:在函数式编程中,为了实现迭代,通常会采用递归操作

参考文�?

面试官:Javascript如何实现继承�?

一、是什�?

继承(inheritance)是面向对象软件技术当中的一个概念�?
如果一个类别B“继承自”另一个类别A,就把这个B称为“A的子类”,而把A称为“B的父类别”也可以称“A是B的超类�?

  • 继承的优�?
    继承可以使得子类具有父类别的各种属性和方法,而不需要再次编写相同的代码

在子类别继承父类别的同时,可以重新定义某些属性,并重写某些方法,即覆盖父类别的原有属性和方法,使其获得与父类别不同的功能

虽然JavaScript并不是真正的面向对象语言,但它天生的灵活性,使应用场景更加丰�?
关于继承,我们举个形象的例子�?
定义一个类(Class)叫汽车,汽车的属性包括颜色、轮胎、品牌、速度、排气量�?

1
2
3
4
5
6
7
class Car{
constructor(color,speed){
this.color = color
this.speed = speed
// ...
}
}

由汽车这个类可以派生出“轿车”和“货车”两个类,在汽车的基础属性上,为轿车添加一个后备厢、给货车添加一个大货箱

1
2
3
4
5
6
7
// 货车
class Truck extends Car{
constructor(color,speed){
super(color,speed)
this.Container = true // 货箱
}
}

这样轿车和货车就是不一样的,但是二者都属于汽车这个类,汽车、轿车继承了汽车的属性,而不需要再次在“轿车”中定义汽车已经有的属�?
在“轿车”继承“汽车”的同时,也可以重新定义汽车的某些属性,并重写或覆盖某些属性和方法,使其获得与“汽车”这个父类不同的属性和方法

1
2
3
4
5
6
7
class Truck extends Car{
constructor(color,speed){
super(color,speed)
this.color = "black" //覆盖
this.Container = true // 货箱
}
}

从这个例子中就能详细说明汽车、轿车以及卡车之间的继承关系

二、实现方�?

下面给出JavaScripy常见的继承方式:

  • 原型链继�?
  • 构造函数继承(借助 call�?- 组合继承
  • 原型式继�?- 寄生式继�?- 寄生组合式继�?

原型链继�?

原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针

举个例子

1
2
3
4
5
6
7
8
9
function Parent() {
this.name = 'parent1';
this.play = [1, 2, 3]
}
function Child() {
this.type = 'child2';
}
Child1.prototype = new Parent();
console.log(new Child())

上面代码看似没问题,实际存在潜在问题

1
2
3
4
var s1 = new Child2();
var s2 = new Child2();
s1.play.push(4);
console.log(s1.play, s2.play); // [1,2,3,4]

改变s1play属性,会发现s2也跟着发生变化了,这是因为两个实例使用的是同一个原型对象,内存空间是共享的

构造函数继�?

借助 call 调用Parent函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
function Parent(){
this.name = 'parent1';
}

Parent.prototype.getName = function () {
return this.name;
}

function Child(){
Parent1.call(this);
this.type = 'child'
}

let child = new Child();
console.log(child); // 没问�?console.log(child.getName()); // 会报�?```

可以看到,父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法

相比第一种原型链继承方式,父类的引用属性不会被共享,优化了第一种继承方式的弊端,但是只能继承父类的实例属性和方法,不能继承原型属性或者方�?


### 组合继承

前面我们讲到两种继承方式,各有优缺点。组合继承则将前两种方式继承起来

```js
function Parent3 () {
this.name = 'parent3';
this.play = [1, 2, 3];
}

Parent3.prototype.getName = function () {
return this.name;
}
function Child3() {
// 第二次调�?Parent3()
Parent3.call(this);
this.type = 'child3';
}

// 第一次调�?Parent3()
Child3.prototype = new Parent3();
// 手动挂上构造器,指向自己的构造函�?Child3.prototype.constructor = Child3;
var s3 = new Child3();
var s4 = new Child3();
s3.play.push(4);
console.log(s3.play, s4.play); // 不互相影�?console.log(s3.getName()); // 正常输出'parent3'
console.log(s4.getName()); // 正常输出'parent3'

这种方式看起来就没什么问题,方式一和方式二的问题都解决了,但是从上面代码我们也可以看到 Parent3 执行了两次,造成了多构造一次的性能开销

原型式继�?

这里主要借助Object.create方法实现普通对象的继承

同样举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let parent4 = {
name: "parent4",
friends: ["p1", "p2", "p3"],
getName: function() {
return this.name;
}
};

let person4 = Object.create(parent4);
person4.name = "tom";
person4.friends.push("jerry");

let person5 = Object.create(parent4);
person5.friends.push("lucy");

console.log(person4.name); // tom
console.log(person4.name === person4.getName()); // true
console.log(person5.name); // parent4
console.log(person4.friends); // ["p1", "p2", "p3","jerry","lucy"]
console.log(person5.friends); // ["p1", "p2", "p3","jerry","lucy"]

这种继承方式的缺点也很明显,因为Object.create 方法实现的是浅拷贝,多个实例的引用类型属性指向相同的内存,存在篡改的可能

寄生式继�?

寄生式继承在上面继承基础上进行优化,利用这个浅拷贝的能力再进行增强,添加一些方�?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let parent5 = {
name: "parent5",
friends: ["p1", "p2", "p3"],
getName: function() {
return this.name;
}
};

function clone(original) {
let clone = Object.create(original);
clone.getFriends = function() {
return this.friends;
};
return clone;
}

let person5 = clone(parent5);

console.log(person5.getName()); // parent5
console.log(person5.getFriends()); // ["p1", "p2", "p3"]

其优缺点也很明显,跟上面讲的原型式继承一�?

寄生组合式继�?

寄生组合式继承,借助解决普通对象的继承问题的 Object.create 方法,在前面几种继承方式的优缺点基础上进行改造,这也是所有继承方式里面相对最优的继承方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function clone (parent, child) {
// 这里改用 Object.create 就可以减少组合继承中多进行一次构造的过程
child.prototype = Object.create(parent.prototype);
child.prototype.constructor = child;
}

function Parent6() {
this.name = 'parent6';
this.play = [1, 2, 3];
}
Parent6.prototype.getName = function () {
return this.name;
}
function Child6() {
Parent6.call(this);
this.friends = 'child5';
}

clone(Parent6, Child6);

Child6.prototype.getFriends = function () {
return this.friends;
}

let person6 = new Child6();
console.log(person6); //{friends:"child5",name:"child5",play:[1,2,3],__proto__:Parent6}
console.log(person6.getName()); // parent6
console.log(person6.getFriends()); // child5

可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题

文章一开头,我们是使用ES6 中的extends 关键字直接实�?JavaScript 的继�?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person {
constructor(name) {
this.name = name
}
// 原型方法
// �?Person.prototype.getName = function() { }
// 下面可以简写为 getName() {...}
getName = function () {
console.log('Person:', this.name)
}
}
class Gamer extends Person {
constructor(name, age) {
// 子类中存在构造函数,则需要在使用“this”之前首先调�?super()�? super(name)
this.age = age
}
}
const asuna = new Gamer('Asuna', 20)
asuna.getName() // 成功访问到父类的方法

利用babel工具进行转换,我们会发现extends实际采用的也是寄生组合继承方式,因此也证明了这种方式是较优的解决继承的方�?

三、总结

下面以一张图作为总结�?

通过Object.create 来划分不同的继承方式,最后的寄生式组合继承方式是通过组合继承改造之后的最优继承方式,�?extends 的语法糖和寄生组合继承的方式基本类似

相关链接

https://zh.wikipedia.org/wiki/%E7%BB%A7%E6%89%BF

面试官:说说你了解的js数据结构�?

什么是数据结构�?数据结构是计算机存储、组织数据的方式�?数据结构意味着接口或封装:一个数据结构可被视为两个函数之间的接口,或者是由数据类型联合组成的存储内容的访问方法封装�?

我们每天的编码中都会用到数据结构
数组是最简单的内存数据结构
下面是常见的数据结构�?1. 数组(Array�?2. 栈(Stack�?3. 队列(Queue�?4. 链表(Linked List�?5. 字典
6. 散列表(Hash table�?7. 树(Tree�?8. 图(Graph�?9. 堆(Heap�?

数组(Array�?数组是最最基本的数据结构,很多语言都内置支持数组�?数组是使用一块连续的内存空间保存数据,保存的数据的个数在分配内存的时候就是确定的�?

在日常生活中,人们经常使用列表:待办事项列表、购物清单等�?
而计算机程序也在使用列表,在下面的条件下,选择列表作为数据结构就显得尤为有用:
数据结构较为简�?不需要在一个长序列中查找元素,或者对其进行排�?反之,如果数据结构非常复杂,列表的作用就没有那么大了�?

栈(Stack�?栈是一种遵循后进先出(LIFO)原则的有序集合

在栈里,新元素都接近栈顶,旧元素都接近栈底�?每次加入新的元素和拿走元素都在顶部操�?

队列(Queue�?队列是遵循先进先出(FIFO,也称为先来先服务)原则的一组有序的�?队列在尾部添加新元素,并从顶部移除元�?最新添加的元素必须排在队列的末�?

链表(Linked List�?链表也是一种列表,已经设计了数组,为什么还需要链表呢�?JavaScript中数组的主要问题时,它们被实现成了对象,

与其他语言(比如C++和Java)的数组相对,效率很低�?如果你发现数组在实际使用时很慢,就可以考虑使用链表来代替它�?
使用条件�?链表几乎可以用在任何可以使用一维数组的情况中�?如果需要随机访问,数组仍然是更好的选择�?

字典

字典是一种以�?值对存储数据的数据结构,js中的Object类就是以字典的形式设计的。JavaScript可以通过实现字典类,让这种字典类型的对象使用起来更加简单,字典可以实现对象拥有的常见功能,并相应拓展自己想要的功能,而对象在JavaScript编写中随处可见,所以字典的作用也异常明显了�?

散列�?也称为哈希表,特点是在散列表上插入、删除和取用数据都非常快�?为什么要设计这种数据结构呢?

用数组或链表存储数据,如果想要找到其中一个数据,需要从头进行遍历,因为不知道这个数据存储到了数组的哪个位置�?
散列表在JavaScript中可以基础数组去进行设计�?数组的长度是预先设定的,所有元素根据和该元素对应的键,保存在数组的特定位置,这里的键和对象的键是类型的概念�?使用散列表存储数组时,通过一个散列函数将键映射为一个数字,这个数字的范围是0到散列表的长度�?
即使使用一个高效的散列函数,依然存在将两个键映射为同一个值得可能,这种现象叫做碰撞。常见碰撞的处理方法有:开链法和线性探测法(具体概念有兴趣的可以网上自信了解)
使用条件�?可以用于数据的插入、删除和取用,不适用于查找数�?

面试官:说说 Javascript 数字精度丢失的问题,如何解决�?

一、场景复�?

一个经典的面试�?

1
0.1 + 0.2 === 0.3 // false

为什么是false�?

先看下面这个比喻

比如一个数 1÷3=0.33333333……

3会一直无限循环,数学可以表示,但是计算机要存储,方便下次取出来再使用,但0.333333…… 这个数无限循环,再大的内存它也存不下,所以不能存储一个相对于数学来说的值,只能存储一个近似值,当计算机存储后再取出时就会出现精度丢失问�?

二、浮点数

“浮点数”是一种表示数字的标准,整数也可以用浮点数的格式来存储

我们也可以理解成,浮点数就是小数

JavaScript中,现在主流的数值类型是Number,而Number采用的是IEEE754规范�?4位双精度浮点数编�?
这样的存储结构优点是可以归一化处理整数和小数,节省存储空�?
对于一个整数,可以很轻易转化成十进制或者二进制。但是对于一个浮点数来说,因为小数点的存在,小数点的位置不是固定的。解决思路就是使用科学计数法,这样小数点位置就固定�?
而计算机只能用二进制�?�?)表示,二进制转换为科学记数法的公式如下�?

其中,a的值为0或�?,e为小数点移动的位�?
举个例子�?
27.0转化成二进制�?1011.0 ,科学计数法表示为:

前面讲到,javaScript存储方式是双精度浮点数,其长度为8个字节,�?4位比�?
64位比特又可分为三个部分:

  • 符号位S:第 1 位是正负数符号位(sign),0代表正数�?代表负数
  • 指数位E:中间的 11 位存储指数(exponent),用来表示次方数,可以为正负数。在双精度浮点数中,指数的固定偏移量�?023
  • 尾数位M:最后的 52 位是尾数(mantissa),超出的部分自动进一舍零

如下图所示:

举个例子�?
27.5 转换为二进制11011.1

11011.1转换为科学记数法 [公式]

符号位为1(正数),指数位�?+�?023+4,即1027

因为它是十进制的需要转换为二进制,�?10000000011,小数部分为10111,补�?2位即�?1011 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000`

所�?7.5存储为计算机的二进制标准形式(符号位+指数�?小数部分 (阶数)),既下面所�?
0+10000000011+011 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000`

二、问题分�?

再回到问题上

1
0.1 + 0.2 === 0.3 // false

通过上面的学习,我们知道,在javascript语言中,0.1 �?0.2 都转化成二进制后再进行运�?

1
2
3
4
5
// 0.1 �?0.2 都转化成二进制后再进行运�?0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010 =
0.0100110011001100110011001100110011001100110011001100111

// 转成十进制正好是 0.30000000000000004

所以输出false

再来一个问题,那么为什么x=0.1得到0.1�?
主要是存储二进制时小数点的偏移量最大为52位,最多可以表达的位数是2^53=9007199254740992,对应科学计数尾数是 9.007199254740992,这也是 JS 最多能表示的精�?
它的长度�?16,所以可以使�?toPrecision(16) 来做精度运算,超过的精度会自动做凑整处理

1
2
.10000000000000000555.toPrecision(16)
// 返回 0.1000000000000000,去掉末尾的零后正好�?0.1

但看到的 0.1 实际上并不是 0.1。不信你可用更高的精度试试:

1
0.1.toPrecision(21) = 0.100000000000000005551

如果整数大于 9007199254740992 会出现什么情况呢�?
由于指数位最大值是1023,所以最大可以表示的整数�?2^1024 - 1,这就是能表示的最大整数。但你并不能这样计算这个数字,因为从 2^1024 开始就变成�?Infinity

1
2
3
4
5
> Math.pow(2, 1023)
8.98846567431158e+307

> Math.pow(2, 1024)
Infinity

那么对于 (2^53, 2^63) 之间的数会出现什么情况呢�?

  • (2^53, 2^54) 之间的数会两个选一个,只能精确表示偶数
  • (2^54, 2^55) 之间的数会四个选一个,只能精确表示4个倍数
  • … 依次跳过更多2的倍数

要想解决大数的问题你可以引用第三方库 bignumber.js,原理是把所有数字当作字符串,重新实现了计算逻辑,缺点是性能比原生差很多

小结

计算机存储双精度浮点数需要先把十进制数转换为二进制的科学记数法的形式,然后计算机以自己的规则{符号�?(指数�?指数偏移量的二进�?+小数部分}存储二进制的科学记数�?
因为存储时有位数限制�?4位),并且某些十进制的浮点数在转换为二进制数时会出现无限循环,会造成二进制的舍入操作(0�?�?,当再转换为十进制时就造成了计算误�?

三、解决方�?

理论上用有限的空间来存储无限的小数是不可能保证精确的,但我们可以处理一下得到我们期望的结果

当你拿到 1.4000000000000001 这样的数据要展示时,建议使用 toPrecision 凑整�?parseFloat 转成数字后再显示,如下:

1
parseFloat(1.4000000000000001.toPrecision(12)) === 1.4  // True

封装成方法就是:

1
2
3
function strip(num, precision = 12) {
return +parseFloat(num.toPrecision(precision));
}

对于运算类操作,�?+-*/,就不能使用 toPrecision 了。正确的做法是把小数转成整数后再运算。以加法为例�?

1
2
3
4
5
6
7
8
9
/**
* 精确加法
*/
function add(num1, num2) {
const num1Digits = (num1.toString().split('.')[1] || '').length;
const num2Digits = (num2.toString().split('.')[1] || '').length;
const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
return (num1 * baseNum + num2 * baseNum) / baseNum;
}

最后还可以使用第三方库,如Math.jsBigDecimal.js

参考文�?

0%