备战前端面试–浏览器和V8引擎篇
V8 引擎
内存机制
考点1:数据存储
JavaScript 中的数据类型是如何存储的?
在 JavaScript 中,数据的基本存储方式有两种:栈存储和堆存储,而变量的存储位置是不固定的。
一般情况下,基本数据类型,其值比较简单,存储在栈空间。
具体而言,以下数据类型存储在栈中:
- boolean
- null
- undefined
- number
- string
- symbol
- bigint
对于
赋值
操作,原始类型的数据被完整地复制给变量的值。对象数据类型的数据,其值通常比较复杂,因而创建的成本也很大,这类数据存储在堆空间中。在堆中,每个对象数据都有一个唯一的引用地址,通过该地址指向这个数据。
对于
赋值
操作,对象类型的数据的引用地址被复制给变量的值。对于栈来说,其功能除了保存数据,还有创建并切换执行上下文。
比如:
function f(a) { console.log(a); } function func(a) { f(a); } func(1);
具体执行时,在系统栈中的流程是:
- 创建 func 函数执行上下文,压入栈顶。
- 执行 func,调用 f 函数,创建 f 函数执行上下文,栈顶指针上移,栈顶变为 f 函数执行上下文。
- f 函数执行完毕,栈顶指针下移,其上下文在执行栈中销毁。
- func 函数执行完毕,栈顶指针下移,其上下文在执行栈中销毁。栈顶变为全局执行上下文。
因此如果采用栈来存储相对基本类型更加复杂的对象数据,那么切换上下文的开销将变得巨大。
在这个基础上再深入一点,我们可以窥见闭包的本质:
实际上所有函数都有闭包(闭包保存了上层上下文的数据,可以看成是上一个执行上下文的副本,然后函数将其作为私有属性
closure
,通过这个属性可以指向闭包中的数据)。因此,闭包和引用类型同理,应当是保存在堆中的(如果是在栈中,随着函数上下文的销毁,下一次函数调用时将无法访问),在函数中访问闭包内的变量,其实是通过引用地址指向闭包变量所在的空间(所以闭包也能像作用域链一样往上查找数据)。可以试试看,证明在每个函数内部都有闭包的存在:
let a = 1 function func() { debugger a = 2 function f() { debugger console.log(a); } f() } func();
闭包甚至可以不是函数(这里是全局执行上下文对象)
闭包默认是不可见的(没有引用就不创建),只有存在指向父作用域的引用时才能看到。和引用类型一样,除非引用闭包的变量释放,否则闭包将一直占用内存。
关于闭包与对象之间的联系,MDN 也说得很清楚:
A closure lets you associate some data (the environment) with a function that operates on that data. This has obvious parallels to object oriented programming, where objects allow us to associate some data (the object’s properties) with one or more methods.
Consequently, you can use a closure anywhere that you might normally use an object with only a single method.
闭包允许您将某些数据(环境)与对该数据进行操作的函数相关联。 这与面向对象编程有明显的相似之处,其中对象允许我们将一些数据(对象的属性)与一个或多个方法相关联。
因此,您可以在通常仅使用单个方法使用对象的任何地方使用闭包。
考点2:内存回收 GC
V8 引擎如何进行垃圾内存的回收?
对于栈的内存空间,只保存简单数据类型的内存,由操作系统自动分配和自动释放,而堆空间中的内存,需要由JS引擎手动释放。
说说 V8 引擎的垃圾回收算法?
绝大部分的对象生命周期都很短,大部分在经过一次垃圾回收后就释放掉。只有少数对象生命周期很长,一直是活跃的对象,v8 引擎为了提高回收效率,将堆内存分为两部分处理:新生代内存和老生代内存。
新生代内存是临时创建的内存空间,一般是 1 - 8 MB的容量,很快就会释放掉。而老生代内存容量要大很多。
针对两种不同的内存空间,v8 引擎执行不同的垃圾回收算法。
新生代垃圾回收:
对象在创建时都是先放在新生代内存中。大部分对象的存活时间很短,因此需要一个非常高效的算法。主要的算法是
Scavenge
(清道夫)。它是一个典型的空间换时间的算法,通过将当前的堆内存一分为二,处于使用状态的空间称为from 空间,而闲置状态的空间称为 to空间。初始时对象都在 from 空间,当开始垃圾回收时,会把仍然活跃的对象复制放入 to 空间,并重新整理 from 空间,释放掉这部分内存。完成复制后,把 from 空间和 to 空间进行置换。经过多次垃圾回收后仍然处于 from 空间的对象,在满足一定条件后(to 空间已使用超过 25%)会被移动到老生代内存中,这个过程叫做晋升。
老生代垃圾回收:
老生代内存中采用的是
Mark-Compact
(标记整理)和Mark-Sweep
( 标记清除)算法。标记清理过程分两段,由于老生代内存中活动对象占多数,所以在标记了非活动对象之后,直接将其清除。但是还存在一个问题,被清除的对象在内存中遍布各个地址,产生了很多内存碎片。若不清理这些碎片,那么在老生代内存中如果新增一个大对象,这时所有的碎片空间都不足以分配,会直接将其垃圾回收,而这是不必要的。
因此,在标记清理过程中,多了一个标记整理的过程。在此阶段,活动对象将会被移动到一端,然后再清理边界外的内存。
在 v8 垃圾回收过程中为什么需要增量标记?
由于 JS 的单线程机制,v8 引擎在执行垃圾回收算法的时候务必会影响到业务逻辑的执行,这将导致线程阻塞。如果老生代内存的回收任务很重,耗时会很大,极度影响性能。在这种情况下, v8 采用增量标记的方案,将主任务拆分为许多小任务,让其穿插在 JS 应用逻辑的执行过程中。每个任务都有 5 - 10 ms 的停顿。
执行机制
考点1:v8 执行过程
- v8 是怎么执行一段代码的?
初始化基础环境后,v8 引擎编译 JavaScript 代码,用解析器(parser)生成抽象语法树 AST。生成 AST 后,解释器根据 AST 生成对应的字节码(Byte Code)。优化编译器(TurboFan)将字节码进行优化,生成可被机器识别的机器码(Machine Code)。
可概括为以下过程:
准备 JavaScript 的运行时环境
编译解析 JavaScript 代码,生成 AST 语法树
解释器根据 AST 生成对应的字节码
解释器解释执行字节码
监视器监听热点代码
基线编译器把热点代码优化为二进制机器码
优化编译器把二进制机器码再优化
v8 的即时编译 JIT 是什么?
v8 的运行时编译机制。v8 引擎在执行期间进行编译,而不是在代码运行之前。
解释器虽然简单,启动快,但是其执行效率很低,因为每次执行都是重新解析代码。而编译器虽然启动慢,但是在经过一次编译以后,再次运行相同的代码,就不必再进行一次重复的过程,因此执行快。
即时编译器尝试将解释器和编译器的优点结合,基本思想是避免重复转换。在解释器执行完一段代码后,其中的热点代码会被送到基线编译器中编译为二进制机器码,再次运行同样的代码,会被直接复用。而同时这些热点代码也会被送到优化编译器。优化编译器使用解释器收集的信息进行假设,并基于这些假设进行优化。
事件循环
考点1:JS 的宏任务与微任务
什么是宏任务?
JS 是单线程 EventLoop 机制,大部分的任务都在主线程上执行。为了让这些任务有条不絮地进行,需要有一个确定执行顺序的机制。V8 采用的是以队列的方式存储这些任务,先进入的先执行:
bool keep_running = true; void MainTherad(){ for(;;){ //执行队列中的任务 Task task = task_queue.takeTask(); ProcessTask(task); //执行延迟队列中的任务 ProcessDelayTask() if(!keep_running) //如果设置了退出标志,那么直接退出线程循环 break; } }
将队列中的任务一一取出,然后执行。队列包括普通任务队列和延迟任务队列(
setTimeout/setInterval
这样的定时器回调任务)。普通任务队列和延迟队列中的任务,都属于宏任务。
什么是微任务?
对于每个宏任务,其内部都有一个关联的微任务队列。微任务最初是为了解决异步回调的问题。对于异步回调有两种处理方案:
- 将异步回调进行宏任务队列的入队操作。
- 将异步回调放在当前宏任务的末尾。
如果采用第一种方式,那么执行回调的时机是在所有宏任务完成后,如果现在的队列非常长,那么回调迟迟得不到执行,造成应用卡顿。
为了规避这一问题,v8 引入第二种执行方式,在每一个宏任务中定义一个微任务队列,在当前宏任务执行完后,会检测其中的微任务队列,如果不为空,则依次执行所有微任务,然后执行下一个宏任务。如果为空,直接执行下一个宏任务。
常见的微任务有
MutationObserver
、Promise.then(或.reject)
以及以 Promise 为基础开发的其他技术(比如fetch API
), 还包括V8 的垃圾回收过程
。
NodeJS
事件循环
考点1:NodeJS 的事件循环
NodeJS 的事件循环和浏览器事件循环的差异?
NodeJS 有三大关键执行阶段:
- 执行定时器回调的阶段(
setTimeout
、setInterval
),检查定时器,时间到了就执行。 - 轮询(
poll
)阶段。在 NodeJS 中异步操作(文件I/O
、网络I/O
等)执行完后,通过事件通知主线程。此阶段如果定时器时间到,执行定时器回调,事件循环回到第一阶段。如果没有定时器,看回调函数队列,如果队列不为空,取出其中的方法依次执行,如果队列为空,检查是否有setImmediate
的回调。有则直接进入check
阶段。没有则继续等待,等待回调函数加入队列后立刻执行,一段时间后自动进入check
阶段。 check
阶段。执行setImmediate
的回调。
梳理一下,NodeJS 的 eventLoop 分为下面的几个阶段:
- timer 阶段
- I/O 异常回调阶段
- 空闲、预备状态(第2阶段结束,poll 未触发之前)
- poll 阶段
- check 阶段
- 关闭事件的回调阶段
两者最主要的区别在于浏览器中的微任务是在
每个相应的宏任务
末尾执行的,而 NodeJS 中的微任务是在不同阶段之间
执行的。- 执行定时器回调的阶段(
浏览器
事件循环
考点1:浏览器的事件循环
如何理解 EventLoop?
console.log('start'); setTimeout(() => { console.log('timeout'); }); Promise.resolve().then(() => { console.log('resolve'); }); console.log('end'); // start // end // resolve // timeout
执行顺序:
- 进入全局执行上下文,整个脚本作为一个宏任务执行。同步代码立刻压入执行栈执行,打印
start
、end
- setTimeOut 作为延迟任务放入到宏任务队列
- Promise.then 作为一个微任务放入到微任务队列
- 本次宏任务执行完,检测到微任务队列不为空,执行 Promise.then
- 进入下一个宏任务,setTimeOut 执行
总结如下:
- 一开始整段脚本作为第一个宏任务执行
- 执行过程中同步代码直接执行,宏任务进入宏任务队列,微任务进入微任务队列
- 当前宏任务执行完出队,检测微任务队列,在微任务之间没有 UI 或网络事件的处理:它们一个立即接一个地执行,直到微任务队列为空
- 执行浏览器 UI 线程的渲染工作,浏览器发生重渲染
- 检测是否有 Web Worker 任务,有则执行
- 渲染完毕后,JS线程继续接管。执行队首新的宏任务,回到第二步,直到宏任务和微任务队列都为空
同时在执行过程中有 4 种不同的情况:
- 宏任务里面新建宏任务(主要是
setTimeOut
):加入宏任务的队列中 - 运行宏任务时新建微任务:加入微任务的队列中
- 运行微任务时新建宏任务:加入宏任务的队列中
- 运行微任务时新建了微任务:新创建的微任务也会马上加入微任务队列,在下次宏任务之前一定会执行。
- 进入全局执行上下文,整个脚本作为一个宏任务执行。同步代码立刻压入执行栈执行,打印
浏览器请求
考点1:页面加载与网络
输入 URL 到页面呈现发生了什么?
网络请求:
构建请求。浏览器构建请求行:
// 请求方法是GET,路径为根路径,HTTP协议版本为1.1 GET / HTTP/1.1
查找强缓存,如果本地存在缓存文件,直接使用。否则进入下一步。
DNS 解析。输入的是域名,而数据包是通过 IP 地址发送的。因此需要得到真实的 IP 地址。而这一步有专门的服务系统处理。DNS 域名解析系统会将域名与其真实 IP 进行映射,得到具体 IP。
浏览器也提供了 DNS 缓存功能。如果一个域名已经解析过,那么直接从缓存中读取。
建立 TCP 连接。经过三次握手确立连接。之后进行数据传输,这个过程需要进行数据包校验,即接收方收到数据包需要向发送方返回确认信息,如果发送方没有收到,判断为数据包丢失,重新发送。数据传输完成,四次握手断开连接。
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。
发送 http 请求。TCP 建立完毕,客户端可以向服务端发送 http 请求,以进行通信。一个 http 请求需要携带三样东西:请求行、请求头、请求体。
网络响应:
- 网络响应。http 请求到达服务器,服务器对其作出回应,最后把数据传输给浏览器,也就是返回网络响应。跟请求类似,网络响应包括响应行、响应头、响应体。
- 持久化。响应完成之后,如果请求头中 Connection 字段设置的是 Connection: Keep-Alive,表示建立了持久连接,TCP 连接会一直保持,之后对该站点的请求都会复用这个连接。否则断开 TCP 连接。
浏览器存储
考点1:浏览器存储
能不能说一下浏览器的本地存储?各自优劣如何?
浏览器的本地存储方案可分为
Cookie
、WebStorage
、IndexdDB
。其中WebStorage
又可分为localStorage
和sessionStorage
。Cookie
:本质上是浏览器存储的很小的文本文件。内部以键值对的形式存储。向同一个域名下发送请求,都会携带相同的
Cookie
。因此服务器就可以解析Cookie
,得到客户端的状态。Cookie
作为状态存储的工具十分轻量化,但也存在许多缺陷:- 容量缺陷。一般情况体积上限只有 4 KB,所以只能存储少量信息。
- 性能缺陷。
Cookie
紧跟域名。不管这个域名需不需要用到,请求都会携带完整的Cookie
,随着请求增多,存在着性能上的浪费。 - 安全缺陷。
Cookie
以纯文本的形式存储数据,很容易被截获篡改。在Cookie
的有效期限内发送给服务器是十分危险的。另外,在HttpOnly
为 false 的情况下,可以通过 JS 脚本读取Cookie
信息。
localStorage
:和
Cookie
类似,针对同一个域名。区别有以下几点:- 存储容量。
localStorage
的上限为 5 MB,针对一个域名持久化存储。 - 只存在于客户端,不参与请求。避免了性能问题和安全问题。
- 接口封装完善。通过
localStorage
暴露在全局,并且有操作方法getItem
和setItem
等。
应用场景:由于其存储容量较大和持久化存储的特性,因此可以存储一些比较稳定的资源,像是官网 logo、Base64 格式的图片。
sessionStorage
:与
localStorage
类似,区别在于其只是会话级别的存储而非持久化存储。在会话结束,也就是页面关闭后就不复存在了。应用场景:存储表单消息,保证页面刷新也不会丢失之前的信息。存储本次浏览记录,关闭页面,也就不需要这些记录了。
IndexedDB
:IndexedDB
是运行在浏览器的非关系型数据库。本质上是数据库,其存储容量理论上没有上限。它支持数据库的特性,比如支持事务、二进制存储,还有一些额外特性:
- 键值对存储。内部采用对象仓库,在这个仓库中使用键值对形式存储。
- 异步操作。数据库的读写属于
I/O
操作,浏览器对异步I/O
提供了支持。 - 受同源策略限制,无法访问跨域数据库。
考点2:浏览器缓存
能不能说一说浏览器缓存?
浏览器缓存分两种情况:
- 强缓存。
- 协商缓存。
首先是检测有没有强缓存。这个阶段不发送 http 请求。如果命中,直接使用本地缓存。
在
HTTP/1.0
时期,检查的字段是Expires
,采用具体过期时间限制资源的使用期限。Expires: Wed, 22 Nov 2019 08:41:00 GMT
在
HTTP/1.1
中,检查的字段是Cache-Control
。它没有采用具体的过期的时间点的方式,而是采用过期的时长控制缓存。对应的字段是max-age
。Cache-Control:max-age=3600
并且具有非常多的属性:
- private: 这种情况就是只有浏览器能缓存了,中间的代理服务器不能缓存。
- no-cache: 跳过当前的强缓存,发送HTTP请求,即直接进入
协商缓存阶段
。 - no-store:不进行任何形式的缓存。
- s-maxage:这和
max-age
长得比较像,但是区别在于s-maxage
是针对代理服务器的缓存时间。
当资源缓存的时限超过了,就会进入协商缓存阶段。
强缓存失效后,通过在请求头中携带
tag
向服务器发送请求,由服务器根据这个tag
决定是否使用缓存,这就是协商缓存。具体来说,这样的缓存
tag
分为两种: Last-Modified 和 ETag。这两者各有优劣,并不存在谁对谁有绝对的优势
,跟上面强缓存的两个tag
不一样。Last-Modified
:即最后修改时间。浏览器第一次向服务器发送请求,服务器在响应头会加上这个字段。浏览器接收后,如果再次请求,会在请求头中携带If-Modified-Since
字段,这个字段的值也就是服务器传来的最后修改时间。服务器拿到请求头的
If-Modified-Since
字段,会与服务器上该资源的最后修改时间对比,如果小于最后修改时间,说明资源更新了,需要返回新资源,和平常的 HTTP 请求响应流程一致,返回200
。否则返回304
,让浏览器直接用缓存。ETag
:是服务器根据当前文件的内容,给文件生成的唯一标识,只要里面的内容有改动,这个值就会变。服务器通过响应头把这个值给浏览器。浏览器接收到
ETag
的值,会在下次请求时,将这个值作为If-None-Match
这个字段的内容,并放到请求头中,然后发给服务器。服务器接收到
If-None-Match
后,会跟服务器上该资源的ETag
进行比对,如果服务器上不存在,返回新资源,跟常规的 HTTP 请求响应的流程一样。否则返回304,告诉浏览器直接用缓存。两种 tag 的比较:
在精确度上,
ETag
是更有优势的。因为Last-Modified
表示的是文件最后修改的时间,如果只是编辑了资源文件,而文件内容没有更改,也会造成缓存失效。Last-Modified
能够感知的单位时间是秒,如果文件在 1 秒内改变了多次,那么这时候的Last-Modified
是不准确的。在性能上,
Last-Modified
优于ETag
,也很好理解,Last-Modified
仅仅只是记录一个时间点,而Etag
需要根据文件的具体内容生成哈希值。另外,如果两种方式都支持的话,服务器会优先考虑
ETag
。浏览器缓存的位置?
浏览器的缓存位置一共有四种,按优先级从高到低排列分别是:
- Service Worker Cache
- Memory Cache
- Disk Cache
- Push Cache
Service Worker 借鉴了 Web Worker 的思路,即让 JS 运行在主线程之外,由于它脱离了浏览器的窗体,因此无法直接访问
DOM
。但它仍然能帮助我们完成很多有用的功能,比如离线缓存
、消息推送
和网络代理
等功能。其中的离线缓存
就是 Service Worker Cache。Memory Cache指的是内存缓存,从效率上讲它是最快的。但是从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。
Disk Cache就是存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,但是他的优势在于存储容量和存储时长。
浏览器的资源存放策略如下:
- 比较大的JS、CSS文件会直接被丢进磁盘,反之丢进内存
- 内存使用率比较高的时候,文件优先进入磁盘
Push Cache 即推送缓存,这是浏览器缓存的最后一道防线。它是 HTTP/2 中的内容,它只在会话(Session)中存在,一旦会话结束就被释放。虽然现在应用的并不广泛,但随着 HTTP/2 的推广,它的应用将越来越广泛。
浏览器渲染
考点1:页面解析渲染过程
解释一下浏览器的解析和渲染过程
浏览器解析进程的主线程:
渲染 DOM 树。
浏览器无法直接理解 HTML 字符串,因此将这一系列的字节码转化为另一种数据结构,就是 DOM 树。本质是一个以 document 为根节点的多叉树。
其中涉及到解析算法,可分为两个阶段:标记化、建树。分别对应词法分析和语法分析。标记生成器会根据 HTML 标签进行记录,然后把标记的信息发送给建树器。建树器收到相应的标签,创建对应的 DOM 对象,然后加入 DOM 树中。
计算 CSS 样式。
浏览器无法直接识别 CSS 样式文本,渲染引擎在收到 CSS 文本后第一时间将其转化为结构化的 StyleSheets 对象,也就是我们常用的样式表。样式在被格式化和标准化之后,就计算每个节点的样式信息。
计算有两个规则:继承和层叠。每个子节点都会继承父节点的样式属性,如果父节点中没有,就默认继承浏览器默认样式(
UserAgent
)。然后是层叠规则,最终样式取决于各个属性共同作用的结果。计算完样式后,所有样式被挂载到window.getComputedStyle
上。生成布局树。
现在已经生成了 DOM 结构和 DOM 节点样式,接下来就是通过浏览器的布局系统确定元素排列的方式和位置。也就是生成一颗布局树。
大致如下:
- 遍历生成的 DOM 树节点,把他们添加到布局树中。(仅包含可见元素)
- 计算布局节点的坐标位置。
浏览器渲染进程的主线程:
建造图层树。
浏览器在绘制页面之前,还需要处理一些特殊的情况。比如 3D变换、如何控制层叠上下文元素显示和隐藏。
一般情况下,节点的图层默认属于其父节点的图层(也叫合成图层)。在某些情况需要提升为一个单独的合成层。这个过程主要涉及显示合成和隐式合成。
显示合成:
- 拥有层叠上下文的节点。包括:
- HTML 根元素本身就具有层叠上下文。
- position 不为
static
并且设置了z-index
属性,产生层叠上下文。 - 元素的 opacity(透明度) 不为 1
- 元素的 transform(变换) 不为 none
- 元素的 filter(滤镜) 不为 none
- 元素的 isolation(创建新的层叠上下文) 为 isolation
- will-change 指定的属性值为上面任意一个。
- 需要剪裁的部分。图层溢出,产生滚动条,也会提升为单独的图层。
隐式合成:
如果一个在层叠上下文中等级较低的节点形成了单独的图层,那么等级在它之上的所有节点都会形成一个单独的图层。这会大大加大内存的压力,甚至让页面崩溃,这就是层爆炸的原因。
- 拥有层叠上下文的节点。包括:
生成绘制列表。
接下来浏览器的绘制引擎会将图层的绘制拆分为一条条指令。然后将这些指令组合成待绘制列表,规划之后的绘制操作。
合成线程拆分图块。
浏览器中的绘制是由单独的线程来实施的,这个线程是合成线程。绘制列表准备好后,渲染进程的主线程会将绘制列表提交给合成线程。
当页面非常长的时候,绘制过程是十分消耗时间的,因此需要合成线程将其拆分成为一个个图块。这样可以大大加速页面的首屏展示。因为之后图块数据要进入 GPU 内存,从浏览器上传到 GPU 内存的速度很慢,Chrome 采用降低首次加载图片分辨率的策略。在合成线程绘制完毕后,再将当前低分辨率的图块替换。
调用线程池生成位图。
合成线程专门维护了一个栅格化线程池,利用它将图块转化成位图。合成线程会将视口最近的图块交给栅格化线程池。这个过程会使用到 GPU 加速。最后处理得到的位图返回给合成线程。
发送给浏览器进程。
合成线程在栅格化操作完成后,会生成绘制命令,并发送给浏览器进程,浏览器根据这个命令,把页面内容绘制到内存,同时发送给显卡。
显卡缓存图像,显示器显示。
无论是手机屏幕还是 PC 显示器,都有一个固定的刷新频率。一般是 60 HZ,即60帧。也就是一秒更新 60 次图片。而每次更新的图片都来自于显卡的前缓冲区 。显卡接收到浏览器传来的页面后,先合成对应的图像,并保存到后缓冲区。之后系统自动将前后缓冲区对调,如此循环更新。
考点2:回流重绘
什么是重绘和回流?
回流:
回流也叫重排。简单来说,当我们对 DOM 结构进行修改,导致其几何尺寸变化的时候,会发生
回流
的过程。以下的操作会触发回流:
- 一个 DOM 元素的几何属性发生变化,常见的有
width、height、padding、margin、left、top、border
等。 - DOM 节点发生增减或移动。
- 读写
offset
、scroll
和client
属性时,为了获取这些计算值浏览器需要进行回流。 - 调用
window.getComputedStyle
方法。
重绘:
重绘是在不影响几何属性的情况下,DOM 的修改导致了样式的变化。
重绘过程不涉及 DOM 结构的变化,元素的位置没有更新,因此不需要重新建立布局树和图层树。
重绘不一定导致回流,但回流一定发生重绘。
实践意义:
- 应该尽量少使用 style,多使用修改 class 的方式。
- 使用
createDocumentFragment
进行批量的 DOM 操作。 - 对于 resize、scroll 进行防抖节流处理。
- 添加 will-change: tranform,让渲染引擎为其单独实现一个图层,当这些变换发生时,仅仅只是利用合成线程去处理这些变换,而不牵扯到主线程,大大提高渲染效率。
- 一个 DOM 元素的几何属性发生变化,常见的有
考点3:渲染与刷新
setTimeout 与 requestAnimationFrame 的区别?
requestAnimationFrame
是 H5 新增的 API。类似于setTimeOut
定时器。它只能在浏览器中使用,是专门为动画属性提供,让DOM 动画
,canvas 动画
,SVG 动画
和webGL 动画
有一个统一的刷新机制。基本思想是让重绘频率与这个动画的刷新频率相近。所以它不需要像setTimeOut
一样传递一个时间,而是由系统获取并使用显示器的刷新率,而且是发生在 UI 渲染之前。setTimeOut
是人为地设定一个间隔时间不断改变图像,会受到显示器分辨率的影响。不同的显示器刷新率不同,如果与setTimeOut
指定的刷新间隔不一致,可能会造成页面卡顿,掉帧。因此,使用
requestAnimationFrame
最大的优势是确保在一次刷新中只执行一次,节约资源,节省电源。
浏览器安全
考点1:同源策略
什么是浏览器同源策略?
源是指 URL 中由协议、域名、端口共同组成的部分。同源策略是一种约定,它是浏览器最核心也最基本的安全功能。可分为两种:
DOM 同源策略:禁止对不同源页面 DOM 进行操作。主要场景是 iframe 跨域。禁止不同域名的 iframe 互相访问。
XMLHttpRequest 同源策略:禁止使用 XHR 对象向不同源的服务器地址发送 Http 请求。
同源策略有什么限制?
Cookie
、LocalStorage
和IndexDB
无法读取。- 无法获取或操作另一个资源页面的
DOM
。 AJAX
请求拦截。
考点2:跨域
为什么要有跨域限制?
如果没有DOM 同源策略,不同域的 iframe 之间可以相互访问,那么黑客可以通过在 iframe 中嵌套真实网站,在用户输入信息后从主网站获取到其中的 DOM 节点。
如果没有 XMLHttpRequest 同源策略,那么黑客可以进行 CSRF(跨站请求伪造) 攻击,在用户登录正常网站后,在浏览器中存储了 cookie。如果用户浏览了另一个恶意网站,执行了其中的恶意 AJAX 脚本,请求会默认把用户的 cookie 也携带上,造成用户数据泄露。
请求跨域的解决方案?
CORS(跨域资源共享)
:是一个 W3C 标准,规定了在必须访问跨域资源时,浏览器和服务器之间如何沟通。其背后的基本思想,是通过自定义的请求头字段来决定请求和响应是成功还是失败。CORS 需要浏览器和服务器同时支持,并且依赖于后端的配置。
浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
简单请求:需要在请求头中额外附带一个
Origin
字段,包含请求来源信息。服务器认为可以接收,就在Access-Control-Allow-Origin
中返回相同的信息。没有这个头部信息或者不匹配,浏览器就驳回请求。如果需要携带 cookie,需要额外设置withCredentials: true
,服务器需要设置响应头部Access-Control-Allow-Credentials: true
。非简单请求:浏览器在发送真实请求之前,先发送一个 Preflight 请求给服务器。使用 OPTIONS 方法,发送下列头部:
Origin:与简单的请求相同。
Access-Control-Request-Method: 请求自身使用的方法。
Access-Control-Request-Headers: (可选)自定义的头部信息,多个头部以逗号分隔。
服务器可以决定是否允许这种类型的请求。服务器通过在响应中发送如下头部与浏览器进行沟通:
- Access-Control-Allow-Origin:与简单的请求相同。
- Access-Control-Allow-Methods: 允许的方法,多个方法以逗号分隔。
- Access-Control-Allow-Headers: 允许的头部,多个方法以逗号分隔。
- Access-Control-Max-Age: 应该将这个 Preflight 请求缓存多长时间(以秒表示)。
一旦服务器通过 Preflight 请求允许该请求之后,以后每次浏览器正常的 CORS 请求,就都跟简单请求一样了。
优点:
- CORS 通信与同源的 AJAX 通信没有差别,代码完全一样,容易维护。
- 支持所有类型的 HTTP 请求。
缺点:
- 存在兼容性问题,特别是 IE10 以下的浏览器。
- 第一次发送非简单请求时会多一次请求。
JSONP
:原理是利用
<script>
标签在浏览器上不受同源限制。Web前端事先定义一个用于获取跨域响应数据的回调函数,并通过没有同源策略限制的script标签发起一个请求(将回调函数的名称放到这个请求的query参数里),然后服务端返回这个回调函数的执行,并将需要响应的数据放到回调函数的参数里,前端的script标签请求到这个执行的回调函数后会立刻执行,于是就拿到了执行的响应数据。
优点:简单,兼容性好,可以解决主流浏览器的跨域访问限制。
缺点:安全性低,可能会遭受 XSS 攻击。有局限性,只支持 GET 方法。
WebSocket
:客户端与服务端双向通信的一种协议。在建立连接之后,WebSocket 的 server 与 client 都能主动向对方发送或接收数据。同时,
WebSocket
在建立连接时需要借助HTTP
协议,连接建立好了之后 client 与 server 之间的双向通信就与 HTTP 无关了。优点:数据轻量,性能开销小,通信高效。支持文本和二进制数据。
缺点:是长连接,受网络限制大。浏览器支持程度不一。服务器维护长连接需要一定成本。
document.domain
:通过将不同子域的
document.domain
设为相同主域实现同域。优点:实现简单。适合 iframe 跨域。
缺点:此方案仅限主域相同,子域不同的跨域应用场景。
window.name
:原理是利用 window 的 location 变化,重新加载后,它的 name 属性可以依然保持不变。每个页面对
window.name
都有读写的权限,不存在跨域问题。代理解决跨域的常用方案?
nginx反向代理
:同源策略是浏览器需要遵循的标准,而如果是服务器向服务器请求就无需遵循同源策略。
实现思路:通过 nginx 配置一个代理服务器(域名与domain1相同)做跳板机,反向代理访问domain2接口(需要配置转发的服务器地址),并且可以顺便修改 cookie 中 domain 信息,方便当前域 cookie 写入,实现跨域登录。
Nodejs中间件代理
:node中间件实现跨域代理,通过启动 Nodejs 代理服务器,实现数据转发。也可以通过设置cookieDomainRewrite参数修改响应头中cookie中域名,实现当前域的cookie写入,方便接口登录认证。
webpack proxy
:借助
webpack-dev-server
实现请求转发。仅适用于开发者模式。
考点3:浏览器攻击
能不能说一下 XSS 攻击?
XSS 全称是
Cross-Site-Scripting
,即跨站脚本。是指在浏览器中执行恶意脚本(无论同域还是跨域)。一般可以做到:
- 窃取 Cookie
- 监听用户行为
- 修改 DOM 伪造登录表单
- 生成浮窗广告
XSS 攻击的实现有三种方式——存储型、反射型和文档型。
存储型
,顾名思义就是将恶意脚本存储了起来,存储型的 XSS 将脚本存储到了服务端的数据库,然后在客户端执行这些脚本,从而达到攻击的效果。常见的场景是留言评论区提交一段脚本代码,如果前后端没有做好转义的工作,那评论内容存到了数据库,在页面渲染过程中
直接执行
, 相当于执行一段未知逻辑的 JS 代码,是非常恐怖的。这就是存储型的 XSS 攻击。反射型
,指的是恶意脚本作为网络请求的一部分。恶意脚本是通过作为网络请求的参数,经过服务器,然后再反射到HTML文档中,执行解析。和存储型
不一样的是,服务器并不会存储这些恶意脚本。文档型
的 XSS 攻击并不会经过服务端,而是作为中间人的角色,在数据传输过程劫持到网络数据包,然后修改里面的 html 文档这样的劫持方式包括
WIFI路由器劫持
或者本地恶意软件
等。能不能说一说CSRF攻击?
CSRF(Cross-site request forgery)
, 即跨站请求伪造,指的是黑客诱导用户点击链接,打开黑客的网站,然后黑客利用用户目前的登录状态发起跨站请求。为了防范
CSRF
攻击,主要使用的技术是JWT(Json Web Token)
。一个完整的
JWT
包含三个部分:Header
:是一个Json
对象,描述JWT
的元数据。Payload
:是一个Json
对象,描述了本次会话的信息。JWT
官方规定了下面几个官方的字段供选用。- iss (issuer):签发人
- exp (expiration time):过期时间
- sub (subject):主题
- aud (audience):受众
- nbf (Not Before):生效时间
- iat (Issued At):签发时间
- jti (JWT ID):编号
Signature
:对前两部分进行签名加密,防止数据篡改。首先需要定义一个秘钥,这个秘钥只有服务器才知道,不能泄露给用户,然后使用Header
中指定的签名算法(默认情况是HMAC SHA256
),算出签名以后将Header、Payload、Signature
三部分拼成一个字符串,每个部分用.
分割开来,就可以返给用户了。之后,浏览器如果要发送请求,就必须带上这个字符串,然后服务器来验证是否合法,如果不合法则不予响应。通常第三方站点无法拿到这个
token
, 因此也就是被服务器给拒绝。
- Post link: https://blog.sticla.top/2021/08/15/front-end-interview-browser/
- Copyright Notice: All articles in this blog are licensed under unless otherwise stated.
GitHub Issues