😱前言
已经进入秋招了,作者本人也在着急的准备面试,凭着之前的面试经验,我总结了一份完整的面试题,其中分析了答题思路和答案,还有一些手写题,希望可以帮到所有参加面试的小伙伴!!!
可能有些地方不严谨,写的也比较急,所以希望大家可以评论留言批评指正!!!
写作不易,我已经毫无保留分享自己的毕生所学
,那你是不是应该毫不吝啬的给个赞吧!!!
持续更新中,还没有总结完。。。
😊(冲!!!)ES6 篇
😃1. 请你说下为什么ES6中引入了 const,let
回答思路:
- 没有
const、let
之前有什么弊端 const、let
解决了什么问题
扩展问题:
- let、const 存储方式
开始回答
- 在没有
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
之后,我们引入了新的概念,块级作用域,很好的解决了变量提升和无块级作用域带来的变量冲突的问题。那是如何解决的呢?我们引入新的问题。
扩展回答:
- 在js代码执行之前会经历编译阶段,而在编译阶段会确定当前执行上下文所需要的信息,
变量环境
,词法环境
,this
,作用域链
,其中,变量环境用来存放var
和function
定义的变量,并且初始化值为undefined
和地址值
,而在执行前变量已经定义好了,也就是所谓的变量提升。而const
和let
定义的变量存储在词法环境中,在词法环境中维护一个小型栈,按照块的形式,将每一个块级变量压入栈顶,执行后弹出,并且不会初始化,因此在一个块级作用域中提前访问变量会报错,也就是所谓的暂时性死区
。
变量查找顺序
:先从词法环境中从栈顶到栈底,然后到变量环境中查找,之后沿着作用域链查找。
相关题型:
- 给代码看输出
注意点:
var
和function
变量提升,并且function
优先于var
,const 、let
块级作用域中的暂时性死区。
😃2. 请你说下Promise?
回答思路
:
- 首先回答
Promise
是什么? Promise
为什么出现,没出现前有什么问题,出现后解决了什么问题?
拓展问题:
Promise
执行机制,微任务宏任务Promise
的缺陷,async/await
语法糖的出现
开始回答:
- 首先,从字面意思来理解,
Promise
期约, 是一个用来执行将来要发生的或者即将要发生的事件的对象,它自身有三种状态,pending
,fulfilled
,rejected
,同一时刻只能有一种状态,一旦状态改变,则不能再更改,也不可逆。通过构造函数的方式使用,传入一个执行器回调函数,以此来决定Promise
的执行状态。之后通过then
方法来决定执行什么样的操作,并且then
方法返回一个值会自动包装为Promise
从而实现对象的链式调用。(之后可以介绍下其他方法race, all, resolve, reject
) - 在
Promise
出现之前,我们在书写异步代码时,通过回调的方式来拿到异步返回的值,代码抒写逻辑不连续,除此之外,如果下一次的执行需要依赖上一次的执行结果,会导致代码嵌套,如果嵌套次数太多,就造成了新的问题,回调地狱,使得代码难看难以维护,Promise
的出现,通过then
方法,解决了函数嵌套的问题,then
方法的链式调用,也将回调地狱的问题迎刃而解。
上面回答,只是简单的介绍了下
Promise
的用法,没有深入的去讲Promise
,有能力的话我们应该扩展去讲它周边的知识
扩展:
1. Promise执行机制
Promise
除了解决异步回调的问题之外,还有一个特性,就是它的执行时机,微任务
,和微任务
对应的还有宏任务
,说到这我们不得不讲下浏览器的异步实现机制,为了解决 js 单线程(可能涉及问题:js为什么是单线程?
)同步执行的效率问题,我们引入了异步执行机制,而异步执行依赖于 v8引擎 中的消息队列和事件循环
机制,也就是js在执行过程中遇到异步任务时,不会立即执行,而是将该事件存放到消息队列当中,而消息队列中存放的任务也就是我们所谓的宏任务
,然后继续执行js代码,当所有同步代码执行完毕之后,通过事件循环
,也就是一个循环代码for或者while
来不停的从消息队列
中取出一个事件来执行,这就是异步任务的执行机制。消息队列有一个缺点,就是所有任务都是按顺序执行,因此,如果我们需要执行一些时间粒度小的任务,比如监听DOM的变化去做相应的业务逻辑的时候,再使用消息队列的话会造成严重的效率问题,因为消息队列是先进先出的结构,在该事件
添加到消息队列尾部时,消息队列内部可能已经有很多任务了,所以宏任务的执行效率不高,这是就引入了新的概念微任务
,在宏任务中包含一个微任务队列
,来存放需要执行效率高的事件,在每次宏任务执行完之后,不会着急执行下一个宏任务,而是将该宏任务中的微任务
执行完毕,再去执行下一个宏任务
。
- js
- 添加微任务:Promise, MutationObserver
- 添加宏任务:script,定时器,I/O,交互事件
- node
- 添加微任务:: nextTick
2. async/await 语法糖
Promise
虽然可以链式调用来解决回调地狱,但是还是不够完美,依然是回调的方式书写代码,为了更加符合代码逻辑,推出了async/await
语法糖,来用同步的方式书写异步代码。使用 async
修饰的函数代表异步函数,会自动包装返回一个Promise
类型的对象,await
关键字用来暂停执行逻辑。通过try/cache
来捕获异常,介绍完后,我们来说下它的执行原理。
执行原理:(自执行的生成器 + 协程)
- 生成器(Generator):
yield/*
,通过调用*
函数来返回一个对象,其中有next,return,trow
方法。next 方法用来恢复*函数
的执行,yield
来暂停*函数
的执行。详细使用大家请自己查阅,这里只做大致介绍。 - 协程:协程是一种比线程更加轻量级的存在,可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程,协程不是被操作系统内核所管理,而完全是由程序所控制。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
通过*
函数来创建一个协程,但是该协程不会立刻执行,当调用next
方法时,将控制权交给该协程,开始执行,遇到yield
关键字后,停止协程,并将控制权返回给父协程,并把 yield 后的值传递给父协程,父协程通过next(value)
传值给该子协程。这就是生成器可以实现暂停执行的原理。
Promise 相关手写题:
说明: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吗?
回答思路:
Set和Map
的特点,有什么API- 有什么不足之处,引入了
WeakMap 和 WeakSet
扩展问题
Map和object
比较,性能对比- 垃圾回收机制(放在浏览器模块讲)
开始回答
- Set 是一种类数组的数据结构,它有一个重要的特点就是Set中不会重复的存储一个值。Map是一种类似object的数据结构,通过键值对的方式进行存储,但是Map的键值可以是任意类型。Set和Map都是可迭代的。 Api的的介绍就不多说了,自己可以查阅一下。
- Set和Map有一个问题就是对引用类型的数据是强引用,如果不主动使用
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
扩展回答
- Map和object 对比:
- object 中的数据是有数序的,根据键值,非负数按顺序存储,字符串按插入顺序存储,而Map是根据插入顺序存储
- object 会对键值做转换,将数组转为字符串类型,而Map中不会,
‘0’
和0
是不一样的键值 - object 是不可迭代的,而Map是可迭代的 性能对比:Map是一种散列表的数据结构,所以其取值和插入的时间复杂度是O(1),因此频繁的取值和插入使用Map的性能更高。
散列表:
是能够通过给定的关键字的值直接访问到具体对应的值的一个数据结构,保存在数组中,而数组是一块连续内存,可以直接根据地址值访问,因此查询速度更快,插入更快。当我们使用Map
保存数据时,Map
会先将我们传入的key
经过哈希函数,生成一个哈希值(整数),而这个哈希值在内存中映射着我们传入的value
值,因此他的访问速度很快。而Set
是一种key和value相同的散列表
,因此不可以重复。
根据能力继续扩展(自己查找)
- 解决哈希冲突
- hash算法:md5
😃四. 说下Proxy和Reflect
答题思路:
- 简述
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
指向 - 改变this指向的方法以及他们的区别
开始回答
-
箭头函数类似于匿名函数,没有名字,并且不需要使用
function
来定义,是一种很简洁的定义函数的方法,如果箭头函数只有一个参数的话可以省略小括号,如果函数体只有一个return
语句的话,可以省略return和花括号
,因此它使用起来是更加方便的,很适合来简化回调函数。const fn = args => args
-
使用起来虽然方便,但是同普通函数来比,少去了很多特性。
- 箭头函数不能当作构造函数使用,由于箭头函数的轻量,它本身是没有原型对象
prototype
这个属性的,而构造函数的作用就是创建一个对象,并将该对象的原型指向函数的原型对象,因此它不可以作为构造函数使用。 - 箭头函数 中没有
this
,在箭头函数中使用this
时,它的this
指向是由他的父级上下文决定的 - 箭头函数中没有
arguments
对象 - 箭头函数不可以使用
*/yield
- 箭头函数不能当作构造函数使用,由于箭头函数的轻量,它本身是没有原型对象
扩展回答:
- 普通函数的
this
指向是动态的,默认绑定为当前执行上下文。只有两种情况,要么指向调用该函数的对象,要么指向window
。- 普通调用:this 指向
window
- 使用对象调用:this 指向 对象
- 普通调用:this 指向
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
指向的方法有- apply:改变
this
指向,以数组的形式传递参数 - call:改变
this
指向,以单独的方式传递参数 - bind:改变
this
指向,返回一个被绑定this
的fn
,以单独的方式传递参数
- apply:改变
相关手写题:
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)
}
}
😘(加油!!!坚持!!!)浏览器篇
😊一. 请你说下浏览器的发展(单进程浏览器和多进程浏览器)。
答题思路:
- 早期浏览器的缺点
- 现代浏览器的优势
扩展问题
- 什么是线程、进程
- 进程间的通信方式
开始回答:
- 单进程浏览器:早期,我们的浏览器是采用单进程架构的,意思就是,所有的任务都在一个进程中执行,网络请求、js运行环境、渲染引擎、页面、垃圾回收、用户交互、浏览器插件等。多个线程在一个独立的进程中工作,共享所有资源,因此浏览器插件可以访问操作系统上的任意资源,所以单进程浏览器是不安全的;由于js和页面渲染等代码都是执行在一个线程上的,如果我们打开多个页面,多个页面的js和渲染机制抢夺一个线程,因此单进程浏览器是不流畅的;再加上如果一个线程出错,会导致整个进程奔溃,进而导致整个浏览器退出无法工作,造成我们打开多个页面,有一个页面出错导致所有页面和浏览器一起关闭,所以单进程的浏览器是不稳定。\
- 多进程浏览器:
现代浏览器采用多进程架构,完美的解决了单进程
不流畅、不稳定、不安全
的问题。现代浏览器包括一个浏览器主进程
,主要负责页面的显示、用户交互、子进程管理、存储等操作;一个GPU进程
负责绘制UI页面和css3D效果,一个网络进程
主要负责网络资源的加载和请求,多个渲染进程
主要作用是将js,css,html转变为一个可视的页面,为什么是多个呢,因为谷歌浏览器默认为每一个页面开启一个渲染进程,出去安全考虑,渲染进程运行在沙箱模式下,因此js不可以直接访问操作系统资源;多个插件进程
,用来运行浏览器的插件,不同的插件执行在不同的进程,防止某个插件出错干扰其他插件和页面。
扩展回答
- 进程:进程就是一个程序的运行实例,是资源分配的最小单位,可以理解为是为该程序运行专门创建的一块内存,用来存放程序运行的代码,运行的数据,还有一个执行任务的主线程。进程之间的资源是相互独立的,当一个进程关闭后,操作系统会回收进程所咋用的全部内存。
线程:线程是依附于进程的,不能独立存在,是cpu调度的最小单位,可以理解为线程是在进程上工作的,不同线程可以并行工作,不同线程之间共享所在进程的资源,一个线程出错会导致整个进程的奔溃。
😊二. 请你说下浏览器的渲染过程
回答思路
- 没有什么思路,该问题也可以同
浏览器中输入url到页面渲染发生了什么?
一起作答。我就从url开始讲起了。
开始回答
资源请求阶段
- 首先,输入内容到地址栏之后,会判断输入的是关键字还是完整的
url
,如果是关键字,浏览器会使用浏览器的默认搜索引擎,合成带搜索关键字的URL
,然后浏览器进程
将该URL
发送到网络进程
。 网络进程
会查找本地是否缓存该网站资源,如果缓存中没有找到,那么就进入浏览器的请求流程,发送该URL
到DNS服务器
获取域名的服务器IP地址,之后开始与服务器建立TCP
连接,而这里就要经历TCP
的三次握手(详细内容放在TCP模块讲
)成功建立连接,如果是https
站点还要进行安全验证(放在http模块讲
),之后浏览器构成请求的请求行请求头等信息,并携带本地存储的跟该域名相关的Cookie
添加到请求头中,一起发送到服务器,服务器接收信息后,将响应数据,包括响应行、响应头、响应体发送给网络进程,等网络进程接收到响应头信息后,发起提交文档
信息,通知渲染进程
和网络进程
建立管道
准备通信。同时开始解析响应头,判断是否需要重定向
如果状态码为301、302
,通过Content-Type
判断此次响应体的文件格式,来决定如何处理响应体资源,是下载资源,还是显示图片,还是显示页面。等渲染进程拿到响应体之后,渲染进程返回确认提交
的消息,浏览器进程接收到之后,会更新浏览器界面状态。之后准备渲染页面。
渲染阶段
渲染进程接收到响应体后,
开始进入渲染流程,首先HTML解析器
将html解析为浏览器可以识别的DOM树
,然后到了样式计算
阶段样式计算
分为三步- 将css转换为浏览器可以识别的结构
styleSheets
。 - 将样式表中的属性值标准化
- 计算处DOM树中每个节点的样式,计算过程中遵循
CSS继承
和层叠
的两个规则,层叠是合并来自多个源的属性的算法
。
之后进入布局阶段
- 将css转换为浏览器可以识别的结构
布局阶段
,我们通过DOM树
和styleSheets
来合成布局树
,来确定DOM元素的几何位置,首先先遍历DOM树的可见元素,将其添加到布局树上,像head
这种标签,还有display:none
的元素不会出现在布局树中,然后根据styleSheets
计算出DOM的几何位置。一切都准备好之后,进入分层阶段。分层阶段
,分层其实就是创建不同的图层,虽然浏览器页面看起来是二维的页面,但是实际上是三维的,根据z-indexing
属性做z轴排序,生成一颗对应的图层树,但是并不是每个DOM都会生成一个单独的图层,只有具有层叠样式属性的DOM才会创建一个图层,而没有的属性附属于它的父节点的图层,所有图层重叠起来,也就构成了我们的浏览器页面。图层绘制
阶段,当所有准备工作做好之后,我们就可以开始绘制,渲染进程将我们的绘制过程拆分成一个个小的绘制指令
,然后按照顺序组成一个待绘制列表
,然后交给合成线程实现真正的绘制
。合成线程
,合成线程拿到绘制指令后,不会直接开始绘制,由于我们的页面可能很大,但是视口
是有限的,因此合成线程
会将图层划分为图块
,然后按照视口附近的图块
优先生成位图
,而这个生成过程是在栅格化
线程池中进行,将我们的图块栅格化处理
,就是将图块转换为位图
栅格化
,栅格化过程都会使用GPU来加速生成,将生成的位图保存在GPU内存中合成显示
,一旦所有图块都被栅格化后,合成线程会生成一个绘制指令提交给浏览器进程,浏览器接收到指令后根据该绘制指令将内容绘制到显存
中,最后将显存显示到屏幕上。
😊三. 请你说下浏览器的垃圾回收机制
回答思路
- 栈内存的回收
- 堆内存的回收
开始回答
- 我们都知道,js 在运行中会维护一个执行栈,每当执行一个函数,就会往执行栈中压入一个执行上下文,用来保存一些当前执行函数的数据,而函数中的原始变量会保存在栈中,而引用类型数据是保存在堆中的,变量中保存的是堆中的地址。栈顶就是维护着我们当前正在执行的函数,当函数执行完之后,有一个出栈操作,出栈也就意味着销毁当前函数的执行上下文,也就回收了该函数保存在栈中的所有内存。
- v8 会把堆内存分为两个部分,一部分用来存放生命周期短的对象,也就是新生代,另一部分用来存放生命周期长的对象,也就是老生代。老生代的内存比新生代大很多,老生代有1400MB,新生代有32MB。
新生代
: 我们先来说说新生代的垃圾回收。v8将新生代的内存分为两个部分,from
区域和to
区域,我们的数据统一保存在from
区域,而to
区域是空闲区域,因为新生代中大部分都是生命周期短的对象,所以在执行垃圾回收时我们将还存活的对像存放到to
区域更高效,然后将from
中的内存全部回收,之后将from
和to
区域调换,就可以继续完成垃圾回收,同时,为了防止新生代内存溢出,v8提供了一套晋升机制,将经历两次存在的内存存放到老生代区域。老生代
:采用标记清除法,因为老生代大多是生命周期长的对象,所以我们去处理生命周期短的更高效,通过遍历调用栈,查看内存中的哪些对象没有相应的变量引用了,那就将其标记,之后将已经标记的内存释放,也就完成了垃圾回收,但是这样一来导致内存中出现很多不连续的片段,与是又采用标记整理,将所有存活的对象都移动到一端,然后清理掉端边界以外的内存,进而实现垃圾回收。但是还有一个问题,js是运行在渲染进程的主线程上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿,新生代内存较小影响不大,但是老生代的内存空间较大,可能在回收过程中造成页面卡顿,为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 执行交替进行,直到标记阶段完成。
😊四.浏览器是如何执行一段js代码的?
回答思路
- 现在执行:将代码转为字节码
- 过去执行:将代码转为机器码,又什么不足
- 执行代码中的优化 开始回答
- js 本身是
解释型语言
,再运行之前,需要先经过解释器处理,因为计算机是不能直接读懂开发人员所写的代码的,所以再执行之前都要先翻译
成计算机能懂的语言,无论编译型语言
还是解释型语言
都是如此。首先,解释器
需要将我们写的源代码生成AST语法树
和执行上下文
, 先对源代码进行分词
,进行词法分析
就是将代码拆分成token
,token
就是指语法上不可再分的最小单位字符或者字符串,之后进行解析
做语法分析
生成AST
,有了AST
和执行上下文
之后,解释器根据AST生成字节码
,然后执行。 - 在过去,V8引擎中并没有字节码,而是通过
编译器
直接将AST
转为机器码
,因为机器码的执行效率是非常高的,所以js运行也很快,但是有一个缺点,机器码占用的内存太大,为了解决占用内存的问题,改为了字节码
,字节码可以远远减少系统内存的使用。 - 生成字节码之后,解释器开始逐条解释执行,在执行过程中,会对代码进行优化,就是
JIT即时编译
技术,在执行字节码同时,收集代码信息,一段代码执行次数多了之后会把该段代码标记为热点代码
,将该段代码的字节码转变为机器码
并且保存,当下次再执行时,直接执行机器码,大大的提升了执行效率。
😊五.请你说下浏览器安全方面的知识。
回答思路
- 浏览器的同源策略以及解决方案
- XSS跨站脚本攻击
- CSRF攻击
- 安全沙箱
- https 数据安全传输
开始回答
1. 浏览器同源策略
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
接口。
从下边代码中可以看到,通过A页面打开了一个新的页面B,同时通过监听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
反向代理到本地,当我们再次发起请求时,不会直接请求跨域服务器,而是先请求代理服务器,然后代理服务器发送请求到跨域的服务器,拿回数据后,再由代理服务器返回给本地。使用反向代理是解决跨域请求的最好方式
。
2. XSS 跨站脚本攻击
我们先来说说什么是XSS攻击
,其实根据其名称跨站脚本攻击,我们就可以知道,是通过执行脚本来做一些恶意操作,比如获取用户的cookie
等信息,监听用户行为
,或者通过修改DOM假冒登录窗口获取用户密码
等。那说了半天浏览器安全策略,那这还是不安全呀,其实这并不是浏览器不安全,而实我们写代码时留下的漏洞。我们来看看恶意脚本是怎么注入的。
储存型攻击
:这种攻击是将恶意执行的脚本保存到用户的数据库中的,比方说有一个网站有一个留言功能,当用户留言后不做任何处理把留言信息保存到数据库,然后将留言展示到网页上,这就留下了漏洞,我们可以通过留言一个script
脚本,做任何操作,一旦打开留言面板访问到我们留言的人,都会执行这段恶意脚本,这就是储存型攻击
反射型攻击
:反射型攻击是利用url
来加入恶意脚本,比方说有一个页面有一个搜索功能,他的请求接口为这样http://xxx.com/queryName='石头山'
,服务器响应搜索不到后会在页面中显示找不到石头山
相关结果,因此这也留下了漏洞,当我把请求接口改成这样http://xxx.com/queryName=<script src="我的网站"><script>
,然后在用户的搜索界面诱导他点击发起这个请求,那么就会执行这段恶意代码进行任意攻击。基于 DOM 的 XSS 攻击
: 其他两种攻击都是借助服务器来实现恶意脚本注入,而DOM型攻击
是属于前端漏洞,可以通过网络劫持,在页面传输过程中修改 HTML 页面的内容,进而注入恶意代码。
如何阻止XSS攻击?
过滤或者转码
:因为反射型攻击和存储型攻击都会经过服务器,因此我们可以在服务器端对用户输入的内容进行过滤,将有script
脚本的字段删除,或者将<、>、/
等特殊符号进行转码防止脚本在客户端执行。CSP
:是浏览器提供的有效防止XSS攻击
和数据注入
的内容安全策略,我们可以通过服务器响应头中添加Content-Security-Policy
字段,或者meta
标签来配置,该站点资源允许从哪些域名加载并执行。可以自行去MDN
官网详细了解。httpOnly
:可以在服务器限制cookie
的访问防止,通过httpOnly
来限制cookie
不可以在浏览器端通过js访问,以此来防止恶意代码偷窃cookie
信息
3. CSRF攻击
CSRF
英文全称是 Cross-site request forgery,所以又称为跨站请求伪造
,CSRF
的本质就是诱导用户去发起某些请求,利用用户的登录状态,帮助
用户发起某些请求,比如转账等操作。
如何阻止CSRF攻击?
cookie
:因为http
请求是没有状态的,所以cookie
是维护服务器和浏览器交互的一个重要数据,因此我们可以通过cookie
本身的属性SameSite
,来有效防止一些冒牌的请求
.- Lax: 携带
Lax
的Cookie
,没有Strict
那么严格,允许get请求、a 标签
在跨域情况下携带cookie - Strict: 携带
Strict
的Cookie
是十分严格的,只有在本站内允许携带cookie,任何跨域形式的请求都不允许携带cookie
。
- Lax: 携带
虽然Strict
是绝对安全的,但是有些时候使用它是十分影响用户体验的,比方式某个网站通过cookie
保存用户登录状态,用户登录之后打开其他网站,而其他网站有跳转该网站的链接,因为Strict
禁止携带cookie,因此用户会失去登录状态而导致重新登录,因此我们大多时候使用Lax
。
- 验证请求站点的来源:我们在服务器端验证请求的来源,由于 CSRF 攻击大多来自于第三方站点,因此服务器可以禁止来自第三方站点的请求。我们可以通过
请求头
中的orign
字段查看请求的来源,以此来决定是否发起响应。 CSRF Token
:这里提到的Token
跟我们用来保存用户状态的Token
不同,这个就是一个用来识别是安全请求的一个标志,后端可以同前端协商携带一个安全字段,每次发起post
请求都将该字段携带,后端通过验证是否存在该字段来决定是否完成响应。由于伪造请求的人不知道我们的该字段,因此他的请求中不会携带该字段,也就不会请求成功。
4. 安全沙箱
通过前边浏览器的进程中学习到,浏览器可以通过网络进程下载和接收任意资源,而这些资源的执行都是在渲染进程
中执行,如果这些资源中有恶意程序对浏览器发起攻击,就会给用户带来极大的安全隐患,因此我们直接从根上扼制这种行为,我们将渲染进程
独立起来,让他与操作系统隔离,因此恶意程序就访问不到除渲染进程之外的任意权限,而将渲染进程和操作系统隔离的这道墙就是安全沙箱
。浏览器中的安全沙箱是利用操作系统提供的安全技术,让渲染进程在执行过程中无法访问或者修改操作系统中的数据,在渲染进程需要访问系统资源的时候,需要通过浏览器内核来实现,然后将访问的结果通过 IPC 转发给渲染进程.安全沙箱最小的保护单位是进程。因为单进程浏览器需要频繁访问或者修改操作系统的数据,所以单进程浏览器是无法被安全沙箱保护的,而现代浏览器采用的多进程架构使得安全沙箱可以发挥作用。
5. https
http 协议中所有数据传输都是采用明文传输,因此传输内容很容易被黑客劫持,因此是十分不安全的。所以提出了一种更安全的协议https
,https
是建立在http
的基础上的,因此它同http
完全一致,只不过是在http应用层
和Tcp传输层
直接又加了新的一层安全层TLS/SSL
,在建立tcp连接之后,做好加密工作,然后再发起http请求。我们来讲讲https
的发展历程。
- https 加密第一版 对称加密:
- 在客户端与服务器建立TCP连接之后,开始验证加密方式,客户端发送浏览器所支持的加密方式,也就是加密套件列表,除此之外,浏览器还要携带一个随机数client-Random
- 服务器接收到之后,选择一种加密方式,然后还会生成一个随机数 service-random,并将 service-random 和加密套件列表返回给浏览器。
- 最后浏览器和服务器分别返回确认消息。这样浏览器端和服务器端都有相同的 client-random 和 service-random 了,然后它们再使用相同的方法将 client-random 和 service-random 混合起来生成一个密钥 master secret,有了密钥 master secret 和加密套件之后,双方就可以进行数据的加密传输了。
缺点:由于clien-random 和 server-random 和 加密套件列表是明文的,所以这种方式仍然不安全,黑客仍然可以窃取用户和浏览器传递的http
数据。
- https 第二版 非对称加密
- 首先浏览器还是发送加密套件列表给服务器。
- 然后服务器会选择一个加密套件, 同时,浏览器维护了一个私钥,公钥,将公钥和选择的加密套件一起发送给客户端
- 最后就是浏览器和服务器返回确认消息
- 这样浏览器端就有了服务器的公钥,在浏览器端向服务器端发送数据时,就可以使用该公钥来加密数据。由于公钥加密的数据只有私钥才能解密,所以即便黑客截获了数据和公钥,他也是无法使用公钥来解密数据的。
缺点:无法保证服务器发送给浏览器的数据安全,黑客任然可以窃取服务器响应给用户的数据。虽然浏览器端可以使用公钥来加密,而黑客没有私钥,因此数据是安全的,但是服务器端只能采用私钥来加密,私钥加密只有公钥能解密,但黑客也是可以获取得到公钥的,这样就不能保证服务器端数据的安全了。
- https 第三版 非对称加密和对称加密搭配使用
思想:在传输数据阶段依然使用对称加密,但是对称加密的密钥我们采用非对称加密来传输
- 首先浏览器向服务器发送对称加密套件列表、非对称加密套件列表和随机数 client-random;
- 服务器保存随机数 client-random,选择对称加密和非对称加密的套件,然后生成随机数 service-random,向浏览器发送选择的加密套件、service-random 和浏览器的公钥;
- 浏览器保存公钥,并生成随机数 pre-master,然后利用公钥对 pre-master 加密,并向服务器发送加密后的数据;
- 最后服务器拿出自己的私钥,解密出 pre-master 数据,并返回确认消息。
- 到此为止,服务器和浏览器就有了共同的 client-random、service-random 和 pre-master,然后服务器和浏览器会使用这三组随机数生成对称密钥,因为服务器和浏览器使用同一套方法来生成密钥,所以最终生成的密钥也是相同的,之后的数据传输可以利用密钥加密,也可以利用密钥解密,保证了双端数据传输的绝对安全性。
缺点: 虽然使用对称加密和非对称加密的方式,解决了数据在客户端和目标站点传输的安全问题,防止数据在中途被黑客窃取,但是,黑客仍然可以模拟服务器,也就是直接模拟目标网站,而浏览器却无法察觉。这 样完全取得了客户端的操控权,可以轻松获取用户的信息。
- https 第四版 添加数字证书
解决问题:告诉浏览器,该网站就是我要访问的真实网站。
- 浏览器发起请求之后,服务器将提前
申请好的证书发送给浏览器端
- 然后浏览器验证该证书的真实性,首先通过CA 机构在签名时同样的 hash 函数来取得信息A,然后再利用证书中携带的
CA机构(证书办法机构,是国内获得工信部认可的组织)
的 公钥解密签名数据B,通过对比信息,可以确认该证书的可靠性和网站的可靠性,同时验证证书时间等等,但是这个 CA机构 可能比较小众,浏览器不知道该不该信任它,然后浏览器会继续查找给这个 CA 颁发证书的 CA,再以同样的方式验证它上级 CA 的可靠性。通常情况下,操作系统中会内置信任的顶级 CA 的证书信息(包含公钥),如果这个 CA 链中没有找到浏览器内置的顶级的 CA,证书也会被判定非法。 - 证书验证完成后,继续第三版的操作。
通过证书验证,非对称加密和对称加密的方式,不仅保证了数据在传输过程的安全性,也保证了服务器的可靠性。
😊六. 请你说下浏览器中js 和 css 是如何影响页面加载的。
答题思路
- 首先说下浏览器的渲染流程
- js 如何影响 DOM 的构建的,HTML解析器是如何生成DOM的
- css 又是如何影响的
- 优化解决方案 开始回答
- 浏览器的渲染流程我们已经在上文中讲过,不熟悉的可以再返回去看一下,在这不重复了,我们接着往下讲
- 通过渲染流程我们知道,首先需要通过
HTML解析器
将html文件转换成DOM
树,而这个过程是在网络进程
接收到响应体,通过管道发送到渲染进程
之后的,接收到多少文件,HTML解析器
就解析多少文件,而不需要等待所有html加载完成之后再开始。知道这一点后我们来聊聊DOM的生成流程。
-
DOM 树的构建流程
首先通过分词器
将html文件字节流转换为Token
,我们用以下代码举例,生成的Token
就是这样的:
StartTaghtml
->StartTagbody
->StartTagh1
->石头山
->EndTagh1
->StartTagdiv
->点个赞再走
->EndTagdiv
->EndTagdiv
->EngTaghtml
<html>
<body>
<h1>石头山</h1>
<div>点个赞再走</div>
</body>
</html>
然后需要将Token
转为DOM节点并且添加到DOM中。HTML解析器维护了一个Token栈结构
,将生成的Token
按顺序压入到这个栈中,如果压入到栈中的是 StartTag Token
,HTML 解析器会为该 Token
创建一个 DOM 节点
,然后将该节点加入到 DOM 树中,它的父节点就是位于栈顶的这个Token
生成的DOM节点。如果是一个文本Token
,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。如果分词器产生的是EndTag Token
,则查看栈顶是否文对应的Start Token
,如果是就将StartTag Token
从栈顶弹出,表示该dom解析完成。通过该流程,可以将html标签解析成一颗完整的DOM树。注意,html解析器会提前在空的Token栈
中压入StartTagdocument
同时创建一个根为document
的DOM树。了解了DOM的生成过程之后,我们回归主题,说下js如何影响ODM构建的,如果在 html 中内嵌了一个script
标签,html解析器
解析到这是一个StartTag script
时候,渲染进程
知道这是一段执行脚本,并且该脚本可能会修改已经生成的DOM树,因此会停止HTML解析器
的解析工作,而先执行该脚本,执行完之后,在继续执行解析操作。因此,js脚本会阻塞DOM树的生成过程
,如果js是远程资源,还要等待资源加载完成,十分影响用户体验。
-
我们了解到,js的执行会阻塞html的解析,也就是DOM树的生成,因为js有操作DOM树的能力,但是,js同时有操作
styleSheets
的能力,所以浏览器不知道该段脚本是否会操作node.style
属性,所以在js执行前,会等待当前页面的css
文件加载,并且等待css
解析生成CSSOM
,然后去执行js,再继续dom树
的构建,严重影响了首屏加载速度。 -
优化方案:
- 通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了。
- 但并不是所有的场合都适合内联,那么还可以尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件。
- 还可以将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 async 或者 defer。
async
: 会在js脚本加载完成后立即执行defer
: 会在html解析完成后,DOMContentLoaded
事件之前执行
😊七. 请你说说浏览器缓存
答题思路
- 什么是缓存,有什么效果
- 200和304区别,强缓存、弱缓存
- 如何设置强缓存、弱缓存
开始回答
-
浏览器缓存,是为了优化页面的二次访问速度,减少服务器请求次数的机制,通过在http响应头或者请求头中携带相关信息,告诉浏览器应该缓存哪些内容,从而下次请求时直接从本地获取,减少网络请求带来的延迟。
-
缓存分为两种,
强缓存
和弱缓存
强缓存
:将资源存在到本地,下次请求时直接从本地拿去,不需要发起请求,响应码为 200弱缓存
: 将资源缓存到本地,但是请求时仍然会发起请求,但是响应只有响应头,没有响应体,响应码为304,告诉浏览器缓存没有过期,可以从本地读取资源。
-
设置强缓存
- Cache-Control
- max-age:设置缓存的到期时间
- public:表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存,即使是通常不可缓存的内容
- private: 表明响应只能被用户浏览器缓存,不能被代理服务器缓存
- no-cache:强制要求缓存把请求提交给原始服务器进行验证 (协商缓存验证)。
- no-store:不应该缓存任何响应数据
设置弱缓存
- ETag/If-None-Match:响应头中携带
Etag
,下次请求,请求头中会自动加入If-None-Match
字段用来校验是否过期 - Last-Modified/If-Modified-Since:响应头中携带
Last-Modified
,下次请求,请求头中会自动加入If-Modified-Since
字段用来校验是否过期
八. 浏览器加载页面性能优化
回答思路
- 先讲下浏览器的渲染机制
- 根据渲染机制做出对应的性能优化措施
开始回答
- 省略讲解,详细可看前文
生成DOM树
->样式计算
->生成布局树
->分层生成图层树
->绘制、生成待绘制列表
->栅格化、将块图转为位图
->合成并显示
- 性能优化
- 加载阶段:
- 通过之前了解,js和css的加载和执行会阻塞DOM树的生成,所以可以通过压缩js和css资源来加快资源的加载速度。或者将其内联。
- 让不对 dom和style 做操作的 script 异步加载,使用defer、async
- 可以将
js
和css
内联,减少关键资源的RTT
个数
- 交互阶段:
- 减少 JavaScript 脚本执行时间,将执行时间较长的js开启多线程执行。
- 避免强制同步布局。正常情况,我们通过
DOM接口
添加或者删除元素,都需要从新计算样式并且布局,为了避免当前的任务占用太长的主线程时间,这些操作都是在另外的任务中异步完成的。但是如果在更改节点后立刻获取修改后节点的信息,会导致立即在主线程中重新布局。 - 合理利用 CSS 合成动画,css动画是直接在合成线程中进行的,从渲染流程来说,大大加快了动画速度,并且不会受到主线程js 或者一些布局任务影响。除此之外如果能提前知道对某个元素执行动画操作,那就最好将其标记为 will-change,这是告诉渲染引擎需要将该元素单独生成一个图层。
- 避免频繁的垃圾回收,从垃圾回收那一节我们知道,垃圾回收任务添加到消息队列也会影响渲染进程中主线程的执行,会阻塞js的执行。
🙃(再忍忍吧!!!) Webpack篇(还没有更新完。。。)
一. 说下模块热加载原理
回答思路
- 先简单说下什么是模块热加载
- 说下原理
开始回答
- 模块热加载就是在修改了某个模块之后,不需要重新加载整个页面,就可以实现页面的部分刷新,大大提高开发效率。
-
- 首先在
webpack-dev-sever
启动后,会实现一个轮询机制,就是定时的去查看文件是否修改,我们可以通过配置watch
和watchOptions
去配置,当检测到有文件改变时会重新进行编译,并将编译后的结果保存到内存中,没有写入到文件夹中,所以不会生产dist
文件,加快了读取速度,减少I/O带来的性能问题。 - 服务器启动之后还会维护一个
ws
协议与服务器建立连接,并且主动向浏览器推送一个哈希值,也就是当前文件被编译之后生成的hash
值。 - 当轮询机制检测到有文件改变之后,会通过ws通知浏览器,有文件改变,并且携带新的编译完成后的
hash
值 - 然后浏览器收到
hash
值之后,会将上一次的hash
值合成请求标识上次hash.hot-update.json
文件,发起一个xhr
请求,服务器响应内容中包含了改变的chunk
。 - 然后继续合成新的请求标识
上次hash.hot-update.js
,通过jsonp
的方式来获取新的模块,通过module.hot.accept
来让用户添加热模块替换规则。
- 首先在
二. tree-shark 原理
回答思路
tree-shark
是基于什么实现的?如何配置。ES
和Commonjs
模块导出有什么区别。tree-shark
原理
开始回答
-
首先
tree-shark
是基于ES
的模块导入实现的,由于ES的模块导入是静态导入,可以在执行前静态分析导入模块是否被使用,从而不会被打包。而Commonjs
是动态导入,无法判断是否被使用,所以要想tree-shark
一定要使用ES
规范。
配置:webpack5
已经在生产模式下自动实现了tree-shark
,我们来聊聊开发模式下如何配置。- 首先使用
optimization.usedExports
来开启标记未使用的导出值 - 然后使用
Terset插件删除这些没有被使用的导出值
。
- 首先使用
-
区别:
- Commonjs支持动态导入,而ES则必须将
import
放在顶层导入,不可以使用if
等语句包裹。虽然后续ES模块也支持动态导入import()
- Commonjs 导出相当于是一个值得拷贝,因此值可以直接重新赋值,而ES是导出的是值得引用,不可以直接重新赋值,只能依赖提供的函数修改。
- ESModule
export default
可以与export
混用,并且前者必须位于顶层,它们导出的模块不属于同一个对象, 而Commonjs 的module.exports
和exports
的导出属于导出一个对象,可以随便混入。
- Commonjs支持动态导入,而ES则必须将
😭(马上就要结束了)HTTP模块
该模块没什么讲的,直接讲下http的发展和各个版本的缺点
http 0.9
这是http
的最初版本,最初设计只是为了传输HTML文件
,只支持html
文件的传输,并且只能发起get
请求,只有请求行,没有请求头和请求体,响应也就只有响应体
http 1.0
由于http
的广泛使用,增加了新的支撑
- 引入了请求头和响应头
- 引入了状态码
- 为了支持多文件传输引入了
Content-Type
- 为了优化引入了Cache缓存机制
缺点:
1. 每次请求发送前都需要建立一次TCP连接
,极大的消耗性能
http 1.1
为了完善http 1.0
的缺点,做出了修改
- 引入了
keep-live
长连接,使得一次TCP
连接可以发送多个http
请求 - 在同一个域名下,可以建立6个TCP连接,实现并发传输,因此可以绑定多个域名来建立更多的TCP连接
缺点:
- 对头阻塞问题:由于http请求必须等待前一个请求响应才可以发送下一个请求,其他请求都需要排队,造成性能和带宽的浪费
- 多个TCP连接,导致多个请求争夺有限的带宽
- 每个TCP连接都需要进行慢启动
拓展:
什么是慢启动?
在TCP
连接时TCP内部会维护一个滑动窗口
,由于TCP是可靠传输,所以每次发送一段数据都会等到响应ACK
确认收到之后发送下一段数据,但是每次确认的效率十分低下,所以出现了滑动窗口
这个概念,发送一组数据后,只需要返回一个ACK
就可以,加快了传输效率。并且这个窗口时可收缩的,可以根据当前网络状态实现最高效率的网络吞吐量。
了解了什么是滑动窗口
后我们继续回归主题,由于TCP第一次连接时不知道当前的网络状态,所以此时发送数据的窗口很小,如果正常响应ACK
的话,则下次窗口会增大为上次的二倍,并且找到最大上限的窗口大小,以达到当前网络的最佳传输效率。
http 2.0
- 为了解决http 1.1慢启动和资源争夺的问题,http 2.0将TCP连接个数减少到了一个。
- http 引入了
二进制分帧层
的概念,通过流来传输数据,每个流都有一个唯一的整数标识符,为了防止请求和响应的id冲突,规定客户端发起的流使用奇数ID,服务器响应的流具有偶数ID,当流传输到对面后再根据不同帧首部的流标识符进行组装。因此不同的请求可以在一个TCP
中乱序传输,真正实现了并发传输。 - 头部压缩,像
get
请求,大部分的数据都是请求头的占用,为了避免请求头部和响应头部的重复发送带来的性能消耗,http 2.0引入了头部压缩
,在服务端和客户端分别维护了一个映射表,在传输时只发送索引,大大减小了头部的数据大小。 - 引入了数据传输的优先级,每个流都携带有一个
31比特
的优先值,根据优先级,将高优先级的帧先发给客户,但又不会绝对的按照优先级约定,防止又带来对头阻塞的问题。 - 增加了服务器推送功能,但是又不同于
websocket
,只能推送静态资源,提前将客户端需要的静态资源发送给客户端。
缺点:虽然可以同时发送多个请求,但是由于tcp的特性,进入的数据包应该和出去时是一样的数据,因此如果某个数据丢包,则所有的传输会被堵塞等待重传,所以没有从根本解决了对头阻塞的问题,如果丢包率太大,这种情况会不如http1.1 6个tcp通道的效率高。
http 3.0
- 摆脱tcp,使用udp
- QUIC协议:基于 UDP 实现了类似于 TCP 的多路数据流、传输可靠性等功能
- 实现了类似 TCP 的流量控制、传输可靠性的功能。虽然 UDP 不提供可靠性的传输,但 QUIC 在 UDP 的基础之上增加了一层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他一些 TCP 中存在的特性。
- 集成了 TLS 加密功能。目前 QUIC 使用的是 TLS1.3,相较于早期版本 TLS1.3 有更多的优点,其中最重要的一点是减少了握手所花费的 RTT 个数。
- 实现了 HTTP/2 中的多路复用功能。和 TCP 不同,QUIC 实现了在同一物理连接上可以有多个独立的逻辑数据流(如下图)。实现了数据流的单独传输,就解决了 TCP 中队头阻塞的问题。
- 实现了快速握手功能。由于 QUIC 是基于 UDP 的,所以 QUIC 可以实现使用 0-RTT 或者 1-RTT 来建立连接,这意味着 QUIC 可以用最快的速度来发送和接收数据,这样可以大大提升首次打开页面的速度。
😱(再坚持一下,马上结束了)VUE模块
一.vue模板编译原理
回答思路
- 首先说下浏览器解析
html
的流程 - 再说下
vue
实现模板编译的过程 开始回答
- html的解析过程是在渲染进程中进行的,在渲染进程拿到响应体后开始解析
html
文件,创建我们的DOM树
,而DOM
树的形成经历了两个过程,首先是分词
,分词器将htlm解析成一个个小的token
,然后html解析器维护了一个token
栈结构,将token
按顺序压入栈中,当遇到开始标签时,对应的创建一个DOM元素,并且将开始token
压入栈中同时作为下一个入栈token
的父元素。当遇到结束Token
时,将该栈顶对应的开始token
出栈,说明该节点已经创建完成。直到所有token
出栈,一颗DOM树
就构建好了。 - 说完浏览器解析html的过程之后,我们来聊下
vue
模板解析的过程,其实核心思想是一样的。我将配合部分源码来讲解。
解析获取模板(vue2源码)
获取模板这一步是在做好一切数据初始化之后进行的,执行vue.prototype.mount
,首先会获取挂载的区域,然后去查看我们组件中有没有render
,会优先获取render
,因为render
不需要进行编译,如果没有的话开始获取模板,并且调用compileToFunctions
进行编译,源码如下:
Vue.prototype.$mount = function (
el,
hydrating
) {
el = el && query(el) // 通过 el 获取挂载DOM
const options = this.$options
if (!options.render) { // 没有 render 函数处理方案
let template = options.template
if (!template) {
template = getOuterHTML(el)
}
if (template) {
const { render, staticRenderFns } = compileToFunctions( // 开始进行编译
template,
{
outputSourceRange: __DEV__,
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
},
this
)
options.render = render
options.staticRenderFns = staticRenderFns
}
}
return mount.call(this, el, hydrating)
}
- 获取到模板之后我们开始进入编译,先通过
parse
方法进行解析并生成ast
,ast
其实就是一个用来描述DOM
关系的一个js对象,大致属性如下:
const ast = {
attrs: [], // 存放当前标签的属性
attrsMap: {id: name}, // 属性映射
start: 0, // 对应的开始标签索引
end: 120, // 该标签对应结束标签的索引
parent: '',对应的父标签对象
tag: 'div'// 标签
type: 1,// 标签类型
children: [] // 子标签对象
}
- 说完之后我们再看看
vue
是如何生成ast
的,它和浏览器解析html的方法完全一致,只不过维护了两个栈,一个用来存放编译过程中生成的开始标签,一个用来存放将编译好的开始标签转换为ast对象的栈。其他过程完全一致,我来看下代码实现,他是如何进行编译的,又是如何转换成ast树的。
function parseHTML(html, options) {
const stack // 用来存放解析到的标签
let index = 0
let last, lastTag
while (html) { // 循环解析html
last = html
if (!lastTag || !isPlainTextElement(lastTag)) { // 判断是不是script style
let textEnd = html.indexOf('<')
if (textEnd === 0) {
const endTagMatch = html.match(endTag) // 通过正则判断是不是结束标签
if (endTagMatch) { // 匹配到当前标签为结束标签
const curIndex = index
advance(endTagMatch[0].length) // 截取掉结束标签
parseEndTag(endTagMatch[1], curIndex, index)//解析结束标签,同时进行出栈操作,并且在其中执行了存放 ast 对象的栈出栈操作
continue
}
const startTagMatch = parseStartTag() // 通过正则匹配开始标签,同时通过正则匹配开始标签上的属性,返回一个解析好的对象
if (startTagMatch) {
handleStartTag(startTagMatch) // 处理解析好的开始标签对象,存放到栈中,同时将该对象创建为ast对象,存到ast栈中
continue
}
}
} else { // 用来处理 script 和 style标签
}
}
}
- 有了 ast 之后,我们只需要构建出对应的
render
函数就可以了,render函数用来生成对应的虚拟DOM树
,有了虚拟dom树,我们就可以通过patch
函数进行渲染啦,让我们来看看render
函数是如何生成的。
function generate(
ast: ASTElement | void,
options: CompilerOptions
): CodegenResult {
const state = new CodegenState(options)
const code = ast
? ast.tag === 'script'
? 'null'
: genElement(ast, state)
: '_c("div")'
return {
render: `with(this){return ${code}}`,
staticRenderFns: state.staticRenderFns
}
}
- render 函数的生成用了一个生成器函数,通递归来生成对应的虚拟dom,但是此处巧妙的用了一个字
符串拼接
+with
方法,来大大减少了代码量,最后再通过new Function(str)
来生成render
函数
export function generate(
ast,
options
) {
const code = genElement(ast, state) // 开始进入递归生成vdom
return {
render: `with(this){return ${code}}`, // 生成字符串,此时还没有被包装成render函数
}
}
function genElement(el) { // 在这个方法中,处理了 所有 vue 指令,递归生成子元素,通过`genChildren`方法
if (el.once && !el.onceProcessed) {
return genOnce(el, state)
} else if (el.for && !el.forProcessed) {
return genFor(el, state)
} else if (el.if && !el.ifProcessed) {
return genIf(el, state)
} else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
return genChildren(el, state) || 'void 0'
} else if (el.tag === 'slot') {
return genSlot(el, state)
} else {
let code
if (el.component) {
code = genComponent(el.component, el, state)
} else {
let data
const maybeComponent = state.maybeComponent(el) // 判断当前标签是不是个组件
if (!el.plain || (el.pre && maybeComponent)) {
data = genData(el, state)
}
let tag: string | undefined
if (!tag) tag = `'${el.tag}'`
const children = el.inlineTemplate ? null : genChildren(el, state, true)
code = `_c(${tag}${data ? `,${data}` : '' // data
}${children ? `,${children}` : '' // children
})`
}
return code
}
}
- 等的
render
函数构建完成之后,就可以进入patch
环节了,也就是我们所说的diff
算法