对于前端开发来说,接触最多的就是浏览器了,我们需要学习一下浏览器的原理,有助于我们更好地理解web应用,以及如何提高和优化web的性能,在遇到问题时候可以及时定位到问题。
一、宏观下的浏览器
当我们打开一个页面,打开chrome浏览器的更多工具 -> 任务管理器,我们会发现有多个进程。
首先先来粗略地总结一下进程和线程的区别:
1. 线程 VS 进程
① 一个进程就是一个程序运行的实例
比如,我们启动一个程序的时候,操作系统就会为该程序分配内存空间,用来存放该程序的代码,数据,文件和执行任务的线程。这种运行环境成为进程。
② 一个进程可以运行多个线程
当一个进程中只有一个执行任务的主线程,则成为单线程,如果有多个线程,则成为多线程,
③进程中一旦有一个线程执行出错,会导致整个进程崩溃
④ 线程之间共享进程的数据,但是进程之间的内容是相互隔离的,毫无关系。
⑤当一个进程关闭之后,操作系统会回收进程所占用的内存空间
2. 早期 vs 现代的浏览器
早期的浏览器是单进程的,所有的模块都运行到同一个进程中,这样会导致很多的问题:
- 不稳定
插件容易导致浏览器崩溃 - 不流畅
当js运行的代码,阻塞了页面,比如遇到了死循环,由于在同一个进程,容易导致失去响应;
页面存在内存泄露的可能,使内存占用变高,导致卡顿。 - 不安全
页面运行的插件,可以获取操作系统的任意资源,如果是恶意的插件,则有可能放出病毒,盗取账号密码等。
现代的浏览器是多进程的,一个页面就是进程,不同站点的不同页面的数据是分隔开的;根剧之前早期的缺陷来看看是如何解决的:
当页面崩溃时影响的只是当前的进程,不会导致整个浏览器崩溃;
js是运行在渲染进程中,所以只会影响到当前的进程,不会影响其他页面;当关闭一个页面时候,整个渲染进程也会被关闭,占用的内存会被系统回收,就不存在内存泄露的问题。
对于安全问题,多进程可以使用安全沙箱,把插件进程和渲染进程锁在沙箱中,不允许访问涉及安全问题的位置。
3. 多进程浏览器
最新的chrome浏览器包括:
- 浏览器主进程
负责界面显示,用户交互,管理子进程,管理存储; - 渲染进程
负责将HTML,CSS,JS转化为用户可交互的页面,排版引擎blink和v8引擎都是运行在该进程中的,每创建一个tab标签就会创建一个渲染进程; - GPU进程
最早用于3D CSS效果,后来UI界面和网页都采用GPU绘画,就成为一个必须的进程; - 网络进程
负责加载网络资源 - 插件进程
负责插件的运行
衡量web页面性能的一个指标是FP,即从页面加载到首次绘制的时长,影响这个指标最重要的因素是网络加载速度,我们需要了解TCP/IP协议,众所周知,两方要通信,则必须要建立起同一的标准和规则,协议就这么来了。
这边简单描述一下是如何通信的:
4. TCP/IP 通信
发送端在应用层中生成http报文数据,传送到传输层,传输层则会在报文头部加入TCP首部,再传送到网络层,加上远程服务器的地址和源地址的IP,即IP头,再经过数据链路层,加入以太网首部,接收端则会一步步解析,最终解析出客户端的请求内容。
TCP特点:
- 对于数据包丢失,提供重传机制
- 引入数据包排序机制,将大文件分割成许多小文件,最后根据序号组成一个大文件
http协议是建立在TCP连接的基础上,通常是由浏览器发起的请求,当我们在浏览器输入一个地址到一个页面渲染完成,究竟发生了什么呢?
5. 浏览器端HTTP 请求流程
当我们在浏览器的地址栏输入一个页面的地址时
①首先是请求行,例如:
GET /test.html HTTP1.1
②再查找缓存
如果请求的页面存在于浏览器的缓存中,则不需要发起真正的请求,这样做的好处是可以减轻服务端的压力,使加载速度更快。如果缓存中没有,则继续
③获取IP和端口号
我们知道要请求一个页面的资源,必须要知道这个资源所在的IP,端口号,和对应的文件目录等等。浏览器会根据输入的域名,请求DNS返回域名对应的IP,如果某个域名曾经解析过,则可以直接从DNS数据缓存中取数据,而不用再次去请求,端口号可以从url中获取或者默认的80端口。
HTTP和TCP的关系:
HTTP的内容是通过TCP传输数据来实现的,所以,http网络请求的第一步就是和TCP建立连接。
④建立连接
chrome机制规定同一个域名同时最多只能建立6个TCP连接,需要排队等待,所以当IP和端口都准备好了,不一定马上就可以建立连接。建立TCP连接需要经过三次握手,详细的就不再说明了,建立完后之后,浏览器就可以换和服务器进行通信了。
⑤发送数据
通过TCP数据传输,将http请求行发送到服务器端,如果是POST方法,则需要加上请求体,一般是一些参数数据。请求行发送之后,还要发送请求体,主要包括浏览器的基础信息,比如内核,操作系统,浏览器内核,cookie或者token等等。
⑥返回数据
服务器解析数据包之后,返回响应行(HTTP/1.1 200 ok),响应头和响应体。响应行中带有协议版本,状态码。
⑦断开和完成
当客户端收到了响应信息,就要关闭TCP连接,如果浏览器在请求头中加入Connect: keep-alive则会保持连接装填,可以节省下次连接建立需要的事件,提升加载速度。
到目前为止,一次http请求就算完成啦!
但是有个问题,就是当第一次请求时候比较缓慢 ,第二次再请求时候速度就变快了很多。这是为啥呢?那就要来看看浏览器的缓存机制了
6. 浏览器中页面缓存
从以上的分析,我们可以知道缓存主要是DNS缓存和页面资源的缓存
- DNS缓存
浏览器本地把对应的IP和域名关联起来 - 页面资源缓存
当浏览器第一次发起http请求,并在请求头中的Cache-control设置max-age即缓存过期时,
第一次请求缓存是空的,就直接访问服务器,然后缓存到本地,当再次请求时,会判断是否在缓存期内,如果在则直接返回本地缓存,如果超过缓存期,会继续发起网络请求,并在请求头中带上:Cache-Control:Max-age=2000
服务器会根据这个值来判断请求的资源是否有更新,如果没有更新,则告诉浏览器当前的缓存可以用,返回304,否则就直接返回最新的资源If-None-Match:"xxx"
7. cookie和token
在实际的业务场景中,我们访问网页,需要保存用户的登录状态,除非用户主动退出,那么浏览器是怎么做到的呢?主要有两种技术:
- cookie
当用户输入账号密码登录时候,服务器会验证账号密码,成功则在响应头中加上:
浏览器在收到响应头时,会解析响应头,发现含有Set-cookie,则会把这个属性对应的值保存到本地,再下次请求时候,会把这个值写到请求头的Cookie里Set-Cookie: UTD=xxx
服务器收到这个字段时,回去session表中查找对应的cookie值(sessionID),并找到用户的登录的状态,并识别身份,再将生成的数据发送给浏览器。当用户登出,session在客户端和服务器端都会被销毁。Cookie: UTD=xxx
- token
当用户输入账号密码登录时候,服务器会生成token,并返回给客户端,客户端需要在下次请求时请求头中带上:
服务端根据对应的token就可以获取用户的信息。Authorization header: Bear xxxxx;
区别:
cookie的验证是有状态的,绘画需要一直在服务端和客户端保持,一旦登出,则全部销毁;
token的验证是无状态的,token存储在客户端,在请求时候根据约定,放在Authorization header,服务端只需要验证token是否有效,不需要验证是否是登录状态,一旦登出,token在客户端可以销毁,但是不影响服务端。
但是现在普遍是使用token而不用cookie,优势在于:
①后端不需要保持对token的记录,cookie则需要,每个token都是独立的,只需要验证有效性,提高了效率
②token的CORS可以很好地解决跨域问题
二、JS工作机制
1. 变量的提升
在js代码执行过程中,js把变量和函数的声明提升到代码开头并设置默认值undefined的行为称之为变量提升,且函数首先会被提升,接着才是变量。
举个例子:
console.log(foo);
function foo(){
console.log("函数声明");
}
var foo = "变量";
此处打印的是函数
等价于:
function foo(){
console.log("函数声明");
}
var foo;
console.log(foo);// 打印函数,重新定义的foo会被忽略
foo = "变量";
console.log(foo); // 打印变量
那么为什么要先变量提升呢?
在js代码执行过程中,要先经过编译阶段,编译后,会生成执行上下文和可执行代码,执行上下文存在一个变量环境对象,保存了变量提升的内容。最后在执行阶段,JavaScript 引擎会从变量环境中去查找自定义的变量和函数。输出结果。那么什么是执行上下文呢?
执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。
2. 调用栈
调用栈是用来管理函数调用关系的一种数据结构
比如在全局作用域中声明一个函数,并且调用了,则会生成一个全局执行上下文,还有一个函数执行上下文,js引擎通过调用栈来管理这些数据。
但是我们在实际开发过程中,可能会遇到栈溢出,那是因为调用栈的大小是有限制的,超过一定数目就会报错
例如:
function division(a,b){
return division(a,b)
}
console.log(division(1,2))
当调用division时,并为其创建执行上下文,压入栈,但是这个函数是递归的,就会一直创建新的调用栈
3. 作用域
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
我们知道使用ES6语法的let和const就不会有变量提升的问题看,不知道你是否有疑惑,js是如何做到对块级作用域的支持呢?
在一个函数中,用let或者const声明的变量会被放在函数执行上下文的词法环境中,如果在函数中又声明了新的作用域块,则会将块中let和const声明的变量压入词法环境栈中,执行之后才会被弹出栈。
4. 作用域链
在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个称为outer,当一段代码使用了一个变量的时候,如果在当前的变量环境中没有查到,则会从outer指向的执行上下文中查找,我们就把这个查找称为作用域链。
5.词法作用域
词法作用域指作用域由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过他能够预测代码在执行过程中如何查找标识符。
词法作用域在代码级阶段就已经决定好了,和函数调用方式无关。
function bar() {
var myName = "hello"
let test1 = 100
if (1) {
let myName = "浏览器"
console.log(test)
}
}
function foo() {
var myName = "a"
let test = 2
{
let test = 3
bar()
}
}
var myName = "a"
let myAge = 10
let test = 1
foo() // 1
分析:调用foo时,间接调用了bar,当时bar执行上下文的outer指向全局执行上下文,所以在bar中找不到test变量时,就会从全局变量中查找,此时发现在全局执行上下文的词法环境中找到了test,则输出值。
6. 闭包
根据词法作用域的规则,我们知道内部函数可以访问外部函数声明的变量,当通过调用外部函数返回内部函数,则外部函数已经执行结束,在调用栈中弹出执行上下文,但是内部函数引用了外部函数中的变量,这些变量依然保存在内存中,我们把这些变量的集合成为闭包。
function foo() {
var myName = "a"
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("b")
bar.getName()
console.log(bar.getName())
那么闭包是还说呢么时候可能被回收呢?
如果引用闭包的函数是个全局变量,那么闭包会一直存在直到页面关闭,如果这个闭包以后不再使用的话,就会造成内存泄露。
如果引用闭包的函数是个局部变量,等函数销毁后,在下次js引擎执行垃圾回收时,判断闭包不再使用时就会回收这块内存。
使用闭包注意点:
如果闭包会一直使用,那么可以作为变量而存在,如果使用频率且占用较大内存,则尽量让它成为一个局部变量。
7. this
this是和执行上下文绑定的,全局执行上下文中的this指向windows对象,这是this和作用域链的唯一交点。
有三种方式来设置函数执行上下文中的this:
(1)可以通过call,apply,bind来改变this的指向
(2)通过对象调用方法设置,this指向的是调用的对象
(3)通过构造函数设置,this指向新建的对象
总结:
- 在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。
- 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。
三、V8工作原理
1. javascript内存机制
在了解内存机制之前,我们先区分一下几个概念:
(1)静态语言和动态语言
我们把在使用之前就需要确认变量的数据类型成为静态语言,反之成为动态语言。
例如C、C++、Java为静态语言,js为动态语言。
(2)强语言和弱语言
当一个变量被声明成了一个类型,但是可以被其他类型的变量赋值,隐式转换类型。我们把这种成称为弱类型语言,反之为强语言类型。
比如python,java为强语言类型,js,php, c, c++为弱语言类型。
内存空间
在js执行过程中,主要有三种类型内存空间,分别是:
① 代码空间
② 栈空间: 维护执行上下文的状态,存储原始类型数据
③ 堆空间:存储引用类型
那么,为什么要区分使用栈空间和堆空间呢?
我们知道在js执行过程中,会产生执行上下文,当一个函数执行完毕时,它的执行上下文会被弹出栈从而被回收。通常情况下,栈空间不会设置得太大,是用来存放一些原型类型的小数据,相对的,引用类型占据的空间都比较大,所以可以放到堆中。但是缺点是:分配内存和回收内存都会占用一定时间。
那么我们可以根据堆和栈进一步分析闭包变量保存方式:
当内部函数引用了外部变量时,会形成闭包,并把内部函数引用的外部变量保存到堆中,形成了闭包,在下次调用内部函数时,就直接去堆中取变量。
关于引用类型的浅拷贝和深拷贝方式(8.浅拷贝和深拷贝)
2. 垃圾回收
我们知道在代码执行过程中,有些数据被使用之后,可能就不再需要,为了节省内存的占用,就出现了垃圾数据回收,分为手动和自动垃圾回收,例如C/C++可以手动垃圾回收,js可以自动垃圾回收。
我们知道js中的数据存在栈和堆中,那么这两种内存空间的垃圾如何回收呢?
-
调用栈中的数据回收
在调用栈中有执行上下文,当执行到某个函数时,会有一个记录当前执行状态的指针(ESP),指向调用栈中函数的执行上下文,当函数调用结束时,ESP会下移到下一个执行上下文,那么这个下移操作就是销毁函数执行上下文的过程。原来的内存已经无效,随时都会被其他内容覆盖。
-
堆中的数据
在说明之前,我们先来了解一下代际假说(The Generational Hypothesis):
(1)大部分的对象在内存中存在的时间很短,也就是说已经分配,很快就不用了。
(2)还有的对象则会存活很久
所以在V8中把堆分为新生代和老生代:
新生代存放生存时间短的对象,容量比较小,为1~8M,由副垃圾回收
老生代存放生存时间长的对象,容量比较大,由主垃圾回收。
垃圾回收器的通用流程:
① 标记活动对象和非活动对象
② 回收非活动对象所占的内存
③ 清除内存后,会产生碎片内存,需要做内存整理