「查缺补漏」送你18道浏览器面试题

44,587 阅读27分钟

前言

想要成为一名合格的前端工程师,掌握相关浏览器的工作原理是必备的,这样子才会有一个完整知识体系,要是「能参透浏览器的工作原理,你就能解决80%的前端难题」。

这篇梳理的话,更多的是对浏览器工作原理篇的查缺补漏,对于一些没有涉及到的知识点,准备梳理梳理,也正好回顾之前梳理的内容。

感谢掘友的鼓励与支持🌹🌹🌹,往期文章都在最后梳理出来了(●'◡'●)


接下来以问题形式展开梳理

1. 常见的浏览器内核有哪些?

浏览器/RunTime 内核(渲染引擎) JavaScript 引擎
Chrome webkit->blink V8
FireFox Gecko SpiderMonkey
Safari Webkit JavaScriptCore
Edge EdgeHTML Chakra(for JavaScript)
IE Trident JScript(IE3.0-IE8.0)
Opera Presto->blink Linear A(4.0-6.1)/ Linear B(7.0-9.2)/ Futhark(9.5-10.2)/ Carakan(10.5-)
Node.js - V8

2. 浏览器的主要组成部分是什么?

  1. 用户界面 - 包括地址栏、前进/后退按钮、书签菜单等。
  2. 浏览器引擎 - 在用户界面和呈现引擎之间传送指令。
  3. 呈现引擎 - 负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。
  4. 网络 - 用于网络调用,比如 HTTP 请求。
  5. 用户界面后端 -用于绘制基本的窗口小部件,比如组合框和窗口。
  6. JavaScript 解释器- 用于解析和执行 JavaScript 代码。
  7. 数据存储 - 这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookie。新的 HTML 规范 (HTML5) 定义了“网络数据库”,这是一个完整(但是轻便)的浏览器内数据库。

值得注意的是,和大多数浏览器不同,Chrome 浏览器的每个标签页都分别对应一个呈现引擎实例。每个标签页都是一个独立的进程。


3. 为什么JavaScript是单线程的,与异步冲突吗

补充:JS中其实是没有线程概念的,所谓的单线程也只是相对于多线程而言。JS的设计初衷就没有考虑这些,针对JS这种不具备并行任务处理的特性,我们称之为“单线程”。

JS的单线程是指一个浏览器进程中只有一个JS的执行线程,同一时刻内只会有一段代码在执行。

举个通俗例子,假设JS支持多线程操作的话,JS可以操作DOM,那么一个线程在删除DOM,另外一个线程就在获取DOM数据,这样子明显不合理,这算是证明之一。

来看段代码👇

function foo() {
    console.log("first");
    setTimeout(( function(){
        console.log( 'second' );
    }),5);
}
 
for (var i = 0; i < 1000000; i++) {
    foo();
}

打印结果就是首先是很多个first,然后再是second。

异步机制是浏览器的两个或以上常驻线程共同完成的,举个例子,比如异步请求由两个常驻线程,JS执行线程和事件触发线程共同完成的。

  • JS执行线程发起异步请求(浏览器会开启一个HTTP请求线程来执行请求,这时JS的任务完成,继续执行线程队列中剩下任务)
  • 然后在未来的某一时刻事件触发线程监视到之前的发起的HTTP请求已完成,它就会把完成事件插入到JS执行队列的尾部等待JS处理

再比如定时器触发(settimeout和setinterval) 是由浏览器的定时器线程执行的定时计数,然后在定时时间把定时处理函数的执行请求插入到JS执行队列的尾端(所以用这两个函数的时候,实际的执行时间是大于或等于指定时间的,不保证能准确定时的)。

所以这么说,JS单线程与异步更多是浏览器行为,之间不冲突。


4. CSS加载会造成阻塞吗

先给出结论

  • CSS不会阻塞DOM解析,但会阻塞DOM渲染。
  • CSS会阻塞JS执行,并不会阻塞JS文件下载

先讲一讲CSSOM作用

  • 第一个是提供给 JavaScript 操作样式表的能力
  • 第二个是为布局树的合成提供基础的样式信息
  • 这个 CSSOM 体现在 DOM 中就是document.styleSheets。

由之前讲过的浏览器渲染流程我们可以看出:

DOM 和 CSSOM通常是并行构建的,所以CSS 加载不会阻塞 DOM 的解析

然而由于Render Tree 是依赖DOM Tree和 CSSOM Tree的,所以它必须等到两者都加载完毕后,完成相应的构建,才开始渲染,因此,CSS加载会阻塞DOM渲染

由于 JavaScript 是可操纵 DOM 和 css 样式 的,如果在修改这些元素属性同时渲染界面(即 JavaScript 线程和 UI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。

因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JavaScript 引擎为互斥的关系。

有个需要注意的点就是:

有时候JS需要等到CSS的下载,这是为什么呢?

仔细思考一下,其实这样做是有道理的,如果脚本的内容是获取元素的样式,宽高等CSS控制的属性,浏览器是需要计算的,也就是依赖于CSS。浏览器也无法感知脚本内容到底是什么,为避免样式获取,因而只好等前面所有的样式下载完后,再执行JS

JS文件下载和CSS文件下载是并行的,有时候CSS文件很大,所以JS需要等待。

因此,样式表会在后面的 js 执行前先加载执行完毕,所以css 会阻塞后面 js 的执行


5. 为什么JS会阻塞页面加载

先给出结论👇

  • JS阻塞DOM解析,也就会阻塞页面

这也是为什么说JS文件放在最下面的原因,那为什么会阻塞DOM解析呢

你可以这样子理解:

由于 JavaScript 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面(即 JavaScript 线程和 UI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。

因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JavaScript 引擎为互斥的关系。

当 JavaScript 引擎执行时 GUI 线程会被挂起,GUI 更新会被保存在一个队列中等到引擎线程空闲时立即被执行。

当浏览器在执行 JavaScript 程序的时候,GUI 渲染线程会被保存在一个队列中,直到 JS 程序执行完成,才会接着执行。

因此如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

另外,如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码


6. defer 和 async 的区别 ?

  • 两者都是异步去加载外部JS文件,不会阻塞DOM解析
  • Async是在外部JS加载完成后,浏览器空闲时,Load事件触发前执行,标记为async的脚本并不保证按照指定他们的先后顺序执行,该属性对于内联脚本无作用 (即没有src属性的脚本)。
  • defer是在JS加载完成后,整个文档解析完成后,触发 DOMContentLoaded 事件前执行,如果缺少 src 属性(即内嵌脚本),该属性不应被使用,因为这种情况下它不起作用

7. DOMContentLoaded 与 load 的区别 ?

  • DOMContentLoaded事件触发时:仅当DOM解析完成后,不包括样式表,图片等资源。
  • onload 事件触发时,页面上所有的 DOM,样式表,脚本,图片等资源已经加载完毕。

那么也就是先DOMContentLoaded -> load,那么在Jquery中,使用(document).load(callback)监听的就是load事件。

那我们可以聊一聊它们与async和defer区别

带async的脚本一定会在load事件之前执行,可能会在DOMContentLoaded之前或之后执行。

  • 情况1: HTML 还没有被解析完的时候,async脚本已经加载完了,那么 HTML 停止解析,去执行脚本,脚本执行完毕后触发DOMContentLoaded事件
  • 情况2: HTML 解析完了之后,async脚本才加载完,然后再执行脚本,那么在HTML解析完毕、async脚本还没加载完的时候就触发DOMContentLoaded事件

如果 script 标签中包含 defer,那么这一块脚本将不会影响 HTML 文档的解析,而是等到HTML 解析完成后才会执行。而 DOMContentLoaded 只有在 defer 脚本执行结束后才会被触发。

  • 情况1:HTML还没解析完成时,defer脚本已经加载完毕,那么defer脚本将等待HTML解析完成后再执行。defer脚本执行完毕后触发DOMContentLoaded事件
  • 情况2:HTML解析完成时,defer脚本还没加载完毕,那么defer脚本继续加载,加载完成后直接执行,执行完毕后触发DOMContentLoaded事件

8. 为什么CSS动画比JavaScript高效

我觉得这个题目说法上可能就是行不通,不能这么说,如果了解的话,都知道will-change只是一个优化的手段,使用JS改变transform也可以享受这个属性带来的变化,所以这个说法上有点不妥。

所以围绕这个问题展开话,更应该说建议推荐使用CSS动画,至于为什么呢,涉及的知识点大概就是重排重绘,合成,这方面的点,我在浏览器渲染流程中也提及了。

尽可能的避免重排和重绘,具体是哪些操作呢,如果非要去操作JS实现动画的话,有哪些优化的手段呢?

比如👇

  • 使用createDocumentFragment进行批量的 DOM 操作
  • 对于 resize、scroll 等进行防抖/节流处理。
  • rAF优化等等

剩下的东西就留给你们思考吧,希望我这是抛砖引玉吧(●'◡'●)


9. 能不能实现事件防抖和节流

函数节流(throttle)

节流的意思是让函数有节制地执行,而不是毫无节制的触发一次就执行一次。什么叫有节制呢?就是在一段时间内,只执行一次。

规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。

抓取一个关键的点:就是执行的时机。要做到控制执行的时机,我们可以通过一个开关,与定时器setTimeout结合完成。

  function throttle(fn, delay) {
            let flag = true,
                timer = null;
            return function (...args) {
                let context = this;
                if (!flag) return;
                flag = false;
                clearTimeout(timer)
                timer = setTimeout(() => {
                    fn.apply(context, args);
                    flag = true;
                }, delay);
            };
        };

函数防抖(debounce)

在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。

核心思想:每次事件触发都会删除原有定时器,建立新的定时器。通俗意思就是反复触发函数,只认最后一次,从最后一次开始计时。

代码:

  function debounce(fn, delay) {
            let timer = null
            return function (...args) {
                let context = this
                if(timer)   clearTimeout(timer)
                timer = setTimeout(function() {
                    fn.apply(context, args)
                },delay)
            }
        }

如何使用 debounce 和 throttle 以及常见的坑

自己造一个 debounce / throttle 的轮子看起来多么诱人,或者随便找个博文复制过来。我是建议直接使用 underscore 或 Lodash 。如果仅需要 _.debounce_.throttle 方法,可以使用 Lodash 的自定义构建工具,生成一个 2KB 的压缩库。使用以下的简单命令即可:

npm i -g lodash-cli
npm i -g lodash-clilodash-cli include=debounce,throttle

常见的坑是,不止一次地调用 _.debounce 方法:

// 错误
$(window).on('scroll', function() {
   _.debounce(doSomething, 300); 
});
// 正确
$(window).on('scroll', _.debounce(doSomething, 200));

debounce 方法保存到一个变量以后,就可以用它的私有方法 debounced_version.cancel(),lodash 和 underscore.js 都有效。

let debounced_version = _.debounce(doSomething, 200);

$(window).on('scroll', debounced_version);


// 如果需要的话debounced_version.cancel();

适合应用场景

防抖

  • search搜索,用户不断输入值时,用防抖来节约Ajax请求,也就是输入框事件。
  • window触发resize时,不断的调整浏览器窗口大小会不断的触发这个事件,用防抖来让其只触发一次

节流

  • 鼠标的点击事件,比如mousedown只触发一次
  • 监听滚动事件,比如是否滑到底部自动加载更多,用throttle判断
  • 比如游戏中发射子弹的频率(1秒发射一颗)

10. 谈一谈你对requestAnimationFrame(rAF)理解

正好跟节流有点关系,有点相似处,就准备梳理一下这个知识点。

高性能动画是什么,那它衡量的标准是什么呢?

动画帧率可以作为衡量标准,一般来说画面在 60fps 的帧率下效果比较好。

换算一下就是,每一帧要在 16.7ms (16.7 = 1000/60) 内完成渲染。

我们来看看MDN对它的解释吧👇

window.requestAnimationFrame() 方法告诉浏览器您希望执行动画并请求浏览器在下一次重绘之前调用指定的函数来更新动画。该方法使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用。-- MDN

当我们调用这个函数的时候,我们告诉它需要做两件事:

  1. 我们需要新的一帧;
  2. 当你渲染新的一帧时需要执行我传给你的回调函数

rAF与 setTimeout 相比

rAF(requestAnimationFrame) 最大的优势是由系统来决定回调函数的执行时机

具体一点讲就是,系统每次绘制之前会主动调用 rAF 中的回调函数,如果系统绘制率是 60Hz,那么回调函数就每16.7ms 被执行一次,如果绘制频率是75Hz,那么这个间隔时间就变成了 1000/75=13.3ms。

换句话说就是,rAF 的执行步伐跟着系统的绘制频率走。它能保证回调函数在屏幕每一次的绘制间隔中只被执行一次(上一个知识点刚刚梳理完函数节流),这样就不会引起丢帧现象,也不会导致动画出现卡顿的问题。

另外它可以自动调节频率。如果callback工作太多无法在一帧内完成会自动降低为30fps。虽然降低了,但总比掉帧好。

与setTimeout动画对比的话,有以下几点优势

  • 当页面隐藏或者最小化时,setTimeout仍然在后台执行动画,此时页面不可见或者是不可用状态,动画刷新没有意义,而言浪费CPU。
  • rAF不一样,当页面处理未激活的状态时,该页面的屏幕绘制任务也会被系统暂停,因此跟着系统步伐走的rAF也会停止渲染,当页面被激活时,动画就从上次停留的地方继续执行,有效节省了 CPU 开销。

什么时候调用呢

规范中似乎是这么去定义的:

  • 在重新渲染前调用。
  • 很可能在宏任务之后不去调用

这样子分析的话,似乎很合理嘛,为什么要在重新渲染前去调用呢?因为rAF作为官方推荐的一种做流畅动画所应该使用的API,做动画不可避免的去操作DOM,而如果是在渲染后去修改DOM的话,那就只能等到下一轮渲染机会的时候才能去绘制出来了,这样子似乎不合理。

rAF在浏览器决定渲染之前给你最后一个机会去改变 DOM 属性,然后很快在接下来的绘制中帮你呈现出来,所以这是做流畅动画的不二选择。

至于宏任务,微任务,这可以说起来就要展开篇幅了,暂时不在这里梳理了。

rAF与节流相比

_.throttle(dosomething, 16) 等价。它是高保真的,如果追求更好的精确度的话,可以用浏览器原生的 API 。

可以使用 rAF API 替换 throttle 方法,考虑一下优缺点:

优点

  • 动画保持 60fps(每一帧 16 ms),浏览器内部决定渲染的最佳时机
  • 简洁标准的 API,后期维护成本低

缺点

  • 动画的开始/取消需要开发者自己控制,不像 ‘.debounce’ 或 ‘.throttle’由函数内部处理。
  • 浏览器标签未激活时,一切都不会执行。
  • 尽管所有的现代浏览器都支持 rAF ,IE9,Opera Mini 和 老的 Android 还是需要打补丁
  • Node.js 不支持,无法在服务器端用于文件系统事件。

根据经验,如果 JavaScript 方法需要绘制或者直接改变属性,我会选择 requestAnimationFrame,只要涉及到重新计算元素位置,就可以使用它。

涉及到 AJAX 请求,添加/移除 class (可以触发 CSS 动画),我会选择 _.debounce 或者 _.throttle ,可以设置更低的执行频率(例子中的200ms 换成16ms)。


11. 能不能实现图片的懒加载

页可见区域宽: document.body.clientWidth;
网页可见区域高: document.body.clientHeight;
网页可见区域宽: document.body.offsetWidth (包括边线的宽);
网页可见区域高: document.body.offsetHeight (包括边线的宽);
网页正文全文宽: document.body.scrollWidth;
网页正文全文高: document.body.scrollHeight;
网页被卷去的高: document.body.scrollTop;
网页被卷去的左: document.body.scrollLeft;
网页正文部分上: window.screenTop;
网页正文部分左: window.screenLeft;
屏幕分辨率的高: window.screen.height;
屏幕分辨率的宽: window.screen.width;
屏幕可用工作区高度: window.screen.availHeight;

关于scrollTop,offsetTop,scrollLeft,offsetLeft用法介绍,点这里

原理思路

  1. 拿到所以的图片img dom
  2. 重点是第二步,判断当前图片是否到了可视区范围内
  3. 到了可视区的高度以后,就将img的data-src属性设置给src
  4. 绑定window的scroll事件

当然了,为了用户的体验更加,默认的情况下,设置一个占位图

本次测试代码

CSS代码👇

<style>
        img{
            display: block;
            height: 320px;
            margin-top: 20px;
            margin: 10px auto;
        }
</style>

HTML👇

<img src="default.png" data-src="https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1595328889118&di=1665d7e122bc96be92d0f3e1b2f5e302&imgtype=0&src=http%3A%2F%2Fwork.361ser.com%2FContent%2Fueditor%2Fnet%2Fupload%2Fimage%2F20171014%2F6364359407281350179759303.jpg" />

第一种方式

clientHeight-scrollTop-offsetTop

直接上我运行的代码👇

let Img = document.getElementsByTagName("img"),
            len = Img.length,
            count = 0; 
        function lazyLoad () {
            let viewH = document.body.clientHeight, //可见区域高度
                scrollTop = document.body.scrollTop; //滚动条距离顶部高度
            for(let i = count; i < len; i++) {
                if(Img[i].offsetTop < scrollTop + viewH ){
                    if(Img[i].getAttribute('src') === 'default.png'){
                        Img[i].src = Img[i].getAttribute('data-src')
                        count++;
                    }
                }
            }
        }
        function throttle(fn, delay) {
            let flag = true,
                timer = null;
            return function (...args) {
                let context = this;
                if (!flag) return;
                flag = false;
                clearTimeout(timer)
                timer = setTimeout(() => {
                    fn.apply(context, args);
                    flag = true;
                }, delay);
            };
        };
        window.addEventListener('scroll', throttle(lazyLoad,1000))
        
        lazyLoad();  // 首次加载

第二种方式

使用 element.getBoundingClientRect() API 直接得到 top 值。

代码👇

let Img = document.getElementsByTagName("img"),
            len = Img.length,
            count = 0; 
        function lazyLoad () {
            let viewH = document.body.clientHeight, //可见区域高度
                scrollTop = document.body.scrollTop; //滚动条距离顶部高度
            for(let i = count; i < len; i++) {
                if(Img[i].getBoundingClientRect().top < scrollTop + viewH ){
                    if(Img[i].getAttribute('src') === 'default.png'){
                        Img[i].src = Img[i].getAttribute('data-src')
                        count++;
                    }
                }
            }
        }
        function throttle(fn, delay) {
            let flag = true,
                timer = null;
            return function (...args) {
                let context = this;
                if (!flag) return;
                flag = false;
                clearTimeout(timer)
                timer = setTimeout(() => {
                    fn.apply(context, args);
                    flag = true;
                }, delay);
            };
        };
        window.addEventListener('scroll', throttle(lazyLoad,1000))

        lazyLoad();  // 首次加载 

好像也差不多,不知道是不是我写的方式有问题(●'◡'●),感觉差不多

来看看效果吧,我给这个事件加了一个节流,这样子操作看起来就更好了。

图片懒加载
图片懒加载

12. 说一说你对Cookie localStorage sessionStorage

Cookie

得扯一下HTTP是一个无状态的协议,这里主要指的是HTTP1.x版本,简单的可以理解为即使同一个客户端连续两次发送请求给服务器,服务器也无法识别这个同一个客户端发的请求,导致的问题,比如现实生活中你加入一个商品到购物车,但是因为无法识别同一个客户端,你刷新页面的话就🤭

为了解决 HTTP 无状态导致的问题(HTTP1.x),后来出现了 Cookie。

Cookie 的存在也不是为了解决通讯协议无状态的问题,只是为了解决客户端与服务端会话状态的问题,这个状态是指后端服务的状态而非通讯协议的状态。

Cookie存放在本地的好处就在于即使你关闭了浏览器,Cookie 依然可以生效。

Cookie设置

怎么去设置呢?简单来说就是👇

  1. 客户端发送 HTTP 请求到服务器
  2. 当服务器收到 HTTP 请求时,在响应头里面添加一个 Set-Cookie 字段
  3. 浏览器收到响应后保存下 Cookie
  4. 之后对该服务器每一次请求中都通过 Cookie 字段将 Cookie 信息发送给服务器。

Cookie指令

在下面这张图里我们可以看到 Cookies 相关的一些属性👇

Cookie
Cookie

这里主要说一些大家可能没有注意的点:

Name/Value

用 JavaScript 操作 Cookie 的时候注意对 Value 进行编码处理。

Expires/Max-Age

Expires 用于设置 Cookie 的过期时间。比如:

Set-Cookie: id=aad3fWa; Expires=Wed, 21 May 2020 07:28:00 GMT;
  • 当 Expires 属性缺省时,表示是会话性 Cookie。
  • 像上图 Expires 的值为 Session,表示的就是会话性 Cookie。
  • 会话性 Cookie 的时候,值保存在客户端内存中,并在用户关闭浏览器时失效。
  • 需要注意的是,有些浏览器提供了会话恢复功能,关闭浏览器,会话期Cookie会保留下来。
  • 与会话性 Cookie 相对的是持久性 Cookie,持久性 Cookies 会保存在用户的硬盘中,直至过期或者清除 Cookie。

Max-Age 用于设置在 Cookie 失效之前需要经过的秒数。比如:

Set-Cookie: id=a3fWa; Max-Age=604800;

假如 Expires 和 Max-Age 都存在,Max-Age 优先级更高。

Domain

Domain 指定了 Cookie 可以送达的主机名。假如没有指定,那么默认值为当前文档访问地址中的主机部分(但是不包含子域名)。

在这里注意的是,不能跨域设置 Cookie

Path

Path 指定了一个 URL 路径,这个路径必须出现在要请求的资源的路径中才可以发送 Cookie 首部。比如设置 Path=/docs/docs/Web/ 下的资源会带 Cookie 首部,/test 则不会携带 Cookie 首部。

Domain 和 Path 标识共同定义了 Cookie 的作用域:即 Cookie 应该发送给哪些 URL。

Secure属性

标记为 Secure 的 Cookie 只应通过被HTTPS协议加密过的请求发送给服务端。使用 HTTPS 安全协议,可以保护 Cookie 在浏览器和 Web 服务器间的传输过程中不被窃取和篡改。

HTTPOnly

设置 HTTPOnly 属性可以防止客户端脚本通过 document.cookie 等方式访问 Cookie,有助于避免 XSS 攻击。

SameSite

SameSite 属性可以让 Cookie 在跨站请求时不会被发送,从而可以阻止跨站请求伪造攻击(CSRF)。

后面讲CSRF攻击会将讲到,这里过。

这个属性值修改有什么影响呢?

Samesite
Samesite

从上图可以看出,对大部分 web 应用而言,Post 表单,iframe,AJAX,Image 这四种情况从以前的跨站会发送三方 Cookie,变成了不发送。

Cookie 的作用

Cookie 主要用于以下三个方面:

  1. 会话状态管理(如用户登录状态、购物车、游戏分数或其它需要记录的信息)
  2. 个性化设置(如用户自定义设置、主题等)
  3. 浏览器行为跟踪(如跟踪分析用户行为等)

Cookie 的缺点

从大小,安全,增加请求大小。

  • 容量缺陷。Cookie 的体积上限只有4KB,只能用来存储少量的信息。
  • 降低性能,Cookie紧跟着域名,不管域名下的某个地址是否需要这个Cookie,请求都会带上完整的Cookie,请求数量增加,会造成巨大的浪费。
  • 安全缺陷,Cookie是以纯文本的形式在浏览器和服务器中传递,很容易被非法用户获取,当HTTPOnly为false时,Cookie信息还可以直接通过JS脚本读取。

localStorage 和 sessionStorage

在 web 本地存储场景上,cookie 的使用受到种种限制,最关键的就是存储容量太小和数据无法持久化存储。

在 HTML 5 的标准下,出现了 localStorage 和 sessionStorage 供我们使用。

异同点

分类 生命周期 存储容量 存储位置
cookie 默认保存在内存中,随浏览器关闭失效(如果设置过期时间,在到过期时间后失效) 4KB 保存在客户端,每次请求时都会带上
localStorage 理论上永久有效的,除非主动清除。 4.98MB(不同浏览器情况不同,safari 2.49M) 保存在客户端,不与服务端交互。节省网络流量
sessionStorage 仅在当前网页会话下有效,关闭页面或浏览器后会被清除。 4.98MB(部分浏览器没有限制) 同上

操作方式

接下来我们来具体看看如何来操作localStoragesessionStorage

let obj = { name: "TianTianUp", age: 18 };
localStorage.setItem("name", "TianTianUp"); 
localStorage.setItem("info", JSON.stringify(obj));

接着进入相同的域名时就能拿到相应的值👇

let name = localStorage.getItem("name");
let info = JSON.parse(localStorage.getItem("info"));

从这里可以看出,localStorage其实存储的都是字符串,如果是存储对象需要调用JSONstringify方法,并且用JSON.parse来解析成对象。

应用场景

  • localStorage 适合持久化缓存数据,比如页面的默认偏好配置,如官网的logo,存储Base64格式的图片资源等;
  • sessionStorage 适合一次性临时数据保存,存储本次浏览信息记录,这样子页面关闭的话,就不需要这些记录了,还有对表单信息进行维护,这样子页面刷新的话,也不会让表单信息丢失。

13. 聊一聊浏览器缓存

浏览器缓存是性能优化的一个重要手段,对于理解缓存机制而言也是很重要的,我们来梳理一下吧👇

强缓存

强缓存两个相关字段,ExpiresCache-Control

强缓存分为两种情况,一种是发送HTTP请求,一种不需要发送。

首先检查强缓存,这个阶段**不需要发送HTTP请求。**通过查找不同的字段来进行,不同的HTTP版本所以不同。

  • HTTP1.0版本,使用的是Expires,HTTP1.1使用的是Cache-Control

Expires

Expires即过期时间,时间是相对于服务器的时间而言的,存在于服务端返回的响应头中,在这个过期时间之前可以直接从缓存里面获取数据,无需再次请求。比如下面这样:

Expires:Mon, 29 Jun 2020 11:10:23 GMT

表示该资源在2020年7月29日11:10:23过期,过期时就会重新向服务器发起请求。

这个方式有一个问题:服务器的时间和浏览器的时间可能并不一致,所以HTTP1.1提出新的字段代替它。

Cache-Control

HTTP1.1版本中,使用的就是该字段,这个字段采用的时间是过期时长,对应的是max-age。

Cache-Control:max-age=6000

上面代表该资源返回后6000秒,可以直接使用缓存。

当然了,它还有其他很多关键的指令,梳理了几个重要的👇

注意点:

  • 当Expires和Cache-Control同时存在时,优先考虑Cache-Control。
  • 当然了,当缓存资源失效了,也就是没有命中强缓存,接下来就进入协商缓存👇

协商缓存

强缓存失效后,浏览器在请求头中携带响应的缓存Tag来向服务器发送请求,服务器根据对应的tag,来决定是否使用缓存。

缓存分为两种,Last-ModifiedETag。两者各有优势,并不存在谁对谁有绝对的优势,与上面所讲的强缓存两个Tag所不同。

Last-Modified

这个字段表示的是最后修改时间。在浏览器第一次给服务器发送请求后,服务器会在响应头中加上这个字段。

浏览器接收到后,如果再次请求,会在请求头中携带If-Modified-Since字段,这个字段的值也就是服务器传来的最后修改时间。

服务器拿到请求头中的If-Modified-Since的字段后,其实会和这个服务器中该资源的最后修改时间对比:

  • 如果请求头中的这个值小于最后修改时间,说明是时候更新了。返回新的资源,跟常规的HTTP请求响应的流程一样。
  • 否则返回304,告诉浏览器直接使用缓存。

ETag

ETag是服务器根据当前文件的内容,对文件生成唯一的标识,比如MD5算法,只要里面的内容有改动,这个值就会修改,服务器通过把响应头把该字段给浏览器。

浏览器接受到ETag值,会在下次请求的时候,将这个值作为If-None-Match这个字段的内容,发给服务器。

服务器接收到If-None-Match后,会跟服务器上该资源的ETag进行比对👇

  • 如果两者一样的话,直接返回304,告诉浏览器直接使用缓存
  • 如果不一样的话,说明内容更新了,返回新的资源,跟常规的HTTP请求响应的流程一样

两者对比

  • 性能上,Last-Modified优于ETagLast-Modified记录的是时间点,而Etag需要根据文件的MD5算法生成对应的hash值。
  • 精度上,ETag优于Last-ModifiedETag按照内容给资源带上标识,能准确感知资源变化,Last-Modified在某些场景并不能准确感知变化,比如👇
    • 编辑了资源文件,但是文件内容并没有更改,这样也会造成缓存失效。
    • Last-Modified 能够感知的单位时间是秒,如果文件在 1 秒内改变了多次,那么这时候的 Last-Modified 并没有体现出修改了。

最后,如果两种方式都支持的话,服务器会优先考虑ETag

缓存位置

接下来我们考虑使用缓存的话,缓存的位置在哪里呢?

浏览器缓存的位置的话,可以分为四种,优先级从高到低排列分别👇

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache

Service Worker

这个应用场景比如PWA,它借鉴了Web Worker思路,由于它脱离了浏览器的窗体,因此无法直接访问DOM。它能完成的功能比如:离线缓存消息推送网络代理,其中离线缓存就是Service Worker Cache

Memory Cache

指的是内存缓存,从效率上讲它是最快的,从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。

Disk Cache

存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,优势在于存储容量和存储时长。

Disk Cache VS Memory Cache

两者对比,主要的策略👇

内容使用率高的话,文件优先进入磁盘

比较大的JS,CSS文件会直接放入磁盘,反之放入内存。

Push Cache

推送缓存,这算是浏览器中最后一道防线吧,它是HTTP/2的内容。具体我也不是很清楚,有兴趣的可以去了解。

总结

  • 首先检查Cache-Control, 尝鲜,看强缓存是否可用
  • 如果可用的话,直接使用
  • 否则进入协商缓存,发送HTTP请求,服务器通过请求头中的If-Modified-Since或者If-None-Match字段检查资源是否更新
  • 资源更新,返回资源和200状态码。
  • 否则,返回304,直接告诉浏览器直接从缓存中去资源。

14. 说一说从输入URL到页面呈现发生了什么?

一旦问这个问题的话,我觉得肯定是一个非常深的问题了,无论从深度还是广度上,要真的答好这个题目,或者梳理清楚的话,挺难的,毕竟一个非常综合性的问题,我作为一个刚刚入门的小白,只能梳理部分知识,更深的知识可以去看看参考链接。

那么我们就开始吧,假设你输入的内容是👇

https://juejin.cn

👇👇👇

网络请求

1. 构建请求

首先,浏览器构建请求行信息(如下所示),构建好后,浏览器准备发起网络请求👇

GET / HTTP1.1
GET是请求方法,路径就是根路径,HTTP协议版本1.1

2. 查找缓存

在真正发起网络请求之前,浏览器会先在浏览器缓存中查询是否有要请求的文件。

先检查强缓存,如果命中的话直接使用,否则进入下一步,强缓存的知识点,上面👆梳理过了。

3. DNS解析

输入的域名的话,我们需要根据域名去获取对应的ip地址。 这个过程需要依赖一个服务系统,叫做是DNS域名解析, 从查找到获取到具体IP的过程叫做是DNS解析

关于DNS篇,可以看看阮一峰的网络日志

首先,浏览器提供了DNS数据缓存功能,如果一个域名已经解析过了,那么就会把解析的结果缓存下来,下次查找的话,直接去缓存中找,不需要结果DNS解析。

解析过程总结如下👇

  1. 首先查看是否有对应的域名缓存,有的话直接用缓存的ip访问

    1. ipconfig /displaydns
      // 输入这个命令就可以查看对应的电脑中是否有缓存
      
  2. 如果缓存中没有,则去查找hosts文件 一般在 c:\windows\system32\drivers\etc\hosts

  3. 如果hosts文件里没找到想解析的域名,则将域名发往自己配置的dns服务器,也叫本地dns服务器

    1. ipconfig/all
      通过这个命令可以查看自己的本地dns服务器
      
  4. 如果本地dns服务器有相应域名的记录,则返回记录。

    1. 电脑的dns服务器一般是各大运营商如电信联通提供的,或者像180.76.76.76,223.5.5.5,4个114等知名dns服务商提供的,本身缓存了大量的常见域名的ip,所以常见的网站,都是有记录的。不需要找根服务器。

  5. 如果电脑自己的服务器没有记录,会去找根服务器。根服务器全球只要13组,回去找其中之一,找了根服务器后,根服务器会根据请求的域名,返回对应的“顶级域名服务器”,如:

    1. 如果请求的域名是xxx.com,则返回负责com域的服务器
    2. 如果是xxx.cn,则发给负责cn域的服务器
    3. 如果是xxx.ca,则发给负责ca域的服务器
  6. 顶级域服务器收到请求,会返回二级域服务器的地址

    1. 比如一个网址是www.xxx.edu.cn,则顶级域名服务器再转发给负责.edu.cn域的二级服务器
  7. 以此类推,最终会发到负责锁查询域名的,最精确的那台dns,可以得到查询结果。

  8. 最后一步,本地dns服务器,把最终的解析结果,返回给客户端,对客户端来讲,只是一去一回的事,客户端并不知道本地dns服务器经过了千山万水。

以上就是大概的过程了,有兴趣的话,可以仔细去看看。

建立TCP链接

我们所了解的就是👉Chrome 在同一个域名下要求同时最多只能有 6 个 TCP 连接,超过 6 个的话剩下的请求就得等待。

那么我们假设不需要等待,我们进入了TCP连接的建立阶段。

建立TCP连接经历下面三个阶段:

  • 通过三次握手建立客户端和服务器之间的连接。
  • 进行数据传输。
  • 断开连接的阶段。数据传输完成,现在要断开连接了,通过四次挥手来断开连接。

从上面看得出来,TCP 连接通过什么手段来保证数据传输的可靠性,一是三次握手确认连接,二是数据包校验保证数据到达接收方,三是通过四次挥手断开连接。

深入理解的话,可以看看对应的文章,掘金上面很多文章都有深入了解,这里就不梳理了。

发送HTTP请求

TCP连接完成后,接下来就可以与服务器通信了,也就是我们经常说的发送HTTP请求。

发送HTTP请求的话,需要携带三样东西:请求行请求头请求体

我们看看大概是是什么样子的吧👇

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cache-Control: no-cache
Connection: keep-alive
Cookie: /* 省略cookie信息 */
Host: juejin.im
Pragma: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36

最后就是请求体,请求体的话只有在POST请求场景下存在,常见的就是表单提交

网络响应

HTTP 请求到达服务器,服务器进行对应的处理。最后要把数据传给浏览器,也就是通常我们说的返回网络响应。

跟请求部分类似,网络响应具有三个部分:响应行响应头响应体

响应行类似下面这样👇

HTTP/1.1 200 OK

对应的响应头数据是怎么样的呢?我们来举个例子看看👇

Access-Control-Max-Age: 86400
Cache-control: private
Connection: close
Content-Encoding: gzip
Content-Type: text/html;charset=utf-8
Date: Wed, 22 Jul 2020 13:24:49 GMT
Vary: Accept-Encoding
Set-Cookie: ab={}; path=/; expires=Thu, 22 Jul 2021 13:24:49 GMT; secure; httponly
Transfer-Encoding: chunked

接下来,我们数据拿到了,你认为就会断开TCP连接吗?

这个的看响应头中的Connection字段。上面的字段值为close,那么就会断开,一般情况下,HTTP1.1版本的话,通常请求头会包含Connection: Keep-Alive表示建立了持久连接,这样TCP连接会一直保持,之后请求统一站点的资源会复用这个连接。

上面的情况就会断开TCP连接,请求-响应流程结束。

到这里的话,网络请求就告一段落了,接下来的内容就是渲染流程了👇

渲染阶段

较为专业的术语总结为以下阶段:

  1. 构建DOM树
  2. 样式计算
  3. 布局阶段
  4. 分层
  5. 绘制
  6. 分块
  7. 光栅化
  8. 合成

关于渲染流程的话,可以看我之前总结的一篇✅✅✅

[1.1W字]写给女友的秘籍-浏览器工作原理(渲染流程)篇


15. 谈一谈你对重排和重绘理解

关于重排和重绘,可以上面的知识点去梳理,也就是渲染阶段,里面也梳理了部分的点,(●'◡'●)

偷个懒,看下面的文章噢👇

[1.1W字]写给女友的秘籍-浏览器工作原理(渲染流程)篇


16. 谈一谈跨域,同源策略,以及跨域解决方案

什么是跨域

跨域,是指浏览器不能执行其他网站的脚本。它是由浏览器的同源策略造成的,是浏览器对JavaScript实施的安全限制。

同源策略

同源策略是一个安全策略。所谓的同源,指的是协议,域名,端口相同。

同源策略
同源策略

浏览器处于安全方面的考虑,只允许本域名下的接口交互,不同源的客户端脚本,在没有明确授权的情况下,不能读写对方的资源。

限制了一下行为:

  • Cookie、LocalStorage 和 IndexDB 无法读取
  • DOM 和 JS 对象无法获取
  • Ajax请求发送不出去

解决方案

当然了,我梳理了几个我觉得工作中常用的,其他的自行去了解。

jsonp跨域

利用script标签没有跨域限制的漏洞,网页可以拿到从其他来源产生动态JSON数据,当然了JSONP请求一定要对方的服务器做支持才可以。

与AJAX对比

JSONP和AJAX相同,都是客户端向服务器发送请求,从服务器获取数据的方式。但是AJAX属于同源策略,JSONP属于非同源策略(跨域请求)

JSONP优点

兼容性比较好,可用于解决主流浏览器的跨域数据访问的问题。缺点就是仅支持get请求,具有局限性,不安全,可能会受到XSS攻击。

思路👇

  • 创建script标签
  • 设置script标签的src属性,以问号传递参数,设置好回调函数callback名称
  • 插入html文本中
  • 调用回调函数,res参数就是获取的数据
let script = document.createElement('script');

script.src = 'http://www.baidu.cn/login?username=TianTianUp&callback=callback';

document.body.appendChild(script);

function callback(res) {
  console.log(res);
 }

当然,jquery也支持jsonp的实现方式

  $.ajax({
            url: 'http://www.baidu.cn/login',
            type: 'GET',
            dataType: 'jsonp', //请求方式为jsonp
            jsonpCallback: 'callback',
            data: {
                "username": "Nealyang"
            }
        })

JSONP优点

  • 它不像XMLHttpRequest对象实现的Ajax请求那样受到同源策略的限制
  • 它的兼容性更好,在更加古老的浏览器中都可以运行,不需要XMLHttpRequest或ActiveX的支持
  • 并且在请求完毕后可以通过调用callback的方式回传结果。

JSONP缺点

  • 它只支持GET请求而不支持POST等其它类型的HTTP请求
  • 它只支持跨域HTTP请求这种情况,不能解决不同域的两个页面之间如何进行JavaScript调用的问题

跨域资源共享 CORS

CORS(Cross-Origin Resource Sharing)跨域资源共享,定义了必须在访问跨域资源时,浏览器与服务器应该如何沟通。CORS背后的基本思想就是使用自定义的HTTP头部让浏览器与服务器进行沟通,从而决定请求或响应是应该成功还是失败。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。

上面是引用,你要记住的关键点👇

CORS 需要浏览器和后端同时支持。IE 8 和 9 需要通过 XDomainRequest 来实现

  • 浏览器会自动进行 CORS 通信,实现 CORS 通信的关键是后端。只要后端实现了 CORS,就实现了跨域。
  • 服务端设置 Access-Control-Allow-Origin 就可以开启 CORS。 该属性表示哪些域名可以访问资源,如果设置通配符则表示所有网站都可以访问资源。

请求分为简单请求非简单请求,所以我们的了解这两种情况。

简单请求

满足下面两个条件,就属于简单请求👇

条件1:使用下列方法之一:

  • GET
  • HEAD
  • POST

条件2:Content-Type 的值仅限于下列三者之一👇

  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器;

XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。

复杂请求

不符合以上条件的请求就肯定是复杂请求了。 复杂请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求,该请求是 option 方法的,通过该请求来知道服务端是否允许跨域请求。

直接上一个例子吧👇 看看一个完整的复杂请求吧,并且介绍一下CORS请求的字段。

//server2.js
let express = require('express')
let app = express()
let whitList = ['http://localhost:3000'] //设置白名单
app.use(function(req, res, next) {
  let origin = req.headers.origin
  if (whitList.includes(origin)) {
    // 设置哪个源可以访问我
    res.setHeader('Access-Control-Allow-Origin', origin)
    // 允许携带哪个头访问我
    res.setHeader('Access-Control-Allow-Headers', 'name')
    // 允许哪个方法访问我
    res.setHeader('Access-Control-Allow-Methods', 'PUT')
    // 允许携带cookie
    res.setHeader('Access-Control-Allow-Credentials', true)
    // 预检的存活时间
    res.setHeader('Access-Control-Max-Age', 6)
    // 允许返回的头
    res.setHeader('Access-Control-Expose-Headers', 'name')
    if (req.method === 'OPTIONS') {
      res.end() // OPTIONS请求不做任何处理
    }
  }
  next()
})
app.put('/getData', function(req, res) {
  console.log(req.headers)
  res.setHeader('name', 'jw') //返回一个响应头,后台需设置
  res.end('我不爱你')
})
app.get('/getData', function(req, res) {
  console.log(req.headers)
  res.end('我不爱你')
})
app.use(express.static(__dirname))
app.listen(4000)

上述代码由http://localhost:3000/index.htmlhttp://localhost:4000/跨域请求,正如我们上面所说的,后端是实现 CORS 通信的关键。

上述的例子,一定对你会有所帮助的,这块代码,是跟着浪里行舟代码来的,参考处注明了出处。

与JSONP对比

  • JSONP只能实现GET请求,而CORS支持所有类型的HTTP请求。
  • 使用CORS,开发者可以使用普通的XMLHttpRequest发起请求和获得数据,比起JSONP有更好的错误处理。
  • JSONP主要被老的浏览器支持,它们往往不支持CORS,而绝大多数现代浏览器都已经支持了CORS)

WebSocket协议跨域

Websocket是HTML5的一个持久化的协议,它实现了浏览器与服务器的全双工通信,同时也是跨域的一种解决方案。

WebSocket和HTTP都是应用层协议,都基于 TCP 协议。但是 WebSocket 是一种双向通信协议,在建立连接之后,WebSocket 的 server 与 client 都能主动向对方发送或接收数据。同时,WebSocket 在建立连接时需要借助 HTTP 协议,连接建立好了之后 client 与 server 之间的双向通信就与 HTTP 无关了。

我们先来看个例子👇

本地文件socket.html向localhost:3000发生数据和接受数据👇

// socket.html
<script>
    let socket = new WebSocket('ws://localhost:3000');
    socket.onopen = function () {
      socket.send('我爱你');//向服务器发送数据
    }
    socket.onmessage = function (e) {
      console.log(e.data);//接收服务器返回的数据
    }
</script>

后端部分👇

// server.js
let WebSocket = require('ws'); //记得安装ws
let wss = new WebSocket.Server({port:3000});
wss.on('connection',function(ws) {
  ws.on('message', function (data) {
    console.log(data);
    ws.send('我不爱你')
  });
})

如果 你想去尝试的话,建议可以去玩一玩Socket.io,

  • 这是因为原生WebSocket API使用起来不太方便,它很好地封装了webSocket接口
  • 提供了更简单、灵活的接口,也对不支持webSocket的浏览器提供了向下兼容。

nginx代理跨域


17. 谈一谈你对XSS攻击理解

什么是 XSS 攻击

XSS 全称是 Cross Site Scripting ,为了与CSS区分开来,故简称 XSS,翻译过来就是“跨站脚本”。

XSS是指黑客往 HTML 文件中或者 DOM 中注入恶意脚本,从而在用户浏览页面时利用注入的恶意脚本对用户实施攻击的一种手段。

最开始的时候,这种攻击是通过跨域来实现的,所以叫“跨域脚本”。发展到现在,往HTML文件中中插入恶意代码方式越来越多,所以是否跨域注入脚本已经不是唯一的注入手段了,但是 XSS 这个名字却一直保留至今。

注入恶意脚本可以完成这些事情:

  1. 窃取Cookie
  2. 监听用户行为,比如输入账号密码后之间发给黑客服务器
  3. 在网页中生成浮窗广告
  4. 修改DOM伪造登入表单

一般的情况下,XSS攻击有三种实现方式

  • 存储型 XSS 攻击
  • 反射型 XSS 攻击
  • 基于 DOM 的 XSS 攻击

存储型 XSS 攻击

存储型XSS
存储型XSS

从图上看,存储型 XSS 攻击大致步骤如下:

  1. 首先黑客利用站点漏洞将一段恶意 JavaScript 代码提交到网站的数据库中;
  2. 然后用户向网站请求包含了恶意 JavaScript 脚本的页面;
  3. 当用户浏览该页面的时候,恶意脚本就会将用户的 Cookie 信息等数据上传到服务器。

比如常见的场景:

在评论区提交一份脚本代码,假设前后端没有做好转义工作,那内容上传到服务器,在页面渲染的时候就会直接执行,相当于执行一段未知的JS代码。这就是存储型 XSS 攻击。

反射型 XSS 攻击

反射型 XSS 攻击指的就是恶意脚本作为网络请求的一部分,随后网站又把恶意的JavaScript脚本返回给用户,当恶意 JavaScript 脚本在用户页面中被执行时,黑客就可以利用该脚本做一些恶意操作。

举个例子:

http://TianTianUp.com?query=<script>alert("你受到了XSS攻击")</script>

如上,服务器拿到后解析参数query,最后将内容返回给浏览器,浏览器将这些内容作为HTML的一部分解析,发现是Javascript脚本,直接执行,这样子被XSS攻击了。

这也就是反射型名字的由来,将恶意脚本作为参数,通过网络请求,最后经过服务器,在反射到HTML文档中,执行解析。

主要注意的就是,服务器不会存储这些恶意的脚本,这也算是和存储型XSS攻击的区别吧

基于 DOM 的 XSS 攻击

基于 DOM 的 XSS 攻击是不牵涉到页面 Web 服务器的。具体来讲,黑客通过各种手段将恶意脚本注入用户的页面中,在数据传输的时候劫持网络数据包

常见的劫持手段有:

  • WIFI路由器劫持
  • 本地恶意软件

阻止 XSS 攻击的策略

以上讲述的XSS攻击原理,都有一个共同点:让恶意脚本直接在浏览器执行。

针对三种不同形式的XSS攻击,有以下三种解决办法

对输入脚本进行过滤或转码

对用户输入的信息过滤或者是转码

举个例子👇

转码后👇

&lt;script&gt;alert(&#39;你受到XSS攻击了&#39;)&lt;/script&gt;

这样的代码在 html 解析的过程中是无法执行的。

当然了对于<script><img><a>等关键字标签也是可以过来的,效果如下👇

   

最后什么都没有剩下了

利用 CSP

该安全策略的实现基于一个称作 Content-Security-PolicyHTTP 首部。

可以移步MDN,有更加规范的解释。我在这里就是梳理一下吧。

CSP,即浏览器中的内容安全策略,它的核心思想大概就是服务器决定浏览器加载哪些资源,具体来说有几个功能👇

  • 限制加载其他域下的资源文件,这样即使黑客插入了一个 JavaScript 文件,这个 JavaScript 文件也是无法被加载的;
  • 禁止向第三方域提交数据,这样用户数据也不会外泄;
  • 提供上报机制,能帮助我们及时发现 XSS 攻击。
  • 禁止执行内联脚本和未授权的脚本;

利用 HttpOnly

由于很多 XSS 攻击都是来盗用 Cookie 的,因此还可以通过使用 HttpOnly 属性来保护我们 Cookie 的安全。这样子的话,JavaScript 便无法读取 Cookie 的值。这样也能很好的防范 XSS 攻击。

通常服务器可以将某些 Cookie 设置为 HttpOnly 标志,HttpOnly 是服务器通过 HTTP 响应头来设置的,下面是打开 Google 时,HTTP 响应头中的一段:

set-cookie: NID=189=M8l6-z41asXtm2uEwcOC5oh9djkffOMhWqQrlnCtOI; expires=Sat, 18-Apr-2020 06:52:22 GMT; path=/; domain=.google.com; HttpOnly

总结

XSS 攻击是指浏览器中执行恶意脚本, 然后拿到用户的信息进行操作。主要分为存储型反射型文档型。防范的措施包括:

  • 对输入内容过滤或者转码,尤其是类似于<script><img><a>标签
  • 利用CSP
  • 利用Cookie的HttpOnly属性

除了以上策略之外,我们还可以通过添加验证码防止脚本冒充用户提交危险操作。而对于一些不受信任的输入,还可以限制其输入长度,这样可以增大 XSS 攻击的难度。


18. 能不能说一说CSRF攻击

什么是CSRF攻击呢?

CSRF 英文全称是 Cross-site request forgery,所以又称为“跨站请求伪造”,是指黑客引诱用户打开黑客的网站,在黑客的网站中,利用用户的登录状态发起的跨站请求。简单来讲,CSRF 攻击就是黑客利用了用户的登录状态,并通过第三方的站点来做一些坏事。

一般的情况下,点开一个诱导你的链接,黑客会在你不知情的时候做哪些事情呢

1. 自动发起 Get 请求

黑客网页里面可能有一段这样的代码👇

 <img src="http://bank.example/withdraw?amount=10000&for=hacker" > 

在受害者访问含有这个img的页面后,浏览器会自动向http://bank.example/withdraw?account=xiaoming&amount=10000&for=hacker发出一次HTTP请求。

bank.example就会收到包含受害者登录信息的一次跨域请求。

2. 自动发起 POST 请求

黑客网页中有一个表单,自动提交的表单👇

 <form action="http://bank.example/withdraw" method=POST>
    <input type="hidden" name="account" value="xiaoming" />
    <input type="hidden" name="amount" value="10000" />
    <input type="hidden" name="for" value="hacker" />
</form>
<script> document.forms[0].submit(); </script> 

访问该页面后,表单会自动提交,相当于模拟用户完成了一次POST操作。

同样也会携带相应的用户 cookie 信息,让服务器误以为是一个正常的用户在操作,让各种恶意的操作变为可能。

3. 引诱用户点击链接

这种需要诱导用户去点击链接才会触发,这类的情况比如在论坛中发布照片,照片中嵌入了恶意链接,或者是以广告的形式去诱导,比如:

 <a href="http://test.com/csrf/withdraw.php?amount=1000&for=hacker" taget="_blank">
  重磅消息!!!
  <a/>

点击后,自动发送 get 请求,接下来和自动发 GET 请求部分同理。

以上三种情况,就是CSRF攻击原理,跟XSS对比的话,CSRF攻击并不需要将恶意代码注入HTML中,而是跳转新的页面,利用服务器的验证漏洞用户之前的登录状态来模拟用户进行操作

防护策略

其实我们可以想到,黑客只能借助受害者的**cookie**骗取服务器的信任,但是黑客并不能凭借拿到cookie,也看不到 cookie的内容。另外,对于服务器返回的结果,由于浏览器同源策略的限制,黑客也无法进行解析。

这就告诉我们,我们要保护的对象是那些可以直接产生数据改变的服务,而对于读取数据的服务,则不需要进行**CSRF**的保护。而保护的关键,是 在请求中放入黑客所不能伪造的信息

用户操作限制——验证码机制

方法:添加验证码来识别是不是用户主动去发起这个请求,由于一定强度的验证码机器无法识别,因此危险网站不能伪造一个完整的请求。

1. 验证来源站点

在服务器端验证请求来源的站点,由于大量的CSRF攻击来自第三方站点,因此服务器跨域禁止来自第三方站点的请求,主要通过HTTP请求头中的两个Header

  • Origin Header
  • Referer Header

这两个Header在浏览器发起请求时,大多数情况会自动带上,并且不能由前端自定义内容。

服务器可以通过解析这两个Header中的域名,确定请求的来源域。

其中,Origin只包含域名信息,而Referer包含了具体的 URL 路径。

在某些情况下,这两者都是可以伪造的,通过AJax中自定义请求头即可,安全性略差。

2. 利用Cookie的SameSite属性

可以看看MDN对此的解释

SameSite可以设置为三个值,StrictLaxNone

  1. Strict模式下,浏览器完全禁止第三方请求携带Cookie。比如请求sanyuan.com网站只能在sanyuan.com域名当中请求才能携带 Cookie,在其他网站请求都不能。
  2. Lax模式,就宽松一点了,但是只能在 get 方法提交表单况或者a 标签发送 get 请求的情况下可以携带 Cookie,其他情况均不能。
  3. 在None模式下,Cookie将在所有上下文中发送,即允许跨域发送。

3. CSRF Token

前面讲到CSRF的另一个特征是,攻击者无法直接窃取到用户的信息(Cookie,Header,网站内容等),仅仅是冒用Cookie中的信息。

那么我们可以使用Token,在不涉及XSS的前提下,一般黑客很难拿到Token。

可以看看这篇文章,将了Token是怎么操作的👉彻底理解cookie,session,token

Token(令牌)做为Web领域验证身份是一个不错的选择,当然了,JWT有兴趣的也可以去了解一下。

Token步骤如下:

第一步:将CSRF Token输出到页面中

首先,用户打开页面的时候,服务器需要给这个用户生成一个Token,该Token通过加密算法对数据进行加密,一般Token都包括随机字符串和时间戳的组合,显然在提交时Token不能再放在Cookie中了(XSS可能会获取Cookie),否则又会被攻击者冒用。因此,为了安全起见Token最好还是存在服务器的Session中,之后在每次页面加载时,使用JS遍历整个DOM树,对于DOM中所有的a和form标签后加入Token。这样可以解决大部分的请求,但是对于在页面加载之后动态生成的HTML代码,这种方法就没有作用,还需要程序员在编码时手动添加Token。

第二步:页面提交的请求携带这个Token

对于GET请求,Token将附在请求地址之后,这样URL 就变成 http://url?csrftoken=tokenvalue。 而对于 POST 请求来说,要在 form 的最后加上: <input type=”hidden” name=”csrftoken” value=”tokenvalue”/> 这样,就把Token以参数的形式加入请求了。

第三步:服务器验证Token是否正确

当用户从客户端得到了Token,再次提交给服务器的时候,服务器需要判断Token的有效性,验证过程是先解密Token,对比加密字符串以及时间戳,如果加密字符串一致且时间未过期,那么这个Token就是有效的。

非常感兴趣的,可以仔细去阅读一下相关的文章,Token是如何加密的,又是如何保证不被攻击者获取道。

总结

CSRF(Cross-site request forgery), 即跨站请求伪造,本质是冲着浏览器分不清发起请求是不是真正的用户本人,所以防范的关键在于在请求中放入黑客所不能伪造的信息。从而防止黑客伪造一个完整的请求欺骗服务器。

防范措施:验证码机制,验证来源站点,利用Cookie的SameSite属性,CSRF Token

参考

❤️ 感谢大家

如果你觉得这篇内容对你挺有有帮助的话:

  1. 点赞支持下吧,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)

  2. 欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。

  3. 觉得不错的话,也可以阅读TianTian近期梳理的文章(感谢掘友的鼓励与支持🌹🌹🌹):

本文使用 mdnice 排版