06-浏览器原理
1. 浏览器安全
1. XSS攻击?
1. 概念
XSS 攻击指的是跨站脚本攻击,是一种代码注入攻击。攻击者通过在网站注入恶意脚本,使之在用户的浏览器上运行,从而盗取用户的信息如 cookie 等
- 本质:因为网站没有对恶意代码进行过滤,与正常的代码混合在一起了,浏览器没有办法分辨哪些脚本是可信的,从而导致了恶意代码的执行
这种攻击方式可以进行以下操作:
- 获取页面的数据。eg:DOM、cookie、localStorage
- DOS 攻击,发送合理请求,占用服务器资源,从而使用户无法访问服务器
- 破坏页面结构
- 流量劫持(将链接指向某网站)
2. 攻击类型
- 存储型:是恶意脚本会存储在目标服务器上,当浏览器请求数据时,脚本从服务器传回并执行
- 反射型:是攻击者诱导用户访问一个带有恶意代码的 URL 后,服务器端接收数据后处理,然后把带有恶意代码的数据发送到浏览器端,浏览器端解析这段带有 XSS 代码的数据后当做脚本执行,最终完成 XSS 攻击
- DOM型:通过修改页面的 DOM 节点形成的 XSS
1. 存储型
- 攻击步骤:
- 攻击者将恶意代码提交到⽬标⽹站的数据库中
- ⽤户打开⽬标⽹站时,⽹站服务端将恶意代码从数据库取出,拼接在 HTML 中返回给浏览器
- ⽤户浏览器接收到响应后解析执⾏,混在其中的恶意代码也被执⾏
- 恶意代码窃取⽤户数据并发送到攻击者的⽹站,或冒充⽤户的⾏为,调⽤⽬标⽹站接⼝执⾏攻击者指定的操作
这种攻击常⻅于带有⽤户保存数据的⽹站功能(eg:论坛发帖、商品评论、⽤户私信等)
2. 反射型
- 攻击步骤:
- 攻击者构造出特殊的 URL,其中包含恶意代码
- ⽤户打开带有恶意代码的 URL 时,⽹站服务端将恶意代码从 URL 中取出,拼接在 HTML 中返回给浏览器
- ⽤户浏览器接收到响应后解析执⾏,混在其中的恶意代码也被执⾏
- 恶意代码窃取⽤户数据并发送到攻击者的⽹站,或冒充⽤户的⾏为,调⽤⽬标⽹站接⼝执⾏攻击者指定的操作
反射型 XSS、存储型 XSS 区别:
- 存储型 XSS:恶意代码存在数据库⾥
- 反射型 XSS:恶意代码存在 URL ⾥
反射型 XSS 漏洞常⻅于通过 URL 传递参数的功能(eg:⽹站搜索、跳转等)。由于需要⽤户主动打开恶意的 URL 才能⽣效,攻击者往往会结合多种⼿段诱导⽤户点击
3. DOM型
- 攻击步骤:
- 攻击者构造出特殊的 URL,其中包含恶意代码
- ⽤户打开带有恶意代码的 URL
- ⽤户浏览器接收到响应后解析执⾏,前端 JS 取出 URL 中的恶意代码并执⾏
- 恶意代码窃取⽤户数据并发送到攻击者的⽹站,或冒充⽤户的⾏为,调⽤⽬标⽹站接⼝执⾏攻击者指定的操作
DOM 型 XSS 跟前两种 XSS 的区别:
- DOM 型 XSS 攻击中,取出和执⾏恶意代码由浏览器端完成,属于前端 JS ⾃身的安全漏洞
- 其他两种 XSS 都属于服务端的安全漏洞
2. 如何防御XSS攻击
XSS 危害如此之大, 在开发网站时,做好哪些防御具体措施:
- 可以从浏览器的执行来进行预防
- 一种是使用纯前端的方式,不用服务器端拼接后返回(不使用服务端渲染)
- 另一种是对需要插入到 HTML 中的代码做好充分的转义。对于 DOM 型的攻击,主要是前端脚本的不可靠而造成的,对于数据获取渲染和字符串拼接时应该对可能出现的恶意代码情况进行判断
- 使用 CSP。CSP 的本质是建立一个白名单,告诉浏览器哪些外部资源可以加载和执行,从而防止恶意代码的注入攻击
- CSP 指的是内容安全策略,它的本质是建立一个白名单,告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截由浏览器自己来实现
- 通常有两种方式来开启 CSP,一种是设置 HTTP 首部中的
Content-Security-Policy
,一种是设置 meta 标签的方式
- 对一些敏感信息进行保护。eg:cookie 使用 http-only,使得脚本无法获取。也可以使用验证码,避免脚本伪装成用户执行一些操作
3. CSRF攻击?
1. 概念
CSRF 攻击指的是跨站请求伪造攻击,攻击者诱导用户进入一个第三方网站,然后该网站向被攻击网站发送跨站请求。如果用户在被攻击网站中保存了登录状态,那么攻击者就可以利用这个登录状态,绕过后台的用户验证,冒充用户向服务器执行一些操作
- 本质:利用 cookie 会在同源请求中携带发送给服务器的特点,以此来实现用户的冒充
2. 攻击类型
- GET 类型的 CSRF 攻击。eg:在网站中的一个 img 标签里构建一个请求,当用户打开这个网站的时候就会自动发起提交
- POST 类型的 CSRF 攻击。eg:构建一个表单,然后隐藏它,当用户进入页面时,自动提交这个表单
- 链接类型的 CSRF 攻击。eg:在 a 标签的 href 属性里构建一个请求,然后诱导用户去点击
4. 如何防御CSRF攻击?
1. 进行同源检测
- 服务器根据 http 请求头中 origin 或 referer 信息来判断请求是否为允许访问的站点,从而对请求进行过滤。当 origin 或 referer 信息都不存在时,直接阻止请求
- 缺点:有些情况下 referer 可以被伪造,同时还会把搜索引擎的链接也给屏蔽了。所以一般网站会允许搜索引擎的页面请求,但是相应的页面请求这种请求方式也可能被攻击者给利用(Referer 字段会告诉服务器该网页是从哪个页面链接过来的)
2. 使用Token进行验证
- 服务器向用户返回一个随机数 Token ,当网站再次发起请求时,在请求参数中加入服务器端返回的 token ,然后服务器对这个 token 进行验证。这种方法解决了使用 cookie 单一验证方式时,可能会被冒用的问题
- 缺点:我们需要给网站中的所有请求都添加上这个 token,操作比较繁琐。还有一个问题是一般不会只有一台网站服务器,如果请求经过负载平衡转移到了其他的服务器,但是这个服务器的 session 中没有保留这个 token 的话,就没有办法验证了。这种情况可以通过改变 token 的构建方式来解决
3. 对Cookie进行双重验证
- 服务器在用户访问网站页面时,向请求域名注入一个 Cookie,内容为随机字符串,然后当用户再次向服务器发送请求时,从 cookie 中取出这个字符串,添加到 URL 参数中,然后服务器通过对 cookie 中的数据和参数中的数据进行比较,来进行验证。使用这种方式是利用了攻击者只能利用 cookie,但是不能访问获取 cookie 的特点。并且这种方法比 CSRF Token 的方法更加方便,并且不涉及到分布式访问的问题
- 缺点:如果网站存在 XSS 漏洞的,那么这种方式会失效。同时这种方式不能做到子域名的隔离
4. Samesite
- 在设置 cookie 属性的时候设置 Samesite ,限制 cookie 不能作为被第三方使用。从而可以避免被攻击者利用。Samesite 一共有两种模式
- 一种是严格模式,在严格模式下 cookie 在任何情况下都不可能作为第三方 Cookie 使用
- 在宽松模式下,cookie 可以被请求是 GET 请求,且会发生页面跳转的请求所使用
5. 中间人攻击?
什么是中间人攻击?如何防范中间人攻击?
中间⼈(Man-in-the-middle attack,MITM)是指攻击者与通讯的两端分别创建独⽴的联系,并交换其所收到的数据,使通讯的两端认为他们正在通过⼀个私密的连接与对⽅直接对话,但事实上整个会话都被攻击者完全控制。在中间⼈攻击中,攻击者可以拦截通讯双⽅的通话并插⼊新的内容
攻击过程:
- 客户端发送请求到服务端,请求被中间⼈截获
- 服务器向客户端发送公钥
- 中间⼈截获公钥,保留在⾃⼰⼿上。然后⾃⼰⽣成⼀个伪造的公钥,发给客户端
- 客户端收到伪造的公钥后,⽣成加密 hash 值发给服务器
- 中间⼈获得加密 hash 值,⽤⾃⼰的私钥解密获得真秘钥,同时⽣成假的加密 hash 值,发给服务器
- 服务器⽤私钥解密获得假密钥,然后加密数据传输给客户端
6. 可能的前端安全问题
1. 跨站脚本
- (Cross-Site Scripting,XSS)
- ⼀种代码注⼊⽅式,为了与 CSS 区分所以被称作 XSS。早期常⻅于⽹络论坛,起因是⽹站没有对⽤户的输⼊进⾏严格的限制,使得攻击者可以将脚本上传到帖⼦让其他⼈浏览到有恶意脚本的⻚⾯,其注⼊⽅式很简单包括但不限于 JS / CSS / Flash 等
2. iframe的滥⽤
- iframe 中的内容是由第三⽅来提供的,默认情况下他们不受控制,他们可以在 iframe 中运⾏ JS 脚本、Flash 插件、弹出对话框等,这可能会破坏前端⽤户体验
3. 跨站点请求伪造
- (Cross-Site Request Forgeries,CSRF)
- 指攻击者通过设置好的陷阱,强制对已完成认证的⽤户进⾏⾮预期的个⼈信息或设定信息等某些状态更新,属于被动攻击
4. 恶意第三⽅库
- ⽆论是后端服务器应⽤还是前端应⽤开发,绝⼤多数时候都是在借助开发框架和各种类库进⾏快速开发,⼀旦第三⽅库被植⼊恶意代码很容易引起安全问题
7. 网络劫持有几种?
- DNS 劫持(输⼊京东被强制跳转到淘宝这就属于 DNS劫持)
- DNS 强制解析:通过修改运营商的本地 DNS 记录,来引导⽤户流量到缓存服务器
- 302 跳转的⽅式:通过监控⽹络出⼝的流量,分析判断哪些内容是可以进⾏劫持处理的,再对劫持的内容发起 302 跳转的回复,引导⽤户获取内容
- HTTP 劫持(访问⾕歌但是⼀直有贪玩蓝⽉的⼴告)
- 由于 http 明⽂传输,运营商会修改 http 响应内容(即加⼴告)
- DNS劫持由于涉嫌违法,已经被监管起来,现在很少会有DNS劫持,⽽ http 劫持依然⾮常盛⾏,最有效的办法就是全站HTTPS,将 HTTP 加密,这使得运营商⽆法获取明⽂,就⽆法劫持你的响应内容
2. 进程与线程
1. 概念
进程是资源分配的最小单位,线程是 CPU 调度执行的最小单位
从本质上说,进程和线程都是 CPU 工作时间片的一个描述:
- 进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序
- 线程是进程中的更小单位,描述了执行一段指令所需的时间
- 一个进程就是一个程序的运行实例。启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,这样的一个运行环境叫进程。进程是运行在虚拟内存上的,虚拟内存是用来解决用户对硬件资源的无限需求和有限的硬件资源之间的矛盾的。从操作系统角度来看,虚拟内存即交换文件;从处理器角度看,虚拟内存即虚拟地址空间
进程和线程间关系的四个特点:
- 进程中的任意一线程执行出错,都会导致整个进程的崩溃
- 线程之间共享进程中的数据
- 当一个进程关闭之后,操作系统会回收进程所占用的内存,当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收
- 进程之间的内容相互隔离。进程隔离就是为了使操作系统中的进程互不干扰,每一个进程只能访问自己占有的数据。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信的机制了
2. Chrome进程
Chrome 浏览器的架构图:
最新的 Chrome 浏览器包括:
- 1 个浏览器主进程
- 主要负责界面显示、用户交互、子进程管理,同时提供存储等功能
- 1 个 GPU 进程
- 其实, GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程
- 1 个网络进程
- 主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程
- 多个渲染进程
- 核心任务是将 HTML、CSS、JS 转换为用户可以与之交互的网页,排版引擎 Blink 和 JS 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下
- 多个插件进程
- 主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响
所以,打开一个网页,最少需要四个进程:1 个网络进程、1 个浏览器进程、1 个 GPU 进程、1 个渲染进程。如果打开的页面有运行插件的话,还需要再加上 1 个插件进程
虽然多进程模型提升了浏览器的稳定性、流畅性、安全性,但同样不可避免地带来了一些问题:
- 更高的资源占用:因为每个进程都会包含公共基础结构的副本(eg:JS 运行环境),这就意味着浏览器会消耗更多的内存资源
- 更复杂的体系架构:浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了
3. 区别
- 进程可以看做独立应用,线程不能
- 资源
- 进程:CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)
- 线程:CPU 调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)
- 通信方面
- 进程通信:需要借助进程间通信
- 线程间:可以通过直接共享同一进程中的资源
- 调度
- 进程切换比线程切换的开销要大,某个进程中的线程切换到另一个进程中的线程时,会引起进程切换
- 线程是 CPU 调度的基本单位,线程的切换不会引起进程切换
- 系统开销
- 由于创建或撤销进程时,系统都要为之分配或回收资源(eg:内存、I/O 等),其开销远大于创建或撤销线程时的开销
- 同理,在进行进程切换时,涉及当前执行进程 CPU 环境还有各种各样状态的保存及新调度进程状态的设置,而线程切换时只需保存和设置少量寄存器内容,开销较小
3. 渲染进程的线程有哪些
1. GUI渲染线程
- 负责渲染浏览器页面,解析 HTML、CSS,构建 DOM 树、构建 CSSOM 树、构建渲染树和绘制页面;当界面需要重绘或由于某种操作引发回流时,该线程就会执行
- 注意:GUI 渲染线程和 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起,GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行
2. JS引擎线程
- JS 引擎线程也称为 JS 内核,负责处理 JS 脚本程序,解析 JS 脚本,运行代码
- JS 引擎线程一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页中无论什么时候都只有一个 JS 引擎线程在运行 JS 程序
- 注意:GUI 渲染线程与 JS 引擎线程的互斥关系,所以如果 JS 执行的时间过长,会造成页面的渲染不连贯,导致页面渲染加载阻塞
3. 时间触发线程
- 属于浏览器而不是 JS 引擎,用来控制事件循环;当 JS 引擎执行代码块,如 setTimeOut 时(也可是来自浏览器内核的其他线程,如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件触发线程中;当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理
- 注意:由于 JS 的单线程关系,所以这些待处理队列中的事件都得排队等待 JS 引擎处理(当 JS 引擎空闲时才会去执行)
4. 定时器触发进程
- 即 setInterval 与 setTimeout 所在线程;浏览器定时计数器并不是由 JS 引擎计数的,因为 JS 引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确性;因此使用单独线程来计时并触发定时器,计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行,所以定时器中的任务在设定的时间点不一定能够准时执行,定时器只是在指定时间点将任务添加到事件队列中
- 注意:W3C 在 HTML 标准中规定,定时器的定时时间不能小于 4ms,如果是小于 4ms,则默认为 4ms
5. 异步http请求线程
- XMLHttpRequest 连接后通过浏览器新开一个线程请求
- 检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将回调函数放入事件队列中,等待 JS 引擎空闲后执行
4. 进程间通信方式
1. 管道通信
管道是一种最基本的进程间通信机制。管道就是操作系统在内核中开辟的一段缓冲区,进程 1 可以将需要交互的数据拷贝到这段缓冲区,进程 2 就可以读取了
特点:
- 只能单向通信
- 只能血缘关系的进程进行通信
- 依赖于文件系统
- 生命周期随进程
- 面向字节流的服务
- 管道内部提供了同步机制
2. 消息队列通信
- 就是一个消息的列表。用户可以在消息队列中添加消息、读取消息等。消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。 每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。可以通过发送消息来避免命名管道的同步和阻塞问题。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制
- 缺点:
- 使用消息队列进行进程间通信,可能会收到数据块最大长度的限制约束等
- 如果频繁的发生进程间的通信行为,那么进程需要频繁地读取队列中的数据到内存,相当于间接地从一个进程拷贝到另一个进程,这需要花费时间
3. 信号量通信
- 共享内存最大的问题就是多进程竞争内存的问题,就像类似于线程安全问题。使用信号量来解决这个问题
- 信号量本质就是一个计数器,用来实现进程之间的互斥与同步
- eg:信号量的初始值是 1,然后 a 进程来访问内存 1 的时候,就把信号量的值设为 0,然后进程 b 也要来访问内存 1的时候,看到信号量的值为 0 就知道已经有进程在访问内存 1 了,这个时候进程 b 就会访问不了内存 1
4. 信号通信
- 信号(Signals)是 Unix 系统中使用的最古老的进程间通信的方法之一。操作系统通过信号来通知进程系统中发生了某种预先规定好的事件(一组事件中的一个),它也是用户进程之间通信和同步的一种原始机制
5. 共享内存通信
- 映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制(eg:信号量),配合使用,来实现进程间的同步和通信
6. 套接字通信
- 共享内存、管道、信号量、消息队列,他们都是多个进程在一台主机之间的通信,采用 Socket 的通信方式
5. 僵尸、孤儿进程是
- 孤儿进程:父进程退出了,而它的一个或多个进程还在运行,那这些子进程都会成为孤儿进程。孤儿进程将被 init 进程(进程号为1)所收养,并由init进程对它们完成状态收集工作
- 僵尸进程:子进程比父进程先结束,而父进程又没有释放子进程占用的资源,那么子进程的进程描述符仍然保存在系统中
6. 死锁原因及解决
所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进
系统中的资源可以分为两类:
- 可剥夺资源:指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺,CPU、主存均属于可剥夺性资源
- 不可剥夺资源:当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放(eg:磁带机、打印机等)
原因:
- 竞争资源
- 竞争不可剥夺资源(eg:系统中只有一台打印机,可供进程 P1 使用,假定 P1 已占用了打印机,若 P2 继续要求打印机打印将阻塞)
- 竞争临时资源(包括硬件中断、信号、消息、缓冲区内的消息等),通常消息通信顺序进行不当,则会产生死锁
- 进程间推进顺序非法
- 若 P1 保持了资源 R1,P2 保持了资源 R2,系统处于不安全状态,因为这两个进程再向前推进,便可能发生死锁
- eg:当 P1 运行到 Request(R2)时,将因 R2 已被 P2 占用而阻塞;当 P2 运行到 Request(R1)时,也将因 R1 已被 P1 占用而阻塞,于是发生进程死锁
产生死锁的必要条件:
- 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用
- 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放
- 环路等待条件:在发生死锁时,必然存在一个进程 —— 资源的环形链
预防死锁的方法:
- 资源一次性分配:一次性分配所有资源,这样就不会再有请求了(破坏请求条件)
- 只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏请保持条件)
- 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
- 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)
7. 多个标签页间通信?
本质上都是通过中介者模式来实现。因为标签页之间没有办法直接通信,让标签页和中介者进行通信,然后让这个中介者来进行消息的转发。通信方法如下:
- 使用 websocket 协议:因为 websocket 协议可以实现服务器推送,所以服务器就可以用来当做这个中介者。标签页通过向服务器发送数据,然后由服务器向其他标签页推送转发
- 使用 ShareWorker 方式:ShareWorker 会在页面存在的生命周期内创建一个唯一的线程,并且开启多个页面也只会使用同一个线程。共享线程就可以充当中介者的角色。标签页间通过共享一个线程,实现数据的交换
- 使用 localStorage 方式:在一个标签页对 localStorage 的变化事件进行监听,然后当另一个标签页修改数据的时候,就可以通过这个监听事件来获取到数据。这时 localStorage 对象就是充当的中介者的角色
- 使用 postMessage 方法:如果能够获得对应标签页的引用,就可以使用 postMessage 方法,进行通信
8. Service Worker的理解
Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker 的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全
Service Worker 实现缓存功能一般分为三个步骤:
- 首先需要先注册 Service Worker
- 然后监听到
install
事件以后就可以缓存需要的文件 - 那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。以下是步骤实现:
// index.js
if (navigator.serviceWorker) {
navigator.serviceWorker
.register('sw.js')
.then(function (registration) {
console.log('service worker 注册成功')
})
.catch(function (err) {
console.log('servcie worker 注册失败')
})
}
// sw.js
// 监听 `install` 事件,回调中缓存所需文件
self.addEventListener('install', e => {
e.waitUntil(
caches.open('my-cache').then(function (cache) {
return cache.addAll(['./index.html', './index.js'])
})
)
})
// 拦截所有请求事件
// 如果缓存中已经有请求的数据就直接用缓存,否则去请求数据
self.addEventListener('fetch', e => {
e.respondWith(
caches.match(e.request).then(function (response) {
if (response) {
return response
}
console.log('fetch source')
})
)
})
打开页面,可以在开发者工具中的 Application
看到 Service Worker 已经启动了:
在 Cache 中所需的文件已被缓存:
3. 浏览器缓存机制
1. 理解
浏览器缓存的全过程:
- 浏览器第一次加载资源,服务器返回 200,浏览器从服务器下载资源文件,并缓存资源文件与 response header,以供下次加载时对比使用
- 下一次加载资源时,由于强制缓存优先级较高,先比较当前时间与上一次返回 200 时的时间差
- 如果没有超过 cache-control 设置的 max-age(如果浏览器不支持 HTTP1.1,则使用 expires 头判断是否过期),则没有过期,并命中强缓存,直接从本地读取资源
- 如果资源已过期,则表明强制缓存没有被命中,则开始协商缓存,向服务器发送带有 If-None-Match 和 If-Modified-Since 的请求
- 服务器收到请求后,优先根据 Etag 的值判断被请求的文件有没有做修改
- Etag 值一致,则没有修改,命中协商缓存,返回 304
- 如果不一致,则有改动,直接返回新的资源文件带上新的 Etag 值并返回 200
- 如果服务器收到的请求没有 Etag 值,则将 If-Modified-Since 和被请求文件的最后修改时间做比对
- 一致则命中协商缓存,返回 304
- 不一致则返回新的 last-modified 和文件并返回 200
很多网站的资源后面都加了版本号,目的:每次升级了 JS 或 CSS 文件后,为了防止浏览器进行缓存,强制改变版本号,客户端浏览器就会重新下载新的 JS 或 CSS 文件 ,以保证用户能够及时获得网站的最新更新
2. 缓存的位置?
资源缓存的位置一共有 3 种,按优先级从高到低分别是:
1. Service Worker
- Service Worker 运行在 JS 主线程之外,虽然由于脱离了浏览器窗体无法直接访问 DOM,但是它可以完成离线缓存、消息推送、网络代理等功能。可以自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的
- 当 Service Worker 没有命中缓存的时候,需要去调用
fetch
函数获取数据,会根据缓存查找优先级去查找数据 - 但是不管是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示是从 Service Worker 中获取的内容
2. Memory Cache
- 就是内存缓存,它的效率最快。 但是内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。一旦我们关闭 Tab 页面,内存中的缓存也就被释放了
3. Disk Cache
- 存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上
- 在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求
- 并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据
4. Push Cache
- Push Cache 是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。并且缓存时间也很短暂,只在会话(Session)中存在,一旦会话结束就被释放
- 特点:
- 所有的资源都能被推送,但是 Edge 和 Safari 浏览器兼容性不怎么好
- 可以推送
no-cache
和no-store
的资源 - 一旦连接被关闭,Push Cache 就被释放
- 多个页面可以使用相同的 HTTP/2 连接,也就是说能使用同样的缓存
- Push Cache 中的缓存只能被使用一次
- 浏览器可以拒绝接受已经存在的资源推送
- 可以给其他域名推送资源
3. 协商缓存、强缓存区别
强缓存策略和协商缓存策略在缓存命中时都会直接使用本地的缓存副本,区别只在于协商缓存会向服务器发送一次请求。缓存不命中时,都会向服务器发送请求来获取资源。在实际的缓存机制中,强缓存策略和协商缓存策略是一起合作使用的
- 浏览器首先会根据请求的信息判断,强缓存是否命中,如果命中则直接使用资源。如果不命中则根据头信息向服务器发起请求,使用协商缓存
- 如果协商缓存命中的话,则服务器不返回资源,浏览器直接使用本地资源的副本
- 如果协商缓存不命中,则浏览器返回最新的资源给浏览器
1. 强缓存
使用强缓存策略时,如果缓存资源有效,则直接使用缓存资源,不必再向服务器发起请求
强缓存策略可以通过两种方式来设置,分别是 http 头信息中的 Expires 属性和 Cache-Control 属性
Expires
:服务器通过在响应头中添加 Expires 属性,来指定资源的过期时间。在过期时间以内,该资源可以被缓存使用,不必再向服务器发送请求。这个时间是一个绝对时间,它是服务器的时间- 因此可能存在这样的问题,就是客户端的时间和服务器端的时间不一致,或用户可以对客户端时间进行修改的情况,这样就可能会影响缓存命中的结果
Cache-Control
:Expires
是 http1.0 中的方式,因为它的一些缺点,在 HTTP 1.1 中提出了新的头部属性Cache-Control
,它提供了对资源的缓存的更精确的控制。可设置的字段:public
:设置了该字段值的资源表示可以被任何对象缓存(包括:发送请求的客户端、代理服务器等)。这个字段值不常用,一般还是使用max-age=
来精确控制private
:设置了该字段值的资源只能被用户浏览器缓存,不允许任何代理服务器缓存。在实际开发当中,对于一些含有用户信息的 HTML,通常都要设置这个字段值,避免代理服务器(CDN)缓存no-cache
:需要先和服务端确认返回的资源是否发生了变化,如果资源未发生变化,则直接使用缓存好的资源no-store
:表示禁止任何缓存,每次都会向服务端发起新的请求,拉取最新的资源max-age=
:设置缓存的最大有效期,单位为秒s-maxage=
:优先级高于max-age=
,仅适用于共享缓存(CDN),优先级高于max-age
或Expires
头max-stale[=]
:设置了该字段表明客户端愿意接收已经过期的资源,但是不能超过给定的时间限制
一般来说只需要设置其中一种方式就可以实现强缓存策略,当两种方式一起使用时,Cache-Control
的优先级要高于 Expires
2. 协商缓存
如果命中强制缓存,无需发起新的请求,直接使用缓存内容,如果没有命中强制缓存,进行协商缓存(如果设置)
- 使用协商缓存策略时,会先向服务器发送一个请求,如果资源没有发生修改,则返回一个 304 状态,让浏览器使用本地的缓存副本。如果资源发生了修改,则返回修改后的资源
命中协商缓存条件:
max-age=xxx
过期了no-store
协商缓存通过 http 头信息中的 Etag 和 Last-Modified 属性来设置:
- 服务器通过在响应头中添加
Last-Modified
属性来指出资源最后一次修改的时间,当浏览器下一次发起请求时,会在请求头中添加一个If-Modified-Since
的属性,属性值为上一次资源返回时的Last-Modified
的值。当请求发送到服务器后服务器会通过这个属性来和资源的最后一次的修改时间来进行比较,以此来判断资源是否做了修改。如果资源没有修改,那么返回 304 状态,让客户端使用本地的缓存。如果资源已经被修改了,则返回修改后的资源- 缺点:就是 Last-Modified 标注的最后修改时间只能精确到秒级,如果某些文件在1秒钟以内,被修改多次的话,那么文件已将改变了但是 Last-Modified 却没有改变,这样会造成缓存命中的不准确
- 服务器在返回资源的时候,在头信息中添加了
Etag
属性,这个属性是资源生成的唯一标识符,当资源发生改变的时候,这个值也会发生改变。在下一次资源请求时,浏览器会在请求头中添加一个If-None-Match
属性,这个属性的值就是上次返回的资源的Etag
的值。服务接收到请求后会根据这个值来和资源当前的Etag
的值来进行比较,以此来判断资源是否发生改变,是否需要返回资源。通过这种方式,比Last-Modified
的方式更加精确 - 当
Last-Modified
和Etag
属性同时出现的时候,Etag
的优先级更高。使用协商缓存的时候,服务器需要考虑负载平衡的问题,因此多个服务器上资源的Last-Modified
应该保持一致,因为每个服务器上Etag
的值都不一样,因此在考虑负载平衡时,最好不要设置Etag
属性
4. 浏览器缓存?
浏览器缓存:浏览器将用户请求过的静态资源,存储到电脑本地磁盘中,当浏览器再次访问时
- 如果服务器的静态资源没有更新,那么在下次请求时,就直接从本地读取即可
- 如果服务器的静态资源已经更新,那么再次请求时,就到服务器拉取新的资源,并保存在本地
- 这样就大大的减少了请求的次数,提高了网站的性能。这就要用到浏览器的缓存策略了
优点:
- 减少了服务器的负担,提高了网站的性能
- 加快了客户端网页的加载速度
- 减少了多余网络数据传输
5. 各种刷新区别
- 点击刷新按钮或者按 F5
- 浏览器直接对本地的缓存文件过期,但是会带上
If-Modifed-Since
,If-None-Match
,这就意味着服务器会对文件检查新鲜度,返回结果可能是 304,也有可能是 200
- 浏览器直接对本地的缓存文件过期,但是会带上
- 用户按 Ctrl + F5(强制刷新)
- 浏览器不仅会对本地文件过期,而且不会带上
If-Modifed-Since
,If-None-Match
,相当于之前从来没有请求过,返回结果是 200
- 浏览器不仅会对本地文件过期,而且不会带上
- 地址栏回车:
- 浏览器发起请求,按照正常流程,本地检查是否过期,然后服务器检查新鲜度,最后返回内容
4. 浏览器组成
1. 浏览器的理解
- 浏览器的主要功能是将用户选择的 web 资源呈现出来,它需要从服务器请求资源,并将其显示在浏览器窗口中,资源的格式通常是 HTML,也包括 PDF、image 及其他格式。用户用 URI(Uniform Resource Identifier,统一资源标识符)来指定所请求资源的位置
- HTML 和 CSS 规范中规定了浏览器解释 html 文档的方式,由 W3C 组织对这些规范进行维护,W3C 是负责制定 web 标准的组织。但是浏览器厂商纷纷开发自己的扩展,对规范的遵循并不完善,这为 web 开发者带来了严重的兼容性问题
- 浏览器可以分为两部分,shell 和 内核。其中 shell 的种类相对比较多,内核则比较少。也有一些浏览器并不区分外壳和内核。从 Mozilla 将 Gecko 独立出来后,才有了外壳和内核的明确划分
- shell 是指浏览器的外壳:菜单,工具栏等。主要是提供给用户界面操作,参数设置等。调用内核来实现各种功能的
- 内核是浏览器的核心。内核是基于标记语言显示内容的程序或模块
2. 内核的理解
浏览器内核主要分成两部分:
- 渲染引擎:职责就是渲染,即在浏览器窗口中显示所请求的内容。默认情况下,渲染引擎可以显示 html、xml 文档、图片,它也可以借助插件显示其他类型数据(eg:使用 PDF 阅读器插件,可以显示 PDF 格式)
- JS 引擎:解析和执行 JS 来实现网页的动态效果
最开始渲染引擎和 JS 引擎并没有区分的很明确,后来 JS 引擎越来越独立,内核就倾向于只指渲染引擎
3. 常见的浏览器内核
- Trident:这种浏览器内核是 IE 浏览器用的内核,因为在早期 IE 占有大量的市场份额,所以这种内核比较流行,以前有很多网页也是根据这个内核的标准来编写的,但是实际上这个内核对真正的网页标准支持不是很好。但是由于 IE 的高市场占有率,微软也很长时间没有更新 Trident 内核,就导致了 Trident 内核和 W3C 标准脱节。还有就是 Trident 内核的大量 Bug 等安全问题没有得到解决,加上一些专家学者公开自己认为 IE 浏览器不安全的观点,使很多用户开始转向其他浏览器
- Gecko:这是 Firefox 和 Flock 所采用的内核,这个内核的优点就是功能强大、丰富,可以支持很多复杂网页效果和浏览器扩展接口,但是代价是也显而易见就是要消耗很多的资源(eg:内存)
- Presto:Opera 曾经采用的就是 Presto 内核,Presto 内核被称为公认的浏览网页速度最快的内核,这得益于它在开发时的天生优势,在处理 JS 脚本等脚本语言时,会比其他的内核快3倍左右。缺点:为了达到很快的速度而丢掉了一部分网页兼容性
- Webkit:Webkit 是 Safari 采用的内核
- 优点:网页浏览速度较快,虽然不及 Presto 但是也胜于 Gecko 和 Trident
- 缺点:对于网页代码的容错性不高,也就是说对网页代码的兼容性较低,会使一些编写不标准的网页无法正确显示
- WebKit 前身是 KDE 小组的 KHTML 引擎,可以说 WebKit 是 KHTML 的一个开源的分支
- Blink:谷歌在 Chromium Blog 上发表博客,称将与苹果的开源浏览器核心 Webkit 分道扬镳,在 Chromium 项目中研发 Blink 渲染引擎(即浏览器核心),内置于 Chrome 浏览器之中。其实 Blink 引擎就是 Webkit 的一个分支,就像 webkit 是 KHTML 的分支一样。Blink 引擎现在是谷歌公司与 Opera Software 共同研发,上面提到过的,Opera 弃用了自己的 Presto 内核,加入 Google 阵营,跟随谷歌一起研发 Blink
4. 常见浏览器所用内核
- IE 浏览器内核:Trident 内核,也是俗称的 IE 内核
- Chrome 浏览器内核:统称为 Chromium 内核或 Chrome 内核,以前是 Webkit 内核,现在是 Blink 内核
- Firefox 浏览器内核:Gecko 内核,俗称 Firefox 内核
- Safari 浏览器内核:Webkit 内核
- Opera 浏览器内核:最初是自己的 Presto 内核,后来加入谷歌大军,从 Webkit 又到了 Blink 内核
- 360浏览器、猎豹浏览器内核:IE + Chrome 双内核
- 搜狗、遨游、QQ 浏览器内核:Trident(兼容模式)+ Webkit(高速模式)
- 百度浏览器、世界之窗内核:IE 内核
- 2345浏览器内核:好像以前是 IE 内核,现在也是 IE + Chrome 双内核了
- UC 浏览器内核:这个众口不一,UC 说是他们自己研发的 U3 内核,但好像还是基于 Webkit 和 Trident ,还有说是基于火狐内核
5. 浏览器的组成部分
- ⽤户界⾯
- 包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗⼝显示请求的⻚⾯外,其他显示的各个部分都属于⽤户界⾯
- 浏览器引擎
- 在⽤户界⾯和呈现引擎之间传送指令
- 呈现引擎
- 负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上
- ⽹络
- ⽤于⽹络调⽤(eg:HTTP 请求)。其接⼝与平台⽆关,并为所有平台提供底层实现
- ⽤户界⾯后端
- ⽤于绘制基本的窗⼝⼩部件(eg:组合框和窗⼝)。其公开了与平台⽆关的通⽤接⼝,⽽在底层使⽤操作系统的⽤户界⾯⽅法
- JS 解释器
- ⽤于解析和执⾏ JS 代码
- 数据存储
- 这是持久层。浏览器需要在硬盘上保存各种数据(eg:Cookie)。新的 HTML 规范(HTML5)定义了“⽹络数据库”,这是⼀个完整(但是轻便)的浏览器内数据库
值得注意的是,和⼤多数浏览器不同,Chrome 浏览器的每个标签⻚都分别对应⼀个呈现引擎实例。每个标签⻚都是⼀个独⽴的进程
5. 浏览器渲染原理
1. 渲染过程
- 首先解析收到的文档,根据文档定义构建一棵 DOM 树,DOM 树是由 DOM 元素及属性节点组成的
- 然后对 CSS 进行解析,生成 CSSOM 规则树
- 根据 DOM 树和 CSSOM 规则树构建渲染树。渲染树的节点被称为渲染对象,渲染对象是一个包含有颜色和大小等属性的矩形,渲染对象和 DOM 元素相对应,但这种对应关系不是一对一的,不可见的 DOM 元素不会被插入渲染树。还有一些 DOM元素对应几个可见对象,它们一般是一些具有复杂结构的元素,无法用一个矩形来描述
- 当渲染对象被创建并添加到树中,它们并没有位置和大小,所以当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做回流)。这一阶段浏览器要做的事情是要弄清楚各个节点在页面中的确切位置和大小。通常这一行为也被称为“自动重排”
- 布局阶段结束后是绘制阶段,遍历渲染树并调用渲染对象的 paint 方法将它们的内容显示在屏幕上,绘制使用 UI 基础组件
大致过程如图:
注意:这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的 html 都解析完成之后再去构建和布局 render 树。它是解析完一部分内容就显示一部分内容,同时,可能还在通过网络下载其余内容
2. 浏览器渲染优化
1. 针对JS
JS 既会阻塞 HTML 的解析,也会阻塞 CSS 的解析。因此我们可以对 JS 的加载方式进行改变,来进行优化:
- 尽量将 JS 文件放在 body 的最后
- body 中间尽量不要写
<script>
标签 <script>
标签的引入资源方式有三种,有一种就是常用的直接引入,还有两种就是使用 async 属性和 defer 属性来异步引入,两者都是去异步加载外部的 JS 文件,不会阻塞 DOM 的解析(尽量使用异步加载)。三者的区别如下:- script:立即停止页面渲染去加载资源文件,当资源加载完毕后立即执行 js 代码,js 代码执行完毕后继续渲染页面
- async:是在下载完成之后,立即异步加载,加载好后立即执行,多个带 async 属性的标签,不能保证加载的顺序
- defer:是在下载完成之后,立即异步加载。加载好后,如果 DOM 树还没构建好,则先等 DOM 树解析好再执行;如果DOM树已经准备好,则立即执行。多个带 defer 属性的标签,按照顺序执行
2. 针对CSS
使用CSS有三种方式:其中 link 和 @import 都是导入外部样式。区别:
link
:浏览器会派发一个新的线程(HTTP线程)去加载资源文件,与此同时 GUI 渲染线程会继续向下渲染代码@import
:GUI 渲染线程会暂时停止渲染,去服务器加载资源文件,资源文件没有返回之前不会继续渲染(阻碍浏览器渲染)style
:GUI 直接渲染
外部样式如果长时间没有加载完毕,浏览器为了用户体验,会使用浏览器会默认样式,确保首次渲染的速度。所以 CSS 一般写在 headr 中,让浏览器尽快发送请求去获取 css 样式
所以,在开发过程中,导入外部样式使用 link,而不用 @import。如果 css 少,尽可能采用内嵌样式,直接写在 style 标签中
3. 针对DOM、CSSOM树
几种方式来减少渲染的时间:
- HTML 文件的代码层级尽量不要太深
- 使用语义化的标签,来避免不标准语义化的特殊处理
- 减少 CSSD 代码的层级,因为选择器是从左向右进行解析的
4. 减少回流与重绘
- 操作 DOM 时,尽量在低层级的 DOM 节点进行操作
- 不要使用
table
布局, 一个小的改动可能会使整个table
进行重新布局 - 使用 CSS 的表达式
- 不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式
- 使用 absolute 或者 fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素
- 避免频繁操作 DOM,可以创建一个文档片段
documentFragment
,在它上面应用所有 DOM 操作,最后再把它添加到文档中 - 将元素先设置
display: none
,操作结束后再把它显示出来。因为在 display 属性为 none 的元素上进行的 DOM 操作不会引发回流和重绘 - 将 DOM 的多个读操作(或写操作)放在一起,而不是读写操作穿插着写。这得益于浏览器的渲染队列机制
浏览器针对页面的回流与重绘,进行了自身的优化 —— 渲染队列
- 浏览器会将所有的回流、重绘的操作放在一个队列中,当队列中的操作到了一定的数量或到了一定的时间间隔,浏览器就会对队列进行批处理。这样就会让多次的回流、重绘变成一次回流重绘
- 将多个读操作(或写操作)放在一起,就会等所有的读操作进入队列之后执行,这样,原本应该是触发多次回流,变成了只触发一次回流
3. 渲染中遇到JS如何处理
- JS 的加载、解析、执行会阻塞文档的解析
- 在构建 DOM 时,HTML 解析器若遇到了 JS,那么它会暂停文档的解析,将控制权移交给 JS 引擎,等 JS 引擎运行完毕,浏览器再从中断的地方恢复继续解析文档
- 如果想要首屏渲染的越快,就越不应该在首屏就加载 JS 文件,这也是都建议将 script 标签放在 body 标签底部的原因。当然在当下,并不是说 script 标签必须放在底部,因为你可以给 script 标签添加 defer 或 async 属性
4. 文档的预解析
- Webkit 和 Firefox 都做了这个优化,当执行 JS 脚本时,另一个线程解析剩下的文档,并加载后面需要通过网络加载的资源
- 这种方式可以使资源并行加载从而使整体速度更快。需要注意的是,预解析并不改变 DOM 树,它将这个工作留给主解析过程,自己只解析外部资源的引用(eg:外部脚本、样式表及图片)
5. CSS阻塞文档解析
- 理论上,既然样式表不改变 DOM 树,也就没有必要停下文档的解析等待它们
- 然而,存在一个问题,JS 脚本执行时可能在文档的解析过程中请求样式信息,如果样式还没有加载和解析,脚本将得到错误的值,显然这将会导致很多问题
- 所以如果浏览器尚未完成 CSSOM 的下载和构建,而我们却想在此时运行脚本,那么浏览器将延迟 JS 脚本执行和文档的解析,直至其完成 CSSOM 的下载和构建。也就是说,在这种情况下,浏览器会先下载和构建 CSSOM,然后再执行 JS,最后再继续文档的解析
6. 优化关键渲染路径
为尽快完成首次渲染,需要最大限度减小以下三种可变因素:
- 关键资源的数量
- 关键资源是可能阻止网页首次渲染的资源。这些资源越少,浏览器的工作量就越小,对 CPU 以及其他资源的占用也就越少
- 关键路径长度
- 关键路径长度受所有关键资源与其字节大小之间依赖关系图的影响:某些资源只能在上一资源处理完毕之后才能开始下载,并且资源越大,下载所需的往返次数就越多
- 关键字节的数量
- 浏览器需要下载的关键字节越少,处理内容并让其出现在屏幕上的速度就越快。要减少字节数,我们可以减少资源数(将它们删除或设为非关键资源),此外还要压缩和优化各项资源,确保最大限度减小传送大小
常规步骤:
- 对关键路径进行分析和特性描述:资源数、字节数、长度
- 最大限度减少关键资源的数量:删除它们,延迟它们的下载,将它们标记为异步等
- 优化关键字节数以缩短下载时间(往返次数)
- 优化其余关键资源的加载顺序:您需要尽早下载所有关键资产,以缩短关键路径长度
7. 什么情况会阻塞渲染
- 首先渲染的前提是生成渲染树,所以 HTML 和 CSS 肯定会阻塞渲染。如果你想渲染的越快,你越应该降低一开始需要渲染的文件大小,并且扁平层级,优化选择器
- 然后当浏览器在解析到 Script 标签时,会暂停构建 DOM,完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件,这也是都建议将 Script 标签放在 body 标签底部的原因
- 当然在当下,并不是说 Script 标签必须放在底部,因为你可以给 Script 标签添加 defer 或 async 属性。当 Script 标签加上 defer 属性以后,表示该 JS 文件会并行下载,但是会放到 HTML 解析完成后顺序执行,所以对于这种情况你可以把 Script 标签放在任意位置。对于没有任何依赖的 JS 文件可以加上 async 属性,表示 JS 文件下载和解析不会阻塞渲染
6. 浏览器本地存储
1. 方式及使用场景
1. Cookie
Cookie 是最早被提出来的本地存储方式,在此之前,服务端是无法判断网络中的两个请求是否是同一用户发起的,为解决这个问题,Cookie 就出现了。Cookie 的大小只有 4kb,它是一种纯文本文件,每次发起 HTTP 请求都会携带 Cookie
- 特性:
- Cookie 一旦创建成功,名称就无法修改
- Cookie 是无法跨域名的,也就是说 a 域名和 b 域名下的 Cookie 是无法共享的,这也是由 Cookie 的隐私安全性决定的,这样就能够阻止非法获取其他网站的 Cookie
- 每个域名下 Cookie 的数量不能超过 20 个,每个 Cookie 的大小不能超过 4kb
- 有安全问题,如果 Cookie 被拦截了,那就可获得 Session 的所有信息,即使加密也于事无补,无需知道 Cookie 的意义,只要转发 Cookie 就能达到目的
- Cookie 在请求一个新的页面的时候都会被发送过去
- 如果需要域名之间跨域共享 Cookie,有两种方法:
- 使用 Nginx 反向代理
- 在一个站点登陆之后,往其他网站写 Cookie。服务端的 Session 存储到一个节点,Cookie 存储 sessionId
- 使用场景:
- 最常见的使用场景就是 Cookie 和 Session 结合使用,将 sessionId 存储到 Cookie 中,每次发请求都会携带这个 sessionId,这样服务端就知道是谁发起的请求,从而响应相应的信息
- 可以用来统计页面的点击次数
2. LocalStorage
LocalStorage 是 HTML5 新引入的特性,由于有的时候我们存储的信息较大,Cookie 就不能满足我们的需求,这时候 LocalStorage 就派上用场了
- 优点:
- 在大小方面,LocalStorage 的大小一般为 5MB,可以储存更多的信息
- LocalStorage 是持久储存,并不会随着页面的关闭而消失,除非主动清理,不然会永久存在
- 仅储存在本地,不像 Cookie 那样每次 HTTP 请求都会被携带
- 缺点:
- 存在浏览器兼容问题,IE8 以下版本的浏览器不支持
- 如果浏览器设置为隐私模式,那将无法读取到 LocalStorage
- LocalStorage 受到同源策略的限制,即端口、协议、主机地址有任何一个不相同,都不会访问
- 常用API:
// 保存数据到 localStorage
localStorage.setItem('key', 'value');
// 从 localStorage 获取数据
let data = localStorage.getItem('key');
// 从 localStorage 删除保存的数据
localStorage.removeItem('key');
// 从 localStorage 删除所有保存的数据
localStorage.clear();
// 获取某个索引的Key
localStorage.key(index)
- 使用场景:
- 有些网站有换肤的功能,这时候就可以将换肤的信息存储在本地的 LocalStorage 中,当需要换肤的时候,直接操作 LocalStorage 即可
- 在网站中的用户浏览信息也会存储在 LocalStorage 中,还有网站的一些不常变动的个人信息等也可以存储在本地的 LocalStorage 中
3. SessionStorage
SessionStorage 和 LocalStorage 都是在 HTML5 才提出来的存储方案,SessionStorage 主要用于临时保存同一窗口(或标签页)的数据,刷新页面时不会删除,关闭窗口或标签页之后将会删除这些数据
- 与 LocalStorage对比:
- SessionStorage 和 LocalStorage 都在本地进行数据存储
- SessionStorage 也有同源策略的限制,但是 SessionStorage 有一条更加严格的限制,SessionStorage 只有在同一浏览器的同一窗口下才能够共享
- LocalStorage 和 SessionStorage 都不能被爬虫爬取
- 常用API:
// 保存数据到 sessionStorage
sessionStorage.setItem('key', 'value');
// 从 sessionStorage 获取数据
let data = sessionStorage.getItem('key');
// 从 sessionStorage 删除保存的数据
sessionStorage.removeItem('key');
// 从 sessionStorage 删除所有保存的数据
sessionStorage.clear();
// 获取某个索引的Key
sessionStorage.key(index)
- 使用场景:
- 由于 SessionStorage 具有时效性,所以可以用来存储一些网站的游客登录的信息,还有临时的浏览记录的信息。当关闭网站之后,这些信息也就随之消除了
2. Cookie的字段及作用
Name
:Cookie 的名称Value
:Cookie 的值,对于认证 Cookie,value 值包括 web 服务器所提供的访问令牌Size
:Cookie的大小Path
:可以访问此 Cookie 的页面路径。 比如 domain 是abc.com
,path 是/test
,那么只有/test
路径下的页面可以读取此CookieSecure
: 指定是否使用 HTTPS 安全协议发送 Cookie。使用 HTTPS 安全协议,可以保护 Cookie 在浏览器和 Web 服务器间的传输过程中不被窃取和篡改。该方法也可用于Web站点的身份鉴别,即在 HTTPS 的连接建立阶段,浏览器会检查 Web 网站的 SSL 证书的有效性。但是基于兼容性的原因(比如有些网站使用自签署的证书)在检测到 SSL 证书无效时,浏览器并不会立即终止用户的连接请求,而是显示安全风险信息,用户仍可以选择继续访问该站点Domain
:可以访问该 Cookie 的域名,Cookie 机制并未遵循严格的同源策略,允许一个子域可以设置或获取其父域的 Cookie。当需要实现单点登录方案时,Cookie 的上述特性非常有用,然而也增加了 Cookie 受攻击的危险,比如攻击者可以借此发动会话定置攻击。因而,浏览器禁止在 Domain 属性中设置.org
、.com
等通用顶级域名、以及在国家及地区顶级域下注册的二级域名,以减小攻击发生的范围HTTP
:该字段包含HTTPOnly
属性 ,该属性用来设置 Cookie 能否通过脚本来访问,默认为空,即可以通过脚本访问。在客户端是不能通过 js 代码去设置一个 httpOnly 类型的 Cookie 的,这种类型的 Cookie 只能通过服务端来设置。该属性用于防止客户端脚本通过document.cookie
属性访问 Cookie,有助于保护 Cookie 不被跨站脚本攻击窃取或篡改。但是,HTTPOnly 的应用仍存在局限性,一些浏览器可以阻止客户端脚本对 Cookie 的读操作,但允许写操作;此外大多数浏览器仍允许通过 XMLHTTP 对象读取 HTTP 响应中的 Set-Cookie 头Expires/Max-size
:此 Cookie 的超时时间。若设置其值为一个时间,那么当到达此时间后,此 Cookie 失效。不设置的话默认值是 Session,意思是 Cookie 会和 session 一起失效。当浏览器关闭(不是浏览器标签页,而是整个浏览器)后,此 Cookie 失效
总结:服务器端可以使用 Set-Cookie 的响应头部来配置 Cookie 信息。一条 Cookie 包括了 5 个属性值 expires、domain、path、secure、HttpOnly
expires
:指定了 Cookie 失效的时间domain
:是域名path
:是路径。domain 和 path 一起限制了 Cookie 能够被哪些 url 访问secure
:规定了 Cookie 只能在确保安全的情况下传输HttpOnly
:规定了这个 Cookie 只能被服务器访问,不能使用 js 脚本访问
3. Ck、LS、SS区别
- Cookie
- 其实最开始是服务器端用于记录用户状态的一种方式,由服务器设置,在客户端存储,然后每次发起同源请求时,发送给服务器端。cookie 最多能存储 4k 数据,它的生存时间由 expires 属性指定,并且 cookie 只能被同源的页面访问共享
- sessionStorage
- html5 提供的一种浏览器本地存储的方法,它借鉴了服务器端 session 的概念,代表的是一次会话中所保存的数据。它一般能够存储 5M 或者更大的数据,它在当前窗口关闭后就失效了,并且 sessionStorage 只能被同一个窗口的同源页面所访问共享
- localStorage
- html5 提供的一种浏览器本地存储的方法,它一般也能够存储 5M 或者更大的数据。它和 sessionStorage 不同的是,除非手动删除它,否则它不会失效,并且 localStorage 也只能被同源页面所访问共享
上面几种方式都是存储少量数据时的存储方式,当需要在本地存储大量数据时,可以使用浏览器的 indexDB 这是浏览器提供的一种本地的数据库存储机制。它不是关系型数据库,它内部采用对象仓库的形式存储数据,它更接近 NoSQL 数据库
4. 前端储存的⽅式?
- cookies
- 在 HTML5 标准前本地储存的主要⽅式,优点是兼容性好,请求头⾃带 cookie ⽅便,缺点是⼤⼩只有 4k,⾃动请求头加⼊ cookie 浪费流量,每个 domain 限制 20 个 cookie,使⽤起来麻烦,需要⾃⾏封装
- localStorage
- HTML5 加⼊的以键值对(Key-Value)为标准的⽅式,优点是操作⽅便,永久性储存(除⾮⼿动删除),⼤⼩为 5M,兼容 IE8+
- sessionStorage
- 与 localStorage 基本类似,区别是 sessionStorage 当⻚⾯关闭后会被清理,⽽且与 cookie、localStorage 不同,他不能在所有同源窗⼝中共享,是会话级别的储存⽅式
- Web SQL
- 2010 年被 W3C 废弃的本地数据库数据存储⽅案,但是主流浏览器(⽕狐除外)都已经有了相关的实现,web sql 类似于 SQLite,是真正意义上的关系型数据库,⽤ sql 进⾏操作,当我们⽤ JS 时要进⾏转换,较为繁琐
- IndexedDB
- 是被正式纳⼊ HTML5 标准的数据库储存⽅案,它是 NoSQL 数据库,⽤键值对进⾏储存,可以进⾏快速读取操作,⾮常适合 web 场景,同时⽤ JS 进⾏操作会⾮常便
5. IndexedDB特点
- 键值对储存
- IndexedDB 内部采用对象仓库(object store)存放数据。所有类型的数据都可以直接存入,包括 JS 对象。对象仓库中,数据以 "键值对" 的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误
- 异步
- IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现
- 支持事务
- IndexedDB 支持事务(transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况
- 同源限制
- IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库
- 储存空间大
- IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB,甚至没有上限
- 支持二进制储存
- IndexedDB 不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer 对象和 Blob 对象)
7. 浏览器同源策略
1. 同源策略
跨域问题其实就是浏览器的同源策略造成的
- 同源策略限制了从同一个源加载的文档或脚本如何与另一个源的资源进行交互。这是浏览器的一个用于隔离潜在恶意文件的重要的安全机制
- 同源策略:protocol(协议)、domain(域名)、port(端口)三者必须一致
URL | 是否跨域 | 原因 |
---|---|---|
http://store.company.com/dir/page.html | 同源 | 完全相同 |
http://store.company.com/dir/inner/another.html | 同源 | 只有路径不同 |
https://store.company.com/secure.html | 跨域 | 协议不同 |
http://store.company.com:81/dir/etc.html | 跨域 | 端口不同 |
http://news.company.com/dir/other.html | 跨域 | 主机不同 |
同源政策主要限制了三个方面:
- 当前域下的 js 脚本不能够访问其他域下的 cookie、localStorage、indexDB
- 当前域下的 js 脚本不能够操作访问操作其他域下的 DOM
- 当前域下 ajax 无法发送跨域请求
目的:主要是为了保证用户的信息安全,它只是对 js 脚本的一种限制,并不是对浏览器的限制,对于一般的 img、或 script 脚本请求都不会有跨域的限制,这是因为这些操作都不会通过响应结果来进行可能出现安全问题的操作
2. 如何解决跨越问题
1. CORS
MDN 对于 CORS 的定义:
- 跨域资源共享(CORS)是一种机制,它使用额外的 HTTP 头来告诉浏览器,让运行在一个 origin(domain)上的 Web 应用被准许访问来自不同源服务器上的指定的资源。当一个资源从与该资源本身所在的服务器不同的域、协议、端口请求一个资源时,资源会发起一个跨域 HTTP 请求
CORS 需要浏览器和服务器同时支持,整个 CORS 过程都是浏览器完成的,无需用户参与。因此实现 CORS的关键就是服务器,只要服务器实现了CORS请求,就可以跨源通信了
浏览器将 CORS 分为简单请求和非简单请求:
简单请求不会触发 CORS 预检请求。若该请求满足以下两个条件,就可以看作是简单请求:
- 请求方法是以下三种方法之一
- HEAD
- GET
- POST
- HTTP的头信息不超出以下几种字段
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限于三个值
application/x-www-form-urlencoded
、multipart/form-data
、text/plain
若不满足以上条件,就属于非简单请求了
1. 简单请求过程
对于简单请求,浏览器会直接发出 CORS 请求,它会在请求的头信息中增加一个 Orign 字段,该字段用来说明本次请求来自哪个源(协议 + 端口 + 域名),服务器会根据这个值来决定是否同意这次请求
- 如果 Orign 指定的域名在许可范围之内,服务器返回的响应就会多出以下信息头
- 如果 Orign 指定的域名不在许可范围之内,服务器会返回一个正常的 HTTP 回应,浏览器发现没有上面的
Access-Control-Allow-Origin
头部信息,就知道出错了。这个错误无法通过状态码识别,因为返回的状态码可能是 200
Access-Control-Allow-Origin: http://api.bob.com # 和Orign一直
Access-Control-Allow-Credentials: true # 表示是否允许发送Cookie
Access-Control-Expose-Headers: FooBar # 指定返回其他字段的值
Content-Type: text/html; charset=utf-8 # 表示文档类型
- 在简单请求中,在服务器内,至少需要设置字段:
Access-Control-Allow-Origin
2. 非简单请求过程
- 非简单请求是对服务器有特殊要求的请求(eg:DELETE 或 PUT 等)。非简单请求的 CORS 请求会在正式通信之前进行一次 HTTP 查询请求,称为预检请求
- 预检请求使用的请求方法是 OPTIONS,表示这个请求是来询问的。他的头信息中的关键字段是 Orign,表示请求来自哪个源。除此之外,头信息中还包括两个字段:
Access-Control-Request-Method
:该字段是必须的,用来列出浏览器的 CORS 请求会用到哪些 HTTP 方法Access-Control-Request-Headers
: 该字段是一个逗号分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段
- 浏览器会询问服务器,当前所在的网页是否在服务器允许访问的范围内,以及可以使用哪些 HTTP 请求方式和头信息字段,只有得到肯定的回复,才会进行正式的 HTTP 请求,否则就会报错
- 服务器在收到浏览器的预检请求之后,会根据头信息的三个字段来进行判断,只要服务器通过了预检请求,在以后每次的 CORS 请求都会自带一个 Origin 头信息字段。服务器的回应,也都会有一个
Access-Control-Allow-Origin
头信息字段,如果没有,就是不同意这个预检请求,就会报错
服务器回应的 CORS 的字段如下:
Access-Control-Allow-Origin: http://api.bob.com # 允许跨域的源地址
Access-Control-Allow-Methods: GET, POST, PUT # 服务器支持的所有跨域请求的方法
Access-Control-Allow-Headers: X-Custom-Header # 服务器支持的所有头信息字段
Access-Control-Allow-Credentials: true # 表示是否允许发送Cookie
Access-Control-Max-Age: 1728000 # 用来指定本次预检请求的有效期,单位为秒
在非简单请求中,至少需要设置以下字段:
'Access-Control-Allow-Origin'
'Access-Control-Allow-Methods'
'Access-Control-Allow-Headers'
3. 减少OPTIONS请求次数
OPTIONS 请求次数过多就会损耗页面加载的性能,降低用户体验度。所以尽量要减少 OPTIONS 请求次数,可以后端在请求的返回头部添加:Access-Control-Max-Age:number
。它表示预检请求的返回结果可以被缓存多久,单位是秒。该字段只对完全一样的 URL 的缓存设置生效,所以设置了缓存时间,在这个时间范围内,再次发送请求就不需要进行预检请求了
4. CORS中Cookie相关问题
在CORS 请求中,如果想要传递 Cookie,就要满足以下三个条件:
- 在请求中设置
withCredentials
- 默认情况下在跨域请求,浏览器是不带 cookie 的。可以通过设置 withCredentials 来进行传递 cookie
// 原生 xml 的设置方式
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
// axios 设置方式
axios.defaults.withCredentials = true;
Access-Control-Allow-Credentials
设置为 trueAccess-Control-Allow-Origin
设置为非*****
2. JSONP
jsonp 的原理就是利用 <script>
标签没有跨域限制,通过 <script>
标签 src 属性,发送带有 callback 参数的 GET 请求,服务端将接口返回数据拼凑到 callback 函数中,返回给浏览器,浏览器解析执行,从而前端拿到 callback 函数返回的数据
- 原生 JS 实现:
<script>
var script = document.createElement('script');
script.type = 'text/JS';
// 传参一个回调函数名给后端,方便后端返回时执行这个在前端定义的回调函数
script.src = 'http://www.domain2.com:8080/login?user=admin&callback=handleCallback';
document.head.appendChild(script);
// 回调执行函数
function handleCallback(res) {
alert(JSON.stringify(res));
}
</script>
服务端返回如下(返回时即执行全局函数):
handleCallback({"success": true, "user": "admin"})
- Vue axios 实现:
this.$http = axios;
this.$http.jsonp('http://www.domain2.com:8080/login', {
params: {},
jsonp: 'handleCallback'
}).then((res) => {
console.log(res);
})
后端 node.js 代码:
var querystring = require('querystring');
var http = require('http');
var server = http.createServer();
server.on('request', function(req, res) {
var params = querystring.parse(req.url.split('?')[1]);
var fn = params.callback;
// jsonp返回设置
res.writeHead(200, { 'Content-Type': 'text/JS' });
res.write(fn + '(' + JSON.stringify(params) + ')');
res.end();
});
server.listen('8080');
console.log('Server is running at port 8080...');
缺点:
- 具有局限性, 仅支持get方法
- 不安全,可能会遭受XSS攻击
3. postMessage跨域
postMessage 是 HTML5 中 XMLHttpRequest Level 2 中的API,且是为数不多可以跨域操作的 window 属性之一,它可用于解决以下方面的问题:
- 页面和其打开的新窗口的数据传递
- 多窗口之间消息传递
- 页面与嵌套的 iframe 消息传递
- 上面三个场景的跨域数据传递
用法:postMessage(data, origin)
方法接受两个参数:
- data: html 5 规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用
JSON.stringify()
序列化 - origin:协议 + 主机 + 端口号,也可以设置为
*
,表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为/
a.html
:domain1.com/a.html
<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>
var iframe = document.getElementById('iframe');
iframe.onload = function() {
var data = {
name: 'aym'
};
// 向domain2传送跨域数据
iframe.contentWindow.postMessage(JSON.stringify(data), 'http://www.domain2.com');
};
// 接受domain2返回数据
window.addEventListener('message', function(e) {
alert('data from domain2 ---> ' + e.data);
}, false);
</script>
b.html
:domain2.com/b.html
<script>
// 接收domain1的数据
window.addEventListener('message', function(e) {
alert('data from domain1 ---> ' + e.data);
var data = JSON.parse(e.data);
if (data) {
data.number = 16;
// 处理后再发回domain1
window.parent.postMessage(JSON.stringify(data), 'http://www.domain1.com');
}
}, false);
</script>
4. Nginx代理跨域
Nginx 代理跨域,实质和 CORS 跨域原理一样,通过配置文件设置请求响应头 Access-Control-Allow-Origin…
等字段
- Nginx 配置解决 iconfont 跨域
- 浏览器跨域访问 js、css、img 等常规静态资源被同源策略许可,但 iconfont 字体文件(eot|otf|ttf|woff|svg)例外,此时可在 Nginx 的静态资源服务器中加入以下配置
location / {
add_header Access-Control-Allow-Origin *;
}
- Nginx 反向代理接口跨域
- 跨域问题:同源策略仅是针对浏览器的安全策略。服务器端调用 HTTP 接口只是使用 HTTP 协议,不需要同源策略,也就不存在跨域问题
- 实现思路:通过 Nginx 配置一个代理服务器域名与 domain1 相同,端口不同)做跳板机,反向代理访问 domain2 接口,并且可以顺便修改 cookie 中 domain 信息,方便当前域 cookie 写入,实现跨域访问
# proxy服务器
server {
listen 81;
server_name www.domain1.com;
location / {
proxy_pass http://www.domain2.com:8080; # 反向代理
proxy_cookie_domain www.domain2.com www.domain1.com; # 修改 cookie 里域名
index index.html index.htm;
# 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
add_header Access-Control-Allow-Origin http://www.domain1.com; # 当前端只跨域不带cookie时,可为*
add_header Access-Control-Allow-Credentials true;
}
}
5. nodejs中间件代理跨域
node 中间件实现跨域代理,原理大致与 Nginx 相同,都是通过启一个代理服务器,实现数据的转发,也可以通过设置 cookieDomainRewrite 参数修改响应头中 cookie 中域名,实现当前域的 cookie 写入,方便接口登录认证
1. 非vue框架的跨域
使用 node + express + http-proxy-middleware
搭建一个 proxy 服务器
- 前端代码:
var xhr = new XMLHttpRequest();
// 前端开关:浏览器是否读写cookie
xhr.withCredentials = true;
// 访问 http-proxy-middleware 代理服务器
xhr.open('get', 'http://www.domain1.com:3000/login?user=admin', true);
xhr.send();
- 中间件服务器代码:
var express = require('express');
var proxy = require('http-proxy-middleware');
var app = express();
app.use('/', proxy({
// 代理跨域目标接口
target: 'http://www.domain2.com:8080',
changeOrigin: true,
// 修改响应头信息,实现跨域并允许带 cookie
onProxyRes: function(proxyRes, req, res) {
res.header('Access-Control-Allow-Origin', 'http://www.domain1.com');
res.header('Access-Control-Allow-Credentials', 'true');
},
// 修改响应信息中的 cookie 域名
cookieDomainRewrite: 'www.domain1.com' // 可以为 false,表示不修改
}));
app.listen(3000);
console.log('Proxy server is listen at port 3000...');
2. vue框架的跨域
node + vue + webpack + webpack-dev-server
搭建的项目,跨域请求接口,直接修改webpack.config.js
配置。开发环境下,vue 渲染服务和接口代理服务都是 webpack-dev-server 同一个,所以页面与代理接口之间不再跨域webpack.config.js
部分配置:
module.exports = {
entry: {},
module: {},
...
devServer: {
historyApiFallback: true,
proxy: [{
context: '/login',
target: 'http://www.domain2.com:8080', // 代理跨域目标接口
changeOrigin: true,
secure: false, // 当代理某些https服务报错时用
cookieDomainRewrite: 'www.domain1.com' // 可以为 false,表示不修改
}],
noInfo: true
}
}
6. document.domain+iframe
- 此方案仅限主域相同,子域不同的跨域应用场景
- 原理:两个页面都通过 js 强制设置
document.domain
为基础主域,就实现了同域
- 父窗口:
domain.com/a.html
<iframe id="iframe" src="http://child.domain.com/b.html"></iframe>
<script>
document.domain = 'domain.com';
var user = 'admin';
</script>
- 子窗口:
child.domain.com/a.html
<script>
document.domain = 'domain.com';
// 获取父窗口中变量
console.log('get js data from parent ---> ' + window.parent.user);
</script>
7. location.hash+iframe
- 原理:a 欲与 b 跨域相互通信,通过中间页 c 来实现。 三个页面,不同域之间利用 iframe 的 location.hash 传值,相同域之间直接 js 访问来通信
- 具体实现:
A 域:a.html
->B域:b.html
->A域:c.html
,a 与 b 不同域只能通过 hash 值单向通信,b 与 c 也不同域也只能单向通信,但 c 与 a 同域,所以 c 可通过 parent.parent 访问 a 页面所有对象
a.html
:domain1.com/a.html
<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>
var iframe = document.getElementById('iframe');
// 向 b.html 传 hash 值
setTimeout(function() {
iframe.src = iframe.src + '#user=admin';
}, 1000);
// 开放给同域 c.html 的回调方法
function onCallback(res) {
alert('data from c.html ---> ' + res);
}
</script>
b.html
:domain2.com/b.html
<iframe id="iframe" src="http://www.domain1.com/c.html" style="display:none;"></iframe>
<script>
var iframe = document.getElementById('iframe');
// 监听 a.html 传来的 hash 值,再传给 c.html
window.onhashchange = function () {
iframe.src = iframe.src + location.hash;
};
</script>
c.html
:http://www.domain1.com/c.html
<script>
// 监听 b.html 传来的 hash 值
window.onhashchange = function () {
// 再通过操作同域a.html的js回调,将结果传回
window.parent.parent.onCallback('hello: ' + location.hash.replace('#user=', ''));
};
</script>
8. window.name+iframe
window.name
属性的独特之处:name 值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值(2MB)
a.html
:domain1.com/a.html
var proxy = function(url, callback) {
var state = 0;
var iframe = document.createElement('iframe');
// 加载跨域页面
iframe.src = url;
// onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
iframe.onload = function() {
if (state === 1) {
// 第2次onload(同域proxy页)成功后,读取同域window.name中数据
callback(iframe.contentWindow.name);
destoryFrame();
} else if (state === 0) {
// 第1次onload(跨域页)成功后,切换到同域代理页面
iframe.contentWindow.location = 'http://www.domain1.com/proxy.html';
state = 1;
}
};
document.body.appendChild(iframe);
// 获取数据以后销毁这个iframe,释放内存;这也保证了安全(不被其他域frame js访问)
function destoryFrame() {
iframe.contentWindow.document.write('');
iframe.contentWindow.close();
document.body.removeChild(iframe);
}
};
// 请求跨域b页面数据
proxy('http://www.domain2.com/b.html', function(data){
alert(data);
});
proxy.html
:domain1.com/proxy.html
- 中间代理页,与a.html同域,内容为空即可
b.html
:domain2.com/b.html
<script>
window.name = 'This is domain2 data!';
</script>
通过 iframe 的 src 属性由外域转向本地域,跨域数据即由 iframe 的 window.name 从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作
9. WebSocket协议
WebSocket protocol 是 HTML5 一种新的协议。它实现了浏览器与服务器全双工通信,同时允许跨域通讯,是 server push 技术的一种很好的实现
- 原生 WebSocket API 使用起来不太方便,使用 Socket.io,它很好地封装了webSocket 接口,提供了更简单、灵活的接口,也对不支持 webSocket 的浏览器提供了向下兼容
- 前端代码
<div>user input:<input type="text"></div>
<script src="https://cdn.bootcss.com/socket.io/2.2.0/socket.io.js"></script>
<script>
var socket = io('http://www.domain2.com:8080');
// 连接成功处理
socket.on('connect', function() {
// 监听服务端消息
socket.on('message', function(msg) {
console.log('data from server: ---> ' + msg);
});
// 监听服务端关闭
socket.on('disconnect', function() {
console.log('Server socket has closed.');
});
});
document.getElementsByTagName('input')[0].onblur = function() {
socket.send(this.value);
};
</script>
- Nodejs socket 后台
var http = require('http');
var socket = require('socket.io');
// 启 http 服务
var server = http.createServer(function(req, res) {
res.writeHead(200, {
'Content-type': 'text/html'
});
res.end();
});
server.listen('8080');
console.log('Server is running at port 8080...');
// 监听 socket 连接
socket.listen(server).on('connection', function(client) {
// 接收信息
client.on('message', function(msg) {
client.send('hello:' + msg);
console.log('data from client: ---> ' + msg);
});
// 断开处理
client.on('disconnect', function() {
console.log('Client socket has closed.');
});
});
3. 正向代理、反向代理
- 正向代理
- 客户端想获得一个服务器的数据,但是因为种种原因无法直接获取。于是客户端设置了一个代理服务器,并且指定目标服务器,之后代理服务器向目标服务器转交请求并将获得的内容发送给客户端。这样本质上起到了对真实服务器隐藏真实客户端的目的。实现正向代理需要修改客户端,比如修改浏览器配置
- 反向代理
- 服务器为了能够将工作负载分不到多个服务器来提高网站性能(负载均衡)等目的,当其受到请求后,会首先根据转发规则来确定请求应该被转发到哪个服务器上,然后将请求转发到对应的真实服务器上。本质上起到了对客户端隐藏真实服务器的作用
- 一般使用反向代理后,需要通过修改 DNS 让域名解析到代理服务器 IP,这时浏览器无法察觉到真正服务器的存在,当然也就不需要修改配置了
正向代理和反向代理的结构是一样的,都是 client-proxy-server 结构,区别在于中间这个 proxy 是哪一方设置的
- 正向代理中,proxy 是 client 设置的,用来隐藏 client
- 在反向代理中,proxy 是 server 设置的,用来隐藏 server
4. Nginx工作原理
Nginx 是一款轻量级的 Web 服务器,也可以用于反向代理、负载平衡、HTTP 缓存等。Nginx 使用异步事件驱动的方法来处理请求,是一款面向性能设计的 HTTP 服务器
- 传统的 Web 服务器,Apache 是 process-based 模型的
- Nginx 是基于 event-driven 模型的。带给了 Nginx 性能上的优势。Nginx 架构的最顶层是一个 master process,这个 master process 用于产生其他的 worker process,这一点和 Apache 非常像,但是 Nginx 的 worker process 可以同时处理大量的HTTP请求,而每个 Apache process 只能处理一个
8. 浏览器事件机制
1. 事件?事件模型?
事件是用户操作网页时发生的交互动作(eg:click/move),事件除了用户触发的动作外,还可以是文档加载,窗口滚动、大小调整。事件被封装成一个 event 对象,包含了该事件发生时的所有相关信息(event的属性)以及可以对事件进行的操作(event 的方法)
现代浏览器一共有三种事件模型:
- DOM0 级事件模型
- 这种模型不会传播,所以没有事件流的概念,但是现在有的浏览器支持以冒泡的方式实现,它可以在网页中直接定义监听函数,也可以通过 js 属性来指定监听函数。所有浏览器都兼容这种方式。直接在 dom 对象上注册事件名称,就是 DOM0 写法
- IE 事件模型
- 在该事件模型中,一次事件共有两个过程,事件处理阶段和事件冒泡阶段
- 事件处理阶段会首先执行目标元素绑定的监听事件
- 然后是事件冒泡阶段,冒泡指的是事件从目标元素冒泡到 document,依次检查经过的节点是否绑定了事件监听函数,如果有则执行。这种模型通过 attachEvent 来添加监听函数,可以添加多个监听函数,会按顺序依次执行
- 在该事件模型中,一次事件共有两个过程,事件处理阶段和事件冒泡阶段
- DOM2 级事件模型
- 在该事件模型中,一次事件共有三个过程,第一个过程是事件捕获阶段。捕获指的是事件从 document 一直向下传播到目标元素,依次检查经过的节点是否绑定了事件监听函数,如果有则执行。后面两个阶段和 IE 事件模型的两个阶段相同。这种事件模型,事件绑定的函数是 addEventListener,其中第三个参数可以指定事件是否在捕获阶段执行
2. 如何阻止事件冒泡
- 普通浏览器使用:
event.stopPropagation()
- IE浏览器使用:
event.cancelBubble = true;
3. 对事件委托的理解
1. 概念
事件委托(事件代理):本质上是利用了浏览器事件冒泡机制。因为事件在冒泡过程中会上传到父节点,父节点可以通过事件对象获取到目标节点,因此可以把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件
- 使用事件委托可以不必要为每一个子元素都绑定一个监听事件,这样减少了内存消耗
- 还可以实现事件的动态绑定。eg:新增了一个子节点,并不需要单独地为它添加一个监听事件,它绑定的事件会交给父元素中的监听函数来处理
2. 特点
- 减少内存消耗
- 如果有一个列表,列表之中有大量的列表项,需要在点击列表项的时候响应一个事件
- 如果给每个列表项一一都绑定一个函数,那对于内存消耗是非常大的,效率上需要消耗很多性能。因此,比较好的方法就是把这个点击事件绑定到他的父层,也就是 ul 上,然后在执行事件时再去匹配判断目标元素,所以事件委托可以减少大量的内存消耗,节约效率
<ul id="list">
<li>item 1</li>
<li>item 2</li>
<li>item 3</li>
...
<li>item n</li>
</ul>
- 动态绑定事件
- 在很多时候,需要通过 AJAX 或用户操作动态的增加、删除列表项元素,那么在每一次改变时,都需要重新给元素绑定、解绑事件
- 如果用了事件委托就没有这种麻烦了,因为事件是绑定在父层的,和目标元素的增减是没有关系的,执行到目标元素是在真正响应执行事件函数的过程中去匹配的,所以使用事件在动态绑定事件的情况下是可以减少很多重复工作的
// 来实现把 #list 下的 li 元素的事件代理委托到它的父层元素也就是 #list 上:
// 给父层元素绑定事件
document.getElementById('list').addEventListener('click', function (e) {
// 兼容性处理
var event = e || window.event;
var target = event.target || event.srcElement;
// 判断是否匹配目标元素
if (target.nodeName.toLocaleLowerCase === 'li') {
console.log('the content is: ', target.innerHTML);
}
});
- 在上述代码中, target 元素则是在
#list
元素之下具体被点击的元素,然后通过判断 target 的一些属性(eg:nodeName,id 等)可以更精确地匹配到某一类#list li
元素之上
3. 局限性
focus
、blur
之类的事件没有事件冒泡机制,所以无法实现事件委托mousemove
、mouseout
虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合事件委托
事件委托会影响页面性能,主要影响因素有:
- 元素中,绑定事件委托的次数
- 点击的最底层元素,到绑定事件元素之间的
DOM
层数
在必须使用事件委托的地方,可以进行如下处理:
- 只在必须的地方,使用事件委托(eg:Ajax 的局部刷新区域)
- 尽量的减少绑定的层级,不在
body
元素上,进行绑定 - 减少绑定的次数,如果可以,那么把多个事件的绑定,合并到一次事件委托中去,由这个事件委托的回调,来进行分发
4. 事件委托使用场景
场景:给页面的所有的 a 标签添加 click 事件
document.addEventListener("click", function (e) {
if (e.target.nodeName == "A")
console.log("a");
}, false);
- 这些 a 标签可能包含一些像 span、img 等元素,如果点击到了这些 a 标签中的元素,就不会触发 click 事件,因为事件绑定上在 a 标签元素上,而触发这些内部的元素时,
e.target
指向的是触发 click 事件的元素(span、img等其他元素) - 这种情况下就可以使用事件委托来处理,将事件绑定在 a 标签的内部元素上,当点击它的时候,就会逐级向上查找,直到找到 a 标签为止
document.addEventListener("click", function (e) {
var node = e.target;
while (node.parentNode.nodeName != "BODY") {
if (node.nodeName == "A") {
console.log("a");
break;
}
node = node.parentNode;
}
}, false);
5. 同步、异步区别
- 同步:
- 当一个进程在执行某个请求时,如果这个请求需要等待一段时间才能返回,那么这个进程会一直等待下去,直到消息返回为止再继续向下执行
- 异步:
- 当一个进程在执行某个请求时,如果这个请求需要等待一段时间才能返回,这个时候进程会继续往下执行,不会阻塞等待消息的返回,当消息返回时系统再通知进程进行处理
6. 事件循环的理解
- 因为 js 是单线程运行的,在代码执行时,通过将不同函数的执行上下文压入执行栈中,来保证代码的有序执行。在执行同步代码时,如果遇到异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当异步事件执行完毕后,再将异步事件对应的回调加入到一个任务队列中等待执行
- 任务队列分为宏任务队列、微任务队列,当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务队列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行。当微任务队列中的任务都执行完成后再去执行宏任务队列中的任务
Event Loop 执行顺序:
- 首先执行同步代码,这属于宏任务
- 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
- 执行所有微任务
- 当执行完所有微任务后,如有必要会渲染页面
- 然后开始下一轮 Event Loop,执行宏任务中的异步代码
7. 宏任务和微任务
- 微任务包括:
- promise 的回调、node 中的 process.nextTick 、对 Dom 变化监听的 MutationObserver
- 宏任务包括:
- script 脚本的执行、setTimeout ,setInterval ,setImmediate 一类的定时事件,还有如 I/O 操作、UI 渲染等
8. 什么是执行栈
- 把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则
当开始执行 JS 代码时,根据先进后出的原则,后执行的函数会先弹出栈,可以看到,foo
函数后执行,当执行完毕后就从栈中弹出了
平时在开发中,可以在报错中找到执行栈的痕迹:
function foo() {
throw new Error('error')
}
function bar() {
foo()
}
bar()
- 可以看到报错在
foo
函数,foo
函数又是在bar
函数中调用的
当使用递归时,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题
function bar() {
bar()
}
bar()
9. Node中的Event Loop
Node 中的 Event Loop 和浏览器中的有什么区别?process.nextTick 执行顺序?
Node 中的 Event Loop 和浏览器中的是完全不相同的东西
Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或执行的回调函数数量到达系统设定的阈值,就会进入下一阶段
- Timers(计时器阶段)
- 初次进入事件循环,会从计时器阶段开始。此阶段会判断是否存在过期的计时器回调(包含 setTimeout 和 setInterval),如果存在则会执行所有过期的计时器回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Pending callbacks 阶段
- Pending callbacks
- 执行推迟到下一个循环迭代的 I/O 回调(系统调用相关的回调)
- Idle/Prepare
- 仅供内部使用
- Poll(轮询阶段)
- 当回调队列不为空时:会执行回调,若回调中触发了相应的微任务,这里的微任务执行时机和其他地方有所不同,不会等到所有回调执行完毕后才执行,而是针对每一个回调执行完毕后,就执行相应微任务。执行完所有的回调后
- 当回调队列为空时(没有回调或所有回调执行完毕):但如果存在有计时器(setTimeout、setInterval、setImmediate)没有执行,会结束轮询阶段,进入 Check 阶段。否则会阻塞并等待任何正在执行的 I/O 操作完成,并马上执行相应的回调,直到所有回调执行完毕
- Check(查询阶段)
- 会检查是否存在 setImmediate 相关的回调,如果存在则执行所有回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Close callbacks 阶段
- Close callbacks
- 执行一些关闭回调。eg:
socket.on('close', ...)
等
- 执行一些关闭回调。eg:
// 首先在有些情况下,定时器的执行顺序其实是**随机**的
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
setTimeout
可能执行在前,也可能执行在后
- 首先
setTimeout(fn, 0) === setTimeout(fn, 1)
,这是由源码决定的 - 进入事件循环也是需要成本的
- 如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行
setTimeout
回调 - 如果准备时间花费小于 1ms,那么就是
setImmediate
回调先执行了
- 如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行
在某些情况下,他们的执行顺序一定是固定的:
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
- 在上述代码中,
setImmediate
永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在setImmediate
回调,所以就直接跳转到 check 阶段去执行回调了 - 上面都是 macrotask 的执行情况,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask
setTimeout(() => {
console.log('timer21')
}, 0)
Promise.resolve().then(function() {
console.log('promise1')
})
- 对于以上代码来说,其实和浏览器中的输出是一样的,microtask 永远执行在 macrotask 前面
最后来看 Node 中的 process.nextTick
,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function () {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
- 对于以上代码,永远都是先把 nextTick 全部打印出来
10. 事件触发的过程
事件触发有三个阶段:
window
往事件触发处传播,遇到注册的捕获事件会触发- 传播到事件触发处时触发注册的事件
- 从事件触发处往
window
传播,遇到注册的冒泡事件会触发
事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个 body
中的子节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行
// 以下会先打印冒泡然后是捕获
node.addEventListener(
'click',
event => {
console.log('冒泡')
},
false
)
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
通常使用 addEventListener
注册事件,该函数的第三个参数可以是布尔值,也可以是对象
- 对于布尔值
useCapture
参数来说,该参数默认值为false
,useCapture
决定了注册的事件是捕获事件还是冒泡事件 - 对于对象参数来说,可以使用以下几个属性:
capture
:布尔值,和useCapture
作用一样once
:布尔值,值为true
表示该回调只会调用一次,调用后会移除监听passive
:布尔值,表示永远不会调用preventDefault
- 一般来说,如果只希望事件只触发在目标上,这时候可以使用
stopPropagation
来阻止事件的进一步传播。通常认为stopPropagation
是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件 stopImmediatePropagation
同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件
node.addEventListener(
'click',
event => {
event.stopImmediatePropagation()
console.log('冒泡')
},
false
)
// 点击 node 只会执行上面的函数,该函数不会执行
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
9. 浏览器GC机制
1. V8的GC机制
V8 实现了准确式 GC,GC 算法采用了分代式垃圾回收机制。因此,V8 将内存(堆)分为新生代和老生代两部分
1. 新生代算法
- 新生代中的对象一般存活时间较短,使用 Scavenge GC 算法
- 在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了
2. 老生代算法
老生代中的对象一般存活时间较长且数量也多,分别是标记清除算法、标记压缩算法
对象出现在老生代空间中:
- 新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中
- To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中
// 老生代中的空间很复杂,有如下几个空间
enum AllocationSpace {
// TODO(v8:7464): Actually map this space's memory as read-only.
RO_SPACE, // 不变的对象空间
NEW_SPACE, // 新生代用于 GC 复制算法的空间
OLD_SPACE, // 老生代常驻对象空间
CODE_SPACE, // 老生代代码对象空间
MAP_SPACE, // 老生代 map 对象
LO_SPACE, // 老生代大空间对象
NEW_LO_SPACE, // 新生代大空间对象
FIRST_SPACE = RO_SPACE,
LAST_SPACE = NEW_LO_SPACE,
FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};
在老生代中,以下情况会先启动标记清除算法:
- 某一个空间没有分块的时候
- 空间中被对象超过一定限制
- 空间不能保证新生代中的对象移动到老生代中
- 在这个阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象
- 在标记大型堆内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题
- 为了解决这个问题,2011 年,V8 从
stop-the-world
标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况 - 但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行
清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象向一端移动,直到所有对象都移动完成然后清理掉不需要的内存
2. 哪些情况造成内存泄漏
- 由于使用未声明的变量,而意外的创建了一个全局变量,而使这个变量一直留在内存中无法被回收
- 设置了 setInterval 定时器,而忘记取消它,如果循环函数有对外部变量的引用的话,那么这个变量会被一直留在内存中,而无法被回收
- 获取一个 DOM 元素的引用,而后面这个元素被删除,由于一直保留了对这个元素的引用,所以它也无法被回收
- 是不合理的使用闭包,从而导致某些变量一直被留在内存当中