JavaScript面试题
参考:https://juejin.cn/post/7061588533214969892#heading-32
1、数据类型
基本的数据类型 undefined, null, number, string, boolean
复杂的数据类型(引用类型) object
es6新添加的数据类型 symbol, bigInt
其中 symobol是标识符类型,主要作用是给对象赋值唯一的属性名,bigInt类型可以是任意大小的整数。
2、数据类型的判断
主要三种方法:
-
typeof 能判断所有基本的数据类型和es6新的类型,但是对于复杂的数据类型,也就是引用类型,则是都返回为Object, 因此无法判断引用类型。
-
instansof 只能判断复杂的数据类型,也就是引用类型。
-
Object.prototype.toString.call() 万能判断方法,可以判断所有的数据类型,包括es6新的类型。
3、基本数据类型和复杂数据类型赋值的过程详解
基本数据类型的赋值过程。
值的变化都是在栈(stack)中进行的,栈的空间小,大小固定,被频繁操作。
比如:
let a = 100;
let b = a;
a = 200;
console.log(b); // 100
复杂的数据类型赋值过程,也就是引用类型。
引用类型的数据也就对象之间的拷贝赋值,在栈中存入的是对象的引用地址,而不是对象中实际的属性和方法,对象中的具体内容是存入在堆中的,堆的空间大,大小不固定,很适合用来存放对象中有多个属性或方法的情况。
上面涉及到浅拷贝和深拷贝的问题,这里仅是以浅拷贝举例,探讨的也是浅拷贝引用类型时的过程。
比如:
let a = { age: 20 };
let b = a;
b.age = 30;
console.log(a.age); // 30
这里简单解释一下浅拷贝,浅拷贝在基本数据类型之间赋值是没有问题的,但是在对象中由于栈只是存放对象的地址引用,这样在浅拷贝赋值时会导致多个对象公用一个引用地址,修改的内容空间也会是一样,在大部分使用对象的场景中,这样是不满足使用要求的。
4、深拷贝详情(手写)
参考:https://juejin.cn/post/6844903929705136141#heading-1
/**
* 浅拷贝
* @param {*} target 基本类型 | 引用类型
* @returns 浅拷贝后的结果
*/
function shallowClone(target) {
let cloneTarget;
// 判断传入值是否是引用类型
if (typeof target === "object") {
cloneTarget = Array.isArray(cloneTarget) ? [] : {};
for (const key in target) {
cloneTarget[key] = target[key];
}
return cloneTarget;
} else {
// 非引用数据,直接拷贝即可
cloneTarget = target;
return cloneTarget;
}
}
/**
* 深拷贝 主要是针对引用类型(对象 数组)且解决了循环引用的问题
* 循环引用 对象的属性间接或直接的引用了自身的情况,这样递归会进入死循环导致栈内存溢出报错
* @param {*} obj 拷贝的目标
* @param {*} map 创建并操作拷贝后的对象
* @returns 深拷贝后的结果
*/
function deepClone(type, target, map = new Map()) {
if (type === 0) {
// 可以适用于绝大部分场景的深拷贝
return JSON.parse(JSON.stringify(target));
} else {
// 严谨的深拷贝
let cloneTarget;
// 判断传入值是否是引用类型
if (typeof target === "object") {
cloneTarget = Array.isArray(cloneTarget) ? [] : {};
// 检查map中有无克隆过的对象
if (map.get(target)) {
// 有就直接返回克隆的对象
return map.get(target);
}
// 没有则继续拷贝,并存一遍拷贝值
map.set(target, cloneTarget);
for (const key in target) {
// 有更深层次的对象可以继续递归直到属性为原始类型, 需要传入存储拷贝值后的map数据结构
cloneTarget[key] = deepClone(target[key], map);
}
return cloneTarget;
} else {
cloneTarget = target;
return cloneTarget;
}
}
}
/**
* 拷贝函数的单元测试
* @param {*} num 0-浅拷贝测试|1-深拷贝测试
* @return 拷贝后的结果
*/
function test(num) {
// 浅拷贝的测试数据
const target = {
a: 1,
b: "测试",
};
if (num === 0) {
return shallowClone(target);
}
// 深拷贝的测试数据
target = {
field1: 1,
field2: undefined,
field3: {
child: "child",
child2: {
child2: "child2",
},
},
field4: [2, 4, 8],
};
// 制造循环引用的数据
target.target = target;
// 使用严谨模式的深拷贝
return deepClone(1, target);
}
// 打印拷贝后的结果
console.log(test(1));
5、谈谈 0.1+0.2 ! == 0.3 ,如何解决
因为计算机无法直接对十进制进行运算,所以需要转换为二进制之后进行对阶运算,js中转换为二进制的方法就是IEEE 754, 这种方法只会保留53位二进制数,多余则截掉,且由于有的十进制数转换为二进制数是无限不循环小数,这样截取掉部分数,就会产生精度损失。
又由于转换为二进制后,指数位数的不同,进行对阶运算时也会产生精度损失。
所以,造成精度可能损失的主要原因是 IEEE 754进制转换 和 对阶运算中指数位数不同。
解决精度损失的问题:
- 换成整数后在进行相加
/**
* 加法运算(适用整数 小数 不适用超过16位的大整数)
* @param {*} num1 加数
* @param {*} num2 被加数
* @returns 相加后的结果
*/
function add(num1, num2) {
// 获取小数位数
const num1Digits = (num1.toString().split(".")[1] || "").length;
const num2Digits = (num2.toString().split(".")[1] || "").length;
// 获取放大倍数 如10 100
const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
// 将小数变为整数相加后在除以放大的倍数
return (num1 * baseNum + num2 * baseNum) / baseNum;
}
- 转换为字符串后相加,在leetcode 415题有相关解法
/**
* 字符串相加 leetcode 415题-字符串相加 https://leetcode.cn/problems/add-strings/
* 给定两个字符串形式的非负整数 num1 和num2 ,计算它们的和并同样以字符串形式返回。
* 你不能使用任何內建的用于处理大整数的库(比如 BigInteger), 也不能直接将输入的字符串转换为整数形式。
* @param {*} num1
* @param {*} num2
* @returns 相加后返回字符串形式的结果
*/
function addStrings(num1, num2) {
// 每位匹配计算的次数
let n1 = num1.length - 1;
let n2 = num2.length - 1;
const res = []; //存放每位相加后的结果
let carry = 0; //每位相加大于等于10的进位
while (n1 >= 0 || n2 >= 0) { //执行每位的相加
let i = num1[n1] - 0 ? num1[n1] - 0 : 0;
let j = num2[n2] - 0 ? num2[n2] - 0 : 0;
const sum = i + j + carry; //如果有进位需要加在后面
res.unshift(sum % 10); //每次存放余数在数组中头部位置
carry = Math.floor(sum / 10); //存放进位的数值
n1 = n1 - 1;
n2 = n2 - 1;
}
if (carry) { //当n1,n2=0时,第0位相加也大于等于10时,就退出循环了,需要在这再填充一次到res
res.unshift(carry);
}
return res.join(""); //最后将数组中的数字拼接回字符串
}
6、原型和原型链
参考:
https://github.com/mqyqingfeng/blog/issues/2
https://juejin.cn/post/7061588533214969892#heading-32
总结:
- 原型是 js每创建一个对象,就会有与之相关联的另一个对象,也就是指原型。每个对象都会从原型中"继承"属性。
- 原型链:由相互关联的原型构成的链状结构,就是原型链。
之后结合下面的图,加以理解。
图中由相互关联的原型组成的链状结构就是原型链,也就是蓝色的这条线。
需要注意的是:
- 所有构造函数的隐式原型都指Function的prototype,也就是Function的实例原型。
- 原型沿着原型链找到尽头是null,null的理解就是没有对象,也就是说沿着原型链找某个属性会在找到Object.prototype的实例原型时,停止查找。
7、作用域和作用域链
-
作用域:规定了如何查找变量,也就是当前执行代码对变量的访问权限。也可以说是作用域决定了当前代码块中变量和其它资源的可见性。(全局作用域, 函数作用域, 块级作用域)
-
作用域链:从当前的作用域一层层向上找某个变量,直到全局作用域,如果还没有找到就说明变量不存在。这种层级关系就是作用域链。(多个执行上下文的变量对象形成的链表就叫做作用域链)
需要注意的是,js 采用的是静态作用域,所以函数的作用域在函数定义时就确定了。
8、执行上下文
总结:当javascript中执行一段可执行的代码时,会创建一个执行上下文,在执行上下文中有三个重要的属性。
- 变量对象 (variable object, OV)
- 作用域链 (scope chain)
- this
9、闭包
在某个内部函数的执行上下文创建时,会将父级函数的活动对象加到内部函数的 [[scope]] 中,形成作用域链,所以即使父级函数的执行上下文销毁(即执行上下文栈弹出父级函数的执行上下文),但是因为其活动对象还是实际存储在内存中可被内部函数访问到的,从而实现了闭包。
闭包是指那些能够访问自由变量的函数。其中自由变量是指在函数中使用的
// 1. 闭包中自由变量的查找,是在函数定义的地方,向上级作用域查找。不是在执行的地方。
// 函数定义的地方查找变量 因此在creat函数作用域中找到a 就不会去全局作用域中查找a了
function creat() {
const a = 200;
return function fn() {
console.log(a);
};
}
const a = 100;
const fn = creat();
fn();
// 2. 闭包应用实例 缓存工具,隐藏数据, 提供api
function creatCache() {
let data = {}; //定义一个空对象,存放数据,且外部不能直接访问该数据
return { //返回一个对象,提供两个api
set: function (key, value) {
data[key] = value;
},
get: function (key) {
return data[key];
},
};
}
// 获取api
const api = creatCache()
// 使用api提供的方法去创建,获取数据
api.set('a', 100);
console.log(api.get('a'));
10、call, apply, bind实现
10.1 call
call可以指定this值去指向某一对象并调用函数或方法。
示例:
var obj = {
value: "obj",
};
function fn() {
console.log(this.value); //打印obj中的value
}
fn.call(obj); //改变this值,指向obj
也就是说,call可以做两件事:
- 改变this指向
- 执行函数或方法
call的具体实现:
/**
* call方法的具体实现
* @param {*} ctx 执行上下文 默认为window 如果需要改变this执行可以传入其它对象
* @param {...any} arg 函数外来参数,也就是函数的行参
* @returns 函数执行的结果
*/
Function.prototype.myCall = function (ctx) {
// 避免上下文不传,不传时默认为 window 全局对象
ctx = ctx || window;
// 判断调用对象
if (typeof this !== "function") {
throw new Error("Type error");
}
// 获取参数
const args = [...arguments].slice(1);
// 创建一个唯一的 Symbol 避免对象属性的重复
const uniqueKey = Symbol("fn");
// 保存当前this(当前this原来指向调用它的方法)
ctx[uniqueKey] = this;
const res = args.length > 0 ? ctx[uniqueKey](...args) : ctx[uniqueKey]();
// 删除对象的属性,减少内存的占用
delete ctx[uniqueKey];
// 返回函数执行的结果
return res;
};
// -----------------测试区域-------------------
// 全局变量
var target = "world";
// 对象中属性
var context = {
target: "xinz",
};
function fn() {
const args = Array.from(arguments).join(" ");
console.log(`${args} ${this.target}`);
}
fn.myCall(context, "hello", "all", "the"); //hello all the xinz
fn.call(context, "hello", "all", "the"); //hello all the xinz
// 第一个参数为空,则默认使用全局对象window
// fn.myCall("", "hello", "all", "the"); //hello all the xinz
// -------------------------------------------
10.2 apply
apply和call使用上没有什么区别,唯一区别在于传参的方式。
apply的具体实现:
/**
* apply方法的具体实现 [与call唯一不同在于传参的格式,call是列表,apply是数组]
* @param {*} ctx 执行上下文 默认为window 如果需要改变this执行可以传入其它对象
* @param {...any} arg 函数外来参数,也就是函数的行参
* @returns 函数执行的结果
*/
Function.prototype.myApply = function (ctx) {
ctx = ctx || window;
if (typeof this !== "function") {
throw new Error("type error");
}
const uniqueKey = Symbol("fn");
ctx[uniqueKey] = this;
const res = arguments[1] ? ctx[uniqueKey](...arguments[1]) : ctx[uniqueKey]();
delete ctx[uniqueKey];
return res;
};
// -----------------测试区域-------------------
// 全局变量
var target = "world";
// 对象中属性
var context = {
target: "xinz",
};
function fn() {
const args = Array.from(arguments).join(" ");
console.log(`${args} ${this.target}`);
}
fn.myApply(context, ["hello", "all", "the"]); //hello all the xinz
fn.apply(context, ["hello", "all", "the"]); //hello all the xinz
// 第一个参数为空,则默认使用全局对象window
// fn.myCall("", ["hello", "all", "the"]); //hello all the world
// -------------------------------------------
10.3 bind
参考:https://github.com/sisterAn/JavaScript-Algorithms/issues/81
bind 的实现与call和apply类似,但是最后返回的是一个函数。
也就是说bind 方法与 call / apply 最大的不同就是前者返回一个绑定上下文的函数,而后两者是直接执行了函数。
bind特点:
- 指定this
- 传入参数
- 返回一个函数
- 函数柯里化(把将一个接收多个参数的函数,变成接收单一参数,并返回之前函数结果的新函数的技术称为柯里化)
bind的具体实现:
/**
* bind方法的具体实现
* @param {*} ctx 执行上下文, 也就是指向myBind的调用者
* @param {...any} arg 函数外来参数,也就是函数的行参
* @returns 函数执行的结果
*/
Function.prototype.myBind = function (ctx) {
// 判断调用bind的是不是函数,不是则抛出异常
if (typeof this !== "function") {
throw new Error("type error");
}
// 保存this,且this是指向调用者
const self = this;
// 获取第一个参数(this)后的参数
const args = Array.prototype.slice.call(arguments, 1)
// const args = [...arguments].splice(1);
return function Fn() {
return self.apply(
// 如果this是Fn的实例,说明是返回的Fn再次new了,需要指定this,否则this失效
this instanceof Fn ? this : ctx,
// 这里是将之前bind里面的参数和返回函数Fn里面又接收的参数合到一起
args.concat(...arguments)
)
}
};
// -----------------测试区域-------------------
// 全局变量
var target = "world";
// 对象中属性
var context = {
target: "xinz",
};
function fn() {
const args = Array.from(arguments).join(" ");
console.log(`${args} ${this.target}`);
}
const test = fn.myBind(context, "hello", "all", "the"); //hello all the xinz
test();
// -------------------------------------------
11、new 的实现
主要做了4个步骤:
-
创建空对象
-
空对象的原型指向构造函数的原型
-
构造函数的this指向空对象,并执行构造函数(可以为这个新对象添加属性,不过构造函数中需要讲这些属性添加到原型中,这样新对象才会有这些属性)
-
判断函数的返回值类型,如果是引用类型则说明构造函数里面直接返回的对象,因此可以直接返回该引用类型的对象, 如果不是则正常返回继承了构造函数原型的新对象
function myNew(ctx){
// 1. 创建空对象
const obj = new Object();
// 2. 空对象的原型指向构造函数的原型
obj.__proto__ = ctx.prototype;
// 3. 构造函数的this指向空对象,并执行构造函数(可以为这个新对象添加属性,不过构造函数中需要讲这些属性添加到原型中,这样新对象才会有这些属性)
const res = ctx.apply(obj, [...arguments].slice(1))
// 4. 判断函数的返回值类型,如果是引用类型则说明构造函数里面直接返回的对象,因此可以直接返回该引用类型的对象, 如果不是则正常返回继承原型了的新对象
return typeof res === 'object' ? res : obj;
}
// -----------------测试区域-------------------
function Test(){
this.a = 1;
}
Test.prototype = {
fn1: function (){
console.log('测试')
return '执行完毕'
}
}
const test = myNew(Test);
console.log(test.a) //1
console.log(test.fn1()); //测试 执行完毕
// -------------------------------------------
12、游览器垃圾回收机制
参考:https://juejin.cn/post/6981588276356317214
有两种垃圾回收策略:
- 标记清除:标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁。
- 引用计数:它把对象是否不再需要简化定义为对象有没有其他对象引用到它。如果没有引用指向该对象(引用计数为 0),对象将被垃圾回收机制回收。