一、Chrome 打开一个页面需要启动多少进程?分别有哪些进程?
打开一个页面需要启动4个进程:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。下面我们来逐个分析下这几个进程的功能。
浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
GPU 进程。其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
为什么要开启4个进程?
因为单进程不稳定、不流畅、不安全。
- 一个插件的意外崩溃会引起整个浏览器的崩溃。渲染引擎模块也是不稳定的,通常一些复杂的 JavaScript 代码就有可能引起渲染引擎模块的崩溃。
- 页面的内存泄漏也是单进程变慢的一个重要原因。通常浏览器的内核都是非常复杂的,运行一个复杂点的页面再关闭页面,会存在内存不能完全回收的情况,这样导致的问题是使用时间越长,内存占用越高,浏览器会变得越慢。
- 通过插件可以获取到操作系统的任意资源,当你在页面运行一个插件时也就意味着这个插件能完全操作你的电脑。如果是个恶意插件,那么它就可以释放病毒、窃取你的账号密码,引发安全性问题。至于页面脚本,它可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题。
二、TCP协议:如何保证页面文件能被完整送达浏览器?
互联网中的数据是通过数据包来传输的。如果发送的数据很大,那么该数据就会被拆分为很多小数据包来传输。比如你现在听的音频数据,是拆分成一个个小的数据包来传输的,并不是一个大的文件一次传输过来的。
1.IP:把数据包送达目的主机
- 上层将含有“极客时间”的数据包交给网络层;
- 网络层再将 IP 头附加到数据包上,组成新的 IP 数据包,并交给底层;
- 底层通过物理网络将数据包传输给主机 B;
- 数据包被传输到主机 B 的网络层,在这里主机 B 拆开数据包的 IP 头信息,并将拆开来的数据部分交给上层;
- 最终,含有“极客时间”信息的数据包就到达了主机 B 的上层了。
2.UDP:把数据包送达应用程序
IP 通过 IP 地址信息把数据包发送给指定的电脑,而 UDP 通过端口号把数据包分发给正确的程序。
为了支持 UDP 协议,把前面的三层结构扩充为四层结构,在网络层和上层之间增加了传输层,如下图所示:
- 上层将含有“极客时间”的数据包交给传输层;
- 传输层会在数据包前面附加上 UDP 头,组成新的 UDP 数据包,再将新的 UDP 数据包交给网络层;
- 网络层再将 IP 头附加到数据包上,组成新的 IP 数据包,并交给底层;
- 数据包被传输到主机 B 的网络层,在这里主机 B 拆开 IP 头信息,并将拆开来的数据部分交给传输层;
- 在传输层,数据包中的 UDP 头会被拆开,并根据 UDP 中所提供的端口号,把数据部分交给上层的应用程序;
- 最终,含有“极客时间”信息的数据包就旅行到了主机 B 上层应用程序这里。
UDP 来传输会存在两个问题:
- 数据包在传输过程中容易丢失;
- 大文件会被拆分成很多小的数据包来传输,这些小的数据包会经过不同的路由,并在不同的时间到达接收端,而 UDP 协议并不知道如何组装这些数据包,从而把这些数据包还原成完整的文件。
3.TCP:把数据完整地送达应用程序
TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。
- 对于数据包丢失的情况,TCP 提供重传机制;
- TCP 引入了数据包排序机制,用来保证把乱序的数据包组合成一个完整的文件。通过 TCP 头的信息保证了一块大的数据传输的完整性。
4.思考
- 浏览器可以同时打开多个页签,他们端口一样吗?如果一样,数据怎么知道去哪个页签?
端口一样的,网络进程知道每个tcp链接所对应的标签是那个,所以接收到数据后,会把数据分发给对应的渲染进程。 - TCP传送数据时 浏览器端就做渲染处理了么?如果前面数据包丢了 后面数据包先来是要等么?类似的那种实时渲染怎么处理?针对数据包的顺序性?
接收到http响应头中的content-type类型时就开始准备渲染进程了,响应体数据一旦接受到便开始做DOM解析了!基于http不用担心数据包丢失的问题,因为丢包和重传都是在tcp层解决的。http能保证数据按照顺序接收的(也就是说,从tcp到http的数据就已经是完整的了,即便是实时渲染,如果发生丢包也得在重传后才能开始渲染) - http 和 websocket都是属于应用层的协议吗?
都是应用层协议,而且websocket名字取的比较有迷惑性,其实和socket完全不一样,可以把websocket看出是http的改造版本,增加了服务器向客户端主动发送消息的能力。 - 关于 “数据在传输的过程中有可能会丢失或者出错”,丢失的数据包去哪里了?凭空消失了吗?出错的数据包又变成啥了? 为什么会出错?
比如网络波动,物理线路故障,设备故障,恶意程序拦截,网络阻塞等等
三、HTTP请求流程:为什么很多站点第二次打开速度会很快?
1.浏览器端发起 HTTP 请求流程
1.1 构建请求
1.2 查找缓存
1.3 准备 IP 地址和端口
浏览器会请求 DNS 返回域名对应的 IP
1.4 等待 TCP 队列
Chrome 有个机制,同一个域名同时最多只能建立 6 个 TCP 连接,如果在同一个域名下同时有 10 个请求发生,那么其中 4 个请求会进入排队等待状态,直至进行中的请求完成。
1.5 建立 TCP 连接
三次握手
1.6 发送 HTTP 请求
1.6.1 发送请求行
首先浏览器会向服务器发送请求行,它包括了请求方法、请求 URI(Uniform Resource Identifier)和 HTTP 版本协议。
发送请求行,就是告诉服务器浏览器需要什么资源
1.6.2 发送请求头
在浏览器发送请求行命令之后,还要以请求头形式发送其他一些信息,把浏览器的一些基础信息告诉服务器。比如包含了浏览器所使用的操作系统、浏览器内核等信息,以及当前请求的域名信息、浏览器端的 Cookie 信息,等等。
1.7 服务器端处理 HTTP 请求并返回响应数据
1.8 断开连接
一旦服务器向客户端返回了请求数据,它就要关闭 TCP 连接。不过如果浏览器或者服务器在其头信息中加入了:Connection:Keep-Alive ,那么 TCP 连接在发送后将仍然保持打开状态。
1.9 重定向
响应行返回的状态码是 301,状态 301 就是告诉浏览器,我需要重定向到另外一个网址,而需要重定向的网址正是包含在响应头的 Location 字段中。
2.为什么很多站点第二次打开速度会很快?
主要原因是第一次加载页面过程中,缓存了一些耗时的数据。
DNS 缓存和页面资源缓存这两块数据是会被浏览器缓存的。
1.DNS缓存
主要就是在浏览器本地把对应的 IP 和域名关联起来,这样在进行DNS解析的时候就很快。
1.1域名解析的过程:浏览器缓存 -> 操作系统缓存 -> hosts 文件 -> 本地域名服务器 -> 根域名服务器 -> 顶级域名服务器 -> 权威域名服务器
2.MemoryCache
是指存在内存中的缓存。从优先级上来说,它是浏览器最先尝试去命中的一种缓存。从效率上来说,它是响应速度最快的一种缓存。内存缓存是快的,也是“短命”的。它和渲染进程“生死相依”,当进程结束后,也就是 tab 关闭以后,内存里的数据也将不复存在。
3.浏览器缓存
浏览器资源缓存:
浏览器缓存,也称Http缓存,分为强缓存和协商缓存。优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存。
强缓存
强缓存
是利用 http 头中的 Expires
和 Cache-Control
两个字段来控制的。强缓存中,当请求再次发出时,浏览器会根据其中的 expires 和 cache-control 判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。
实现强缓存,过去我们一直用expires。当服务器返回响应时,在 Response Headers 中将过期时间写入 expires 字段。像这样
expires: Wed, 12 Sep 2019 06:12:18 GMT
可以看到,expires 是一个时间戳,接下来如果我们试图再次向服务器请求资源,浏览器就会先对比本地时间和 expires 的时间戳,如果本地时间小于 expires 设定的过期时间,那么就直接去缓存中取这个资源。
从这样的描述中大家也不难猜测,expires 是有问题的,它最大的问题在于对“本地时间”的依赖。如果服务端和客户端的时间设置可能不同,或者我直接手动去把客户端的时间改掉,那么 expires 将无法达到我们的预期。
考虑到 expires 的局限性,HTTP1.1 新增了Cache-Control
字段来完成 expires 的任务。expires 能做的事情,Cache-Control 都能做;expires 完成不了的事情,Cache-Control 也能做。因此,Cache-Control 可以视作是 expires 的完全替代方案。在当下的前端实践里,我们继续使用 expires 的唯一目的就是向下兼容。
cache-control: max-age=31536000
在 Cache-Control 中,我们通过max-age来控制资源的有效期。max-age 不是一个时间戳,而是一个时间长度。在本例中,max-age 是 31536000 秒,它意味着该资源在 31536000 秒以内都是有效的,完美地规避了时间戳带来的潜在问题。
Cache-Control 相对于 expires 更加准确,它的优先级也更高。当 Cache-Control 与 expires 同时出现时,我们以 Cache-Control 为准。
协商缓存
协商缓存依赖于服务端与浏览器之间的通信。协商缓存机制下,浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。如果服务端提示缓存资源未改动(Not Modified),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是 304。
协商缓存的实现,从 **Last-Modified**
到 **Etag**
,Last-Modified 是一个时间戳,如果我们启用了协商缓存,它会在首次请求时随着 Response Headers 返回:
Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT
随后我们每次请求时,会带上一个叫 If-Modified-Since 的时间戳字段,它的值正是上一次 response 返回给它的 last-modified 值:
If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT
服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化。如果发生了变化,就会返回一个完整的响应内容,并在 Response Headers 中添加新的 Last-Modified 值;否则,返回如上图的 304 响应,Response Headers 不会再添加 Last-Modified 字段。
使用 Last-Modified 存在一些弊端,这其中最常见的就是这样两个场景:
- 我们编辑了文件,但文件的内容没有改变。服务端并不清楚我们是否真正改变了文件,它仍然通过最后编辑时间进行判断。因此这个资源在再次被请求时,会被当做新资源,进而引发一次完整的响应——不该重新请求的时候,也会重新请求。
- 当我们修改文件的速度过快时(比如花了 100ms 完成了改动),由于 If-Modified-Since 只能检查到以秒为最小计量单位的时间差,所以它是感知不到这个改动的——该重新请求的时候,反而没有重新请求了。
这两个场景其实指向了同一个 bug——服务器并没有正确感知文件的变化。为了解决这样的问题,Etag 作为 Last-Modified 的补充出现了。
Etag
是由服务器为每个资源生成的唯一的标识字符串,这个标识字符串可以是基于文件内容编码的,只要文件内容不同,它们对应的 Etag 就是不同的,反之亦然。因此 Etag 能够精准地感知文件的变化。
Etag 的生成过程需要服务器额外付出开销,会影响服务端的性能,这是它的弊端。因此启用 Etag 需要我们审时度势。正如我们刚刚所提到的——Etag 并不能替代 Last-Modified,它只能作为 Last-Modified 的补充和强化存在。
Etag 在感知文件变化上比 Last-Modified 更加准确,优先级也更高。当 Etag 和 Last-Modified 同时存在时,以 Etag 为准。
Service Worker Cache
Service Worker 是一种独立于主线程之外的 Javascript 线程。它脱离于浏览器窗体,因此无法直接访问 DOM。这样独立的个性使得 Service Worker 的“个人行为”无法干扰页面的性能,这个“幕后工作者”可以帮我们实现离线缓存、消息推送和网络代理等功能。我们借助 Service worker 实现的离线缓存就称为 Service Worker Cache。
Service Worker 的生命周期包括 install、active、working 三个阶段。一旦 Service Worker 被 install,它将始终存在,只会在 active 与 working 之间切换,除非我们主动终止它。这是它可以用来实现离线存储的重要先决条件.
Push Cache
Push Cache 是指 HTTP2 在 server push 阶段存在的缓存。这块的知识比较新,应用也还处于萌芽阶段,应用范围有限不代表不重要——HTTP2 是趋势、是未来。在它还未被推而广之的此时此刻,我仍希望大家能对 Push Cache 的关键特性有所了解:
- Push Cache 是缓存的最后一道防线。浏览器只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的情况下才会去询问 Push Cache。
- Push Cache 是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放。
- 不同的页面只要共享了同一个 HTTP2 连接,那么它们就可以共享同一个 Push Cache。
综上所述:
浏览器是通过响应头中的 Cache-Control 字段来设置是否缓存该资源。
在该缓存资源还未过期的情况下, 如果再次请求该资源,会直接返回缓存中的资源给浏览器。但如果缓存过期了,浏览器则会继续发起网络请求,并且在 HTTP 请求头中带上:If-None-Match:”4f80f-13c-3a1xb12a”服务器收到请求头后,会根据 If-None-Match 的值来判断请求的资源是否有更新。
- 如果没有更新,就返回 304 状态码,相当于服务器告诉浏览器:“这个缓存可以继续使用,这次就不重复发送数据给你了。
- 如果资源有更新,服务器就直接返回最新资源给浏览器。
3.登录状态是如何保持的?
如果服务器端发送的响应头内有 Set-Cookie 的字段,那么浏览器就会将该字段的内容保持到本地。当下次客户端再往该服务器发送请求时,客户端会自动在请求头中加入 Cookie 值后再发送出去。服务器端发现客户端发送过来的 Cookie 后,会去检查究竟是从哪一个客户端发来的连接请求,然后对比服务器上的记录,最后得到该用户的状态信息。
四、从输入URL到页面展示,这中间发生了什么?
dns解析过程:浏览器缓存 -> 操作系统缓存 -> hosts 文件 -> 本地域名服务器 -> 根域名服务器 -> 顶级域名服务器 -> 权威域名服务器
五、html、css、javascript是如何渲染成页面的?
1.构建 DOM 树
将 HTML 转换为浏览器能够理解的结构——DOM 树。
2.样式计算(Recalculate Style)
2.1 将 CSS 文本转换为浏览器可以理解的结构——styleSheets。
2.2 转换样式表中的属性值,使其标准化
如 2em、blue、bold,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。2em 被解析成了 32px,red 被解析成了 rgb(255,0,0),bold 被解析成了 700……。
2.3 计算出 DOM 树中每个节点的具体样式
- 首先是 CSS 继承。CSS 继承就是每个 DOM 节点都包含有父节点的样式。
- 样式层叠。
层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。
3.布局阶段
计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局。
3.1 创建布局树
- 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中;
- 而不可见的节点会被布局树忽略掉,如 head 标签下面的全部内容,再比如 body.p.span 这个元素,因为它的属性包含 dispaly:none,所以这个元素也没有被包进布局树。
3.2 布局计算
4.分层
因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。
5.图层绘制
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制。
6.栅格化操作
当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程,然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。
7.合成和显示
一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。
一个完整的渲染流程大致可总结为如下:
- 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。
- 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
- 创建布局树,并计算元素的布局信息。
- 对布局树进行分层,并生成分层树。
- 为每个图层生成绘制列表,并将其提交到合成线程。
- 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
- 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
- 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。
8.回流和重绘
回流:
当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程就是回流(也叫重排)。
重绘:
当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)。这个过程叫做重绘。由此我们可以看出,重绘不一定导致回流,回流一定会导致重绘。
常见的会导致回流的元素:
- 常见的几何属性有 width、height、padding、margin、left、top、border 等等。
- 最容易被忽略的操作:获取一些需要通过即时计算得到的属性,当你要用到像这样的属性:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight 时,浏览器为了获取这些值,也会进行回流。
- 当我们调用了 getComputedStyle 方法,或者 IE 里的 currentStyle 时,也会触发回流。原理是一样的,都为求一个“即时性”和“准确性”。
避免方式:
- 避免逐条改变样式,使用class名去合并样式.
- 批量修改 DOM:使用文档片段
**DocumentFragment**
创建一个子树,然后再拷贝到文档中
```javascript const list = document.querySelector(‘#list’); const fruits = [‘Apple’, ‘Orange’, ‘Banana’, ‘Melon’];<div id="list"></div>
const fragment = document.createDocumentFragment();
fruits.forEach(fruit => { const li = document.createElement(‘li’); li.innerHTML = fruit; fragment.appendChild(li); });
list.appendChild(fragment);
3. 极限优化时,可以将其样式修改为 `display: none` 后修改
4. 避免多次触发上面提到的那些会触发重排的方法,尽量用 **变量存储**
5. 对具有复杂动画的元素使用绝对定位,使其脱离文档流,否则会引起父元素及后续元素频繁回流
`**优点**`
- 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
- 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
- 对于 transform 和 opacity 效果,不会触发 layout 和 paint
**注意:**
部分浏览器缓存了一个 flush 队列,把我们触发的回流与重绘任务都塞进去,待到队列里的任务多起来、或者达到了一定的时间间隔,或者“不得已”的时候,再将这些任务一口气出队。但是当我们访问一些即使属性时,浏览器会为了获得此时此刻的、最准确的属性值,而提前将 flush 队列的任务出队。
<a name="fc5c6c51"></a>
## 六、变量提升:JavaScript代码是按顺序执行的吗?
- JavaScript 代码执行过程中,需要先做变量提升,而之所以需要实现变量提升,是因为 JavaScript 代码在执行之前需要先编译。
- **在编译阶段**,变量和函数会被存放到变量环境中,变量的默认值会被设置为 **undefined**;
- **在代码执行阶段**,JavaScript 引擎会从变量环境中去查找自定义的变量和函数。
- 如果在编译阶段,存在两个相同的函数,那么最终存放在变量环境中的是最后定义的那个,这是因为后定义的会覆盖掉之前定义的。
**JavaScript 的执行机制:先编译,再执行。**
<a name="37866edc"></a>
## 七、调用栈
当一段代码被执行时,JavaScript 引擎先会对其进行编译,并创建执行上下文。
1. 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
2. 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
3. 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。
- 每调用一个函数,JavaScript 引擎会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码。
- 如果在一个函数 A 中调用了另外一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶。
- 当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈。
- 当分配的调用栈空间被占满时,会引发“堆栈溢出”问题。
<a name="0fa62972"></a>
## 八、块级作用域
作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
**在 ES6 之前,ES 的作用域只有两种:**
- 全局作用域和函数作用域。全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
- 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
**变量提升所带来的问题:**
1. 变量容易在不被察觉的情况下被覆盖掉。
2. 本应销毁的变量没有被销毁。
ES6 是为了解决变量提升带来的缺陷,引入了块级作用域。
**块级作用域**就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。
es6 使用 let 或者 const 关键字来实现块级作用域。
接下来看一下这段代码的执行过程:
```javascript
function foo(){
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a)
console.log(b)
}
console.log(b)
console.log(c)
console.log(d)
}
foo()
- 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
- 通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。
- 在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。
当执行到作用域块中的console.log(a)这行代码时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。
块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。
var的创建和初始化被提升,赋值不会被提升。
let的创建被提升,初始化和赋值不会被提升。
function的创建、初始化和赋值均会被提升。
es5中模仿块级作用域是通过立即执行函数。
function outputNumbers(count){
(function(){
for(var i = 0; i < count; ++i){
console.log(i);
}
})();
console.log(i);//Uncaught ReferenceError: i is not defined
}
outputNumbers(5);
立即执行函数执行完毕就会销毁,i
也会随之销毁。根据闭包机制,内部的立即执行函数也可以访问到count
。这样就实现了块级作用域——无论在立即执行函数中声明什么变量都不会影响外部变量的使用。
九、闭包、作用域链、this
十、V8工作原理
1.数据是如何存储的
在 JavaScript 中,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。
在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是**代码空间**
、**栈空间**
、**堆空间**
。
- 代码空间主要是存储可执行代码的。
- 栈空间存储的是原始类型的变量值,以及引用类型的引用地址。
- 堆空间存放的是引用类型的值。JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的。
在编译过程中,如果 JavaScript 引擎判断到一个闭包,也会在堆空间创建换一个“closure(fn)”
的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存闭包中的变量。所以闭包中的变量是存储在“堆空间”中的。
JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。因此需要“栈”和“堆”两种空间。
2.垃圾回收
2.1栈的垃圾回收:
当一个函数执行结束之后,JavaScript 引擎会通过向下移动指针来销毁该函数保存在栈中的执行上下文。
堆的垃圾回收:
2.2堆垃圾回收
2.2.1代际假说
- 大部分对象存活时间很短
- 不被销毁的对象,会活的更久
2.2.2分类
V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。
新生代
算法:Scavenge 算法
原理:
1、把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。
2、新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。
3、先对对象区域中的垃圾做标记,标记完成之后,把这些存活的对象复制到空闲区域中
4、完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。
对象晋升策略:
经过两次垃圾回收依然还存活的对象,会被移动到老生区中。
老生代
算法:标记 - 清除(Mark-Sweep)算法
原理:
1、标记:标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
2、清除:将垃圾数据进行清除。
碎片:
对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存。
算法:标记 - 整理(Mark-Compact)算法
原理:
1、标记:和标记 - 清除的标记过程一样,从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素标记为活动对象。
2、整理:让所有存活的对象都向内存的一端移动
3、清除:清理掉端边界以外的内存
优化算法:增量标记(Incremental Marking)算法
原理:
1、为了降低老生代的垃圾回收而造成的卡顿,V8把一个完整的垃圾回收任务拆分为很多小的任务,让垃圾回收标记和 JavaScript 应用逻辑交替进行
十一、消息队列与事件循环
为了应对渲染进程主线程繁琐的任务(DOM解析、样式计算、布局、处理js任务、各种输入事件),引入了消息队列和事件循环系统。
从任务的复杂度逐渐增加,循序渐进的分析每种场景的处理方式。
- 单线程能够处理安排好的同步任务
- 引入事件循环接受新的任务
- 引入消息队列处理其他进程发来的任务
渲染主线程会频繁接收到来自于 IO 线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理。
要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。结合起来就是如下:- 添加一个消息队列;
- 渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程;
- 渲染主线程会循环地从消息队列头部中读取任务,执行任务。
- 引入宏任务和微任务解决任务优先级的问题。
- 通过Js回调功能解决单个js任务执行时间过长的问题。
十二、setTimeout是如何实现的?
执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,你不能将定时器的回调函数直接添加到消息队列中。
在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。
正常消息队列和延迟队列都是消息队列,都属于宏任务。
十三、XMLHttpRequest 是如何实现的?
setTimeout 是直接将延迟任务添加到延迟队列中,而 XMLHttpRequest 发起请求,是由浏览器的其他进程或者线程去执行,然后再将执行结果利用 IPC 的方式通知渲染进程,之后渲染进程再将对应的消息添加到消息队列中。
1.XMLHttpRequest 请求过程:
- 第一步:创建 XMLHttpRequest 对象。当执行到let xhr = new XMLHttpRequest()后,JavaScript 会创建一个 XMLHttpRequest 对象 xhr,用来执行实际的网络请求操作。
- 第二步:为 xhr 对象注册回调函数。因为网络请求比较耗时,所以要注册回调函数,这样后台任务执行完成之后就会通过调用回调函数来告诉其执行结果。XMLHttpRequest 的回调函数主要有下面几种:ontimeout,用来监控超时请求,如果后台请求超时了,该函数会被调用;onerror,用来监控出错信息,如果后台请求出错了,该函数会被调用;onreadystatechange,用来监控后台请求过程中的状态,比如可以监控到 HTTP 头加载完成的消息、HTTP 响应体消息以及数据加载完成的消息等。
- 第三步:配置基础的请求信息。注册好回调事件之后,接下来就需要配置基础的请求信息了,首先要通过 open 接口配置一些基础的请求信息,包括请求的地址、请求方法(是 get 还是 post)和请求方式(同步还是异步请求)。还可以通过xhr.responseType = “text”来配置服务器返回的格式,将服务器返回的数据自动转换为自己想要的格式,如果将 responseType 的值设置为 json,那么系统会自动将服务器返回的数据转换为 JavaScript 对象格式。
还可以通过 xhr.setRequestHeader 来添加。 - 第四步:发起请求。一切准备就绪之后,就可以调用xhr.send来发起网络请求了。
渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。
如果网络请求出错了,就会执行 xhr.onerror;
如果超时了,就会执行 xhr.ontimeout;
如果是正常的数据接收,就会执行 onreadystatechange 来反馈相应的状态。
2.XMLHttpRequest 使用过程中的“坑”
- 跨域问题
- HTTPS 混合内容的问题
HTTPS 混合内容是 HTTPS 页面中包含了不符合 HTTPS 安全要求的内容,比如包含了 HTTP 资源,通过 HTTP 加载的图像、视频、样式表、脚本等,都属于混合内容。
十四、宏任务和微任务
1.宏任务:
消息队列中宏任务的执行过程:
先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask;最后统计执行完成的时长等信息。
宏任务难以满足对时间精度要求较高的任务。比如在你调用 setTimeout 来设置回调任务的间隙,消息队列中就有可能被插入很多系统级的任务。如果中间被插入的任务执行时间过久的话,那么就会影响到后面任务的执行了。宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如后面要介绍的监听 DOM 变化的需求。
2.微任务
微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。也就是说每个宏任务在执行时,会创建自己的微任务队列。。
产生微任务有两种方式:
- 第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
- 第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。
微任务的执行时机:
在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。
如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。
MutationObserver
MutationObserver 是用来监听 DOM 变化的一套方法 ,将响应函数改成异步调用,可以不用在每次 DOM 变化都触发异步调用,而是等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。这样即使频繁地操纵 DOM,也不会对性能造成太大的影响。
MutationObserver 采用了“异步 + 微任务”的策略。
- 通过异步操作解决了同步操作的性能问题;
- 通过微任务解决了实时性的问题。
比如我们有个需求,希望插入DOM完成后,才执行某些行为,我们就可以这样做。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id='div'>
</div>
<script>
let observe = new MutationObserver(function(){
// 这一步之所以打印出p的数量,是为了验证插完dom节点后,才执行这一步。
console.log('插入完成',document.querySelectorAll('p').length)
})
observe.observe(div,{childList:true})
console.log('我是同步代码')
for(let i = 0 ;i < 50;i++){
div.appendChild(document.createElement('p'))
}
for(let i = 0 ;i < 50;i++){
div.appendChild(document.createElement('p'))
}
</script>
</body>
</html>
十五、生成器(Generator)的实现原理
使用方式:
function* genDemo() {
console.log("开始执行第一段")
yield 'generator 2'
console.log("开始执行第二段")
yield 'generator 2'
console.log("开始执行第三段")
yield 'generator 2'
console.log("执行结束")
return 'generator 2'
}
console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')
输出结果:
main 0
开始执行第一段
generator 2
main 1
开始执行第二段
generator 2
main 2
开始执行第三段
generator 2
main 3
执行结束
generator 2
main 4
观察输出结果,你会发现函数 genDemo 并不是一次执行完的,全局代码和 genDemo 函数交替执行。其实这就是生成器函数的特性,可以暂停执行,也可以恢复执行。
- 在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行。
- 外部函数可以通过 next 方法恢复函数的执行。
原理:
生成器函数是基于协程实现的。
协程:
协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程,比如当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。
协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
上面那段代码的执行图示:
从图中可以看出来协程的四点规则:
- 通过调用生成器函数 genDemo 来创建一个协程 gen,创建之后,gen 协程并没有立即执行。
- 要让 gen 协程执行,需要通过调用 gen.next。
- 当协程正在执行的时候,可以通过 yield 关键字来暂停 gen 协程的执行,并返回主要信息给父协程。
- 如果协程在执行期间,遇到了 return 关键字,那么 JavaScript 引擎会结束当前协程,并将 return 后面的内容返回给父协程。
V8是如何切换协程的调用栈的?
- 第一点:gen 协程和父协程是在主线程上交互执行的,并不是并发执行的,它们之前的切换是通过 yield 和 gen.next 来配合完成的。
- 第二点:当在 gen 协程中调用了 yield 方法时,JavaScript 引擎会保存 gen 协程当前的调用栈信息,并恢复父协程的调用栈信息。同样,当在父协程中执行 gen.next 时,JavaScript 引擎会保存父协程的调用栈信息,并恢复 gen 协程的调用栈信息。
使用使用生成器和 Promise 来改造一段 Promise 代码:
fetch('https://www.geekbang.org')
.then((response) => {
console.log(response)
return fetch('https://www.geekbang.org/test')
}).then((response) => {
console.log(response)
}).catch((error) => {
console.log(error)
})
改造后的代码:
//foo函数
function* foo() {
let response1 = yield fetch('https://www.geekbang.org')
console.log('response1')
console.log(response1)
let response2 = yield fetch('https://www.geekbang.org/test')
console.log('response2')
console.log(response2)
}
//执行foo函数的代码
let gen = foo()
function getGenPromise(gen) {
return gen.next().value
}
getGenPromise(gen).then((response) => {
console.log('response1')
console.log(response)
return getGenPromise(gen)
}).then((response) => {
console.log('response2')
console.log(response)
})
- 首先执行的是let gen = foo(),创建了 gen 协程。
- 然后在父协程中通过执行 gen.next 把主线程的控制权交给 gen 协程。gen 协程获取到主线程的控制权后,就调用 fetch 函数创建了一个 Promise 对象 response1,然后通过 yield 暂停 gen 协程的执行,并将 response1 返回给父协程。
- 父协程恢复执行后,调用 response1.then 方法等待请求结果。
- 等通过 fetch 发起的请求完成之后,会调用 then 中的回调函数,then 中的回调函数拿到结果之后,通过调用 gen.next 放弃主线程的控制权,将控制权交 gen 协程继续执行下个请求。
十六、async/await
使用 promise.then 也是相当复杂,虽然整个请求流程已经线性化了,但是代码里面包含了大量的 then 函数,使得代码依然不是太容易阅读。基于这个原因,ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。而且可以通过try catch捕获错误。
其实 async/await 技术背后的秘密就是 Promise 和生成器应用,往低层说就是微任务和协程应用。要搞清楚 async 和 await 的工作原理,我们就得对 async 和 await 分开分析。
async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。
async function foo() {
return 2
}
console.log(foo()) // Promise {<resolved>: 2}
await
async function foo() {
console.log(1)
let a = await 100
console.log(a)
console.log(2)
}
console.log(0)
foo()
console.log(3)
当执行到await 时,会默认创建一个 Promise 对象,并调用promise.resolve()函数,
let promise_ = new Promise((resolve,reject){
resolve(100)
})
await后面的代码相当于是promise.then(),JavaScript 引擎会将该任务提交给微任务队列。
然后 JavaScript 引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将 promise 对象返回给父协程。主线程的控制权已经交给父协程了,这时候父协程要做的一件事是调用 promise.then 来监控 promise 状态的改变。接下来继续执行父协程的流程,这里我们执行console.log(3),并打印出来 3。随后父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列,微任务队列中有resolve(100)的任务等待执行,执行到这里的时候,会触发 promise_.then 中的回调函数,
promise_.then((value)=>{
//回调函数被激活后
//将主线程控制权交给foo协程,并将vaule值传给协程
})
该回调函数被激活以后,会将主线程的控制权交给 foo 函数的协程,并同时将 value 值传给该协程。foo 协程激活之后,会把刚才的 value 值赋给了变量 a,然后 foo 协程继续执行后续语句,执行完成之后,将控制权归还给父协程。
总结:
使用 async/await 可以实现用同步代码的风格来编写异步代码,这是因为 async/await 的基础技术使用了生成器和 Promise,生成器是协程的实现,利用生成器能实现生成器函数的暂停和恢复。
(以前一直以为promise.then就是添加微任务,原来真的的微任务是promise.resolve/reject。then函数只是resolve/reject执行的副产品)
十七、JavaScript是如何影响DOM树构建的
1.DOM 树如何生成
HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。
网络进程接收到响应头之后,会根据响应头中的 content-type 字段来判断文件的类型,比如 content-type 的值是“text/html”,那么浏览器就会判断这是一个 HTML 类型的文件,然后为该请求选择或者创建一个渲染进程。渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据“喂”给 HTML 解析器。
第一步:通过分词器将字节流转换为Token;第二步:将Token解析为DOM节点;第三步:将DOM节点添加到DOM树中。
2.JavaScript 是如何影响 DOM 生成的
执行到js代码的时候,html解析器会停止DOM的解析,下载并执行js代码。
代码里引用了外部的 CSS 文件,那么在执行 JavaScript 之前,还需要等待外部的 CSS 文件下载完成,并解析生成 CSSOM 对象之后,才能执行 JavaScript 脚本。
脚本执行完成之后,HTML 解析器恢复解析过程,继续解析后续的内容,直至生成最终的 DOM。
3.如何规避js脚本阻塞dom
预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。
CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积。
async 或 defer 。如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码,
async 和 defer 虽然都是异步的,不过还有一些差异,使用 async 标志的脚本文件一旦加载完成,会立即执行;而使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。
defer标记的多个脚本需要按顺序执行 而aysnc标记的多个脚本是无序的
无论JS放在文档的尾部还是 头部,js执行的时候也是会阻塞页面的渲染。
十八、为什么CSS影响首次加载白屏时间
CSS 文件是会阻塞首次渲染的,因为在构建 DOM 的过程中需要 HTML 和 JavaScript 文件,在构造渲染树的过程中需要用到 CSS 文件。
1.页面渲染流程
如下HTML 文件中包含了 CSS 的外部引用和 JavaScript 外部文件
<html>
<head>
<link href="theme.css" rel="stylesheet">
</head>
<body>
<div>geekbang com</div>
<script src='foo.js'></script>
<div>geekbang com</div>
</body>
</html>
渲染流水线:
在接收到 HTML 数据之后的预解析过程中,HTML 预解析器识别出来了有 CSS 文件和 JavaScript 文件需要下载,然后就暂停解析,并同时发起这两个文件的下载请求。
不管 CSS 文件和 JavaScript 文件谁先到达,都要先等到 CSS 文件下载完成并生成 CSSOM,然后再执行 JavaScript 脚本,最后再继续构建 DOM,构建布局树,绘制页面。
因为 JavaScript 有修改 CSSOM 的能力,所以在执行 JavaScript 之前,还需要依赖 CSSOM。也就是说 CSS 在部分情况下也会阻塞 DOM 的生成。
等 DOM 和 CSSOM 都构建好之后,渲染引擎就会构造布局树。布局树的结构基本上就是复制 DOM 树的结构,不同之处在于 DOM 树中那些不需要显示的元素会被过滤掉,如 display:none 属性的元素、head 标签、script 标签等。复制好基本的布局树结构之后,渲染引擎会为对应的 DOM 元素选择对应的样式信息,这个过程就是样式计算。样式计算完成之后,渲染引擎还需要计算布局树中每个元素对应的几何位置,这个过程就是计算布局。通过样式计算和计算布局就完成了最终布局树的构建。再之后,就该进行后续的绘制操作了。
影响页面白屏时间的主要因素是下载 CSS 文件、下载 JavaScript 文件和执行 JavaScript。
所以要想缩短白屏时长,可以有以下策略:
- 通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了。
- 但并不是所有的场合都适合内联,那么还可以尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件。
- 还可以将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 sync 或者 defer。
- 对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件。
如下代码:
1:<script src="foo.js" type="text/javascript"></script>
2:<script defer src="foo.js" type="text/javascript"></script>
3:<script sync src="foo.js" type="text/javascript"></script>
4:<link rel="stylesheet" type="text/css" href="foo.css" />
5:<link rel="stylesheet" type="text/css" href="foo.css" media="screen"/>
6:<link rel="stylesheet" type="text/css" href="foo.css" media="print" />
7:<link rel="stylesheet" type="text/css" href="foo.css" media="orientation:landscape" />
8:<link rel="stylesheet" type="text/css" href="foo.css" media="orientation:portrait" />
第1条:下载JavaScript文件并执行同步代码,会阻塞页面渲染
第2条:defer异步下载JavaScript文件,会在HTML解析完成之后执行,不会阻塞页面渲染
第3条:sync异步下载JavaScript文件,下载完成之后会立即执行,有可能会阻塞页面渲染
第4条:下载CSS文件,可能阻塞页面渲染
第5条:media属性用于区分设备,screen表示用于有屏幕的设备,无法用于打印机、3D眼镜、盲文阅读机等,在题设手机条件下,会加载,与第4条一致,可能阻塞页面渲染
第6条:print用于打印预览模式或打印页面,这里不会加载,不会阻塞页面渲染
第7条:orientation:landscape表示横屏,与题设条件一致,会加载,与第4条一致,可能阻塞页面渲染
第8天:orientation:portrait表示竖屏,这里不会加载,不会阻塞页面渲染
如果页面中引入了css文件,一定要等到CSSOM完成之后才能执行js。因为不知道js是否有访问或者修改cssom的操作,所以 不管有没有访问css 都要等cssom完成过后执行js。
十九、页面优化
一个页面有三个阶段:加载阶段、交互阶段和关闭阶段。
- 加载阶段,是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和 JavaScript 脚本。
- 交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是 JavaScript 脚本。
- 关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。
1.加载阶段的优化原则:
- 如何减少关键资源的个数?一种方式是可以将 JavaScript 和 CSS 改成内联的形式,比如上图的 JavaScript 和 CSS,若都改成内联模式,那么关键资源的个数就由 3 个减少到了 1 个。另一种方式,如果 JavaScript 代码没有 DOM 或者 CSSOM 的操作,则可以改成 async 或者 defer 属性;同样对于 CSS,如果不是在构建页面之前加载的,则可以添加媒体取消阻止显现的标志。当 JavaScript 标签加上了 async 或者 defer、CSSlink 属性之前加上了取消阻止显现的标志后,它们就变成了非关键资源了。
- 如何减少关键资源的大小?可以压缩 CSS 和 JavaScript 资源,移除 HTML、CSS、JavaScript 文件中一些注释内容,也可以通过前面讲的取消 CSS 或者 JavaScript 中关键资源的方式。
- 如何减少关键资源 RTT 的次数?可以通过减少关键资源的个数和减少关键资源的大小搭配来实现。除此之外,还可以使用 CDN 来减少每次 RTT 时长。
2.交互阶段的优化主要是指渲染进程渲染帧速度。
- 减少JavaScript脚本执行时间
- 避免强制同步布局,添加 删除dom后计算样式布局是在另外一个任务中执行的,这时候获取样式信息,会将其变成同步任务。
- 避免布局抖动
- 合理利用CSS合成动画(标识 will-change 单独生成一个图层)避免频繁的垃圾回收。
- (尽量避免临时垃圾数据,优化存储结构,避免小颗粒对象产生)
面试题部分
一、输入 URL 到页面展示
大体上,可以分为六步,当然每一步都可以详细展开来说,这里先放一张总览图:
1.DNS域名解析
域名解析的过程:浏览器缓存 -> 操作系统缓存 -> hosts 文件 -> 本地域名服务器 -> 根域名服务器 -> 顶级域名服务器 -> 权威域名服务器
在网络世界,你肯定记得住网站的名称,但是很难记住网站的 IP 地址,因而也需要一个地址簿,就是 DNS 服务器。DNS 服务器是高可用、高并发和分布式的,它是树状结构,如图:
- 根 DNS 服务器 :返回顶级域 DNS 服务器的 IP 地址
- 顶级域 DNS 服务器:返回权威 DNS 服务器的 IP 地址
- 权威 DNS 服务器 :返回相应主机的 IP 地址
DNS的域名查找,在客户端和浏览器,本地DNS之间的查询方式是递归查询;在本地DNS服务器与根域及其子域之间的查询方式是迭代查询;
递归过程:
在客户端输入 URL 后,会有一个递归查找的过程,从浏览器缓存中查找->本地的hosts文件查找->找本地DNS解析器缓存查找->本地DNS服务器查找,这个过程中任何一步找到了都会结束查找流程。
如果本地DNS服务器无法查询到,则根据本地DNS服务器设置的转发器进行查询。若未用转发模式,则迭代查找过程如下图:
结合起来的过程,可以用一个图表示:
在查找过程中,有以下优化点:
- DNS存在着多级缓存,从离浏览器的距离排序的话,有以下几种: 浏览器缓存,系统缓存,路由器缓存,IPS服务器缓存,根域名服务器缓存,顶级域名服务器缓存,主域名服务器缓存。
- 在域名和 IP 的映射过程中,给了应用基于域名做负载均衡的机会,可以是简单的负载均衡,也可以根据地址和运营商做全局的负载均衡。
2.建立TCP连接
首先,判断是不是https的,如果是,则HTTPS其实是HTTP + SSL / TLS 两部分组成,也就是在HTTP上又加了一层处理加密信息的模块。服务端和客户端的信息传输都会通过TLS进行加密,所以传输的数据都是加密后的数据。
三次握手
- 第一次握手:建立连接。客户端发送连接请求报文段,将SYN位置为1,Sequence Number为x;然后,客户端进入SYN_SEND状态,等待服务器的确认;
- 第二次握手:服务器收到SYN报文段。服务器收到客户端的SYN报文段,需要对这个SYN报文段进行确认,设置Acknowledgment Number为x+1(Sequence Number+1);同时,自己还要发送SYN请求信息,将SYN位置为1,Sequence Number为y;服务器端将上述所有信息放到一个报文段(即SYN+ACK报文段)中,一并发送给客户端,此时服务器进入SYN_RECV状态;
- 第三次握手:客户端收到服务器的SYN+ACK报文段。然后将Acknowledgment Number设置为y+1,向服务器发送ACK报文段,这个报文段发送完毕以后,客户端和服务器端都进入ESTABLISHED状态,完成TCP三次握手。
SSL握手过程
- 服务端将自己的公钥登录至数字证书认证机构,数字证书认证机构用自己的私钥对服务端公钥署数字签名;
- 客户端发出 HTTPS 请求,请求服务端建立 SSL / TLS 连接;
- 服务端接收到 HTTPS 请求,将申请到的数字证书和服务端公钥一同返回给客户端;
- 客户端在接收到服务端公钥后,数字证书认证机构利用提前植入到浏览器的认证公钥,向数字证书认证机构认证公钥证书上的数字签名,确认服务器公钥的真实性;
- 认证通过之后,客户端随机生成通信使用的密钥,然后使用服务端公钥对密钥进行加密,返回给服务端;
- 服务端收到加密内容后,通过服务端私钥进行非对称解密,得到客户端密钥,至此双方都获得了对称加密的密钥;
- 之后,双方使用密钥进行对称加密通信。
**备注**
ACK:此标志表示应答域有效,就是说前面所说的TCP应答号将会包含在TCP数据包中;有两个取值:0和1,为1的时候表示应答域有效,反之为0。TCP协议规定,只有ACK=1时有效,也规定连接建立后所有发送的报文的ACK必须为1。
SYN(SYNchronization):在连接建立时用来同步序号。当SYN=1而ACK=0时,表明这是一个连接请求报文。对方若同意建立连接,则应在响应报文中使SYN=1和ACK=1. 因此, SYN置1就表示这是一个连接请求或连接接受报文。
FIN(finis)即完,终结的意思, 用来释放一个连接。当 FIN = 1 时,表明此报文段的发送方的数据已经发送完毕,并要求释放连接。
3.发送HTTP请求,服务器处理请求,返回响应结果
TCP连接建立后,浏览器就可以利用HTTP/HTTPS协议向服务器发送请求了。服务器接受到请求,就解析请求头,如果头部有缓存相关信息如if-none-match与if-modified-since,则验证缓存是否有效,若有效则返回状态码为304,若无效则重新返回资源,状态码为200.
这里有发生的一个过程是HTTP缓存,是一个常考的考点,大致过程如图:
其过程,比较多内容,可以参考我的这篇文章《浏览器相关原理(面试题)详细总结一》,这里我就不详细说了~
4.关闭TCP连接
- 第一次分手:主机1(可以使客户端,也可以是服务器端),设置Sequence Number和Acknowledgment Number,向主机2发送一个FIN报文段;此时,主机1进入FIN_WAIT_1状态;这表示主机1没有数据要发送给主机2了;
- 第二次分手:主机2收到了主机1发送的FIN报文段,向主机1回一个ACK报文段,Acknowledgment Number为Sequence Number加1;主机1进入FIN_WAIT_2状态;主机2告诉主机1,我”同意”你的关闭请求;
- 第三次分手:主机2向主机1发送FIN报文段,请求关闭连接,同时主机2进入LAST_ACK状态;
- 第四次分手:主机1收到主机2发送的FIN报文段,向主机2发送ACK报文段,然后主机1进入TIME_WAIT状态;主机2收到主机1的ACK报文段以后,就关闭连接;此时,主机1等待2MSL后依然没有收到回复,则证明Server端已正常关闭,那好,主机1也可以关闭连接了。
5.浏览器渲染
按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、栅格化和显示。如图:
- 渲染进程将 HTML 内容转换为能够读懂DOM 树结构。
- 渲染引擎将 CSS 样式表转化为浏览器可以理解的styleSheets,计算出 DOM 节点的样式。
- 创建布局树,并计算元素的布局信息。这个过程需要注意的是
回流和重绘
。 - 对布局树进行分层,并生成分层树。
- 为每个图层生成绘制列表,并将其提交到合成线程。合成线程将图层分图块,并栅格化将图块转换成位图。
- 合成线程发送绘制图块命令给浏览器进程。浏览器进程根据指令生成页面,并显示到显示器上。
构建 DOM 树
浏览器从网络或硬盘中获得HTML字节数据后会经过一个流程将字节解析为DOM树,先将HTML的原始字节数据转换为文件指定编码的字符,然后浏览器会根据HTML规范来将字符串转换成各种令牌标签,如html、body等。最终解析成一个树状的对象模型,就是dom树。
具体步骤:
- 转码(Bytes -> Characters)—— 读取接收到的 HTML 二进制数据,按指定编码格式将字节转换为 HTML 字符串
- Tokens 化(Characters -> Tokens)—— 解析 HTML,将 HTML 字符串转换为结构清晰的 Tokens,每个 Token 都有特殊的含义同时有自己的一套规则
- 构建 Nodes(Tokens -> Nodes)—— 每个 Node 都添加特定的属性(或属性访问器),通过指针能够确定 Node 的父、子、兄弟关系和所属 treeScope(例如:iframe 的 treeScope 与外层页面的 treeScope 不同)
- 构建 DOM 树(Nodes -> DOM Tree)—— 最重要的工作是建立起每个结点的父子兄弟关系
样式计算
渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
CSS 样式来源主要有 3 种,分别是通过 link 引用的外部 CSS 文件、style标签内的 CSS、元素的 style 属性内嵌的 CSS。
,其样式计算过程主要为:
可以看到上面的 CSS 文本中有很多属性值,如 2em、blue、bold,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。处理完成后再处理样式的继承和层叠,有些文章将这个过程称为CSSOM的构建过程。
页面布局
布局过程,即排除 script、meta
等功能化、非视觉节点,排除 display: none
的节点,计算元素的位置信息,确定元素的位置,构建一棵只包含可见元素布局树。如图:
其中,这个过程需要注意的是回流和重绘
,关于回流和重绘,详细的可以看我另一篇文章《浏览器相关原理(面试题)详细总结二》,这里就不说了~
生成分层树
页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree),如图:
如果你熟悉 PS,相信你会很容易理解图层的概念,正是这些图层叠加在一起构成了最终的页面图像。在浏览器中,你可以打开 Chrome 的”开发者工具”,选择”Layers”标签。渲染引擎给页面分了很多图层,这些图层按照一定顺序叠加在一起,就形成了最终的页面。
并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。那么需要满足什么条件,渲染引擎才会为特定的节点创建新的层呢?详细的可以看我另一篇文章《浏览器相关原理(面试题)详细总结二》,这里就不说了~
栅格化
合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。如图:
GitHub
通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。
显示
最后,合成线程发送绘制图块命令给浏览器进程。浏览器进程根据指令生成页面,并显示到显示器上,渲染过程完成。
css 并不会阻塞渲染,但是如果js解析依赖css的话,css也就会阻塞。??
二、缓存
(一)
缓存概念与分类
其实缓存是一个很「大」的概念,尤其 Web 缓存分为很多种。比如:
- 数据库缓存
- (代理)服务器缓存
- CDN 缓存
- 浏览器缓存
甚至一个函数的执行结果都可以进行缓存。而我们要分析的就是 HTTP 缓存,或者浏览器缓存
HTTP 缓存的官方概念:
HTTP 缓存(或 Web 缓存)是用于临时存储(缓存)Web 文档(如 HTML 页面和图像),以减少服务器延迟的一种信息技术。HTTP 缓存系统会保存下通过这套系统的文档的副本;如果满足某些条件,则可以由缓存满足后续请求。HTTP 缓存系统既可以指设备,也可以指计算机程序。
《HTTP 权威指南》一书中,这样介绍到缓存:
在前端开发中,性能一直都是被大家所重视的一点,然而判断一个网站的性能最直观的就是看网页打开的速度。其中提高网页反应速度的一个方式就是使用缓存。一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷。那么下面我们就来看看服务器端缓存的原理。
目前网络应用中很少有不接入缓存的案例。缓存之所以这么重要,是因为它能带来非常多的好处:
- 使得网页加载和呈现速度更快
- 减少了不必要的的数据传输,因而节省网络流量和带宽
- 在上一步的基础上,服务器的负担因此减少
事实上,前两点非常好理解,合理地使用缓存,能够最大限度地读取和利用本地已有的静态资源,减少了数据传输,加快了网页应用的呈现。对于第三点,可能一两个用户的访问对于减小服务器的负担没有明显效果。但请设想高并发的场景,使用缓存对于减小服务器压力非常有帮助。
对于浏览器缓存的分类,分类方式有很多,按缓存位置分类,我们有:
- memory cache
- disk cache
- Service Worker 等
浏览器的资源缓存分为 from disk cache 和 from memory cache 两类。当首次访问网页时,资源文件被缓存在内存中,同时也会在本地磁盘中保留一份副本。当用户刷新页面,如果缓存的资源没有过期,那么直接从内存中读取并加载。当用户关闭页面后,当前页面缓存在内存中的资源被清空。当用户再一次访问页面时,如果资源文件的缓存没有过期,那么将从本地磁盘进行加载并再次缓存到内存之中。
关于 from disk cache 和 from memory cache 的区别:
When you visit a URL in Chrome, the HTML and the other assets(like images) on the page are stored locally in a memory and a disk cache. Chrome will use the memory cache first because it is much faster, but it will also store the page in a disk cache in case you quit your browser or it crashes, because the disk cache is persistent.
翻译:
当您访问 chrome 中的 URL 时,页面上的 HTML 和其他资产(如图像)将本地存储在内存和磁盘缓存中。Chrome 将首先使用内存缓存,因为它的速度快得多,但它也会将页面存储在磁盘缓存中,以防您退出浏览器或它崩溃,因为磁盘缓存是持久的。
如果按失效策略分类,我们有:
- 强缓存
- 协商缓存
缓存策略是理解缓存的最重要一环,我们这节课重点了解一下强缓存和协商缓存。说到底缓存最重要的核心就是解决什么时候使用缓存,什么时候更新缓存的问题。
强缓存
强缓存是指客户端在第一次请求后,有效时间内不会再去请求服务器,而是直接使用缓存数据。
那么这个过程,就涉及到一个缓存有效时间的判断。在有效时间判断上,HTTP 1.0 和 HTTP 1.1 是有所不同的。
HTTP 1.0 版本规定响应头字段 Expires,它对应一个未来的时间戳。客户端第一次请求之后,服务端下发 Expires 响应头字段,当客户端再次需要请求时,先会对比当前时间和 Expires 头中设置的时间。如果当前时间早于 Expires 时间,那么直接使用缓存数据;反之,需要再次发送请求,更新数据。
响应头如:
Expires:Tue, 13 May 2020 09:33:34 GMT
上述 Expires 信息告诉浏览器:在 2020.05.13 号之前,可以直接使用该文本的缓存副本。
Expires 为负数,那么就等同于 no-cache,正数或零同 max-age 的表意是相同的。
但是使用 Expires 响应头存在一些小的瑕疵,比如:
- 可能会因为服务器和客户端的 GMT 时间不同,出现偏差
- 如果修改了本地时间,那么客户端端日期可能不准确
- 写法太复杂,字符串多个空格,少个字母,都会导致非法属性从而设置失效
在 HTTP 1.1 版本中,服务端使用 Cache-control 这个响应头,这个头部更加强大,它具有多个不同值:
- private:表示私有缓存,不能被共有缓存代理服务器缓存,不能在用户间共享,可被用户的浏览器缓存。
- public:表示共有缓存,可被代理服务器缓存,比如 CDN,允许多用户间共享
- max-age:值以秒为单位,表示缓存的内容会在该值后过期
- no-cache:这个字段并不表示不使用缓存,而是需要使用协商缓存。
- no-store:所有内容都不会被缓存
- must-revalidate:告诉浏览器,你这必须再次验证检查信息是否过期, 返回的代号就不是 200 而是 304 了
关于 Cache-control 的取值,还有其他情况比如 s-maxage,proxy-revalidate 等,以及 HTTP 1.0 的 Pragma,由于比较少用或已经过气,我们不再过多介绍。
我们看这样的 Cache-control 设置:
//Response HeadersCache-Control:private, max-age=0, must-revalidate
它表示:该资源只能被浏览器缓存,而不能被代理缓存。max-age 标识为 0,说明该缓存资源立即过期,must-revalidate 告诉浏览器,需要验证文件是否过期,接下来可能会使用协商缓存进行判断。
HTTP 规定,如果 Cache-control 的 max-age 和 Expires 同时出现,那么 max-age 的优先级更高,他会默认覆盖掉 expires。
关于 Cache-control 取值总结,我们可以参考 Google developer 的一个图示:
对于上图的翻译图:
协商缓存
我们进一步思考,强缓存判断的实质上是缓存资源是否超出某个时间或者某个时间段。很多情况是超出了这个时间或时间段,但是资源并没有更新。从优化的角度来说,我们真正应该关心的是服务器端文件是否已经发生了变化。此时我们需要用到协商缓存策略。
那如何做到知晓「服务器端文件是否已经发生了变化」了呢?回到强缓存上,强缓存关于是否使用缓存的决断完全是由浏览器作出的,单一的浏览器是不可能知道「服务器端文件是否已经发生了变化」的。那么协商缓存需要将是否使用缓存的决定权交给服务端,因此协商缓存还是需要一次网络请求的。
协商缓存过程:在浏览器端,当对某个资源的请求没有命中强缓存时,浏览器就会发一个请求到服务器,验证协商缓存是否命中,如果协商缓存命中,请求响应返回的 HTTP 状态为 304。
现在问题就到服务端如何判断资源有没有过期上了。服务端掌握着最新的资源,那么为了做对比,它需要知道客户端的资源信息。根据 HTTP 协议,这个决断是根据【Last-Modified,If-Modified-Since】和【ETag、If-None-Match】这两对 header 来作出的。
我们先来看【Last-Modified,If-Modified-Since】 这一对 header 主导的协商缓存过程:
- 浏览器第一次请求资源,服务端在返回资源的响应头中加入 Last-Modified 字段,这个字段表示这个资源在服务器上的最近修改时间
Last-Modified: Tue, 12 Jan 2019 09:08:53 GMT
- 浏览器收到响应,并记录 Last-Modified 这个响应头的值为 T
- 当浏览器再次向服务端请求该资源时,请求头加上 If-Modified-Since 的 header,这个 If-Modified-Since 的值正是上一次请求该资源时,后端返回的 Last-Modified 响应头值 T
- 服务端再次收到请求,根据请求头 If-Modified-Since 的值 T,判断相关资源是否在 T 时间后有变化;如果没有变化则返回 304 Not Modified,且并不返回资源内容,浏览器使用资源缓存值;如果有变化,则正常返回资源内容,且更新 Last-Modified 响应头内容
我们思考这种基于时间的判断方式和 HTTP 1.0 的 Expires 的问题类似,如果客户端的时间不准确,就会导致判断不可靠;同时 Last-Modified 标注的最后修改只能精确到秒级,如果某些文件在 1 秒钟以内,被修改多次的话,它将不能准确标注文件的修改时间;也要考虑到,一些文件也许会周期性的更改,但是他的内容并不改变,仅仅改变的修改时间,这时候使用 Last-Modified 就不是很合适了。为了弥补这种小缺陷,就有了 【ETag、If-None-Match】这一对 header 头来进行协商缓存的判断。
我们来看 【ETag、If-None-Match】这一对 header 主导的协商缓存过程:
- 浏览器第一次请求资源,服务端在返回资源的响应头中加入 Etag,Etag 能够弥补 Last-Modified 的问题,因为 Etag 的生成过程类似文件 hash 值,Etag 是一个字符串,不同文件内容对应不同的 Etag 值
//response HeadersETag:"751F63A30AB5F98F855D1D90D217B356"
- 浏览器收到响应,记录 Etag 这个响应头的值为 E
- 浏览器再次跟服务器请求这个资源时,在请求头上加上 If-None-Match,值为 Etag 这个响应头的值 E
- 服务端再次收到请求,根据请求头 If-None-Match 的值 E,根据资源生成一个新的 ETag,对比 E 和新的 Etag:如果两值相同,则说明资源没有变化,返回 304 Not Modified,同时携带着新的 ETag 响应头;如果两值不同,就正常返回资源内容,这时也更新 ETag 响应头
- 浏览器收到 304 的响应后,就会从缓存中加载资源
这里需要重点说明一下的是 Etag 的生成策略,实际上规范并没有强制说明,这就取决于各大厂商或平台的自主实现方式了:Apache 中,ETag 的值,默认是对文件的索引节(INode),大小(Size)和最后修改时间(MTime)进行混淆后得到的;MDN 使用 wiki 内容的十六进制数字的哈希值。
另外一个需要注意的细节是:Etag 优先级比 Last-Modified 高,如果他们组合出现在请求头当中,我们会优先采用 Etag 策略。同时 Etag 也有自己的问题:相同的资源,在两台服务器产生的 Etag 是不是相同的,所以对于使用服务器集群来处理请求的网站来说, Etag 的匹配概率会大幅降低。所在在这种情况下,使用 Etag 来处理缓存,反而会有更大的开销。
流程图
由上述内容我们开出:为了使缓存策略更加可靠,灵活,HTTP 1.0 版本 和 HTTP 1.1 版本的缓存策略一直是在渐进增强的。这也意味着 HTTP 1.0 版本 和 HTTP 1.1 版本关于缓存的特性可以同时使用,强制缓存和协商缓存也会同时使用。当然他们在混合使用时有优先级的限制,我们通过下面这个流程图来做一个总结:
根据这个流程,我们该如何合理应用缓存呢?一般来说:
优先级上:Cache-Control > Expires > ETag > Last-Modified
强制缓存优先级最高,并且资源的改动在缓存有效期内浏览器都不会发送请求,因此强制缓存的使用适用于大型且不易修改的的资源文件,例如第三方 CSS、JS 文件或图片资源。如果更加灵活的话,我们也可以为文件名加上 hash 进行版本的区分。
协商缓存灵活性高,适用于数据的缓存,根据上述知识的介绍,采用 Etag 标识进行对比灵活度最高,也最为可靠。对于数据的缓存,我们可以重点考虑存入内存中,因为内存加载速最快,并且数据体积小。
总结
这一讲我们梳理了缓存知识体系,实际上缓存并不难理解,只要搞清楚什么时候使用缓存这个关键问题,并以此问题为核心,结合 HTTP 协议关于缓存的发展变革,就很容易掌握理论知识。
下一讲,我们将集中总结常见的缓存面试考察点,并结合实战来巩固知识。
(二)
上一讲,我们了解了缓存的几种方式和基本概念;这一讲,让我们从应用和面试的角度出发,巩固理论基础,加深操作印象。
缓存和浏览器操作
缓存的重要一环是浏览器,常见浏览器行为对应的缓存行为有哪些呢?我们来做一个总结(注意,不同浏览器引擎、不同版本可能会有差别,读者可以根据不同情况酌情参考):
- 当用户 Ctrl + F5 强制刷新网页时,浏览器直接从服务器加载,跳过强缓存和协商缓存
- 当用户仅仅敲击 F5 刷新网页时,跳过强缓存,但是仍然会进行协商缓存过程
这里我借用 Alloy Team 的图进行一个总结:
缓存相关面试题目
知识点我们已经梳理完毕,是时候刷一下经典题目来巩固了。以下题目都可以在上述知识中找到答案,我们也当做一个总结和考察。
- 题目一:如何禁止浏览器不缓存静态资源
在实际工作中,很多场景都需要禁用浏览器缓存。比如可以使用 Chrome 隐私模式,在代码层面可以设置相关请求头:
Cache-Control: no-cache, no-store, must-revalidate
此外,也可以给请求的资源增加一个版本号:
我们也可以使用 Meta 标签来声明缓存规则:
- 题目二:设置以下 request/response header 会有什么效果?
cache-control: max-age=0
上述响应头属于强缓存,因为 max-age 设置为 0,所以浏览器必须发请求重新验证资源。这时候会走协商缓存机制,可能返回 200 或者 304。
- 题目三:设置以下 request/response header 会有什么效果?
cache-control: no-cache
上述响应头属于强缓存,因为设置 no-cache,所以浏览器必须发请求重新验证资源。这时候会走协商缓存机制。
- 题目四:除了上述方式,还有哪种方式可以设置浏览器必须发请求重新验证资源,走协商缓存机制?
设置 request/response header:
cache-control: must-revalidate
- 题目五:设置以下 request/response header 会有什么效果?
Cache-Control: max-age=60, must-revalidate
如果资源在 60s 内再次访问,走强缓存,可以直接返回缓存资源内容;如果超过 60s,则必须发送网络请求到服务端,去验证资源的有效性。
- 题目五:据你的经验,为什么大厂都不怎么用 Etag?
大厂多使用负载分担的方式来调度 HTTP 请求。因此,同一个客户端对同一个页面的多次请求,很可能被分配到不同的服务器来相应,而根据 ETag 的计算原理,不同的服务器,有可能在资源内容没有变化的情况下,计算出不一样的 Etag,而使得缓存失效。
- 题目六:Yahoo 的 YSlow 页面分析工具为什么推荐关闭 ETag?
因为 Etag 计算较为复杂,所以可能会使得服务端响应变慢。
缓存实战
我们来通过几个简单的真实项目案例实操一下缓存。
启动项目
首先创建项目:
mkdir cache`
`npm init
之后,得到 package.json,同时声明我们的相关依赖:
{
"name": "cache",
"version": "1.0.0",
"description": "Cache demo",
"main": "index.js",
"scripts": {
"start": "nodemon ./index.js"
},
"keywords": [
"cache",
"node"
],
"devDependencies": {
"@babel/core": "latest",
"@babel/preset-env": "latest",
"@babel/register": "latest",
"koa": "latest",
"koa-conditional-get": "^2.0.0",
"koa-etag": "^3.0.0",
"koa-static": "latest"
},
"dependencies": {
"nodemon": "latest"
},
"license": "ISC"
}
使用 nodemon 来启动并 watch Node 脚本,同时配置 .babelrc 如下:
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
]
]
}
在 cache/static 目录下,创建 index.html 和一张测试图片 web.png:
前端开发核心知识进阶
.cache img {
display: block;
width: 100%;
}
加载失败,请点击重试
看一下我们的核心脚本 index.js,其实就是一个简单的 NodeJS 服务:
index.js:
require('@babel/register');
require('./cache.js');
cache.js:
import Koa from 'koa'
import path from 'path'
import resource from 'koa-static'
const app = new Koa()
const host = 'localhost'
const port = 6666
app.use(resource(path.join(__dirname, './static')))
app.listen(port, () => {
console.log(`server is listen in ${host}:${port}`)
})
我们启动:
npm run start
得到页面:
应用缓存
我们来尝试加入一些缓存,首先应用强缓存,只需要在响应头上加入相关字段即可:
import Koa from 'koa'
import path from 'path'
import resource from 'koa-static'
const app = new Koa()
const host = 'localhost'
const port = 5999
app.use(async (ctx, next) => {
ctx.set({
'Cache-Control': 'max-age=5000'
})
await next()
})
app.use(resource(path.join(__dirname, './static')))
app.listen(port, () => {
console.log(`server is listen in ${host}:${port}`);
})
我们加入了 Cache-Control 头,设置 max-age 值为 5000。页面得到了响应:
再次刷新,得到了 200 OK(from memory cache)的标记:
当我们关掉浏览器,再次打开页面,得到了 200 OK(from disk cache)的标记。请体会与 from memory cache 的不同,memory cache 已经随着我们关闭浏览器而清除,这里是从 disk cache 取到的缓存。
我们尝试将 max-age 改为 5 秒,5 秒后再次刷新页面,发现缓存已经失效。这里读者可以自行试验,不再截图了。
下面来试验一下协商缓存,在初始 package.json 中,已经引入了 koa-etag 和 koa-conditional-get 这两个包依赖。
修改 cache.js 为:
import Koa from 'koa'
import path from 'path'
import resource from 'koa-static'
import conditional from 'koa-conditional-get'
import etag from 'koa-etag'
const app = new Koa()
const host = 'localhost'
const port = 5999
app.use(conditional())
app.use(etag())
app.use(resource(path.join(__dirname, './static')))
app.listen(port, () => {
console.log(`server is listen in ${host}:${port}`)
})
一切都很简单:
我们再次刷新浏览器,这次找到请求头,得到了 If-None-Match 字段,且内容与上一次的响应头相同。
因为我们的图片并没有发生变化,所以得到了 304 响应头。
读者可以自行尝试替换图片来验证内容。
这里我们主要使用了 Koa 库,如果对于原生 NodeJS,这里截取一个代码片段,供大家参考,该代码主要实现了 【if-modified-since/last-modified】头:
http.createServer((req, res) => {
let { pathname } = url.parse(req.url, true)
let absolutePath = path.join(__dirname, pathname)
fs.stat(path.join(__dirname, pathname), (err, stat) => {
// 路径不存在
if(err) {
res.statusCode = 404
res.end('Not Fount')
return
}
if(stat.isFile()) {
res.setHeader('Last-Modified', stat.ctime.toGMTString())
if(req.headers['if-modified-since'] === stat.ctime.toGMTString()) {
res.statusCode = 304
res.end()
return
}
fs.createReadStream(absolutePath).pipe(res)
}
})
})
该项目源码,读者可以在这里找到。
源码探究
在上面应用 Etag 试验当中,使用了 koa-etag 这个包,这里我们就来了解一下这个包的实现。
源码如下:
var calculate = require('etag');
var Stream = require('stream');
var fs = require('mz/fs');
module.exports = etag;
function etag(options) {
return function etag(ctx, next) {
return next()
.then(() => getResponseEntity(ctx))
.then(entity => setEtag(ctx, entity, options));
};
}
function getResponseEntity(ctx, options) {
// no body
var body = ctx.body;
if (!body || ctx.response.get('ETag')) return;
// type
var status = ctx.status / 100 | 0;
// 2xx
if (2 != status) return;
if (body instanceof Stream) {
if (!body.path) return;
return fs.stat(body.path).catch(noop);
} else if (('string' == typeof body) || Buffer.isBuffer(body)) {
return body;
} else {
return JSON.stringify(body);
}
}
function setEtag(ctx, entity, options) {
if (!entity) return;
ctx.response.etag = calculate(entity, options);
}
function noop() {}
我们看整个 etag 库就是一个中间件,它首先调用 getResponseEntity 方法获取响应体,根据 body 最终调用了 setEtag 方法,根据响应内容生产 etag。最终生成 etag 的计算过程又利用了 etag 这个包,再来看一下 etag 库:
'use strict'
module.exports = etag
var crypto = require('crypto')
var Stats = require('fs').Stats
var toString = Object.prototype.toString
function entitytag (entity) {
if (entity.length === 0) {
// fast-path empty
return '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"'
}
// compute hash of entity
var hash = crypto
.createHash('sha1')
.update(entity, 'utf8')
.digest('base64')
.substring(0, 27)
// compute length of entity
var len = typeof entity === 'string'
? Buffer.byteLength(entity, 'utf8')
: entity.length
return '"' + len.toString(16) + '-' + hash + '"'
}
function etag (entity, options) {
if (entity == null) {
throw new TypeError('argument entity is required')
}
// support fs.Stats object
var isStats = isstats(entity)
var weak = options && typeof options.weak === 'boolean'
? options.weak
: isStats
// validate argument
if (!isStats && typeof entity !== 'string' && !Buffer.isBuffer(entity)) {
throw new TypeError('argument entity must be string, Buffer, or fs.Stats')
}
// generate entity tag
var tag = isStats
? stattag(entity)
: entitytag(entity)
return weak
? 'W/' + tag
: tag
}
function isstats (obj) {
// genuine fs.Stats
if (typeof Stats === 'function' && obj instanceof Stats) {
return true
}
// quack quack
return obj && typeof obj === 'object' &&
'ctime' in obj && toString.call(obj.ctime) === '[object Date]' &&
'mtime' in obj && toString.call(obj.mtime) === '[object Date]' &&
'ino' in obj && typeof obj.ino === 'number' &&
'size' in obj && typeof obj.size === 'number'
}
function stattag (stat) {
var mtime = stat.mtime.getTime().toString(16)
var size = stat.size.toString(16)
return '"' + size + '-' + mtime + '"'
}
etag 方法接受一个 entity 最为入参一,entity 可以是 string、Buffer 或者 Stats 类型。如果是 Stats 类型,那么 etag 的生成方法会有不同:
var mtime = stat.mtime.getTime().toString(16)
var size = stat.size.toString(16)
return '"' + size + '-' + mtime + '"'
主要是根据 Stats 类型的 entity 的 mtime 和 size 特征,拼成一个 etag 即可。
如果是正常 String 或者 Buffer 类型,etag 的生成方法依赖了内置 crypto 包,主要是根据 entity 生成 hash,hash 生成主要依赖了 sha1 加密方法:
var hash = crypto
.createHash('sha1')
.update(entity, 'utf8')
.digest('base64')
了解了这些,如果面试官再问「Etag 的生成方法」,我想读者已经能够有一定底气了。
实现一个验证缓存的轮子
分析完关于 etag 的这个库,我们来尝试自救造一个轮子,也当作留给大家的一个作业。这个轮子的需要完成验证缓存是否可用的功能,它接受请求头和响应头,并根据这两个头部,返回一个布尔值,表示缓存是否可用。
应用举例:
var reqHeaders = { 'if-none-match': '"foo"' }
var resHeaders = { 'etag': '"bar"' }
isFresh(reqHeaders, resHeaders)
// => false
var reqHeaders = { 'if-none-match': '"foo"' }
var resHeaders = { 'etag': '"foo"' }
isFresh(reqHeaders, resHeaders)
// => true
在业务端使用时,可以直接:
var isFresh = require('is-fresh')
var http = require('http')
var server = http.createServer(function (req, res) {
if (isFresh(req.headers, {
'etag': res.getHeader('ETag'),
'last-modified': res.getHeader('Last-Modified')
})) {
res.statusCode = 304
res.end()
return
}
res.statusCode = 200
res.end('hello, world!')
})
server.listen(3000)
实现这道题目的前提就是先要了解缓存的基本知识,知晓缓存优先级。我们应该先验证 cache-control,之后验证 If-None-Match,之后是 If-Modified-Since。了解了这些,我们按部就班不难实现:
var CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/
function fresh (reqHeaders, resHeaders) {
// fields
var modifiedSince = reqHeaders['if-modified-since']
var noneMatch = reqHeaders['if-none-match']
if (!modifiedSince && !noneMatch) {
return false
}
var cacheControl = reqHeaders['cache-control']
if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) {
return false
}
// if-none-match
if (noneMatch && noneMatch !== '*') {
var etag = resHeaders['etag']
if (!etag) {
return false
}
var etagStale = true
var matches = parseTokenList(noneMatch)
for (var i = 0; i < matches.length; i++) {
var match = matches[i]
if (match === etag || match === 'W/' + etag || 'W/' + match === etag) {
etagStale = false
break
}
}
if (etagStale) {
return false
}
}
// if-modified-since
if (modifiedSince) {
var lastModified = resHeaders['last-modified']
var modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince))
if (modifiedStale) {
return false
}
}
return true
}
function parseHttpDate (date) {
var timestamp = date && Date.parse(date)
return typeof timestamp === 'number'
? timestamp
: NaN
}
function parseTokenList (str) {
var end = 0
var list = []
var start = 0
for (var i = 0, len = str.length; i < len; i++) {
switch (str.charCodeAt(i)) {
case 0x20: /* */
if (start === end) {
start = end = i + 1
}
break
case 0x2c: /* , */
list.push(str.substring(start, end))
start = end = i + 1
break
default:
end = i + 1
break
}
}
list.push(str.substring(start, end))
return list
}
这个实现比较简单,读者可以尝试解读该源码,如果这两讲的内容你已经融会贯通,上述实现并不困难。
当然,缓存的轮子却也没有「想象的那么简单」,「上述的代码强健性是否足够」?「API 设计是否优雅」?等这些话题值得思考。也希望在整个内容完结后,针对实战代码的优化和调试,应用的踩坑和解决能够大家继续交流。我们也会针对上述代码,展开更多内容。
总结
我们通过两讲的学习,介绍了缓存这一热门话题。缓存体现了理论规范和实战结合的美妙,是网络应用经验的结晶。建议读者可以多观察大型门户网站、页面应用,并结合工程化知识来看待并学习缓存。
1)浏览器缓存策略
浏览器每次发起请求时,先在本地缓存中查找结果以及缓存标识,根据缓存标识来判断是否使用本地缓存。如果缓存有效,则使
用本地缓存;否则,则向服务器发起请求并携带缓存标识。根据是否需向服务器发起HTTP请求,将缓存过程划分为两个部分:
强制缓存和协商缓存,强缓优先于协商缓存。
- 强缓存,服务器通知浏览器一个缓存时间,在缓存时间内,下次请求,直接用缓存,不在时间内,执行比较缓存策略。
- 协商缓存,让客户端与服务器之间能实现缓存文件是否更新的验证、提升缓存的复用率,将缓存信息中的Etag和Last-Modified
通过请求发送给服务器,由服务器校验,返回304状态码时,浏览器直接使用缓存。
HTTP缓存都是从第二次请求开始的:
- 第一次请求资源时,服务器返回资源,并在response header中回传资源的缓存策略;
- 第二次请求时,浏览器判断这些请求参数,击中强缓存就直接200,否则就把请求参数加到request header头中传给服务器,看是否击中协商缓存,击中则返回304,否则服务器会返回新的资源。这是缓存运作的一个整体流程图:
2)强缓存
- 强缓存命中则直接读取浏览器本地的资源,在network中显示的是from memory或者from disk
- 控制强制缓存的字段有:Cache-Control(http1.1)和Expires(http1.0)
- Cache-control是一个相对时间,用以表达自上次请求正确的资源之后的多少秒的时间段内缓存有效。
- Expires是一个绝对时间。用以表达在这个时间点之前发起请求可以直接从浏览器中读取数据,而无需发起请求
- Cache-Control的优先级比Expires的优先级高。前者的出现是为了解决Expires在浏览器时间被手动更改导致缓存判断错误的问题。
如果同时存在则使用Cache-control。
3)强缓存-expires
- 该字段是服务器响应消息头字段,告诉浏览器在过期时间之前可以直接从浏览器缓存中存取数据。
- Expires 是 HTTP 1.0 的字段,表示缓存到期时间,是一个绝对的时间 (当前时间+缓存时间)。在响应消息头中,设置这个字段之后,就可以告诉浏览器,在未过期之前不需要再次请求。
- 由于是绝对时间,用户可能会将客户端本地的时间进行修改,而导致浏览器判断缓存失效,重新请求该资源。此外,即使不考虑修改,时差或者误差等因素也可能造成客户端与服务端的时间不一致,致使缓存失效。
- 优势特点
- 1、HTTP 1.0 产物,可以在HTTP 1.0和1.1中使用,简单易用。
- 2、以时刻标识失效时间。
- 劣势问题
- 1、时间是由服务器发送的(UTC),如果服务器时间和客户端时间存在不一致,可能会出现问题。
- 2、存在版本问题,到期之前的修改客户端是不可知的。
4)强缓存-cache-control
- 已知Expires的缺点之后,在HTTP/1.1中,增加了一个字段Cache-control,该字段表示资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求。
- 这两者的区别就是前者是绝对时间,而后者是相对时间。下面列举一些
Cache-control
字段常用的值:(完整的列表可以查看MDN)max-age
:即最大有效时间。must-revalidate
:如果超过了max-age
的时间,浏览器必须向服务器发送请求,验证资源是否还有效。no-cache
:不使用强缓存,需要与服务器验证缓存是否新鲜。no-store
: 真正意义上的“不要缓存”。所有内容都不走缓存,包括强制和对比。public
:所有的内容都可以被缓存 (包括客户端和代理服务器, 如 CDN)private
:所有的内容只有客户端才可以缓存,代理服务器不能缓存。默认值。
- Cache-control 的优先级高于 Expires,为了兼容 HTTP/1.0 和 HTTP/1.1,实际项目中两个字段都可以设置。
- 该字段可以在请求头或者响应头设置,可组合使用多种指令:
- 可缓存性
:- public:浏览器和缓存服务器都可以缓存页面信息
- private:default,代理服务器不可缓存,只能被单个用户缓存
- no-cache:浏览器器和服务器都不应该缓存页面信息,但仍可缓存,只是在缓存前需要向服务器确认资源是否被更改。可配合private,
过期时间设置为过去时间。 - only-if-cache:客户端只接受已缓存的响应
- 到期
- max-age=:缓存存储的最大周期,超过这个周期被认为过期。
- s-maxage=:设置共享缓存,比如can。会覆盖max-age和expires。
- max-stale[=]:客户端愿意接收一个已经过期的资源
- min-fresh=:客户端希望在指定的时间内获取最新的响应
- stale-while-revalidate=:客户端愿意接收陈旧的响应,并且在后台一部检查新的响应。时间代表客户端愿意接收陈旧响应
的时间长度。 - stale-if-error=:如新的检测失败,客户端则愿意接收陈旧的响应,时间代表等待时间。
- 重新验证和重新加载
- must-revalidate:如页面过期,则去服务器进行获取。
- proxy-revalidate:用于共享缓存。
- immutable:响应正文不随时间改变。
- 其他
- no-store:绝对禁止缓存
- no-transform:不得对资源进行转换和转变。例如,不得对图像格式进行转换。
- 可缓存性
- 优势特点
- 1、HTTP 1.1 产物,以时间间隔标识失效时间,解决了Expires服务器和客户端相对时间的问题。
- 2、比Expires多了很多选项设置。
- 劣势问题
- 1、存在版本问题,到期之前的修改客户端是不可知的。
5)协商缓存
- 协商缓存的状态码由服务器决策返回200或者304
- 当浏览器的强缓存失效的时候或者请求头中设置了不走强缓存,并且在请求头中设置了If-Modified-Since 或者 If-None-Match 的时候,会将这两个属性值到服务端去验证是否命中协商缓存,如果命中了协商缓存,会返回 304 状态,加载浏览器缓存,并且响应头会设置 Last-Modified 或者 ETag 属性。
- 对比缓存在请求数上和没有缓存是一致的,但如果是 304 的话,返回的仅仅是一个状态码而已,并没有实际的文件内容,因此 在响应体体积上的节省是它的优化点。
- 协商缓存有 2 组字段(不是两个),控制协商缓存的字段有:Last-Modified/If-Modified-since(http1.0)和Etag/If-None-match(http1.1)
- Last-Modified/If-Modified-since表示的是服务器的资源最后一次修改的时间;Etag/If-None-match表示的是服务器资源的唯一标
识,只要资源变化,Etag就会重新生成。 - Etag/If-None-match的优先级比Last-Modified/If-Modified-since高。
6)协商缓存-协商缓存-Last-Modified/If-Modified-since
- 1.服务器通过
Last-Modified
字段告知客户端,资源最后一次被修改的时间,例如Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT
- 2.浏览器将这个值和内容一起记录在缓存数据库中。
- 3.下一次请求相同资源时时,浏览器从自己的缓存中找出“不确定是否过期的”缓存。因此在请求头中将上次的
Last-Modified
的值写入到请求头的If-Modified-Since
字段 - 4.服务器会将
If-Modified-Since
的值与Last-Modified
字段进行对比。如果相等,则表示未修改,响应 304;反之,则表示修改了,响应 200 状态码,并返回数据。 - 优势特点
- 1、不存在版本问题,每次请求都会去服务器进行校验。服务器对比最后修改时间如果相同则返回304,不同返回200以及资源内容。
- 劣势问题
- 2、只要资源修改,无论内容是否发生实质性的变化,都会将该资源返回客户端。例如周期性重写,这种情况下该资源包含的数据实际上一样的。
- 3、以时刻作为标识,无法识别一秒内进行多次修改的情况。 如果资源更新的速度是秒以下单位,那么该缓存是不能被使用的,因为它的时间单位最低是秒。
- 4、某些服务器不能精确的得到文件的最后修改时间。
- 5、如果文件是通过服务器动态生成的,那么该方法的更新时间永远是生成的时间,尽管文件可能没有变化,所以起不到缓存的作用。
7)协商缓存-Etag/If-None-match
- 为了解决上述问题,出现了一组新的字段
Etag
和If-None-Match
Etag
存储的是文件的特殊标识(一般都是 hash 生成的),服务器存储着文件的Etag
字段。之后的流程和Last-Modified
一致,只是Last-Modified
字段和它所表示的更新时间改变成了Etag
字段和它所表示的文件 hash,把If-Modified-Since
变成了If-None-Match
。服务器同样进行比较,命中返回 304, 不命中返回新资源和 200。- 浏览器在发起请求时,服务器返回在Response header中返回请求资源的唯一标识。在下一次请求时,会将上一次返回的Etag值赋值给If-No-Matched并添加在Request Header中。服务器将浏览器传来的if-no-matched跟自己的本地的资源的ETag做对比,如果匹配,则返回304通知浏览器读取本地缓存,否则返回200和更新后的资源。
- Etag 的优先级高于 Last-Modified。
- 优势特点
- 1、可以更加精确的判断资源是否被修改,可以识别一秒内多次修改的情况。
- 2、不存在版本问题,每次请求都回去服务器进行校验。
- 劣势问题
- 1、计算ETag值需要性能损耗。
- 2、分布式服务器存储的情况下,计算ETag的算法如果不一样,会导致浏览器从一台服务器上获得页面内容后到另外一台服务器上进行验证时现ETag不匹配的情况。
三、跨域
同源策略是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到XSS、CSRF等攻击。所谓同源是指”协议+域名+端口”三者相同,即便两个不同的域名指向同一个ip地址,也非同源。
同源策略限制内容有:
- Cookie、LocalStorage、IndexedDB 等存储性内容
- DOM 节点
- AJAX 请求发送后,结果被浏览器拦截了
但是有三个标签是允许跨域加载资源: