已经进入秋招了,作者本人也在着急的准备面试,凭着之前的面试经验,我总结了一份完整的面试题,其中分析了答题思路和答案,还有一些手写题,希望可以帮到所有参加面试的小伙伴!!!
可能有些地方不严谨,写的也比较急,所以希望大家可以评论留言批评指正!!!
写作不易,我已经毫无保留分享自己的毕生所学
,那你是不是应该毫不吝啬的给个赞吧!!!
回答思路:
const、let
之前有什么弊端const、let
解决了什么问题扩展问题:
开始回答
const、let
之前,我们使用 var
来定义变量,因此我们的作用域
只分为两种,也就是全局作用域和块级作用域,因此,我们在使用像for或者if
这种关键词时,会有很大的隐患,可能造成变量冲突。for: 正常来说,我们在 for 循环中定义其他变量,在结束后 for 中定义的变量应该被销毁,不会修改全局变量,但是由于没有块级作用域的限制,使得本应该销毁的变量没有被销毁。
if: 正常我们在fun
函数中会打印x的值1,但是由于var
变量提升,并且没有块级作用域的限制,虽然if
中的代码不会执行,但是变量 x 会覆盖fun
外部的值,导致x打印undefined
。
var i = 5;
for(var i = 3; i < 10; i ++){
// 其他操作
}
console.log(i)
---------------------------
var x = 1
function fun (){
console.log(x)
if(false){
var x = 1
}
console.log(x)
}
let、const
之后,我们引入了新的概念,块级作用域,很好的解决了变量提升和无块级作用域带来的变量冲突的问题。那是如何解决的呢?我们引入新的问题。扩展回答:
变量环境
,词法环境
,this
,作用域链
,其中,变量环境用来存放 var
和 function
定义的变量,并且初始化值为undefined
和地址值
,而在执行前变量已经定义好了,也就是所谓的变量提升。而const
和let
定义的变量存储在词法环境中,在词法环境中维护一个小型栈,按照块的形式,将每一个块级变量压入栈顶,执行后弹出,并且不会初始化,因此在一个块级作用域中提前访问变量会报错,也就是所谓的暂时性死区
。变量查找顺序
:先从词法环境中从栈顶到栈底,然后到变量环境中查找,之后沿着作用域链查找。
注意点:
var
和function
变量提升,并且function
优先于var
,const 、let
块级作用域中的暂时性死区。
回答思路
:
Promise
是什么?Promise
为什么出现,没出现前有什么问题,出现后解决了什么问题?拓展问题:
Promise
执行机制,微任务宏任务Promise
的缺陷,async/await
语法糖的出现开始回答:
Promise
期约, 是一个用来执行将来要发生的或者即将要发生的事件的对象,它自身有三种状态,pending
, fulfilled
,rejected
,同一时刻只能有一种状态,一旦状态改变,则不能再更改,也不可逆。通过构造函数的方式使用,传入一个执行器回调函数,以此来决定Promise
的执行状态。之后通过then
方法来决定执行什么样的操作,并且then
方法返回一个值会自动包装为Promise
从而实现对象的链式调用。(之后可以介绍下其他方法 race, all, resolve, reject
)Promise
出现之前,我们在书写异步代码时,通过回调的方式来拿到异步返回的值,代码抒写逻辑不连续,除此之外,如果下一次的执行需要依赖上一次的执行结果,会导致代码嵌套,如果嵌套次数太多,就造成了新的问题,回调地狱,使得代码难看难以维护,Promise
的出现,通过 then
方法,解决了函数嵌套的问题,then
方法的链式调用,也将回调地狱的问题迎刃而解。上面回答,只是简单的介绍了下
Promise
的用法,没有深入的去讲Promise
,有能力的话我们应该扩展去讲它周边的知识
扩展:
Promise
除了解决异步回调的问题之外,还有一个特性,就是它的执行时机,微任务
,和微任务
对应的还有宏任务
,说到这我们不得不讲下浏览器的异步实现机制,为了解决 js 单线程(可能涉及问题:js为什么是单线程?
)同步执行的效率问题,我们引入了异步执行机制,而异步执行依赖于 v8引擎 中的消息队列和事件循环
机制,也就是js在执行过程中遇到异步任务时,不会立即执行,而是将该事件存放到消息队列当中,而消息队列中存放的任务也就是我们所谓的宏任务
,然后继续执行js代码,当所有同步代码执行完毕之后,通过事件循环
,也就是一个循环代码for或者while
来不停的从消息队列
中取出一个事件来执行,这就是异步任务的执行机制。消息队列有一个缺点,就是所有任务都是按顺序执行,因此,如果我们需要执行一些时间粒度小的任务,比如监听DOM的变化去做相应的业务逻辑的时候,再使用消息队列的话会造成严重的效率问题,因为消息队列是先进先出的结构,在该事件
添加到消息队列尾部时,消息队列内部可能已经有很多任务了,所以宏任务的执行效率不高,这是就引入了新的概念微任务
,在宏任务中包含一个微任务队列
,来存放需要执行效率高的事件,在每次宏任务执行完之后,不会着急执行下一个宏任务,而是将该宏任务中的微任务
执行完毕,再去执行下一个宏任务
。
Promise
虽然可以链式调用来解决回调地狱,但是还是不够完美,依然是回调的方式书写代码,为了更加符合代码逻辑,推出了async/await
语法糖,来用同步的方式书写异步代码。使用 async
修饰的函数代表异步函数,会自动包装返回一个Promise
类型的对象,await
关键字用来暂停执行逻辑。通过try/cache
来捕获异常,介绍完后,我们来说下它的执行原理。
执行原理:(自执行的生成器 + 协程)
yield/*
,通过调用 *
函数来返回一个对象,其中有next,return,trow
方法。next 方法用来恢复*函数
的执行,yield
来暂停 *函数
的执行。详细使用大家请自己查阅,这里只做大致介绍。通过*
函数来创建一个协程,但是该协程不会立刻执行,当调用next
方法时,将控制权交给该协程,开始执行,遇到yield
关键字后,停止协程,并将控制权返回给父协程,并把 yield 后的值传递给父协程,父协程通过next(value)
传值给该子协程。这就是生成器可以实现暂停执行的原理。
说明:Promise底层实现了微任务的调用,我们没办法用代码实现,只能通过js模拟执行
Promise 的实现
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
class MyPromise {
constructor(excutor) {
this.state = PENDING
this.value = null
this.err = null
this.onFulfilledCallback = null
this.onRejectedCallback = null
excutor(this.Resolve, this.Reject)
}
Resolve = val => {
if (this.state == PENDING) {
this.state = FULFILLED
this.value = val
this.onFulfilledCallback && this.onFulfilledCallback(this.value)
}
}
Reject = err => {
if (this.state == PENDING) {
this.state = REJECTED
this.value = err
this.onRejectedCallback && this.onFulfilledCallback(this.err)
}
}
then = (onFulfilled, onRejected) => {
if (this.state == FULFILLED) {
const res = onFulfilled(this.value)
return MyPromise.resolve(res)
}
if (this.state == REJECTED) {
const err = onRejected(this.err)
return MyPromise.reject(err)
}
if (this.state == PENDING) {
this.onFulfilledCallback = onFulfilled
this.onRejectedCallback = onRejected
}
}
static resolve(val) {
return new MyPromise((res, rej) => {
res(val)
})
}
static reject(err) {
return new MyPromise((res, rej) => {
rej(val)
})
}
}
Promise.race
static race(promiseList) {
return new MyPromise((res, rej) => {
const len = promiseList.length
for (let i = 0; i < len; i++) {
promiseList[i].then(res, rej)
}
})
}
Promise.all
static all(promiseList) {
return new MyPromise((res, rej) => {
const len = promiseList.length, result = []
for (let i = 0; i < len; i++) {
promiseList[i].then(value => {
result.push(value)
if (len == result.length) res(result)
}, err => {
rej(err)
})
}
})
}
Promise.allSettled
static allSettled(promiseList) {
return new MyPromise((res, rej) => {
const len = promiseList.length
const result = []
for (let i = 0; i < len; i++) {
promiseList[i].then(value => {
result.push({ state: 'fulfilled', value })
}, err => {
result.push({ state: 'rejected', value: err })
})
}
if (len == result.length) {
res(result)
}
})
}
Promise 控制高并发
function limitRequest(request = [], limit = 3) {
const callback = []
return new Promise((res, rej) => {
let count = 0, len = request.length
while (limit > 0) {
start()
limit--;
}
function start() {
count++;
const req = request.shift()
req().then(value => {
callback.push(value)
console.log(value)
}).finally(() => {
if (count == len) {
res(callback)
} else {
start()
}
})
}
})
}
自执行生成器
const co = function (generator) {
const gen = generator()
return new Promise((res, rej) => {
const run = function (val = undefined) {
const { value, done } = gen.next(val)
if (!done) {
Promise.resolve(value).then(ret => run(ret))
} else {
res(value)
}
}
run()
})
}
const Async = function* () {
const x = yield 4;
console.log(x)
const y = yield Promise.resolve(2)
console.log(y)
return 7
}
co(Async)
回答思路:
Set和Map
的特点,有什么APIWeakMap 和 WeakSet
扩展问题
Map和object
比较,性能对比开始回答
delete
删除数据,这段引用类型数据会永远存在于内存中,不会被垃圾回收机制回收,因为Set和Map
一直保持对该引用类型数据的引用。因此引入了WeakMap和WeakSet
,因为是为了解决对引用类型数据的强引用类型,所以,WeakMap和WeakSet
与Set和Map
不同的是,键名只能是引用类型,不可以是字符串或者数组等类型,WeakMap和WeakSet
对数据是弱引用,因此不会阻止垃圾回收机制回收。因为其弱引用类型,所以也没用keys,values,entire,size
的api。案例
正常来讲,btn所指向的内存中会保存该dom
,当 btn=null
后会断开btn同保存dom内存的连接
会回收该段内存,由于Map的强引用,即时连接断开,保存该dom
的内存也不会被回收。
let btn = document.getElementById('button')
let map = new Map([
[btn, { count: 0 }]
])
btn = null
扩展回答
‘0’
和0
是不一样的键值散列表:
是能够通过给定的关键字的值直接访问到具体对应的值的一个数据结构,保存在数组中,而数组是一块连续内存,可以直接根据地址值访问,因此查询速度更快,插入更快。当我们使用Map
保存数据时,Map
会先将我们传入的key
经过哈希函数,生成一个哈希值(整数),而这个哈希值在内存中映射着我们传入的value
值,因此他的访问速度很快。而Set
是一种key和value相同的散列表
,因此不可以重复。
根据能力继续扩展(自己查找)
答题思路:
Proxy
和Reflect
的作用Object.defineProperty
缺点,Proxy
的优势扩展回答
Proxy
的应用:vue3响应式原理开始回答
首先Proxy
根据字面意思来看,它是一个对象的代理,代理就意味着我们想要为一个对象添加或者删除一些属性的时候,不能直接操作该对象,我们只需要操作代理对象就可以,通过代理对象做一些拦截就可以监测到我们对该对象的行为。Proxy
通过构造函数的形式调用,接收两个参数,一个是需要代理的对象target
,另一个是一些代理的行为handler
。
Reflect
是伴随Proxy
使用的一个对象,它本身实现了同Proxy
的 handler 相同的全部方法,它就像是我们在修改 handler 的默认行为时的一个正确指引,无论我们怎样对handler做修改,通过Reflect
总能保持正确的输出。其上的API
请自己查阅。
在Proxy
出现之前,我们有一个与之相同功能的实现方式,通过Object.defineProperty
来为对象的某个键值实现代理,实现数据劫持,但是它有一个严重的弊端,是为对象的键值实现数据劫持,因此没有添加的键值不会实现,这也是vue2
中响应式原理实现的一个缺陷,新添加的属性不会实现响应式,必须通过$set
方法来单独实现响应式,而vue3
通过Proxy和Reflect
实现对对象的代理,也就是响应式原理,完美的解决了vue2
的问题。这也就是Proxy
的优势。
答题思路:
扩展问题
this
指向开始回答
箭头函数类似于匿名函数,没有名字,并且不需要使用function
来定义,是一种很简洁的定义函数的方法,如果箭头函数只有一个参数的话可以省略小括号,如果函数体只有一个return
语句的话,可以省略return和花括号
,因此它使用起来是更加方便的,很适合来简化回调函数。
const fn = args => args
使用起来虽然方便,但是同普通函数来比,少去了很多特性。
prototype
这个属性的,而构造函数的作用就是创建一个对象,并将该对象的原型指向函数的原型对象,因此它不可以作为构造函数使用。this
,在箭头函数中使用this
时,它的this
指向是由他的父级上下文决定的arguments
对象*/yield
扩展回答:
this
指向是动态的,默认绑定为当前执行上下文。只有两种情况,要么指向调用该函数的对象,要么指向window
。
window
const obj = {
fn(){
console.log(this)
}
}
const f = obj.fn
f() // 指向 window
obj.fn() // 指向 obj
箭头函数this
指向父级上下文中的 this
,由于箭头函数本身没有this
,所以它的this
绑定的是fn
函数的this
const obj = {
fn() {
const s = () => {
console.log(this)
}
s()
}
}
const fn = obj.fn
fn() // window
obj.fn() //obj
this
指向的方法有
this
指向,以数组的形式传递参数this
指向,以单独的方式传递参数this
指向,返回一个被绑定this
的fn
,以单独的方式传递参数call
Function.prototype.myCall = function (obj = window, ...args) {
const key = Symbol()
obj[key] = this
obj[key](...args)
delete obj[key]
}
apply
Function.prototype.myApply = function (obj = window, args = []) {
const key = Symbol()
obj[key] = this
obj[key](...args)
delete obj[key]
}
bind
Function.prototype.myBind = function (obj = window, ...args) {
const fn = this
return function () {
fn.call(obj, ...args)
}
}
答题思路:
扩展问题
开始回答:
不流畅、不稳定、不安全
的问题。现代浏览器包括一个浏览器主进程
,主要负责页面的显示、用户交互、子进程管理、存储等操作;一个GPU进程
负责绘制UI页面和css3D效果,一个网络进程
主要负责网络资源的加载和请求,多个渲染进程
主要作用是将js,css,html转变为一个可视的页面,为什么是多个呢,因为谷歌浏览器默认为每一个页面开启一个渲染进程,出去安全考虑,渲染进程运行在沙箱模式下,因此js不可以直接访问操作系统资源;多个插件进程
,用来运行浏览器的插件,不同的插件执行在不同的进程,防止某个插件出错干扰其他插件和页面。扩展回答
回答思路
浏览器中输入url到页面渲染发生了什么?
一起作答。我就从url开始讲起了。开始回答
资源请求阶段
url
,如果是关键字,浏览器会使用浏览器的默认搜索引擎,合成带搜索关键字的URL
,然后浏览器进程
将该URL
发送到网络进程
。网络进程
会查找本地是否缓存该网站资源,如果缓存中没有找到,那么就进入浏览器的请求流程,发送该URL
到DNS服务器
获取域名的服务器IP地址,之后开始与服务器建立TCP
连接,而这里就要经历TCP
的三次握手(详细内容放在TCP模块讲
)成功建立连接,如果是https
站点还要进行安全验证(放在http模块讲
),之后浏览器构成请求的请求行请求头等信息,并携带本地存储的跟该域名相关的Cookie
添加到请求头中,一起发送到服务器,服务器接收信息后,将响应数据,包括响应行、响应头、响应体发送给网络进程,等网络进程接收到响应头信息后,发起提交文档
信息,通知渲染进程
和网络进程
建立管道
准备通信。同时开始解析响应头,判断是否需要重定向
如果状态码为301、302
,通过Content-Type
判断此次响应体的文件格式,来决定如何处理响应体资源,是下载资源,还是显示图片,还是显示页面。等渲染进程拿到响应体之后,渲染进程返回确认提交
的消息,浏览器进程接收到之后,会更新浏览器界面状态。之后准备渲染页面。渲染阶段
渲染进程接收到响应体后,
开始进入渲染流程,首先HTML解析器
将html解析为浏览器可以识别的DOM树
,然后到了样式计算
阶段样式计算
分为三步
styleSheets
。CSS继承
和层叠
的两个规则,层叠是合并来自多个源的属性的算法
。布局阶段
,我们通过DOM树
和styleSheets
来合成布局树
,来确定DOM元素的几何位置,首先先遍历DOM树的可见元素,将其添加到布局树上,像head
这种标签,还有display:none
的元素不会出现在布局树中,然后根据styleSheets
计算出DOM的几何位置。一切都准备好之后,进入分层阶段。分层阶段
,分层其实就是创建不同的图层,虽然浏览器页面看起来是二维的页面,但是实际上是三维的,根据z-indexing
属性做z轴排序,生成一颗对应的图层树,但是并不是每个DOM都会生成一个单独的图层,只有具有层叠样式属性的DOM才会创建一个图层,而没有的属性附属于它的父节点的图层,所有图层重叠起来,也就构成了我们的浏览器页面。图层绘制
阶段,当所有准备工作做好之后,我们就可以开始绘制,渲染进程将我们的绘制过程拆分成一个个小的绘制指令
,然后按照顺序组成一个待绘制列表
,然后交给合成线程实现真正的绘制
。合成线程
,合成线程拿到绘制指令后,不会直接开始绘制,由于我们的页面可能很大,但是视口
是有限的,因此合成线程
会将图层划分为图块
,然后按照视口附近的图块
优先生成位图
,而这个生成过程是在栅格化
线程池中进行,将我们的图块栅格化处理
,就是将图块转换为位图
栅格化
,栅格化过程都会使用GPU来加速生成,将生成的位图保存在GPU内存中合成显示
,一旦所有图块都被栅格化后,合成线程会生成一个绘制指令提交给浏览器进程,浏览器接收到指令后根据该绘制指令将内容绘制到显存
中,最后将显存显示到屏幕上。回答思路
开始回答
新生代
: 我们先来说说新生代的垃圾回收。v8将新生代的内存分为两个部分,from
区域和to
区域,我们的数据统一保存在from
区域,而to
区域是空闲区域,因为新生代中大部分都是生命周期短的对象,所以在执行垃圾回收时我们将还存活的对像存放到to
区域更高效,然后将from
中的内存全部回收,之后将from
和to
区域调换,就可以继续完成垃圾回收,同时,为了防止新生代内存溢出,v8提供了一套晋升机制,将经历两次存在的内存存放到老生代区域。老生代
:采用标记清除法,因为老生代大多是生命周期长的对象,所以我们去处理生命周期短的更高效,通过遍历调用栈,查看内存中的哪些对象没有相应的变量引用了,那就将其标记,之后将已经标记的内存释放,也就完成了垃圾回收,但是这样一来导致内存中出现很多不连续的片段,与是又采用标记整理,将所有存活的对象都移动到一端,然后清理掉端边界以外的内存,进而实现垃圾回收。但是还有一个问题,js是运行在渲染进程的主线程上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿,新生代内存较小影响不大,但是老生代的内存空间较大,可能在回收过程中造成页面卡顿,为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 执行交替进行,直到标记阶段完成。回答思路
解释型语言
,再运行之前,需要先经过解释器处理,因为计算机是不能直接读懂开发人员所写的代码的,所以再执行之前都要先翻译
成计算机能懂的语言,无论编译型语言
还是解释型语言
都是如此。首先,解释器
需要将我们写的源代码生成AST语法树
和执行上下文
, 先对源代码进行分词
,进行词法分析
就是将代码拆分成token
,token
就是指语法上不可再分的最小单位字符或者字符串,之后进行解析
做语法分析
生成AST
,有了AST
和执行上下文
之后,解释器根据AST生成字节码
,然后执行。编译器
直接将AST
转为机器码
,因为机器码的执行效率是非常高的,所以js运行也很快,但是有一个缺点,机器码占用的内存太大,为了解决占用内存的问题,改为了字节码
,字节码可以远远减少系统内存的使用。JIT即时编译
技术,在执行字节码同时,收集代码信息,一段代码执行次数多了之后会把该段代码标记为热点代码
,将该段代码的字节码转变为机器码
并且保存,当下次再执行时,直接执行机器码,大大的提升了执行效率。回答思路
开始回答
DOM方面
来说,同源策略限制了来自不同源的 JavaScript 脚本对当前 DOM 对象读和写的操作。如果说有两个页面属于同一个源下A页面和B页面,我们通过A页面打开B页面,那么这种情况,我们就可以在B页面中操作A页面的DOM,甚至在B页面中将A页面的dom全部删除。我们在B页面中可以通过window.opener
来操作A页面的window
对象。而如果A页面打开的B页面是属于不同网站的,那么这种操作就是禁止的。数据方面
来说,同源策略限制了不同源的站点访问当前站点的Cookie、LocalStorage
等数据。网络层面
来说,浏览器的同源策略禁止请求其他站点返回的数据资源,意味着禁止跨源资源共享。DOM
:我们了解到,由于同源策略,不同源的js脚本无法对当前的DOM对象进行操作,但是浏览器使用一个机制来为我们提供方便,postMessage
接口。window
的message
,来限制该窗口可以接收哪些源的数据进行操作,而在B页面中,通过opener
拿到A页面的window,然后使用opener.postMessage
来向A页面发送数据,同时传入一个URL来限制哪些源可以接收该窗口传递的数据,通过两个窗口的配合,完美的解决浏览器的同源机制带来的限制。// A页面
window.open('http://localhost:3000/h', '_blank', '')
window.addEventListener('message', event => {
if (event.origin == 'http://localhost:3000') {
console.log(event.data)
}
})
// B页面
const A = window.opener
const script = `
const btn = document.createElement('button')
btn.innerText = '哈哈'
document.body.append(btn)
`
A.postMessage(script,'http://localhost:3001')
数据
: 通过postMessage
我们也解决了数据传输问题。网络资源请求
:
Access-Control-Allow-Origin
响应头来限制解决跨域访问的问题,当发生跨域请求时,我们的请求会正常的发送到服务器端并且拿回数据,只不过浏览器出于安全考虑禁止用户访问该数据,因此在发生一些复杂请求时,会先发起一个预检请求options
,来询问服务器哪些源可以访问该资源,服务器通过返回携带Access-Control-Allow-Origin
的响应头来告诉浏览器,该源可以访问该资源,进而解决跨域请求。JSONP
来解决跨域请求问题:其实该方法就是利用script和img
标签的特性,因为浏览器通过标签加载任何源的脚本资源和图片资源是没有安全限制的,因此利用该特性可以解决跨域问题,但是只能发起Get
请求。我用script
脚本举例:client.js
origin: http://localhost:3000/
const getData = function(data){
console.log(data)
}
const script = document.createElement('script')
script.src = 'http://localhost:3001/get?name=getData'
node.js
origin: http://localhost:3001/get
const name = req.url.split('?')[1].split('=')[1]
const data = '' // 假如是数据库拿出的数据
res.setHeaders('Content-Type', 'application/javascript')
res.end(`name(${data})`)
nginx
反向代理到本地,当我们再次发起请求时,不会直接请求跨域服务器,而是先请求代理服务器,然后代理服务器发送请求到跨域的服务器,拿回数据后,再由代理服务器返回给本地。使用反向代理是解决跨域请求的最好方式
。我们先来说说什么是XSS攻击
,其实根据其名称跨站脚本攻击,我们就可以知道,是通过执行脚本来做一些恶意操作,比如获取用户的cookie
等信息,监听用户行为
,或者通过修改DOM假冒登录窗口获取用户密码
等。那说了半天浏览器安全策略,那这还是不安全呀,其实这并不是浏览器不安全,而实我们写代码时留下的漏洞。我们来看看恶意脚本是怎么注入的。
储存型攻击
:这种攻击是将恶意执行的脚本保存到用户的数据库中的,比方说有一个网站有一个留言功能,当用户留言后不做任何处理把留言信息保存到数据库,然后将留言展示到网页上,这就留下了漏洞,我们可以通过留言一个script
脚本,做任何操作,一旦打开留言面板访问到我们留言的人,都会执行这段恶意脚本,这就是储存型攻击
反射型攻击
:反射型攻击是利用url
来加入恶意脚本,比方说有一个页面有一个搜索功能,他的请求接口为这样http://xxx.com/queryName='石头山'
,服务器响应搜索不到后会在页面中显示找不到石头山
相关结果,因此这也留下了漏洞,当我把请求接口改成这样http://xxx.com/queryName=