2020年2月

今天来继续学习MDN Web开发技术基础之HTTP的部分内容。在前端面试中,HTTP部分除了缓存,问的最多可能就是跨域问题,无非关注跨域问题产生的原因,如何解决跨域问题等。

同源策略与跨源访问

在了解访问控制之前,我们要了解为什么设置一个控制机制,在访问什么资源的时候要进行何种控制。同源策略是浏览器的一种基本安全策略,但是实际的需求往往还是需要跨源访问资源的,进行安全可靠的跨源资源访问,也是Web开发需要注意的内容。

浏览器的同源策略

首先我们了解一下Web安全的一个重要概念—同源策略。

同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。

简单来说就是访问a.com,返回的文档和脚本中,如果请求b.com的资源,将受到浏览器的策略管理,防止网络拦截修改了站点资源加载了恶意资源。

而“同源”指的是协议+主机+端口相同的资源。例如http://a.com和https://a.com,由于协议不同而不属于同源;再如http://a.com和http://b.com,由于主机不同而不属于同源;再如http://a.com和http://a.com:81,由于端口不同也不属于同源。而http://a.com/1/2.html和http://a.com/2/3.html满足“协议/主机/端口”三元组属于同源。

注意!IE同源限制不包含端口,且互信的域名不限制。

通过about:blank 打开的窗口与打开者算作一个源,因此其包含的代码算在打开者的作用范围,以此来讨论是否跨域。

注意!通过document.domain 修改当前页面的域时,只能修改为当前域或父域,然后后续的同源检测将以这个设置成功的域进行。比如http://b.a.com/1/2.html中使用脚本document.domain=“a.com” ,后续的代码将通过http://a.com/1/3.html的资源请求,认为是同源。但是2.html并不能执行document.domain=“b.com” 这样的代码。另外就是要注意document.domain=“a.com” 这样的操作会将端口号覆盖为null,要么指明端口,要么确保两个域端口都是null。修改域的操作一般用在让子域访问父域的资源。

跨源网络访问

为了安全起见,浏览器要求同源之间的资源安全访问,所以同源策略会限制所有的,尝试在资源中访问其他资源的行为。比如异步请求API XMLHttpRequest发送的请求和img标签所加载的资源。

  • 通常同源策略允许跨源的写操作,比如表单提交。
  • 允许跨源资源嵌入。比如script标签嵌入跨域脚本,link标签嵌入跨域css(Content-Type头可能需要处理一下),img标签嵌入跨域图片资源,其他的还有video,audio,object,embed,applet等标签,css中的@font-face引入字体(这个特性不同浏览器会不同),另外frame标签和iframe标签在根站点没有X-Frame-Options的情况下可以加载跨域站点资源。
  • 不允许跨源读操作。

!!!那么在同源策略的限制下,就完全不允许访问跨域资源了吗?那就要继续讨论我们的CORS。上面也说到了,同源策略下跨域访问也分允许和不允许,如果连那些允许的内容也想禁止掉该怎么办:

  • 如果要拒绝跨域写操作,也就是拒绝跨域的表单提交等,可以使用传说中的CSRF token。这里简单提一下跨站请求攻击,通过一些方法让用户发起一个GET请求(通常),会携带用户浏览器的绘画信息,让用户在不知道这个请求作用的前提下执行了某项需要登陆权限的操作。这种攻击能够成功的原因,一方面是因为用户的凭证存在cookie,使用GET请求有时候不需要脚本(让用户点开一个攻击链接),所以要防御这种攻击,可以通过referer字段,保证请求是从制定源发起的,或者使用CSRF token。设置这个token是为了和cookie中的凭证区分开,往往是存在于资源的内部,所以攻击者无法提前知道。
  • 如果要拒绝跨域读操作,要确保不能被嵌入。
  • 如果要拒绝跨域嵌入,根据官方文档的描述,貌似没有办法。这里要说一下,浏览器在处理HTML文档中的script标签时,通常不会按照Content-Type来处理,而是直接当作JavaScript脚本来处理,所以如果你的script的src指向一个不是脚本文件的资源,浏览器也会按照脚本来解析。

跨源脚本API访问

在浏览器环境下,有一些API可能会引起不同源文档的关联,比如在某页面通过window.open来打开另一个源的文档,这个时候window和location对象的访问也会有响应的限制。由于打开者和被打开者不是同源资源,但是window.blur和window.close等操作是允许跨源使用,另外还可以注意window.postMessage也是可以的。而Location对象只能用replace,具体还要看浏览器。

跨域资源共享CORS

由于同源策略的限制,处于安全考虑往往不允许跨源资源的完全访问,而跨域资源共享机制,通过HTTP头部信息,在HTTP层面可信任的访问不同源的资源。或者说浏览器的同源策略,是建立在HTTP访问控制的基础上,放通某些操作,同时阻挡某些操作。注意!这里我读到一句话,某些不被允许的跨域请求,有可能是在发送层面被阻止,也有可能是在响应层面被阻止。

具体到跨源读操作,浏览器的同源策略,限制跨源HTTP请求,也就是XMLHttpRequest以及新的Fetch API,而限制的方式是:使用这些API请求资源的脚本等,这里统称为Web应用,仅限于从加载这个应用的域请求HTTP资源,除非响应能够正确包含CORS头。所以正确的描述跨域问题,应该是跨域请求收到跨域资源共享机制的控制。

哪些行为需要CORS

跨域资源共享让以下情况可以进行跨域HTTP请求:

  • XMLHttpRequest和Fetch API发起的跨域HTTP请求,需要在CORS指导下进行
  • Web字体也受CORS指导,网站的字体资源需要保护,让已授权网站进行使用
  • WebGL题图
  • drawImage方法绘制图片和视频资源时,这些资源的访问收到CORS指导,也是为了防止资源被滥用

CORS原理

跨域资源共享机制通过HTTP头,请求-响应过程中,就能让浏览器知道该资源是不是允许访问,同时浏览器也可以根据共享机制来保证资源的可访问性。

功能概述

跨域资源共享机制,或者说规范,对HTTP首部信息进行了一些扩充,为的是能够指明共享的条件,比如什么样的资源可共享,可被什么样的客户端共享之类的。同时CORS主要针对GET以外的,可能对服务器产生影响的HTTP方法有要求,比如常用的异步请求方法POST,或者文件上传PUT,浏览器会用OPTIONS请求进行检查,因为OPTIONS请求得到的响应也包含跨域控制的头字段,浏览器就可以根据这些字段来决定是否进行同源控制。当然,发送OPTIONS请求时,服务器也知道浏览器的信息。

要注意的是,CORS发起的检测请求也是会失败的,但是代码中使用API发起的请求并没有真的执行,所以产生错误可能无法在代码中被处理。

使用场景

CORS要求跨域请求要先进行检查,但是某些“简单请求”不会这样。如果一个请求,方法是GET,HEAD或POST中的一种,Content-Type是text/plain,multipart/form-data,application/x-www-form-urlencoded中的一种,以及一些其他要求(具体可以参见文档),这样的请求(同时满足这些条件)被认为是“简单请求”,不会进行OPTIONS请求检查。

虽然简单请求不会进行检查,但是对于服务器来说,如果不满足要求也是得不到响应的,所以并不是跨过了同源策略的限制。

下面将使用官网的一个例子分析学习以下:

// 加入http://foo.example页面中如下脚本
var invocation = new XMLHttpRequest();
var url = 'http://bar.other/resources/public-data/';
   
function callOtherDomain() {
  if(invocation) {    
    invocation.open('GET', url, true);
    invocation.onreadystatechange = handler;
    invocation.send(); 
  }
}

产生的请求报文和响应报文如下:

GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Referer: http://foo.example/examples/access-control/simpleXSInvocation.html
Origin: http://foo.example


HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2.0.61 
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[XML Data]

请求报文就不难发现,Origin包含的主机名和Host指明的主机名不相同,是一个跨域请求。同时包含各种Accept-头。响应头的一个重要字段Access-Control-Allow-Origin: * ,也就是说服务器接受的跨域访问来源没有限制,浏览器用请求头中的Origin和比较,发现是包含关系,也就是允许,所以这个跨域请求OK。

当然这个*是由服务器决定的,如果服务器认为这个接口只能对某些站点开放,比如本例中的foo.example,那么进行这样的设置Access-Control-Allow-Origin: http://foo.example,这个跨域请求同样是OK的,因为Origin: http://foo.example和“http://foo.example”比较是匹配的。

最简单的跨域资源访问控制,由请求头中的Origin和响应头中的Access-Control-Allow-Origin进行匹配,Access-Control-Allow-Origin的值则只能是*(所有)或者是Origin字段指明的域名,即泛匹配和严格匹配。

OPTIONS请求

HTTP方法中有一个比较特殊的,OPTIONS,专门用于检查请求,目的是为了知道服务器是否支持即将发送的真是请求,如果不进行检查,跨域请求可能会产生不可预见的影响,这是一个为了保证请求安全的措施之一。

简单来说,不是“简单请求”的各种请求都需要进行检查,之前的例子中,虽然是跨域请求,但是没有特别的请求头,也不是特殊的请求方法,所以作为一个简单请求并没有进行检查。那下面在看一个因为包含了特殊请求头的跨域请求,引起了检查请求,并如何作用:

var invocation = new XMLHttpRequest();
var url = 'http://bar.other/resources/post-here/';
var body = '<?xml version="1.0"?><person><name>Arun</name></person>';
    
function callOtherDomain(){
  if(invocation)
    {
      invocation.open('POST', url, true);
      invocation.setRequestHeader('X-PINGOTHER', 'pingpong');
      invocation.setRequestHeader('Content-Type', 'application/xml');
      invocation.onreadystatechange = handler;
      invocation.send(body); 
    }
}

这个简单的例子要向一个不同源的服务器发送一个XML文档,同时设定了一个自定义请求头“X-PINGOTHER”,Content-Type也设定成“application/xml”,虽然是POST请求,但是仍然会触发OPTIONS请求进行检查。从流程上来说,浏览器会先发送一个OPTIONS请求,包信息是这样的:

OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

Access-Control-Request-Method: POSTAccess-Control-Request-Headers: X-PINGOTHER, Content-Type 信息,这两个就是要检查的内容,询问服务器支不支持POST方法,同时询问相关的请求头。假设得到以下的响应:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

OPTIONS请求的响应包含了Access-Control-*的内容,回答了OPTIONS要检查的内容,服务器支持POST,GET和OPTIONS请求,支持X-PINGOTHER和Content-Type头部。Access-Control-Max-Age: 86400这个信息告诉浏览器,在24小时内不用再检查这个请求,可以减少不必要的OPTIONS请求。当然浏览器自身也有自己的限制,可能不会完全按照这个字段来。

身份凭证问题

之前也说到浏览器同源策略是为了资源访问的安全,所以进行跨域请求的时候,浏览器自然也会很谨慎,所以一般来说是不会携带Cookie的,也就是一般用作身份凭证的信息。但是CORS既然是为了方便资源共享,自然也会可控的携带身份凭证,这就是传说中的withCredentials 设置。相应的,服务器也要处理这种情况,浏览器通过设置withCredentials 携带Cookie过去,但是服务器没有Access-Control-Allow-Credentials:true 设置,响应也是没法完成的。

这里还要格外注意一个问题,如果服务器设置了Access-Control-Allow-Origin:true 即允许任何其他域跨域访问,这时跨域请求携带Cookie会导致请求失败。想要正确携带Cookie信息必须要严格设置Access-Control-Allow-Origin的值。这大概也是因为Cookie和域应当严格对应的关系。

详细介绍一下相关首部的含义和作用

前面有很多跨域请求的例子,请求头和响应头都有一些CORS标准下专用的信息,负责安全的进行跨域请求,有浏览器表明态度的信息,也有服务器的控制,下面来仔细看看。

Access-Control-Allow-Origin

响应首部

可能值:<origin>|*

这个字段指定了哪些外域可以访问该资源。特别说明:不需要携带身份凭证的资源,可以设置为“*”通配符,否则必须要指明外域。

另外,设置了具体的外域值,响应的Vary 字段(这个字段在HTTP缓存中有介绍)必须包含Origin,也就是说虽然资源接受外域的请求,但是第一次从a.com触发,返回了Vary字段包含Origin,使得第二次从b.com触发时,不会错误的使用缓存。

Access-Control-Expose-Headers

响应首部

可能值:各种响应头

以XMLHttpRequest实例来说,getResponseHeade()获取响应头信息,只包含一些基本的Cache-Control,Content-Type等,Access-Control-Expose-Headers响应头可以让脚本获取更多其他的头部信息。这个头信息是为了保证服务器自定义头部能正确不读取到。

Access-Control-Max-Age

响应头部

可能值:<秒数>

在OPTIONS请求的部分遇到过这个响应头,用于表达检查请求有效性。如果有这个字段,不用每次都进行检查。

Access-Control-Allow-Credentials

响应头部

可能值:true

浏览器通过设置withCredentials携带了Cookie,服务器则通过设置这个头部,让浏览器可否读响应。如果OPTIONS请求的响应包含该头部,表示实际请求时可以携带Cookie。如果GET简单请求携带了Cookie,但服务器响应没有这个字段,响应就会被忽略掉了,浏览器也不会把内容返回出来。

Access-Control-Allow-Methods

响应头部

可能值:<method>[,<method>]*

包含一个或多个支持的HTTP方法,用于OPTIONS请求的响应,告诉浏览器服务器支持何种类型的HTTP方法。

Access-Control-Allow-Headers

响应头部

可能值:<field-name>[,<field-name>]*

包含一个或多个允许的请求头部,用于OPTIONS请求的响应,告诉浏览器实际请求中,服务器允许携带的头部字段。

Origin

请求头部

可能值:<origin>

这个字段就是直接表明源的,内容是源站的URI,没有路径信息(因为不重要),表达源的概念依靠服务器名称或者叫主机名称就够了。

不过要注意的是这个字段可能为空,当且仅当源站是一个data URL。这个字段是基本的请求头,不管是不是跨域请求,总会携带这个字段表明来源。

Access-Control-Request-Method

请求头部

可能值:<method>

用于OPTIONS请求头部,告知服务器真正请求的方法

Access-Control-Request-Headers

请求头部

可能值:<field-name>[,<field-name>]*

用于OPTIONS请求头部,告知服务器真正请求会包含的头部信息。

总结

“跨域”本身描述的是一种情况,从一个源访问另一个源的资源,或者说从一个域要访问另一个域的资源,这种情况是跨域。所以跨域发生在具体什么情况:资源跳转型(表单提交,重定向)、嵌入型(script和img标签等)和异步请求(ajax相关)。同源策略则是针对跨域情形的限制-放通策略。如果非要进行跨域访问,嵌入hack—jsonp,或者利用一些同源策略下API的放通性—postMessage+window.open之类。标准一点的方式就是利用HTTP访问控制,在资源共享策略的帮助下进行发起安全的跨域请求,服务器也允许,自然就可以访问跨域资源。除此之外nginx代理,node转发算是在服务器上进行“域的扩大”,在客户端的表现上就看不出是跨域了,Origin和Host是一样的。而一个比较有争议的方式WebSocket协议,是否算是跨域,确实可以链接不同源的websocket服务并获取数据,但WebSocket协议是GET请求升级上来,理论上又算是简单请求,算是在CORS下的正常请求,所以这里也没有具体讨论。

本文并没有介绍和具体举例jsonp,或者iframe传值,毕竟这种方式对于服务器端的逻辑有特殊要求,客户端脚本也会有限制,比如jsonp制定的callback,在现在的开发模式中几乎没法使用。所以用比较标准,对客户端服务器端都友好的方式,是本文介绍的目的。

MDN官方文档对于Web开发技术部分,划分了几大块:

  • 基础:HTML,CSS和HTTP
  • Web脚本:JavaScript,WebAPI,事件和Web Components(新)
  • 图形:Canvas,SVG和WebGL
  • 其他:多媒体,WebAssembly(新),MathML,XSLT,EXSLT,XPath

从这样的划分中,我们不难发现,HTTP的重要程度可能远超Web脚本,这也是为什么很多前端面试题都是围绕着网络展开的,本文主要讨论老生常谈的HTTP缓存。

HTTP—超文本传输协议

应用层协议(网络基础这里暂时不会具体学习),为Web浏览器与Web服务器设计,所以HTTP是典型的客户端-服务端模型,请求-响应模式。HTTP是典型的无状态协议,双端在不同的请求之间不保留数据(尽管现在已经不完全是)。

MDN官方文档对与HTTP的划分:

  • 资源与URIs:标示互联网上的资源,资源定位标示符,MIME等。
  • HTTP简介:基础、概览、消息、会话和协议升级机制(101状态码)
  • HTTP安全:内容安全策略,公钥,严格传输安全,Cookie安全,XSS,X-Content-Type-Options等。
  • HTTP CORS 跨域资源共享(这个也会专门开一章学习)
  • HTTP 身份验证(如果你有拒绝访问403等相关问题,可以看看这一章节)
  • HTTP 缓存,本文所关注的主要内容,缓存类型和缓存头的含义
  • HTTP 数据压缩
  • HTTP 条件请求(If-*头)缓存更新会与其相关
  • HTTP 内容协商,Accept-*头,300状态码等相关内容
  • HTTP Cookies 补足HTTP无状态的问题
  • HTTP 请求范围
  • HTTP 重定向 或者叫URL转发,3XX状态码相关

HTTP 缓存

请求HTML页面并渲染出来往往需要请求很多资源,简单来看请求脚本资源和样式表资源都会使用大量的HTTP请求,而建立链接的过程有时比实际的传输过程更消耗时间(具体可以参见相关网络内容),HTTP缓存的目的就是为了重用已经获取过的资源来减少不必要的请求,减少网络拥塞的可能。

HTTP 缓存类型

首先我们要明确什么是缓存技术:保存资源副本,下次使用时可直接使用副本的技术。当然这是简单的定义,储存哪些资源的副本,如何存储这些副本等,都需要具体的讨论。使用缓存保留某些资源的副本,对于Web服务器来说可能减少处理请求的压力,根据缓存的位置不同,处理速度也会更快。而缓存的核心问题,一个是讨论缓存的内容,如果所有资源都缓存,空间压力是需要考虑的问题,对于哪些动态数据来说缓存是无效的;还有就是缓存的更新问题,虽然缓存保存的是哪些相对稳定的数据,但是随着时间的推移缓存可能过去,请求条件的变更缓存可能也需要更新,要确保缓存不能缓存过期的数据,否则就会产生逻辑上的错误,而不只是性能问题。

我们先讨论浏览器缓存和代理缓存(网关缓存、CDN、反向代理缓存和负载均衡等偏重于服务端缓存,针对前端的话先不了解这些)。

浏览器缓存从大分类上属于私有缓存,用户通过浏览器下载的各种文档,缓存在浏览器管理的存储空间中。代理缓存属于共享缓存,针对部分网络用户架设的web代理服务器缓存热门资源,这种缓存对于浏览器的用户来说基本上是不可见且无感的,而浏览器缓存用户可以手动清除,可以通过网络监视查看哪些数据走了浏览器缓存。

缓存操作的目标

介绍缓存的功能时,主要是为了减少网络拥塞,提高用户访问速度,但是似乎没有缓存整个过程也应当是正常的,所以缓存的有无都不应当影响访问的正确性和完整性。浏览器在设计缓存时,充分考虑了用户使用web站点的习惯和需求,对于GET请求的文档(URI相同)进行缓存,比如html文档,脚本文件和样式表文件。对于用户的异步请求来说,没有必有进行缓存,大部分都是灵活的数据。

这里说一下URI和URL。URI是统一资源标示符,URL是统一资源定位符,URN是统一资源名称。理论上URI包含URL和URN,URI比URL更抽象,URL用定位的方式实现URI(我们可以说URL和URI完全不是一个东西)。浏览器输入网址访问HTML资源,其实就是提供了一个定位进行访问资源,如果这个资源在特定的环境下,能够用其他方式唯一表示(唯一编号等),且不包含定位信息,那么这个表示就是该资源的URI。不用过于纠结URI和URL具体怎么区分,知道URL是URI的子集,URL往往包含“定位信息”即可。

先来看一些具体的缓存情况:

  • 一个GET请求,响应码200,包含HTML文档,图片等,被缓存下来
  • 永久重定向:一个请求的响应为301,表示老得URL对应的资源已经换到新的URL下,所以浏览器要缓存这一个变更,下载访问老URL的时候,就会用从新URL拿来的数据使用。新URL是从响应的Location响应头字段获取。
  • 错误响应:对于404的页面
  • 不完全响应:响应状态码206。206成功状态表示请求成功,主体包含所请求的数据区间,请求首部包含Range时。
  • 除了所有GET请求之外的,包含了特定cache头的请求

缓存控制

下面我们来研究一下那些老生常谈的用于控制缓存的请求首部:

Cache-Control头:从HTTP/1.1开始,表达是否支持缓存,请求首部和响应首部都有。

  • no-store:禁止进行缓存!表示不得保存任何请求和响应的内容,客户端发起带这样的头部的请求时,都是一个完整的请求。
  • no-cache:强制确认缓存!带有该内容的请求,会携带着本地缓存相关的验证字段,向服务器发送请求,服务端则可以根据懈怠的验证字段判断缓存是不是过期,如果没有过期我们就会得到熟悉的304,从而使用浏览器本地的缓存。
  • public/private:私有/公共缓存!如果是public则表示可以被任何中间人进行缓存,比如CDN等;private相反表示中间人不能缓存,只能在浏览器私有缓存。
  • max-age=<seconds>:缓存过期!直接指明缓存能够持续的最大时间,超过这个时间后,如果没有更新缓存,那么这条缓存将失效。
  • must-revalidate:缓存验证确认!使用缓存时必须要验证,验证不过将无法使用。具体后面有介绍。

Pragma头:从HTTP/1.0开始,与Cache-Control:no-cache效果相同,响应头中没有这个字段,这只是一个兼容性的字段,知道是啥就行。

新鲜度

缓存是为了复用那些已经获取过的数据,但是这些数据往往不是永久的,随着时间的推移,这些数据可能已经失去的时效性,今天看的数据,也许明天看没变,后天看没变,但是大后天它会变,比如周期举行的比赛的结果。初次之外,缓存通常使用的空间不大,毕竟大部分数据不会是长久有效的,同时浏览的页面不断增加,占用的空间也越来越大,某天心血来潮使用的网页,也许以后很久都不会访问,还占用空间就很不合理了,所以混存通常会设计周期性的删除策略,方便那些真正经常使用的数据享用资源。

一个很重要的因素,HTTP是客户端-服务器模型,请求-响应模式,服务器更新数据,如果客户端没有请求,是无法知道数据的变化的。所以客户端和服务端之间会制定一个策略,比如约定过期时间,即使没有请求,一段时间后也会自动弃用缓存,释放空间,且下一次请求能够保证最新数据。驱逐算法用于保证缓存中的数据的新鲜度,将陈旧的资源替换成新鲜的资源,但不是直接清除,而是将请求附加If-None-Match请求头,发送给服务器后可以得到关于新鲜度的信息。304 Not Modified响应不带有实体,但是表达了新鲜度检查结果,所以就用缓存副本。

https://mdn.mozillademos.org/files/13771/HTTPStaleness.png

从官方网站提供的这样示意图,基本可以看出缓存的工作过程。一开始缓存中没有数据,所以请求会发送到服务器,正确响应后缓存看到响应头中Cache-Control字段,max-age=100表示最大有效时间100s。10s再次访问相同的文档,发现缓存中有副本,且max-age还没过认为是新鲜的,于是直接返回到,这次访问并没有实际的请求到服务器。而100s后再次访问,缓存中有副本,但是max-age已过,请求会被附带If-None-Match特殊首部字段发送到服务器,服务器返回304表示你的副本还没有过期,那么缓存max-age被刷新,可继续使用。

对于Cache-Control:max-age=N这样的响应头,缓存的寿命就会被设定为N。另外还有一个你见过的字段Expires,这个字段指明的是一个具体的过期时间点,和Date字段进行比较,判断是否过期。再有就是Last-Modified字段,缓存寿命=[Date-(Last-Modified)]*10%。

理论上设置尽可能长的过期时间,能够更多的利用缓存,但对于长期不更新的数据,如果需要进行更新,则会比较麻烦。比如HTML中的脚本和样式表,有的时候会通过HTML中script标签和style标签的src,更改其url(改变queryString之类的)拉取新的资源,但是这样也会造成不必要的缓存浪费。这种方式被称作revving技术。在高频变动的资源文件作入口的前提下,修改低频变动资源的链接,从而达到使用更新后的资源的目的。例如我们老的HTML中使用jQuery.js?v=1.1,新的HTML则使用的新版本jQuery.js?v=1.2,这时理论上缓存中两个脚本文件都有,这样还有一个好处就是如果HTML也被缓存了,新旧可能都有访问,这样连接关系没变,依赖关系得到了保证。

https://mdn.mozillademos.org/files/13779/HTTPRevved.png

我又无耻的弄了官网的图,很好的解释了资源相关连,HTML变更与依赖资源变更的缓存使用情况,所以这样的缓存机制保留了依赖关系,新旧版本都能正常使用。现在前端项目多用脚手架工具进行打包,很少关注这样的依赖关系的变化产生的影响,因此我们也经常看到用哈希码作文件名维持关系并缓存的。

缓存验证

当用户访问某个网站的地址时,浏览器就开始作缓存验证的工作了。前面提到的Cache-Control字段决定了如何使用缓存,比如遇到must-revalidate,就会强制进行验证。当然浏览器也提供了选项。一般来说浏览器只会检查要访问的资源是不是过期,must-revalidate则要跟服务器进行确认,然后会给用户返回对应的资源。

ETage响应头是对UA不透明的值,意思就是浏览器其实并不认识这个头,也不知道含义是什么。但是这个字段会在验证时将其赋予If-None-Match字段进行发送,这是一个强校验器。

相应的Last-Modified响应头是一种弱校验器,会被带到If-Modified-Since进行缓存验证。

浏览器向服务器发送验证请求,服务器返回200或者304,都表明浏览器可以信任缓存数据,另外304将会指导浏览器刷新缓存的过期时间。

带Vary头的响应

Vary 是一个HTTP响应头部信息,它决定了对于未来的一个请求头,应该用一个缓存的回复(response)还是向源服务器请求一个新的回复。它被服务器用来表明在 content negotiation algorithm(内容协商算法)中选择一个资源代表的时候应该使用哪些头部信息(headers)

怎么说,Vary响应头会影响后续的请求。我又要偷官网的图了:

https://mdn.mozillademos.org/files/13769/HTTPVary.png

第一次请求,带有请求头Accept-Encoding(告诉服务器我接受怎样的编码),没有缓存内容,发送到服务端并得到200响应。这个响应包含Content-Encoded(告诉客户端是怎么内容编码的)和Vary:Content-Encoding字段。这个Vary字段就指明了下次请求时,如果服务器要求的Content-Encoding对不上,那么就不认为命中了缓存。于是缓存时用文档的URL+Content-Encoding的值作为缓存键,保存下这次的内容。

第二次请求,Accept-Encoding携带了br,由于上一次请求的Vary字段,缓存中对应的文档要求gzip因此无法命中,请求依旧到达了服务端,并且正常返回了Content-Encoded:br的响应,也带有Vary字段,这时缓存就会扩大命中的范围(注意是更新,而不是新增)。

第三次请求,Accept-Encoding仍然是br,但是上次响应已经扩大了范围,所以这次命中缓存并返回了。

这样做有什么意义呢?Vary可以携带User-Agent,区分移动端浏览器和桌面端浏览器。

HTTP 条件请求

由于之前看到了很多If-*的请求头,这里有必要介绍一下HTTP条件请求。

在 HTTP 协议中有一个“条件式请求”的概念,在这类请求中,请求的结果,甚至请求成功的状态,都会随着验证器与受影响资源的比较结果的变化而变化。这类请求可以用来验证缓存的有效性,省去不必要的控制手段,以及验证文件的完整性,例如在断点续传的场景下或者在上传或者修改服务器端的文件的时候避免更新丢失问题。

验证器这个概念之前应该是提到过了,不仅可以验证缓存有效性,还能验证完整性。这里貌似有出现了另外一个前端常考的题目—断点续传,这个不在本文的学习范畴内。

简单来说,就是请求带有特定的首部,会影响响应结果。也就是说通过请求的首部来给这次的请求-响应一个限定条件,根据这些条件的匹配情况响应会有一些不同。

比如安全方法(不动服务器状态的方法)GET通常用于请求文件,条件请求可以让服务器进行判断,是否应当返回。而类似PUT方法在上传文件时,可以对待上传的文件进行版本匹配之类的判断,满足条件才上传。当然这些逻辑貌似现在都在服务端应用中处理了。

验证器

之前我们提到过ETag强校验器。条件请求首目的都是为了在服务端进行匹配用的,比如请求某项资源时,如何确定缓存中的数据和服务端数据是否没有变化,完全比较的话你还得把缓存内容也发出去,这显然更浪费资源了,于是有了一些校验器字段,比较验证器的值还是靠谱的。

ETag是强验证器,也就是说要进行完全匹配的,一一对应,断点续传时可能通过该字段进行确认。ETag的值通常使用MD5或其他类似算法,计算资源的散列值,就像我们平时用散列值判断文件是否有改动一样。注意:ETag值通常是一个字符串,如果以“w/”开头,表示将不采用强验证。

Last-Modified是弱验证器,因为需要和Date字段进行联合计算,还有10%的容忍度(或者说是精度),所以并不那么精确。

条件首部

  • If-Match:该请求首部的值是一个曾经响应首部的ETag值,用于在服务端比较对应资源是否变更。If-Match语意上表示匹配的情况下才返回资源,所以更新缓存谁一般用的是If-None-Match。
  • If-None-Match:和If-Match类似,只是在比较为不匹配时返回资源(也就是传输中的返回新鲜资源)。
  • If-Modified-Since:该请求首部携带之前响应首部的Last-Modified值,如果服务端资源的Last-Modified字段已经比该请求首部给的值晚(也就是别更新过),则会返回这个资源。
  • If-Unmodified-Since:和If-Modified-Since相反。
  • If-Range:和If-Match和If-Modified-Since类似,包含一个tag或者时间,如果能匹配则正常返回206,否则的返回200,对应的是完整的资源而不是部分。

条件请求与缓存

缓存的验证需要用的ETage和Last-Modified,用于判断缓存是否过期,其实就是用条件请求进行更新。

首先:没有缓存的情况下,请求正常被处理返回200 OK。这是响应头都会带着Last-Modified和ETag,或者说验证器随着响应回到浏览器中,跟着资源一同被缓存起来,这也是为了后面的缓存验证发起条件请求时可以使用。

有Cache-Control首部控制的缓存有效性,采取特定的动作。可以回去复习一下Cache-Control字段的含义即对应的情况,最常用的应该就是no-cache,也就是强制使用确认缓存的方式,用验证器发起条件请求来确定是否使用缓存。如果缓存没有失效,比如没有超过max-age的情况下,是不会发起请求的。

常见的304 Not Modified也都是条件请求经常返回的,客户端会根据这个响应来刷新缓存的新鲜度。所以请求还是发出去了,但是304响应不带body,所以传输消耗还是来的比较小,且相对固定缓存来说更容易实现更新。

更新:条件请求的本质含义是满足条件的请求可以得到响应,反之则会给一些提示而不是响应。比如GET /doc HTTP/1.1 在没有缓存的情况下,服务器正常返回200,并且携带Last-Modified:dateEtag:“xyz” 的响应首部,这两个信息和具体资源都被缓存。下次再请求/doc 时,会带上相应的条件头部,Last-Modified:date 对应If-Modified-Since:date 表达的是如果服务器的资源的最新修改时间,在date之后,请给我这个资源;Etag:“xyz” 对应If-None-Match:"xyz" 表达的是如果服务器的资源tag不是xyz了,请给我这个资源。

这时假设服务器资源没有变动,那么这两个条件都不满足,肯定不会返回200,更不会给客户端这个资源;相反的,如果资源变动,这两个条件都满足,会给一个200响应,并提供最新资源,再提供新的Last-Modified:newDateEtag:“xyz1” 新的头部用于告知客户端新的资源是怎样的。

注意:以上内容对于web开发来说是透明的,也无法控制的,但是分析网站性能是可能会用到。

总结

面试题中经常遇到缓存问题,你知道哪些与缓存相关的头部信息,304和200,强缓存和协商缓存之类的问题。304 Not Modified可能都知道是因为资源没变所以返回这个码来告诉浏览器可以用缓存,而为什么返回304可能还需要知道条件请求,条件请求与If-*相关,这些头的值可能是Last-Modified字段和ETag字段的值,满足条件就200,不满足条件就304。当前强缓存和协商缓存并没有在官方的文档中出现,但是你看缓存你就会了解期核心字段Cache-Control,不同的值有不同的效果,no-cache最常用,可以认为这就是协商缓存的标志。而max-age=<seconds>直接限定过期时间,认为是强制缓存。相应的,你会看到Expires头,Pragma头,这些理论上都没有Cache-Control头重要,但是他们也能作为缓存的依据。Expries头需要和Date头搭配,而Pragma头是一个兼容性的头,也是一个协商缓存的标志。

附赠:其他的条件请求场景

  1. 增量下载的完整性:增量下载的是HTTP协议规定的一项功能,允许恢复之前的操作,响应头信息Accept-Ranges:bytes表明下载是可恢复的。浏览器知道了服务器支持,下次请求就可以带上Ranges首部进行端点续传了。比如“Ranges:23783-”表示获取某一字节后的数据。那跟条件请求有什么关系,假设在两次请求之间资源发生了改变,可以利用If-Modified-Since和If-Match首部来判断,用于告知客户端是否需要重新开始下载。不难发现这样其实在继续之前增加了一个请求,所以有了一个If-Range首部。
  2. 使用乐观锁避免更新丢失问题:针对一些内容管理系统,在进行远程文件更新时,需要用乐观锁来保证版本一直问题。这里用PUT上传文件为例,客户端读取源文件,修改,然后上传。一般来说正常情况下PUT成功会返回204 No Content表示成功更新。但是如果有两人同时针对资源在进行修改,先后提交,在没有保护的状态下会出现覆盖的情况,且被覆盖的人无法得知,而最终使用谁的更新无法确定,这种根据谁先提交谁的修改就会丢失的情况,也称为竞争状态。所以要解决的是在竞争时不要丢失更改。无法通知已经提交成功的人,那就通知更新会被拒绝的人。那么提交的时候增加条件请求,通过乐观锁(即版本号控制并发的一种方式)模式,判断“版本号”。有一个人更新成功,“版本号”或者说ETag更新,下一个人提交时条件请求失败得到412 Precondition Failed响应,就知道自己修改的过程中已经有人提交成功了。当然后面的工作可能就需要自己的逻辑了。
  3. 资源首次上传问题:和上面的问题类似,两个人几乎同时上传同一个文件,在服务端要创建文件也会产生竞争状态。解决方案也是使用条件请求,If-None-Match首部提供为“*”,表示有这个实体在没有的情况下才能上传,否则也会返回412哟。

注意!If-None-Match首部有兼容问题,HTTP/1.1以后才有,所以使用前请HEAD一下。

我的阿里面经(中)

Q4:Webpack中plugins和loader的作用和区别

我的回答:loader是针对特定规则指明的文件进行定向处理。plugins则提供更多的webpack功能,往往是针对整个打包的过程。loader更关注什么样的文件用什么方式处理,而plugins则关心整个项目的情况。

A4:了解一些Webpack打包的过程

根据官方文档的说明,plugins应该是:

插件是 webpack 的 支柱 功能。webpack 自身也是构建于,你在 webpack 配置中用到的相同的插件系统之上!插件目的在于解决 loader 无法实现的其他事。

比如常用的插件[CommonsChunkPlugin](https://webpack.docschina.org/plugins/commons-chunk-plugin),其作用就是抽取共享模块,这个动作显然是针对整个项目的,而不是针对单个文件或者某一类资源进行的。

而loader就比较明确了:

loader 用于对模块的源代码进行转换。loader 可以使你在 import 或"加载"模块时预处理文件。因此,loader 类似于其他构建工具中“任务(task)”,并提供了处理前端构建步骤的强大方法。

loader需要设置特定的匹配规则,针对某一种资源施行特定的任务。

从Webpack的功能上看,是一个打包工具,大体上可以认为它分析项目中各个文件,获取它们的类型和依赖关系,并将它们整合成一个或多个bundle。核心概念概念包括入口、出口、loader、插件、模式以及浏览器兼容性。不难理解webpack从入口文件或目录下手,能够在内部创建一个依赖关系图谱,借用loader对不同的文件进行特殊的处理,比如TS-JS的转换,高级别ES代码转换为低级别ES代码等。同时使用插件对整个工程进行扩展,比如注入环境变量等。最终生成打包的结果。说白了webpack是一个包含“翻译”和“打包”的过程,靠loader将各种资源进行处理,靠插件在打包的过程中完成所需的功能。

Q5:Babel的作用和原理,高级别语言特性哪些能向下哪些不能

我的回答:babel通过将源码转化成AST抽象语法树,根据不同的翻译要求,由语法树重新组合成目标代码。具体哪些特性不能被shim,我是真的没有想起来。

A5:Babel是一个JavaScipt的编译器

Babel 是一个工具链,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。功能主要包括:

  • 语法转换
  • 通过 Polyfill 方式在目标环境中添加缺失的特性 (通过 @babel/polyfill 模块)
  • 源码转换 (codemods)

语法转换可以认为是不同写法的处理,比如Class语法转换成普通的原型写法。通过Polyfill的方式增加特性,也可以认为是利用低版本的特性,来实现高版本的特性,比如Promise的polyfill。源码转化则包含更多的翻译工作,比如Flow转换会将类型声明的内容删除等。

大部分的新语言特性,可以通过翻译的方式在ES2015的环境下得到相同的功能,比如箭头函数,字符串模板,结构,let/const,export/import等。

这里着重注意不支持的部分:迭代器(包含异步函数),Symbol,Array.from本身不支持,但是可以通过polyfill。

@babel/preset-env 最智能的包

@babel/preset-stage-0到@babel/preset-stage-3 被弃用,针对不同提案阶段的特性进行转换

@babel/preset-flow 针对flow语法进行转换

@babel/preset-react 包含jsx翻译等

@babel/preset-typescript 针对ts翻译

Q6:Http状态码中301和302分别是什么,了解过Http/2

2xx系状态码,3xx状态码,4xx状态码,5xx状态码,HTTP协议的状态码很重要就对了。HTTP/2和SPDY啥的见过没了解过,既然问肯定就跟前端会有关系。

A6:301 Move Permanently和302 Moved Temporarily

简而言之,3xx的状态码表达请求的资源已经不在这里,可能在别的地方。

301 Move Permanently资源永久移动,用作重定向,响应必须包含一个Location字段表示重定向目标,一般的浏览器都具备连接编辑功能,GET请求得到301响应,浏览器会自动跳转,所以301是网站由HTTP自动跳转HTTPS最好的方式。

302 Moved Temporarily资源暂时移动,一般情况下(不带特殊缓存头),该状态码不可缓存。响应中也包含Location字段,但是对于后续的请求仍然使用老的URL。当一个连接a使用302指向另一个连接b,对于搜索引擎来说,可能收录目标连接b,也有可能因为连接b的一些原因只收录连接a,这可能会引起收录“劫持”。

303 See Other

303主要目的是允许POST请求的响应将客户端定位到某个资源上

304 Not Modified,大名鼎鼎的未修改。这会牵扯到一个复杂的浏览器缓存问题,稍后会有专门的文章介绍

其他还有一些3xx系的状态码,就不在这具体展开了。

HTTP/2这里略提一些,稍后也有专门的文章学习。HTTP/2学名叫超文本传输协议第2版,主要基于SPDY协议。这里先列一些关键点:

  1. 请求方法,状态码以及大部分头部字段都和HTTP/1.1高度兼容
  2. Server Push(这一点我认为和前端相关性比较大)
  3. 请求管线化
  4. 修复队头阻塞问题
  5. 数据传输采用多路复用,使多个请求合并在一个TCP链接中
  6. 强制压缩,包含请求头

HTTP/2和SPDY的区别有哪些呢?

HTTP/2的开发基于SPDY进行跃进式改进。在诸多修改中,最显著的改进在于,HTTP/2使用了一份经过定制的压缩算法,基于霍夫曼编码,以此替代了SPDY的动态流压缩算法,以避免对协议的Oracle攻击——这一类攻击以CRIME为代表。此外,HTTP/2禁用了诸多加密包,以保证基于TLS的连接的前向安全。

Q7:聊一下Node如何实现多进程,EventLoop怎么Loop的

玩JS怎么都躲不过EventLoop,因为多线程特性基本上是各个编程语言都具备的,且与编程思想紧密关联,这个不了解是不可能的。

A7:多线程、多进程以及EventLoop

线程和进程这种字眼儿真是学了忘忘了学,重新再看看:

进程(Process):在面向进程的操作系统下,是基本的执行实体;在面向线程的操作系统下,是线程的容器。用比较直观的说法就是,用户双击运行一个程序,就会产生一个独立的进程,有些程序支持启动多个进程相互独立。打开任务管理器,或者活动监视器,你就能看到一堆进程信息,占用的CPU资源,启动的线程数,GPU资源等。

线程(Thread):操作系统中运算调度的最小单位。

一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务

进程与线程的区别:进程是计算机管理运行程序的一种方式,一个进程下可包含一个或者多个线程。线程可以理解为子进程。

CPU的核心数和线程数:一般来说CPU的核心数和同时执行线程数相同,而有了超线程技术后,就出现了“双核四线程”这样的线程数超过核心数的情况。所以线程数就表达了这款CPU同一时刻有多少个线程同时能被运算。

Node.js® 是一个基于 Chrome V8 引擎 的 JavaScript 运行时。

Node.js是JavaScript的运行时,而“运行时”在wiki上的定义是指整个运行时期。全局安装的Node.js的前提下,命令行陨星node命令启动REPL,在任务管理器中你就会发现新建了一个node进程。所以Node.js也被叫做”能够在服务端执行JavaScript的运行环境“。Node.js使用V8引擎作为JavaScript的运行引擎,同时包含运行依赖库,以及和底层交互的依赖库等。

那么所谓的”Node.js是以单线程运行“,其实表达的是你编写的程序在Node.js环境下运行是单线程的模式,或者JavaScript代码在V8引擎中是单线程运行的。

Node.js的事件循环官方介绍看这里。单线程在执行I /O时理论上是要等待的,但这明显不符合现代编程需要,所以Node.js通过事件循环来实现非阻塞I/O。一个时间循环包含诸多阶段,每个阶段逐个完成,每个阶段都执行完后再回到第一个阶段。每个阶段包含一个FIFO的回调队列,先执行这个阶段特定的操作,然后清空队列(或者到上限),才会进入下一阶段。

Node.js通过将事件循环注册到操作系统的方式,来响应请求。产生连接时,操作系统产生一个回调,这些回调会在事件循环中被处理。所以在Node.js中用户请求并不是对应到单独的线程,而是对应到具体的回调。

使用http模块可以创建一个简单的服务器,形如:

const http = require('http');

const server = http.createServer((request, response) => {
  // magic happens here!
});

调用createServer提供的回调函数,其实就相当于处理一个链接的逻辑,以此可以稍微具体的看到在Node.js中连接与回调之间的关系。

说了一大通,忘了介绍Node.js如何进行多进程操作的。Node.js通过child_process模块来创建子进程:

  1. child_process.exec(command[, options], callback),使用子进程执行命令,命令结果将传递到callback中。注意,这里是可以通过子进程来执行”node xxx.js“的,通过全局对象process来获取当前进程的一些变量,比如process.argv表示运行起该进程的命令参数集合。比如我们使用”node some.js ‘args’“启动一个Node.js程序,在some.js或者整个应用中,可以通过process.argv[2]来获取到‘args’这个字符串。
  2. child_process.spawn(command, args),和exec差不多,只不过参数从command中独立出来。注意,该方法产生子进程是异步的,不会阻止父进程的事件循环,通过on('event name',callback)来响应子进程的各种窗台
  3. child_process.fork(modulePath, args),直接提供一个可执行的Node.js模块,并以此产生一个子进程。

通过上述方法建立的子进程,通过stdin、 stdout 和 stderr 的管道与父进程进行通信,这些管道通过stdout.on('data',callback)的形式在父进程中监听信息,且管道有容量限制。比如当子进程想stdout中写入大量内容,且没有将这些输出消耗掉,缓冲区满了会导致子进程阻塞,直到缓冲区可以继续写入内容。初次之外还要注意创建同步进程:

child_process.spawnSync()、child_process.execSync() 和 child_process.execFileSync() 方法是同步的,并且将会阻塞 Node.js 事件循环、暂停任何其他代码的执行,直到衍生的进程退出。

使用 child_process.spawn()、child_process.exec()、child_process.execFile() 或 child_process.fork() 方法会创建 ChildProcess 的实例:

  • 当子进程的stdio流关闭时触发close事件
  • 子进程中subprocess.disconnect()调用会产生disconnect事件,不能发送或接受消息
  • 无法衍生进程,无法发送消息,无法杀死进程,都会导致error事件
  • 退出事件exit,正常退出会有退出码,default为null,接到信号终止,将通过第二个参数标明
  • 子进程通过process.send()发送信息时触发message事件

更多信息请查看Node.js官方文档child_process模块一章。

在家办公的坏处就是整个人萌萌哒,看着不爽的分支就想删除

恰巧被我手贱删除的分支,还是一个经过测试,但没有安排上线的功能分支(似乎普通开发者就不应有删除分支的权限)。还好恢复的步骤比较简单,即使是远端分支被删除也可以恢复。

前提条件:

  1. 你记得你最后提交的内容
  2. 你有类似Jenkins的CI,有发布记录的

如果你能记住你最后的提交内容,在git log里检索就好。

git log -g | grep -C5 '提交信息'

你会得到类似如下格式的内容:

commit [commit_id]
Reflog: HEAD@{1} ([author_name] <[author_email]>)
Reflog message: commit: '提交信息'
Author: [author_name] <[author_email]>
Date:   Sun Jan 31 22:26:33 2016 +0800

    '提交信息'

其中commit字段对应的commit_id就是用于创建恢复分支的标记

如果你记不住最后提交的信息了(像我这样粗心大意),你可以使用考虑从类似Jenkins这样的发布工具中找找记录,如果能找到最后的发布记录,你也可以得到

Commit [commit_id] by [author_name]

之类的信息,其中的commit_id也是一样的

核心命令:git branch [new_branch_name] [commit_id]

这样你就有了一个新的恢复分支,基于commit_id创建,包含了你丢失的信息