面试题 Js 篇
数据类型
基本的数据类型介绍,及值类型和引用类型的理解
在 JS 中共有 8 种基础的数据类型,分别为:Undefined 、 Null 、 Boolean 、 Number 、 String 、 Object 、 Symbol 、 BigInt。
其中 Symbol 和 BigInt 是 ES6 新增的数据类型,可能会被单独问:
- Symbol 代表独一无二的值,最大的用法是用来定义对象的唯一属性名。
- BigInt 可以表示任意大小的整数。
值类型是直接存储在栈(stack)中的简单数据段,占据空间小、大小固定,属于被频繁使用数据,所以放入栈中存储。
值类型的赋值变动过程如下:
1 | let a = 100 |
引用类型存储在堆(heap)中的对象,占据空间大、大小不固定。如果存储在栈中,将会影响程序运行的性能。
引用类型的赋值变动过程如下:
1 | let a = { age: 20 } |
数据类型的判断
- typeof:能判断所有值类型,函数。不可对 null、对象、数组进行精确判断,因为都返回 object 。
对于 null 来说:
1 | typeof null === 'object' // true |
源于 JavaScript 的 bug,牵涉到太多的 Web 系统,修复它会产生更多的 bug,令许多系统无法正常工作,所以也许永远也不会修复
我们需要用复合条件来检测 null 的类型:
1 | var a = null |
null 是基本类型中唯一一个假值(falsy 或者 false-like)类型
还有一种情况:
1 | typeof function a() { |
function 也是 object 的子类型,具体来说,函数是 “可调用对象”,它有一个内部属性 [call],该属性使其可以被调用。函数不仅是对象,还可以拥有属性,例如:
1 | function a(b, c) { |
函数对象的 length 属性是其声明参数的个数:
1 | a.length // 2 |
因为该函数声明了两个命名参数,b 和 c,所以其 length 值为 2。
1 | typeof [1, 2, 3] === 'object' // true |
数组也是对象,确切地说,它也是 object 的一个子类型,数组的元素按数字顺序来进行索引(而非像普通对象那样通过字符串键值),其 length 属性是元素的个数。
因为 JavaScript 中的变量没有类型,在对变量执行 typeof 操作时,得到的结果并不是该变量的类型,而是该变量持有的值的类型。
1 | var a = 42 |
typeof 42 首先返回字符串”number”,然后 typeof “number” 返回”string”。
- instanceof:能判断对象类型,不能判断基本数据类型,其内部运行机制是判断在其原型链中能否找到该类型的原型。比如考虑以下代码:
1 | class People {} |
其实现就是顺着原型链去找,如果能找到对应的 Xxxxx.prototype 即为 true 。比如这里的 vortesnail 作为实例,顺着原型链能找到 Student.prototype 及 People.prototype ,所以都为 true 。
- **Object.prototype.toString.call ()**:所有原始数据类型都是能判断的,还有 Error 对象,Date 对象等。
1 | Object.prototype.toString.call(2) // "[object Number]" |
衍生问题:如何判断变量是否为数组?
1 | Array.isArray(arr) // true |
手写深拷贝
1 | // 使用递归,会出现栈溢出问题,改为循环可解决 |
首先需要了解 JavaScript 中赋值和参数传递的工作机制:
JavaScript 中的引用和其他语言中的引用 / 指针不同,它们不能指向别的变量 / 引用,只能指向值。如果一个值有 10 个引用,这些引用指向的都是同一个值,变量相互之间没有引用 / 指向关系。JavaScript 对值和引用的赋值 / 传递在语法上没有区别,完全根据值的类型来决定。
对于简单值(即标量基本类型值,scalar primitive)总是通过值复制的方式来赋值 / 传递,包括 null、undefined、string、number、boolean 和 ES6 中的 symbol。
对于复合值(compound value)—— 对象(包括数组和封装对象)和函数,则总是通过引用复制的方式来赋值 / 传递。
一个例子:
1 | var a = 2 |
上例中 2 是一个标量基本类型值,所以变量 a 持有该值的一个复本,b 持有它的另一个复本,b 更改时,a 的值保持不变。c 和 d 分别指向同一个复合值 [1,2,3] 的两个不同的引用,所以它们更改的是同一个值,更改后它们指向更新后的新值 [1,2,3,4]。
由于引用指向的是值本身而非变量,所以一个引用无法更改另一个引用的指向。
1 | var a = [1, 2, 3] |
在函数参数中:
1 | function foo(x) { |
我们向函数传递 a 的时候,实际是将引用 a 的一个复本赋值给 x,而 a 仍然指向 [1,2,3]。在函数中我们可以通过引用 x 来更改数组的值(push (4) 之后变为 [1,2,3,4])。但 x = [4,5,6] 并不影响 a 的指向,所以 a 仍然指向 [1,2,3,4]。我们不能通过引用 x 来更改引用 a 的指向,只能更改 a 和 x 共同指向的值。
如果要将 a 的值变为 [4,5,6,7],必须更改 x 指向的数组,而不是为 x 赋值一个新的数组:
1 | function foo(x) { |
通过值复制的方式来传递复合值(如数组):
1 | foo(a.slice()) |
slice (..) 不带参数会返回当前数组的一个浅复本(shallow copy)。由于传递给函数的是指
向该复本的引用,所以 foo (..) 中的操作不会影响 a 指向的数组。
如果要将标量基本类型值传递到函数内并进行更改,就需要将该值封装到一个复合值(对象、数组等)中,然后通过引用复制的方式传递:
1 | function foo(wrapper) { |
这里 obj 是一个封装了标量基本类型值 a 的封装对象。obj 引用的一个复本作为参数 wrapper 被传递到 foo (..) 中。这样我们就可以通过 wrapper 来访问该对象并更改它的属性。函数执行结束后 obj.a 将变成 42。
与预期不同的是,虽然传递的是指向数字对象的引用复本,但我们并不能通过它来更改其
中的基本类型值:
1 | function foo(x) { |
原因是标量基本类型值是不可更改的(字符串和布尔也是如此)。如果一个数字对象的标
量基本类型值是 2,那么该值就不能更改,除非创建一个包含新值的数字对象。
(以上摘录自《你不知道的 JavaScript(中卷)》第二章 - 数字)
浅拷贝:创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。
深拷贝:将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象。
1 | /** |
根据 0.1+0.2 ! == 0.3,讲讲 IEEE 754 ,如何让其相等?
了解 IEEE 754:0.1 + 0.2 不等于 0.3?为什么 JavaScript 有这种 “骚” 操作?
运算机制:硬核基础二进制篇(一)0.1 + 0.2 != 0.3 和 IEEE-754 标准
原因总结:
- 进制转换 :js 在做数字计算的时候,0.1 和 0.2 都会被转成二进制后无限循环 ,但是 js 采用的 IEEE 754 二进制浮点运算,最大可以存储 53 位有效数字,于是大于 53 位后面的会全部截掉,将导致精度丢失。
- 对阶运算 :由于指数位数不相同,运算时需要对阶运算,阶小的尾数要根据阶差来右移(0 舍 1 入),尾数位移时可能会发生数丢失的情况,影响精度。
解决办法:
1. 转为整数(大数)运算。
1 | function add(a, b) { |
2. 使用 Number.EPSILON 设置误差范围,通常称为机器精度(machine epsilon),对 JavaScript 的数字来说,这个值通常是 2^-52 (2.220446049250313e-16)。
从 ES6 开始,该值被定义在 Number.ESPILON 中,我们可以直接使用:
1 | function isEqual(a, b) { |
也可以为 ES6 之前的版本写 polyfill:
1 | if (!Number.EPSILON) { |
能够呈现的最大浮点数大约是 1.798e+308(这是一个相当大的数字),它定义在 Number.MAX_VALUE 中。最小浮点数定义在 Number.MIN_VALUE 中,大约是 5e-324,它不是负数,但无限接近于 0!可以说,Number.EPSILON 的实质是一个可以接受的最小误差范围,一般来说为 Math.pow (2, -52) 。
3. 转成字符串,对字符串做加法运算。
1 | // 字符串数字相加 |
原型与原型链
之前写的原型和原型链总结的文章:原型和原型链
总结:
- 原型:
- 所有引用类型都有一个__proto__(隐式原型) 属性,属性值是一个普通的对象
- 所有函数都有一个 prototype(原型) 属性,属性值是一个普通的对象
- 所有引用类型的__proto__属性指向它构造函数的 prototype
- 原型链:当访问一个对象的某个属性时,会先在这个对象本身属性上查找,如果没有找到,则会去它的__proto__隐式原型上查找,即它的构造函数的 prototype,如果还没有找到就会再在构造函数的 prototype 的__proto__中查找,这样一层一层向上查找就会形成一个链式结构,我们称为原型链。
作用域与作用域链
JavaScript 深入之词法作用域和动态作用域
深入理解 JavaScript 作用域和作用域链
- 作用域:规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。换句话说,作用域决定了代码区块中变量和其他资源的可见性。(全局作用域、函数作用域、块级作用域)
- 作用域链:从当前作用域开始一层层往上找某个变量,如果找到全局作用域还没找到,就放弃寻找 。这种层级关系就是作用域链。(由多个执行上下文的变量对象构成的链表就叫做作用域链,学习下面的内容之后再考虑这句话)
需要注意的是,js 采用的是静态作用域,所以函数的作用域在函数定义时就确定了。
执行上下文
JavaScript 深入之执行上下文栈
JavaScript 深入之变量对象
JavaScript 深入之作用域链
JavaScript 深入之执行上下文
总结:当 JavaScript 代码执行一段可执行代码时,会创建对应的执行上下文。对于每个执行上下文,都有三个重要属性:
- 变量对象(Variable object,VO);
- 作用域链(Scope chain);
- this
JavaScript 的 this 原理
彻底理解 js 中 this 的指向
闭包
根据 MDN 中文的定义,闭包的定义如下:
在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。可以在一个内层函数中访问到其外层函数的作用域。
也可以这样说:
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
下面我们来看一段代码,清晰地展示了闭包:
1 | function foo() { |
函数 bar () 的词法作用域能够访问 foo () 的内部作用域。然后我们将 bar () 函数本身当作一个值类型进行传递。在这个例子中,我们将 bar 所引用的函数对象本身当作返回值。
在 foo () 执行后,其返回值(也就是内部的 bar () 函数)赋值给变量 baz 并调用 baz (),实际上只是通过不同的标识符引用调用了内部的函数 bar ()。
bar () 显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域以外的地方执行。
在 foo () 执行后,通常会期待 foo () 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 foo () 的内容不会再被使用,所以很自然地会考虑对其进行回收。
而闭包的 “神奇” 之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。谁在使用这个内部作用域?原来是 bar () 本身在使用。
拜 bar () 所声明的位置所赐,它拥有涵盖 foo () 内部作用域的闭包,使得该作用域能够一直存活,以供 bar () 在之后任何时间进行引用。
bar () 依然持有对该作用域的引用,而这个引用就叫作闭包。
循环中的闭包
看下面的代码:
1 | for (var i = 1; i <= 5; i++) { |
实际上,它并不像我们期望的那样每秒一次分别输出数字 1-5,它会以每秒一次的频率输出 5 次 6。为什么?
输出显示的是循环结束时 i 的最终值,因为当定时器运行时,即使每个迭代中执行的是 setTimeout (.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己 “捕获” 一个 i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。
这样说的话,当然所有函数共享一个 i 的引用。循环结构让我们误以为背后还有更复杂的机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环,那它同这段代码是完全等价的。
下面回到正题。缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。
IIFE (立即执行函数表达式,Immediately Invoked Function Expression)会通过声明并立即执行一个函数来创建作用域。
它需要有自己的变量,用来在每个迭代中储存 i 的值:
1 | for (var i = 1; i <= 5; i++) { |
可以对这段代码进行一些改进:
1 | for (var i = 1; i <= 5; i++) { |
在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量,本质上这是将一个块转换成一个可以被关闭的作用域:
1 | for (var i = 1; i <= 5; i++) { |
变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量:
1 | for (let i = 1; i <= 5; i++) { |
(以上摘录自《你不知道的 JavaScript(上卷)》第一部分第五章 - 作用域闭包)
call、apply、bind 实现
JavaScript 中 call,apply,bind 方法的总结
call
call () 方法在使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或方法。
举个例子:
1 | var obj = { |
通过 call 方法我们做到了以下两点:
- call 改变了 this 的指向,指向到 obj 。
- fn 函数执行了。
那么如果我们自己写 call 方法的话,可以怎么做呢?我们先考虑改造 obj。
1 | var obj = { |
这时候 this 就指向了 obj ,但是这样做我们手动给 obj 增加了一个 fn 属性,这显然是不行的,不用担心,我们执行完再使用对象属性的删除方法(delete)不就行了?
1 | obj.fn = fn |
根据这个思路,我们就可以写出来了:
1 | Function.prototype.myCall = function (context) { |
apply
我们会了 call 的实现之后,apply 就变得很简单了,他们没有任何区别,除了传参方式。
1 | Function.prototype.myApply = function (context) { |
bind
bind 返回的是一个函数,这个地方可以详细阅读这篇文章,讲的非常清楚:解析 bind 原理,并手写 bind 实现。
1 | Function.prototype.myBind = function (context) { |
new 实现
- 首先创一个新的空对象。
- 根据原型链,设置空对象的 proto 为构造函数的 prototype 。
- 构造函数的 this 指向这个对象,执行构造函数的代码(为这个新对象添加属性)。
- 判断函数的返回值类型,如果是引用类型,就返回这个引用类型的对象。
1 | function myNew(context) { |
异步
event loop、宏任务和微任务
Promise
async/await 和 Promise 的关系
- async/await 是消灭异步回调的终极武器。
- 但和 Promise 并不互斥,反而,两者相辅相成。
- 执行 async 函数,返回的一定是 Promise 对象。
- await 相当于 Promise 的 then。
- try…catch 可捕获异常,代替了 Promise 的 catch。