👩 个人主页:不爱吃糖的程序媛
🙋♂️ 作者简介:前端领域新星创作者、CSDN内容合伙人,专注于前端各领域技术,成长的路上共同学习共同进步,一起加油呀!
✨系列专栏:前端面试宝典、JavaScript进阶、vue实战
📢 资料领取:前端进阶资料以及文中源码可以在🎈公众号【不爱吃糖的程序媛】领取
前端面试基础知识题
1. 浏览器的垃圾回收机制有哪些?
JS会在创建变量时自动分配内存,在不使用的时候会自动周期性的释放内存,释放的过程就叫 “垃圾回收”。
一方面自动分配内存减轻了开发者的负担,开发者不用过多的去关注内存使用,但是另一方面,正是因为因为是自动回收,所以如果不清楚回收的机制,会很容易造成混乱,而混乱就很容易造成"内存泄漏"。
由于是自动回收,所以就存在一个 “内存是否需要被回收的” 的问题,但是这个问题的判定在程序中意味着无法通过某个算法去准确完整的解决,后面探讨的回收机制只能有限的去解决一般的问题。
垃圾回收对是否需要回收的问题主要依赖于对变量的判定是否可访问,由此衍生出两种主要的回收算法:
- 标记清理
- 引用计数
标记清理
标记清理是js最常用的回收策略,2012年后所有浏览器都使用了这种策略,此后的对回收策略的改进也是基于这个策略的改进。其策略是:
变量进入上下文,也可理解为作用域,会加上标记,证明其存在于该上下文;
将所有在上下文中的变量以及上下文中被访问引用的变量标记去掉,表明这些变量活跃有用;
在此之后再被加上标记的变量标记为准备删除的变量,因为上下文中的变量已经无法访问它们;
执行内存清理,销毁带标记的所有非活跃值并回收之前被占用的内存;
局限:
由于是从根对象(全局对象)开始查找,对于那些无法从根对象查询到的对象都将被清除;
回收后会形成内存碎片,影响后面申请大的连续内存空间
引用计数
引用计数策略相对而言不常用,因为弊端较多。其思路是对每个值记录它被引用的次数,通过最后对次数的判断(引用数为0)来决定是否保留,具体的规则有:
声明一个变量,赋予它一个引用值时,计数+1;
同一个值被赋予另外一个变量时,引用+1;
保存对该值引用的变量被其他值覆盖,引用-1;
引用为0,回收内存;
局限:最重要的问题就是,循环引用 的问题。
function refProblem () {
let a = new Object();
let b = new Object();
a.c = b;
b.c = a; //互相引用
}
根据之前提到的规则,两个都互相引用了,引用计数不为0,所以两个变量都无法回收。如果频繁的调用改函数,则会造成很严重的内存泄漏。
2. 箭头函数的 this 指向哪⾥?
箭头函数不同于传统JavaScript中的函数,箭头函数并没有属于⾃⼰的this,它所谓的this是捕获其所在上下⽂的 this 值,作为⾃⼰的 this 值,并且由于没有属于⾃⼰的this,所以是不会被new调⽤的,这个所谓的this也不会被改变。
可以⽤Babel理解⼀下箭头函数:
// ES6
const obj = {
getArrow() {
return () => {
console.log(this === obj);
};
}
}
转化后:
// ES5,由 Babel 转译
var obj = {
getArrow: function getArrow() {
var _this = this;
return function () {
console.log(_this === obj);
};
}
};
3. 使用Promise实现每隔1秒输出1,2,3
这道题比较简单的一种做法是可以用Promise配合着reduce不停的在promise后面叠加.then,请看下面的代码:
const arr = [1, 2, 3]
arr.reduce((p, x) => {
return p.then(() => {
return new Promise(r => {
setTimeout(() => r(console.log(x)), 1000)
})
})
}, Promise.resolve())
还可以更简单一点写:
const arr = [1, 2, 3]
arr.reduce((p, x) => p.then(() => new Promise(r => setTimeout(() => r(console.log(x)), 1000))), Promise.resolve())
4. 如何使用js计算一个html页面有多少种标签?
这道题看似简单,但是是一个很有价值的一道题目。它包含了很多重要的知识:
- 如何获取所有DOM节点
- 伪数组如何转为数组
- 去重
解题过程
- 获取所有的DOM节点。
document.querySelectorAll('*')
此时得到的是一个NodeList集合,我们需要将其转化为数组,然后对其筛选。
- 转化为数组
[...document.querySelectorAll('*')]
一个拓展运算符就轻松搞定。
- 获取数组每个元素的标签名
[...document.querySelectorAll('*')].map(ele => ele.tagName)
使用一个map方法,将我们需要的结果映射到一个新数组。
- 去重
new Set([...document.querySelectorAll('*')].map(ele=> ele.tagName)).size
我们使用ES6中的Set对象,把数组作为构造函数的参数,就实现了去重,再使用Set对象的size方法就可以得到有多少种HTML元素了。
5. Html文档渲染过程,css文件和js文件的下载,是否会阻塞渲染?
浏览器内有多个进程,其中渲染进程被称为浏览器内核,负责页面渲染和执行 JS 脚本等。渲染进程负责浏览器的解析和渲染,内部有 JS 引擎线程、 GUI 渲染线程、事件循环管理线程、定时器线程、HTTP 线程。
JS 引擎线程负责执行 JS 脚本,GUI 渲染线程负责页面的解析和渲染,两者是互斥的,也就是执行 JS 的时候页面是停止解析和渲染的。这是因为如果在页面渲染的同时 JS 引擎修改了页面元素,比如清空页面,会造成后续页面渲染的不必要和错误。而由于 JS 经常要操作 DOM ,就要涉及 JS 引擎线程和 GUI 渲染线程的通信,而线程间通信代价是非常昂贵的,这也是造成 JS 操作 DOM 效率不高的原因。
浏览器的 HTML/CSS 的解析和渲染都属于 GUI渲染线程,所以和 JS 引擎线程是互斥、阻塞的。下面从代码实际运行的角度分析浏览器解析和渲染的顺序,以及互相间的阻塞关系。
CSS 阻塞
- css 文件的下载和解析不会影响 DOM 的解析,但是会阻塞 DOM 的渲染。因为 CSSOM Tree 要和 DOM Tree 合成 Render Tree 才能绘制页面。下面的 test1 在 css 下载并解析完成前是默认样式, test2 在 css 下载并解析完成之前不会显示:
<button class="btn btn-primary">test1</button>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css">
<div>test2</div>
- css 文件没下载并解析完成之前,后续的 js 脚本不能执行。下面的 alert(‘ok’) 在 css 下载并解析完成之前不会弹出来:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css">
<script>
alert('ok')
</script>
- css 文件的下载不会阻塞前面的 js 脚本执行。下面的 alert(‘ok’) 会在 css 下载完成前弹出:
<script>
alert('ok')
</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css">
所以在需要提前执行不操作 dom 元素的 js 时,不妨把 js 放到 css 文件之前。
JS 阻塞
js 文件的下载和解析会阻塞 GUI 渲染进程,也就是会阻塞 DOM 和 CSS 的解析和渲染。
js 文件没下载并解析完成之前,后续的 HTML 和 CSS 无法解析:
<script src="https://code.jquery.com/jquery-3.4.1.js"></script>
<div>test</div>
js 文件的下载不会阻塞前面 HTML 和 CSS 的解析:
<div>test</div>
<script src="https://code.jquery.com/jquery-3.4.1.js"></script>
需要注意的点
- 第一,GUI 渲染线程会尽可能早的将内容呈现到屏幕上,并不会等到所有的 HTML 都解析完成之后再去构建和布局 Render Tree,而是解析完一部分内容就显示一部分内容,同时,可能还在通过网络下载其余内容。下面 test1 会在 js 文件下载完成前渲染完成,而 test2 则会在 js 文件下载并执行完之后渲染:
<div>test1</div>
<script src="https://code.jquery.com/jquery-3.4.1.js"></script>
<div>test2</div>
- 第二,文件的下载是不会被阻塞的,不管是 css 还是 js 文件,浏览器的主线程会在页面解析前开启下载,所以就算在外部脚本执行前删除脚本,脚本也还是会下载。
<body>
<script>
document.body.remove()
</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css">
<script src="https://code.jquery.com/jquery-3.4.1.js"></script>
</body>
6. forEach 中能否使用 await ?
function test() {
let arr = [3, 2, 1];
arr.forEach(async (item) => {
const res = await fetch(item);
console.log(res);
});
console.log("end");
}
function fetch(x) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(x);
}, 500 * x);
});
}
test();
上面代码的输出结果是:
end
1
2
3
为什么
其实原因很简单,那就是 forEach 只支持同步代码。
我们可以参考下 Polyfill 版本的 forEach,简化以后类似就是这样的伪代码
while (index < arr.length) {
callback(item, index) //也就是我们传入的回调函数
}
从上述代码中我们可以发现,forEach 只是简单的执行了下回调函数而已,并不会去处理异步的情况。 并且即使你在 callback 中使用 break 也并不能结束遍历。
怎么解决
一般来说解决的办法有2种:
- for…of
//因为 for...of 内部处理的机制和 forEach 不同,forEach 是直接调用回调函数,for...of 是通过迭代器的方式去遍历。
async function test() {
let arr = [3, 2, 1];
for (const item of arr) {
const res = await fetch(item);
console.log(res);
}
console.log("end");
}
- for循环
async function test() {
let arr = [3, 2, 1];
for (var i = 0; i < arr.length; i++) {
const res = await fetch(arr[i]);
console.log(res);
}
console.log("end");
}
function fetch(x) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(x);
}, 500 * x);
});
}
test();
7. 如何中断Promise?
Promise 有个缺点就是一旦创建就无法取消,所以本质上 Promise 是无法被终止的,但我们在开发过程中可能会遇到下面两个需求:
- 中断调用链
就是在某个 then/catch 执行之后,不想让后续的链式调用继续执行了。
somePromise
.then(() => {})
.then(() => {
// 终止 Promise 链,让下面的 then、catch 和 finally 都不执行
})
.then(() => console.log('then'))
.catch(() => console.log('catch'))
.finally(() => console.log('finally'))
一种方法是在then中直接抛错, 这样就不会执行后面的then, 直接跳到catch方法打印err(但此方法并没有实际中断)。但如果链路中对错误进行了捕获,后面的then函数还是会继续执行。
Promise的then方法接收两个参数:
Promise.prototype.then(onFulfilled, onRejected)
若onFulfilled或onRejected是一个函数,当函数返回一个新Promise对象时,原Promise对象的状态将跟新对象保持一致,详见Promises/A+标准。
因此,当新对象保持“pending”状态时,原Promise链将会中止执行。
Promise.resolve().then(() => {
console.log('then 1')
return new Promise(() => {})
}).then(() => {
console.log('then 2')
}).then(() => {
console.log('then 3')
}).catch((err) => {
console.log(err)
})
- 中断Promise
注意这里是中断而不是终止,因为 Promise 无法终止,这个中断的意思是:在合适的时候,把 pending 状态的 promise 给 reject 掉。例如一个常见的应用场景就是希望给网络请求设置超时时间,一旦超时就就中断,我们这里用定时器模拟一个网络请求,随机 3 秒之内返回。
function timeoutWrapper(p, timeout = 2000) {
const wait = new Promise((resolve, reject) => {
setTimeout(() => {
reject('请求超时')
}, timeout)
})
return Promise.race([p, wait])
}
8. Object.create 和 new 有什么区别?
js中创建对象的方式一般有两种Object.create和new
const Base = function(){};
const o1 = Object.create(Base);
const o2 = new Base();
在讲述两者区别之前,我们需要知道:
- 构造函数Foo的原型属性Foo.prototype指向了原型对象。
- 原型对象保存着实例共享的方法,有一个指针constructor指回构造函数。
- js中只有函数有 prototype 属性,所有的对象只有 proto 隐式属性。
那这样到底有什么不一样呢?
Object.create
先来看看 Object.create 的实现方式
Object.create = function (o) {
var F = function () {};
F.prototype = o;
return new F();
};
可以看出来。Object.create是内部定义一个对象,并且让F.prototype对象 赋值为引进的对象/函数 o,并return出一个新的对象。
new
再看看 const o2 = new Base() 的时候,new做了什么。
var o1 = new Object();
o1.[[Prototype]] = Base.prototype;
Base.call(o1);
new做法是新建一个obj对象o1,并且让o1的__proto__指向了Base.prototype对象。并且使用 call 进行强转作用环境。从而实现了实例的创建。
区别
//看似是一样的。我们对原来的代码进行改进一下。
var Base = function () {
this.a = 2
}
var o1 = new Base();
var o2 = Object.create(Base);
console.log(o1.a); // 2
console.log(o2.a); // undefined
可以看到Object.create 失去了原来对象的属性的访问。
再进行下改造:
var Base = function () {
this.a = 2
}
Base.prototype.a = 3;
var o1 = new Base();
var o2 = Object.create(Base);
console.log(o1.a); // 2
console.log(o2.a); // undefined
小结
9. 箭头函数和普通函数有啥区别?箭头函数能当构造函数吗?
什么是箭头函数?
ES6中允许使用箭头=>来定义箭头函数,具体语法,我们来看一个简单的例子:
// 箭头函数
let fun = (name) => {
// 函数体
return `Hello ${name} !`;
};
// 等同于
let fun = function (name) {
// 函数体
return `Hello ${name} !`;
};
可以看出,定义箭头函在数语法上要比普通函数简洁得多。箭头函数省去了function关键字,采用箭头=>来定义函数。函数的参数放在=>前面的括号中,函数体跟在=>后的花括号中。
箭头函数与普通函数的区别
1、语法更加简洁、清晰
从上面的基本语法示例中可以看出,箭头函数的定义要比普通函数定义简洁、清晰得多,很快捷。
2、箭头函数不会创建自己的this(重要!!深入理解!!)
我们先来看看MDN上对箭头函数this的解释。
箭头函数不会创建自己的this,所以它没有自己的this,它只会从自己的作用域链的上一层继承this。
箭头函数没有自己的this,它会捕获自己在定义时(注意,是定义时,不是调用时)所处的外层执行环境的this,并继承这个this值。所以,箭头函数中this的指向在它被定义的时候就已经确定了,之后永远不会改变。
3、箭头函数继承而来的this指向永远不变(重要!!深入理解!!)
上面的例子,就完全可以说明箭头函数继承而来的this指向永远不变。对象obj的方法b是使用箭头函数定义的,这个函数中的this就永远指向它定义时所处的全局执行环境中的this,即便这个函数是作为对象obj的方法调用,this依旧指向Window对象。
4、.call()/.apply()/.bind()无法改变箭头函数中this的指向
.call()/.apply()/.bind()方法可以用来动态修改函数执行时this的指向,但由于箭头函数的this定义时就已经确定且永远不会改变。所以使用这些方法永远也改变不了箭头函数this的指向,虽然这么做代码不会报错。
5、箭头函数不能作为构造函数使用
我们先了解一下构造函数的new都做了些什么?简单来说,分为四步:
① JS内部首先会先生成一个对象;
② 再把函数中的this指向该对象;
③ 然后执行构造函数中的语句;
④ 最终返回该对象实例。
但是!!因为箭头函数没有自己的this,它的this其实是继承了外层执行环境中的this,且this指向永远不会随在哪里调用、被谁调用而改变,所以箭头函数不能作为构造函数使用,或者说构造函数不能定义成箭头函数,否则用new调用时会报错!
6、箭头函数没有自己的arguments
箭头函数没有自己的arguments对象。在箭头函数中访问arguments实际上获得的是外层局部(函数)执行环境中的值。
7、箭头函数没有原型prototype
let sayHi = () => {
console.log('Hello World !')
};
console.log(sayHi.prototype); // undefined
8、箭头函数不能用作Generator函数,不能使用yeild关键字
10. async/await 和 Promise 有什么关系?
Promise
Promise 对象是一个代理对象(代理一个值),被代理的值在Promise对象创建时可能是未知的。它允许你为异步操作的成功和失败分别绑定相应的处理方法(handlers)。 这让异步方法可以像同步方法那样返回值,但并不是立即返回最终执行结果,而是一个能代表未来出现的结果的promise对象
async/await
es2017的新语法,async/await就是generator + promise的语法糖
async/await 和 Promise 的关系非常的巧妙,await必须在async内使用,并装饰一个Promise对象,async返回的也是一个Promise对象。
async/await中的return/throw会代理自己返回的Promise的resolve/reject,而一个Promise的resolve/reject会使得await得到返回值或抛出异常。
- 如果方法内无await节点
return 一个字面量则会得到一个{PromiseStatus: resolved}的Promise。
throw 一个Error则会得到一个{PromiseStatus: rejected}的Promise。
- 如果方法内有await节点
async会返回一个{PromiseStatus: pending}的Promise(发生切换,异步等待Promise的执行结果)。
Promise的resolve会使得await的代码节点获得相应的返回结果,并继续向下执行。
Promise的reject 会使得await的代码节点自动抛出相应的异常,终止向下继续执行。