从输入url到页面展现发生了什么
在浏览器中输入 URL 到页面展现,经历了多个步骤,这个过程通常被称为“网页加载过程”或“页面生命周期”。以下是详细的步骤说明:
-
URL 解析:
-
当用户在浏览器地址栏中输入 URL,浏览器首先对这个 URL 进行解析。
-
解析包括协议(例如,
http
或https
)、主机名、端口号、路径以及查询参数等。
-
-
DNS 解析:
-
浏览器需要将主机名(例如,www.example.com)解析为IP 地址。
-
浏览器首先检查本地缓存中是否有相应的 IP 地址,如果没有,则向 DNS 服务器发送请求获取 IP 地址。
-
-
建立 TCP 连接:
-
使用获得的 IP 地址和端口号,浏览器尝试与服务器建立 TCP 连接。
-
这个过程包括三次握手,确保浏览器和服务器之间建立可靠的连接。
-
-
发起 HTTP 请求:
-
浏览器向服务器发送 HTTP 请求,请求中包含了要获取的资源信息,如请求的方法(GET、POST 等)、资源路径、请求头等。
-
如果是 HTTPS,还会进行 SSL/TLS 握手过程。
-
-
服务器处理请求:
-
服务器接收到浏览器的请求后,根据请求的资源路径和其他信息,执行相应的处理逻辑。
-
服务器可能会查询数据库、调用后端服务等,最终生成响应。
-
-
返回 HTTP 响应:
-
服务器将生成的 HTTP 响应返回给浏览器。
-
响应包含状态码、响应头(包含了有关响应的元信息)以及响应体(包含了实际的资源数据)。
-
-
浏览器接收响应:
-
浏览器接收到服务器返回的 HTTP 响应后,首先检查状态码。2xx 表示成功,3xx 表示重定向,4xx 表示客户端错误,5xx 表示服务器错误。
-
根据响应头中的信息处理响应,可能包括设置 Cookie、缓存处理等。
-
-
HTML 解析与构建 DOM 树:
-
如果响应的是 HTML 内容,浏览器开始解析 HTML,构建 DOM(文档对象模型)树。
-
HTML 解析器遇到外部资源(例如样式表、脚本、图片等)时,会发起新的请求获取这些资源。
-
-
CSS 解析与构建 CSSOM 树:
-
如果响应中包含了样式表,浏览器会解析 CSS 内容,构建 CSSOM(CSS 对象模型)树。
-
CSSOM 树和 DOM 树一起用于计算和应用最终的样式。
-
-
JavaScript 解析与执行:
* 如果响应中包含了 JavaScript 脚本,浏览器会解析并执行这些脚本。
* 脚本执行期间可能会修改 DOM 结构、样式和处理用户交互。
-
渲染页面:
* 浏览器根据 DOM 树和 CSSOM 树计算出布局,然后将页面绘制到屏幕上。
* 这个过程可能会触发重新布局和重绘。
-
页面加载完成:
* 当所有资源都加载完成,页面完成渲染后,触发 `load` 事件。
* 开发者可以通过 JavaScript 监听该事件,执行一些页面加载完成后的操作。
这个过程涵盖了从用户输入 URL 到页面展现的主要步骤,其中每一步都是为了获取、处理和呈现页面所需的资源。
一、浏览器
1. 事件机制
事件触发三阶段
事件触发有三个阶段
-
window
往事件触发处传播,遇到注册的捕获事件会触发 -
传播到事件触发处时触发注册的事件
-
从事件触发处往
window
传播,遇到注册的冒泡事件会触发
事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个目标节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行。
node.addEventListener(
'click',
event => {
console.log('冒泡')
},
false
)
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
addEventListener第三个参数
addEventListener() 和 removeEventLinstener() 接收 3 个参数:事件名、事件处理函数 和 一个 option 对象或一个布尔值 useCapture( true 表示在捕获阶段调用事件处理程序, false (默认值)表示在冒泡阶段调用事件处理程序,因为跨浏览器兼容性好,所以事件处理程序默认会被添加到事件流的冒泡阶段(也就是默认最后一个参数为 false ))。
addEventListener(type, listener, useCapture | options)
option 参数有一下几个选择
-
capture: Boolean,表示 listener 会在该类型的事件捕获阶段传播到该 EventTarget 时触发。
-
once: Boolean,表示 listener 在添加之后最多只调用一次。如果是 true, listener 会在其被调用之后自动移除。
-
passive: Boolean,设置为true时,表示 listener 永远不会调用 preventDefault()。如果 listener 仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。
useCapture
简单的说,我个人的理解是 useCapture 参数指定了该事件处理程序触发的“时机” :是在事件流的捕获阶段还是冒泡阶段。但是,无论最后一个参数设置为什么,都不会阻碍事件流的传播。
target和currentTarget的区别
currentTarget始终是监听事件者,而target是事件的真正发出者。
DOM事件处理程序中this和currentTarget永远相等 HTML事件处理程序和IE事件处理程序this指向window
<!DOCTYPE html>
<html>
<head>
<title>Example</title>
</head>
<body>
<div id="A">
A
<div id="B">
B
</div>
</div>
<script>
var a = document.getElementById('A'),
b = document.getElementById('B');
function handler(e) {
console.log(e.target);
console.log(e.currentTarget);
}
a.addEventListener('click', handler, false);
</script>
</body>
</html>
当点击A时:输出:
<div id="A">...<div>
<div id="A">...<div>
点击B时输出:
<div id="B"></div>
<div id="A">...</div>
2. Event loop(事件循环)
进程:是系统分配的独立资源,是CPU资源分配的基本单位,进程是由一个或多个线程组成的。
线程:线程是资源调度的最小单位,是进程的执行流,是CPU调度和分配的基本单位。同个进程之中的多个线程之间是共享进程的资源的。
浏览器内核:
1.浏览器是多进程的,浏览器的每一个tab标签都代表一个独立的进程(多个空白的tab标签会合并成一个进程),浏览器内核(浏览器渲染进程)属于浏览器多进程中的一种。
2.浏览器内核有多种线程在工作
-
GUI渲染线程:
-
负责渲染页面,解析HTML、CSS构成DOM树等,当页面重绘或者由某种操作引起的回流都会调起该线程。
-
和JS引擎线程是互斥的,当JS引擎线程在工作的时候,GUI渲染线程会被挂起,GUI更新被放在JS任务队列中,等待JS引擎线程空闲时继续进行。
-
-
JS引擎线程:
-
单线程工作,负责解析运行JavaScript脚本。
-
和GUI渲染线程互斥,JS运行耗时过程会导致页面阻塞
-
-
事件触发线程:当事件符合触发条件触发时,该线程会把对应的事件回调函数添加到队列的队尾,等待JS引擎处理
-
定时器触发线程
-
浏览器定时计数器并不是由JS引擎计数的,阻塞会导致计时不准确。
-
开启定时器触发线程来计时并处罚计时,计时后悔被添加到任务队列中,等待JS引擎处理。
-
-
HTTP请求线程:
-
http请求的时候会开启一条请求线程。
-
处理请求有结果后,将回调函数添加到任务队列,等待JS处理。
-
浏览器端事件循环
JS是单线程的
JS是单线程的,或者说只有一个主线程,也就是它一次只能执行一段代码。JS中其实是没有线程概念的,所谓的单线程也只是相对于多线程而言。JS的设计初衷就没有考虑这些,针对JS这种不具备并行任务处理的特性,我们称之为“单线程”。
JS 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。
虽然JS运行在浏览器中是单线程的,但是浏览器是事件驱动的(Event driven),浏览器中很多行为是异步(Asynchronized)的,会创建事件并放入执行队列中。浏览器中很多异步行为都是由浏览器新开一个线程去完成,一个浏览器至少实现三个常驻线程:
-
JS引擎线程
-
GUI渲染线程
-
事件触发线程
JS事件循环机制
JavaScript 事件循环机制分为浏览器和 Node 事件循环机制,两者的实现技术不一样,浏览器 Event Loop 是 HTML 中定义的规范,Node Event Loop 是由 libuv 库实现。这里主要讲的是浏览器部分。
JS有一个 main thread
主线程和 call-stack
调用栈(执行栈)所有的任务都会被防到调用栈等待主线程执行
-
JS 调用栈
JS 调用栈是一种后进先出的数据结构。当函数被调用时,会被添加到栈中的顶部,执行完成之后就从栈顶部移出该函数,直到栈内被清空。
-
同步任务、异步任务
JavaScript 单线程中的任务分为同步任务和异步任务。同步任务会在调用栈中按照顺序排队等待主线程执行,异步任务则会在异步有了结果后将注册的回调函数添加到任务队列(消息队列)中等待主线程空闲的时候,也就是栈内被清空的时候,被读取到栈中等待主线程执行。任务队列是先进先出的数据结构。
-
Event Loop
调用栈中的同步任务都执行完毕,栈内被清空了,就代表主线程空闲了,这个时候就会去任务队列中按照顺序读取一个任务放入到栈中执行。每次栈内被清空,都会去读取任务队列有没有任务,有就读取执行,一直循环读取-执行的操作,就形成了事件循环。
-
定时器
定时器会开启一条定时器触发线程来触发计时,定时器会在等待了指定的时间后将事件放入到任务队列中等待读取到主线程执行。
定时器指定的延时毫秒数其实并不准确,因为定时器只是在到了指定的时间时将事件放入到任务队列中,必须要等到同步的任务和现有的任务队列中的事件全部执行完成之后,才会去读取定时器的事件到主线程执行,中间可能会存在耗时比较久的任务,那么就不可能保证在指定的时间执行。
所以准确的来讲,定时器执行的条件是:经过指定的时间并且主线程空闲
-
宏任务(macro-task)、微任务(micro-task)
除了广义的同步任务和异步任务,JavaScript 单线程中的任务可以细分为宏任务和微任务。
macro-task包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering。
micro-task包括:process.nextTick, Promises, Object.observe, MutationObserver。
注:JS的循环机制,即遇到宏任务,先执行宏任务,然后在执行微任务
console.log(1);
setTimeout(function() {
console.log(2);
})
var promise = new Promise(function(resolve, reject) {
console.log(3);
resolve();
})
promise.then(function() {
console.log(4);
})
console.log(5);
示例中,setTimeout 和 Promise被称为任务源,来自不同的任务源注册的回调函数会被放入到不同的任务队列中。
有了宏任务和微任务的概念后,那 JS 的执行顺序是怎样的?是宏任务先还是微任务先?
第一次事件循环中,JavaScript 引擎会把整个 script 代码当成一个宏任务执行,执行完成之后,再检测本次循环中是否寻在微任务,存在的话就依次从微任务的任务队列中读取执行完所有的微任务,再读取宏任务的任务队列中的任务执行,再执行所有的微任务,如此循环。JS 的执行顺序就是每次事件循环中的宏任务-微任务。
-
上面的示例中,第一次事件循环,整段代码作为宏任务进入主线程执行。
-
遇到了 setTimeout ,就会等到过了指定的时间后将回调函数放入到宏任务的任务队列中。
-
遇到 Promise,将 then 函数放入到微任务的任务队列中。
-
整个事件循环完成之后,会去检测微任务的任务队列中是否存在任务,存在就执行。
-
第一次的循环结果打印为: 1,3,5,4。
-
接着再到宏任务的任务队列中按顺序取出一个宏任务到栈中让主线程执行,那么在这次循环中的宏任务就是 setTimeout 注册的回调函数,执行完这个回调函数,发现在这次循环中并不存在微任务,就准备进行下一次事件循环。
-
检测到宏任务队列中已经没有了要执行的任务,那么就结束事件循环。
-
最终的结果就是 1,3,5,4,2。
3. 存储
cookie,localStorage,sessionStorage,indexDB
特性 | cookie | localStorage | sessionStorage | indexDB |
---|---|---|---|---|
数据生命周期 | 一般由服务器生成,可以设置过期时间 | 除非被清理,否则一直存在 | 页面关闭就清理 | 除非被清理,否则一直存在 |
数据存储大小 | 4K | 5M | 5M | 无限 |
与服务端通信 | 每次都会携带在 header 中,对于请求性能影响 | 不参与 | 不参与 | 不参与 |
从上表可以看到,cookie
已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStorage
和 sessionStorage
。对于不怎么改变的数据尽量使用 localStorage
存储,否则可以用 sessionStorage
存储。
对于 cookie
,我们还需要注意安全性。
属性 | 作用 |
---|---|
value | 如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识 |
http-only | 不能通过 JS 访问 Cookie,减少 XSS 攻击 |
secure | 只能在协议为 HTTPS 的请求中携带 |
same-site | 规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击 |
sessionStorage 和 localStorage 是HTML5 Web Storage API 提供的,可以方便的在web请求之间保存数据。有了本地数据,就可以避免数据在浏览器和服务器间不必要地来回传递。
sessionStorage、localStorage、cookie都是在浏览器端存储的数据,其中sessionStorage的概念很特别,引入了一个“浏览器窗口”的概念。
sessionStorage是在同源的同窗口(或tab)中,始终存在的数据。也就是说只要这个浏览器窗口没有关闭,即使刷新页面或进入同源另一页面,数据仍然存在。关闭窗口后,sessionStorage即被销毁。同时“独立”打开的不同窗口,即使是同一页面,sessionStorage对象也是不同的。
Web Storage带来的好处:
-
减少网络流量:一旦数据保存在本地后,就可以避免再向服务器请求数据,因此减少不必要的数据请求,减少数据在浏览器和服务器间不必要地来回传递。
-
快速显示数据:性能好,从本地读数据比通过网络从服务器获得数据快得多,本地数据可以即时获得。再加上网页本身也可以有缓存,因此整个页面和数据都在本地的话,可以立即显示。
-
临时存储:很多时候数据只需要在用户浏览一组页面期间使用,关闭窗口后数据就可以丢弃了,这种情况使用sessionStorage非常方便。
浏览器本地存储与服务器端存储之间的区别
其实数据既可以在浏览器本地存储,也可以在服务器端存储。
浏览器端可以保存一些数据,需要的时候直接从本地获取,sessionStorage、localStorage和cookie都由浏览器存储在本地的数据。
服务器端也可以保存所有用户的所有数据,但需要的时候浏览器要向服务器请求数据。
-
服务器端可以保存用户的持久数据,如数据库和云存储将用户的大量数据保存在服务器端。
-
服务器端也可以保存用户的临时会话数据。服务器端的session机制,如jsp的 session 对象,数据保存在服务器上。实现上,服务器和浏览器之间仅需传递session id即可,服务器根据session id找到对应用户的session对象。会话数据仅在一段时间内有效,这个时间就是server端设置的session有效期。
服务器端保存所有的用户的数据,所以服务器端的开销较大,而浏览器端保存则把不同用户需要的数据分布保存在用户各自的浏览器中。
浏览器端一般只用来存储小数据,而服务器可以存储大数据或小数据。
服务器存储数据安全一些,浏览器只适合存储一般数据。
sessionStorage 、localStorage 和 cookie 之间的区别
共同点:都是保存在浏览器端,且同源的。
区别:
-
cookie数据始终在同源的http请求中携带(即使不需要),即cookie在浏览器和服务器间来回传递。而sessionStorage和localStorage不会自动把数据发给服务器,仅在本地保存。cookie数据还有路径(path)的概念,可以限制cookie只属于某个路径下。
-
存储大小限制也不同,cookie数据不能超过4k,同时因为每次http请求都会携带cookie,所以cookie只适合保存很小的数据,如会话标识。sessionStorage和localStorage 虽然也有存储大小的限制,但比cookie大得多,可以达到5M或更大。
-
数据有效期不同,sessionStorage:仅在当前浏览器窗口关闭前有效,自然也就不可能持久保持;localStorage:始终有效,窗口或浏览器关闭也一直保存,因此用作持久数据;cookie只在设置的cookie过期时间之前一直有效,即使窗口或浏览器关闭。
-
作用域不同,sessionStorage不在不同的浏览器窗口中共享,即使是同一个页面;localStorage 在所有同源窗口中都是共享的;cookie也是在所有同源窗口中都是共享的。
-
Web Storage 支持事件通知机制,可以将数据更新的通知发送给监听者。
-
Web Storage 的 api 接口使用更方便。
4. 渲染机制
浏览器的渲染机制一般分为以下几个步骤
-
处理 HTML 并构建 DOM 树。
-
处理 CSS 构建 CSSOM 树。
-
将 DOM 与 CSSOM 合并成一个渲染树。
-
根据渲染树来布局,计算每个节点的位置。
-
调用 GPU 绘制,合成图层,显示在屏幕上。
在构建 CSSOM 树时,会阻塞渲染,直至 CSSOM 树构建完成。并且构建 CSSOM 树是一个十分消耗性能的过程,所以应该尽量保证层级扁平,减少过度层叠,越是具体的 CSS 选择器,执行速度越慢。
当 HTML 解析到 script 标签时,会暂停构建 DOM,完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件。并且 CSS 也会影响 JS 的执行,只有当解析完样式表才会执行 JS,所以也可以认为这种情况下,CSS 也会暂停构建 DOM。
Load 和 DOMContentLoaded 区别
Load 事件触发代表页面中的 DOM,CSS,JS,图片已经全部加载完毕。
DOMContentLoaded 事件触发代表初始的 HTML 被完全加载和解析,不需要等待 CSS,JS,图片加载。
图层
一般来说,可以把普通文档流看成一个图层。特定的属性可以生成一个新的图层。不同的图层渲染互不影响,所以对于某些频繁需要渲染的建议单独生成一个新图层,提高性能。但也不能生成过多的图层,会引起反作用。
通过以下几个常用属性可以生成新图层
-
3D 变换:
translate3d
、translateZ
-
will-change
-
video
、iframe
标签 -
通过动画实现的
opacity
动画转换 -
position: fixed
重绘(Repaint)和回流(Reflow)
重绘和回流是渲染步骤中的一小节,但是这两个步骤对于性能影响很大。
-
重绘是当节点需要更改外观而不会影响布局的,比如改变
color
、background-color
、visibility
等会引起重绘 -
回流是布局或者几何属性需要改变就会引起回流。
回流必定会发生重绘,重绘不一定会引发回流。回流所需的成本比重绘高的多,改变深层次的节点很可能导致父节点的一系列回流。
会导致回流的操作:
-
页面首次渲染
-
浏览器窗口大小发生改变
-
元素尺寸或位置发生改变
-
元素内容变化(文字数量或图片大小等等)
-
元素字体大小变化
-
添加或者删除可见的
DOM
元素 -
激活
CSS
伪类(例如::hover
) -
查询某些属性或调用某些方法
一些常用且会导致回流的属性和方法:
-
clientWidth
、clientHeight
、clientTop
、clientLeft
-
offsetWidth
、offsetHeight
、offsetTop
、offsetLeft
-
scrollWidth
、scrollHeight
、scrollTop
、scrollLeft
-
scrollIntoView()
、scrollIntoViewIfNeeded()
-
getComputedStyle()
-
getBoundingClientRect()
-
scrollTo()
现代浏览器会对频繁的回流或重绘操作进行优化:浏览器会维护一个队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值的,浏览器就会将队列清空,进行一次批处理,这样可以把多次回流和重绘变成一次。
当你访问以下属性或方法时,浏览器会立刻清空队列:
-
clientWidth
、clientHeight
、clientTop
、clientLeft
-
offsetWidth
、offsetHeight
、offsetTop
、offsetLeft
-
scrollWidth
、scrollHeight
、scrollTop
、scrollLeft
-
width
、height
-
getComputedStyle()
-
getBoundingClientRect()
因为队列中可能会有影响到这些属性或方法返回值的操作,即使你希望获取的信息与队列中操作引发的改变无关,浏览器也会强行清空队列,确保你拿到的值是最精确的。
很多人不知道的是,重绘和回流其实和 Event loop 有关。
-
当 Event loop 执行完 Microtasks 后,会判断 document 是否需要更新。因为浏览器是 60Hz 的刷新率,每 16ms 才会更新一次。
-
然后判断是否有
resize
或者scroll
,有的话会去触发事件,所以resize
和scroll
事件也是至少 16ms 才会触发一次,并且自带节流功能。 -
判断是否触发了 media query
-
更新动画并且发送事件
-
判断是否有全屏操作事件
-
执行
requestAnimationFrame
回调 -
执行
IntersectionObserver
回调,该方法用于判断元素是否可见,可以用于懒加载上,但是兼容性不好 -
更新界面
-
以上就是一帧中可能会做的事情。如果在一帧中有空闲时间,就会去执行
requestIdleCallback
回调。
减少重绘和回流
-
增加多个节点使用documentFragment:不是真实dom的部分,不会引起重绘和回流
-
用 translate 代替 top ,因为 top 会触发回流,但是translate不会。所以translate会比top节省了一个layout的时间
<div class="test"></div> <style> .test { position: absolute; top: 10px; width: 100px; height: 100px; background: red; } </style> <script> setTimeout(() => { document.querySelector('.test').style.top = '100px' }, 1000) </script>
-
使用
visibility
替换display: none
,因为前者只会引起重绘,后者会引发回流(改变了布局);opacity
代替visiability
,visiability
会触发重绘(paint),但opacity不会。 -
把 DOM 离线后修改,比如:先把 DOM 给
display:none
(有一次 Reflow),然后你修改 100 次,然后再把它显示出来 -
不要把 DOM 结点的属性值放在一个循环里当成循环里的变量
for (let i = 0; i < 1000; i++) { console.log(document.querySelector('.test').style.offsetTop) }
-
尽量少用table布局,table布局的话,每次有单元格布局改变,都会进行整个tabel回流重绘;
-
最好别频繁去操作DOM节点,最好把需要操作的样式,提前写成class,之后需要修改。只需要修改一次,需要修改的时候,直接修改className,做成一次性更新多条css DOM属性,一次回流重绘总比多次回流重绘要付出的成本低得多;
-
动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用
requestAnimationFrame
-
每次访问DOM的偏移量属性的时候,例如获取一个元素的scrollTop、scrollLeft、scrollWidth、offsetTop、offsetLeft、offsetWidth、offsetHeight之类的属性,浏览器为了保证值的正确也会回流取得最新的值,所以如果你要多次操作,最取完做个缓存。更加不要for循环中访问DOM偏移量属性,而且使用的时候,最好定义一个变量,把要需要的值赋值进去,进行值缓存,把回流重绘的次数减少;
-
将频繁运行的动画变为图层,图层能够阻止该节点回流影响别的元素。比如对于
video
标签,浏览器会自动将该节点变为图层。
5. 浏览器兼容问题
页面中的元素为什么默认具有间距?
原因:
1.img本来是行内元素,却可以用width 和height,当父元素没有设置高度的时候,用子元素们的高度计算出的高度给父元素的时候就会出现3px空隙这类的问题。
2.img图片默认排版为 inline-block;而所有的inline-block元素之间都会有空白。
解决办法:
-
将img显示设置成
display:block
:这样会使img独占一行显示,但是底部的间距不存在了 -
使用float属性让img浮动布局:间隙消失,但是会带来元素浮动所存在的问题
-
通过设置父元素的
font-size
属性将空白字符大小设置成0:间隙消失,但是所有的字体都不能显示 -
父元素设置
word-spacing: -npx;
或者letter-spacing:-npx
,n根据实际情况决定 -
写标签的时候写在同一行:可读性太差
如何解决设置标签最低高度 min-height
不兼容的问题
利用IE6不识别 !important
实现:
.box{
height:auto !important;
height:500px;
min-height:500px;
}
如何实现盒模型
Element{
box-sizing:content-box
box-sizing:border-box
}
浏览器 hack
IE6识别的 hacker
是下划线(_)和星号(*)
为什么存在hack:由于不同厂商的浏览器,比如Internet Explorer,Safari,Mozilla Firefox,Chrome等,或者是同一厂商的浏览器的不同版本,如IE6和IE7,对CSS的解析认识不完全一样,因此会导致生成的页面效果不一样,得不到我们所需要的页面效果。 这个时候我们就需要针对不同的浏览器去写不同的CSS,让它能够同时兼容不同的浏览器,能在不同的浏览器中也能得到我们想要的页面效果。
二、性能
针对性能优化可以去看这篇文章:当面试官问我前端可以做的性能优化有哪些
1. 网络相关
DNS预解析
DNS 解析也是需要时间的,可以通过预解析的方式来预先获得域名所对应的 IP。
预解析的实现:
-
用meta信息来告知浏览器, 当前页面要做DNS预解析:
<meta http-equiv="x-dns-prefetch-control" content="on" />
-
在页面header中使用link标签来强制对DNS预解析:
<link rel="dns-prefetch" href="http://bdimg.share.baidu.com" />
注:dns-prefetch需慎用,多页面重复DNS预解析会增加重复DNS查询次数。
PS:DNS预解析主要是用于网站前端页面优化,在SEO中的作用湛蓝还未作验证,但作为增强用户体验的一部分rel="dns-prefetch"或许值得大家慢慢发现。
<link rel="dns-prefetch" href="//yuchengkai.cn" />
缓存
缓存对于前端性能优化来说是个很重要的点,良好的缓存策略可以降低资源的重复加载提高网页的整体加载速度。
缓存到底存在那里
内存(200 from memory catch)or 磁盘(200 from disk catch):Chrome会根据本地内存的使用率来决定缓存到底是存在内存还是存在磁盘。内存的使用率高则放在磁盘,否则放到内存。这也解释了为什么统一资源有时在内存有时在磁盘。
缓存的好处
-
减少服务器压力
-
节省带宽
-
性能提升:本地比请求远程更快
通常浏览器缓存策略分为两种:强缓存和协商缓存。
强缓存
实现强缓存可以通过两种响应头实现:Expires
和 Cache-Control
。强缓存表示在缓存期间不需要请求,state code
为 200
Expires: Wed, 22 Oct 2018 08:41:00 GMT
Expires
是 HTTP / 1.0 的产物,表示资源会在 Wed, 22 Oct 2018 08:41:00 GMT
后过期,需要再次请求。并且 Expires
受限于本地时间,如果修改了本地时间,可能会造成缓存失效。
Cache-control: max-age=30
Cache-Control
出现于 HTTP / 1.1,优先级高于 Expires
。该属性表示资源会在 30 秒后过期,需要再次请求。
Cache-Control的取值
-
private:资源只被浏览器缓存,中间的服务器不允许缓存
-
public:资源也被中间人缓存
-
max-age:缓存有效期,单位毫秒
-
no-store:资源不需要缓存
-
no-cache:资源被缓存但是立即过期,相当于 max-age=0
协商缓存
如果缓存过期了,我们就可以使用协商缓存来解决问题。协商缓存需要请求,如果缓存有效会返回 304。
协商缓存需要客户端和服务端共同实现,和强缓存一样,也有两种实现方式。
Last-Modified 和 If-Modified-Since
Last-Modified
表示本地文件最后修改日期,If-Modified-Since
会将 Last-Modified
的值发送给服务器,询问服务器在该日期后资源是否有更新,有更新的话就会将新的资源发送回来。
但是如果在本地打开缓存文件,就会造成 Last-Modified
被修改,所以在 HTTP / 1.1 出现了 ETag
。
ETag 和 If-None-Match
ETag
类似于文件指纹,If-None-Match
会将当前 ETag
发送给服务器,询问该资源 ETag
是否变动,有变动的话就将新的资源发送回来。并且 ETag
优先级比 Last-Modified
高。
选择合适的缓存策略
对于大部分的场景都可以使用强缓存配合协商缓存解决,但是在一些特殊的地方可能需要选择特殊的缓存策略
-
对于某些不需要缓存的资源,可以使用
Cache-control: no-store
,表示该资源不需要缓存 -
对于频繁变动的资源,可以使用
Cache-Control: no-cache
并配合ETag
使用,表示该资源已被缓存,但是每次都会发送请求询问资源是否更新。 -
对于代码文件来说,通常使用
Cache-Control: max-age=31536000
并配合策略缓存使用,然后对文件进行指纹处理,一旦文件名变动就会立刻下载新的文件
预加载
在开发中,可能会遇到这样的情况。有些资源不需要马上用到,但是希望尽早获取,这时候就可以使用预加载。
预加载其实是声明式的 fetch
,强制浏览器请求资源,并且不会阻塞 onload
事件,可以使用以下代码开启预加载
<link rel="preload" href="http://example.com" />
预加载可以一定程度上降低首屏的加载时间,因为可以将一些不影响首屏但重要的文件延后加载,唯一缺点就是兼容性不好。
预渲染
可以通过预渲染将下载的文件预先在后台渲染,可以使用以下代码开启预渲染
<link rel="prerender" href="http://example.com" />
预渲染虽然可以提高页面的加载速度,但是要确保该页面百分百会被用户在之后打开,否则就白白浪费资源去渲染
2. 优化渲染过程
懒执行
懒执行就是将某些逻辑延迟到使用时再计算。该技术可以用于首屏优化,对于某些耗时逻辑并不需要在首屏就使用的,就可以使用懒执行。懒执行需要唤醒,一般可以通过定时器或者事件的调用来唤醒。
懒加载
懒加载就是将不关键的资源延后加载。
懒加载的原理就是只加载自定义区域(通常是可视区域,但也可以是即将进入可视区域)内需要加载的东西。对于图片来说,先设置图片标签的 src
属性为一张占位图,将真实的图片资源放入一个自定义属性中,当进入自定义区域时,就将自定义属性替换为 src
属性,这样图片就会去下载资源,实现了图片懒加载。
懒加载不仅可以用于图片,也可以使用在别的资源上。比如进入可视区域才开始播放视频等等。
3. 文件优化
图片优化
计算图片大小
对于一张 100 _ 100 像素的图片来说,图像上有 10000 个像素点,如果每个像素的值是 RGBA 存储的话,那么也就是说每个像素有 4 个通道,每个通道 1 个字节(8 位 = 1 个字节),所以该图片大小大概为 39KB(10000 _ 1 * 4 / 1024)。
但是在实际项目中,一张图片可能并不需要使用那么多颜色去显示,我们可以通过减少每个像素的调色板来相应缩小图片的大小。
了解了如何计算图片大小的知识,那么对于如何优化图片,想必大家已经有 2 个思路了:
-
减少像素点
-
减少每个像素点能够显示的颜色
图片加载优化
-
不用图片。很多时候会使用到很多修饰类图片,其实这类修饰图片完全可以用 CSS 去代替。
-
对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片。
-
小图使用 base64 格式
-
将多个图标文件整合到一张图片中(雪碧图)
-
选择正确的图片格式:
-
对于能够显示 WebP 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点就是兼容性并不好
-
小图使用 PNG,其实对于大部分图标这类图片,完全可以使用 SVG 代替
-
照片使用 JPEG
-
其他文件优化
-
CSS 文件放在
head
中 -
服务端开启文件压缩功能
-
将
script
标签放在body
底部,因为 JS 文件执行会阻塞渲染。当然也可以把script
标签放在任意位置然后加上defer
,表示该文件会并行下载,但是会放到 HTML 解析完成后顺序执行。对于没有任何依赖的 JS 文件可以加上async
,表示加载和渲染后续文档元素的过程将和 JS 文件的加载与执行并行无序进行。 -
执行 JS 代码过长会卡住渲染,对于需要很多时间计算的代码可以考虑使用
Webworker
。Webworker
可以让我们另开一个线程执行脚本而不影响渲染
CDN
静态资源尽量使用 CDN 加载,由于浏览器对于单个域名有并发请求上限,可以考虑使用多个 CDN 域名。对于 CDN 加载静态资源需要注意 CDN 域名要与主站不同,否则每次请求都会带上主站的 Cookie。
4. 其他
使用 webpack优化项目
-
对于 Webpack4,打包项目使用 production 模式,这样会自动开启代码压缩
-
使用 ES6 模块来开启 tree shaking,这个技术可以移除没有使用的代码
-
优化图片,对于小图可以使用 base64 的方式写入文件中
-
按照路由拆分代码,实现按需加载
-
给打包出来的文件名添加哈希,实现浏览器缓存文件
监控
对于代码运行错误,通常的办法是使用 window.onerror
拦截报错。该方法能拦截到大部分的详细报错信息,但是也有例外
-
对于跨域的代码运行错误会显示
Script error.
对于这种情况我们需要给script
标签添加crossorigin
属性 -
对于某些浏览器可能不会显示调用栈信息,这种情况可以通过
arguments.callee.caller
来做栈递归
对于异步代码来说,可以使用 catch
的方式捕获错误。比如 Promise
可以直接使用 catch
函数,async await
可以使用 try catch
但是要注意线上运行的代码都是压缩过的,需要在打包时生成 sourceMap 文件便于 debug。
对于捕获的错误需要上传给服务器,通常可以通过 img
标签的 src
发起一个请求。
5. 谈谈对重构的理解
网站重构是指在不改变外部行为的前提下,简化结构、添加可读性。且在网站前端保持一致的行为。也就是说,在不改变UI的情况下,对网站进行优化,在扩展的同时保持一致的UI
对于传统的网站来说,重构通常包括以下几个方面:
-
把表格(table)布局改为 DIV+CSS
-
使网站前端兼容现代浏览器
-
对移动平台进行优化
-
针对搜索引擎进行优化
深层次的网站重构应该考虑以下方面:
-
减少代码间的耦合
-
让代码保持弹性
-
严格按照规范编写代码
-
设计可扩展的API
-
代替旧的框架、语言
-
增强用户体验
-
对速度进行优化
-
压缩 JavaScript,CSS,image等前端资源(通常由服务器来解决)
-
优化程序的性能
-
采用CDN来加速资源加载
-
优化 JavaScript DOM
-
缓存HTTP服务器文件
6. 如果一个页面上有大量图片,优化图片加载
参考链接[4]
-
将图片服务和应用服务分离:对于服务器来说,图片始终是最消耗系统资源的,如果将图片服务和应用服务放在同一服务器的话,应用服务器很容易会因为图片的高I/O负载而崩溃,所以当网站存在大量的图片读写操作时,建议使用图片服务器。
-
小图片比较多时可以使用CSS Sprite 、SVG sprite、Icon font 、Base64
-
图片压缩:借助一些第三方软件来进行压缩,压缩后分辨率不变,肉眼看不失真; 我们项目中对使用的图片基本都会进行压缩再上传。
-
图片懒加载:图片懒加载,简单来说就是在页面渲染过程中,图片不会一次性全部加载,会在需要的时候加载,比如当滚动条滚动到某一个位置时触发事件加载图片。 为优化回流,可以先设置占位符
实现方案1
document.documentElement.clientHeight document.documentElement.scrollTop element.offsetTop
如果:clientHeight+scroolTop>offsetTop,则图片进入了可视区内,则被请求。
实现方案2® 我们滚动条向下滚动的时候,bound.top值会变得越来越小,也就是图片到可视区顶部的距离也越来越小,所以当bound.top == clientHeight时,说明土片马上就要进入可视区了,只要我们在滚动,图片就会进入可视区,那么就需要请求资源了。也就是说,在bound.top<=clientHeight时,图片是在可视区域内的。
var imgs = document.querySelectorAll('img'); function isIn(el) { var bound = el.getBoundingClientRect(); var clientHeight = window.innerHeight; return bound.top <= clientHeight; } function check() { Array.from(imgs).forEach(function(el){ if(isIn(el)){ loadImg(el); } }) } function loadImg(el) { if(!el.src){ var source = el.dataset.src; el.src = source; } } window.onload = window.onscroll = function () { check(); }
6. 图片过大加载优化方案
-
如果是相册之类的可以预加载,在展示当前图片的时候,就加载它的前一个和后一个图片
-
加载的时候可以先加载一个压缩率非常高的缩略图,以提高用户体验,点击或加载到之后再查看清晰图
-
使用渐进式jpeg,会提高用户体验 参考文章[5]
-
如果展示区域小于图片的真实大小,可以在服务端先压缩到合适的尺寸
7. 针对CSS,如何性能优化?
-
正确使用
display
属性,display
属性会影响页面的渲染,因此要注意以下几个方面-
display:inline
后不应该在使用width、height、margin、padding、float
-
display:inline-block
后不应该在使用float
-
display:block
后不应该在使用vertical-aligin
-
display:table-*
后不应该在使用margin
或float
-
-
不滥用
float
-
不声明过多的
font-size
-
当值为0 时不需要单位
-
标准化各种浏览器前缀,并注意以下几个方面
-
浏览器无前缀应放在最后面
-
CSS动画只用(-webkit-无前缀)两种即可
-
其他区前缀包括
-webkit-、-moz-、-ms-
、无前缀
-
-
避免让选择符像正则表达式。
-
避免使用CSS表达式(又称动态属性)、高级选择器、通配选择器
-
尽量使用
id、class
选择器设置样式(避免使用style属性设置行内样式) -
尽量使用CSS3动画
-
减少重绘和回流
-
为图片表明高度和宽度(如果浏览器没有找到这两个参数,它需要一边下载图片一遍计算大小。当浏览器知道高度和宽度这两个参数之后,即时图片暂时无法显示,页面上也会流出图片的空位,然后继续加载后面的内容,从而优化加载时间,提升浏览体验)
8. 针对HTML,如何性能优化?
-
对于资源加载,按需加载或异步加载
-
首次加载的资源不超过1024kb,即越小越好
-
压缩HTML、CSS、JavaScript文件
-
减少DOM节点
-
避免空src(空src在部分浏览器中会导致无效请求而且会重新加载当前页面,影响速度和效率)
9. 针对JavaScript,如何性能优化?
-
缓存DOM的选择和计算
-
尽量使用事件委托模式,避免批量绑定事件
-
使用
touchstart、touchend
代替click
-
合理使用
retuestAnimationFrame
动画代替setTimeout
-
适当使用
canvas
-
尽量避免在高频事件中(如
TouchMove、Scroll
事件 )中修改视图,这会导致多次渲染 -
少用全局变量
-
用innerHTML代替DOM操作,减少DOM操作次数
-
缓存DOM节点查找的结果
10. 如何优化服务器端?
-
启用 Gzip压缩
-
延长资源缓存时间,合理设置资源的过期时间,对于以下昌吉不更新的静态资源过期时间设置的长一些
-
减少
Cookie
头信息的大小,头信息越大,资源传输速度越慢 -
图片或者CSS、JavaScript文件均可以使用CDN来加速
11. 优化服务端接口
-
接口合并:如果一个页面需要请求两部分以上的数据接口,则建议合并成一个,以减少http请求数
-
减少数据量:去掉接口返回的数据中不需要的数据
-
缓存数据:首次加载请求后,缓存数据;对于非首次请求,优先使用上次请求的数据,这样可以提升非首次请求的响应速度
12. 如何优化脚本的执行?
-
把CSS写在页面头部,把JavaScript程序写在页面尾部或异步操作中
-
避免图片和IFrame等的空src,空src会重新加载当前页面,影响速度和效率
-
尽量避免重设置图片大小,多次重设会引发图片的多次重绘,影响性能
-
图片避免使用 DataURL。DataURL图片没有使用图片的压缩算法,文件会变大,并且在解码后在渲染,加载慢耗时长
15. 如何优化渲染?
-
通过HTML设置 Viewport元标签,Viewport可以加速页面的渲染,如以下代码所示。
<meta name="viewport" content="width=device-width,initial-scale=1">
-
减少DOM节点数量,DOM节点太多会影响页面的渲染,应尽量减少DOM节点数量
-
尽量使用CSS3动画,合理使用
requestAnimationFrame
动画代替setTimeout
,适当使用canvas
动画 -
对于高频事件优化
TouchMove、Scroll
事件可导致多次渲染 -
提升GPU的速度,用CSS中的属性(
CSS3 transitions、CSS3 SD transforms、Opacity、Canvas、WebGL、Video
)来处罚GPU渲染
14. 如何设置DNS缓存?
在浏览器地址栏中输入URL以后,浏览器首先要查询域名(hostname)对应服务器的IP地址,一般需要耗费20-120ms的时间。DNS查询完成之前,浏览器无法识别服务器的IP,因此不下载任何数据。基于性能考虑,ISP运营商、局域网路由、操作系统、客户端(浏览器)均会有响应的DNS缓存机制。
-
IE缓存30min,可以通过注册表中的 DNSCacheTimeout项设置
-
Firefox混存1min,通过 network.dnsCacheExpiration配置
-
在Chrome中通过依次单击“设置->选项->高级选项”,并勾选“用预提取DNS提高网页载入速度”选项来配置缓存时间。
三、安全
目前使用Vue
和React
框架很少存在xss风险,原因是:
-
Vue和React都使用虚拟DOM来管理DOM操作。这使得它们可以更有效地跟踪和更新页面的状态,同时减少了直接操作HTML的需求。
-
Vue和React的模板引擎会自动对插值表达式中的变量进行转义。
但是开发过程中应该避免使用React中的dangerouslySetInnerHTML
或Vue中的v-html
,除非你能确保渲染的内容是安全的。这会绕过框架的自动转义机制。
XSS 攻击
Cross-Site Scripting(跨站脚本攻击)简称 XSS,是一种代码注入攻击。攻击者通过在目标网站上注入恶意脚本,使之在用户的浏览器上运行。利用这些恶意脚本,攻击者可获取用户的敏感信息如 Cookie、SessionID 等,进而危害数据安全。
XSS 的本质是:恶意代码未经过滤,与网站正常的代码混在一起;浏览器无法分辨哪些脚本是可信的,导致恶意脚本被执行。
而由于直接在用户的终端执行,恶意代码能够直接获取用户的信息,或者利用这些信息冒充用户向网站发起攻击者定义的请求。
在部分情况下,由于输入的限制,注入的恶意脚本比较短。但可以通过引入外部的脚本,并由浏览器执行,来完成比较复杂的攻击策略。
XSS 攻击可分为存储型、反射型和 DOM 型三种:
存储型 XSS
存储型 XSS 的攻击步骤:
-
攻击者将恶意代码提交到目标网站的数据库中。
-
用户打开目标网站时,网站服务端将恶意代码从数据库取出,拼接在 HTML 中返回给浏览器。
-
用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行。
-
恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。
这种攻击常见于带有用户保存数据的网站功能,如论坛发帖、商品评论、用户私信等。
反射型 XSS
反射型 XSS 的攻击步骤:
-
攻击者构造出特殊的 URL,其中包含恶意代码。
-
用户打开带有恶意代码的 URL 时,网站服务端将恶意代码从 URL 中取出,拼接在 HTML 中返回给浏览器。
-
用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行。
-
恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。
反射型 XSS 跟存储型 XSS 的区别是:存储型 XSS 的恶意代码存在数据库里,反射型 XSS 的恶意代码存在 URL 里。
反射型 XSS 漏洞常见于通过 URL 传递参数的功能,如网站搜索、跳转等。
由于需要用户主动打开恶意的 URL 才能生效,攻击者往往会结合多种手段诱导用户点击。
POST 的内容也可以触发反射型 XSS,只不过其触发条件比较苛刻(需要构造表单提交页面,并引导用户点击),所以非常少见。
DOM 型 XSS
DOM 型 XSS 的攻击步骤:
-
攻击者构造出特殊的 URL,其中包含恶意代码。
-
用户打开带有恶意代码的 URL。
-
用户浏览器接收到响应后解析执行,前端 JavaScript 取出 URL 中的恶意代码并执行。
-
恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。
DOM 型 XSS 跟前两种 XSS 的区别:DOM 型 XSS 攻击中,取出和执行恶意代码由浏览器端完成,属于前端 JavaScript 自身的安全漏洞,而其他两种 XSS 都属于服务端的安全漏洞。
如何防御XSS
XSS 攻击有两大要素:
-
攻击者提交恶意代码。
-
浏览器执行恶意代码。
输入过滤
对于明确的输入类型,例如数字、URL、电话号码、邮件地址等等内容,在用户输入和写入数据库前进行输入过滤还是必要的。
输入侧过滤能够在某些情况下解决特定的 XSS 问题,但会引入很大的不确定性和乱码问题。 我们举一个例子,一个正常的用户输入了 5 < 7
这个内容,在写入数据库前,被转义,变成了 5 < 7
。
问题是:在提交阶段,我们并不确定内容要输出到哪里。
这里的“并不确定内容要输出到哪里”有两层含义:
-
用户的输入内容可能同时提供给前端和客户端,而一旦经过了
escapeHTML()
,客户端显示的内容就变成了乱码(5 < 7
)。 -
在前端中,不同的位置所需的编码也不同。
-
当
5 < 7
作为 HTML 拼接页面时,可以正常显示:
<div title="comment">5 <
-
当
5 < 7
通过 Ajax 返回,然后赋值给 JavaScript 的变量时,前端得到的字符串就是转义后的字符。这个内容不能直接用于 Vue 等模板的展示,也不能直接用于内容长度计算。不能用于标题、alert 等。
既然输入过滤并非完全可靠,我们就要通过“防止浏览器执行恶意代码”来防范 XSS。这部分分为两类:
-
防止 HTML 中出现注入。
-
防止 JavaScript 执行时,执行恶意代码。
预防存储型和反射型 XSS 攻击
存储型和反射型 XSS 都是在服务端取出恶意代码后,插入到响应 HTML 里的,攻击者刻意编写的“数据”被内嵌到“代码”中,被浏览器所执行。
预防这两种漏洞,有两种常见做法:
-
改成纯前端渲染,把代码和数据分隔开。
-
对 HTML 做充分转义。
纯前端渲染
纯前端渲染的过程:
-
浏览器先加载一个静态 HTML,此 HTML 中不包含任何跟业务相关的数据。
-
然后浏览器执行 HTML 中的 JavaScript。
-
JavaScript 通过 Ajax 加载业务数据,调用 DOM API 更新到页面上。
但纯前端渲染还需注意避免 DOM 型 XSS 漏洞(例如 onload
事件和 href
中的 javascript:xxx
等
转义 HTML
如果拼接 HTML 是必要的,就需要采用合适的转义库,对 HTML 模板各处插入点进行充分的转义。
常用的模板引擎,如 doT.js、ejs、FreeMarker 等,对于 HTML 转义通常只有一个规则,就是把 & < > " ' /
这几个字符转义掉,确实能起到一定的 XSS 防护作用,但并不完善
预防 DOM 型 XSS 攻击
DOM 型 XSS 攻击,实际上就是网站前端 JavaScript 代码本身不够严谨,把不可信的数据当作代码执行了。
在使用 .innerHTML
、.outerHTML
、document.write()
时要特别小心,不要把不可信的数据作为 HTML 插到页面上,而应尽量使用 .textContent
、.setAttribute()
等。
如果用 Vue/React 技术栈,并且不使用 v-html
/dangerouslySetInnerHTML
功能,就在前端 render 阶段避免 innerHTML
、outerHTML
的 XSS 隐患。
DOM 中的内联事件监听器,如 location
、onclick
、onerror
、onload
、onmouseover
等,<a>
标签的 href
属性,JavaScript 的 eval()
、setTimeout()
、setInterval()
等,都能把字符串作为代码运行。如果不可信的数据拼接到字符串中传递给这些 API,很容易产生安全隐患,请务必避免。
其他安全措施
-
HTTP-only Cookie: 禁止 JavaScript 读取某些敏感 Cookie,攻击者完成 XSS 注入后也无法窃取此 Cookie。
-
验证码:防止脚本冒充用户提交危险操作。
XSS是前端做还是后端做
防范存储型和反射型 XSS 是后端的责任。而 DOM 型 XSS 攻击不发生在后端,是前端的责任。防范 XSS 是需要后端和前端共同参与的系统工程。
转义应该在输出 HTML 时进行,而不是在提交用户输入时
CSRF
跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。 跟XSS相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。
简单点说,CSRF 就是利用用户的登录态发起恶意请求。
CSRF 分为GET型,POST型(利用自动提交的表单)和链接型(不常见,只有打开链接才会触发)
一个典型的CSRF攻击有着如下的流程:
-
受害者登录a.com,并保留了登录凭证(Cookie)。
-
攻击者引诱受害者访问了b.com。
-
b.com 向 a.com 发送了一个请求:a.com/act=xx。浏览器会默认携带a.com的Cookie。
-
a.com接收到请求后,对请求进行验证,并确认是受害者的凭证,误以为是受害者自己发送的请求。
-
a.com以受害者的名义执行了act=xx。
-
攻击完成,攻击者在受害者不知情的情况下,冒充受害者,让a.com执行了自己定义的操作。
如何防御CSRF
同源检测
既然CSRF大多来自第三方网站,那么我们就直接禁止外域(或者不受信任的域名)对我们发起请求。
那么问题来了,我们如何判断请求是否来自外域呢?
在HTTP协议中,每一个异步请求都会携带两个Header,用于标记来源域名:
-
Origin Header
-
Referer Header
这两个Header在浏览器发起请求时,大多数情况会自动带上,并且不能由前端自定义内容。 服务器可以通过解析这两个Header中的域名,确定请求的来源域。
如果Origin存在,那么直接使用Origin中的字段确认来源域名就可以。
但是Origin在以下两种情况下并不存在:
-
IE11同源策略: IE 11 不会在跨站CORS请求上添加Origin标头,Referer头将仍然是唯一的标识。最根本原因是因为IE 11对同源的定义和其他浏览器有不同,有两个主要的区别,可以参考MDN Same-origin\_policy#IE\_Exceptions**[6]
-
302重定向: 在302重定向之后Origin不包含在重定向的请求中,因为Origin可能会被认为是其他来源的敏感信息。对于302重定向的情况来说都是定向到新的服务器上的URL,因此浏览器不想将Origin泄漏到新的服务器上。
origin不存在时使用Referer Header确定来源域名:
根据HTTP协议,在HTTP头中有一个字段叫Referer,记录了该HTTP请求的来源地址。 对于Ajax请求,图片和script等资源请求,Referer为发起请求的页面地址。对于页面跳转,Referer为打开页面历史记录的前一个页面地址。因此我们使用Referer中链接的Origin部分可以得知请求的来源域名。
这种方法并非万无一失,Referer的值是由浏览器提供的,虽然HTTP协议上有明确的要求,但是每个浏览器对于Referer的具体实现可能有差别,并不能保证浏览器自身没有安全漏洞。使用验证 Referer 值的方法,就是把安全性都依赖于第三方(即浏览器)来保障,从理论上来讲,这样并不是很安全。在部分情况下,攻击者可以隐藏,甚至修改自己请求的Referer。
2014年,W3C的Web应用安全工作组发布了Referrer Policy草案,对浏览器该如何发送Referer做了详细的规定。截止现在新版浏览器大部分已经支持了这份草案,我们终于可以灵活地控制自己网站的Referer策略了。新版的Referrer Policy规定了五种Referer策略:No Referrer、No Referrer When Downgrade、Origin Only、Origin When Cross-origin、和 Unsafe URL。之前就存在的三种策略:never、default和always,在新标准里换了个名称。。他们的对应关系如下:
根据上面的表格因此需要把Referrer Policy的策略设置成same-origin,对于同源的链接和引用,会发送Referer,referer值为Host不带Path;跨域访问则不携带Referer。
设置Referrer Policy的方法有三种:
-
在CSP设置
-
页面头部增加meta标签
-
a标签增加referrerpolicy属性
上面说的这些比较多,但我们可以知道一个问题:攻击者可以在自己的请求中隐藏Referer。
另外在以下情况下Referer没有或者不可信:
-
IE6、7下使用window.location.href=url进行界面的跳转,会丢失Referer。
-
IE6、7下使用window.open,也会缺失Referer。
-
HTTPS页面跳转到HTTP页面,所有浏览器Referer都丢失。
-
点击Flash上到达另外一个网站的时候,Referer的情况就比较杂乱,不太可信。
如果Origin和Referer都不存在,建议直接进行阻止,特别是如果您没有使用随机CSRF Token(参考下方)作为第二次检查。
综上所述:同源验证是一个相对简单的防范方法,能够防范绝大多数的CSRF攻击。但这并不是万无一失的,对于安全性要求较高,或者有较多用户输入内容的网站,我们就要对关键的接口做额外的防护措施。
CSRF Token
前面讲到CSRF的另一个特征是,攻击者无法直接窃取到用户的信息(Cookie,Header,网站内容等),仅仅是冒用Cookie中的信息。
而CSRF攻击之所以能够成功,是因为服务器误把攻击者发送的请求当成了用户自己的请求。那么我们可以要求所有的用户请求都携带一个CSRF攻击者无法获取到的Token。服务器通过校验请求是否携带正确的Token,来把正常的请求和攻击的请求区分开,也可以防范CSRF的攻击。
原理
CSRF Token的防护策略分为三个步骤:
1. 将CSRF Token输出到页面中
首先,用户打开页面的时候,服务器需要给这个用户生成一个Token,该Token通过加密算法对数据进行加密,一般Token都包括随机字符串和时间戳的组合,显然在提交时Token不能再放在Cookie中了,否则又会被攻击者冒用。因此,为了安全起见Token最好还是存在服务器的Session中,之后在每次页面加载时,使用JS遍历整个DOM树,对于DOM中所有的a和form标签后加入Token。这样可以解决大部分的请求,但是对于在页面加载之后动态生成的HTML代码,这种方法就没有作用,还需要程序员在编码时手动添加Token。
2. 页面提交的请求携带这个Token
对于GET请求,Token将附在请求地址之后,这样URL 就变成 http://url/?csrftoken=tokenvalue%E3%80%82 而对于 POST 请求来说,要在 form 的最后加上:
<input type=”hidden” name=”csrftoken” value=”tokenvalue”/>
这样,就把Token以参数的形式加入请求了。
3. 服务器验证Token是否正确
当用户从客户端得到了Token,再次提交给服务器的时候,服务器需要判断Token的有效性,验证过程是先解密Token,对比加密字符串以及时间戳,如果加密字符串一致且时间未过期,那么这个Token就是有效的。
这种方法要比之前检查Referer或者Origin要安全一些,Token可以在产生并放于Session之中,然后在每次请求时把Token从Session中拿出,与请求中的Token进行比对,但这种方法的比较麻烦的在于如何把Token以参数的形式加入请求。
Token是一个比较有效的CSRF防护方法,只要页面没有XSS漏洞泄露Token,那么接口的CSRF攻击就无法成功。
验证码和密码其实也可以起到CSRF Token的作用哦,而且更安全。
Samesite Cookie属性
防止CSRF攻击的办法已经有上面的预防措施。为了从源头上解决这个问题,Google起草了一份草案来改进HTTP协议,那就是为Set-Cookie响应头新增Samesite属性,它用来标明这个 Cookie是个“同站 Cookie”,同站Cookie只能作为第一方Cookie,不能作为第三方Cookie,Samesite 有两个属性值,分别是 Strict 和 Lax,下面分别讲解:
Samesite=Strict
这种称为严格模式,表明这个 Cookie 在任何情况下都不可能作为第三方 Cookie,绝无例外。比如说 b.com 设置了如下 Cookie:
Set-Cookie: foo=1
Set-Cookie: bar=2
Set-Cookie: baz=3
我们在 a.com 下发起对 b.com 的任意请求,foo 这个 Cookie 都不会被包含在 Cookie 请求头中,但 bar 会。举个实际的例子就是,假如淘宝网站用来识别用户登录与否的 Cookie 被设置成了 Samesite=Strict,那么用户从百度搜索页面甚至天猫页面的链接点击进入淘宝后,淘宝都不会是登录状态,因为淘宝的服务器不会接受到那个 Cookie,其它网站发起的对淘宝的任意请求都不会带上那个 Cookie。
Samesite=Lax
这种称为宽松模式,比 Strict 放宽了点限制:假如这个请求是这种请求(改变了当前页面或者打开了新页面)且同时是个GET请求,则这个Cookie可以作为第三方Cookie。比如说 b.com设置了如下Cookie:
Set-Cookie: foo=1
Set-Cookie: bar=2
Set-Cookie: baz=3
当用户从 a.com 点击链接进入 b.com 时,foo 这个 Cookie 不会被包含在 Cookie 请求头中,但 bar 和 baz 会,也就是说用户在不同网站之间通过链接跳转是不受影响了。但假如这个请求是从 a.com 发起的对 b.com 的异步请求,或者页面跳转是通过表单的 post 提交触发的,则bar也不会发送。
我们应该如何使用SamesiteCookie
如果SamesiteCookie被设置为Strict,浏览器在任何跨域请求中都不会携带Cookie,新标签重新打开也不携带,所以说CSRF攻击基本没有机会。
但是跳转子域名或者是新标签重新打开刚登陆的网站,之前的Cookie都不会存在。尤其是有登录的网站,那么我们新打开一个标签进入,或者跳转到子域名的网站,都需要重新登录。对于用户来讲,可能体验不会很好。
如果SamesiteCookie被设置为Lax,那么其他网站通过页面跳转过来的时候可以使用Cookie,可以保障外域连接打开页面时用户的登录状态。但相应的,其安全性也比较低。
另外一个问题是Samesite的兼容性不是很好,现阶段除了从新版Chrome和Firefox支持以外,Safari以及iOS Safari都还不支持,现阶段看来暂时还不能普及。
而且,SamesiteCookie目前有一个致命的缺陷:不支持子域。例如,种在topic.a.com下的Cookie,并不能使用a.com下种植的SamesiteCookie。这就导致了当我们网站有多个子域名时,不能使用SamesiteCookie在主域名存储用户登录信息。每个子域名都需要用户重新登录一次。
总之,SamesiteCookie是一个可能替代同源验证的方案,但目前还并不成熟,其应用场景有待观望。
总结
简单总结一下上文的防护策略:
-
CSRF自动防御策略:同源检测(Origin 和 Referer 验证)。
-
CSRF主动防御措施:Token验证 或者 双重Cookie验证 以及配合Samesite Cookie。
-
保证页面的幂等性,后端接口不要在GET页面中做用户操作。
安全问题主要参考这两篇文章[8]
四、同源策略
参考链接[9]
同源的含义
-
协议相同
-
域名相同
-
端口相同
同源的目的
浏览器安全的基石是”同源政策”。
保证用户信息的安全,防止恶意的网站窃取数据。
设想这样一种情况:A网站是一家银行,用户登录以后,又去浏览其他网站。如果其他网站可以读取A网站的 Cookie,会发生什么?
很显然,如果Cookie 包含隐私(比如存款总额),这些信息就会泄漏。更可怕的是,Cookie 往往用来保存用户的登录状态,如果用户没有退出登录,其他网站就可以冒充用户,为所欲为。因为浏览器同时还规定,提交表单不受同源政策的限制。
限制范围
随着互联网的发展,”同源政策”越来越严格。目前,如果非同源,共有三种行为受到限制:
-
Cookie、LocalStorage 和 IndexDB 无法读取。
-
DOM 无法获得。
-
AJAX请求不能发送。
虽然这些限制是必要的,但是有时很不方便,合理的用途也受到影响。下面将详细介绍,如何规避上面三种限制。
cookie
document.domain
Cookie是服务器写入浏览器的一小段信息,只有同源的网页才能共享。但是,两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置document.domain共享 Cookie。
举例来说,A网页是w1.example.com/a.html,B网页是w2.example.com/b.html,那么只要设置相同的document.domain,两个网页就可以共享Cookie。
document.domain = 'example.com'
现在,A网页通过脚本设置一个 Cookie:
document.cookie = "test1=hello"
B网页就可以读到这个 Cookie
var allCookie = document.cookie
注意,这种方法只适用于 Cookie 和 iframe 窗口,LocalStorage 和 IndexDB 无法通过这种方法,规避同源政策,而要使用下文介绍的PostMessage API。
服务器指定 cookie 所属域名
服务器也可以在设置Cookie的时候,指定Cookie的所属域名为一级域名,比如.example.com:
Set-Cookie: key=value
这样的话,二级域名和三级域名不用做任何设置,都可以读取这个Cookie。
iframe
如果两个网页不同源,就无法拿到对方的DOM。典型的例子是iframe窗口和window.open方法打开的窗口,它们与父窗口无法通信。
比如,父窗口运行下面的命令,如果iframe窗口不是同源,就会报错:
document.getElementById("myIFrame").contentWindow.document
上面命令中,父窗口想获取子窗口的DOM,因为跨源导致报错。
反之亦然,子窗口获取主窗口的DOM也会报错:
window.parent.document.body
如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的document.domain属性,就可以规避同源政策,拿到DOM。
对于完全不同源的网站,目前有三种方法,可以解决跨域窗口的通信问题:
-
片段识别符(fragment identifier)
-
window.name
-
跨文档通信API(Cross-document messaging)
片段识别符
片段标识符(fragment identifier)指的是,URL的#号后面的部分,比如 example.com/x.html#frag…[12] 的 #fragment。如果只是改变片段标识符,页面不会重新刷新
父窗口可以把信息,写入子窗口的片段标识符:
var src = originURL + '#' + data
document.getElementById('myIFrame').src = src
子窗口通过监听hashchange事件得到通知:
window.onhashchange = checkMessage
function checkMessage() {
var message = window.location.hash
// ...
}
同样的,子窗口也可以改变父窗口的片段标识符:
parent.location.href= target + "#" + hash
window.name
window.name
浏览器窗口有window.name属性。这个属性的最大特点是,无论是否同源,只要在同一个窗口里,前一个网页设置了这个属性,后一个网页可以读取它。
父窗口先打开一个子窗口,载入一个不同源的网页,该网页将信息写入window.name属性。
window.name = data
接着,子窗口跳回一个与主窗口同域的网址。
location = 'http://parent.url.com/xxx.html'
然后,主窗口就可以读取子窗口的window.name了:
var data = document.getElementById('myFrame').contentWindow.name
window.postMessage
HTML5为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。
这个API为window对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。
举例来说,父窗口aaa.com[13]向子窗口bbb.com[14]发消息,调用postMessage方法就可以了:
var popup = window.open('http://bbb.com', 'title')
popup.postMessage('Hello World!', 'http://bbb.com')
postMessage方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin),即”协议 + 域名 + 端口”。也可以设为*,表示不限制域名,向所有窗口发送。
子窗口向父窗口发送消息的写法类似:
window.opener.postMessage('Nice to see you', 'http://aaa.com');
父窗口和子窗口都可以通过message事件,监听对方的消息。
window.addEventListener('message', function(e) {
console.log(e.data);
},false);
message事件的事件对象event,提供以下三个属性。
-
event.source:发送消息的窗口
-
event.origin: 消息发向的网址
-
event.data: 消息内容
下面的例子是,子窗口通过event.source属性引用父窗口,然后发送消息。
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
event.source.postMessage('Nice to see you!', '*');
}
event.origin属性可以过滤不是发给本窗口的消息:
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
if (event.origin !== 'http://aaa.com') return;
if (event.data === 'Hello World') {
event.source.postMessage('Hello', event.origin);
} else {
console.log(event.data);
}
}
LocalStorage
通过window.postMessage,读写其他窗口的 LocalStorage 也成为了可能。
下面是一个例子,主窗口写入iframe子窗口的localStorage:
window.onmessage = function(e) {
if (e.origin !== 'http://bbb.com') {
return;
}
var payload = JSON.parse(e.data);
localStorage.setItem(payload.key, JSON.stringify(payload.data));
};
上面代码中,子窗口将父窗口发来的消息,写入自己的LocalStorage。
父窗口发送消息的代码如下:
var win = document.getElementsByTagName('iframe')[0].contentWindow
var obj = { name: 'Jack' }
win.postMessage(JSON.stringify({key: 'storage', data: obj}), 'http://bbb.com')
加强版的子窗口接收消息的代码如下。
window.onmessage = function(e) {
if (e.origin !== 'http://bbb.com') return;
var payload = JSON.parse(e.data);
switch (payload.method) {
case 'set':
localStorage.setItem(payload.key, JSON.stringify(payload.data));
break;
case 'get':
var parent = window.parent;
var data = localStorage.getItem(payload.key);
parent.postMessage(data, 'http://aaa.com');
break;
case 'remove':
localStorage.removeItem(payload.key);
break;
}
};
加强版的父窗口发送消息代码如下。
var win = document.getElementsByTagName('iframe')[0].contentWindow;
var obj = { name: 'Jack' };
win.postMessage(JSON.stringify({key: 'storage', method: 'set', data: obj}), 'http://bbb.com');
win.postMessage(JSON.stringify({key: 'storage', method: "get"}), "*");
window.onmessage = function(e) {
if (e.origin != 'http://aaa.com') return;
console.log(JSON.parse(e.data).name);
AJAX
同源政策规定,AJAX请求只能发给同源的网址,否则就报错。
除了架设服务器代理(浏览器请求同源服务器,再由后者请求外部服务),有三种方法规避这个限制。
-
JSONP
-
WebSocket
-
CORS
JSONP
JSONP是服务器与客户端跨源通信的常用方法。最大特点就是简单适用,老式浏览器全部支持,服务器改造非常小。
它的基本思想是,网页通过添加一个<script>
元素,向服务器请求JSON数据,这种做法不受同源政策限制;服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。
首先,网页动态插入<script>
元素,由它向跨源网址发出请求。
function addScriptTag(src) {
var script = document.createElement('script');
script.setAttribute("type","text/javascript");
script.src = src;
document.body.appendChild(script);
}
window.onload = function () {
addScriptTag('http://example.com/ip?callback=foo');
}
function foo(data) {
console.log('Your public IP address is: ' + data.ip);
};
上面代码通过动态添加<script>
元素,向服务器example.com发出请求。注意,该请求的查询字符串有一个callback参数,用来指定回调函数的名字,这对于JSONP是必需的。
服务器收到这个请求以后,会将数据放在回调函数的参数位置返回。
foo({
"ip": "8.8.8.8"
});
由于<script>
元素请求的脚本,直接作为代码运行。这时,只要浏览器定义了foo函数,该函数就会立即调用。作为参数的JSON数据被视为JavaScript对象,而不是字符串,因此避免了使用JSON.parse的步骤。
Websocket
WebSocket是一种通信协议,使用ws://(非加密)和wss://(加密)作为协议前缀。该协议不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。
下面是一个例子,浏览器发出的WebSocket请求的头信息(摘自维基百科)。
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
上面代码中,有一个字段是Origin,表示该请求的请求源(origin),即发自哪个域名。
正是因为有了Origin这个字段,所以WebSocket才没有实行同源政策。因为服务器可以根据这个字段,判断是否许可本次通信。如果该域名在白名单内,服务器就会做出如下回应。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
CORS
CORS是跨源资源分享(Cross-Origin Resource Sharing)的缩写。它是W3C标准,是跨源AJAX请求的根本解决方法。
相比JSONP:
-
CORS与JSONP的使用目的相同,但是比JSONP更强大。
-
JSONP只支持GET请求,CORS支持所有类型的HTTP请求。JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据。
CORS
CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。
整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。
因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。
浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
只要同时满足以下两大条件,就属于简单请求。
-
请求方法是以下三种方法之一:
-
HEAD
-
GET
-
POST
-
-
HTTP的头信息不超出以下几种字段:
-
Accept
-
Accept-Language
-
Content-Language
-
Last-Event-ID
-
Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain
-
这是为了兼容表单(form),因为历史上表单一直可以发出跨域请求。AJAX 的跨域设计就是,只要表单可以发,AJAX 就可以直接发。
凡是不同时满足上面两个条件,就属于非简单请求。
浏览器对这两种请求的处理,是不一样的。
简单请求
对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段。
下面是一个例子,浏览器发现这次跨源AJAX请求是简单请求,就自动在头信息之中,添加一个Origin字段。
GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
上面的头信息中,Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。
如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequest的onerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。
如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8
上面的头信息之中,有三个与CORS请求相关的字段,都以Access-Control-开头。
(1)Access-Control-Allow-Origin
该字段是必须的。它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求。
(2)Access-Control-Allow-Credentials
该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可。
(3)Access-Control-Expose-Headers
该字段可选。CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:
-
Cache-Control
-
Content-Language
-
Content-Type
-
Expires
-
Last-Modified
-
Pragma
如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。上面的例子指定,getResponseHeader('FooBar')可以返回FooBar字段的值。
withCredentials 属性
上面说到,CORS请求默认不发送Cookie和HTTP认证信息。如果要把Cookie发到服务器,一方面要服务器同意,指定Access-Control-Allow-Credentials字段。
Access-Control-Allow-Credentials: true
另一方面,开发者必须在AJAX请求中打开withCredentials属性。
var xhr = new XMLHttpRequest()
xhr.withCredentials = true
否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。
但是,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,可以显式关闭withCredentials。
xhr.withCredentials = false
需要注意的是,如果要发送Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传,且(跨源)原网页代码中的document.cookie也无法读取服务器域名下的Cookie。
非简单请求
预检请求
非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUT或DELETE,或者Content-Type字段的类型是application/json。
非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight)。
浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。
下面是一段浏览器的JavaScript脚本。
var url = 'http://api.alice.com/cors'
var xhr = new XMLHttpRequest()
xhr.open('PUT', url, true)
xhr.setRequestHeader('X-Custom-Header', 'value')
xhr.send()
上面代码中,HTTP请求的方法是PUT,并且发送一个自定义头信息X-Custom-Header。
浏览器发现,这是一个非简单请求,就自动发出一个”预检”请求,要求服务器确认可以这样请求。下面是这个”预检”请求的HTTP头信息。
OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
“预检”请求用的请求方法是OPTIONS,表示这个请求是用来询问的。头信息里面,关键字段是Origin,表示请求来自哪个源。
除了Origin字段,”预检”请求的头信息包括两个特殊字段。
(1)Access-Control-Request-Method
该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法,上例是PUT。
(2)Access-Control-Request-Headers
该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段,上例是X-Custom-Header。
预检请求的回应
服务器收到”预检”请求以后,检查了Origin、Access-Control-Request-Method和Access-Control-Request-Headers字段以后,确认允许跨源请求,就可以做出回应。
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://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain
上面的HTTP回应中,关键的是Access-Control-Allow-Origin字段,表示api.bob.com[15] 可以请求数据。该字段也可以设为星号,表示同意任意跨源请求。
如果服务器否定了”预检”请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。控制台会打印出如下的报错信息。
XMLHttpRequest cannot load http:
Origin http:
服务器回应的其他CORS相关字段如下。
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000
(1)Access-Control-Allow-Methods
该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次”预检”请求。
(2)Access-Control-Allow-Headers
如果浏览器请求包括Access-Control-Request-Headers字段,则Access-Control-Allow-Headers字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在”预检”中请求的字段。
(3)Access-Control-Allow-Credentials
该字段与简单请求时的含义相同。
(4)Access-Control-Max-Age
该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。
浏览器的正常请求和回应
一旦服务器通过了”预检”请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。
下面是”预检”请求之后,浏览器的正常CORS请求。
PUT /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
上面头信息的Origin字段是浏览器自动添加的。
下面是服务器正常的回应。
Access-Control-Allow-Origin: http:
Content-Type: text/html; charset=utf-8
上面头信息中,Access-Control-Allow-Origin字段是每次回应都必定包含的。