# JavaScript 综合问答
- js 基础
- js 机制
- js 内存
- 异步
# 解释下变量提升?✨
JavaScript 引擎的工作方式是,先解析代码,获取所有被声明的变量,然后再一行一行地运行。这造成的结果,就是所有的变量的声明语句,都会被提升到代码的头部,这就叫做变量提升(hoisting)。
console.log(a); // undefined
var a = 1;
function b() {
console.log(a);
}
b(); // 1
2
3
4
5
6
7
8
上面的代码实际执行顺序是这样的:
第一步: 引擎将var a = 1
拆解为var a = undefined
和 a = 1
,并将var a = undefined
放到最顶端,a = 1
还在原来的位置
这样一来代码就是这样:
var a = undefined;
console.log(a); // undefined
a = 1;
function b() {
console.log(a);
}
b(); // 1
2
3
4
5
6
7
8
9
第二步就是执行,因此 js 引擎一行一行从上往下执行就造成了当前的结果,这就叫变量提升。
原理详解请移步,预解释与变量提升
# 一段 JavaScript 代码是如何执行的?✨
此部分涉及概念较多,请移步JavaScript 执行机制
# 理解闭包吗?✨
这个问题其实在问:
- 闭包是什么?
- 闭包有什么作用?
# 闭包是什么
MDN 的解释:闭包是函数和声明该函数的词法环境的组合。
按照我的理解就是:闭包 =『函数』和『函数体内可访问的变量总和』
举个简单的例子:
(function () {
var a = 1;
function add() {
var b = 2;
var sum = b + a;
console.log(sum); // 3
}
add();
})();
2
3
4
5
6
7
8
9
10
add
函数本身,以及其内部可访问的变量,即 a = 1
,这两个组合在一起就被称为闭包,仅此而已。
# 闭包的作用
闭包最大的作用就是隐藏变量,闭包的一大特性就是内部函数总是可以访问其所在的外部函数中声明的参数和变量,即使在其外部函数被返回(寿命终结)了之后
基于此特性,JavaScript 可以实现私有变量、特权变量、储存变量等
我们就以私有变量举例,私有变量的实现方法很多,有靠约定的(变量名前加_),有靠 Proxy 代理的,也有靠 Symbol 这种新数据类型的。
但是真正广泛流行的其实是使用闭包。
function Person() {
var name = "cxk";
this.getName = function () {
return name;
};
this.setName = function (value) {
name = value;
};
}
const cxk = new Person();
console.log(cxk.getName()); //cxk
cxk.setName("jntm");
console.log(cxk.getName()); //jntm
console.log(name); //name is not defined
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
函数体内的var name = 'cxk'
只有getName
和setName
两个函数可以访问,外部无法访问,相对于将变量私有化。
# JavaScript 的作用域链理解吗?✨
JavaScript 属于静态作用域,即声明的作用域是根据程序正文在编译时就确定的,有时也称为词法作用域。
其本质是 JavaScript 在执行过程中会创造可执行上下文,可执行上下文中的词法环境中含有外部词法环境的引用,我们可以通过这个引用获取外部词法环境的变量、声明等,这些引用串联起来一直指向全局的词法环境,因此形成了作用域链。
原理详解请移步JavaScript 执行机制
# ES6 模块与 CommonJS 模块有什么区别?
ES6 Module 和 CommonJS 模块的区别:
- CommonJS 是对模块的浅拷贝,ES6 Module 是对模块的引用,即 ES6 Module 只存只读,不能改变其值,具体点就是指针指向不能变,类似 const
- import 的接口是 read-only(只读状态),不能修改其变量值。 即不能修改其变量的指针指向,但可以改变变量内部指针指向,可以对 commonJS 对重新赋值(改变指针指向),但是对 ES6 Module 赋值会编译报错。
ES6 Module 和 CommonJS 模块的共同点:
- CommonJS 和 ES6 Module 都可以对引入的对象进行赋值,即对对象内部属性的值进行改变。
# js 有哪些类型?
JavaScript 的类型分为两大类,一类是原始类型,一类是复杂(引用)类型。
原始类型:
- boolean
- null
- undefined
- number
- string
- symbol
复杂类型:
- Object
还有一个没有正式发布但即将被加入标准的原始类型 BigInt。
# 为什么会有 BigInt 的提案?
JavaScript 中 Number.MAX_SAFE_INTEGER 表示最大安全数字,计算结果是 9007199254740991,即在这个数范围内不会出现精度丢失(小数除外)。
但是一旦超过这个范围,js 就会出现计算不准确的情况,这在大数计算的时候不得不依靠一些第三方库进行解决,因此官方提出了 BigInt 来解决此问题。
# null 与 undefined 的区别是什么?
null 表示为空,代表此处不应该有值的存在,一个对象可以是 null,代表是个空对象,而 null 本身也是对象。
undefined 表示『不存在』,JavaScript 是一门动态类型语言,成员除了表示存在的空值外,还有可能根本就不存在(因为存不存在只在运行期才知道),这就是 undefined 的意义所在。
# 0.1+0.2 为什么不等于 0.3?
JS 的 Number
类型遵循的是 IEEE 754 标准,使用的是 64 位固定长度来表示。
IEEE 754 浮点数由三个域组成,分别为 sign bit (符号位)、exponent bias (指数偏移值) 和 fraction (分数值)。64 位中,sign bit 占 1 位,exponent bias 占 11 位,fraction 占 52 位。
通过公式表示浮点数的值 value = sign x exponent x fraction
**
当一个数为正数,sign bit 为 0,当为负数时,sign bit 为 1.
以 0.1 转换为 IEEE 754 标准表示为例解释一下如何求 exponent bias 和 fraction。转换过程主要经历 3 个过程:
- 将 0.1 转换为二进制表示
- 将转换后的二进制通过科学计数法表示
- 将通过科学计数法表示的二进制转换为 IEEE 754 标准表示
# 将 0.1 转换为二进制表示
回顾一下一个数的小数部分如何转换为二进制。一个数的小数部分,乘以 2,然后取整数部分的结果,再用计算后的小数部分重复计算,直到小数部分为 0 。
因此 0.1 转换为二进制表示的过程如下:
小数 | x2 的结果 | 整数部分 |
---|---|---|
0.1 | 0.2 | 0 |
0.2 | 0.4 | 0 |
0.4 | 0.8 | 0 |
0.8 | 1.6 | 1 |
0.6 | 1.2 | 1 |
0.2 | 0.4 | 0 |
0.4 | 0.8 | 0 |
0.8 | 1.6 | 1 |
0.6 | 1.2 | 1 |
... | ... | ... |
得到 0.1 的二进制表示为 0.00011...(无限重复 0011)
# 通过科学计数法表示
0.00011...(无限重复 0011) 通过科学计数法表示则是 1.10011001...(无线重复 1001)*2
# 转换为 IEEE 754 标准表示
当经过科学计数法表示之后,就可以求得 exponent bias 和 fraction 了。
exponent bias (指数偏移值) 等于 双精度浮点数固定偏移值 (2-1) 加上指数实际值(即 2 中的 -4) 的 11 位二进制表示。为什么是 11 位?因为 exponent bias 在 64 位中占 11 位。
因此 0.1 的 exponent bias 等于 1023 + (-4) = 1019 的 11 位二进制表示,即 011 1111 1011。
再来获取 0.1 的 fraction,fraction 就是 1.10011001...(无线重复 1001) 中的小数位,由于 fraction 占 52 位所以抽取 52 位小数,1001...(中间有 11 个 1001)...1010 (请注意最后四位,是 1010 而不是 1001,因为四舍五入有进位,这个进位就是造成 0.1 + 0.2 不等于 0.3 的原因)
0 011 1111 1011 1001...( 11 x 1001)...1010
(sign bit) (exponent bias) (fraction)
2
此时如果将这个数转换为十进制,可以发现值已经变为 0.100000000000000005551115123126 而不是 0.1 了,因此这个计算精度就出现了问题。
# 类型转换的规则有哪些?
在 if 语句、逻辑语句、数学运算逻辑、==等情况下都可能出现隐士类型转换。
# 类型转换的原理是什么?
类型转换指的是将一种类型转换为另一种类型,例如:
var b = 2;
var a = String(b);
console.log(typeof a); //string
2
3
当然,类型转换分为显式和隐式,但是不管是隐式转换还是显式转换,都会遵循一定的原理,由于 JavaScript 是一门动态类型的语言,可以随时赋予任意值,但是各种运算符或条件判断中是需要特定类型的,因此 JavaScript 引擎会在运算时为变量设定类型.
这看起来很美好,JavaScript 引擎帮我们搞定了类型
的问题,但是引擎毕竟不是 ASI(超级人工智能),它的很多动作会跟我们预期相去甚远,我们可以从一到面试题开始.
{
}
+[]; //0
2
3
答案是 0
是什么原因造成了上述结果呢?那么我们得从 ECMA-262 中提到的转换规则和抽象操作说起,有兴趣的童鞋可以仔细阅读下这浩如烟海的语言规范 (opens new window),如果没这个耐心还是往下看.
这是 JavaScript 种类型转换可以从原始类型转为引用类型,同样可以将引用类型转为原始类型,转为原始类型的抽象操作为ToPrimitive
,而后续更加细分的操作为:ToNumber ToString ToBoolean
。
为了更深入的探究 JavaScript 引擎是如何处理代码中类型转换问题的,就需要看 ECMA-262 详细的规范,从而探究其内部原理,我们从这段内部原理示意代码开始.
// ECMA-262, section 9.1, page 30. Use null/undefined for no hint,
// (1) for number hint, and (2) for string hint.
function ToPrimitive(x, hint) {
// Fast case check.
if (IS_STRING(x)) return x;
// Normal behavior.
if (!IS_SPEC_OBJECT(x)) return x;
if (IS_SYMBOL_WRAPPER(x)) throw MakeTypeError(kSymbolToPrimitive);
if (hint == NO_HINT) hint = IS_DATE(x) ? STRING_HINT : NUMBER_HINT;
return hint == NUMBER_HINT ? DefaultNumber(x) : DefaultString(x);
}
// ECMA-262, section 8.6.2.6, page 28.
function DefaultNumber(x) {
if (!IS_SYMBOL_WRAPPER(x)) {
var valueOf = x.valueOf;
if (IS_SPEC_FUNCTION(valueOf)) {
var v = %_CallFunction(x, valueOf);
if (IsPrimitive(v)) return v;
}
var toString = x.toString;
if (IS_SPEC_FUNCTION(toString)) {
var s = %_CallFunction(x, toString);
if (IsPrimitive(s)) return s;
}
}
throw MakeTypeError(kCannotConvertToPrimitive);
}
// ECMA-262, section 8.6.2.6, page 28.
function DefaultString(x) {
if (!IS_SYMBOL_WRAPPER(x)) {
var toString = x.toString;
if (IS_SPEC_FUNCTION(toString)) {
var s = %_CallFunction(x, toString);
if (IsPrimitive(s)) return s;
}
var valueOf = x.valueOf;
if (IS_SPEC_FUNCTION(valueOf)) {
var v = %_CallFunction(x, valueOf);
if (IsPrimitive(v)) return v;
}
}
throw MakeTypeError(kCannotConvertToPrimitive);
}
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
上面代码的逻辑是这样的:
- 如果变量为字符串,直接返回.
- 如果
!IS_SPEC_OBJECT(x)
,直接返回. - 如果
IS_SYMBOL_WRAPPER(x)
,则抛出异常. - 否则会根据传入的
hint
来调用DefaultNumber
和DefaultString
,比如如果为Date
对象,会调用DefaultString
. DefaultNumber
:首先x.valueOf
,如果为primitive
,则返回valueOf
后的值,否则继续调用x.toString
,如果为primitive
,则返回toString
后的值,否则抛出异常DefaultString
:和DefaultNumber
正好相反,先调用toString
,如果不是primitive
再调用valueOf
.
那讲了实现原理,这个ToPrimitive
有什么用呢?实际很多操作会调用ToPrimitive
,比如加、相等或比较操。在进行加操作时会将左右操作数转换为primitive
,然后进行相加。
下面来个实例,({}) + 1(将{}放在括号中是为了内核将其认为一个代码块)会输出啥?可能日常写代码并不会这样写,不过网上出过类似的面试题。
加操作只有左右运算符同时为String或Number
时会执行对应的%_StringAdd或%NumberAdd
,下面看下({}) + 1
内部会经过哪些步骤:
{}
和1
首先会调用 ToPrimitive
{}
会走到DefaultNumber
,首先会调用valueOf
,返回的是Object
{}
,不是 primitive 类型,从而继续走到toString
,返回[object Object]
,是String
类型
最后加操作,结果为[object Object]1
再比如有人问你[] + 1
输出啥时,你可能知道应该怎么去计算了,先对[]
调用ToPrimitive
,返回空字符串,最后结果为"1"。
# 谈谈你对原型链的理解?✨
这个问题关键在于两个点,一个是原型对象是什么,另一个是原型链是如何形成的
# 原型对象
绝大部分的函数(少数内建函数除外)都有一个prototype
属性,这个属性是原型对象用来创建新对象实例,而所有被创建的对象都会共享原型对象,因此这些对象便可以访问原型对象的属性。
例如hasOwnProperty()
方法存在于 Obejct 原型对象中,它便可以被任何对象当做自己的方法使用.
用法:
object.hasOwnProperty( propertyName )
hasOwnProperty()
函数的返回值为Boolean
类型。如果对象object
具有名称为propertyName
的属性,则返回true
,否则返回false
。
var person = {
name: "Messi",
age: 29,
profession: "football player",
};
console.log(person.hasOwnProperty("name")); //true
console.log(person.hasOwnProperty("hasOwnProperty")); //false
console.log(Object.prototype.hasOwnProperty("hasOwnProperty")); //true
2
3
4
5
6
7
8
由以上代码可知,hasOwnProperty()
并不存在于person
对象中,但是person
依然可以拥有此方法.
所以person
对象是如何找到Object
对象中的方法的呢?靠的是原型链。
# 原型链
原因是每个对象都有 __proto__
属性,此属性指向该对象的构造函数的原型。
对象可以通过 __proto__
与上游的构造函数的原型对象连接起来,而上游的原型对象也有一个__proto__
,这样就形成了原型链。
经典原型链图
# 如何判断是否是数组?
es6 中加入了新的判断方法
if(Array.isArray(value)){
return true;
}
2
3
在考虑兼容性的情况下可以用 toString 的方法
if (!Array.isArray) {
Array.isArray = function (arg) {
return Object.prototype.toString.call(arg) === "[object Array]";
};
}
2
3
4
5
# 谈一谈你对 this 的了解?✨
this 的指向不是在编写时确定的,而是在执行时确定的,同时,this 不同的指向在于遵循了一定的规则。
首先,在默认情况下,this 是指向全局对象的,比如在浏览器就是指向 window。
name = "Bale";
function sayName() {
console.log(this.name);
}
sayName(); //"Bale"
2
3
4
5
6
7
其次,如果函数被调用的位置存在上下文对象时,那么函数是被隐式绑定的。
function f() {
console.log(this.name);
}
var obj = {
name: "Messi",
f: f,
};
obj.f(); //被调用的位置恰好被对象obj拥有,因此结果是Messi
2
3
4
5
6
7
8
9
10
再次,显示改变 this 指向,常见的方法就是 call、apply、bind
以 bind 为例:
function f() {
console.log(this.name);
}
var obj = {
name: "Messi",
};
var obj1 = {
name: "Bale",
};
f.bind(obj)(); //Messi ,由于bind将obj绑定到f函数上后返回一个新函数,因此需要再在后面加上括号进行执行,这是bind与apply和call的区别
2
3
4
5
6
7
8
9
10
11
12
最后,也是优先级最高的绑定 new 绑定。
用 new 调用一个构造函数,会创建一个新对象, 在创造这个新对象的过程中,新对象会自动绑定到 Person 对象的 this 上,那么 this 自然就指向这个新对象。
function Person(name) {
this.name = name;
console.log(name);
}
var person1 = new Person("Messi"); //Messi
2
3
4
5
6
绑定优先级: new 绑定 > 显式绑定 >隐式绑定 >默认绑定
# 那么箭头函数的 this 指向哪里?✨
箭头函数不同于传统 JavaScript 中的函数,箭头函数并没有属于自己的 this,它的所谓的 this 是捕获其所在上下文的 this 值,作为自己的 this 值,并且由于没有属于自己的 this,而箭头函数是不会被 new 调用的,这个所谓的 this 也不会被改变.
我们可以用 Babel 理解一下箭头函数:
// ES6
const obj = {
getArrow() {
return () => {
console.log(this === obj);
};
},
};
2
3
4
5
6
7
8
转化后
// ES5,由 Babel 转译
var obj = {
getArrow: function getArrow() {
var _this = this;
return function () {
console.log(_this === obj);
};
},
};
2
3
4
5
6
7
8
9
# async/await 是什么?
async 函数,就是 Generator 函数的语法糖,它建立在 Promises 上,并且与所有现有的基于 Promise 的 API 兼容。
- Async—声明一个异步函数(async function someName(){...})
- 自动将常规函数转换成 Promise,返回值也是一个 Promise 对象
- 只有 async 函数内部的异步操作执行完,才会执行 then 方法指定的回调函数
- 异步函数内部可以使用 await
- Await—暂停异步的功能执行(var result = await someAsyncCall()😉
- 放置在 Promise 调用之前,await 强制其他代码等待,直到 Promise 完成并返回结果
- 只能与 Promise 一起使用,不适用与回调
- 只能在 async 函数内部使用
# async/await 相比于 Promise 的优势?
- 代码读起来更加同步,Promise 虽然摆脱了回调地狱,但是 then 的链式调用也会带来额外的阅读负担
- Promise 传递中间值非常麻烦,而 async/await 几乎是同步的写法,非常优雅
- 错误处理友好,async/await 可以用成熟的 try/catch,Promise 的错误捕获非常冗余
- 调试友好,Promise 的调试很差,由于没有代码块,你不能在一个返回表达式的箭头函数中设置断点,如果你在一个.then 代码块中使用调试器的步进(step-over)功能,调试器并不会进入后续的.then 代码块,因为调试器只能跟踪同步代码的『每一步』。
# JavaScript 的参数是按照什么方式传递的?
# 基本类型传递方式
由于 js 中存在复杂类型和基本类型,对于基本类型而言,是按值传递的.
var a = 1;
function test(x) {
x = 10;
console.log(x);
}
test(a); // 10
console.log(a); // 1
2
3
4
5
6
7
虽然在函数test
中a
被修改,并没有有影响到
外部a
的值,基本类型是按值传递的.
# 复杂类型按引用传递?
我们将外部a
作为一个对象传入test
函数.
var a = {
a: 1,
b: 2,
};
function test(x) {
x.a = 10;
console.log(x);
}
test(a); // { a: 10, b: 2 }
console.log(a); // { a: 10, b: 2 }
2
3
4
5
6
7
8
9
10
可以看到,在函数体内被修改的a
对象也同时影响到了外部的a
对象,可见复杂类型是按引用传递的.
可是如果再做一个实验:
var a = {
a: 1,
b: 2,
};
function test(x) {
x = 10;
console.log(x);
}
test(a); // 10
console.log(a); // { a: 1, b: 2 }
2
3
4
5
6
7
8
9
10
外部的a
并没有被修改,如果是按引用传递的话,由于共享同一个堆内存,a
在外部也会表现为10
才对.
此时的复杂类型同时表现出了按值传递
和按引用传递
的特性.
# 按共享传递
复杂类型之所以会产生这种特性,原因就是在传递过程中,对象a
先产生了一个副本a
,这个副本a
并不是深克隆得到的副本a
,副本a
地址同样指向对象a
指向的堆内存.
因此在函数体中修改x=10
只是修改了副本a
,a
对象没有变化.
但是如果修改了x.a=10
是修改了两者指向的同一堆内存,此时对象a
也会受到影响.
有人讲这种特性叫做传递引用,也有一种说法叫做按共享传递.
# 聊一聊如何在 JavaScript 中实现不可变对象?
实现不可变数据有三种主流的方法
- 深克隆,但是深克隆的性能非常差,不适合大规模使用
- Immutable.js,Immutable.js 是自成一体的一套数据结构,性能良好,但是需要学习额外的 API
- immer,利用 Proxy 特性,无需学习额外的 api,性能良好
原理详解请移步实现 JavaScript 不可变数据