极客<<浏览器工作原理与实践>>笔记

721 阅读44分钟

开篇词:

你当然可以把浏览器看成一个黑盒,左边输入一个 URL,经过黑盒处理之后,右边返回你预期的效果。如果你对黑盒一无所知,你倒依然可以写前端代码,也可以使用很多最佳实践的策略来优化代码,这就如同不了解操作系统的工作原理同样可以在操作系统上写应用一样。

但如果你理解了这个黑盒子是如何工作的,那情况就不同了。你可以站在更高的维度审视你的项目,通过全视野快速定位项目中不合理的地方。比如,首屏的显示就涉及了 DNS、HTTP、DOM 解析、CSS 阻塞、JavaScript 阻塞等技术因素,其中一项没处理好就可能导致整个页面的延时。

而如果你了解了浏览器的工作原理,更加可以把这些知识点串成线,连成网,最终形成自己的知识体系,练就像专家一样思考问题、解决问题的能力。

QA摘录:

前Chrome的排版引擎是Blink,Blink是从Webkit分支独立出来的,起初和Webkit基本是一样的,但是随着Google在Blink上的发力,目前的差异已经非常大了。 其实站在大的结构层面来看,Blink,WebKit , Gecko三者的渲染流水线基本是类似的,只是一些术语有些不同。 但是落实到具体实现层面来看,差别就很大了,如JS引擎的实现,排版过程,绘制过程等都有很大差异。

宏观视角下的浏览器:

06 | 渲染流程(下):HTML、CSS和JavaScript,是如何变成页面的?

图层绘制在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来我们看看渲染引擎是怎么实现图层绘制的?

现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?

答案依然是否定的。因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。如果你熟悉 PS,相信你会很容易理解图层的概念,正是这些图层叠加在一起构成了最终的页面图像。

要想直观地理解什么是图层,你可以打开 Chrome 的“开发者工具”,选择“Layers”标签,就可以可视化页面的分层情况,如下图所示:

现在你知道了浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。

布局树和图层树关系示意图:

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。如上图中的 span 标签没有专属图层,那么它们就从属于它们的父节点图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层。

那么需要满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。

第一点,拥有层叠上下文属性的元素会被提升为单独的一层。页面是个二维平面,但是层叠上下文能够让 HTML 元素具有三维概念,这些 HTML 元素按照自身属性的优先级分布在垂直于这个二维平面的 z 轴上。你可以结合下图来直观感受

从图中可以看出,明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等,都拥有层叠上下文属性。

第二点,需要剪裁(clip)的地方也会被创建为图层。

出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。

通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。

在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。

基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512,如下图所示:

然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的。

通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。

有了上面介绍渲染流水线的基础,我们再来看看三个和渲染流水线相关的概念——“重排”“重绘”和“合成”。理解了这三个概念对于你后续 Web 的性能优化会有很大帮助。

1. 更新了元素的几何属性(重排)

从上图可以看出,如果你通过 JavaScript 或者 CSS 修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。

2. 更新元素的绘制属性(重绘)接下来,我们再来看看重绘,比如通过 JavaScript 更改某些元素的背景颜色,渲染流水线会怎样调整呢?你可以参考下图:

相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。

3. 直接合成阶段

在上图中,我们使用了 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

至于如何用这些概念去优化页面,我们会在后面相关章节做详细讲解的,这里你只需要先结合“渲染流水线”弄明白这三个概念及原理就行。

通过这么多年的生活和工作经验来看,无论是做架构设计、产品设计,还是具体到代码的实现,甚至处理生活中的一些事情,能够把复杂问题简单化的人都是具有大智慧的。所以,在工作或生活中,你若想要简化遇到的问题,就要刻意地练习,练就抓住问题本质的能力,把那些复杂的问题简单化,从而最终真正解决问题。

浏览器中的页面

21 | Chrome开发者工具:利用网络面板做性能分析

Chrome 开发者工具(简称 DevTools)是一组网页制作和调试的工具,内嵌于 Google Chrome 浏览器中。它一共包含了 10 个功能面板:

下载信息概要中,你要重点关注下 DOMContentLoaded 和 Load 两个事件,以及这两个事件的完成时间。DOMContentLoaded,这个事件发生后,说明页面已经构建好 DOM 了,这意味着构建 DOM 所需要的 HTML 文件、JavaScript 文件、CSS 文件都已经下载完成了。Load,说明浏览器已经加载了所有的资源(图像、样式表等)。

第一个是 Queuing,也就是排队的意思,当浏览器发起一个请求的时候,会有很多原因导致该请求不能被立即执行,而是需要排队等待。导致请求处于排队状态的原因有很多。

首先,页面中的资源是有优先级的,比如 CSS、HTML、JavaScript 等都是页面中的核心文件,所以优先级最高;而图片、视频、音频这类资源就不是核心资源,优先级就比较低。通常当后者遇到前者时,就需要“让路”,进入待排队状态。其次,我们前面也提到过,浏览器会为每个域名最多维护 6 个 TCP 连接,如果发起一个 HTTP 请求时,这 6 个 TCP 连接都处于忙碌状态,那么这个请求就会处于排队状态。

等待排队完成之后,就要进入发起连接的状态了。不过在发起连接之前,还有一些原因可能导致连接过程被推迟,这个推迟就表现在面板中的 Stalled 上,它表示停滞的意思。

接下来,就到了 Initial connection/SSL 阶段了,也就是和服务器建立连接的阶段,这包括了建立 TCP 连接所花费的时间;不过如果你使用了 HTTPS 协议,那么还需要一个额外的 SSL 握手时间,这个过程主要是用来协商一些加密信息的。

和服务器建立好连接之后,网络进程会准备请求数据,并将其发送给网络,这就是 Request sent 阶段。通常这个阶段非常快,因为只需要把浏览器缓冲区的数据发送出去就结束了,并不需要判断服务器是否接收到了,所以这个时间通常不到 1 毫秒。

数据发送出去了,接下来就是等待接收服务器第一个字节的数据,这个阶段称为 Waiting (TTFB),通常也称为“第一字节时间”。 TTFB 是反映服务端响应速度的重要指标,对服务器来说,TTFB 时间越短,就说明服务器响应越快。

接收到第一个字节之后,进入陆续接收完整数据的阶段,也就是 Content Download 阶段,这意味着从第一字节时间到接收到全部响应数据所用的时间。

22 | DOM树:JavaScript是如何影响DOM树构建的?

什么是 DOM

从网络传给渲染引擎的 HTML 文件字节流是无法直接被渲染引擎理解的,所以要将其转化为渲染引擎能够理解的内部结构,这个结构就是 DOM。DOM 提供了对 HTML 文档结构化的表述。在渲染引擎中,DOM 有三个层面的作用。

从页面的视角来看,DOM 是生成页面的基础数据结构。

从 JavaScript 脚本视角来看,DOM 提供给 JavaScript 脚本操作的接口,通过这套接口,JavaScript 可以对 DOM 结构进行访问,从而改变文档的结构、样式和内容。

从安全视角来看,DOM 是一道安全防护线,一些不安全的内容在 DOM 解析阶段就被拒之门外了。

DOM 树如何生成

在渲染引擎内部,有一个叫 HTML 解析器(HTMLParser)的模块,它的职责就是负责将 HTML 字节流转换为 DOM 结构。

HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。

解析 HTML 也是一样的,需要通过分词器先将字节流转换为一个个 Token,分为 Tag Token 和文本 Token。上述 HTML 代码通过词法分析生成的 Token 如下所示:

HTML 解析器维护了一个 Token 栈结构,将 Token 解析为 DOM 节点,并将 DOM 节点添加到 DOM 树中。

如果压入到栈中的是 StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。

如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。

如果分词器解析出来的是 EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。

这里需要补充说明下,HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构。

但是解析到

这里需要重点关注下载环境,因为 JavaScript 文件的下载过程会阻塞 DOM 解析,而通常下载又是非常耗时的,会受到网络环境、JavaScript 文件大小等因素的影响。

不过 Chrome 浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。

再回到 DOM 解析上,我们知道引入 JavaScript 线程会阻塞 DOM,不过也有一些相关的策略来规避,比如使用 CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积。另外,如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码。

async 和 defer 虽然都是异步的,不过还有一些差异,使用 async 标志的脚本文件一旦加载完成,会立即执行;而使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。

该示例中,JavaScript 代码出现了 div1.style.color = ‘red' 的语句,**它是用来操纵 CSSOM 的,所以在执行 JavaScript 之前,需要先解析 JavaScript 语句之上所有的 CSS 样式。**所以如果代码里引用了外部的 CSS 文件,那么在执行 JavaScript 之前,还需要等待外部的 CSS 文件下载完成,并解析生成 CSSOM 对象之后,才能执行 JavaScript 脚本。

而 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操纵了 CSSOM 的,所以渲染引擎在遇到 JavaScript 脚本时,不管该脚本是否操纵了 CSSOM,都会执行 CSS 文件下载,解析操作,再执行 JavaScript 脚本。

所以说 JavaScript 脚本是依赖样式表的,这又多了一个阻塞过程。至于如何优化,我们在下篇文章中再来深入探讨。

通过上面的分析,我们知道了 JavaScript 会阻塞 DOM 生成,而样式文件又会阻塞 JavaScript 的执行,所以在实际的工程中需要重点关注 JavaScript 文件和样式表文件,使用不当会影响到页面性能的。

23 | 渲染流水线:CSS如何影响首次加载时的白屏时间?

本文我们先站在渲染流水线的视角来介绍 CSS 是如何工作的,然后通过 CSS 的工作流程来分析性能瓶颈,最后再来讨论如何减少首次加载时的白屏时间。

                                仅含有 CSS 的页面渲染流水线

                               含有 JavaScript 和 CSS 的页面渲染流水线

上一篇文章中我们提到过在解析 DOM 的过程中,如果遇到了 JavaScript 脚本,那么需要先暂停 DOM 解析去执行 JavaScript,因为 JavaScript 有可能会修改当前状态下的 DOM。

不过在执行 JavaScript 脚本之前,如果页面中包含了外部 CSS 文件的引用,或者通过 style 标签内置了 CSS 内容,那么渲染引擎还需要将这些内容转换为 CSSOM,因为 JavaScript 有修改 CSSOM 的能力,所以在执行 JavaScript 之前,还需要依赖 CSSOM。也就是说 CSS 在部分情况下也会阻塞 DOM 的生成。

从图中可以看出来,在接收到 HTML 数据之后的预解析过程中,HTML 预解析器识别出来了有 CSS 文件和 JavaScript 文件需要下载,然后就同时发起这两个文件的下载请求,需要注意的是,这两个文件的下载过程是重叠的,所以下载时间按照最久的那个文件来算。

后面的流水线就和前面是一样的了,不管 CSS 文件和 JavaScript 文件谁先到达,都要先等到 CSS 文件下载完成并生成 CSSOM,然后再执行 JavaScript 脚本,最后再继续构建 DOM,构建布局树,绘制页面。

影响页面展示的因素以及优化策略

前面我们为什么要花这么多文字来分析渲染流水线呢?主要原因就是渲染流水线影响到了首次页面展示的速度,而首次页面展示的速度又直接影响到了用户体验,所以我们分析渲染流水线的目的就是为了找出一些影响到首屏展示的因素,然后再基于这些因素做一些针对性的调整。

那么接下来我们就来看看从发起 URL 请求开始,到首次显示页面的内容,在视觉上经历的三个阶段。

第一个阶段,等请求发出去之后,到提交数据阶段,这时页面展示出来的还是之前页面的内容。关于提交数据你可以参考前面《04 | 导航流程:从输入 URL 到页面展示,这中间发生了什么?》这篇文章。

第二个阶段,提交数据之后渲染进程会创建一个空白页面,我们通常把这段时间称为解析白屏,并等待 CSS 文件和 JavaScript 文件的加载完成,生成 CSSOM 和 DOM,然后合成布局树,最后还要经过一系列的步骤准备首次渲染。

第三个阶段,等首次渲染完成之后,就开始进入完整页面的生成阶段了,然后页面会一点点被绘制出来。

影响第一个阶段的因素主要是网络或者是服务器处理这块儿,前面文章中我们已经讲过了,这里我们就不再继续分析了。至于第三个阶段,我们会在后续文章中分析,所以这里也不做介绍了。

现在我们重点关注第二个阶段,这个阶段的主要问题是白屏时间,如果白屏时间过久,就会影响到用户体验。为了缩短白屏时间,我们来挨个分析这个阶段的主要任务,包括了解析 HTML、下载 CSS、下载 JavaScript、生成 CSSOM、执行 JavaScript、生成布局树、绘制页面一系列操作。

通常情况下的瓶颈主要体现在下载 CSS 文件、下载 JavaScript 文件和执行 JavaScript。

所以要想缩短白屏时长,可以有以下策略:

通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了。

但并不是所有的场合都适合内联,那么还可以尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件。

还可以将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 sync 或者 defer。

对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件。

通过以上策略就能缩短白屏展示的时长了,不过在实际项目中,总是存在各种各样的情况,这些策略并不能随心所欲地去引用,所以还需要结合实际情况来调整最佳方案。

QA: 今天留给你的思考题是:当你横屏方向拿着一个手机时,打开一个页面,观察下面几种资源的加载方式,你认为哪几种会阻塞页面渲染?为什么?

评论区高赞回答:

1:<script src="foo.js" type="text/javascript"></script> // 下载JavaScript文件并执行同步代码,会阻塞页面渲染
2:<script defer src="foo.js" type="text/javascript"></script> // defer异步下载JavaScript文件,会在HTML解析完成之后执行,不会阻塞页面渲染
3:<script async src="foo.js" type="text/javascript"></script> // 异步下载JavaScript文件,下载完成之后会立即执行,有可能会阻塞页面渲染
4:<link rel="stylesheet" type="text/css" href="foo.css" /> // 下载CSS文件,可能阻塞页面渲染
5:<link rel="stylesheet" type="text/css" href="foo.css" media="screen"/> // media属性用于区分设备,screen表示用于有屏幕的设备,无法用于打印机、3D眼镜、盲文阅读机等,在题设手机条件下,会加载
6:<link rel="stylesheet" type="text/css" href="foo.css" media="print" /> // print用于打印预览模式或打印页面,这里不会加载
7:<link rel="stylesheet" type="text/css" href="foo.css" media="orientation:landscape" /> // orientation:landscape表示横屏,与题设条件一致,会加载
8:<link rel="stylesheet" type="text/css" href="foo.css" media="orientation:portrait" /> // orientation:portrait表示竖屏,这里不会加载

解决“阻塞效应”**-**解决脚本文件下载阻塞网页渲染的问题

24 | 分层和合成机制:为什么CSS动画比JavaScript高效?

本文我们主要讲解渲染引擎的分层和合成机制,因为分层和合成机制代表了浏览器最为先进的合成技术,Chrome 团队为了做到这一点,做了大量的优化工作。了解其工作原理,有助于拓宽你的视野,而且也有助于你更加深刻地理解 CSS 动画和 JavaScript 底层工作机制。

显示器是怎么显示图像的

每个显示器都有固定的刷新频率,通常是 60HZ,也就是每秒更新 60 张图片,更新的图片都来自于显卡中一个叫前缓冲区的地方显示器所做的任务很简单,就是每秒固定读取 60 次前缓冲区中的图像,并将读取的图像显示到显示器上。

那么这里显卡做什么呢?

**显卡的职责就是合成新的图像,并将图像保存到后缓冲区中,**一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到最新显卡合成的图像。通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但有时候,在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。

帧 VS 帧率

当你通过滚动条滚动页面,或者通过手势缩放页面时,屏幕上就会产生动画的效果。之所以你能感觉到有动画的效果,是因为在滚动或者缩放操作时,渲染引擎会通过渲染流水线生成新的图片,并发送到显卡的后缓冲区。

大多数设备屏幕的更新频率是 60 次 / 秒,这也就意味着正常情况下要实现流畅的动画效果,渲染引擎需要每秒更新 60 张图片到显卡的后缓冲区。

我们把渲染流水线生成的每一副图片称为一帧,把渲染流水线每秒更新了多少帧称为帧率,比如滚动过程中 1 秒更新了 60 帧,那么帧率就是 60Hz(或者 60FPS)。

由于用户很容易观察到那些丢失的帧,**如果在一次动画过程中,渲染引擎生成某些帧的时间过久,那么用户就会感受到卡顿,这会给用户造成非常不好的印象。
**

**要解决卡顿问题,就要解决每帧生成时间过久的问题,为此 Chrome 对浏览器渲染方式做了大量的工作,其中最卓有成效的策略就是引入了分层和合成机制。**分层和合成机制代表了当今最先进的渲染技术,所以接下来我们就来分析下什么是合成和渲染技术。

如何生成一帧图像

渲染流水线生成任意一帧有重排、重绘和合成三种方式。

这三种方式的渲染路径是不同的,通常渲染路径越长,生成图像花费的时间就越多。比如重排,它需要重新根据 CSSOM 和 DOM 来计算布局树,这样生成一幅图片时,会让整个渲染流水线的每个阶段都执行一遍,如果布局复杂的话,就很难保证渲染的效率了。而重绘因为没有了重新布局的阶段,操作效率稍微高点,但是依然需要重新计算绘制信息,并触发绘制操作之后的一系列操作。

相较于重排和重绘,合成操作的路径就显得非常短了,并不需要触发布局和绘制两个阶段,如果采用了 GPU,那么合成的效率会非常高。

分层和合成

通常页面的组成是非常复杂的,有的页面里要实现一些复杂的动画效果,比如点击菜单时弹出菜单的动画特效,滚动鼠标滚轮时页面滚动的动画效果,当然还有一些炫酷的 3D 动画特效。如果没有采用分层机制,从布局树直接生成目标图片的话,那么每次页面有很小的变化时,都会触发重排或者重绘机制,这种“牵一发而动全身”的绘制策略会严重影响页面的渲染效率。

将素材分解为多个图层的操作就称为分层,最后将这些图层合并到一起的操作就称为合成。所以,分层和合成通常是一起使用的。

需要重点关注的是,合成操作是在合成线程上完成的,这也就意味着在执行合成操作时,是不会影响到主线程执行的。这就是为什么经常主线程卡住了,但是 CSS 动画依然能执行的原因。

25 | 页面性能:如何系统地优化页面?

这里我们所谈论的页面优化,其实就是要让页面更快地显示和响应。

由于一个页面在它不同的阶段,所侧重的关注点是不一样的,所以如果我们要讨论页面优化,就要分析一个页面生存周期的不同阶段。通常一个页面有三个阶段:加载阶段、交互阶段和关闭阶段。

**加载阶段,**是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和 JavaScript 脚本。

交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是 JavaScript 脚本。

关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。

这里我们需要重点关注加载阶段和交互阶段,因为影响到我们体验的因素主要都在这两个阶段,下面我们就来逐个详细分析下。

                                                      加载阶段渲染流水线

通过前面文章的讲解,你应该已经知道了并非所有的资源都会阻塞页面的首次绘制,比如图片、音频、视频等文件就不会阻塞页面的首次渲染;而 JavaScript、首次请求的 HTML 资源文件、CSS 文件是会阻塞首次渲染的,因为在构建 DOM 的过程中需要 HTML 和 JavaScript 文件,在构造渲染树的过程中需要用到 CSS 文件。

**我们把这些能阻塞网页首次渲染的资源称为关键资源。**基于关键资源,我们可以继续细化出来三个影响页面首次渲染的核心因素。

**第一个是关键资源个数。**关键资源个数越多,首次页面的加载时间就会越长。比如上图中的关键资源个数就是 3 个,1 个 HTML 文件、1 个 JavaScript 和 1 个 CSS 文件。

**第二个是关键资源大小。**通常情况下,所有关键资源的内容越小,其整个资源的下载时间也就越短,那么阻塞渲染的时间也就越短。上图中关键资源的大小分别是 6KB、8KB 和 9KB,那么整个关键资源大小就是 23KB。

**第三个是请求关键资源需要多少个 RTT(Round Trip Time)。**那什么是 RTT 呢? 在《02 | TCP 协议:如何保证页面文件能被完整送达浏览器?》这篇文章中我们分析过,当使用 TCP 协议传输一个文件时,比如这个文件大小是 0.1M,由于 TCP 的特性,这个数据并不是一次传输到服务端的,而是需要拆分成一个个数据包来回多次进行传输的。RTT 就是这里的往返时延。它是网络中一个重要的性能指标,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延。通常 1 个 HTTP 的数据包在 14KB 左右,所以 1 个 0.1M 的页面就需要拆分成 8 个包来传输了,也就是说需要 8 个 RTT。

至于 JavaScript 和 CSS 文件,这里需要注意一点,由于渲染引擎有一个预解析的线程,在接收到 HTML 数据之后,预解析线程会快速扫描 HTML 数据中的关键资源,一旦扫描到了,会立马发起请求,你可以认为 JavaScript 和 CSS 是同时发起请求的,所以它们的请求是重叠的,那么计算它们的 RTT 时,只需要计算体积最大的那个数据就可以了。

了解了影响加载过程中的几个核心因素之后,接下来我们就可以系统性地考虑优化方案了。总的优化原则就是减少关键资源个数,降低关键资源大小,降低关键资源的 RTT 次数。

**如何减少关键资源的个数?**一种方式是可以将 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 时长。

在优化实际的页面加载速度时,你可以先画出优化之前关键资源的图表,然后按照上面优化关键资源的原则去优化,优化完成之后再画出优化之后的关键资源图表。

接下来我们再来聊聊页面加载完成之后的交互阶段以及应该如何去优化。谈交互阶段的优化,其实就是在谈渲染进程渲染帧的速度,因为在交互阶段,帧的渲染速度决定了交互的流畅度。因此讨论页面优化实际上就是讨论渲染引擎是如何渲染帧的,否则就无法优化帧率。

我们先来看看交互阶段的渲染流水线(如下图)。和加载阶段的渲染流水线有一些不同的地方是,在交互阶段没有了加载关键资源和构建 DOM、CSSOM 流程,通常是由 JavaScript 触发交互动画的。

**
**

                                          交互阶段渲染流水线

结合上图,我们来一起回顾下交互阶段是如何生成一个帧的。大部分情况下,生成一个新的帧都是由 JavaScript 通过修改 DOM 或者 CSSOM 来触发的。还有另外一部分帧是由 CSS 来触发的。

如果在计算样式阶段发现有布局信息的修改,那么就会触发重排操作,然后触发后续渲染流水线的一系列操作,这个代价是非常大的。

同样如果在计算样式阶段没有发现有布局信息的修改,只是修改了颜色一类的信息,那么就不会涉及到布局相关的调整,所以可以跳过布局阶段,直接进入绘制阶段,这个过程叫重绘。不过重绘阶段的代价也是不小的。

还有另外一种情况,通过 CSS 实现一些变形、渐变、动画等特效,这是由 CSS 触发的,并且是在合成线程上执行的,这个过程称为合成。因为它不会触发重排或者重绘,而且合成操作本身的速度就非常快,所以执行合成是效率最高的方式。

回顾了在交互过程中的帧是如何生成的,那接下来我们就可以讨论优化方案了。**一个大的原则就是让单个帧的生成速度变快。**所以,下面我们就来分析下在交互阶段渲染流水线中有哪些因素影响了帧的生成速度以及如何去优化。

1. 减少 JavaScript 脚本执行时间

有时 JavaScript 函数的一次执行时间可能有几百毫秒,这就严重霸占了主线程执行其他渲染任务的时间。针对这种情况我们可以采用以下两种策略:

一种是将一次执行的函数分解为多个任务,使得每次的执行时间不要过久。

另一种是采用 Web Workers。你可以把 Web Workers 当作主线程之外的一个线程,在 Web Workers 中是可以执行 JavaScript 脚本的,不过 Web Workers 中没有 DOM、CSSOM 环境,这意味着在 Web Workers 中是无法通过 JavaScript 来访问 DOM 的,所以我们可以把一些和 DOM 操作无关且耗时的任务放到 Web Workers 中去执行。

总之,在交互阶段,对 JavaScript 脚本总的原则就是不要一次霸占太久主线程。

2. 避免强制同步布局

在介绍强制同步布局之前,我们先来聊聊正常情况下的布局操作。通过 DOM 接口执行添加元素或者删除元素等操作后,是需要重新计算样式和布局的,不过正常情况下这些操作都是在另外的任务中异步完成的,这样做是为了避免当前的任务占用太长的主线程时间。为了直观理解,你可以参考下面的代码:

<html>
<body>
    <div id="mian_div">
        <li id="time_li">time</li>
        <li>geekbang</li>
    </div>

    <p id="demo">强制布局demo</p>
    <button onclick="foo()">添加新元素</button>

    <script>
        function foo() {
            let main_div = document.getElementById("mian_div")      
            let new_node = document.createElement("li")
            let textnode = document.createTextNode("time.geekbang")
            new_node.appendChild(textnode);
            document.getElementById("mian_div").appendChild(new_node);
        }
    </script>
</body>
</html>

对于上面这段代码,我们可以使用 Performance 工具来记录添加元素的过程,如下图所示:

                                  Performance 记录添加元素的执行过程

从图中可以看出来,执行 JavaScript 添加元素是在一个任务中执行的,重新计算样式布局是在另外一个任务中执行,这就是正常情况下的布局操作。

理解了正常情况下的布局操作,接下来我们就可以聊什么是强制同步布局了。

**所谓强制同步布局,是指 JavaScript 强制将计算样式和布局操作提前到当前的任务中。**为了直观理解,这里我们对上面的代码做了一点修改,让它变成强制同步布局,修改后的代码如下所示:

function foo() {
    let main_div = document.getElementById("mian_div")
    let new_node = document.createElement("li")
    let textnode = document.createTextNode("time.geekbang")
    new_node.appendChild(textnode);
    document.getElementById("mian_div").appendChild(new_node);
    //由于要获取到offsetHeight,
    //但是此时的offsetHeight还是老的数据,
    //所以需要立即执行布局操作
    console.log(main_div.offsetHeight)
}

将新的元素添加到 DOM 之后,我们又调用了main_div.offsetHeight来获取新 main_div 的高度信息。如果要获取到 main_div 的高度,就需要重新布局,所以这里在获取到 main_div 的高度之前,JavaScript 还需要强制让渲染引擎默认执行一次布局操作。我们把这个操作称为强制同步布局。

同样,你可以看下面通过 Performance 记录的任务状态:

                                             触发强制同步布局 Performance 图

从上图可以看出来,计算样式和布局都是在当前脚本执行过程中触发的,这就是强制同步布局。

为了避免强制同步布局,我们可以调整策略,在修改 DOM 之前查询相关值。代码如下所示:

function foo() {
    let main_div = document.getElementById("mian_div")
    //为了避免强制同步布局,在修改DOM之前查询相关值
    console.log(main_div.offsetHeight)
    let new_node = document.createElement("li")
    let textnode = document.createTextNode("time.geekbang")
    new_node.appendChild(textnode);
    document.getElementById("mian_div").appendChild(new_node);
    
}

3. 避免布局抖动

还有一种比强制同步布局更坏的情况,那就是布局抖动。所谓布局抖动,是指在一次 JavaScript 执行过程中,多次执行强制布局和抖动操作。为了直观理解,你可以看下面的代码:

function foo() {
    let time_li = document.getElementById("time_li")
    for (let i = 0; i < 100; i++) {
        let main_div = document.getElementById("mian_div")
        let new_node = document.createElement("li")
        let textnode = document.createTextNode("time.geekbang")
        new_node.appendChild(textnode);
        new_node.offsetHeight = time_li.offsetHeight;
        document.getElementById("mian_div").appendChild(new_node);
    }
}

我们在一个 for 循环语句里面不断读取属性值,每次读取属性值之前都要进行计算样式和布局。执行代码之后,使用 Performance 记录的状态如下所示:

                                                Performance 中关于布局抖动的表现
从上图可以看出,在 foo 函数内部重复执行计算样式和布局,这会大大影响当前函数的执行效率。这种情况的避免方式和强制同步布局一样,都是尽量不要在修改 DOM 结构时再去查询一些相关值。

4. 合理利用 CSS 合成动画

合成动画是直接在合成线程上执行的,这和在主线程上执行的布局、绘制等操作不同,如果主线程被 JavaScript 或者一些布局任务占用,CSS 动画依然能继续执行。所以要尽量利用好 CSS 合成动画,如果能让 CSS 处理动画,就尽量交给 CSS 来操作。

另外,如果能提前知道对某个元素执行动画操作,那就最好将其标记为 will-change,这是告诉渲染引擎需要将该元素单独生成一个图层。

5. 避免频繁的垃圾回收

我们知道 JavaScript 使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。

所以要尽量避免产生那些临时垃圾数据。那该怎么做呢?可以尽可能优化储存结构,尽可能避免小颗粒对象的产生。

总结:

我们主要讲解了如何系统优化加载阶段和交互阶段的页面。

在加载阶段,核心的优化原则是:优化关键资源的加载速度,减少关键资源的个数,降低关键资源的 RTT 次数。

在交互阶段,核心的优化原则是:尽量减少一帧的生成时间。可以通过减少单次 JavaScript 的执行时间、避免强制同步布局、避免布局抖动、尽量采用 CSS 的合成动画、避免频繁的垃圾回收等方式来减少一帧生成的时长。

浏览器中的JavaScript执行机制 (5讲)

07 | 变量提升:JavaScript代码是按顺序执行的吗?

那为什么先讲执行上下文呢?它这么重要吗?可以这么说,**只有理解了 JavaScrip 的执行上下文,你才能更好地理解 JavaScript 语言本身,比如变量提升、作用域和闭包等。**不仅如此,理解执行上下文和调用栈的概念还能助你成为一名更合格的前端开发者。

showName()
console.log(myname)
var myname = '极客时间'
function showName() {
    console.log('函数showName被执行');
}

                                       在声明之前使用函数和变量的执行结果

从上面两段代码的执行结果来看,我们可以得出如下三个结论。在执行过程中,若使用了未声明的变量,那么 JavaScript 执行会报错。在一个变量定义之前使用它,不会出错,但是该变量的值会为 undefined,而不是定义时的值。在一个函数定义之前使用它,不会出错,且函数能正确执行。

     如何理解`var myname = '极客时间'`

                                                 函数的声明和赋值

所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined。

                                                       模拟变量提升示意图

从概念的字面意义上来看,**“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,正如我们所模拟的那样。但,这并不准确。实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中。**对,你没听错,一段 JavaScript 代码在执行之前需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段。大致流程你可以参考下图:

                                            JavaScript 的执行流程图

执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。

在执行上下文中存在一个变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容。

实际上,编译阶段和执行阶段都是非常复杂的,包括了词法分析、语法解析、代码优化、代码生成等,这些内容我会在《14 | 编译器和解释器:V8 是如何执行一段 JavaScript 代码的?》那节详细介绍。

代码中出现相同的变量或者函数怎么办?

function showName() {
    console.log('极客邦');
}
showName();
function showName() {
    console.log('极客时间');
}
showName(); 

一段代码如果定义了两个相同名字的函数,那么最终生效的是最后一个函数。

08 | 调用栈:为什么JavaScript代码会出现栈溢出?

当一段代码被执行时,JavaScript 引擎先会对其进行编译,并创建执行上下文。创建执行上下文一般有这么三种情况

  1. 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  2. 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  3. 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。

调用栈就是用来管理函数调用关系的一种数据结构**。** 因此要讲清楚调用栈,你还要先弄明白函数调用和栈结构。

                                                              函数调用过程

为便于你更好地理解调用栈,下面我们再来看段稍微复杂点的示例代码:

var a = 2
function add(b,c){
  return b+c
}
function addAll(b,c){
var d = 10
result = add(b,c)
return  a+result+d
}
addAll(3,6)

                                                    执行 add 函数时的调用栈

在开发中,如何利用好调用栈

1. 如何利用浏览器查看调用栈的信息

**当你执行一段复杂的代码时,你可能很难从代码文件中分析其调用关系,这时候你可以在你想要查看的函数中加入断点,然后当执行到该函数时,就可以查看该函数的调用栈了。
**

从图中可以看出,右边的“call stack”下面显示出来了函数的调用关系:栈的最底部是 anonymous,也就是全局的函数入口;

除了通过断点来查看调用栈,你还可以使用 console.trace() 来输出当前的函数调用关系,比如在示例代码中的 add 函数里面加上了 console.trace(),你就可以看到控制台输出的结果。

                                                         使用 trace 函数输出当前调用栈信息

2. 栈溢出(Stack Overflow)

现在你知道了调用栈是一种用来管理执行上下文的数据结构,符合后进先出的规则。不过还有一点你要注意,调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。

特别是在你写递归代码的时候,就很容易出现栈溢出的情况。比如下面这段代码:

function division(a,b){
    return division(a,b)
}
console.log(division(1,2))

                                                          栈溢出错误

从上图你可以看到,抛出的错误信息为:超过了最大栈调用大小(Maximum call stack size exceeded)。

这个函数是递归的,并且没有任何终止条件,所以它会一直创建新的函数执行上下文,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。

理解了栈溢出原因后,你就可以使用一些方法来避免或者解决栈溢出的问题,比如把递归调用的形式改造成其他形式,或者使用加入定时器的方法来把当前任务拆分为其他很多小任务。

总结

好了,今天的内容就讲到这里,下面来总结下今天的内容。

  • 每调用一个函数,JavaScript 引擎会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码。

  • 如果在一个函数 A 中调用了另外一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶。

  • 当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈。

  • 当分配的调用栈空间被占满时,会引发“堆栈溢出”问题。

09 | 块级作用域:var缺陷以及为什么要引入let和const?

由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。

**虽然 ECMAScript6(以下简称 ES6)已经通过引入块级作用域并配合 let、const 关键字,来避开了这种设计缺陷,但是由于 JavaScript 需要保持向下兼容,所以变量提升在相当长一段时间内还会继续存在。**这也加大了你理解概念的难度,因为既要理解新的机制,又要理解变量提升这套机制,关键这两套机制还是同时运行在“一套”系统中的。

作用域(scope)

为什么 JavaScript 中会存在变量提升这个特性,而其他语言似乎都没有这个特性呢?要讲清楚这个问题,我们就得先从作用域讲起。

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域。

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

在 ES6 之前,JavaScript 只支持这两种作用域,相较而言,其他语言则都普遍支持块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。

简单来讲,如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。

和 Java、C/C++ 不同,ES6 之前是不支持块级作用域的,因为当初设计这门语言的时候,并没有想到 JavaScript 会火起来,所以只是按照最简单的方式来设计。没有了块级作用域,再把作用域内部的变量统一提升无疑是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是 JavaScript 中的变量提升。

变量提升所带来的问题

1. 变量容易在不被察觉的情况下被覆盖掉

var myname = "极客时间"
function showName(){
  console.log(myname);
  if(0){
   var myname = "极客邦"
  }
  console.log(myname);
}
showName()

执行上面这段代码,打印出来的是 undefined,而并没有像前面 C 代码那样打印出来“极客时间”的字符串。为什么输出的内容是 undefined 呢?我们再来分析一下。

                                            开始执行 showName 函数时的调用栈

showName 函数的执行上下文创建后,JavaScript 引擎便开始执行 showName 函数内部的代码了。首先执行的是:

console.log(myname);

结合上面的调用栈状态图,你可以看到这里有两个 myname 变量:一个在全局执行上下文中,其值是“极客时间”;另外一个在 showName 函数的执行上下文中,其值是 undefined。那么到底该使用哪个呢?

相信做过 JavaScript 开发的同学都能轻松回答出来答案:“当然是先使用函数执行上下文里面的变量啦!”的确是这样,这是因为在函数执行过程中,JavaScript 会优先从当前的执行上下文中查找变量,由于变量提升,当前的执行上下文中就包含了变量 myname,而值是 undefined,所以获取到的 myname 的值就是 undefined。

2. 本应销毁的变量没有被销毁

接下来我们再来看下面这段让人误解更大的代码:

function foo(){
  for (var i = 0; i < 7; i++) {
  }
  console.log(i); 
}
foo()

这同样也是由变量提升而导致的,在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 并没有被销毁。

为了解决这些问题,ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。这种就非常符合我们的编程习惯了:作用域块内声明的变量不影响块外面的变量。

在同一段代码中,ES6 是如何做到既要支持变量提升的特性,又要支持块级作用域的呢?

**
**

                                     变量查找过程

通过上图,我们可以得出以下结论:

  • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
  • 通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。
  • 在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。

总结

好了,今天的内容就讲到这里,下面我来简单总结下今天的内容。

由于 JavaScript 的变量提升存在着变量覆盖、变量污染等设计缺陷,所以 ES6 引入了块级作用域关键字来解决这些问题。

之后我们还通过对变量环境和词法环境的介绍,分析了 JavaScript 引擎是如何同时支持变量提升和块级作用域的。

既然聊到了作用域,那最后我们再简单聊下编程语言吧。经常有人争论什么编程语言是世界上最好的语言,**但如果站在语言本身来说,我觉得这种争论没有意义,因为语言是工具,而工具是用来创造价值的,至于能否创造价值或创造多大价值不完全由语言本身的特性决定。**这么说吧,即便一门设计不那么好的语言,它也可能拥有非常好的生态,比如有完善的框架、非常多的落地应用,又或者能够给开发者带来更多的回报,这些都是评判因素。

如果站在语言层面来谈,每种语言其实都是在相互借鉴对方的优势,协同进化,比如 JavaScript 引进了块级作用域、迭代器和协程,其底层虚拟机的实现和 Java、Python 又是非常相似,也就是说如果你理解了 JavaScript 协程和 JavaScript 中的虚拟机,其实你也就理解了 Java、Python 中的协程和虚拟机的实现机制。

所以说,语言本身好坏不重要,重要的是能为开发者创造价值。

10 | 作用域链和闭包 :代码中出现相同的变量,JavaScript引擎是如何选择的?

ES6 是如何通过变量环境和词法环境来同时支持变量提升和块级作用域,这其中就涉及到作用域链的概念。

理解作用域链是理解闭包的基础,而闭包在 JavaScript 中几乎无处不在,同时作用域和作用域链还是所有编程语言的基础。所以,如果你想学透一门语言,作用域和作用域链一定是绕不开的。

首先我们来看下面这段代码:

那今天我们就来聊聊什么是作用域链,并通过作用域链再来讲讲什么是闭包

function bar() {
    console.log(myName)
}
function foo() {
    var myName = "极客邦"
    bar()
}
var myName = "极客时间"
foo()

                                               执行 bar 函数时的调用栈

打印出来的结果是“极客时间”。为什么会是这种情况呢?要解释清楚这个问题,那么你就需要先搞清楚作用域链了。

作用域链

其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。

当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。为了直观理解,你可以看下面这张图:

                                      带有外部引用的调用栈示意图

现在你知道变量是通过作用域链来查找的了,不过还有一个疑问没有解开,foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?

要回答这个问题,你还需要知道什么是词法作用域。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。

词法作用域

**词法作用域就是指作用域是由代码中函数声明的位置来决定的,**所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

也就是说,词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。

(PS: 个人理解,函数的上级上下文环境就是函数声明的位置外部的上下文)

闭包

但理解了变量环境、词法环境和作用域链等概念,那接下来你再理解什么是 JavaScript 中的闭包就容易多了。这里你可以结合下面这段代码来理解什么是闭包:

function foo() {
    var myName = "极客时间"
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName:function(){
            console.log(test1)
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())

**foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。**这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包。

之所以是专属背包,是因为除了 setName 和 getName 函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo 函数的闭包

好了,现在我们终于可以给闭包一个正式的定义了。在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。

你也可以通过“开发者工具”来看看闭包的情况,打开 Chrome 的“开发者工具”,在 bar 函数任意地方打上断点,然后刷新页面,可以看到如下内容:

                                   开发者工具中的闭包展示

闭包是怎么回收的

理解什么是闭包之后,接下来我们再来简单聊聊闭包是什么时候销毁的。因为如果闭包使用不正确,会很容易造成内存泄漏的,关注闭包是如何回收的能让你正确地使用闭包。

通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏

如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。

11 | this:从JavaScript执行上下文的视角讲清楚this

**在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。**但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 又搞出来另外一套 this 机制

希望你能区分清楚作用域链和 this 是两套不同的系统,它们之间基本没太多联系。在前期明确这点,可以避免你在学习 this 的过程中,和作用域产生一些不必要的关联。

**this 是和执行上下文绑定的,也就是说每个执行上下文中都有一个 this。
**

执行上下文主要分为三种——全局执行上下文、函数执行上下文和 eval 执行上下文,所以对应的 this 也只有这三种——全局执行上下文中的 this、函数中的 this 和 eval 中的 this。

**全局执行上下文中的 this
**

全局执行上下文中的 this 是指向 window 对象的。这也是 this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。

函数执行上下文中的 this

通常情况下,有下面三种方式来设置函数执行上下文中的 this 值。

1. 通过函数的 call 、bind、apply方法设置

2. 通过对象调用方法设置

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
  }
}
myObj.showThis()

在这段代码中,我们定义了一个 myObj 对象,该对象是由一个 name 属性和一个 showThis 方法组成的,然后再通过 myObj 对象来调用 showThis 方法。执行这段代码,你可以看到,最终输出的 this 值是指向 myObj 的。

所以,你可以得出这样的结论:使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的。

接下来我们稍微改变下调用方式,把 showThis 赋给一个全局对象,然后再调用该对象,代码如下所示:

var myObj = {
  name : "极客时间",
  showThis: function(){
    this.name = "极客邦"
    console.log(this)
  }
}
var foo = myObj.showThis
foo()

执行这段代码,你会发现 this 又指向了全局 window 对象。

所以通过以上两个例子的对比,你可以得出下面这样两个结论:

  • 在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。
  • 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。

3. 通过构造函数中设置

你可以像这样设置构造函数中的 this,如下面的示例代码:

function CreateObj(){
  this.name = "极客时间"
}
var myObj = new CreateObj()

其实,当执行 new CreateObj() 的时候,JavaScript 引擎做了如下四件事:

首先创建了一个空对象 tempObj;

接着调用 CreateObj.call 方法,并将 tempObj 作为 call 方法的参数,这样当 CreateObj 的执行上下文创建时,它的 this 就指向了 tempObj 对象;

然后执行 CreateObj 函数,此时的 CreateObj 函数执行上下文中的 this 指向了 tempObj 对象;

最后返回 tempObj 对象。

为了直观理解,我们可以用代码来演示下:

  var tempObj = {}
  CreateObj.call(tempObj)
  return tempObj

this 的设计缺陷以及应对方案

1. 嵌套函数中的 this 不会从外层函数中继承

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    function bar(){console.log(this)}
    bar()
  }
}
myObj.showThis()

执行这段代码后,你会发现函数 bar 中的 this 指向的是全局 window 对象,而函数 showThis 中的 this 指向的是 myObj 对象。这就是 JavaScript 中非常容易让人迷惑的地方之一,也是很多问题的源头。

你可以通过一个小技巧来解决这个问题,比如在 showThis 函数中声明一个变量 self 用来保存 this,然后在 bar 函数中使用 self,其实,这个方法的的本质是把 this 体系转换为了作用域的体系。

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    var self = this
    function bar(){
      self.name = "极客邦"
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)

其实,你也可以使用 ES6 中的箭头函数来解决这个问题,结合下面代码:

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    var bar = ()=>{
      this.name = "极客邦"
      console.log(this)
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)

这是因为 ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。

通过上面的讲解,你现在应该知道了 this 没有作用域的限制,这点和变量不一样,所以嵌套函数不会从调用它的函数中继承 this,这样会造成很多不符合直觉的代码。要解决这个问题,你可以有两种思路:

  • 第一种是把 this 保存为一个 self 变量,再利用变量的作用域机制传递给嵌套函数。
  • 第二种是继续使用 this,但是要把嵌套函数改为箭头函数,因为箭头函数没有自己的执行上下文,所以它会继承调用函数中的 this。

2. 普通函数中的 this 默认指向全局对象 window

在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。

不过这个设计也是一种缺陷,因为在实际工作中,我们并不希望函数执行上下文中的 this 默认指向全局对象,因为这样会打破数据的边界,造成一些误操作。如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用。

这个问题可以通过设置 JavaScript 的“严格模式”来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的 this 值是 undefined,这就解决上面的问题了。

总结

首先,在使用 this 时,为了避坑,你要谨记以下三点:

  1. 当函数作为对象的方法调用时,函数中的 this 就是该对象;
  2. 当函数被正常调用时,在严格模式下,this 值是 undefined,非严格模式下 this 指向的是全局对象 window;
  3. 嵌套函数中的 this 不会继承外层函数的 this 值。

最后,我们还提了一下箭头函数,因为箭头函数没有自己的执行上下文,所以箭头函数的 this 就是它外层函数的 this。

V8工作原理 (3讲)

12 | 栈空间和堆空间:数据是如何存储的?

**动态语言:**是运行时才确定数据类型的语言,变量在使用之前无需申明类型。

**静态语言:**是编译时变量的数据类型就可以确定的语言。

而支持隐式类型转换的语言称为弱类型语言,不支持隐式类型转换的语言称为强类型语言

                                                     语言类型图

现在我们知道了,JavaScript 是一种弱类型的、动态的语言。那这些特点意味着什么呢?

弱类型,意味着你不需要告诉 JavaScript 引擎这个或那个变量是什么数据类型,JavaScript 引擎在运行代码的时候自己会计算出来。

动态,意味着你可以使用同一个变量保存不同类型的数据。

其实 JavaScript 中的数据类型一种有 8 种,它们分别是:

了解这些类型之后,还有三点需要你注意一下。

第一点,使用 typeof 检测 Null 类型时,返回的是 Object。这是当初 JavaScript 语言的一个 Bug,一直保留至今,之所以一直没修改过来,主要是为了兼容老的代码。

第二点,Object 类型比较特殊,它是由上述 7 种类型组成的一个包含了 key-value 对的数据类型。

第三点,我们把前面的 7 种数据类型称为原始类型,把最后一个对象类型称为引用类型,之所以把它们区分为两种不同的类型,是因为它们在内存中存放的位置不一样。

接下来,我们就来讲解一下 JavaScript 的原始类型和引用类型到底是怎么储存的。

要理解 JavaScript 在运行过程中数据是如何存储的,你就得先搞清楚其存储空间的种类。下面是我画的 JavaScript 的内存模型,你可以参考下:

从图中可以看出, 在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间和堆空间。

为了搞清楚栈空间是如何存储数据的,我们还是先看下面这段代码:

function foo(){
    var a = "极客时间"
    var b = a
    var c = {name:"极客时间"}
    var d = c
}
foo()

                                               执行到第 4 行时的调用栈状态图

从上图你可以清晰地观察到,对象类型是存放在堆空间的,在栈空间中只是保留了对象的引用地址,当 JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的,相当于多了一道转手流程。

好了,现在你应该知道了原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的。不过你也许会好奇,为什么一定要分“堆”和“栈”两个存储空间呢?所有数据直接存放在“栈”中不就可以了吗?

**答案是不可以的。这是因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。**比如文中的 foo 函数执行结束了,JavaScript 引擎需要离开当前的执行上下文,只需要将指针下移到上个执行上下文的地址就可以了,foo 函数执行上下文栈区空间全部回收,具体过程你可以参考下图:

                                               调用栈中切换执行上下文状态

所以通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。

再谈闭包

function foo() {
    var myName = "极客时间"
    let test1 = 1
    const test2 = 2
    var innerBar = { 
        setName:function(newName){
            myName = newName
        },
        getName:function(){
            console.log(test1)
            return myName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())

在编译过程中,遇到内部函数 setName,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 foo 函数中的 myName 变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存 myName 变量。

总的来说,产生闭包的核心有两步:第一步是需要预扫描内部函数;第二步是把内部函数引用的外部变量保存到堆中。

13 | 垃圾回收:垃圾数据是如何自动回收的?

通常情况下,垃圾数据回收分为手动回收和自动回收两种策略。

如 C/C++ 就是使用手动回收策略,何时分配内存、何时销毁内存都是由代码控制的。

另外一种使用的是自动垃圾回收的策略,如 JavaScript、Java、Python 等语言,产生的垃圾数据是由垃圾回收器来释放的,并不需要手动通过代码来释放。

对于 JavaScript 而言,也正是这个“自动”释放资源的特性带来了很多困惑,也让一些 JavaScript 开发者误以为可以不关心内存管理,这是一个很大的误解。

调用栈中的数据是如何回收的

有一个记录当前执行状态的指针(称为 ESP),指向调用栈中当前正在执行函数的执行上下文。

                                   从栈中回收 showName 执行上下文

从图中可以看出,当 showName 函数执行结束之后,ESP 向下移动到 foo 函数的执行上下文中,上面 showName 的执行上下文虽然保存在栈内存中,但是已经是无效内存了。比如当 foo 函数再次调用另外一个函数时,这块内容会被直接覆盖掉,用来存放另外一个函数的执行上下文。

所以说,当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。

堆中的数据是如何回收的

当上面那段代码的 foo 函数执行结束之后,ESP 应该是指向全局执行上下文的,那这样的话,showName 函数和 foo 函数的执行上下文就处于无效状态了,不过保存在堆中的两个对象依然占用着空间,如下图所示:

                                      foo 函数执行结束后的内存状态

要回收堆中的垃圾数据,就需要用到 JavaScript 中的垃圾回收器了。

14 | 编译器和解释器:V8是如何执行一段JavaScript代码的?

前端工具和框架的自身更新速度非常快,而且还不断有新的出现。**要想追赶上前端工具和框架的更新速度,你就需要抓住那些本质的知识,然后才能更加轻松地理解这些上层应用。**比如我们接下来要介绍的 V8 执行机制,能帮助你从底层了解 JavaScript,也能帮助你深入理解语言转换器 Babel、语法检查工具 ESLint、前端框架 Vue 和 React 的一些底层实现机制。因此,了解 V8 的编译流程能让你对语言以及相关工具有更加充分的认识。

要深入理解 V8 的工作原理,你需要搞清楚一些概念和原理,比如接下来我们要详细讲解的**编译器(Compiler)、解释器(Interpreter)、抽象语法树(AST)、字节码(Bytecode)、即时编译器(JIT)**等概念,都是你需要重点关注的。

15 | 消息队列和事件循环:页面是怎么“活”起来的?

18 | 宏任务和微任务:不是所有任务都是一个待遇

在前面几篇文章中,我们介绍了消息队列,并结合消息队列介绍了两种典型的 WebAPI——setTimeoutXMLHttpRequest,通过这两个 WebAPI 我们搞清楚了浏览器的消息循环系统是怎么工作的。不过随着浏览器的应用领域越来越广泛,消息队列中这种粗时间颗粒度的任务已经不能胜任部分领域的需求,所以又出现了一种新的技术——微任务。微任务可以在实时性和效率之间做一个有效的权衡。

从目前的情况来看,微任务已经被广泛地应用,基于微任务的技术有 MutationObserver、Promise 以及以 Promise 为基础开发出来的很多其他的技术。所以微任务的重要性也与日俱增,了解其底层的工作原理对于你读懂别人的代码,以及写出更高效、更具现代的代码有着决定性的作用。

19 | Promise:使用Promise,告别回调函数

20 | async/await:使用同步的方式去写异步代码

32 | 同源策略:为什么XMLHttpRequest不能跨域请求资源?

总结下本文的主要内容。同源策略会隔离不同源的 DOM、页面数据和网络通信,进而实现 Web 页面的安全性。

我们在文章开头提到过,Web 世界是开放的,可以接入任何资源,而同源策略要让一个页面的所有资源都来自于同一个源,也就是要将该页面的所有 HTML 文件、JavaScript 文件、CSS 文件、图片等资源都部署在同一台服务器上,这无疑违背了 Web 的初衷,也带来了诸多限制。比如将不同的资源部署到不同的 CDN 上时,CDN 上的资源就部署在另外一个域名上,因此我们就需要同源策略对页面的引用资源开一个“口子”,让其任意引用外部文件。

不过鱼和熊掌不可兼得,要绝对的安全就要牺牲掉便利性,因此我们要在这二者之间做权衡,找到中间的一个平衡点,也就是目前的页面安全策略原型。总结起来,它具备以下三个特点:

  1. 页面中可以引用第三方资源,不过这也暴露了很多诸如 XSS 的安全问题,因此又在这种开放的基础之上引入了 CSP 来限制其自由程度。
  2. 使用 XMLHttpRequest 和 Fetch 都是无法直接进行跨域请求的,因此浏览器又在这种严格策略的基础之上引入了CORS跨域资源共享策略,让其可以安全地进行跨域操作。
  3. 两个不同源的 DOM 是不能相互操纵的,因此,浏览器中又实现了跨文档消息机制 window.postMessage,让其可以比较安全地通信。

33 | 跨站脚本攻击(XSS):为什么Cookie中有HttpOnly属性?

如何阻止 XSS 攻击

我们知道存储型 XSS 攻击和反射型 XSS 攻击都是需要经过 Web 服务器来处理的,因此可以认为这两种类型的漏洞是服务端的安全漏洞。而基于 DOM 的 XSS 攻击全部都是在浏览器端完成的,因此基于 DOM 的 XSS 攻击是属于前端的安全漏洞

但无论是何种类型的 XSS 攻击,它们都有一个共同点,那就是首先往浏览器中注入恶意脚本,然后再通过恶意脚本将用户信息发送至黑客部署的恶意服务器上

所以要阻止 XSS 攻击,我们可以通过阻止恶意 JavaScript 脚本的注入恶意消息的发送来实现。

接下来我们就来看看一些常用的阻止 XSS 攻击的策略。

针对这些 XSS 攻击,主要有三种防范策略,第一种是通过服务器对输入的内容进行过滤或者转码,第二种是充分利用好 CSP,第三种是使用 HttpOnly 来保护重要的 Cookie 信息。

34 | CSRF攻击:陌生链接不要随便点

什么是 CSRF 攻击

CSRF 英文全称是 Cross-site request forgery,所以又称为“跨站请求伪造”,**是指黑客引诱用户打开黑客的网站,在黑客的网站中,利用用户的登录状态发起的跨站请求。**简单来讲,CSRF 攻击就是黑客利用了用户的登录状态,并通过第三方的站点来做一些坏事。

和 XSS 不同的是,CSRF 攻击不需要将恶意代码注入用户的页面,仅仅是利用服务器的漏洞用户的登录状态来实施攻击。

下面是我总结的发起 CSRF 攻击的三个必要条件

  • 第一个,目标站点一定要有 CSRF 漏洞;
  • 第二个,用户要登录过目标站点,并且在浏览器上保持有该站点的登录状态;
  • 第三个,需要用户打开一个第三方站点,可以是黑客的站点,也可以是一些论坛。

满足以上三个条件之后,黑客就可以对用户进行 CSRF 攻击了。这里还需要额外注意一点,与 XSS 攻击不同,CSRF 攻击不会往页面注入恶意脚本,因此黑客是无法通过 CSRF 攻击来获取用户页面数据的;其最关键的一点是要能找到服务器的漏洞,所以说对于 CSRF 攻击我们主要的防护手段是提升服务器的安全性。

要让服务器避免遭受到 CSRF 攻击,通常有以下几种途径。

1. 充分利用好 Cookie 的 SameSite 属性

2. 验证请求的来源站点

因此,服务器的策略是优先判断 Origin,如果请求头中没有包含 Origin 属性,再根据实际情况判断是否使用 Referer 值。

3. CSRF Token

总结

结合前面两篇文章,我们可以得出页面安全问题的主要原因就是浏览器为同源策略开的两个“后门”:一个是在页面中可以任意引用第三方资源,另外一个是通过 CORS 策略让 XMLHttpRequest 和 Fetch 去跨域请求资源。

为了解决这些问题,我们引入了 CSP 来限制页面任意引入外部资源,引入了 HttpOnly 机制来禁止 XMLHttpRequest 或者 Fetch 发送一些关键 Cookie,引入了 SameSite 和 Origin 来防止 CSRF 攻击。

通过这三篇文章的分析,相信你应该已经能搭建 Web 页面安全的知识体系网络了。有了这张网络,你就可以将 HTTP 请求头和响应头中各种安全相关的字段关联起来,比如 Cookie 中的一些字段,还有 X-Frame-Options、X-Content-Type-Options、X-XSS-Protection 等字段,也可以将 CSP、CORS 这些知识点关联起来。当然这些并不是浏览器安全的全部,后面两篇文章我们还会介绍浏览器系统安全浏览器网络安全两大块的内容,这对于你学习浏览器安全来说也是至关重要的。

35 | 安全沙箱:页面和系统之间的隔离墙

安全沙箱

上面我们分析过了,由于渲染进程需要执行 DOM 解析、CSS 解析、网络图片解码等操作,如果渲染进程中存在系统级别的漏洞,那么以上操作就有可能让恶意的站点获取到渲染进程的控制权限,进而又获取操作系统的控制权限,这对于用户来说是非常危险的。

因为网络资源的内容存在着各种可能性,所以浏览器会默认所有的网络资源都是不可信的,都是不安全的。但谁也不能保证浏览器不存在漏洞,只要出现漏洞,黑客就可以通过网络内容对用户发起攻击。

我们知道,如果你下载了一个恶意程序,但是没有执行它,那么恶意程序是不会生效的。同理,浏览器之于网络内容也是如此,浏览器可以安全地下载各种网络资源,但是如果要执行这些网络资源,比如解析 HTML、解析 CSS、执行 JavaScript、图片编解码等操作,就需要非常谨慎了,因为一不小心,黑客就会利用这些操作对含有漏洞的浏览器发起攻击。

浏览器中的安全沙箱是利用操作系统提供的安全技术,让渲染进程在执行过程中无法访问或者修改操作系统中的数据,在渲染进程需要访问系统资源的时候,需要通过浏览器内核来实现,然后将访问的结果通过 IPC 转发给渲染进程。

安全沙箱最小的保护单位是进程。因为单进程浏览器需要频繁访问或者修改操作系统的数据,所以单进程浏览器是无法被安全沙箱保护的,而现代浏览器采用的多进程架构使得安全沙箱可以发挥作用。

36 | HTTPS:让数据传输更安全

结束语 | 大道至简

写技术文章的核心就是用简单的语言讲清楚一个复杂的问题。

**如果非让我总结写作过程中的一些经验和心得,我会用四个词来概括:大道至简,学会权衡,懂得舍弃,持续进化。**具体来讲,首先你懂得了大道,有着全局视野,才有能力去分析哪些是重要的、哪些是不重要的,这样你才有可能去化繁为简;在化繁为简的过程中,你还需要去权衡利弊,有勇气去舍弃那些不重要的或者已经花费了大量精力去做的事;完成之后,你还需要持续跟进,进行持续的重构改进!