oakland / tecblog

My tech blogs
4 stars 0 forks source link

浏览器工作原理与实践 #270

Open oakland opened 4 years ago

oakland commented 4 years ago

keyword: 极客时间,极客绑,李兵,浏览器,浏览器原理

Content-Type

在处理了跳转信息之后,我们继续导航流程的分析。URL 请求的数据类型,有时候是一个下载类型,有时候是正常的 HTML 页面,那么浏览器是如何区分它们呢? 答案是 Content-Type。Content-Type 是 HTTP 头中一个非常重要的字段, 它告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据 Content-Type 的值来决定如何显示响应体的内容。 响应头中的 Content-type 字段的值是 text/html,这就是告诉浏览器,服务器返回的数据是HTML 格式。 接下来我们再来利用 curl 来请求极客时间安装包的地址,如下所示:

image

从返回的响应头信息来看,其 Content-Type 的值是 application/octet-stream,显示数据是字节流类型的,通常情况下,浏览器会按照下载类型来处理该请求。 需要注意的是,如果服务器配置 Content-Type 不正确,比如将 text/html 类型配置成 application/octet-stream 类型,那么浏览器可能会曲解文件内容,比如会将一个本来是用来展示的页面,变成了一个下载文件。

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

image

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

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

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

image

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

解释了程序在执行过程中为什么需要堆和栈两种数据结构后,我们还是回到示例代码那里,看看它最后一步将变量 c 赋值给变量 d 是怎么执行的?

在 JavaScript 中,赋值操作和其他语言有很大的不同,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。

所以d=c的操作就是把 c 的引用地址赋值给 d,你可以参考下图:

image

从图中你可以看到,变量 c 和变量 d 都指向了同一个堆中的对象,所以这就很好地解释了文章开头的那个问题,通过 c 修改 name 的值,变量 d 的值也跟着改变,归根结底它们是同一个对象。

整个上面这段论述讲的很透彻,尽管之前就知道这个原理。但是让我最受启发的在于为什么需要堆栈两套存储空间,而不是全部存在栈空间中。从上面的内容可以看到其实栈空间存的都是简单数据类型,因为对象类型的数据会以对象内存地址的方式存在栈空间中,这个地址就是一串数字?,这样的话存的就都是简单数据类型了。方便 js 对栈空间进行回收管理。

再谈闭包 现在你知道了作用域内的原始类型数据会被存储到栈空间,引用类型会被存储到堆空间,基于这两点的认知,我们再深入一步,探讨下闭包的内存模型。

这里以《10 | 作用域链和闭包 :代码中出现相同的变量,JavaScript 引擎是如何选择的?》中关于闭包的一段代码为例:

 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())

当执行这段代码的时候,你应该有过这样的分析:由于变量 myName、test1、test2 都是原始类型数据,所以在执行 foo 函数的时候,它们会被压入到调用栈中;当 foo 函数执行结束之后,调用栈中 foo 函数的执行上下文会被销毁,其内部变量 myName、test1、test2 也应该一同被销毁。

但是在那篇文章中,我们介绍了当 foo 函数的执行上下文销毁时,由于 foo 函数产生了闭包,所以变量 myName 和 test1 并没有被销毁,而是保存在内存中,那么应该如何解释这个现象呢?

要解释这个现象,我们就得站在内存模型的角度来分析这段代码的执行流程。

  1. 当 JavaScript 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文。
  2. 在编译过程中,遇到内部函数 setName,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 foo 函数中的 myName 变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存 myName 变量。
  3. 接着继续扫描到 getName 方法时,发现该函数内部还引用变量 test1,于是 JavaScript 引擎又将 test1 添加到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了 myName 和 test1 两个变量了。
  4. 由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中。 通过上面的分析,我们可以画出执行到 foo 函数中“return innerBar”语句时的调用栈状态,如下图所示:

image

从上图你可以清晰地看出,当执行到 foo 函数时,闭包就产生了;当 foo 函数执行结束之后,返回的 getName 和 setName 方法都引用“clourse(foo)”对象,所以即使 foo 函数退出了,“clourse(foo)”依然被其内部的 getName 和 setName 方法引用。所以在下次调用bar.setName或者bar.getName时,创建的执行上下文中就包含了“clourse(foo)”。

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

上面这段内容对于理解闭包又加深了印象,最受启发的地方在于 closure(foo) 这个对象的创建,这个对象的创建实际上根本解决了为什么会有闭包的原因。其实闭包就是相当于带着 closure(xxx) 对象的内容。

作者回复: 关于foo函数执行上下文销毁过程:foo函数执行结束之后,当前执行状态的指针下移到栈中的全局执行上下文的位置,foo函数的执行上下文的那块数据就挪出来,这也就是foo函数执行上下文的销毁过程,这个文中有提到,你可以参考“调用栈中切换执行上下文状态“图。

第二个问题:innerBar返回后,含有setName和getName对象,这两个对象里面包含了堆中的closure(foo)的引用。虽然foo执行上下文销毁了,foo函数中的对closure(foo)的引用也断开了,但是setName和getName里面又重新建立起来了对closure(foo)引用。

你可以: 1:打开“开发者工具” 2:在控制台执行上述代码 3:然后选择“Memory”标签,点击"take snapshot" 获取V8的堆内存快照。 4:然后“command+f"(mac) 或者 "ctrl+f"(win),搜索“setName”,然后你就会发现setName对象下面包含了 raw_outer_scope_info_or_feedback_metadata,对闭包的引用数据就在这里面。

这个回复也引出了另外一个问题,就是如何使用 memory 控制面板的问题,这个可以作为引申题目下来再研究。

15 消息队列和时间循环

页面使用单线程的缺点 第一个问题是如何处理高优先级的任务。

比如一个典型的场景是监控 DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用 JavaScript 设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。

不过这个模式有个问题,因为 DOM 变化非常频繁,如果每次发生变化的时候,都直接调用相应的 JavaScript 接口,那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降。

如果将这些 DOM 变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了。

这也就是说,如果 DOM 发生变化,采用同步通知的方式,会影响当前任务的执行效率;如果采用异步方式,又会影响到监控的实时性。

那该如何权衡效率和实时性呢?

针对这种情况,微任务就应用而生了,下面我们来看看微任务是如何权衡效率和实时性的。

通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。

等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。

整个上面这一段内容给出了为什么要分微任务和宏任务的原因,非常好。

16 settimeout

看了一下,我觉得这个 setTimeout 其实更像一个可以插队的 hashmap,就是自己给自己定时间,到时间了就去任务队列插队,如果当前循环队列中正在执行,就等这个任务执行完,然后插入队列,如果没有任务就直接插队进去。

18 宏任务/微任务

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

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

微任务出现的原因

关于什么是宏任务,什么是微任务,评论里有人说:

宿主发起的任务是宏任务 如点击事件,settimeout 进消息队列;js引擎发起的任务是微任务如promise

我觉得这个说法不错。

image

20 async/await

要搞懂函数为何能暂停和恢复,那你首先要了解协程的概念。协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程,比如当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。

oakland commented 3 years ago

01 | Chrome架构 单进程 向 多进程 发展,每个标签(tab)是一个渲染进程,各个标签之间互不影响,一个标签卡死或者挂掉不会影响整个浏览器的其他 tab。

还有一种特殊的情况是:

Chrome的默认策略是,每个标签对应一个渲染进程。但是如果从一个页面打开了新页面,而新页面和当前页面属于同一站点时,那么新页面会复用父页面的渲染进程。官方把这个默认策略叫process-per-site-instance。 直白的讲,就是如果几个页面符合同一站点,那么他们将被分配到一个渲染进程里面去。 所以,这种情况下,一个页面崩溃了,会导致同一站点的页面同时崩溃,因为他们使用了同一个渲染进程。

oakland commented 3 years ago

02 | TCP协议:如何保证页面文件能被完整送达浏览器?

虽说UDP 不能保证数据可靠性,但是传输速度却非常快,所以 UDP 会应用在一些关注速度、但不那么严格要求数据完整性的领域,如在线视频、互动游戏等。

对于浏览器请求,或者邮件这类要求数据传输可靠性(reliability)的应用,如果使用 UDP 来传输会存在两个问题:

备注:tcp 和 udp 就是权衡速度和可靠性之后产生的两种协议,每个协议倾向不同的特点。

问答:

现在的浏览器可以同时打开多个页签,他们端口一样吗?如果一样,数据怎么知道去哪个页签? 作者回复: 端口一样的,网络进程知道每个tcp链接所对应的标签是那个,所以接收到数据后,会把数据分发给对应的渲染进程

oakland commented 3 years ago

06 | 渲染流程(下)

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