知识体系中,最重要的是骨架,脉络。有了骨架后,才方便填充细节。所以,先梳理下主干流程:
- 从浏览器接收到url到开启网络请求线程(这一部分涉及浏览器的机制以及进程与线程之间的关系)
- 从开启网络线程到发出一个完整的http请求(这一部分涉及到dns查询,tcp/ip请求,五层因特网协议栈等知识)
- 从服务器接收到请求到对应后台接收到请求(这一部分可能涉及到负载均衡,安全拦截以及后台内部的处理等等)
- 后台和前台的http交互(这一部分包括http头部、响应码、报文结构、cookie,cookie优化,以及编码解码,如gzip压缩等)
- 单独拎出来的缓存问题,http的缓存(这部分包括http缓存头部,etag,catch-control等)
- 浏览器接收到http数据包后的解析流程(这部分包括dom树、css规则树、合并成render树,然后layout、painting渲染、复合图层合成、GPU绘制、外链资源处理、loaded和domcontentloaded等)
- CSS的可视化格式模型(元素的渲染规则,如css三大模型,BFC,IFC等概念)
- JS引擎解析过程(JS的解释阶段,预处理阶段,执行阶段生成执行上下文,VO,作用域链、回收机制等等)
- 其它(可以拓展不同的知识模块,如跨域,web安全,hybrid模式等等内容)
梳理出主干骨架,然后就需要往骨架上填充细节内容了。
浏览器是多进程的,有一个主控进程,以及每一个tab页面都会新开一个进程(某些情况下多个tab会合并进程)进程可能包括主控进程,插件进程,GPU,tab页(浏览器内核)等等...主要有:
- Browser进程:浏览器的主进程(负责协调、主控),只有一个。
- 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建。
- GPU进程:最多一个,用于3D绘制。
- 浏览器渲染进程(内核(Renderer进程)):默认每个Tab页面一个进程,互不影响,控制页面渲染,脚本执行,事件处理等(有时候会优化,如多个空白tab会合并成一个进程)。
如下图,大多数名字QQ浏览器的进程都是我打开的一个个tab页面。如果你在浏览器新打开一个页面,浏览器就会多一个进程,反之减少一个。
因为所有的浏览器内容展示都离不开浏览器内核进程,所以先从浏览器内核进程开始说起,随后穿插其他各大进程。每一个tab页面可以看作是浏览器内核进程,然后这个进程是多线程的,它有几大类子线程:
GUI线程
- 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
- 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。
- 注意,GUI渲染线程与JS引擎线程是互斥的。当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
事件触发线程
- 归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)。
- 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中
- 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理。
- 注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)。
定时器线程
- 传说中的setInterval与setTimeout所在线程。
- 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)。
- 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)。
- 注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。
JS引擎线程
- 也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)。
- JS引擎线程负责解析Javascript脚本,运行代码。
- JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序。
- 同样注意,GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
网络请求线程
- 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求。
- 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。
可以看到,里面的JS引擎是内核进程中的一个线程,这也是为什么常说JS引擎是单线程的。
如果自己打开任务管理器,然后打开一个浏览器,就可以看到:任务管理器中出现了两个进程(一个是主控进程,一个则是打开Tab页的渲染进程),然后在这前提下,看下整个简化的过程:
- Browser进程收到用户请求,首先需要获取页面内容(譬如通过网络下载资源),随后将该任务通过RendererHost接口传递给Render进程。
- Renderer进程的Renderer接口收到消息,简单解释后,交给GUI渲染线程,然后开始渲染。
- 渲染线程接收请求,加载网页并渲染网页,这其中可能需要Browser进程获取资源和需要GPU进程来帮助渲染。
- 当然可能会有JS线程操作DOM(这样可能会造成回流并重绘)。
- 最后Render进程将结果传递给Browser进程。
- Browser进程接收到结果并将结果绘制出来
这里绘一张简单的图:(很简化)
看完这一整套流程,应该对浏览器的运作有了一定理解了,这样有了知识架构的基础后,后续就方便往上填充内容。
根据上文我们可以知道,当我们输入url然后回车后:
- 浏览器获取url,浏览器主进程接管,开一个下载线程。
- 然后进行 http请求(略去DNS查询,IP寻址等等操作),然后等待响应,获取内容。
- 随后将内容通过RendererHost接口转交给Renderer进程,GUI线程开始运行,浏览器渲染流程开始。
浏览器器内核的Render进程拿到内容后,渲染过程大概可以划分成以下几个步骤:
- 解析html建立dom树。
- 解析css构建render树(将CSS代码解析成树形的数据结构,然后结合DOM合并成render树)。
- 布局render树(Layout/reflow),负责各元素尺寸、位置的计算。
- 绘制render树(paint),绘制页面像素信息。
- 浏览器会将各层的信息发送给GPU,GPU会将各层合成(composite),显示在屏幕上。
具体过程如下图:
所有详细步骤都已经略去,渲染完毕后就是load事件了,之后就是自己的JS逻辑处理了.既然略去了一些详细的步骤,那么就提一些可能需要注意的细节把。
load事件与DOMContentLoaded事件的先后
- 当 DOMContentLoaded 事件触发时,仅当DOM加载完成,不包括样式表,图片。
- 当 onload 事件触发时,页面上所有的DOM,样式表,脚本,图片都已经加载完成了。
css加载是否会阻塞dom树渲染?
- css是由单独的下载线程异步下载的,所以css加载不会阻塞DOM树解析(异步加载时DOM照常构建)
- 但会阻塞render树渲染(渲染时需等css加载完毕,因为render树需要css信息)。
根据上文我们可以知道了composite概念。可以简单的这样理解,浏览器渲染的图层一般包含两大类:普通图层以及复合图层。
首先,普通文档流内可以理解为一个复合图层(这里称为默认复合层,里面不管添加多少元素,其实都是在同一个复合图层中)。 其次,absolute布局(fixed也一样),虽然可以脱离普通文档流,但它仍然属于默认复合层。然后,可以通过硬件加速的方式,声明一个新的复合图层,它会单独分配资源(当然也会脱离普通文档流,这样一来,不管这个复合图层中怎么变化,也不会影响默认复合层里的回流重绘)。
因此,GPU中,各个复合图层是单独绘制的,所以互不影响,这也是为什么某些场景硬件加速效果一级棒。可以Chrome源码调试 -> More Tools -> Rendering -> Layer borders中看到,黄色的就是复合图层信息。
如何变成复合图层(硬件加速):
- 最常用的方式:translate3d、translateZ。
- opacity属性/过渡动画(需要动画执行的过程中才会创建合成层,动画没有开始或结束后元素还会回到之前的状态)。
- 其他,譬如以前的flash插件!
absolute和硬件加速的区别:
- absolute虽然可以脱离普通文档流,但是无法脱离默认复合层。所以,就算absolute中信息改变时不会改变普通文档流中render树。但是,浏览器最终绘制时,是整个复合层绘制的。所以absolute中信息的改变,仍然会影响整个复合层的绘制。(浏览器会重绘它,如果复合层中内容多,absolute带来的绘制信息变化过大,资源消耗是非常严重的)。
- 硬件加速直接就是在另一个复合层了(另起炉灶),所以它的信息改变不会影响默认复合层(当然了,内部肯定会影响属于自己的复合层),仅仅是引发最后的合成(输出视图)。一般一个元素开启硬件加速后会变成复合图层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能。但是尽量不要大量使用复合图层,否则由于资源消耗过度,页面反而会变的更卡。
硬件加速时使用index的原因:
- 具体的原理时这样的: “webkit CSS3中,如果这个元素添加了硬件加速,并且index层级比较低。那么在这个元素的后面其它元素(层级比这个元素高的,或者相同的,并且releative或absolute属性相同的),会默认变为复合层渲染。如果处理不当会极大的影响性能”。
- 简单点理解,其实可以认为是一个隐式合成的概念:如果a是一个复合图层,而且b在a上面,那么b也会被隐式转为一个复合图层。
首先理解一些概念:
- JS分为同步任务和异步任务。
- 同步任务都在主线程上执行,形成一个执行栈。
- 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件。
- 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行。
如图:
事件循环机制进一步补充,如图:
上图大致描述就是:主线程运行时会产生执行栈,栈中的代码调用某些api时,它们会在事件队列中添加各种事件(当满足触发条件后,如ajax请求完毕)。而栈中的代码执行完毕,就会读取事件队列中的事件,去执行那些回调.如此循环。注意,总是要等待栈中的代码执行完毕后才会去读取事件队列中的事件。
上述事件循环机制的核心是:JS引擎线程和事件触发线程。但事件上,里面还有一些隐藏细节,譬如调用setTimeout后,是如何等待特定时间后才添加到事件队列中的?是JS引擎检测的么?当然不是了。它是由定时器线程控制(因为JS引擎自己都忙不过来,根本无暇分身)。为什么要单独的定时器线程?因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确,因此很有必要单独开一个线程用来计时。什么时候会用到定时器线程?当使用setTimeout或setInterval时,它需要定时器线程计时,计时完成后就会将特定的事件推入事件队列中。譬如:
这段代码的作用是当1000毫秒计时完毕后(由定时器线程计时),将回调函数推入事件队列中,等待主线程执行。
这段代码的效果是最快的时间内将回调函数推入事件队列中,等待主线程执行。
注意:
- 执行结果是:先begin后hello!
- 虽然代码的本意是0毫秒后就推入事件队列,但是W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。(不过也有一说是不同浏览器有不同的最小时间设定)。
- 所以就算不等待4ms,就算假设0毫秒就推入事件队列,也会先执行begin(因为只有可执行栈内空了后才会主动读取事件队列)。
用setTimeout模拟定期计时和直接用setInterval是有区别的。
- 因为每次setTimeout计时到后就会去执行,然后执行一段时间后才会继续setTimeout,中间就多了误差(误差多少与代码执行时间有关)!
- 而setInterval则是每次都精确的隔一段时间推入一个事件(但是,事件的实际执行时间不一定就准确,还有可能是这个事件还没执行完毕,下一个事件就来了)(不过也有一说是不同浏览器有不同的最小时间设定)。
- 而且setInterval有一些比较致命的问题就是:累计效应(上面提到的),如果setInterval代码在(setInterval)再次添加到队列之前还没有完成执行,就会导致定时器代码连续运行好几次,而之间没有间隔。就算正常间隔执行,多个setInterval的代码执行时间可能会比预期小(因为代码执行需要一定时间)。
所以,鉴于这么多但问题,目前一般认为的最佳方案是:用setTimeout模拟setInterval,或者特殊场合直接用requestAnimationFrame。
看到这里,基本上已经对基本的js运行机制有所了解了。在ES5的情况是够用了,但是在ES6盛行的现在,仍然会遇到一些问题,譬如下面这题:
它的正确执行顺序是这样子的:
为什么呢?因为Promise里有了一个一个新的概念: microtask 或者,进一步,JS中分为两种任务类型: macrotask 和microtask 。在ECMAScript中, microtask 称为 jobs , macrotask 可称为 task 它们的定义?区别?简单点可以按如下理解:
macrotask(又称之为宏任务),可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
- 每一个task会从头到尾将这个任务执行完毕,不会执行其它。
- 浏览器为了能够使得JS内部task与DOM任务能够有序的执行,会在一个task执行结束后,在下一个 task 执行开始前,对页面进行重新渲染。
microtask(又称为微任务),可以理解是在当前 task 执行结束后立即执行的任务。
- 也就是说,在当前task任务后,下一个task之前,在渲染之前。
- 所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。
- 也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。
分别很么样的场景会形成macrotask和microtask呢?
- macrotask:主代码块,setTimeout,setInterval等(可以看到,事件队列中的每一个事件都是一个macrotask)。
- microtask:Promise,process.nextTick等。
__补充:在node环境下,process.nextTick的优先级高于Promise__,也就是可以简单理解为:在宏任务结束后会先执行微任务队列中的nextTickQueue部分,然后才会执行微任务中的Promise部分。
再根据线程来理解下:
- macrotask中的事件都是放在一个事件队列中的,而这个队列由事件触发线程维护。
- microtask中的所有微任务都是添加到微任务队列(Job Queues)中,等待当前macrotask执行完毕后执行,而这个队列由JS引擎线程维护。(这点由自己理解+推测得出,因为它是在主线程下无缝执行的)。
所以,总结下运行机制:
- 执行一个宏任务(栈中没有就从事件队列中获取)。
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中。
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)。
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染。
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)。
如图:
另外,请注意Promise的polyfill与官方版本的区别:
- 官方版本中,是标准的microtask形式。
- polyfill,一般都是通过setTimeout模拟的,所以是macrotask形式。
__补充:以上关于macrotask和microtask的总结,有一些浏览器执行结果不一样(因为它们可能把microtask当成macrotask来执行了),但是为了简单,这里不描述一些不标准的浏览器下的场景(但记住,有些浏览器可能并不标准)。
__补充:使用MutationObserver实现microtask
MutationObserver可以用来实现microtask(它属于microtask,优先级小于Promise,一般是Promise不支持时才会这样做)。它是HTML5中的新特性,作用是:监听一个DOM变动,当DOM对象树发生任何变动时,Mutation Observer会得到通知。像以前的Vue源码中就是利用它来模拟nextTick的,具体原理是,创建一个TextNode并监听内容变化,然后要nextTick的时候去改一下这个节点的文本内容,如下:(Vue的源码,未修改)
不过,现在的Vue(2.5+)的nextTick实现移除了MutationObserver的方式(据说是兼容性原因),取而代之的是使用MessageChannel。(当然,默认情况仍然是Promise,不支持才兼容的)。MessageChannel属于宏任务,优先级是:MessageChannel->setTimeout,所以Vue(2.5+)内部的nextTick与2.4及之前的实现是不一样的,需要注意下。
看到这里,不知道对JS的运行机制是不是更加理解了。从头到尾梳理,而不是就某一个碎片化知识应该是会更清晰的吧?同时,也应该注意到了JS根本就没有想象的那么简单,前端的知识也是无穷无尽,层出不穷的概念、N多易忘的知识点、各式各样的框架、底层原理方面也是可以无限的往下深挖,然后你就会发现,你知道的太少了!
输入URL后,会进行解析(URL的本质就是统一资源定位符)
URL一般包括几大部分:
- protocol,协议头,譬如有
http,ftp等。- host,主机域名或IP地址。
- port,端口号。
- path,目录路径。
- query,即查询参数。
- fragment,即 # 后的 hash 值,一般用来定位到某个位置。
每次网络请求时都需要开辟单独的线程进行,譬如如果URL解析到http协议,就会新建一个网络线程去处理资源下载因此浏览器会根据解析出得协议,开辟一个网络线程,前往请求资源。
到了这里,已经对浏览器的运行有了一个整体的概念,接下来,先简单梳理一些概念:
GUI渲染线程与JS引擎线程互斥
- 由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JS线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。
- 因此为了防止渲染出现不可预期的结果,浏览器设置GUI渲染线程与JS引擎为互斥的关系,当JS引擎执行时GUI线程会被挂起,GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。
- 注意,GUI渲染线程与JS引擎线程是互斥的。当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
JS阻塞页面加载
- 从上述的互斥关系,可以推导出,JS如果执行时间过长就会阻塞页面。
- 譬如,假设JS引擎正在进行巨量的计算,此时就算GUI有更新,也会被保存到队列中,等待JS引擎空闲后执行。然后,由于巨量计算,所以JS引擎很可能很久很久后才能空闲,自然会感觉到巨卡无比。
- JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序。
- 所以,要尽量避免JS执行时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。
WebWorker,JS的多线程?
- HTML5中支持了Web Worker。MDN的官方解释是:“Web Worker为代码在后台线程中运行脚本提供了一种简单方法,线程可以执行任务而不干扰用户界面。一个worker是使用一个构造函数创建的一个对象(e.g. Worker()) 运行一个命名的JavaScript文件。这个文件包含将在工作线程中运行的代码; workers 运行在另一个全局上下文中,不同于当前的window。因此,使用 window快捷方式获取当前全局的范围 (而不是self) 在一个 Worker 内将返回错误。”。
- 可以这样理解:“创建Worker时,JS引擎向浏览器申请开一个子线程(子线程是浏览器开的,完全受主线程控制,而且不能操作DOM)。JS引擎线程与worker线程间通过特定的方式通信(postMessage API,需要通过序列化对象来与线程交互特定的数据)。”。
- 所以,如果有非常耗时的工作,请单独开一个Worker线程,这样里面不管如何翻天覆地都不会影响JS引擎主线程,只待计算出结果后,将结果通信给主线程即可。
- 而且,JS引擎是单线程的,这一点的本质仍然未改变,Worker可以理解是浏览器给JS引擎开的外挂,专门用来解决那些大量计算问题。
WebWorker与SharedWorker的区别?
- WebWorker只属于某个页面,不会和其他页面的Render进程(浏览器内核进程)共享。所以Chrome在Render进程中(每一个Tab页就是一个render进程)创建一个新的线程来运行Worker中的JavaScript程序。
- SharedWorker是浏览器所有页面共享的,不能采用与Worker同样的方式实现,因为它不隶属于某个Render进程,可以为多个Render进程共享使用。所以Chrome浏览器为SharedWorker单独创建一个进程来运行JavaScript程序,在浏览器中每个相同的JavaScript只存在一个SharedWorker进程,不管它被创建多少次。
- 所以WebWorker与SharedWorker的区别本质上就是进程和线程的区别。SharedWorker由独立的进程管理,WebWorker只是属于render进程下的一个线程。
如果输入的是域名,需要进行dns解析成IP,大致流程:
- 如果浏览器有缓存,直接使用浏览器缓存,否则使用本机缓存,再没有的话就是用host。
- 如果本地没有,就向dns域名服务器查询(当然,中间可能还会经过路由,也有缓存等),查询到对应的IP。
注意,域名查询时有可能是经过了CDN调度器的(如果有cdn存储功能的话)。而且,需要知道dns解析是很耗时的,因此如果解析域名过多,会让首屏加载变得过慢,可以考虑dns-prefetch优化。
DNS Prefetch,即DNS预获取,是前端优化的一部分。一般来说,在前端优化中与 DNS 有关的有两点: 一个是减少DNS的请求次数,另一个就是进行DNS预获取 。DNS 作为互联网的基础协议,其解析的速度似乎很容易被网站优化人员忽视。现在大多数新浏览器已经针对DNS解析进行了优化,典型的一次DNS解析需要耗费 20-120 毫秒,减少DNS解析时间和次数是个很好的优化方式。DNS Prefetching是让具有此属性的域名不需要用户点击链接就在后台解析,而域名解析和内容载入是串行的网络操作,所以这个方式能 减少用户的等待时间,提升用户体验 。默认情况下浏览器会对页面中和当前域名(正在浏览网页的域名)不在同一个域的域名进行预获取,并且缓存结果,这就是隐式的 DNS Prefetch。如果想对页面中没有出现的域进行预获取,那么就要使用显示的 DNS Prefetch 了。
DNS Prefetch 应该尽量的放在网页的前面,推荐放在 后面。具体使用方法如下:
互联网进行通信时,需要相应的网络协议,TCP/IP是为使用互联网而开发定制的协议族。因此,互联网的协议就是TCP/IP。注意:TCP/IP不是一个协议,而是一个协议族的统称。里面包括IP协议、IMCP协议、TCP协议等等等。而浏览器发出的http请求的本质上就是tcp/ip请求。TCP将http长报文划分为短报文,通过三次握手与服务端建立连接,进行可靠传输。建立连接成功后,接下来就正式传输数据。然后,待到断开连接时,需要进行四次挥手(因为是全双工的,所以需要四次挥手)。
如图:
当客户端与服务器经过三次挥手建立连接之后,就要开始传输数据了。而传输数据之前又需要客户端向服务器请求数据,常见的请求方式有GET和POST。当然,关于GET和POST的区别是什么也是一道历史悠久的面试题,你在网上随便一搜,基本都会看到下面的答案:
- POST更安全(不会作为url的一部分,不会被缓存、保存在服务器日志、以及浏览器浏览记录和书签中)
- POST发送的数据更大(GET有url长度限制)
- POST能发送更多的数据类型和编码方式(GET只能发送ASCII字符进行url编码)
- GET参数通过URL传递,POST放在Request body中。
- GET在浏览器回退时是无害的,而POST会再次提交请求。
- POST用于修改和写入数据,GET一般用于搜索排序和筛选之类的操作
- POST比GET慢
- 等等等
但是我要告诉你,GET和POST本质上没有区别!
首先要知道GET和POST是什么?
GET和POST是HTTP协议中的两种发送请求的方法。HTTP的底层是TCP/IP。所以GET和POST的底层也是TCP/IP,也就是说,GET/POST都是TCP链接。GET和POST能做的事情是一样一样的。你要给GET加上request body,给POST带上url参数,技术上是完全行的通的。
举个栗子:
在我大万维网世界中,TCP就像汽车,我们用TCP来运输数据,它很可靠,从来不会发生丢件少件的现象。但是如果路上跑的全是看起来一模一样的汽车,那这个世界看起来是一团混乱,送急件的汽车可能被前面满载货物的汽车拦堵在路上,整个交通系统一定会瘫痪。为了避免这种情况发生,交通规则HTTP诞生了。HTTP给汽车运输设定了好几个服务类别,有GET, POST, PUT, DELETE等等,HTTP规定,当执行GET请求的时候,要给汽车贴上GET的标签(设置method为GET),而且要求把传送的数据放在车顶上(url中)以方便记录。如果是POST请求,就要在车上贴上POST的标签,并把货物放在车厢里。当然,你也可以在GET的时候往车厢内偷偷藏点货物,但是这是很不光彩;也可以在POST的时候在车顶上也放一些数据,让人觉得傻乎乎的。HTTP只是个行为准则,而TCP才是GET和POST怎么实现的基本。
但是,我们只看到HTTP对GET和POST参数的传送渠道(url还是requrest body)提出了要求。“标准答案”里关于参数大小的限制又是从哪来的呢?
在我大万维网世界中,还有另一个重要的角色:运输公司。不同的浏览器(发起http请求)和服务器(接受http请求)就是不同的运输公司。 虽然理论上,你可以在车顶上无限的堆货物(url中无限加参数)。但是运输公司可不傻,装货和卸货也是有很大成本的,他们会限制单次运输量来控制风险,数据量太大对浏览器和服务器都是很大负担。业界不成文的规定是,(大多数)浏览器通常都会限制url长度在2K个字节,而(大多数)服务器最多处理64K大小的url。超过的部分,恕不处理。如果你用GET服务,在request body偷偷藏了数据,不同服务器的处理方式也是不同的,有些服务器会帮你卸货,读出数据,有些服务器直接忽略,所以,虽然GET可以带request body,也不能保证一定能被接收到哦。
好了,现在你知道,GET和POST本质上就是TCP链接,并无差别。但是由于HTTP的规定和浏览器/服务器的限制,导致他们在应用过程中体现出一些不同。
虽然GET和POST请求在被本质上都是TCP链接,但是他们在数据请求或者发送过程中的表现就完全一模一样嘛?还记得刚刚说两者的区别时,其中有一点:GET比POST快!为什么呢?
长的说:对于GET方式的请求,浏览器会把http header和data一并发送出去,服务器响应200(返回数据);而对于POST,浏览器先发送header,服务器响应100 continue,浏览器再发送data,服务器响应200 ok(返回数据)。也就是说,GET只需要汽车跑一趟就把货送到了,而POST得跑两趟,第一趟,先去和服务器打个招呼“嗨,我等下要送一批货来,你们打开门迎接我”,然后再回头把货送过去。因为POST需要两步,时间上消耗的要多一点,看起来GET比POST更有效。因此Yahoo团队有推荐用GET替换POST来优化网站性能。但这是一个坑!跳入需谨慎。为什么?
- GET与POST都有自己的语义,不能随便混用。
- 据研究,在网络环境好的情况下,发一次包的时间和发两次包的时间差别基本可以无视。而在网络环境差的情况下,两次包的TCP在验证数据包完整性上,有非常大的优点。
- 并不是所有浏览器都会在POST中发送两次包,Firefox就只发送一次。
最后的不同:post不能进行管道化传输
http权威指南中是这样说的:http的一次会话需要先建立tcp连接(大部分是tcp,但是其他安全协议也是可以的),然后才能通信,如果 每次连接都只进行一次http会话,那这个连接过程占的比例太大了!于是出现了持久连接:在http/1.0+中是connection首部中添加keep-alive值,在http/1.1中是在connection首部中添加persistent值,当然两者不仅仅是命名上的差别,http/1.1中,持久连接是默认的,除非显示在connection中添加close,否则持久连接不会关闭,而http/1.0+中则恰好相反,除非显示在connection首部中添加keep-alive,否则在接收数据包后连接就断开了。 出现了持久连接还不够,在http/1.1中,还有一种称为管道通信的方式进行速度优化:把需要发送到服务器上的所有请求放到输出队列中,在第一个请求发送出去后,不等到收到服务器的应答,第二个请求紧接着就发送出去,但是这样的方式有一个问题:不安全,如果一个管道中有10个连接,在发送出9个后,突然服务器告诉你,连接关闭了,此时客户端即使收到了前9个请求的答复,也会将这9个请求的内容清空,也就是说,白忙活了……此时,客户端的这9个请求需要重新发送。这对于幂等请求还好(比如get,多发送几次都没关系,每次都是相同的结果),如果是post这样的非幂等请求(比如支付的时候,多发送几次就惨了),肯定是行不通的。 所以,post请求不能通过管道的方式进行通信!很有可能,post请求需要重新建立连接,这个过程不跟完全没优化的时候一样了么?所以,在可以使用get请求通信的时候,不要使用post请求,这样用户体验会更好,当然,如果有安全性要求的话,post会更好。管道化传输在浏览器端的实现还需考证,貌似默认情况下大部分浏览器(除了opera)是不进行管道化传输的,除非手动开启!
最后再来说一个对以GET提交请求时url理解容易错误的地方:
- http协议并未规定get和post的长度限制。
- GET的最大长度限制是因为浏览器和web服务器限制了URL的长度。
- 不同的浏览器和web服务器,限制的最大长度不一样。
TCP/IP 协议栈是一系列网络协议的总和,是构成网络通信的核心骨架,它定义了电子设备如何连入因特网,以及数据如何在它们之间进行传输。TCP/IP 协议采用4层结构,分别是应用层、传输层、网络层和链路层,每一层都呼叫它的下一层所提供的协议来完成自己的需求。由于我们大部分时间都工作在应用层,下层的事情不用我们操心;其次网络协议体系本身就很复杂庞大,入门门槛高,因此很难搞清楚TCP/IP的工作原理,通俗一点讲就是,一个主机的数据要经过哪些过程才能发送到对方的主机上。 接下来,我们就来探索一下这个过程。
首先理解一个概念:
物理介质就是把电脑连接起来的物理手段,常见的有光纤、双绞线,以及无线电波,它决定了电信号(0和1)的传输方式,物理介质的不同决定了电信号的传输带宽、速率、传输距离以及抗干扰性等等。TCP/IP协议栈分为四层,每一层都由特定的协议与对方进行通信,而协议之间的通信最终都要转化为 0 和 1 的电信号,通过物理介质进行传输才能到达对方的电脑,因此物理介质是网络通信的基石。下面我们通过一张图先来大概了解一下TCP/IP协议的基本框架:
当通过http发起一个请求时,应用层、传输层、网络层和链路层的相关协议依次对该请求进行包装并携带对应的首部,最终在链路层生成以太网数据包,以太网数据包通过物理介质传输给对方主机,对方接收到数据包以后,然后再一层一层采用对应的协议进行拆包,最后把应用层数据交给应用程序处理。网络通信就好比送快递,商品外面的一层层包裹就是各种协议,协议包含了商品信息、收货地址、收件人、联系方式等,然后还需要配送车、配送站、快递员,商品才能最终到达用户手中。一般情况下,快递是不能直达的,需要先转发到对应的配送站,然后由配送站再进行派件。配送车就是物理介质,配送站就是网关, 快递员就是路由器,收货地址就是IP地址,联系方式就是MAC地址。 快递员负责把包裹转发到各个配送站,配送站根据收获地址里的省市区,确认是否需要继续转发到其他配送站,当包裹到达了目标配送站以后,配送站再根据联系方式找到收件人进行派件。 有了整体概念以后,下面我们详细了解一下各层的分工。
链路层
网络通信就是把有特定意义的数据通过物理介质传送给对方,单纯的发送 0 和 1 是没有意义的,要传输有意义的数据,就需要以字节为单位对 0 和 1 进行分组,并且要标识好每一组电信号的信息特征,然后按照分组的顺序依次发送。以太网规定一组电信号就是一个数据包,一个数据包被称为一帧, 制定这个规则的协议就是以太网协议。一个完整的以太网数据包如下图所示:
整个数据帧由首部、数据和尾部三部分组成,首部固定为14个字节,包含了目标MAC地址、源MAC地址和类型;数据最短为46个字节,最长为1500个字节,如果需要传输的数据很长,就必须分割成多个帧进行发送;尾部固定为4个字节,表示数据帧校验序列,用于确定数据包在传输过程中是否损坏。因此,以太网协议通过对电信号进行分组并形成数据帧,然后通过物理介质把数据帧发送给接收方。那么以太网如何来识接收方的身份呢?以太网规协议定,接入网络的设备都必须安装网络适配器,即网卡,数据包必须是从一块网卡传送到另一块网卡。而网卡地址就是数据包的发送地址和接收地址,也就是帧首部所包含的MAC地址,MAC地址是每块网卡的身份标识,就如同我们身份证上的身份证号码,具有全球唯一性。MAC地址采用十六进制标识,共6个字节,前三个字节是厂商编号,后三个字节是网卡流水号,例如 4C-0F-6E-12-D2-19有了MAC地址以后,以太网采用广播形式,把数据包发给该子网内所有主机,子网内每台主机在接收到这个包以后,都会读取首部里的目标MAC地址,然后和自己的MAC地址进行对比,如果相同就做下一步处理,如果不同,就丢弃这个包。所以链路层的主要工作就是对电信号进行分组并形成具有特定意义的数据帧,然后以广播的形式通过物理介质发送给接收方。
网络层
对于上面的过程,有几个细节问题值得我们思考:
- 发送者如何知道接收者的MAC地址?
- 发送者如何知道接收者和自己同属一个子网?
- 如果接收者和自己不在同一个子网,数据包如何发给对方?
为了解决这些问题,网络层引入了三个协议,分别是IP协议、ARP协议、路由协议。
IP协议
通过前面的介绍我们知道,MAC地址只与厂商有关,与所处的网络无关,所以无法通过MAC地址来判断两台主机是否属于同一个子网。因此,网络层引入了IP协议,制定了一套新地址,使得我们能够区分两台主机是否同属一个网络,这套地址就是网络地址,也就是所谓的IP地址。TCP/IP 协议网络上的每一个网络适配器都有一个唯一的 IP 地址。IP地址目前有两个版本,分别是IPv4和IPv6,IPv4是一个32位的地址,常采用4个十进制数字表示。IP协议将这个32位的地址分为两部分,前面部分代表网络地址,后面部分表示该主机在局域网中的地址。由于各类地址的分法不尽相同,以C类地址192.168.24.1为例,其中前24位就是网络地址,后8位就是主机地址。因此, 如果两个IP地址在同一个子网内,则网络地址一定相同。为了判断IP地址中的网络地址,IP协议还引入了子网掩码, IP地址和子网掩码通过按位与运算后就可以得到网络地址。由于发送者和接收者的IP地址是已知的(应用层的协议会传入), 因此我们只要通过子网掩码对两个IP地址进行AND运算后就能够判断双方是否在同一个子网了。
ARP协议
即地址解析协议,是根据IP地址获取MAC地址的一个网络层协议。其工作原理如下:ARP首先会发起一个请求数据包,数据包的首部包含了目标主机的IP地址,然后这个数据包会在链路层进行再次包装,生成以太网数据包,最终由以太网广播给子网内的所有主机,每一台主机都会接收到这个数据包,并取出标头里的IP地址,然后和自己的IP地址进行比较,如果相同就返回自己的MAC地址,如果不同就丢弃该数据包。ARP接收返回消息,以此确定目标机的MAC地址;与此同时,ARP还会将返回的MAC地址与对应的IP地址存入本机ARP缓存中并保留一定时间,下次请求时直接查询ARP缓存以节约资源。cmd输入 arp -a 就可以查询本机缓存的ARP数据。
路由协议
通过ARP协议的工作原理可以发现,ARP的MAC寻址还是局限在同一个子网中,因此网络层引入了路由协议,首先通过IP协议来判断两台主机是否在同一个子网中,如果在同一个子网,就通过ARP协议查询对应的MAC地址,然后以广播的形式向该子网内的主机发送数据包;如果不在同一个子网,以太网会将该数据包转发给本子网的网关进行路由。网关是互联网上子网与子网之间的桥梁,所以网关会进行多次转发,最终将该数据包转发到目标IP所在的子网中,然后再通过ARP获取目标机MAC,最终也是通过广播形式将数据包发送给接收方。而完成这个路由协议的物理设备就是路由器,在错综复杂的网络世界里,路由器扮演者交通枢纽的角色,它会根据信道情况,选择并设定路由,以最佳路径来转发数据包。
在网络层被包装的数据包就叫IP数据包,IPv4数据包的结构如下图所示:
IP数据包由首部和数据两部分组成,首部长度为20个字节,主要包含了目标IP地址和源IP地址,目标IP地址是网关路由的线索和依据;数据部分的最大长度为65515字节,理论上一个IP数据包的总长度可以达到65535个字节,而以太网数据包的最大长度是1500个字符,如果超过这个大小,就需要对IP数据包进行分割,分成多帧发送。
所以,网络层的主要工作是定义网络地址,区分网段,子网内MAC寻址,对于不同子网的数据包进行路由。
传输层
链路层定义了主机的身份,即MAC地址, 而网络层定义了IP地址,明确了主机所在的网段,有了这两个地址,数据包就从可以从一个主机发送到另一台主机。但实际上数据包是从一个主机的某个应用程序发出,然后由对方主机的应用程序接收。而每台电脑都有可能同时运行着很多个应用程序,所以当数据包被发送到主机上以后,是无法确定哪个应用程序要接收这个包。因此传输层引入了UDP协议来解决这个问题,为了给每个应用程序标识身份,UDP协议定义了端口,同一个主机上的每个应用程序都需要指定唯一的端口号,并且规定网络中传输的数据包必须加上端口信息。 这样,当数据包到达主机以后,就可以根据端口号找到对应的应用程序了。UDP定义的数据包就叫做UDP数据包,结构如下所示:
UDP数据包由首部和数据两部分组成,首部长度为8个字节,主要包括源端口和目标端口;数据最大为65527个字节,整个数据包的长度最大可达到65535个字节。UDP协议比较简单,实现容易,但它没有确认机制, 数据包一旦发出,无法知道对方是否收到,因此可靠性较差,为了解决这个问题,提高网络可靠性,TCP协议就诞生了,TCP即传输控制协议,是一种面向连接的、可靠的、基于字节流的通信协议。简单来说TCP就是有确认机制的UDP协议,每发出一个数据包都要求确认,如果有一个数据包丢失,就收不到确认,发送方就必须重发这个数据包。为了保证传输的可靠性,TCP 协议在 UDP 基础之上建立了三次对话的确认机制,也就是说,在正式收发数据前,必须和对方建立可靠的连接。由于建立过程较为复杂,我们在这里做一个形象的描述:
经过三次对话之后,主机A才会向主机B发送正式数据,而UDP是面向非连接的协议,它不与对方建立连接,而是直接就把数据包发过去了。所以 TCP 能够保证数据包在传输过程中不被丢失,但美好的事物必然是要付出代价的,相比 UDP,TCP 实现过程复杂,消耗连接资源多,传输速度慢。TCP 数据包和 UDP 一样,都是由首部和数据两部分组成,唯一不同的是,TCP 数据包没有长度限制,理论上可以无限长,但是为了保证网络的效率,通常 TCP 数据包的长度不会超过IP数据包的长度,以确保单个 TCP 数据包不必再分割。
总结一下,传输层的主要工作是定义端口,标识应用程序身份,实现端口到端口的通信,TCP协议可以保证数据传输的可靠性。
应用层
理论上讲,有了以上三层协议的支持,数据已经可以从一个主机上的应用程序传输到另一台主机的应用程序了,但此时传过来的数据是字节流,不能很好的被程序识别,操作性差。因此,应用层定义了各种各样的协议来规范数据格式,常见的有 HTTP、FTP、SMTP 等,HTTP 是一种比较常用的应用层协议,主要用于B/S架构之间的数据通信,其报文格式如下:
在 Resquest Headers 中,Accept 表示客户端期望接收的数据格式,而 ContentType 则表示客户端发送的数据格式;在 Response Headers 中,ContentType 表示服务端响应的数据格式,这里定义的格式,一般是和 Resquest Headers 中 Accept 定义的格式是一致的。有了这个规范以后,服务端收到请求以后,就能正确的解析客户端发来的数据,当请求处理完以后,再按照客户端要求的格式返回,客户端收到结果后,按照服务端返回的格式进行解析。
所以应用层的主要工作就是定义数据格式并按照对应的格式解读数据。
最后,我们梳理一下每层模型的职责:
- 链路层:对0和1进行分组,定义数据帧,确认主机的物理地址,传输数据;
- 网络层:定义IP地址,确认主机所在的网络位置,并通过IP进行MAC寻址,对外网数据包进行路由转发;
- 传输层:定义端口,确认主机上应用程序的身份,并将数据包交给对应的应用程序;
- 应用层:定义数据格式,并按照对应的格式解读数据。
然后再把每层模型的职责串联起来,用一句通俗易懂的话讲就是:
当你输入一个网址并按下回车键的时候,首先,应用层协议对该请求包做了格式定义;紧接着传输层协议加上了双方的端口号,确认了双方通信的应用程序;然后网络协议加上了双方的IP地址,确认了双方的网络位置;最后链路层协议加上了双方的MAC地址,确认了双方的物理位置,同时将数据进行分组,形成数据帧,采用广播方式,通过传输介质发送给对方主机。而对于不同网段,该数据包首先会转发给网关路由器,经过多次转发后,最终被发送到目标主机。目标机接收到数据包后,采用对应的协议,对帧数据进行组装,然后再通过一层一层的协议进行解析,最终被应用层的协议解析并交给服务器处理。
当然,其实也有一个完整的OSI七层框架,与之相比,多了会话层、表示层。OSI七层框架:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层
- 表示层:主要处理两个通信系统中交换信息的表示方式,包括数据格式交换,数据加密与解密,数据压缩与终端类型转换等。
- 会话层:它具体管理不同用户和进程之间的对话,如控制登陆和注销过程。
以上内容是对TCP/IP模型做了简单的介绍,而实际上每一层模型都有很多协议,每个协议要做的事情也很多,但我们首先得有一个清晰的脉络结构,掌握每一层模型最基本的作用,然后再去丰富细枝末节的东西,也许会更容易理解。
服务端在接收到请求时,内部会进行很多的处理。这里由于不是专业的后端分析,所以只是简单的介绍下,不深入。
对于大型的项目,由于并发访问量很大,所以往往一台服务器是吃不消的,所以一般会有若干台服务器组成一个集群,然后配合反向代理实现负载均衡。当然了,负载均衡不止这一种实现方式,这里不深入...简单的说:
用户发起的请求都指向调度服务器(反向代理服务器,譬如安装了nginx控制负载均衡),然后调度服务器根据实际的调度算法,分配不同的请求给对应集群中的服务器执行,然后调度器等待实际服务器的HTTP响应,并将它反馈给用户。
一般后台都是部署到容器中的,所以一般为:
- 是容器接受到请求(如tomcat容器)。
- 然后对应容器中的后台程序接收到请求(如java程序)。
- 然后就是后台会有自己的统一处理,处理完后响应响应结果。
概括起来就是:
- 一般有的后端是有统一的验证的,如安全拦截,跨域验证。
- 如果这一步不符合规则,就直接返回了相应的http报文(如拒绝请求等)。
- 然后当验证通过后,才会进入实际的后台代码,此时是程序接收到请求,然后执行(譬如查询数据库,大量计算等等)。
- 等程序执行完毕后,就会返回一个http响应包(一般这一步也会经过多层封装)。
- 然后就是将这个包从后端发送到前端,完成交互。
报文一般包括了:通用头部,请求/响应头部,请求/响应体
这也是开发人员见过的最多的信息,包括如下:
- Request Url: 请求的web服务器地址
- Request Method: 请求方式(GET、POST、OPTIONS、PUT、HEAD、DELETE、CONNECT、TRACE)
- Status Code: 请求的返回状态码,如200代表成功
- Remote Address: 请求的远程服务器地址(会转为IP)
譬如,在跨域拒绝时,可能是method为options,状态码为404/405等(当然,实际上可能的组合有很多)。其中,Method的话一般分为两批次:
- HTTP1.0定义了三种请求方法: GET, POST 和 HEAD方法。以及几种Additional Request Methods:PUT、DELETE、LINK、UNLINK。参考:https://tools.ietf.org/html/rfc1945
- HTTP1.1定义了八种请求方法:GET、POST、HEAD、OPTIONS, PUT, DELETE, TRACE 和 CONNECT 方法。参考:https://tools.ietf.org/html/rfc2616
这里面最常用到的就是状态码,很多时候都是通过状态码来判断,如(列举几个最常见的):
- 200——表明该请求被成功地完成,所请求的资源发送回客户端
- 304——自从上次请求后,请求的网页未修改过,请客户端使用本地缓存
- 400——客户端请求有错(譬如可以是安全模块拦截)
- 401——请求未经授权
- 403——禁止访问(譬如可以是未登录时禁止)
- 404——资源未找到
- 500——服务器内部错误
- 503——服务不可用
- ...
再列举下大致不同范围状态的意义:
- 1xx——指示信息,表示请求已接收,继续处理
- 2xx——成功,表示请求已被成功接收、理解、接受
- 3xx——重定向,要完成请求必须进行更进一步的操作
- 4xx——客户端错误,请求有语法错误或请求无法实现
- 5xx——服务器端错误,服务器未能实现合法的请求
总之,当请求出错时,状态码能帮助快速定位问题,完整版本的状态可以自行去互联网搜索。
请求和响应头部也是分析时常用到的。常用的请求头部(部分):
- Accept: 接收类型,表示浏览器支持的MIME类型(对标服务端返回的Content-Type)
- Accept-Encoding:浏览器支持的压缩类型,如gzip等,超出类型不能接收
- Content-Type:客户端发送出去实体内容的类型
- Cache-Control: 指定请求和响应遵循的缓存机制,如no-cache
- If-Modified-Since:对应服务端的Last-Modified,用来匹配看文件是否变动,只能精确到1s之内,http1.0中
- Expires:缓存控制,在这个时间内不会请求,直接使用缓存,http1.0,而且是服务端时间
- Max-age:代表资源在本地缓存多少秒,有效时间内不会请求,而是使用缓存,http1.1中
- If-None-Match:对应服务端的ETag,用来匹配文件内容是否改变(非常精确),http1.1中
- Cookie: 有cookie并且同域访问时会自动带上
- Connection: 当浏览器与服务器通信时对于长连接如何进行处理,如keep-alive
- Host:请求的服务器URL
- Origin:最初的请求是从哪里发起的(只会精确到端口),Origin比Referer更尊重隐私
- Referer:该页面的来源URL(适用于所有类型的请求,会精确到详细页面地址,csrf拦截常用到这个字段)
- User-Agent:用户客户端的一些必要信息,如UA头部等
常用的响应头部(部分):
- Access-Control-Allow-Headers: 服务器端允许的请求Headers
- Access-Control-Allow-Methods: 服务器端允许的请求方法
- Access-Control-Allow-Origin: 服务器端允许的请求Origin头部(譬如为*)
- Content-Type: 服务器端允许的请求Headers
- Date: 服务端返回的实体内容的类型
- Cache-Control: 数据从服务器发送的时间
- Last-Modified: 告诉浏览器或其他客户,什么环境可以安全的缓存文档
- Expires: 请求资源的最后修改时间
- Max-age: 应该在什么时候认为文档已经过期,从而不再缓存它
- ETag: 客户端的本地资源应该缓存多少秒,开启了Cache-Control后有效
- Set-Cookie: 请求变量的实体标签的当前值
- Keep-Alive: 设置和页面关联的cookie,服务器通过这个头部把cookie传给客户端
- Server: 服务器的一些相关信息
一般来说,请求头部和响应头部是匹配分析的。
- 譬如,请求头部的Accept要和响应头部的Content-Type匹配,否则会报错
- 譬如,跨域请求时,请求头部的Origin要匹配响应头部的Access-Control-Allow-Origin,否则会报跨域错误
- 譬如,在使用缓存时,请求头部的If-Modified-Since、If-None-Match分别和响应头部的Last-Modified、ETag对应
还有很多的分析方法,这里不一一赘述
http请求时,除了头部,还有消息实体,一般来说请求实体中会将一些需要的参数都放入进入(用于POST请求)。譬如实体中可以放参数的序列化形式(a=1&b=2这种),或者直接放表单对象(Form Data对象,上传时可以夹杂参数以及文件),等等。而一般响应实体中,就是放服务端需要传给客户端的内容。一般现在的接口请求时,实体中就是对于的信息的json格式,而像页面请求这种,里面就是直接放了一个html字符串,然后浏览器自己解析并渲染。
如下图是对某请求的http报文结构的简要分析:
cookie是浏览器的一种本地存储方式,一般用来帮助客户端和服务端通信的,常用来进行身份校验,结合服务端的session使用。场景如下(简述):
- 在登陆页面,用户登陆了。此时,服务端会生成一个session,session中有对于用户的信息(如用户名、密码等)
- 然后会有一个sessionid(相当于是服务端的这个session对应的key)
- 然后服务端在登录页面中写入cookie,值就是:jsessionid=xxx
- 然后浏览器本地就有这个cookie了,以后访问同域名下的页面时,自动带上cookie,自动检验,在有效时间内无需二次登陆。
上述就是cookie的常用场景简述(当然了,实际情况下得考虑更多因素)。
一般来说,cookie是不允许存放敏感信息的(千万不要明文存储用户名、密码),因为非常不安全,如果一定要强行存储,首先,一定要在cookie中设置httponly(这样就无法通过js操作了),另外可以考虑rsa等非对称加密(因为实际上,浏览器本地也是容易被攻克的,并不安全)。另外,由于在同域名的资源请求时,浏览器会默认带上本地的cookie,针对这种情况,在某些场景下是需要优化的。譬如以下场景:
- 客户端在域名A下有cookie(这个可以是登陆时由服务端写入的)
- 然后在域名A下有一个页面,页面中有很多依赖的静态资源(都是域名A的,譬如有20个静态资源)
- 此时就有一个问题,页面加载,请求这些静态资源时,浏览器会默认带上cookie,也就是说,这20个静态资源的http请求,每一个都得带上cookie,而实际上静态资源并不需要cookie验证。
- 此时就造成了较为严重的浪费,而且也降低了访问速度(因为内容更多了)
当然了,针对这种场景,是有优化方案的(多域名拆分)。具体做法就是:
- 将静态资源分组,分别放到不同的域名下(如static.base.com)
- 而page.base.com(页面所在域名)下请求时,是不会带上static.base.com域名的cookie的,所以就避免了浪费
说到了多域名拆分,这里再提一个问题,那就是:
- 在移动端,如果请求的域名数过多,会降低请求速度(因为域名整套解析流程是很耗费时间的,而且移动端一般带宽都比不上pc)
- 此时就需要用到一种优化方案:dns-prefetch(让浏览器空闲时提前解析dns域名,不过也请合理使用,勿滥用)
关于cookie的交互,可以看下图总结:
首先,明确gzip是一种压缩格式,需要浏览器支持才有效(不过一般现在浏览器都支持),而且gzip压缩效率很好(高达70%左右)。然后gzip一般是由apache、tomcat等web服务器开启。当然服务器除了gzip外,也还会有其它压缩格式(如deflate,没有gzip高效,且不流行)。所以一般只需要在服务器上开启了gzip压缩,然后之后的请求就都是基于gzip压缩格式的,非常方便。
首先看TCP/IP层面的定义:
- 长连接:一个TCP/IP连接上可以连续发送多个数据包,在TCP连接保持期间,如果没有数据包发送,需要双方发检测包以维持此连接,一般需要自己做在线维持(类似于心跳包)
- 短连接:通信双方有数据交互时,就建立一个TCP连接,数据发送完成后,则断开此tcp连接
然后在http层面:
- http1.0中,默认使用的是短连接,也就是说,浏览器没进行一次http操作,就建立一次连接,任务结束就中断连接,譬如每一个静态资源请求时都是一个单独的连接
- http1.1起,默认使用长连接,使用长连接会有这一行Connection: keep-alive,在长连接的情况下,当一个网页打开完成后,客户端和服务端之间用于传输http的tcp连接不会关闭,如果客户端再次访问这个服务器的页面,会继续使用这一条已经建立的连接
注意: keep-alive不会永远保持,它有一个持续时间,一般在服务器中配置(如apache),另外长连接需要客户端和服务器都支持时才有效。
http2.0不是https,它相当于是http的下一代规范(譬如https的请求可以是http2.0规范的)。然后简述下http2.0与http1.1的显著不同点:
- http1.1中,每请求一个资源,都是需要开启一个tcp/ip连接的,所以对应的结果是,每一个资源对应一个tcp/ip请求,由于tcp/ip本身有并发数限制,所以当资源一多,速度就显著慢下来
- http2.0中,一个tcp/ip请求可以请求多个资源,也就是说,只要一次tcp/ip请求,就可以请求若干个资源,分割成更小的帧请求,速度明显提升。
所以,如果http2.0全面应用,很多http1.1中的优化方案就无需用到了(譬如打包成精灵图,静态资源多域名拆分等)。然后简述下http2.0的一些特性:
- 多路复用(即一个tcp/ip连接可以请求多个资源)
- 首部压缩(http头部压缩,减少体积)
- 二进制分帧(在应用层跟传送层之间增加了一个二进制分帧层,改进传输性能,实现低延迟和高吞吐量)
- 服务器端推送(服务端可以对客户端的一个请求发出多个响应,可以主动通知客户端)
- 请求优先级(如果流被赋予了优先级,它就会基于这个优先级来处理,由服务器决定需要多少资源来处理该请求。)
https就是安全版本的http,譬如一些支付等操作基本都是基于https的,因为http请求的安全系数太低了。简单来看,https与http的区别就是: 在请求前,会建立ssl链接,确保接下来的通信都是加密的,无法被轻易截取分析 一般来说,如果要将网站升级成https,需要后端支持(后端需要申请证书等),然后https的开销也比http要大(因为需要额外建立安全链接以及加密等),所以一般来说http2.0配合https的体验更佳(因为http2.0更快了) 一般来说,主要关注的就是SSL/TLS的握手流程,如下(简述):
- 浏览器请求建立SSL链接,并向服务端发送一个随机数–Client random和客户端支持的加密方法,比如RSA加密,此时是明文传输。
- 服务端从中选出一组加密算法与Hash算法,回复一个随机数–Server random,并将自己的身份信息以证书的形式发回给浏览器(证书里包含了网站地址,非对称加密的公钥,以及证书颁发机构等信息)
- 浏览器收到服务端的证书后
- 验证证书的合法性(颁发机构是否合法,证书中包含的网址是否和正在访问的一样),如果证书信任,则浏览器会显示一个小锁头,否则会有提示- 用户接收证书后(不管信不信任),浏览会生产新的随机数–Premaster secret,然后证书中的公钥以及指定的加密方法加密`Premaster secret`,发送给服务器。
- 用户接收证书后(不管信不信任),浏览会生产新的随机数–Premaster secret,然后证书中的公钥以及指定的加密方法加密`Premaster secret`,发送给服务器。
- 利用Client random、Server random和Premaster secret通过一定的算法生成HTTP链接数据传输的对称加密key-`session key`
- 使用约定好的HASH算法计算握手消息,并使用生成的`session key`对消息进行加密,最后将之前生成的所有信息发送给服务端。
- 服务端收到浏览器的回复
- 利用已知的加解密方式与自己的私钥进行解密,获取`Premaster secret`
- 和浏览器相同规则生成`session key`
- 使用`session key`解密浏览器发来的握手消息,并验证Hash是否与浏览器发来的一致
- 使用`session key`加密一段握手消息,发送给浏览器之后所有的https通信数据将由之前浏览器生成的session key并利用对称加密算法进行加密。
缓存可以简单的划分成两种类型:强缓存(200 from cache)与协商缓存(304)
- 强缓存时,浏览器如果判断本地缓存未过期,就直接使用,无需发起http请求
- 协商缓存时,浏览器会向服务端发起http请求,然后服务端告诉浏览器文件未改变,让浏览器使用本地缓存
上述提到了强缓存和协商缓存,那它们是怎么区分的呢?答案是通过不同的http头部控制。先看下这几个头部:
- If-None-Match/E-tag
- If-Modified-Since/Last-Modified
- Cache-Control/Max-Age
- Pragma/Expires
强缓存上面已经介绍了,直接从缓存中获取资源而不经过服务器;与强缓存相关的header字段有两个:
- Expires,这是http1.0时的规范;它的值为一个绝对时间的GMT格式的时间字符串,如Mon, 10 Jun 2015 21:31:12 GMT,如果发送请求的时间在expires之前,那么本地缓存始终有效,否则就会发送请求到服务器来获取资源。
- cache-control:max-age=number,这是http1.1时出现的header信息,主要是利用该字段的max-age值来进行判断,它是一个相对值;资源第一次的请求时间和Cache-Control设定的有效期,计算出一个资源过期时间,再拿这个过期时间跟当前的请求时间比较,如果请求时间在过期时间之前,就能命中缓存,否则就不行;cache-control除了该字段外,还有下面几个比较常用的设置值:
- no-cache:不使用本地缓存。需要使用缓存协商,先与服务器确认返回的响应是否被更改,如果之前的响应中存在ETag,那么请求的时候会与服务端验证,如果资源未被更改,则可以避免重新下载。
- no-store:直接禁止游览器缓存数据,每次用户请求该资源,都会向服务器发送一个请求,每次都会下载完整的资源。
- public:可以被所有的用户缓存,包括终端用户和CDN等中间代理服务器。
- private:只能被终端用户的浏览器缓存,不允许CDN等中继缓存服务器对其缓存。
注意:如果cache-control与Expires同时存在的话,cache-control的优先级高于Expires。
协商缓存都是由服务器来确定缓存资源是否可用的,所以客户端与服务器端要通过某种标识来进行通信,从而让服务器判断请求资源是否可以缓存访问,这主要涉及到下面两组header字段,这两组搭档都是成对出现的,即第一次请求的响应头带上某个字段(Last-Modified或者Etag),则后续请求则会带上对应的请求字段(If-Modified-Since或者If-None-Match),若响应头没有Last-Modified或者Etag字段,则请求头也不会有对应的字段。
- Last-Modified/If-Modified-Since,都是GMT格式的时间字符串,具体过程:
- 浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在respone的header加上Last-Modified的header,这个header表示这个资源在服务器上的最后修改时间。
- 浏览器再次跟服务器请求这个资源时,在request的header上加上If-Modified-Since的header,这个header的值就是上一次请求时返回的Last-Modified的值。
- 服务器再次收到资源请求时,根据浏览器传过来If-Modified-Since和资源在服务器上的最后修改时间判断资源是否有变化,如果没有变化则返回304 Not Modified,但是不会返回资源内容;如果有变化,就正常返回资源内容。当服务器返回304 Not Modified的响应时,response header中不会再添加Last-Modified的header,因为既然资源没有变化,那么Last-Modified也就不会改变,这是服务器返回304时的response header。
- 浏览器收到304的响应后,就会从缓存中加载资源。
- 如果协商缓存没有命中,浏览器直接从服务器加载资源时,Last-Modified的Header在重新加载的时候会被更新,下次请求时,If-Modified-Since会启用上次返回的Last-Modified值。
- Etag/If-None-Match:这两个值是由服务器生成的每个资源的唯一标识字符串,只要资源有变化就这个值就会改变;其判断过程与Last-Modified/If-Modified-Since类似,与Last-Modified不一样的是,当服务器返回304 Not Modified的响应时,由于ETag重新生成过,response header中还会把这个ETag返回,即使这个ETag跟之前的没有变化。
再提一点,其实HTML页面中也有一个meta标签可以控制缓存方案
不过,这种方案还是比较少用到,因为支持情况不佳,譬如缓存代理服务器肯定不支持,所以不推荐。
首先明确,http的发展是从http1.0到http1.1。而在http1.1中,出了一些新内容,弥补了http1.0的不足:
http1.0中的缓存控制:
- Pragma:严格来说,它不属于专门的缓存控制头部,但是它设置no-cache时可以让本地强缓存失效(属于编译控制,来实现特定的指令,主要是因为兼容http1.0,所以以前又被大量应用)
- Expires:服务端配置的,属于强缓存,用来控制在规定的时间之前,浏览器不会发出请求,而是直接使用本地缓存,注意,Expires一般对应服务器端时间,如Expires:Fri, 30 Oct 1998 14:19:41
- If-Modified-Since/Last-Modified:这两个是成对出现的,属于协商缓存的内容,其中浏览器的头部是If-Modified-Since,而服务端的是Last-Modified,它的作用是,在发起请求时,如果If-Modified-Since和Last-Modified匹配,那么代表服务器资源并未改变,因此服务端不会返回资源实体,而是只返回头部,通知浏览器可以使用本地缓存。Last-Modified,顾名思义,指的是文件最后的修改时间,而且只能精确到1s以内
http1.1中的缓存控制:
- Cache-Control:缓存控制头部,有no-cache、max-age等多种取值
- Max-Age:服务端配置的,用来控制强缓存,在规定的时间之内,浏览器无需发出请求,直接使用本地缓存,注意,Max-Age是Cache-Control头部的值,不是独立的头部,譬如Cache-Control: max-age=3600,而且它值得是绝对时间,由浏览器自己计算
- If-None-Match/E-tag:这两个是成对出现的,属于协商缓存的内容,其中浏览器的头部是If-None-Match,而服务端的是E-tag,同样,发出请求后,如果If-None-Match和E-tag匹配,则代表内容未变,通知浏览器使用本地缓存,和Last-Modified不同,E-tag更精确,它是类似于指纹一样的东西,基于FileEtag INode Mtime Size生成,也就是说,只要文件变,指纹就会变,而且没有1s精确度的限制。
Max-Age相比Expires?
- Expires使用的是服务器端的时间是有时候会有这样一种情况-客户端时间和服务端不同步。那这样,可能就会出问题了,造成了浏览器本地的缓存无用或者一直无法过期。所以一般http1.1后不推荐使用Expires。
- 而Max-Age使用的是客户端本地时间的计算,因此不会有这个问题因此推荐使用Max-Age。注意,如果同时启用了Cache-Control与Expires,Cache-Control优先级高。
E-tag相比Last-Modified?
- Last-Modified:表明服务端的文件最后何时改变的。它有一个缺陷就是只能精确到1s,然后还有一个问题就是有的服务端的文件会周期性的改变,导致缓存失效。
- 而E-tag:是一种指纹机制,代表文件相关指纹。只有文件变才会变,也只要文件变就会变,也没有精确时间的限制,只要文件一遍,立马E-tag就不一样了。如果同时带有E-tag和Last-Modified,服务端会优先检查E-tag。
各大缓存头部的整体关系如下图:(红色标记为大于号,表示优先关系)
当浏览器对某个资源的请求没有命中强缓存,就会发一个请求到服务器,验证协商缓存是否命中,如果协商缓存命中,请求响应返回的http状态为304并且会显示一个Not Modified的字符串,然后资源就会从本地加载了。然而,我们在实际开发中接触最多的却是强缓存。
在实际应用中我们会碰到需要强缓存的场景和不需要强缓存的场景,通常有2种方式来设置是否启用强缓存:
- 通过代码的方式,在web服务器返回的响应中添加Expires和Cache-Control Header;
- 通过配置web服务器的方式,让web服务器在响应资源的时候统一添加Expires和Cache-Control Header。
nginx和apache作为专业的web服务器,都有专门的配置文件,可以配置expires和cache-control,这方面的知识,如果你对运维感兴趣的话,可以在百度上搜索“nginx 设置 expires cache-control”或“apache 设置 expires cache-control”都能找到不少相关的文章。由于在开发的时候不会专门去配置强缓存,而浏览器又默认会缓存图片,css和js等静态资源,所以开发环境下经常会因为强缓存导致资源没有及时更新而看不到最新的效果,解决这个问题的方法有很多,常用的有以下几种:
- 直接ctrl+f5,这个办法能解决页面直接引用的资源更新的问题;
- 使用浏览器的隐私模式开发;
- 如果用的是chrome,可以f12在network那里把缓存给禁掉(这是个非常有效的方法):
- 在开发阶段,给资源加上一个动态的参数,如css/index.css?v=0.0001,由于每次资源的修改都要更新引用的位置,同时修改参数的值,所以操作起来不是很方便。当然现在我们可以通过使用前端自动化构建工具如webpack,glup等开启本地服务器实现。
- 如果资源引用的页面,被嵌入到了一个iframe里面,可以在iframe的区域右键单击重新加载该页面。
- 如果缓存问题出现在ajax请求中,最有效的解决办法就是ajax的请求地址追加随机数。
- 还有一种情况就是动态设置iframe的src时,有可能也会因为缓存问题,导致看不到最新的效果,这时候在要设置的src后面添加随机数也能解决问题。
最后就是强缓存的应用了,强缓存是前端性能优化最有力的工具,没有之一,对于有大量静态资源的网页,一定要利用强缓存,提高响应速度。通常的做法是,为这些静态资源全部配置一个超时时间超长的Expires或Cache-然而这种缓存配置方式会带来一个新的问题,就是发布时资源更新的问题,比如某一张图片,在用户访问第一个版本的时候已经缓存到了用户的电脑上,当网站发布新版本,替换了这个图片时,已经访问过第一个版本的用户由于缓存的设置,导致在默认的情况下不会请求服务器最新的图片资源,除非他清掉或禁用缓存或者强制刷新,否则就看不到最新的图片效果。然而我们可以通过通过更新页面中引用的资源路径,让浏览器主动放弃缓存,加载新资源。就像这样:
当然如果要彻底解决这个问题,单从但从理论上上述答案都太过浅显,网上有关于这个问题的深入,这里不过多讨论。现在已经有很多前端工具能够实际地解决这个问题,webpack,glup等。
强缓存还有一点需要注意的是,通常都是针对静态资源使用,动态资源需要慎用,除了服务端页面可以看作动态资源外,那些引用静态资源的html也可以看作是动态资源,如果这种html也被缓存,当这些html更新之后,可能就没有机制能够通知浏览器这些html有更新,尤其是前后端分离的应用里,页面都是纯html页面,每个访问地址可能都是直接访问html页面,这些页面通常不加强缓存,以保证浏览器访问这些页面时始终请求服务器最新的资源。
到这里相信你已经对HTTPS缓存有了大概了解,也能运用一些知识去解决我们在开发时遇到的一些问题了。从上面我们知道,缓存可以说是性能优化中简单高效的一种优化方式了。一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷。然而http缓存只是http对于浏览器缓存制定的规范,真正实现缓存的是浏览器。下面我们就来看看浏览器在静态资源缓存时做了什么:
这时,问题就来了。浏览器加载过来静态资源之后,如果这些资源可以被缓存。那么它们被存到哪里了呢?不废话了,直接上图:
可以看出,对于浏览器缓存机制。其实大部分都是在http缓存理论基础上,加以实现的。因此对已经说过的部分,我们不加赘述。只关注下浏览器的缓存位置。
Service Worker
Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。Service Worker 的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。
Service Worker 实现缓存功能一般分为三个步骤:首先需要先注册 Service Worker,然后监听到 install 事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。当 Service Worker 没有命中缓存的时候,我们需要去调用 fetch 函数获取数据。也就是说,如果我们没有在 Service Worker 命中缓存的话,会根据缓存查找优先级去查找数据。但是不管我们是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示我们是从 Service Worker 中获取的内容。
Memory Cache
Memory Cache 也就是内存中的缓存,主要包含的是当前中页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。读取内存中的数据肯定比磁盘快,内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。 一旦我们关闭 Tab 页面,内存中的缓存也就被释放了。
那么既然内存缓存这么高效,我们是不是能让数据都存放在内存中呢?这是不可能的。计算机中的内存一定比硬盘容量小得多,操作系统需要精打细算内存的使用,所以能让我们使用的内存必然不多。当我们访问过页面以后,再次刷新页面,可以发现很多数据都来自于内存缓存:
内存缓存中有一块重要的缓存资源是preloader相关指令(例如)下载的资源。总所周知preloader的相关指令已经是页面优化的常见手段之一,它可以一边解析js/css文件,一边网络请求下一个资源。需要注意的事情是,内存缓存在缓存资源时并不关心返回资源的HTTP缓存头Cache-Control是什么值,同时资源的匹配也并非仅仅是对URL做匹配,还可能会对Content-Type,CORS等其他特征做校验。
Disk Cache
Disk Cache 也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上。在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。绝大部分的缓存都来自 Disk Cache,关于 HTTP 的协议头中的缓存字段,我们会在下文进行详细介绍。浏览器会把哪些文件丢进内存中?哪些丢进硬盘中?关于这点,网上说法不一,不过以下观点比较靠得住:
- 对于大文件来说,大概率是不存储在内存中的,反之优先
- 当前系统内存使用率高的话,文件优先存储进硬盘
Push Cache
Push Cache(推送缓存)是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在Chrome浏览器中只有5分钟左右,同时它也并非严格执行HTTP头中的缓存指令。
前面有提到http交互,那么接下来就是浏览器获取到html,然后解析,渲染。
整个渲染步骤中,HTML解析是第一步。简单的理解,这一步的流程是这样的:浏览器解析HTML,构建DOM树。但实际上,在分析整体构建时,却不能一笔带过,得稍微展开。解析HTML到构建出DOM当然过程可以简述如下:
Bytes -> characters -> tokens -> nodes -> DOM
譬如假设有这样一个HTML页面:(以下部分的内容出自参考来源,修改了下格式)
浏览器处理如下:
列举其中的一些重点过程:
- Conversion转换:浏览器将获得的HTML内容(Bytes)基于他的编码转换为单个字符
- Tokenizing分词:浏览器按照HTML规范标准将这些字符转换为不同的标记token。每个token都有自己独特的含义以及规则集
- Lexing词法分析:分词的结果是得到一堆的token,此时把他们转换为对象,这些对象分别定义他们的属性和规则
- DOM构建:因为HTML标记定义的就是不同标签之间的关系,这个关系就像是一个树形结构一样。例如:body对象的父节点就是HTML对象,然后段略p对象的父节点就是body对象。
同理,CSS规则树的生成也是类似。简述为:
Bytes -> characters -> tokens -> nodes -> CSSOM
当DOM树和CSSOM都有了后,就要开始构建渲染树了。一般来说,渲染树和DOM树相对应的,但不是严格意义上的一一对应。因为有一些不可见的DOM元素不会插入到渲染树中,如head这种不可见的标签或者display: none等。整体来说可以看图:
有了render树,接下来就是开始渲染,基本流程如下:
图中重要的四个步骤就是:
- 计算CSS样式
- 构建渲染树
- 布局,主要定位坐标和大小,是否换行,各种position overflow z-index属性
- 绘制,将图像绘制出来
然后,图中的线与箭头代表通过js动态修改了DOM或CSS,导致了重新布局(Layout)或渲染(Repaint)。这里Layout和Repaint的概念是有区别的:
- Layout,也称为Reflow,即回流。一般意味着元素的内容、结构、位置或尺寸发生了变化,需要重新计算样式和渲染树
- Repaint,即重绘。意味着元素发生的改变只是影响了元素的一些外观之类的时候(例如,背景色,边框颜色,文字颜色等),此时只需要应用新样式绘制这个元素就可以了
回流的成本开销要高于重绘,而且一个节点的回流往往回导致子节点以及同级节点的回流,所以优化方案中一般都包括,尽量避免回流。其中能引起回流的操作有
- 页面渲染初始化
- DOM结构改变,比如删除了某个节点
- render树变化,比如减少了padding
- 窗口resize
- 最复杂的一种:获取某些属性,引发回流,很多浏览器会对回流做优化,会等到数量足够时做一次批处理回流,但是除了render树的直接变化,当获取一些属性时,浏览器为了获得正确的值也会触发回流,这样使得浏览器优化无效,包括:
- offset(Top/Left/Width/Height)
- scroll(Top/Left/Width/Height)
- cilent(Top/Left/Width/Height)
- width,height
- 调用了getComputedStyle()或者IE的currentStyle
回流一定伴随着重绘,重绘却可以单独出现。所以一般会有一些优化方案,如:
- 减少逐项更改样式,最好一次性更改style,或者将样式定义为class并一次性更新
- 避免循环操作dom,创建一个documentFragment或div,在它上面应用所有DOM操作,最后再把它添加到window.document
- 避免多次读取offset等属性。无法避免则将它们缓存到变量
- 将复杂的元素绝对定位或固定定位,使得它脱离文档流,否则回流代价会很高
- 注意:改变字体大小会引发回流
上述中的渲染中止步于绘制,但实际上绘制这一步也没有这么简单,它可以结合复合层和简单层的概念来讲。我们在上面浏览器的GPU线程那篇讲过了,这里进简单总结下:
- 可以认为默认只有一个复合图层,所有的DOM节点都是在这个复合图层下的
- 如果开启了硬件加速功能,可以将某个节点变成复合图层
- 复合图层之间的绘制互不干扰,由GPU直接控制
- 而简单图层中,就算是absolute等布局,变化时不影响整体的回流,但是由于在同一个图层中,仍然是会影响绘制的,因此做动画时性能仍然很低。而复合层是独立的,所以一般做动画推荐使用硬件加速
Chrome的开发者工具中,Performance中可以看到详细的渲染过程:
上面介绍了html解析,渲染流程。但实际上,在解析html时,会遇到一些资源连接,此时就需要进行单独处理了。简单起见,这里将遇到的静态资源分为一下几大类(未列举所有):
- CSS样式资源
- JS脚本资源
- img图片类资源
遇到外链时的处理
当遇到上述的外链时,会单独开启一个下载线程去下载资源(http1.1中是每一个资源的下载都要开启一个http请求,对应一个tcp/ip链接)。
遇到CSS样式资源
- CSS下载时异步,不会阻塞浏览器构建DOM树
- 但是会阻塞渲染,也就是在构建render时,会等到css下载解析完毕后才进行(这点与浏览器优化有关,防止css规则不断改变,避免了重复的构建)
- 有例外,media query声明的CSS是不会阻塞渲染的
遇到JS脚本资源
- 阻塞浏览器的解析,也就是说发现一个外链脚本时,需等待脚本下载完成并执行后才会继续解析HTML
- 浏览器的优化,一般现代浏览器有优化,在脚本阻塞时,也会继续下载其它资源(当然有并发上限),但是虽然脚本可以并行下载,解析过程仍然是阻塞的,也就是说必须这个脚本执行完毕后才会接下来的解析,并行下载只是一种优化而已
- defer与async,普通的脚本是会阻塞浏览器解析的,但是可以加上defer或async属性,这样脚本就变成异步了,可以等到解析完毕后再执行(efer和async是有区别的: defer是延迟执行,而async是异步执行)。
遇到img图片类资源
遇到图片等资源时,直接就是异步下载,不会阻塞解析,下载完毕后直接用图片替换原有src的地方
前面提到了整体的渲染概念,但实际上文档树中的元素是按什么渲染规则渲染的,是可以进一步展开的,此部分内容即: CSS的可视化格式模型
先了解:
- CSS中规定每一个元素都有自己的盒子模型(相当于规定了这个元素如何显示)
- 然后可视化格式模型则是把这些盒子按照规则摆放到页面上,也就是如何布局
- 换句话说,盒子模型规定了怎么在页面里摆放盒子,盒子的相互作用等等
说到底: CSS的可视化格式模型就是规定了浏览器在页面中如何处理文档树
关键字:
- 包含块(Containing Block)
- 控制框(Controlling Box)
- BFC(Block Formatting Context)
- IFC(Inline Formatting Context)
- 定位体系
- 浮动
- ...
另外,CSS有三种定位机制:普通流
,浮动
,绝对定位
。
一个元素的box的定位和尺寸,会与某一矩形框有关,这个框就称之为包含块。
元素会为它的子孙元素创建包含块,但是,并不是说元素的包含块就是它的父元素,元素的包含块与它的祖先元素的样式等有关系
譬如:
- 根元素是最顶端的元素,它没有父节点,它的包含块就是初始包含块
- static和relative的包含块由它最近的块级、单元格或者行内块祖先元素的内容框(content)创建
- fixed的包含块是当前可视窗口
- absolute的包含块由它最近的position 属性为
absolute
、relative
或者fixed
的祖先元素创建
- 如果其祖先元素是行内元素,则包含块取决于其祖先元素的
direction
特性- 如果祖先元素不是行内元素,那么包含块的区域应该是祖先元素的内边距边界
块级元素和块框以及行内元素和行框的相关概念
- 块级元素会生成一个块框(
Block Box
),块框会占据一整行,用来包含子box和生成的内容- 块框同时也是一个块包含框(
Containing Box
),里面要么只包含块框,要么只包含行内框(不能混杂),如果块框内部有块级元素也有行内元素,那么行内元素会被匿名块框包围
关于匿名块框的生成,示例:
Some text
More text
div
生成了一个块框,包含了另一个块框p
以及文本内容Some text
,此时Some text
文本会被强制加到一个匿名的块框里面,被div
生成的块框包含(其实这个就是IFC
中提到的行框,包含这些行内框的这一行匿名块形成的框,行框和行内框不同)
换句话说:
如果一个块框在其中包含另外一个块框,那么我们强迫它只能包含块框,因此其它文本内容生成出来的都是匿名块框(而不是匿名行内框)
行内框:
- 一个行内元素生成一个行内框
- 行内元素能排在一行,允许左右有其它元素
关于匿名行内框的生成,示例:
Someemphasizedtext
P
元素生成一个块框,其中有几个行内框(如EM
),以及文本Some
,text
,此时会专门为这些文本生成匿名行内框
display
的几个属性也可以影响不同框的生成:
block
,元素生成一个块框inline
,元素产生一个或多个的行内框inline-block
,元素产生一个行内级块框,行内块框的内部会被当作块块来格式化,而此元素本身会被当作行内级框来格式化(这也是为什么会产生BFC
)none
,不生成框,不再格式化结构中,当然了,另一个visibility: hidden
则会产生一个不可见的框
总结:
- 如果一个框里,有一个块级元素,那么这个框里的内容都会被当作块框来进行格式化,因为只要出现了块级元素,就会将里面的内容分块几块,每一块独占一行(出现行内可以用匿名块框解决)
- 如果一个框里,没有任何块级元素,那么这个框里的内容会被当成行内框来格式化,因为里面的内容是按照顺序成行的排列
当对一个文档进行布局(lay out)的时候,浏览器的渲染引擎会根据标准之一的 CSS 基础框盒模型(CSS basic box model),将所有元素表示为一个个矩形的盒子(box)。CSS 决定这些盒子的大小、位置以及属性(例如颜色、背景、边框尺寸…)。
每个盒子由四个部分(或称区域)组成,其效用由它们各自的边界(Edge)所定义(原文:defined by their respective edges,可能意指容纳、包含、限制等)。如图,与盒子的四个组成区域相对应,每个盒子有四个边界:内容边界 Content edge、内边距边界 Padding Edge、边框边界 Border Edge、外边框边界 Margin Edge。
内容区域 content area ,由内容边界限制,容纳着元素的“真实”内容,例如文本、图像,或是一个视频播放器。它的尺寸为内容宽度(或称 content-box 宽度)和内容高度(或称 content-box 高度)。它通常含有一个背景颜色(默认颜色为透明)或背景图像。
如果 box-sizing
为 content-box
(默认),则内容区域的大小可明确地通过 width
、min-width
、max-width
、height
、min-height
,和 max-height
控制。
内边距区域 padding area 由内边距边界限制,扩展自内容区域,负责延伸内容区域的背景,填充元素中内容与边框的间距。它的尺寸是 padding-box 宽度 和 padding-box 高度。
内边距的粗细可以由 padding-top
、padding-right
、padding-bottom
、padding-left
,和简写属性 padding
控制。
边框区域 border area 由边框边界限制,扩展自内边距区域,是容纳边框的区域。其尺寸为 border-box 宽度 和 border-box 高度。
边框的粗细由 border-width
和简写的 border
属性控制。如果 box-sizing
属性被设为 border-box
,那么边框区域的大小可明确地通过 width
、min-width
, max-width
、height
、min-height
,和 max-height
属性控制。假如框盒上设有背景(background-color
或 background-image
),背景将会一直延伸至边框的外沿(默认为在边框下层延伸,边框会盖在背景上)。此默认表现可通过 CSS 属性 background-clip
来改变。
外边距区域 margin area 由外边距边界限制,用空白区域扩展边框区域,以分开相邻的元素。它的尺寸为 margin-box 宽度 和 margin-box 高度。
外边距区域的大小由 margin-top
、margin-right
、margin-bottom
、margin-left
,和简写属性 margin
控制。在发生外边距合并的情况下,由于盒之间共享外边距,外边距不容易弄清楚。
最后,请注意,除可替换元素外,对于行内元素来说,尽管内容周围存在内边距与边框,但其占用空间(每一行文字的高度)则由 line-height
属性决定,即使边框和内边距仍会显示在内容周围。
什么是层布局模型?层布局模型就像是图像软件PhotoShop中非常流行的图层编辑功能一样,每个图层能够精确定位操作,但在网页设计领域,由于网页大小的活动性,层布局没能受到热捧。但是在网页上局部使用层布局还是有其方便之处的。下面我们来学习一下html中的层布局。
如何让html元素在网页中精确定位,就像图像软件PhotoShop中的图层一样可以对每个图层能够精确定位操作。CSS定义了一组定位(positioning)属性来支持层布局模型。
层模型有三种形式:
- 绝对定位(position: absolute)
- 相对定位(position: relative)
- 固定定位(position: fixed)
需要设置position:absolute(表示绝对定位),这条语句的作用将元素从文档流中拖出来,然后使用left、right、top、bottom属性相对于其最接近的一个具有定位属性的父包含块(下文的组合使用)进行绝对定位。如果不存在这样的包含块,则相对于body元素,即相对于浏览器窗口。
需要设置position:relative(表示相对定位),它通过left、right、top、bottom属性确定元素在正常文档流中的偏移位置。相对定位完成的过程是首先按static(float)方式生成一个元素(并且元素像层一样浮动了起来),然后相对于以前的位置移动,移动的方向和幅度由left、right、top、bottom属性确定,偏移前的位置保留不动。
fixed:表示固定定位,与absolute定位类型类似,但它的相对移动的坐标是视图(屏幕内的网页窗口)本身。由于视图本身是固定的,它不会随浏览器窗口的滚动条滚动而变化,除非你在屏幕中移动浏览器窗口的屏幕位置,或改变浏览器窗口的显示大小,因此固定定位的元素会始终位于浏览器窗口内视图的某个位置,不会受文档流动影响,这与background-attachment:fixed;属性功能相同。
CSS 的 float(浮动),会使元素向左或向右移动,其周围的元素也会重新排列。Float(浮动),往往是用于图像,但它在布局时一样非常有用。
一个浮动元素会尽量向左或向右移动,直到它的外边缘碰到包含框或另一个浮动框的边框为止。这是因为浮动元素产生了浮动流,所有产生了浮动流的元素,块级元素是看不到他们的(也就是说,块级元会认为浮动元素所占的位置是没有东西的,就会自动覆盖上去)。而产生了BFC的元素和文本类属性的元素(带有inline的元素)以及文本都能看到浮动元素,也就是说产生浮动的元素对不同的其他元素的影响不同。
表现为:
- 浮动元素会从文档的正常流中删除,不占原来的位置;即元素浮动后会脱离标准流
- 一个元素浮动时,其他内容会“环绕”该元素(不仅对于浮动图像)
- 要浮动一个非替换元素,则必须为该元素声明一个width
- 元素浮动后就具有了行内块元素的特性,浮动元素会生成一个块级框
浮动规则(部分):
- 浮动元素只能在其包含块内容区内浮动(除了设置负外边距和浮动元素比其包含块更宽)
- 浮动元素互相贴靠,不会相互重叠
- 浮动元素的顶端应当与其标记所在行框(源文档中)的顶端对齐
- 行内框与浮动元素重叠时,其所有都在浮动元素“之上”显示
- 块框与浮动元素重叠时,仅内容在浮动元素“之上”显示,边框和背景在浮动元素“之下”显示
- 浮动元素的包含块是其最近的块级祖先元素
在讲BFC之前,先说下什么是FC, FC即格式上下文,它定义框内部的元素渲染规则,比较抽象,譬如:
- FC像是一个大箱子,里面装有很多元素
- 箱子可以隔开里面的元素和外面的元素(所以外部并不会影响FC内部的渲染)
- 内部的规则可以是:如何定位,宽高计算,margin折叠等等
不同类型的框参与的FC类型不同,譬如块级框对应BFC,行内框对应IFC。
注意,并不是说所有的框都会产生FC,而是符合特定条件才会产生,只有产生了对应的FC后才会应用对应渲染规则。
BFC规则:
- 在块格式化上下文中 每一个元素左外边与包含块的左边相接触(对于从右到左的格式化,右外边接触右边)
- 即使存在浮动也是如此(所以浮动元素正常会直接贴近它的包含块的左边,与普通元素重合)
- 除非这个元素也创建了一个新的BFC
总结几点BFC特点:
- 内部
box
在垂直方向,一个接一个的放置- box的垂直方向由
margin
决定,属于同一个BFC的两个box间的margin会重叠- BFC区域不会与
float box
重叠(可用于排版)- BFC就是页面上的一个隔离的独立容器,容器里面的子元素不会影响到外面的元素。反之也如此
- 计算BFC的高度时,浮动元素也参与计算(不会浮动坍塌)
如何触发BFC?
- 根元素
float
属性不为none
position
为absolute
或fixed
display
为inline-block
,flex
,inline-flex
,table
,table-cell
,table-caption
overflow
不为visible
__补充,display: table
,它本身不产生BFC,但是它会产生匿名框(包含display: table-cell
的框),而这个匿名框产生BFC
IFC即行内框产生的格式上下文
IFC规则:
- 在行内格式化上下文中 框一个接一个地水平排列,起点是包含块的顶部。
- 水平方向上的 margin,border 和 padding 在框之间得到保留
- 框在垂直方向上可以以不同的方式对齐:它们的顶部或底部对齐,或根据其中文字的基线对齐
包含那些框的长方形区域,会形成一行,叫做行框,行框的宽度由它的包含块和其中的浮动元素决定,高度的确定由行高度计算规则决定。
行框的规则:
- 如果几个行内框在水平方向无法放入一个行框内,它们可以分配在两个或多个垂直堆叠的行框中(即行内框的分割)
- 行框在堆叠时没有垂直方向上的分割且永不重叠
- 行框的高度总是足够容纳所包含的所有框。不过,它可能高于它包含的最高的框(例如,框对齐会引起基线对齐)
- 行框的左边接触到其包含块的左边,右边接触到其包含块的右边。
结合补充下IFC规则:
- 浮动元素可能会处于包含块边缘和行框边缘之间
- 尽管在相同的行内格式化上下文中的行框通常拥有相同的宽度(包含块的宽度),它们可能会因浮动元素缩短了可用宽度,而在宽度上发生变化
- 同一行内格式化上下文中的行框通常高度不一样(如,一行包含了一个高的图形,而其它行只包含文本) 当一行中行内框宽度的总和小于包含它们的行框的宽,它们在水平方向上的对齐,取决于 `text-align` 特性
- 空的行内框应该被忽略 即不包含文本,保留空白符,margin/padding/border非0的行内元素, 以及其他常规流中的内容(比如,图片,inline blocks 和 inline tables), 并且不是以换行结束的行框, 必须被当作零高度行框对待
总结:
- 行内元素总是会应用IFC渲染规则
- 行内元素会应用IFC规则渲染,譬如
text-align
可以用来居中等- 块框内部,对于文本这类的匿名元素,会产生匿名行框包围,而行框内部就应用IFC渲染规则
- 行内框内部,对于那些行内元素,一样应用IFC渲染规则
- 另外,
inline-block
,会在元素外层产生IFC(所以这个元素是可以通过text-align
水平居中的),当然,它内部则按照BFC规则渲染
相比BFC规则来说,IFC可能更加抽象(因为没有那么条理清晰的规则和触发条件)
但总的来说,它就是行内元素自身如何显示以及在框内如何摆放的渲染规则,这样描述应该更容易理解
首先得明确: JS是解释型语音,所以它无需提前编译,而是由解释器实时运行
引擎对JS的处理过程可以简述如下:
- 读取代码,进行词法分析(Lexical analysis),然后将代码分解成词元(token)
- 对词元进行语法分析(parsing),然后将代码整理成语法树(syntax tree)
- 使用翻译器(translator),将代码转为字节码(bytecode)
- 使用字节码解释器(bytecode interpreter),将字节码转为机器码
最终计算机执行的就是机器码。
为了提高运行速度,现代浏览器一般采用即时编译(JIT-Just In Time compiler
)。即字节码只在运行时编译,用到哪一行就编译哪一行,并且把编译结果缓存(inline cache
)这样整个程序的运行速度能得到显著提升。而且,不同浏览器策略可能还不同,有的浏览器就省略了字节码的翻译步骤,直接转为机器码(如chrome的v8)
总结起来可以认为是: 核心的JIT
编译器将源码编译成机器码运行
上述将的是解释器的整体过程,这里再提下在正式执行JS前,还会有一个预处理阶段
(譬如变量提升,分号补全等)
预处理阶段会做一些事情,确保JS可以正确执行,这里仅提部分:
分号补全
JS执行是需要分号的,但为什么以下语句却可以正常运行呢?
console.log('a')
console.log('b')
原因就是JS解释器有一个Semicolon Insertion规则,它会按照一定规则,在适当的位置补充分号
譬如列举几条自动加分号的规则:
- 当有换行符(包括含有换行符的多行注释),并且下一个
token
没法跟前面的语法匹配时,会自动补分号。- 当有
}
时,如果缺少分号,会补分号。- 程序源代码结束时,如果缺少分号,会补分号。
于是,上述的代码就变成了
console.log('a');
console.log('b');
所以可以正常运行
当然了,这里有一个经典的例子:
function b() {
return
{
a: 'a'
};
}
由于分号补全机制,所以它变成了:
function b() {
return;
{
a: 'a'
};
}
所以运行后是undefined
变量提升
一般包括函数提升和变量提升
譬如:
a = 1;
b();
function b() {
console.log('b');
}
var a;
经过变量提升后,就变成:
function b() {
console.log('b');
}
var a;
a = 1;
b();
这里没有展开,其实展开也可以牵涉到很多内容的
譬如可以提下变量声明,函数声明,形参,实参的优先级顺序,以及es6中let有关的临时死区等
解释器解释完语法规则后,就开始执行,然后整个执行流程中大致包含以下概念:
- 执行上下文,执行堆栈概念(如全局上下文,当前活动上下文)
- VO(变量对象)和AO(活动对象)
- 作用域链
- this机制等
这些概念如果深入讲解的话内容过多,因此这里仅提及部分特性
执行上下文简单解释
- JS有
执行上下文
)- 浏览器首次载入脚本,它将创建
全局执行上下文
,并压入执行栈栈顶(不可被弹出)- 然后每进入其它作用域就创建对应的执行上下文并把它压入执行栈的顶部
- 一旦对应的上下文执行完毕,就从栈顶弹出,并将上下文控制权交给当前的栈。
- 这样依次执行(最终都会回到全局执行上下文)
譬如,如果程序执行完毕,被弹出执行栈,然后有没有被引用(没有形成闭包),那么这个函数中用到的内存就会被垃圾处理器自动回收。
每一个执行上下文,都有三个重要属性:
- 变量对象(
Variable object,VO
)- 作用域链(
Scope chain
)this
VO与AO
VO是执行上下文的属性(抽象概念),但是只有全局上下文的变量对象允许通过VO的属性名称来间接访问(因为在全局上下文里,全局对象自身就是变量对象)AO(activation object
),当函数被调用者激活,AO就被创建了。
可以理解为:
- 在函数上下文中:
VO === AO
- 在全局上下文中:
VO === this === global
总的来说,VO中会存放一些变量信息(如声明的变量,函数,arguments
参数等等)
作用域链
它是执行上下文中的一个属性,原理和原型链很相似,作用很重要。
譬如流程简述:
- 在函数上下文中,查找一个变量foo
- 如果函数的VO中找到了,就直接使用 否则去它的父级作用域链中(__parent__)找
- 如果父级中没找到,继续往上找
- 直到全局上下文中也没找到就报错
this指针
这也是JS的核心知识之一,由于内容过多,这里就不展开,仅提及部分
注意:this是执行上下文环境的一个属性,而不是某个变量对象的属性
因此:
- this是没有一个类似搜寻变量的过程
- 当代码中使用了this,这个 this的值就直接从执行的上下文中获取了,而不会从作用域链中搜寻
- this的值只取决中进入上下文时的情况
所以经典的例子:
var baz = 200;
var bar = {
baz: 100,
foo: function() {
console.log(this.baz);
}
};
var foo = bar.foo;
// 进入环境:global
foo(); // 200,严格模式中会报错,Cannot read property 'baz' of undefined
// 进入环境:global bar
bar.foo(); // 100
就要明白了上面this的介绍,上述例子很好理解
更多参考:
深入理解JavaScript系列(13):This? Yes,this!
JS有垃圾处理器,所以无需手动回收内存,而是由垃圾处理器自动处理。一般来说,垃圾处理器有自己的回收策略。譬如对于那些执行完毕的函数,如果没有外部引用(被引用的话会形成闭包),则会回收。(当然一般会把回收动作切割到不同的时间段执行,防止影响性能)。
常用的两种垃圾回收规则是:
- 标记清除
- 引用计数
Javascript引擎基础GC方案是(simple GC
):mark and sweep
(标记清除),简单解释如下:
- 遍历所有可访问的对象。
- 回收已不可访问的对象。
譬如:(出自javascript高程)
当变量进入环境时,例如,在函数中声明一个变量,就将这个变量标记为“进入环境”。
从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。
而当变量离开环境时,则将其标记为“离开环境”。
垃圾回收器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。
然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记(闭包,也就是说在环境中的以及相关引用的变量会被去除标记)。
而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。
最后,垃圾回收器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。
关于引用计数,简单点理解:
跟踪记录每个值被引用的次数,当一个值被引用时,次数+1
,减持时-1
,下次垃圾回收器会回收次数为0
的值的内存(当然了,容易出循环引用的bug)
GC的缺陷
和其他语言一样,javascript的GC策略也无法避免一个问题: GC时,停止响应其他操作。这是为了安全考虑。而Javascript的GC在100ms
甚至以上。对一般的应用还好,但对于JS游戏,动画对连贯性要求比较高的应用,就麻烦了。这就是引擎需要优化的点: 避免GC造成的长时间停止响应。
GC优化策略
这里介绍常用到的:分代回收(Generation GC)。目的是通过区分“临时”与“持久”对象:
- 多回收“临时对象”区(
young generation
)- 少回收“持久对象”区(
tenured generation
)- 减少每次需遍历的对象,从而减少每次GC的耗时。
像node v8引擎就是采用的分代回收(和java一样,作者是java虚拟机作者。)
更多可以参考:
V8 内存浅析
可以参考:
ajax跨域,这应该是最全的解决方案了
可以参考:
AJAX请求真的不安全么?谈谈Web安全与AJAX的关系。
可以参考:
CSS像素、物理像素、逻辑像素、设备像素比、PPI、Viewport