浏览器工作原理

常见的一些问题及相关参考链接

好资源链接

  1. 一名【合格】前端工程师的自检清单

微信分享 SPA/history 模式

  1. ios 与 android 识别 url 的异同

vue 相关

  1. vue 源码技术内幕

element-ui

  1. vue-element-admin

数字滚动

  1. 滚动插件 countUp.js
  2. 滚动插件 npm

好的 ui 风格

  1. material
  2. materializecss

js 动画

  • https://www.zcfy.cc/article/11-javascript-animation-libraries-for-2018
  • http://www.css88.com/archives/7389
  • http://h5bp.github.io/Effeckt.css/
  1. tween
  2. 页面切换效果
  3. animejs
  4. 动画库 4
  5. 动画库 5
  6. 动画库 6
  7. 动画库 7
  8. 动画库 8

css 相关

  1. css 实现 ooter 置底
  2. 学习 css 布局

文档生成工具

  1. jsdoc
  2. vue-styleguidist

RSA 与 AES

  1. 为什么 RSA 每次加密的结果都不同

webView 性能优化

  1. 美团分析 webview

react 相关

react 的  今天昨天 react 有哪些最佳实践

Gcs-Vno-Jekyll 主题 博客相关

ios 喵神 安卓某开发 安卓某开发 安卓某开发

终端相关

  1. npm -g 遇到 write access
  2. mac 配置环境变量

IED 相关

  1. vscode 配置 vue 插件

1、熟悉常见 Web 技术,理解 JavaScript 语言核心技术(DOM,BOM,Ajax,JSON,事件)等。

web 安全,egg,node+express,算法,浏览器缓存,开发中的一些坑,一些好的代码集合?
常见的 web 技术有哪些?

常见 web 技术:框架,ui 库,动画库,时间库,js 工具库,特定组件库(selectTree),工程化:webpack,babel,loader,plugin,server,proxy 等等,新特性 Es2020(需要使用几个?),pwa,webworker,service worker,

什么是 BOM?

BOM 是浏览器对象模型,是用来访问浏览器的功能,比如 window 对象,而 window 对象即是访问 Bom 的接口,又是 ecmaJs 规定的全局对象 参考:https://mp.weixin.qq.com/s/2JWXRVfqIlx_C1avkesNxw

知识点:虽然定义在全局的变量 就会变成 window 对象的属性。但与直接定义 window 对象的属性还是不同的。 直接定义的全局变量不能使用 delete 删除。 因为使用 var 定义的变量都有一个 Configurable 的特性,这个特性的值被设置为 false,所以这样定义的属性不能被 delete 删除(若删除则返回 false)。

var test = "msg";
Object.getOwnPropertyDescriptor(window, "test");

value: "msg";
writable: true;
enumerable: true;
configurable: false;

知识点:location 也是一个非常特别的属性。因为,它即是 window 的属性,也是 document 的属性。即 window.location === document.location === location

什么是 DOM?

Dom 是文档对象模型,浏览器渲染引擎无法直接理解 html 文档,需要转换成 dom 才可以,另外由于 dom 是对象,因此也就提供了与之相关的命令来操作 dom,比如 document.queryslsect()等等。

分析下 Ajax?

Ajax 可以说是前端发展的分水岭,Ajax 之前页面刷新的方式是整个页面刷新,而 Ajax 提供了另外一种方式:局部页面刷新,体验更好。而 Ajax 即“Asynchronous Javascript And XML”, 异步 js 和 xml,说白了就是异步获取 js 代码或者 xml,进而利用获取的 js 和 xml 来更新页面的对应部分,所以是局部刷新……相比获取整个页面文档,显然体积小很多。 底层用的是 XMLHttpRequest 对象(IE6 及以下使用 ActiveXObject 对象),常见请求格式参考: https://mp.weixin.qq.com/s/DL06fe2bwEdiAbiIwOqbog

注意:

  • xml.open(method,url,async),参数三为是否同步,ture 为异步 false 为同步。
  • get 和 post 除了请求方式和请求体不同,还有一点就是 get 请求会被缓存
  • 相比 XMLHttpRequest 对象这种最原始的请求方式,不但不方便而且结构啰嗦,Fetch 被称为下一代 Ajax 技术,采用 Promise 方式来处理数据。 是一种简洁明了的 API,比 XMLHttpRequest 更加简单易用。说白了 Ajax 是使用事件监听,而 Fetch 是使用 promise 处理响应。但现在还不能做到所有浏览器支持,可以引入 polyfill。fetch 是浏览器原生支持的,并有没利用 XMLHttpRequest 来封装。
  • 代码里若是调用 XMLHttpRequest,则是直接走网络进程,不需要浏览器主进程介入。

2、深刻理解 MV*、数据驱动视图、web 语义化等并熟练掌握 Vue 相关框架

什么是 MV*?

Mv*模型,说白了就是数据,逻辑,显示之间的关系,像 vue 这种 mvvm,v 就可以理解为视图,也就是 template,而 m 就是数据,而 vm 就是 data,视图和数据就是通过 data 联系起来的。在 vue 里的 methods 以及各种生命周期无不是为了在不同阶段增删改查 data,data 修改完后,框架会批量更新到页面,也就利用到了事件队列。

而 mvc 模型,在 egg 里体现比较明显,c 是控制器,主要执行一些复杂的数据处理,比如增删改查数据库里的数据,v 就是页面,当然页面有很多模板,比如 ejs,pug 等。然后 m 就可以理解为数据库里的数据(而有时候我们说的 redis 和真正意义上的数据库还不同,操作数据库成本较高,而 redis 主要解决的问题是如何快速的响应给用户经常访问的信息,比如用户的登陆信息等) Redis 参考:https://mp.weixin.qq.com/s/OYu_dwA3BvSFt_5T2VYE1Q

如何理解数据驱动视图?

数据驱动视图,相比很早之前,获取元素标签,然后直接去更新里面的内容,现在只需更改对应的数据就可以自动把变化更新到页面。只是框架帮我们做了

3、熟练掌握 Web 及 Mobile 相关的开发技术,考虑页面加载、执行、渲染等性能优化

浏览器的架构?

页面加载过程,先来了解一下浏览器的架构:

  • 浏览器进程:主要负责用户交互、子进程管理和文件储存等功能。

  • 网络进程:是面向渲染进程和浏览器进程等提供网络下载功能。

  • 渲染进程:的主要职责是把从网络下载的 HTML、JavaScript、CSS、图片等资源解析为可以显示和交互的页面。因为渲染进程所有的内容都是通过网络获取的,会存在一些恶意代码利用浏览器漏洞对系统进行攻击,所以运行在渲染进程里面的代码是不被信任的。这也是为什么 Chrome 会让渲染进程运行在安全沙箱里,就是为了保证系统的安全。 。

  • GPU 进程,GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。

  • 插件进程,主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

请求长时间处于 pending 状态或者脚本执行死循环,这时刷新或前进后退页面不响应,刷新或前进后退页面是属于浏览器主进程的 UI 交互行为,为什么渲染进程里的 js 引擎执行会影响到主进程? 答:因为前进或者后退也需要执行当前页面脚本啊,比如要执行 beforeunload 事件,执行的时候页面没响应了,所以前进后退也就失效了

渲染进程有个主线程,DOM 解析,样式计算,执行 JavaScript,执行垃圾回收等等操作都是在这个主线程上执行的,没有所谓的渲染引擎线程和 js 引擎线程的概念,你可以把渲染和执行 JavaScript 是一种功能,如果要执行这些功能的话,需要在一个线程上执行,在 chrome 中,他们都是执行在渲染进程的主线程上。正是因为他们都是执行在同一个线程之上的,所以同一时刻只能运行一个功能,也就是你说的互斥。

从输入 URL 到页面展示,这中间发生了什么?
  1. 当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是搜索内容,还是请求的 URL。(按下回车后,浏览器给当前页面一次执行 beforeunload 事件的机会,意味着你可以在页面退出前做些什么)
  2. 浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程首先查找本地是否有缓存(这里缓存可以理解为资源和域名缓存等),有则直接返回,没有则进行 DNS 解析获取 ip,如果是 https 则还需要建立 TLS 连接。
  3. 浏览器构建请求头(头和行),发送给服务器,服务器解析请求头,然后返回响应头,网络进程收到响应头便开始解析,需要根据 code 码做适当处理,比如 301,302 等重定向的,就会获取 Location 字段,重新发起请求。还会根据 content-type 来判断是下载还是普通页面。下载的话就直接交给下载管理器,同时该 URL 的导航过程也就结束了。如果是页面,因为页面是在渲染进程里,所以下一步就是准备渲染进程
  4. 默认情况下,Chrome 会为每个页面分配一个渲染进程,但一个域下的页面一般共用一个渲染进程,准备好了渲染进程,但现在页面资源还在网络进程那,所以下一步就需要提交文档。
  5. 所谓提交文档,就是指浏览器进程将网络进程接收到的 HTML 数据提交给渲染进程(其实浏览器收到网络进程的响应头后,就给渲染进程发消息,说你要准备渲染页面啦,渲染进程收到消息就会与网络进程建立连接来获取页面数据,等页面数据传输完毕后,会告诉浏览器主进程,主进程收到确认提交消息后就会更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面)
  6. 一旦主进程收到确认提交消息后,渲染进程就开始页面解析及子资源加载了。而一旦页面生成完成,渲染进程会发消息给主进程,然后浏览器主进程就会停止标签图标的加载动画。
渲染进程渲染页面的详细过程是什么?

上面我们知道了 URL 到页面展示的大概过程,那渲染进程是如何解析页面资源的呢?页面资源包含 html,css,js 等,按照渲染流水线可以分如下几个阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。

  1. 构建 DOM 树,浏览器无法直接理解 html,所以需要转化为浏览器可以理解的格式 DOM 树。构建 DOM 树的输入是 html 文件,然后经过 html 解析器生成 DOM 树,控制台输入 document 即可看到 DOM 树。DOM 树几乎和 html 是一模一样的,只是前者存在于内存中,可以通过 js 来增删改查。
  2. 样式计算,和 HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets。控制台输入:document.styleSheets 即可查看。同样可以通过 js 来增删改查,另外需要注意,因为编写代码时,我们用了很多不同的单位比如 rem,em,red,blod 等,这些都会再转换为一个标准的基准,比如 1em 转为 16px,red 转为 rgb(255,0,0)等。最后根据 css 的继承和层叠规则(控制台可以看到层叠顺序,UserAgent 表示浏览器默认样式),最终输出每个 DOM 节点的样式,并保存在 ComputedStyle 结构里。
  3. 布局树,现在有了 DOM 树和 DOM 树的样式,下一步就是创建布局树,因为有些节点是 display:node,因此这些节点将不会出现在布局树上。接下来就是根据布局树,详细计算出布局树中相应节点的准确位置了(过程复杂),并将这些位置信息保存在布局树中。
  4. 分层,页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree),可以在控制台打开 layers 查看图层信息。但并不是每个元素占用一个图层,一般有层叠上下文(z-index 或三维属性)和剪裁特性(超出视图滚动)的元素,才会被提升为一个单独的图层。
  5. 图层绘制,想象一下我们如何画画,是不是先画底色,然后一层一层的叠加画。而图层绘制原理差不多,会将每个图层绘制拆分为很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表。可以通过控制台-Layers-document 查看
  6. 栅格化操作,绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程。有的图层很大,全部一下子绘制,开销太大,一般会将图层划分为图块(tile),而根据视口的大小,只绘制可见视口上下一定范围优先级高的图块(大小一般为 256x256 或者 512x512),但有时候即使优先级高,也会耗时很多造成白屏,此时 chrome 首次合成时采用低分辨率的图片,等正常比例内容绘制好之后再替换之前低分辨率内容。也就是栅格化,是指合成线程会按照视口附近的图块来优先生成位图。而栅格化一般都会借助 GPU
  7. 合成与显示,一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

知道了渲染过程,也就知道了为何重排,重绘的代价高了,而CSS3 的动画性能比较好,原因就在于其避开了重排和重绘,直接在非主线程上执行合成动画操作,没有占用主线程的资源,因此绘制效率大大提升

变量提升,JavaScript 代码是按顺序执行的吗?

所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined。

“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,但,这并不准确。实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中。对,你没听错,一段 JavaScript 代码在执行之前需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段

输入一段代码,经过编译,会生成两部分:执行上下文(Execution context)和可执行代码。

执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。在执行上下文中存在一个变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容,比如下面代码:

showName();
console.log(myname);
var myname = "极客时间";
function showName() {
  console.log("函数showName被执行");
}

// 变量提升的部分
var myname = undefined;
function showName() {
  console.log("函数showName被执行");
}

// 执行部分的代码
showName();
console.log(myname);
myname = "极客时间";

在变量环境中,就有如下存在:

VariableEnvironment:
     myname -> undefined,
     showName ->function : {console.log(myname)

注意:如果一个函数带有参数,编译过程中,参数会通过参数列表保存到变量环境中

js 引擎会逐行分析代码,遇见可变量提升的就会在变量环境的对象里初始化,接下来 JavaScript 引擎会把声明以外的代码编译为字节码。接下来 JavaScript 引擎开始执行“可执行代码”,按照顺序一行一行地执行。遇到变量,便在变量环境中查找,找不到就会报错。

另外需要注意:变量提升时,后者会覆盖前者,若是变量和函数同名,则函数优先级高。

总结:

  • JavaScript 代码执行过程中,需要先做变量提升,而之所以需要实现变量提升,是因为 JavaScript 代码在执行之前需要先编译
  • 在编译阶段,变量和函数会被存放到变量环境中,变量的默认值会被设置为 undefined;在代码执行阶段,JavaScript 引擎会从变量环境中去查找自定义的变量和函数
  • 如果在编译阶段,存在两个相同的函数,那么最终存放在变量环境中的是最后定义的那个,这是因为后定义的会覆盖掉之前定义的。
调用栈:为什么 JavaScript 代码会出现栈溢出?

当一段代码被执行时,JavaScript 引擎会对齐进行编译,并创建执行上下文,但哪种代码才会在执行前编译并创建执行上下文呢?

  1. 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  2. 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  3. 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。

代码中经常会出现函数调用函数的情况,而调用栈就是用来管理函数调用关系的一种数据结构。

var a = 2;
function add() {
  var b = 10;
  return a + b;
}
add();

// 全局执行上下文之变量环境
a = undefined;
add = function() {
  b = 10;
  return a + b;
};

执行过程:

  1. 执行到 add()之前,js 引擎会首先创建全局执行上下文,包含声明的函数和变量。
  2. 全局执行上下文准备好之后,开始执行全局代码,从全局执行上文中取出 add 函数声明代码
  3. 然后对 add 函数进行编译,并创建函数的执行上下文和可执行代码。
  4. 最后执行代码。

因此我们知道,当执行到 add 函数的时候,我们就有了两个执行上下文了——全局执行上下文和 add 函数的执行上下文。也就是说,在执行 JavaScript 时,可能会存在多个执行上下文,而这个多个执行上下文就是通过栈这种数据结构来管理的

栈结构类似一个一端封死的单行道,先进去的只能最后出来,最后进去的先出来。即:后进先出

在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈。

*实际应用:*

  • 开发者工具 - source - 断点 - 刷新页面 - 右侧 call stack 即可查看。栈的最底部一般是 anonymous 表示全局的函数的入口
  • 可以直接在代码里执行 console.trace()

栈溢出:调用栈是有大小的(容量和深度,任何一个指标超过即溢出),当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。 比如以下递归逻辑:

function division(a, b) {
  return division(a, b);
}
console.log(division(1, 2));
// 执行函数division,并创建执行上下文,由于是递归,则一直创建执行上下文
// 并压入到执行栈中,最后导致执行栈溢出
// 超过了最大栈调用大小(Maximum call stack size exceeded)。

知道了,因为不断压入执行上下文才会出现栈溢出,所以如果降低压入执行栈的次数,不就解决了吗?

块级作用域:var 缺陷以及为什么要引入 let 和 const?

由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。

ECMAScript6(以下简称 ES6)已经通过引入块级作用域并配合 let、const 关键字,来避开了这种设计缺陷,但是由于 JavaScript 需要保持向下兼容,所以变量提升在相当长一段时间内还会继续存在。

那为何会有变量提升呢? 这个问题需要先说作用域,作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期

在 ES6 之前,作用域只有两种:全局和函数。而其他语言都普遍支持块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。

和 Java、C/C++ 不同,ES6 之前是不支持块级作用域的,因为当初设计这门语言的时候,并没有想到 JavaScript 会火起来,所以只是按照最简单的方式来设计。没有了块级作用域,再把作用域内部的变量统一提升无疑是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是 JavaScript 中的变量提升。

由于变量提升会导致一些莫名其妙的问题:

  • 变量值在莫名的情况下被覆盖
  • 本应销毁的变量没有被销毁。
// 按道理讲,for循环结束后,i应该被销毁
// 但实际上,i由于变量提升,没有被销毁
function foo() {
  for (var i = 0; i < 7; i++) {}
  console.log(i);
}
foo(); // 7

这些问题导致一些表现和其他支持块级作用域的语言表现不一致,必然给人一些误解。因此 ES6 引入 let 和 const 关键字来支持块级作用域。

但 js 是如何支持块级作用域的呢? 其实块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。

也就是说,在执行上下文中,除了变量环境,还维护一个词法环境,这个词法环境里通过维护一个栈结构来支持块级作用域。而当代码需要一个变量时,从执行上下文里查找的顺序是:先词法环境后变量环境,因此就实现了块级作用域。

作用域链和闭包 :代码中出现相同的变量,JavaScript 引擎是如何选择的?

根据执行栈里的执行上下文顺序,下面代码执行后,应该打印‘极客邦’,但结果却是‘极客时间’。。。这里就涉及到另一个概念:作用域链

function bar() {
  console.log(myName);
}
function foo() {
  var myName = "极客邦";
  bar();
}
var myName = "极客时间";
foo();

其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。我们把这个通过作用域查找变量的链条叫做作用域链,切记切记:作用域链的顺序与执行栈的顺序不一定相同

那这个作用域链的顺序是由什么确定的呢?答案是:词法作用域,词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。而词法作用域是代码阶段就决定好的,和函数是在哪调用的没有关系。

因此上面 bar 函数虽然在 foo 函数内部调用的,但 bar 定义在全局,因此依然去全局执行上下文的变量环境中去找 myName。

在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。

function foo() {
    var myName = "极客时间"
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName:function(){
            console.log(test1)
            // debugger
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())

// 栈顶foo函数执行上下文之变量环境:
myName = ‘极客时间’;
innerBar = function() {}
outer // 指向外部作用域

// 栈顶foo函数执行上下文之词法环境:
test2 = 2
test1 = 1

// 栈底全局执行上下文之变量环境
bar = undefined
outer = null
// 栈底全局执行上下文之词法环境为空

当执行完 foo()后,foo 的执行上下文从栈顶弹出,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。如下:

// 栈顶foo函数执行上下文之变量环境为空:

// 栈顶foo函数执行上下文之词法环境:
myName = "极客时间";
test1 = 1;

// 栈底全局执行上下文之变量环境
bar = undefined;
outer = null;
// 栈底全局执行上下文之词法环境为空

根据执行栈里上下文顺序以及作用域链,可以知道结果为 1,极客邦(setName 先修改了)。

实际应用:可以在 getName 内部打断点,然后控制台查看右侧 scope,可以看到作用域链的顺序:Local–>Closure(foo)–>Global,其中 Closure(foo)就是 foo 函数的闭包,Local 就是当前的 getName 函数的作用域。

通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。

如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量

var bar = {
  myName: "time.geekbang.com",
  printName: function() {
    console.log(myName);
  }
};
function foo() {
  let myName = "极客时间";
  return bar.printName;
}
let myName = "极客邦";
let _printName = foo();
_printName();
bar.printName();

分析以上代码,看结果如何,务必注意,闭包产生条件:内层函数引用外层函数作用域下的变量,并且内层函数在全局可以访问

解析:

  1. bar 不是一个函数,因此 bar 当中的 printName 其实是一个全局声明的函数,bar 当中的 myName 只是对象的一个属性,也和 printName 没有联系,如果要产生联系,需要使用 this 关键字,表示这里的 myName 是对象的一个属性,不然的话,printName 会通过词法作用域链去到其声明的环境,也就是全局,去找 myName

  2. foo 函数返回的 printName 是全局声明的函数,因此和 foo 当中定义的变量也没有任何联系,这个时候 foo 函数返回 printName 并不会产生闭包。因此打印两次极客邦

this:从 JavaScript 执行上下文的视角讲清楚 this

前面的例题,其实就是想实现在对象内部的方法中使用对象内部的属性,但结果却不是想要的效果。。。这确实是一个需求,但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 又搞出来另外一套 this 机制

如下即可实现实现对象内部的方法使用对象内部的属性:

printName: function () {
        console.log(this.myName)
    }

务必注意:this 机制和作用域链是两套不同的机制。

前面我们知道,执行时上下文中有变量环境、词法环境、外部环境,但其实还有一个 this。this 是和执行上下文绑定的,也就是说每个执行上下文中都有一个 this。

执行上下文主要分三种:全局,函数,eval。因此 this 也有对应着这三种。

全局执行上下文中的 this 是指向 window 对象的。这也是 this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。全局函数中的 this 也指向 window。。。那有没有什么方式可以修改 this 指向呢?

答案是肯定的,

  1. 常用 call,apply,bind 三种方式之一。
  2. 对象来调用其内部的一个方法,该方法的 this 是指向对象本身的,obj.f()可以理解为 obj.f.call(obj),但是务必注意如果将一个函数赋值给一个全局变量,this 将指向全局
var myObj = {
  name: "极客时间",
  showThis: function() {
    this.name = "极客邦";
    console.log(this);
  }
};
var foo = myObj.showThis;
foo(); // 全局
  1. 通过构造函数,通过 new 关键字构建好了一个新对象,并且构造函数中的 this 其实就是新对象本身。代码如下:
var o = new Foo();
// 等价于
var o = new Object(); //1、新建空对象
o.__proto__ = Foo.prototype; //2、建立连接
let returnVal = Foo.call(o); //3、执行
if (typeof returnVal === "object") {
  //4、判断返回值
  return returnVal;
} else {
  return o;
}

this 的设计缺陷以及应对方案:

  1. 嵌套函数中的 this 不会从外层函数中继承
// bar函数里的this是什么呢?
var myObj = {
  name: "极客时间",
  showThis: function() {
    console.log(this);
    function bar() {
      console.log(this);
    }
    bar();
  }
};
myObj.showThis();

函数 bar 中的 this 指向的是全局 window 对象,而函数 showThis 中的 this 指向的是 myObj 对象。可以通过新增 self 或_this 变量传递一下,就可以保证 bar 里的 this 与其所在函数的 this 指向相同了。其实这样就是将 this 体系转为了作用域体系

当然还可以利用箭头函数,因为箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数

  1. 普通函数中的 this 默认指向 window 实际工作中,我们并不想让 this 默认指向 window,这时可以利用 call,apply,bind 等来显示改变,当然还可以设置 js 的严格模式,该模式下 this 默认指向 undefined。

v8 工作原理

栈空间和堆空间:数据是如何存储的?

每种编程语言都具有内建的数据类型,但它们的数据类型常有不同之处,使用方式也很不一样,比如 C 语言在定义变量之前,就需要确定变量的类型,我们把这种在使用之前就需要确认其变量数据类型的称为静态语言。

相反地,我们把在运行过程中需要检查数据类型的语言称为动态语言,比如 js,因为在声明之前不需要确定是哪种数据类型。

虽然 C 语言定义时声明了具体的数据类型,但依然可以将一个 int 类型赋值给布尔类型的变量,这是因为:C 编译器会把 int 型的变量悄悄转换为 bool 型的变量。而支持隐式类型转换的语言称为弱类型语言,不支持隐式类型转换的语言称为强类型语言。在这点上,C 和 JavaScript 都是弱类型语言。

而常见语言的类型分类:

  • 动态弱类型:Perl,PHP,VB,JavaScript
  • 动态强类型:Python,Ruby
  • 静态弱类型:C,C++
  • 静态强类型:C#,Java

ECMAScript标准规定了 7 种数据类型,分两种:原始类型和引用类型

原始类型:

  • Null:只包含一个值:null
  • Undefined:只包含一个值:undefined
  • Boolean:包含两个值:true 和 false
  • Number:整数或浮点数,还有一些特殊值(-Infinity、+Infinity、NaN)
  • String:一串表示文本值的字符序列
  • Symbol:一种实例是唯一且不可改变的数据类型
  • BigInt:es10(ECMAScript2019)

引用类型:

  • Object,

之所以把它们区分为两种不同的类型,是因为它们在内存中存放的位置不一样。

在 JavaScript 的执行过程中,主要有三种类型内存空间,分别是代码空间、栈空间和堆空间。其中代码空间主要用来存储可执行代码的。

栈空间和堆空间: 栈空间就是之前提及的调用栈,是用来存储执行上下文的,而执行上下文可以有多个,比如全局,某函数等等。原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的。堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。

不过你也许会好奇,为什么一定要分“堆”和“栈”两个存储空间呢?所有数据直接存放在“栈”中不就可以了吗? 答案是不可以的。这是因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。

在 JavaScript 中,赋值操作和其他语言有很大的不同,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。

垃圾回收:垃圾数据是如何自动回收的?

通常情况下,垃圾数据回收分为手动回收和自动回收两种策略。

如 C/C++ 就是使用手动回收策略,何时分配内存、何时销毁内存都是由代码控制的。

另外一种使用的是自动垃圾回收的策略,如 JavaScript、Java、Python 等语言,产生的垃圾数据是由垃圾回收器来释放的,并不需要手动通过代码来释放。对于 JavaScript 而言,也正是这个“自动”释放资源的特性带来了很多困惑,也让一些 JavaScript 开发者误以为可以不关心内存管理,这是一个很大的误解。

垃圾回收策略一般分为手动回收和自动回收,java python JavaScript 等高级预言为了减轻程序员负担和出错概率采用了自动回收策略。JavaScript 的原始类型数据和引用数据是分别存储在栈和椎中的,由于栈和堆分配空间大小差异,垃圾回收方式也不一样。栈中分配空间通过 ESP 的向下移动销毁保存在栈中数据;堆中垃圾回收主要通过副垃圾回收器(新生代)和主垃圾回收器(老生代)负责的,副垃圾回收器采用 scavenge 算法将区域分为对象区域和空闲区域,通过两个区域的反转让新生代区域无限使用下去。主垃圾回收器采用 Mark-Sweep(Mark-Compact Incremental Marking 解决不同场景下问题的算法改进)算法进行空间回收的。无论是主副垃圾回收器的策略都是标记-清除-整理三个大的步骤。另外还有新生代的晋升策略(两次未清除的),大对象直接分配在老生代。

编译器和解释器:V8 是如何执行一段 JavaScript 代码的?

前端工具和框架的自身更新速度非常块,而且还不断有新的出现。要想追赶上前端工具和框架的更新速度,你就需要抓住那些本质的知识,然后才能更加轻松地理解这些上层应用。比如我们接下来要介绍的 V8 执行机制,能帮助你从底层了解 JavaScript,也能帮助你深入理解语言转换器 Babel、语法检查工具 ESLint、前端框架 Vue 和 React 的一些底层实现机制

要深入理解 V8 的工作原理,你需要搞清楚一些概念和原理,比如接下来我们要详细讲解的编译器(Compiler)、解释器(Interpreter)、抽象语法树(AST)、字节码(Bytecode)、即时编译器(JIT)

编译器和解释器 之所以存在编译器和解释器,是因为机器不能直接理解我们所写的代码,所以在执行程序之前,需要将我们所写的代码“翻译”成机器能读懂的机器语言。按语言的执行流程,可以把语言划分为编译型语言和解释型语言。

编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。

而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。

V8 是如何执行一段 JavaScript 代码的?

V8 在执行过程中既有解释器 Ignition,又有编译器 TurboFan。

  1. 生成抽象语法树(AST)和执行上下文。 高级语言是开发者可以理解的语言,但是让编译器或者解释器来理解就非常困难了。对于编译器或者解释器来说,它们可以理解的就是 AST 了。所以无论你使用的是解释型语言还是编译型语言,在编译过程中,它们都会生成一个 AST。这和渲染引擎将 HTML 格式文件转换为计算机可以理解的 DOM 树的情况类似。其实可以把 AST 看成看成代码的结构化表示,编译器或者解释器后续的工作都需要依赖于 AST,而不是源代码

AST 是非常重要的一种数据结构,在很多项目中有着广泛的应用,Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。

还有 ESLint 也使用 AST。ESLint 是一个用来检查 JavaScript 编写规范的插件,其检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题。

第一阶段是分词(tokenize),又称为词法分析,其作用是将一行行的源码拆解成一个个 token。所谓 token,指的是语法上不可能再分的、最小的单个字符或字符串。例如 var myName = “极客时间”简单地定义了一个变量,其中关键字“var”、标识符“myName” 、赋值运算符“=”、字符串“极客时间”四个都是 token,而且它们代表的属性还不一样。

第二阶段是解析(parse),又称为语法分析。将上一步生成的 token 数据,根据语法规则转为 AST,若有语法错误,则会停止解析并报错。

第三阶段,有了 AST 后,那接下来 V8 就会生成该段代码的执行上下文

  1. 生成字节码 有了 AST 和执行上下文后,解释器就登场了,它会根据 AST 生成字节码,并执行字节码。

其实一开始 V8 并没有字节码,而是直接将 AST 转换为机器码,由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的。但是随着 Chrome 在手机上的广泛普及,特别是运行在 512M 内存的手机上,内存占用问题也暴露出来了,因为 V8 需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器,最终花了将进四年的时间,实现了现在的这套架构。

字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。注意机器码是和机器相关的,不同的机器对应的机器码不同。机器码占用内容大的原因是因为生成了大量的机器码,而字节码比较简短,占用空间小。

  1. 执行代码 生成字节码之后,接下来就进入执行阶段。

通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。到了这里,相信你已经发现了,解释器 Ignition 除了负责生成字节码之外,它还有另外一个作用,就是解释执行字节码。在 Ignition 执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

V8 的解释器和编译器的取名也很有意思。解释器 Ignition 是点火器的意思,编译器 TurboFan 是涡轮增压的意思,寓意着代码启动时通过点火器慢慢发动,一旦启动,涡轮增压介入,其执行效率随着执行时间越来越高效率,因为热点代码都被编译器 TurboFan 转换了机器码,直接执行机器码就省去了字节码“翻译”为机器码的过程。

其实字节码配合解释器和编译器是最近一段时间很火的技术,比如 Java 和 Python 的虚拟机也都是基于这种技术实现的,我们把这种技术称为即时编译(JIT)。具体到 V8,就是指解释器 Ignition 在解释执行字节码的同时,收集代码信息,当它发现某一部分代码变热了之后,TurboFan 编译器便闪亮登场,把热点的字节码转换为机器码,并把转换后的机器码保存起来,以备下次使用。

对于 JavaScript 工作引擎,除了 V8 使用了“字节码 +JIT”技术之外,苹果的 SquirrelFish Extreme 和 Mozilla 的 SpiderMonkey 也都使用了该技术。

JavaScript 的性能优化:

在 V8 诞生之初,也出现过一系列针对 V8 而专门优化 JavaScript 性能的方案,比如隐藏类、内联缓存等概念都是那时候提出来的。不过随着 V8 的架构调整,你越来越不需要这些微优化策略了,

相反,对于优化 JavaScript 执行效率,你应该将优化的中心聚焦在单次脚本的执行时间和脚本的网络下载上,主要关注以下三点内容:

  1. 提升单次脚本的执行速度,避免 JavaScript 的长任务霸占主线程,这样可以使得页面快速响应交互;
  2. 避免大的内联脚本,因为在解析 HTML 的过程中,解析和编译也会占用主线程;
  3. 减少 JavaScript 文件的容量,因为更小的文件会提升下载速度,并且占用更低的内存。

注意:

  • 编译的单位是全局代码或函数,比如下载完一个 js 文件,先编译这个 js 文件,但是 js 文件内定义的函数是不会编译的。等调用到该函数的时候,Javascript 引擎才会去编译该函数!
  • 最后反正都需要字节码,为何不直接编译成字节码?可以认为 WebAssembly 就是,WebAssembly 经过 TuboFan 处理下就能执行
  • 字节码最终也会转为机器码,因为最后都是 cpu 来执行,cpu 只执行机器码

浏览器中的页面循环系统

消息队列和事件循环:页面是怎么“活”起来的?

每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务。

首先,我们可以使用单线程来处理已经安排好的的任务,但是,有时候会在执行安排好的任务时,又收到新任务改如何执行的?这就需要一个循环系统,来监听是否有新任务,而监听是否有新任务是通过事件机制。因此也就是事件循环机制。

前面接受的任务都是来自于线程内部,那如果是新任务来自另一个线程呢?这就需要一个消息队列,其他线程的任务就会压入消息队列,渲染主线程会循环地从消息队列头部中读取任务,执行任务。

但如何处理其他进程的消息呢?渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,然后再消息组装成任务,放在消息队列(队列里就是待执行的任务)。

那消息队列里的任务都有哪些类型呢?如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。以上这些事件都是在主线程中执行的,所以在编写 Web 应用时,你还需要衡量这些事件所占用的时长,并想办法解决单个任务占用主线程过久的问题。

页面使用单线程的缺点

  • 如何处理高优先级的任务。

如果一个高优先级的任务来了,立马就执行回调,则会影响当前任务 的执行效率。如果采用异步方式,添加到消息队列的尾部,又会影响实时性。如何权衡实时性和效率性?针对这种情况就出现了微任务

通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。

等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。

  • 如何解决单个任务执行时长过久的问题?

因为所有的任务都是在单线程中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间,对这种情况,JavaScript 可以通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。

WebAPI:setTimeout 是如何实现的?

执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,你不能将定时器的回调函数直接添加到消息队列中。

在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务,可以理解为一个延迟队列(其实是 hashmap 结构,这里只是和消息队列区分开)。

在执行完一个消息队列里的任务后,会检查延迟队列,若不为空,则会检查这些延迟队列里的任务是否到期,到期的话就会执行。等到期的任务执行完成之后,再继续执行下一个循环过程。

设置定时器,js 引擎会返回一个定时器的 ID,通常一个定时器的任务再没有被执行的时候,可以取消。其实浏览器内部实现取消定时器的操作也是非常简单的,就是直接从延迟队列中,通过 ID 查找到对应的任务,然后再将其从队列中删除掉就可以了。

使用 setTimeout 的一些注意事项:

  1. 如果当前任务执行时间过久,会影响延迟队里里到期定时器任务的执行
  2. 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒

也就是说在定时器函数里面嵌套调用定时器,也会延长定时器的执行时间,如下:

function cb() {
  setTimeout(cb, 0);
}
setTimeout(cb, 0);

通过 Performance 可以看到,前面五次调用的时间间隔比较小,嵌套调用超过五次以上,后面每次的调用最小时间间隔是 4 毫秒。之所以出现这样的情况,是因为在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。

  1. 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒

除了前面的 4 毫秒延迟,还有一个很容易被忽略的地方,那就是未被激活的页面中定时器最小值大于 1000 毫秒,也就是说,如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。

  1. 延时执行时间有最大值

Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被立即执行。你可以运行下面这段代码:

function showName() {
  console.log("极客时间");
}
var timerID = setTimeout(showName, 2147483648); //会被理解调用执行
  1. 使用 setTimeout 设置的回调函数中的 this 不符合直觉

如果被 setTimeout 推迟执行的回调函数是某个对象的方法,那么该方法中的 this 关键字将指向全局环境,而不是定义时所在的那个对象。

var name = 1;
var MyObj = {
  name: 2,
  showName: function() {
    console.log(this.name);
  }
};
setTimeout(MyObj.showName, 1000); // 1

// 解决办法

//箭头函数
setTimeout(() => {
  MyObj.showName();
}, 1000);

//或者function函数
setTimeout(function() {
  MyObj.showName();
}, 1000);

// 或者bind
setTimeOut(MyObj.showName.bind(MyObj), 1000);

综上:setTimeout 设置的回调任务实时性并不是太好,所以很多场景并不适合使用 setTimeout。比如你要使用 JavaScript 来实现动画效果,函数 requestAnimationFrame 就是个很好的选择。

WebAPI:XMLHttpRequest 是怎么实现的?

function GetWebData(URL) {
  /**
   * 1:新建XMLHttpRequest请求对象
   */
  let xhr = new XMLHttpRequest();

  /**
   * 2:注册相关事件回调处理函数
   */
  xhr.onreadystatechange = function() {
    switch (xhr.readyState) {
      case 0: //请求未初始化
        console.log("请求未初始化");
        break;
      case 1: //OPENED
        console.log("OPENED");
        break;
      case 2: //HEADERS_RECEIVED
        console.log("HEADERS_RECEIVED");
        break;
      case 3: //LOADING
        console.log("LOADING");
        break;
      case 4: //DONE
        if (this.status == 200 || this.status == 304) {
          console.log(this.responseText);
        }
        console.log("DONE");
        break;
    }
  };

  xhr.ontimeout = function(e) {
    console.log("ontimeout");
  };
  xhr.onerror = function(e) {
    console.log("onerror");
  };

  /**
   * 3:打开请求
   */
  xhr.open("Get", URL, true); //创建一个Get请求,采用异步

  /**
   * 4:配置参数
   */
  xhr.timeout = 3000; //设置xhr请求的超时时间
  xhr.responseType = "text"; //设置响应返回的数据格式
  xhr.setRequestHeader("X_TEST", "time.geekbang");

  /**
   * 5:发送请求
   */
  xhr.send();
}

第一步:创建 XMLHttpRequest 对象。

第二步:为 xhr 对象注册回调函数。

因为网络请求比较耗时,所以要注册回调函数,这样后台任务执行完成之后就会通过调用回调函数来告诉其执行结果。

第三步:配置基础的请求信息。

注册好回调事件之后,接下来就需要配置基础的请求信息了,通过 open 接口配置一些基础的请求信息,包括请求的地址、请求方法(是 get 还是 post)和请求方式(同步还是异步请求)

还可以通过 xhr.responseType = “text”来配置服务器返回的格式,将服务器返回的数据自动转换为自己想要的格式,如果将 responseType 的值设置为 json,那么系统会自动将服务器返回的数据转换为 JavaScript 对象格式。还可以通过 xhr.setRequestHeader 来添加自定义请求头

第四步:发起请求。

渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。

XMLHttpRequest 使用过程中的“坑”?

  • 跨域问题
var xhr = new XMLHttpRequest();
var url = "https://time.geekbang.org/";
function handler() {
  switch (xhr.readyState) {
    case 0: //请求未初始化
      console.log("请求未初始化");
      break;
    case 1: //OPENED
      console.log("OPENED");
      break;
    case 2: //HEADERS_RECEIVED
      console.log("HEADERS_RECEIVED");
      break;
    case 3: //LOADING
      console.log("LOADING");
      break;
    case 4: //DONE
      if (this.status == 200 || this.status == 304) {
        console.log(this.responseText);
      }
      console.log("DONE");
      break;
  }
}

function callOtherDomain() {
  if (xhr) {
    xhr.open("GET", url, true);
    xhr.onreadystatechange = handler;
    xhr.send();
  }
}
callOtherDomain();

在控制台打开上面代码,就会提示跨域 。

  • https 混合内容的问题。

HTTPS 混合内容是 HTTPS 页面中包含了不符合 HTTPS 安全要求的内容,比如包含了 HTTP 资源,通过 HTTP 加载的图像、视频、样式表、脚本等,都属于混合内容。通常,如果 HTTPS 请求页面中使用混合内容,浏览器会针对 HTTPS 混合内容显示警告,用来向用户表明此 HTTPS 页面包含不安全的资源。

通过 HTML 文件加载的混合资源,虽然给出警告,但大部分类型还是能加载的。而使用 XMLHttpRequest 请求时,浏览器认为这种请求可能是攻击者发起的,会阻止此类危险的请求。比如我通过浏览器打开地址 https://www.iteye.com/groups ,然后通过控制台,使用 XMLHttpRequest 来请求 http://img-ads.csdn.net/2018/201811150919211586.jpg ,这时候请求就会报错:

Mixed Content: The page at 'https://www.google.com/search?q=%E7%99%BE%E5%BA%A6%E5%9C%B0%E5%9B%BE&rlz=1C5CHFA_enUS880US881&oq=%E7%99%BE%E5%BA%A6%E5%9C%B0%E5%9B%BE&aqs=chrome..69i57j0l7.5259j0j4&sourceid=chrome&ie=UTF-8' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint 'http://img-ads.csdn.net/2018/201811150919211586.jpg'. This request has been blocked; the content must be served over HTTPS.

综上:这里不单纯地讲一个问题,而是将回调类型、循环系统、网络请求和安全问题“串联”起来了。

setTimeout 是直接将延迟任务添加到延迟队列中,而 XMLHttpRequest 发起请求,是由浏览器的其他进程或者线程去执行,然后再将执行结果利用 IPC 的方式通知渲染进程,之后渲染进程再将对应的消息添加到消息队列中

宏任务和微任务:不是所有任务都是一个待遇

宏任务

前面我们已经知道,页面中的大部分任务都是在主线程上执行的,这些任务包括了:

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成、文件读写完成事件。

为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务

宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以胜任了。页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。

微任务

先来了解一下异步回调放入任务队列的方式,主要有两种:

第一种是把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数。如 setTimeout 和 XMLHttpRequest 的回调函数都是通过这种方式来实现的。

第二种方式的执行时机是在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的,其实就是放入微任务队列里。

产生微任务的方式

第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。

第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

微任务的执行时刻:在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。

在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

监听 DOM 变化方法演变

早起使用 Mutation Event 这种观察者的设计模式,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调,缺点是频繁调用 js 接口造成严重性能问题。

后来 MutationObserver 来代替 Mutation Event,MutationObserver 将响应函数改成异步调用,可以不用在每次 DOM 变化都触发异步调用,而是等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。这样即使频繁地操纵 DOM,也不会对性能造成太大的影响。

通过异步调用和减少触发次数来缓解了性能问题,那么如何保持消息通知的及时性呢

这时候,微任务就可以上场了,在每次 DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8 引擎就会按照顺序执行微任务了。

总结:

  • 通过异步操作解决了同步操作的性能问题
  • 通过微任务解决了实时性的问题

Promise:使用 Promise,告别回调函数

学习一门新技术,最好的方式是先了解这门技术是如何诞生的,以及它所 解决的问题是什么。而 Promise 解决的是异步编码风格的问题

先来了解一下JavaScript 的异步编程模型:页面主线程发起了一个耗时的任务,并将任务交给另外一个进程去处理,这时页面主线程会继续执行消息队列中的任务。等该进程处理完这个任务后,会将该任务添加到渲染进程的消息队列中(等到处理完这个任务后),并排队等待循环系统的处理。排队结束之后,循环系统会取出消息队列中的任务进行处理,并触发相关的回调操作。

而 web 页面的但线程结构决定了异步回调,而异步回调影响了我们的编码方式,代码逻辑不连续及回调地狱(后面的请求依赖于前面的请求),因此解决思路:消灭嵌套调用,合并多个任务的错误处理。

Promise 通过回调函数延迟绑定和回调函数返回值穿透的技术,解决了循环嵌套。

因为 Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被 onReject 函数处理或 catch 语句捕获为止。具备了这样“冒泡”的特性后,就不需要在每个 Promise 对象中单独捕获异常了

async/await:使用同步的方式去写异步代码

使用 Promise 会让逻辑里充满大量的 then,使得代码不易阅读 ,基于这个原因,ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。

fetch("https://www.geekbang.org")
  .then(response => {
    console.log(response);
    return fetch("https://www.geekbang.org/test");
  })
  .then(response => {
    console.log(response);
  })
  .catch(error => {
    console.log(error);
  });

// 使用await/async
async function foo() {
  try {
    let response1 = await fetch("https://www.geekbang.org");
    console.log("response1");
    console.log(response1);
    let response2 = await fetch("https://www.geekbang.org/test");
    console.log("response2");
    console.log(response2);
  } catch (err) {
    console.error(err);
  }
}
foo();

整个异步处理的逻辑都是使用同步代码的方式来实现的,而且还支持 try catch 来捕获异常,这就是完全在写同步代码,所以是非常符合人的线性思维的。

要想明白 async 和 await 到底是怎么工作的,我们首先需要介绍生成器(Generator)是如何工作的,然后分析 Generator 的底层实现机制——协程,而 async/await 使用 Generator 和 Promise 两种技术。

生成器 VS 协程

生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的。

function* genDemo() {
  console.log("开始执行第一段");
  yield "generator 2";

  console.log("开始执行第二段");
  yield "generator 2";

  console.log("开始执行第三段");
  yield "generator 2";

  console.log("执行结束");
  return "generator 2";
}

console.log("main 0");
let gen = genDemo();
console.log(gen.next().value);
console.log("main 1");
console.log(gen.next().value);
console.log("main 2");
console.log(gen.next().value);
console.log("main 3");
console.log(gen.next().value);
console.log("main 4");

生成器函数的具体使用方式:

  1. 在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行
  2. 外部函数可以通过 next 方法恢复函数的执行。

那函数为何为暂停和恢复呢?原因就在于协程,协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程,比如当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;

正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源

则根据上面代码看出协程的执行顺序:

  1. 通过调用生成器函数 genDemo 来创建一个协程 gen,创建之后,gen 协程并没有立即执行。
  2. 要让 gen 协程执行,需要通过调用 gen.next。
  3. 当协程正在执行的时候,可以通过 yield 关键字来暂停 gen 协程的执行,并返回主要信息给父协程。
  4. 如果协程在执行期间,遇到了 return 关键字,那么 JavaScript 引擎会结束当前协程,并将 return 后面的内容返回给父协程。

注意:

  1. gen 协程和父协程是在主线程上交互执行的,并不是并发执行的,它们之间的切换是通过 yield 和 gen.next 来配合完成的。
  2. 当在 gen 协程中调用了 yield 方法时,JavaScript 引擎会保存 gen 协程当前的调用栈信息,并恢复父协程的调用栈信息。同样,当在父协程中执行 gen.next 时,JavaScript 引擎会保存父协程的调用栈信息,并恢复 gen 协程的调用栈信息。

其实在 js 中,生成器只是协程的一种实现方式。

再用生成器和 promise 来改造开始的代码:

//foo函数
function* foo() {
  let response1 = yield fetch("https://www.geekbang.org");
  console.log("response1");
  console.log(response1);
  let response2 = yield fetch("https://www.geekbang.org/test");
  console.log("response2");
  console.log(response2);
}

//执行foo函数的代码
let gen = foo();
function getGenPromise(gen) {
  return gen.next().value;
}
getGenPromise(gen)
  .then(response => {
    console.log("response1");
    console.log(response);
    return getGenPromise(gen);
  })
  .then(response => {
    console.log("response2");
    console.log(response);
  });

不过通常,我们把执行生成器的代码封装成一个函数,并把这个执行生成器代码的函数称为执行器(可参考著名的 co 框架),如下面这种方式:

function* foo() {
  let response1 = yield fetch("https://www.geekbang.org");
  console.log("response1");
  console.log(response1);
  let response2 = yield fetch("https://www.geekbang.org/test");
  console.log("response2");
  console.log(response2);
}
co(foo());

通过使用生成器配合执行器,就能实现使用同步的方式写出异步代码了。但程序员的追求是无止境的,因此又在 ES7 中引入了 async/await,这种方式彻底告别了执行器和生成器。async/await 技术背后的秘密就是 Promise 和生成器应用,往低层说就是微任务和协程应用

1. async 根据 MDN 定义,async 是一个通过异步执行隐式返回 Promise 作为结果的函数。

async function foo() {
  return 2;
}
console.log(foo()); // Promise {<resolved>: 2}

执行这段代码,可以看到 async 声明的 foo 函数返回一个 promise 对象,状态是 resolved。

2. await 我们知道了 async 函数返回一个 Promise 对象,那下面的 await 到底是什么呢?

async function foo() {
  console.log(1);
  let a = await 100;
  console.log(a);
  console.log(2);
}
console.log(0);
foo();
console.log(3);
// 0 1 3 100 2

执行顺序:

  1. 首先,执行 console.log(0)这个语句,打印出来 0。
  2. 紧接着就是执行 foo 函数,由于 foo 函数是被 async 标记过的,所以当进入该函数的时候,JavaScript 引擎会保存当前的调用栈等信息,然后执行 foo 函数中的 console.log(1)语句,并打印出 1。
  3. 当执行到 await 100 时,会默认创建一个 Promise 对象,代码如下:
let promise_ = new Promise((resolve,reject){
  resolve(100)
})

在这个 promise_ 对象创建的过程中,我们可以看到在 executor 函数中调用了 resolve 函数,JavaScript 引擎会将该任务提交给微任务队列。

  1. 然后 JavaScript 引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将 promise* 对象返回给父协程。这时候父协程要做的一件事是调用 promise*.then 来监控 promise 状态的改变。
  2. 接下来继续执行父协程的流程,这里我们执行 console.log(3),并打印出来 3。随后父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列,微任务队列中有 resolve(100)的任务等待执行,执行到这里的时候,会触发 promise_.then 中的回调函数,该回调函数被激活以后,会将主线程的控制权交给 foo 函数的协程,并同时将 value 值传给该协程。
  3. foo 协程激活之后,会把刚才的 value 值赋给了变量 a,然后 foo 协程继续执行后续语句,执行完成之后,将控制权归还给父协程。

浏览器中的页面

Chrome 开发者工具:利用网络面板做性能分析

Chrome 开发者工具(简称 DevTools)是一组网页制作和调试的工具,内嵌于 Google Chrome 浏览器中。

一共包含了 10 个功能面板,包括了 Elements、Console、Sources、NetWork、Performance、Memory、Application、Security、Audits 和 Layers。简单来说,Chrome 开发者工具为我们提供了通过界面访问或者编辑 DOM 和 CSSOM 的能力,还提供了强大的调试功能和查看性能指标的能力。

这里只查看单个资源的时间线:NetWork - 点击任意一个下载资源 - 右侧 Timing 查看

第一个是 Queuing,也就是排队的意思,当浏览器发起一个请求的时候,会有很多原因导致该请求不能被立即执行,而是需要排队等待。导致请求处于排队状态的原因有很多。

  1. 首先,页面中的资源是有优先级的,比如 CSS、HTML、JavaScript 等都是页面中的核心文件,所以优先级最高;而图片、视频、音频这类资源就不是核心资源,优先级就比较低。通常当后者遇到前者时,就需要“让路”,进入待排队状态。
  2. 其次,我们前面也提到过,浏览器会为每个域名最多维护 6 个 TCP 连接,如果发起一个 HTTP 请求时,这 6 个 TCP 连接都处于忙碌状态,那么这个请求就会处于排队状态。 3.最后,网络进程在为数据分配磁盘空间时,新的 HTTP 请求也需要短暂地等待磁盘分配结束。

第二个是 Stalled,等待排队完成之后,就要进入发起连接的状态了。不过在发起连接之前,还有一些原因可能导致连接过程被推迟,这个推迟就表现在面板中的 Stalled 上,它表示停滞的意思。

如果你使用了代理服务器,还会增加一个 Proxy Negotiation 阶段,也就是代理协商阶段,它表示代理服务器连接协商所用的时间

第三个是 Initial connection/SSL 阶段,也就是和服务器建立连接的阶段,这包括了建立 TCP 连接所花费的时间;不过如果你使用了 HTTPS 协议,那么还需要一个额外的 SSL 握手时间,这个过程主要是用来协商一些加密信息的。

第四个是 Request sent 阶段,和服务器建立好连接之后,网络进程会准备请求数据,并将其发送给网络,这就是 Request sent 阶段。通常这个阶段非常快,因为只需要把浏览器缓冲区的数据发送出去就结束了,并不需要判断服务器是否接收到了,所以这个时间通常不到 1 毫秒。

第五个是 Waiting (TTFB),数据发送出去了,接下来就是等待接收服务器第一个字节的数据,这个阶段称为 Waiting (TTFB),通常也称为“第一字节时间”。 TTFB 是反映服务端响应速度的重要指标,对服务器来说,TTFB 时间越短,就说明服务器响应越快。

第五个是 Content Download 阶段,接收到第一个字节之后,进入陆续接收完整数据的阶段,也就是 Content Download 阶段,这意味着从第一字节时间到接收到全部响应数据所用的时间。

优化时间线上耗时项:

  1. 排队(Queuing)时间过久

排队时间过久,大概率是由浏览器为每个域名最多维护 6 个连接导致的。那么基于这个原因,你就可以让 1 个站点下面的资源放在多个域名下面,比如放到 3 个域名下面,这样就可以同时支持 18 个连接了,这种方案称为域名分片技术。除了域名分片技术外,我个人还建议你把站点升级到 HTTP2,因为 HTTP2 已经没有每个域名最多维护 6 个 TCP 连接的限制了。

  1. 第一字节时间(TTFB)时间过久
  • 服务器生成页面数据的时间过久。对于动态网页来说,服务器收到用户打开一个页面的请求时,首先要从数据库中读取该页面需要的数据,然后把这些数据传入到模板中,模板渲染后,再返回给用户。服务器在处理这个数据的过程中,可能某个环节会出问题。
  • 网络的原因。比如使用了低带宽的服务器,或者本来用的是电信的服务器,可联通的网络用户要来访问你的服务器,这样也会拖慢网速。
  • 发送请求头时带上了多余的用户信息。比如一些不必要的 Cookie 信息,服务器接收到这些 Cookie 信息之后可能需要对每一项都做处理,这样就加大了服务器的处理时长。

面对第一种服务器的问题,你可以想办法去提高服务器的处理速度,比如通过增加各种缓存的技术;针对第二种网络问题,你可以使用 CDN 来缓存一些静态文件;至于第三种,你在发送请求时就去尽可能地减少一些不必要的 Cookie 数据信息。

  1. Content Download 时间过久

如果单个请求的 Content Download 花费了大量时间,有可能是字节数太多的原因导致的。这时候你就需要减少文件大小,比如压缩、去掉源码中不必要的注释等方法。

DOM 树:JavaScript 是如何影响 DOM 树构建的?

什么是 DOM

从网络传给渲染引擎的 HTML 文件字节流是无法直接被渲染引擎理解的,所以要将其转化为渲染引擎能够理解的内部结构,这个结构就是 DOM。DOM 提供了对 HTML 文档结构化的表述,在渲染引擎中,DOM 有三个层面的作用:

  • 从页面的视角来看,DOM 是生成页面的基础数据结构
  • 从 JavaScript 脚本视角来看,DOM 提供给 JavaScript 脚本操作的接口,通过这套接口,JavaScript 可以对 DOM 结构进行访问,从而改变文档的结构、样式和内容。
  • 从安全视角来看,DOM 是一道安全防护线,一些不安全的内容在 DOM 解析阶段就被拒之门外了。

简言之,DOM 是表述 HTML 的内部数据结构,它会将 Web 页面和 JavaScript 脚本连接起来,并过滤一些不安全的内容。

在渲染引擎内部,有一个叫 HTML 解析器(HTMLParser)的模块,它的职责就是负责将 HTML 字节流转换为 DOM 结构。HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据

那详细的流程是怎样的呢?网络进程接收到响应头之后,会根据响应头中的 content-type 字段来判断文件的类型,比如 content-type 的值是“text/html”,那么浏览器就会判断这是一个 HTML 类型的文件,然后为该请求选择或者创建一个渲染进程。渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据“喂”给 HTML 解析器。你可以把这个管道想象成一个“水管”,网络进程接收到的字节流(其实可以理解为 html 文档)像水一样倒进这个“水管”,而“水管”的另外一端是渲染进程的 HTML 解析器,它会动态接收字节流,并将其解析为 DOM。

JavaScript 是如何影响 DOM 生成的

<html>
  <body>
    <div>1</div>
    <script>
      let div1 = document.getElementsByTagName("div")[0];
      div1.innerText = "time.geekbang";
    </script>
    <div>test</div>
  </body>
</html>

当解析到<script>标签时,渲染引擎判断这是一段脚本,此时 HTML 解析器就会暂停 DOM 的解析,因为接下来的 JavaScript 可能要修改当前已经生成的 DOM 结构。

如果是外链 js 脚本,当执行到 JavaScript 标签时,暂停整个 DOM 的解析,执行 JavaScript 代码,不过这里执行 JavaScript 时,需要先下载这段 JavaScript 代码。这里需要重点关注下载环境,因为 JavaScript 文件的下载过程会阻塞 DOM 解析,而通常下载又是非常耗时的,会受到网络环境、JavaScript 文件大小等因素的影响。

不过 Chrome 浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。

我们可以使用 CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积。另外,如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码,async 加载完立即执行,而 defer 是在 DOMContentLoaded 事件之前执行。

<html>
  <head>
    <style src="theme.css"></style>
  </head>
  <body>
    <div>1</div>
    <script>
      let div1 = document.getElementsByTagName("div")[0];
      div1.innerText = "time.geekbang"; //需要DOM
      div1.style.color = "red"; //需要CSSOM
    </script>
    <div>test</div>
  </body>
</html>

JavaScript 代码出现了 div1.style.color = ‘red’ 的语句,它是用来操纵 CSSOM 的,所以在执行 JavaScript 之前,需要先解析 JavaScript 语句之上所有的 CSS 样式。所以如果代码里引用了外部的 CSS 文件,那么在执行 JavaScript 之前,还需要等待外部的 CSS 文件下载完成,并解析生成 CSSOM 对象之后,才能执行 JavaScript 脚本。

而 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操纵了 CSSOM 的,所以渲染引擎在遇到 JavaScript 脚本时,不管该脚本是否操纵了 CSSOM,都会执行 CSS 文件下载,解析操作,再执行 JavaScript 脚本

综上,我们知道了 JavaScript 会阻塞 DOM 生成,而样式文件又会阻塞 JavaScript 的执行。

额外说明一下,渲染引擎还有一个安全检查模块叫 XSSAuditor,是用来检测词法安全的。在分词器解析出来 Token 之后,它会检测这些模块是否安全,比如是否引用了外部脚本,是否符合 CSP 规范,是否存在跨站点请求等。如果出现不符合规范的内容,XSSAuditor 会对该脚本或者下载任务进行拦截。

渲染流水线:CSS 如何影响首次加载时的白屏时间?

渲染流水线为什么需要 CSSOM 呢?

和 HTML 一样,渲染引擎也是无法直接理解 CSS 文件内容的,所以需要将其解析成渲染引擎能够理解的结构,这个结构就是 CSSOM。和 DOM 一样,CSSOM 也具有两个作用,第一个是提供给 JavaScript 操作样式表的能力,第二个是为布局树的合成提供基础的样式信息。

场景一:

<html>
  <head>
    <link href="theme.css" rel="stylesheet" />
  </head>
  <body>
    <div>geekbang com</div>
  </body>
</html>

首先是发起主页面的请求,发起方可能是渲染进程也可能是浏览器进程,请求被送到网络进程,网络进程收到返回的 HTML 数据之后,将其发送给渲染进程,渲染进程会解析 HTML 数据并构建 DOM。这里你需要特别注意下,请求 HTML 数据和构建 DOM 中间有一段空闲时间,这个空闲时间有可能成为页面渲染的瓶颈。

我们还知道,当渲染进程接收 HTML 文件字节流时,会先开启一个预解析线程,如果遇到 JavaScript 文件或者 CSS 文件,那么预解析线程会提前下载这些数据。对于上面的代码,预解析线程会解析出来一个外部的 theme.css 文件,并发起 theme.css 的下载。这里也有一个空闲时间需要你注意一下,就是在 DOM 构建结束之后、theme.css 文件还未下载完成的这段时间内,渲染流水线无事可做,因为下一步是合成布局树,而合成布局树需要 CSSOM 和 DOM,所以这里需要等待 CSS 加载结束并解析成 CSSOM。

场景二:

<html>
  <head>
    <link href="theme.css" rel="stylesheet" />
  </head>
  <body>
    <div>geekbang com</div>
    <script>
      console.log("time.geekbang.org");
    </script>
    <div>geekbang com</div>
  </body>
</html>

这个场景只是在 body 标签内部加了一个简单的 JavaScript。渲染流程就变了。。。

在解析 DOM 的过程中,如果遇到了 JavaScript 脚本,那么需要先暂停 DOM 解析去执行 JavaScript,因为 JavaScript 有可能会修改当前状态下的 DOM。不过在执行 JavaScript 脚本之前,如果页面中包含了外部 CSS 文件的引用,或者通过 style 标签内置了 CSS 内容,那么渲染引擎还需要将这些内容转换为 CSSOM,因为 JavaScript 有修改 CSSOM 的能力,所以在执行 JavaScript 之前,还需要依赖 CSSOM。也就是说 CSS 在部分情况下也会阻塞 DOM 的生成。

场景三:

<html>
  <head>
    <link href="theme.css" rel="stylesheet" />
  </head>
  <body>
    <div>geekbang com</div>
    <script src="foo.js"></script>
    <div>geekbang com</div>
  </body>
</html>

页面通过外部引用,分别引用了 css 和 js,渲染流程又如何呢?

在接收到 HTML 数据之后的预解析过程中,HTML 预解析器识别出来了有 CSS 文件和 JavaScript 文件需要下载,然后就同时发起这两个文件的下载请求,需要注意的是,这两个文件的下载过程是重叠的,所以下载时间按照最久的那个文件来算。

不管 CSS 文件和 JavaScript 文件谁先到达,都要先等到 CSS 文件下载完成并生成 CSSOM,然后再执行 JavaScript 脚本,最后再继续构建 DOM,构建布局树,绘制页面。

综上可知,其实只要有 js 和 css 共存的情况下,css 都会阻塞 dom 的生成,因为 dom 生成依赖 js,而 js 又依赖 cssom(这里只是说 js 有操作 cssom 的可能,因此必须生成 cssom 后,js 才可以执行)。

影响页面展会的因素以及优化策略?

从发起 URL 请求开始,到首次显示页面的内容,在视觉上经历三个阶段:

  1. 第一个阶段,等请求发出去之后,到提交数据阶段,这时页面展示出来的还是之前页面的内容。
  2. 第二个阶段,提交数据之后渲染进程会创建一个空白页面,我们通常把这段时间称为解析白屏,并等待 CSS 文件和 JavaScript 文件的加载完成,生成 CSSOM 和 DOM,然后合成布局树,最后还要经过一系列的步骤准备首次渲染。
  3. 等首次渲染完成之后,就开始进入完整页面的生成阶段了,然后页面会一点点被绘制出来。

阶段一影响因素主要集中在网络或者服务器处理这块。

阶段二的主要任务为:解析 HTML、下载 CSS、下载 JavaScript、生成 CSSOM、执行 JavaScript、生成布局树、绘制页面一系列操作。因此瓶颈主要在下载 CSS 文件、下载 JavaScript 文件和执行 JavaScript,则可以采取以下优化措施:

  • 通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了。
  • 但并不是所有的场合都适合内联,那么还可以尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件。
  • 还可以将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 sync 或者 defer。
  • 对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件。

分层和合成机制:为什么 CSS 动画比 JavaScript 高效?

显示器是怎么显示图像的?

每个显示器都有固定的刷新频率,通常是 60HZ,也就是每秒更新 60 张图片,更新的图片都来自于显卡中一个叫前缓冲区的地方,显示器所做的任务很简单,就是每秒固定读取 60 次前缓冲区中的图像,并将读取的图像显示到显示器上。

那么这里显卡做什么呢?

显卡的职责就是合成新的图像,并将图像保存到后缓冲区中,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到最新显卡合成的图像。通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但有时候,在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。

帧 VS 帧率

大多数设备屏幕的更新频率是 60 次 / 秒,这也就意味着正常情况下要实现流畅的动画效果,渲染引擎需要每秒更新 60 张图片到显卡的后缓冲区。我们把渲染流水线生成的每一副图片称为一帧,把渲染流水线每秒更新了多少帧称为帧率,比如滚动过程中 1 秒更新了 60 帧,那么帧率就是 60Hz(或者 60FPS)。

如果在一次动画过程中,渲染引擎生成某些帧的时间过久,那么用户就会感受到卡顿,为此 Chrome 对浏览器渲染方式做了大量的工作,其中最卓有成效的策略就是引入了分层和合成机制

而生成一帧的方式有:重排、重绘和合成三种方式。很明显越是涉及的方式越多,生成图像发费的时间也就越多。如果只是利用了合成,且有 GPU 参与,则会大大缩短时间。其中重排和重绘操作都是在渲染进程的主线程上执行的,比较耗时;而合成操作是在渲染进程的合成线程上执行的,执行速度快,且不占用主线程。

而 Chrome 浏览器是怎么实现合成操作的?

用三个词来概括总结:分层、分块和合成。

你可以把一张网页想象成是由很多个图片叠加在一起的,每个图片就对应一个图层,Chrome 合成器最终将这些图层合成了用于显示页面的图片。在这个过程中,将素材分解为多个图层的操作就称为分层,最后将这些图层合并到一起的操作就称为合成。所以,分层和合成通常是一起使用的。

如果将一个页面被划分为两个层,当进行到下一帧的渲染时,上面的一帧可能需要实现某些变换,如平移、旋转、缩放、阴影或者 Alpha 渐变,这时候合成器只需要将两个层进行相应的变化操作就可以了,显卡处理这些操作驾轻就熟,所以这个合成过程时间非常短。

页面性能:如何系统地优化页面?

这里我们所谈论的页面优化,其实就是要让页面更快地显示和响应。由于一个页面在它不同的阶段,所侧重的关注点是不一样的,所以如果我们要讨论页面优化,就要分析一个页面生存周期的不同阶段。

通常一个页面有三个阶段:加载阶段、交互阶段和关闭阶段。

  • 加载阶段,是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和 JavaScript 脚本。
  • 交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是 JavaScript 脚本。
  • 关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。

加载阶段

我们知道了并非所有的资源都会阻塞页面的首次绘制,比如图片、音频、视频等文件就不会阻塞页面的首次渲染;而 JavaScript、首次请求的 HTML 资源文件、CSS 文件是会阻塞首次渲染的,因为在构建 DOM 的过程中需要 HTML 和 JavaScript 文件,在构造渲染树的过程中需要用到 CSS 文件。

我们把这些能阻塞网页首次渲染的资源称为关键资源。基于关键资源,我们可以继续细化出来三个影响页面首次渲染的核心因素。

第一个是关键资源个数。关键资源个数越多,首次页面的加载时间就会越长。

第二个是关键资源大小。通常情况下,所有关键资源的内容越小,其整个资源的下载时间也就越短,那么阻塞渲染的时间也就越短。

第三个是请求关键资源需要多少个 RTT(Round Trip Time),当使用 TCP 协议传输一个文件时,比如这个文件大小是 0.1M,由于 TCP 的特性,这个数据并不是一次传输到服务端的,而是需要拆分成一个个数据包来回多次进行传输的。RTT 就是这里的往返时延。它是网络中一个重要的性能指标,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延。通常 1 个 HTTP 的数据包在 14KB 左右,所以 1 个 0.1M 的页面就需要拆分成 8 个包来传输了,也就是说需要 8 个 RTT。

注意:至于 JavaScript 和 CSS 文件,这里需要注意一点,由于渲染引擎有一个预解析的线程,在接收到 HTML 数据之后,预解析线程会快速扫描 HTML 数据中的关键资源,一旦扫描到了,会立马发起请求,你可以认为 JavaScript 和 CSS 是同时发起请求的(如果同时存在外链 js 和 css),所以它们的请求是重叠的,那么计算它们的 RTT 时,只需要计算体积最大的那个数据就可以了。

因此综上,在加载阶段优化措施主要从减少关键资源个数,降低关键资源大小,降低关键资源的 RTT 次数几个方面入手即可。

  • 如何减少关键资源的个数?一种方式是可以将 JavaScript 和 CSS 改成内联的形式,比如上图的 JavaScript 和 CSS,若都改成内联模式,那么关键资源的个数就由 3 个减少到了 1 个。另一种方式,如果 JavaScript 代码没有 DOM 或者 CSSOM 的操作,则可以改成 async 或者 defer 属性;同样对于 CSS,如果不是在构建页面之前加载的,则可以添加媒体取消阻止显现的标志。当 JavaScript 标签加上了 async 或者 defer、CSSlink 属性之前加上了取消阻止显现的标志后,它们就变成了非关键资源了。
  • 如何减少关键资源的大小?可以压缩 CSS 和 JavaScript 资源,移除 HTML、CSS、JavaScript 文件中一些注释内容,也可以通过前面讲的取消 CSS 或者 JavaScript 中关键资源的方式。
  • 如何减少关键资源 RTT 的次数?可以通过减少关键资源的个数和减少关键资源的大小搭配来实现。除此之外,还可以使用 CDN 来减少每次 RTT 时长。

交互阶段

交互阶段的优化,其实就是在谈渲染进程渲染帧的速度,因为在交互阶段,帧的渲染速度决定了交互的流畅度。因此讨论页面优化实际上就是讨论渲染引擎是如何渲染帧的,否则就无法优化帧率。

和加载阶段的渲染流水线有一些不同的地方是,在交互阶段没有了加载关键资源和构建 DOM、CSSOM 流程,通常是由 JavaScript 或 css 触发交互动画的。因此一个大的优化原则就是让单个帧的生成速度变快。

1. 减少 JavaScript 脚本执行时间

有时 JavaScript 函数的一次执行时间可能有几百毫秒,这就严重霸占了主线程执行其他渲染任务的时间。针对这种情况我们可以采用以下两种策略:

  • 一种是将一次执行的函数分解为多个任务,使得每次的执行时间不要过久
  • 另一种是采用 Web Workers。你可以把 Web Workers 当作主线程之外的一个线程,在 Web Workers 中是可以执行 JavaScript 脚本的,不过 Web Workers 中没有 DOM、CSSOM 环境,这意味着在 Web Workers 中是无法通过 JavaScript 来访问 DOM 的,所以我们可以把一些和 DOM 操作无关且耗时的任务放到 Web Workers 中去执行。

2. 避免强制同步布局

通过 DOM 接口执行添加元素或者删除元素等操作后,是需要重新计算样式和布局的,不过正常情况下这些操作都是在另外的任务中异步完成的(也就是执行操作 dom 的函数,和函数内具体的逻辑生效其实是两个任务),这样做是为了避免当前的任务占用太长的主线程时间。

强制同步布局,是指 JavaScript 强制将计算样式和布局操作提前到当前的任务中,这也就将本来两个任务合并成一个任务了,也就拉长了任务时间。比如下面

function foo() {
  let main_div = document.getElementById("mian_div");
  let new_node = document.createElement("li");
  let textnode = document.createTextNode("time.geekbang");
  new_node.appendChild(textnode);
  document.getElementById("mian_div").appendChild(new_node);
  //由于要获取到offsetHeight,
  //但是此时的offsetHeight还是老的数据,
  //所以需要立即执行布局操作
  console.log(main_div.offsetHeight);
}

而为了避免强制布局,可以如下:

function foo() {
  let main_div = document.getElementById("mian_div");
  //为了避免强制同步布局,在修改DOM之前查询相关值
  console.log(main_div.offsetHeight);
  let new_node = document.createElement("li");
  let textnode = document.createTextNode("time.geekbang");
  new_node.appendChild(textnode);
  document.getElementById("mian_div").appendChild(new_node);
}

3. 避免布局抖动

还有一种比强制同步布局更坏的情况,那就是布局抖动。所谓布局抖动,是指在一次 JavaScript 执行过程中,多次执行强制布局和抖动操作

function foo() {
  let time_li = document.getElementById("time_li");
  for (let i = 0; i < 100; i++) {
    let main_div = document.getElementById("mian_div");
    let new_node = document.createElement("li");
    let textnode = document.createTextNode("time.geekbang");
    new_node.appendChild(textnode);
    new_node.offsetHeight = time_li.offsetHeight;
    document.getElementById("mian_div").appendChild(new_node);
  }
}

我们在一个 for 循环语句里面不断读取属性值,每次读取属性值之前都要进行计算样式和布局。这种情况的避免方式和强制同步布局一样,都是尽量不要在修改 DOM 结构后再去查询一些相关值

4. 合理利用 CSS 合成动画

5. 避免频繁的垃圾回收 我们知道 JavaScript 使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。所以要尽量避免产生那些临时垃圾数据。

那该怎么做呢?可以尽可能优化储存结构,尽可能避免小颗粒对象的产生

虚拟 DOM:虚拟 DOM 和实际的 DOM 有何不同?

虚拟 DOM 是最近非常火的技术,两大著名前端框架 React 和 Vue 都使用了虚拟 DOM。

真实 dom 操作,一般会涉及重排,重绘和合同等,有时候还会引发强制同步布局和布局抖动的问题,这些操作都会大大降低渲染效率。对于简单页面还好,若是复杂页面,则会带来严重的性能问题。

所以我们需要有一种方式来减少 JavaScript 对 DOM 的操作,这时候虚拟 DOM 就上场了。

我们先来看看虚拟 DOM 到底要解决哪些事情:

  • 将页面改变的内容应用到虚拟 DOM 上,而不是直接应用到 DOM 上。
  • 变化被应用到虚拟 DOM 上时,虚拟 DOM 并不急着去渲染页面,而仅仅是调整虚拟 DOM 的内部状态,这样操作虚拟 DOM 的代价就变得非常轻了。
  • 在虚拟 DOM 收集到足够的改变时,再把这些变化一次性应用到真实的 DOM 上。

react 常见虚拟 dom 的过程:

  • 创建阶段。首先依据 JSX 和基础数据创建出来虚拟 DOM,它反映了真实的 DOM 树的结构。然后由虚拟 DOM 树创建出真实 DOM 树,真实的 DOM 树生成完后,再触发渲染流水线往屏幕输出页面。
  • 更新阶段。如果数据发生了改变,那么就需要根据新的数据创建一个新的虚拟 DOM 树;然后 React 比较两个新旧虚拟 DOM 树,找出变化的地方,并把变化的地方一次性更新到真实的 DOM 树上;最后渲染引擎更新渲染流水线,并生成新的页面。

再站在双缓存的角度如何理解虚拟 dom?

在开发游戏或者处理其他图像的过程中,屏幕从前缓冲区读取数据然后显示。但是很多图形操作都很复杂且需要大量的运算,比如一幅完整的画面,可能需要计算多次才能完成,如果每次计算完一部分图像,就将其写入缓冲区,那么就会造成一个后果,那就是在显示一个稍微复杂点的图像的过程中,你看到的页面效果可能是一部分一部分地显示出来,因此在刷新页面的过程中,会让用户感受到界面的闪烁。

而使用双缓存,可以让你先将计算的中间结果存放在另一个缓冲区中,等全部的计算结束,该缓冲区已经存储了完整的图形之后,再将该缓冲区的图形数据一次性复制到显示缓冲区,这样就使得整个图像的输出非常稳定。

在这里,你可以把虚拟 DOM 看成是 DOM 的一个 buffer,和图形显示一样,它会在完成一次完整的操作之后,再把结果应用到 DOM 上,这样就能减少一些不必要的更新,同时还能保证 DOM 的稳定输出。

渐进式网页应用(PWA):它究竟解决了 Web 应用的哪些问题?

提到过浏览器的三大进化路线:

  • 第一个是应用程序 Web 化;
  • 第二个是 Web 应用移动化;
  • 第三个是 Web 操作系统化;

Web 应用移动化是 Google 梦寐以求而又一直在发力的一件事,不过对于移动设备来说,前有本地 App,后有移动小程序,想要浏览器切入到移动端是相当困难的一件事,因为浏览器的运行性能是低于本地 App 的,并且 Google 也没有类似微信或者 Facebook 这种体量的用户群体。

但是要让浏览器切入到移动端,让其取得和原生应用同等待遇可是 Google 的梦想,那该怎么做呢?也就是 PWA,全称是 Progressive Web App,它是一套理念,渐进式增强 Web 的优势,并通过技术手段渐进式缩短和本地应用或者小程序的距离。

Web 应用 VS 本地应用

相对于本地应用,web 页面到底缺少什么呢?

  • 首先,Web 应用缺少离线使用能力,在离线或者在弱网环境下基本上是无法使用的。而用户需要的是沉浸式的体验,在离线或者弱网环境下能够流畅地使用是用户对一个应用的基本要求。
  • 其次,Web 应用还缺少了消息推送的能力,因为作为一个 App 厂商,需要有将消息送达到应用的能力。
  • 最后,Web 应用缺少一级入口,也就是将 Web 应用安装到桌面,在需要的时候直接从桌面打开 Web 应用,而不是每次都需要通过浏览器来打开。

针对以上 Web 缺陷,PWA 提出了两种解决方案:通过引入 Service Worker 来试着解决离线存储和消息推送的问题,通过引入 manifest.json 来解决一级入口的问题

什么是 Service Worker

在 Service Worker 之前,尝试过使用 App Cache 标准来缓存页面,但问题比较多最终也被废弃。而 Service Worker 理念是在页面和网络之间增加一个拦截器,用来缓存和拦截请求。

在没有安装 Service Worker 之前,WebApp 都是直接通过网络模块来请求资源的。安装了 Service Worker 模块之后,WebApp 请求资源时,会先通过 Service Worker,让它判断是返回 Service Worker 缓存的资源还是重新去网络请求资源。一切的控制权都交由 Service Worker 来处理。

Service Worker 架构

1. 架构:

为了避免 JavaScript 过多占用页面主线程时长的情况,浏览器实现了 Web Worker 的功能。Web Worker 的目的是让 JavaScript 能够运行在页面主线程之外,不过由于 Web Worker 中是没有当前页面的 DOM 环境的,所以在 Web Worker 中只能执行一些和 DOM 无关的 JavaScript 脚本,并通过 postMessage 方法将执行的结果返回给主线程。所以说在 Chrome 中, Web Worker 其实就是在渲染进程中开启的一个新线程,它的生命周期是和页面关联的。

另外,由于 Service Worker 还需要会为多个页面提供服务,所以还不能把 Service Worker 和单个页面绑定起来。在目前的 Chrome 架构中,Service Worker 是运行在浏览器进程中的,因为浏览器进程生命周期是最长的,所以在浏览器的生命周期内,能够为所有的页面提供服务。

2. 消息推送:

消息推送也是基于 Service Worker 来实现的。因为消息推送时,浏览器页面也许并没有启动,这时就需要 Service Worker 来接收服务器推送的消息,并将消息通过一定方式展示给用户

3. 安全

支持 Service Worker 的前提是,站点要支持 https,Service Worker 还需要同时支持 Web 页面默认的安全策略、储入同源策略、内容安全策略(CSP)等

WebComponent:像搭积木一样构建 Web 应用

之所诞生这个技术,主要是想前端组件化,对于 js 我们可以做到组件化,组件之前可以互相不干扰,但对于 DOM(页面中至于一个 dom,任何地方都可以读取和修改 dom)和 CSS(样式有可能污染),就不太容易做到。。。

而 WebComponent 给出了解决思路,它提供了对局部视图封装能力,可以让 DOM、CSSOM 和 JavaScript 运行在局部环境中,这样就使得局部的 CSS 和 DOM 不会影响到全局

WebComponent 是一套技术的组合,具体涉及到了 Custom elements(自定义元素)、Shadow DOM(影子 DOM)和 HTML templates(HTML 模板)

其实 WebComponent 的核心就是影子 DOM。而其主要作用无非以下两点:

  • 影子 DOM 中的元素对于整个网页是不可见的;
  • 影子 DOM 的 CSS 不会影响到整个网页的 CSSOM,影子 DOM 内部的 CSS 只对内部的元素起作用。

浏览器中的网络

HTTP/1:HTTP 性能优化

HTTP 是浏览器中最重要且使用最多的协议,是浏览器和服务器之间的通信语言,也是互联网的基石。先来介绍 http 的发展史,再来看发展过程中所遇到的各种瓶颈以及对应的解决办法。

1. 超文本传输协议 HTTP/0.9

最早的 HTTP/0.9 是于 1991 年提出的,主要用于学术交流,需求很简单——用来在网络之间传递 HTML 超文本的内容,所以被称为超文本传输协议。当时的需求很简单,就是用来传输体积很小的 HTML 文件,所以 HTTP/0.9 的实现有以下三个特点。

  • 第一个是只有一个请求行,并没有 HTTP 请求头和请求体,因为只需要一个请求行就可以完整表达客户端的需求了。
  • 第二个是服务器也没有返回头信息,这是因为服务器端并不需要告诉客户端太多信息,只需要返回数据就可以了。
  • 第三个是返回的文件内容是以 ASCII 字符流来传输的,因为都是 HTML 格式的文件,所以使用 ASCII 字节码来传输是最合适的。

2. 被浏览器推动的 HTTP/1.0

1994 年底出现了拨号上网服务,同年网景又推出一款浏览器,从此万维网就不局限于学术交流了,而是进入了高速的发展阶段。随之而来的是万维网联盟(W3C)和 HTTP 工作组(HTTP-WG)的创建,它们致力于 HTML 的发展和 HTTP 的改进。

万维网的告诉发展,导致在在浏览器中展示的不单是 HTML 文件了,还包括了 JavaScript、CSS、图片、音频、视频等不同类型的文件。因此支持多种类型的文件下载是 HTTP/1.0 的一个核心诉求,而且文件格式不仅仅局限于 ASCII 编码,还有很多其他类型编码的文件。

在 http0.9 中,浏览器和服务器只会发送简单的请求行和响应行,并没有其他额途径告诉苏服务器更多的消息,如文件编码、文件类型等。为了让客户端和服务器能更深入地交流,HTTP/1.0 引入了请求头和响应头,它们都是以为 Key-Value 形式保存的,在 HTTP 发送请求时,会带上请求头信息,服务器返回数据时,会先返回响应头信息。

那 HTTP/1.0 是怎么通过请求头和响应头来支持多种不同类型的数据呢?

要支持多种类型的文件,我们就需要解决以下几个问题。

  • 首先,浏览器需要知道服务器返回的数据是什么类型的,然后浏览器才能根据不同的数据类型做针对性的处理。
  • 其次,由于万维网所支持的应用变得越来越广,所以单个文件的数据量也变得越来越大。为了减轻传输性能,服务器会对数据进行压缩后再传输,所以浏览器需要知道服务器压缩的方法
  • 再次,由于万维网是支持全球范围的,所以需要提供国际化的支持,服务器需要对不同的地区提供不同的语言版本,这就需要浏览器告诉服务器它想要什么语言版本的页面。
  • 最后,由于增加了各种不同类型的文件,而每种文件的编码形式又可能不一样,为了能够准确地读取文件,浏览器需要知道文件的编码类型。

基于以上问题,HTTP/1.0 的方案是通过请求头和响应头来进行协商,在发起请求时候会通过 HTTP 请求头告诉服务器它期待服务器返回什么类型的文件、采取什么形式的压缩、提供什么语言的文件以及文件的具体编码。最终发送出来的请求头内容如下:

accept: text/html
accept-encoding: gzip, deflate, br
accept-Charset: ISO-8859-1,utf-8
accept-language: zh-CN,zh

其中第一行表示期望服务器返回 html 类型的文件,第二行表示期望服务器可以采用 gzip、deflate 或者 br 其中的一种压缩方式,第三行表示期望返回的文件编码是 UTF-8 或者 ISO-8859-1,第四行是表示期望页面的优先语言是中文

服务器接收到浏览器发送的请求头信息后,会根据请求头信息准备响应数据,不过有时候会有一些意外情况发生,比如浏览器请求的压缩类型是 gzip,但是服务器不支持 gzip,只支持 br 压缩,那么它会通过响应头中的 content-encoding 字段告诉浏览器最终的压缩类型,也就是说最终浏览器需要根据响应头的信息来处理数据。下面是一段响应头的数据信息:

content-encoding: br
content-type: text/html; charset=UTF-8

其中第一行表示服务器采用了 br 的压缩方法,第二行表示服务器返回的是 html 文件,并且该文件的编码类型是 UTF-8。

有了响应头的信息,浏览器就会使用 br 方法来解压文件,再按照 UTF-8 的编码格式来处理原始文件,最后按照 HTML 的方式来解析该文件

另外 http1.0 还增加了几个典型的特性:

  • 有的请求服务器可能无法处理,或者处理出错,这时候就需要告诉浏览器服务器最终处理该请求的情况,这就引入了状态码。状态码是通过响应行的方式来通知浏览器的。
  • 为了减轻服务器的压力,在 HTTP/1.0 中提供了 Cache 机制,用来缓存已经下载过的数据。
  • 服务器需要统计客户端的基础信息,比如 Windows 和 macOS 的用户数量分别是多少,所以 HTTP/1.0 的请求头中还加入了用户代理的字段。

3. 缝缝补补的 HTTP/1.1

  1. 改进持久连接

HTTP/1.0 每进行一次 HTTP 通信,都需要经历建立 TCP 连接、传输 HTTP 数据和断开 TCP 连接三个阶段,而 TCP 连接很耗时,因此引入持久连接,即在一个 TCP 连接上可以传输多个 HTTP 请求,持久连接在 HTTP/1.1 中是默认开启的,如果你不想要采用持久连接,可以在 HTTP 请求头中加上 Connection: close。目前浏览器中对于同一个域名,默认允许同时建立 6 个 TCP 持久连接。

  1. 不成熟的 HTTP 管线化

持久连接虽然能减少 TCP 的建立和断开次数,但是它需要等待前面的请求返回之后,才能进行下一次请求。如果 TCP 通道中的某个请求因为某些原因没有及时返回,那么就会阻塞后面的所有请求,这就是著名的队头阻塞的问题。

HTTP/1.1 中试图通过管线化的技术来解决队头阻塞的问题。HTTP/1.1 中的管线化是指将多个 HTTP 请求整批提交给服务器的技术,虽然可以整批发送请求,不过服务器依然需要根据请求顺序来回复浏览器的请求。FireFox、Chrome 都做过管线化的试验,但是由于各种原因,它们最终都放弃了管线化技术

  1. 提供虚拟主机的支持

在 HTTP/1.0 中,每个域名绑定了一个唯一的 IP 地址,因此一个服务器只能支持一个域名。但是随着虚拟主机技术的发展,需要实现在一台物理主机上绑定多个虚拟主机,每个虚拟主机都有自己的单独的域名,这些单独的域名都公用同一个 IP 地址。因此,HTTP/1.1 的请求头中增加了 Host 字段,用来表示当前的域名地址,这样服务器就可以根据不同的 Host 值做不同的处理。

  1. 对动态生成的内容提供了完美支持

在设计 HTTP/1.0 时,需要在响应头中设置完整的数据大小,如 Content-Length: 901,这样浏览器就可以根据设置的数据大小来接收数据。不过随着服务器端的技术发展,很多页面的内容都是动态生成的,因此在传输数据之前并不知道最终的数据大小,这就导致了浏览器不知道何时会接收完所有的文件数据。

HTTP/1.1 通过引入 Chunk transfer 机制来解决这个问题,服务器会将数据分割成若干个任意大小的数据块,每个数据块发送时会附上上个数据块的长度,最后使用一个零长度的块作为发送数据完成的标志。这样就提供了对动态内容的支持。

  1. 客户端 Cookie、安全机制

HTTP/2:如何提升网络速度?

虽然 HTTP/1.1 已经做了大量的优化,但是依然存在很多性能瓶颈,而 HTTP/1.1 为网络效率做了大量的优化,最核心的有如下三种方式:

  • 增加了持久连接;
  • 浏览器为每个域名最多同时维护 6 个 TCP 持久连接;
  • 使用 CDN 的实现域名分片机制(如果需要下载的文件数量很多,该技术会加快很多)。

HTTP/1.1 的主要问题

  1. 对带宽的利用率不理想

带宽是指每秒最大能发送或者接收的字节数。我们把每秒能发送的最大字节数称为上行带宽,每秒能够接收的最大字节数称为下行带宽。

我们常说的 100M 带宽,实际的下载速度能达到 12.5M/S,而采用 HTTP/1.1 时,也许在加载页面资源时最大只能使用到 2.5M/S,很难将 12.5M 全部用满。

主要有以下几个原因导致:

  • 第一个原因,TCP 的慢启动。 一旦一个 TCP 连接建立之后,就进入了发送数据状态,刚开始 TCP 协议会采用一个非常慢的速度去发送数据,然后慢慢加快发送数据的速度,直到发送数据的速度达到一个理想状态,我们把这个过程称为慢启动。慢启动是 TCP 为了减少网络拥塞的一种策略,我们是没有办法改变的。而之所以说慢启动会带来性能问题,是因为页面中常用的一些关键资源文件本来就不大,如 HTML 文件、CSS 文件和 JavaScript 文件,通常这些文件在 TCP 连接建立好之后就要发起请求的,但这个过程是慢启动,所以耗费的时间比正常的时间要多很多,这样就推迟了宝贵的首次渲染页面的时长了。
  • 第二个原因,同时开启了多条 TCP 连接,那么这些连接会竞争固定的带宽。比如系统同时建立了多条 TCP 连接,当带宽充足时,每条连接发送或者接收速度会慢慢向上增加;而一旦带宽不足时,这些 TCP 连接又会减慢发送或者接收的速度。比如一个页面有 200 个文件,使用了 3 个 CDN,那么加载该网页的时候就需要建立 6 * 3,也就是 18 个 TCP 连接来下载资源;在下载过程中,当发现带宽不足的时候,各个 TCP 连接就需要动态减慢接收数据的速度。这样就会出现一个问题,因为有的 TCP 连接下载的是一些关键资源,如 CSS 文件、JavaScript 文件等,而有的 TCP 连接下载的是图片、视频等普通的资源文件,但是多条 TCP 连接之间又不能协商让哪些关键资源优先下载,这样就有可能影响那些关键资源的下载速度了。
  • 第三个原因,HTTP/1.1 队头阻塞的问题。出现对头阻塞的问题时,带宽、cpu 都被白白的浪费了。而在浏览器处理页面过程中,是非常希望能提前接收到数据的,这样就可以对这些数据做预处理操作,比如提前接收到了图片,那么就可以提前进行编解码操作,等到需要使用该图片的时候,就可以直接给出处理后的数据了,这样能让用户感受到整体速度的提升。

HTTP/2 的多路复用

HTTP/1.1 所存在的一些主要问题:慢启动和 TCP 连接之间相互竞争带宽是由于 TCP 本身的机制导致的,而队头阻塞是由于 HTTP/1.1 的机制导致的

基于此,HTTP/2 的思路就是一个域名只使用一个 TCP 长连接来传输数据,这样整个页面资源的下载过程只需要一次慢启动,同时也避免了多个 TCP 连接竞争带宽所带来的问题

另外,就是队头阻塞的问题,等待请求完成后才能去请求下一个资源,这种方式无疑是最慢的,所以 HTTP/2 需要实现资源的并行请求,也就是任何时候都可以将请求发送给服务器,而并不需要等待其他请求的完成,然后服务器也可以随时返回处理好的请求资源给浏览器。

HTTP/2 使用了最核心、最重要且最具颠覆性的多路复用技术,可以将请求分成一帧一帧的数据去传输,这样带来了一个额外的好处,就是当收到一个优先级高的请求时,比如接收到 JavaScript 或者 CSS 关键资源的请求,服务器可以暂停之前的请求来优先处理关键资源的请求。

多路复用是如何实现的呢?

HTTP/2 添加了一个二进制分帧层(在传输层和应用层之间),其实也就是在浏览器发送给服务端或服务端发送给浏览器端,数据都会经过二进制分帧层处理,然后再经过网络模型的其他层进行传输(类似 https 的 TLS 层)。

基于二进制分帧层,还可以实现以下功能:

  • 可以设置请求的优先级
  • 服务器推送
  • 头部压缩

HTTP/2 协议规范于 2015 年 5 月正式发布,在那之后,该协议已在互联网和万维网上得到了广泛的实现和部署。从目前的情况来看,国内外一些排名靠前的站点基本都实现了 HTTP/2 的部署。使用 HTTP/2 能带来 20%~ 60% 的效率提升,至于 20% 还是 60% 要看优化的程度

HTTP/3:甩掉 TCP、TLS 的包袱,构建高效网络

一、TCP 的队头阻塞

虽然 HTTP/2 解决了应用层面的队头阻塞问题,不过和 HTTP/1.1 一样,HTTP/2 依然是基于 TCP 协议的,而 TCP 最初就是为了单连接而设计的。你可以把 TCP 连接看成是两台计算机之前的一个虚拟管道,计算机的一端将要传输的数据按照顺序放入管道,最终数据会以相同的顺序出现在管道的另外一头。

从一端发送给另外一端的数据会被拆分为一个个按照顺序排列的数据包,这些数据包通过网络传输到了接收端,接收端再按照顺序将这些数据包组合成原始数据,这样就完成了数据传输。

如果在数据传输的过程中,有一个数据包因为网络故障或者其他原因而丢包了,那么整个 TCP 的连接就会处于暂停状态,需要等待丢失的数据包被重新传输过来。

我们就把在 TCP 传输过程中,由于单个数据包的丢失而造成的阻塞称为 TCP 上的队头阻塞

在 HTTP/2 中,多个请求是跑在一个 TCP 管道中的,如果其中任意一路数据流中出现了丢包的情况,那么就会阻塞该 TCP 连接中的所有请求。这不同于 HTTP/1.1,使用 HTTP/1.1 时,浏览器为每个域名开启了 6 个 TCP 连接,如果其中的 1 个 TCP 连接发生了队头阻塞,那么其他的 5 个连接依然可以继续传输数据。

所以随着丢包率的增加,HTTP/2 的传输效率也会越来越差。有测试数据表明,当系统达到了 2% 的丢包率时,HTTP/1.1 的传输效率反而比 HTTP/2 表现得更好。

二、TCP 建立连接的延时

除了 TCP 队头阻塞之外,TCP 的握手过程也是影响传输效率的一个重要因素。

网络延迟又称为 RTT(Round Trip Time)。我们把从浏览器发送一个数据包到服务器,再从服务器返回数据包到浏览器的整个往返时间称为 RTT。RTT 是反映网络性能的一个重要指标。

那建立 TCP 连接时,需要花费多少个 RTT 呢?

我们知道 HTTP/1 和 HTTP/2 都是使用 TCP 协议来传输的,而如果使用 HTTPS 的话,还需要使用 TLS 协议进行安全传输,而使用 TLS 也需要一个握手过程,这样就需要有两个握手延迟过程。

  • 在建立 TCP 连接的时候,需要和服务器进行三次握手来确认连接成功,也就是说需要在消耗完 1.5 个 RTT 之后才能进行数据传输。
  • 进行 TLS 连接,TLS 有两个版本——TLS1.2 和 TLS1.3,每个版本建立连接所花的时间不同,大致是需要 1 ~ 2 个 RTT

总之,在传输数据之前,我们需要花掉 3 ~ 4 个 RTT。如果浏览器和服务器的物理距离较近,那么 1 个 RTT 的时间可能在 10 毫秒以内,也就是说总共要消耗掉 30 ~ 40 毫秒。这个时间也许用户还可以接受,但如果服务器相隔较远,那么 1 个 RTT 就可能需要 100 毫秒以上了,这种情况下整个握手过程需要 300 ~ 400 毫秒,这时用户就能明显地感受到“慢”了。

三、TCP 协议僵化

我们知道了 TCP 协议存在队头阻塞和建立连接延迟等缺点,那我们是不是可以通过改进 TCP 协议来解决这些问题呢?

答案是:非常困难。之所以这样,主要中间设备的僵化和操作系统更新滞后。

四、QUIC 协议

HTTP/2 存在一些比较严重的与 TCP 协议相关的缺陷,但由于 TCP 协议僵化,我们几乎不可能通过修改 TCP 协议自身来解决这些问题,那么解决问题的思路是绕过 TCP 协议,发明一个 TCP 和 UDP 之外的新的传输协议。但是这也面临着和修改 TCP 一样的挑战,因为中间设备的僵化,这些设备只认 TCP 和 UDP,如果采用了新的协议,新协议在这些设备同样不被很好地支持。

因此,HTTP/3 选择了一个折衷的方法——UDP 协议,基于 UDP 实现了类似于 TCP 的多路数据流、传输可靠性等功能,我们把这套功能称为 QUIC 协议

HTTP/3 中的 QUIC 协议集合了以下几点功能:

  • 实现了类似 TCP 的流量控制、传输可靠性的功能。虽然 UDP 不提供可靠性的传输,但 QUIC 在 UDP 的基础之上增加了一层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他一些 TCP 中存在的特性。
  • 集成了 TLS 加密功能。目前 QUIC 使用的是 TLS1.3,相较于早期版本 TLS1.3 有更多的优点,其中最重要的一点是减少了握手所花费的 RTT 个数。
  • 实现了 HTTP/2 中的多路复用功能。和 TCP 不同,QUIC 实现了在同一物理连接上可以有多个独立的逻辑数据流(可以理解为一个 tcp,但同时有多个管道,每个管道只会影响该管道内的数据包)。实现了数据流的单独传输,就解决了 TCP 中队头阻塞的问题。
  • 实现了快速握手功能。由于 QUIC 是基于 UDP 的,所以 QUIC 可以实现使用 0-RTT 或者 1-RTT 来建立连接,这意味着 QUIC 可以用最快的速度来发送和接收数据,这样可以大大提升首次打开页面的速度。

五、HTTP/3 的挑战

虽然理论上 HTTP/3 很好的解决了 http/2 的问题,但距离真正的部署还有很长的距离要走。因为动了底层协议,所以 HTTP/3 的增长会比较缓慢,这和 HTTP/2 有着本质的区别

浏览器安全

浏览器安全可以分为三大块——Web 页面安全、浏览器网络安全和浏览器系统安全

同源策略:为什么 XMLHttpRequest 不能跨域请求资源?

如果两个 URL 的协议、域名和端口都相同,我们就称这两个 URL 同源。浏览器默认两个相同的源之间是可以相互访问资源和操作 DOM 的。两个不同的源之间若想要相互访问资源或者操作 DOM,那么会有一套基础的安全策略的制约,我们把这称为同源策略。

具体来讲,同源策略主要表现在 DOM、Web 数据和网络这三个层面

第一个,DOM 层面。同源策略限制了来自不同源的 JavaScript 脚本对当前 DOM 对象读和写的操作。

比如打开极客时间的官网,然后再从官网中打开另外一个专栏页面,由于第一个页面和第二个页面是同源关系,所以我们可以在第二个页面中操作第一个页面的 DOM,比如将第一个页面全部隐藏掉,代码如下所示:

{
  let pdom = opener.document;
  pdom.body.style.display = "none";
}

该代码中,对象 opener 就是指向第一个页面的 window 对象,我们可以通过操作 opener 来控制第一个页面中的 DOM。

第二个,数据层面。同源策略限制了不同源的站点读取当前站点的 Cookie、IndexDB、LocalStorage 等数据。由于同源策略,我们依然无法通过第二个页面的 opener 来访问第一个页面中的 Cookie、IndexDB 或者 LocalStorage 等内容。

第三个,网络层面。同源策略限制了通过 XMLHttpRequest 等方式将站点的数据发送给不同源的站点。

不过安全性和便利性是相互对立的,让不同的源之间绝对隔离,无疑是最安全的措施,但这也会使得 Web 项目难以开发和使用。因此我们就要在这之间做出权衡,出让一些安全性来满足灵活性;而出让安全性又带来了很多安全问题,最典型的是 XSS 攻击和 CSRF 攻击。这两种攻击后续再说,先来看看浏览器出让了同源策略的哪些安全性

1. 页面中可以嵌入第三方资源,同源策略要让一个页面的所有资源都来自于同一个源,也就是要将该页面的所有 HTML 文件、JavaScript 文件、CSS 文件、图片等资源都部署在同一台服务器上,这无疑违背了 Web 的初衷,也带来了诸多限制。比如将不同的资源部署到不同的 CDN 上时,CDN 上的资源就部署在另外一个域名上,因此我们就需要同源策略对页面的引用资源开一个“口子”,让其任意引用外部文件。

所以最初的浏览器都是支持外部引用资源文件的,不过这也带来了很多问题。之前在开发浏览器的时候,遇到最多的一个问题是浏览器的首页内容会被一些恶意程序劫持,劫持的途径很多,其中最常见的是恶意程序通过各种途径往 HTML 文件中插入恶意脚本,也就是 xss 攻击。

为了解决 XSS 攻击,浏览器中引入了内容安全策略,称为 CSP。CSP 的核心思想是让服务器决定浏览器能够加载哪些资源,让服务器决定浏览器是否能够执行内联 JavaScript 代码。通过这些手段就可以大大减少 XSS 攻击。

2. 跨域资源共享和跨文档消息机制,前者其实就是当前域去请求其他域的一些资源,可以使用跨域资源共享(CORS)来解决。而有时候我们需要再不同源的 DOM 之间进行通信,测试可以通过 window.postMessage 来实现。

鱼和熊掌不可兼得,要绝对的安全就要牺牲掉便利性,因此我们要在这二者之间做权衡,找到中间的一个平衡点,也就是目前的页面安全策略原型。总结起来,它具备以下三个特点:

  • 页面中可以引用第三方资源,不过这也暴露了很多诸如 XSS 的安全问题,因此又在这种开放的基础之上引入了 CSP 来限制其自由程度。
  • 使用 XMLHttpRequest 和 Fetch 都是无法直接进行跨域请求的,因此浏览器又在这种严格策略的基础之上引入了跨域资源共享策略,让其可以安全地进行跨域操作。
  • 两个不同源的 DOM 是不能相互操纵的,因此,浏览器中又实现了跨文档消息机制,让其可以比较安全地通信。

什么是 XSS 攻击

XSS 全称是 Cross Site Scripting,为了与“CSS”区分开来,故简称 XSS,翻译过来就是“跨站脚本”。XSS 攻击是指黑客往 HTML 文件中或者 DOM 中注入恶意脚本,从而在用户浏览页面时利用注入的恶意脚本对用户实施攻击的一种手段。

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

当页面被注入了恶意 JavaScript 脚本时,浏览器无法区分这些脚本是被恶意注入的还是正常的页面内容,所以恶意注入 JavaScript 脚本也拥有所有的脚本权限。下面我们就来看看,如果页面被注入了恶意 JavaScript 脚本,恶意脚本都能做哪些事情。

  • 可以窃取 Cookie 信息。恶意 JavaScript 可以通过“document.cookie”获取 Cookie 信息,然后通过 XMLHttpRequest 或者 Fetch 加上 CORS 功能将数据发送给恶意服务器;
  • 恶意服务器拿到用户的 Cookie 信息之后,就可以在其他电脑上模拟用户的登录,然后进行转账等操作。可以监听用户行为。
  • 恶意 JavaScript 可以使用“addEventListener”接口来监听键盘事件,比如可以获取用户输入的信用卡等信息,将其发送到恶意服务器。黑客掌握了这些信息之后,又可以做很多违法的事情。
  • 可以通过修改 DOM 伪造假的登录窗口,用来欺骗用户输入用户名和密码等信息。
  • 还可以在页面内生成浮窗广告,这些广告会严重地影响用户体验。

恶意脚本是怎么注入的?主要有存储型 XSS 攻击,反射性 XSS 攻击和基于 DOM 的 XSS 攻击。

  1. 存储型 XSS 攻击
  2. 反射型 XSS 攻击
  3. DOM 型 XSS 攻击

我们知道存储型 XSS 攻击和反射型 XSS 攻击都是需要经过 Web 服务器来处理的,因此可以认为这两种类型的漏洞是服务端的安全漏洞。而基于 DOM 的 XSS 攻击全部都是在浏览器端完成的,因此基于 DOM 的 XSS 攻击是属于前端的安全漏洞

但无论是何种类型的 XSS 攻击,它们都有一个共同点,那就是首先往浏览器中注入恶意脚本,然后再通过恶意脚本将用户信息发送至黑客部署的恶意服务器上所以要阻止 XSS 攻击,我们可以通过阻止恶意 JavaScript 脚本的注入和恶意消息的发送来实现。

一些常用的阻止 XSS 攻击的策略。

  1. 服务器对输入脚本进行过滤或转码
  2. 充分利用 CSP(主要限制加载其他域下的资源文件,禁止向第三方域提交数据,禁止执行内联脚本和未授权的脚本,还提供了上报机制)
  3. 使用 HttpOnly 属性,由于很多 XSS 攻击都是来盗用 Cookie 的,设置 HttpOnly 后,Cookie 只能使用在 http 请求过程中,无法被 js 读取。

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

CSRF 攻击:陌生链接不要随便点

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

主要有以下三种方式实施 CSRF 攻击:

  1. 自动发起 Get 请求
  2. 自动发起 Post 请求
  3. 引诱用户点击链接

和 XSS 不同的是,CSRF 攻击不需要将恶意代码注入用户的页面,仅仅是利用服务器的漏洞(未对请求做身份校验)和用户的登录状态来实施攻击。

而实施 CSRF 攻击的必要条件:

  • 第一个,目标站点一定要有 CSRF 漏洞;
  • 第二个,用户要登录过目标站点,并且在浏览器上保持有该站点的登录状态;
  • 第三个,需要用户打开一个第三方站点,可以是黑客的站点,也可以是一些论坛。

要让服务器避免遭受到 CSRF 攻击,通常有以下几种途径。

1. 充分利用好 Cookie 的 SameSite 属性,通常 CSRF 攻击都是从第三方站点发起的,要防止 CSRF 攻击,我们最好能实现从第三方站点发送请求时禁止 Cookie 的发送,Cookie 中的 SameSite 属性正是为了解决这个问题的,SameSite 选项通常有 Strict、Lax 和 None 三个值。如果 SameSite 的值是 Strict,那么浏览器会完全禁止第三方 Cookie。Lax 相对宽松一点从第三方站点的链接打开和从第三方站点提交 Get 方式的表单这两种方式都会携带 Cookie。但如果在第三方站点中使用 Post 方法,或者通过 img、iframe 等标签加载的 URL,这些场景都不会携带 Cookie。而如果**使用 None **的话,在任何情况下都会发送 Cookie 数据。

对于防范 CSRF 攻击,我们可以针对实际情况将一些关键的 Cookie 设置为 Strict 或者 Lax 模式,这样在跨站点请求时,这些关键的 Cookie 就不会被发送到服务器,从而使得黑客的 CSRF 攻击失效。

2. 验证请求的来源站点,由于 CSRF 攻击大多来自于第三方站点,因此服务器可以禁止来自第三方站点的请求。那么该怎么判断请求是否来自第三方站点呢?

答案就是请求头中的 Referer 和 Origin 属性了,Referer 是 HTTP 请求头中的一个字段,记录了该 HTTP 请求的来源地址。比如我从极客时间的官网打开了 InfoQ 的站点,那么请求头中的 Referer 值是极客时间的 URL(包含路径信息)。但是有一些场景是不适合将来源 URL 暴露给服务器的,因此浏览器提供给开发者一个选项,可以不用上传 Referer 值,具体可参考 Referrer Policy。但在服务器端验证请求头中的 Referer 并不是太可靠,因此标准委员会又制定了 Origin 属性,在一些重要的场合,比如通过 XMLHttpRequest、Fecth 发起跨站请求或者通过 Post 方法发送请求时,都会带上 Origin 属性

Origin 属性只包含了域名信息,并没有包含具体的 URL 路径,这是 Origin 和 Referer 的一个主要区别。服务器的策略是优先判断 Origin,如果请求头中没有包含 Origin 属性,再根据实际情况判断是否使用 Referer 值。

3. CSRF Token,其实就是每次页面打开时,服务器都会生成一个 csrf token 写入到页面的隐藏域里,等到用户发起请求时会写到这个 csrf token 值,服务端进行校验。而如果请求是第三方站点发出的,则无法获取到 csrf token 值,所以即使发出了请求,服务器也会因为没有或 csrf token 不正确而拒绝请求。

综上:我们可以得出页面安全问题的主要原因就是浏览器为同源策略开的两个“后门”:一个是在页面中可以任意引用第三方资源,另外一个是通过 CORS 策略让 XMLHttpRequest 和 Fetch 去跨域请求资源。

为了解决这些问题,我们引入了 CSP 来限制页面任意引入外部资源,引入了 HttpOnly 机制来禁止 XMLHttpRequest 或者 Fetch 发送一些关键 Cookie,引入了 SameSite 和 Origin 来防止 CSRF 攻击。

安全沙箱:页面和系统之间的隔离墙

我们知道浏览器被划分为浏览器内核和渲染内核两个核心模块,其中浏览器内核是由网络进程、浏览器主进程和 GPU 进程组成的,渲染内核就是渲染进程。那如果我们在浏览器中打开一个页面,这两个模块是怎么配合的呢?

所有的网络资源都是通过浏览器内核来下载的,下载后的资源会通过 IPC 将其提交给渲染进程(浏览器内核和渲染进程之间都是通过 IPC 来通信的)。然后渲染进程会对这些资源进行解析、绘制等操作,最终生成一幅图片。但是渲染进程并不负责将图片显示到界面上,而是将最终生成的图片提交给浏览器内核模块,由浏览器内核模块负责显示这张图片。

或许你有疑问,为什么一定要通过浏览器内核去请求资源,再将数据转发给渲染进程,而不直接从进程内部去请求网络资源?为什么渲染进程只负责生成页面图片,生成图片还要经过 IPC 通知浏览器内核模块,然后让浏览器内核去负责展示图片?这样不是复杂了吗?

其实这主要是从安全的角度考虑的,由于渲染进程需要执行 DOM 解析、CSS 解析、网络图片解码等操作,如果渲染进程中存在系统级别的漏洞,那么以上操作就有可能让恶意的站点获取到渲染进程的控制权限,进而又获取操作系统的控制权限,这对于用户来说是非常危险的。

因为网络资源的内容存在着各种可能性,所以浏览器会默认所有的网络资源都是不可信的,都是不安全的。但谁也不能保证浏览器不存在漏洞,只要出现漏洞,黑客就可以通过网络内容对用户发起攻击。

我们知道,如果你下载了一个恶意程序,但是没有执行它,那么恶意程序是不会生效的。同理,浏览器之于网络内容也是如此,浏览器可以安全地下载各种网络资源,但是如果要执行这些网络资源,比如解析 HTML、解析 CSS、执行 JavaScript、图片编解码等操作,就需要非常谨慎了,因为一不小心,黑客就会利用这些操作对含有漏洞的浏览器发起攻击。

基于以上原因,我们需要在渲染进程和操作系统之间建一道墙,即便渲染进程由于存在漏洞被黑客攻击,但由于这道墙,黑客就获取不到渲染进程之外的任何操作权限。将渲染进程和操作系统隔离的这道墙就是我们要聊的安全沙箱。

浏览器中的安全沙箱是利用操作系统提供的安全技术,让渲染进程在执行过程中无法访问或者修改操作系统中的数据,在渲染进程需要访问系统资源的时候,需要通过浏览器内核来实现,然后将访问的结果通过 IPC 转发给渲染进程

安全沙箱最小的保护单位是进程。因为单进程浏览器需要频繁访问或者修改操作系统的数据,所以单进程浏览器是无法被安全沙箱保护的,而现代浏览器采用的多进程架构使得安全沙箱可以发挥作用

安全沙箱最小的保护单位是进程,并且能限制进程对操作系统资源的访问和修改,这就意味着如果要让安全沙箱应用在某个进程上,那么这个进程必须没有读写操作系统的功能,比如读写本地文件、发起网络请求、调用 GPU 接口等

再看下渲染进程和浏览器内核分别复杂哪些职责:

  • 渲染进程:html 解析,css 解析,图片解码,js 执行,布局,绘制,xml 解析
  • 浏览器内核:Cookie 存储,Cache 存储,网络请求,文件读取,下载管理,ssl/tls,浏览器窗口管理等。

那安全沙箱是如何影响到各个模块功能的呢?

1. 持久存储

由于安全沙箱需要负责确保渲染进程无法直接访问用户的文件系统,但是在渲染进程内部有访问 Cookie 的需求、有上传文件的需求,为了解决这些文件的访问需求,所以现代浏览器将读写文件的操作全部放在了浏览器内核中实现,然后通过 IPC 将操作结果转发给渲染进程。

2. 网络访问

同样有了安全沙箱的保护,在渲染进程内部也是不能直接访问网络的,如果要访问网络,则需要通过浏览器内核。不过浏览器内核在处理 URL 请求之前,会检查渲染进程是否有权限请求该 URL,比如检查 XMLHttpRequest 或者 Fetch 是否是跨站点请求(难道跨域的请求没有被发出去吗?不对啊,之前项目里如果设置自定义请求头没和服务器对应起来,服务器可以接受到请求啊。。。不对,此时协议,域名,端口是匹配的,只是不满足 cors 规则而已),或者检测 HTTPS 的站点中是否包含了 HTTP 的请求。

3. 用户交互

通常情况下,如果你要实现一个 UI 程序,操作系统会提供一个界面给你,该界面允许应用程序与用户交互,允许应用程序在该界面上进行绘制,比如 Windows 提供的是 HWND,Linux 提供的 X Window,我们就把 HWND 和 X Window 统称为窗口句柄。应用程序可以在窗口句柄上进行绘制和接收键盘鼠标消息。

不过在现代浏览器中,由于每个渲染进程都有安全沙箱的保护,所以在渲染进程内部是无法直接操作窗口句柄的,这也是为了限制渲染进程监控到用户的输入事件。

由于渲染进程不能直接访问窗口句柄,所以渲染进程需要完成以下两点大的改变。

第一点渲染进程需要渲染出位图。为了向用户显示渲染进程渲染出来的位图,渲染进程需要将生成好的位图发送到浏览器内核,然后浏览器内核将位图复制到屏幕上。

第二点操作系统没有将用户输入事件直接传递给渲染进程,而是将这些事件传递给浏览器内核。然后浏览器内核再根据当前浏览器界面的状态来判断如何调度这些事件,如果当前焦点位于浏览器地址栏中,则输入事件会在浏览器内核内部处理;如果当前焦点在页面的区域内,则浏览器内核会将输入事件转发给渲染进程。

之所以这样设计,就是为了限制渲染进程有监控到用户输入事件的能力,所以所有的键盘鼠标事件都是由浏览器内核来接收的,然后浏览器内核再通过 IPC 将这些事件发送给渲染进程。

4、站点隔离(Site Isolation)

所谓站点隔离是指 Chrome 将同一站点(包含了相同根域名和相同协议的地址)中相互关联的页面放到同一个渲染进程中执行。

最开始 Chrome 划分渲染进程是以标签页为单位,也就是说整个标签页会被划分给某个渲染进程。但是,按照标签页划分渲染进程存在一些问题,原因就是一个标签页中可能包含了多个 iframe,而这些 iframe 又有可能来自于不同的站点,这就导致了多个不同站点中的内容通过 iframe 同时运行在同一个渲染进程中。

目前所有操作系统都面临着两个 A 级漏洞——幽灵(Spectre)和熔毁(Meltdown),这两个漏洞是由处理器架构导致的,很难修补,黑客通过这两个漏洞可以直接入侵到进程的内部,如果入侵的进程没有安全沙箱的保护,那么黑客还可以发起对操作系统的攻击

所以如果一个银行站点包含了一个恶意 iframe,然后这个恶意的 iframe 利用这两个 A 级漏洞去入侵渲染进程,那么恶意程序就能读取银行站点渲染进程内的所有内容了,这对于用户来说就存在很大的风险了。

因此 Chrome 几年前就开始重构代码,将标签级的渲染进程重构为 iframe 级的渲染进程,然后严格按照同一站点的策略来分配渲染进程,这就是 Chrome 中的站点隔离。

实现了站点隔离,就可以将恶意的 iframe 隔离在恶意进程内部,使得它无法继续访问其他 iframe 进程的内容,因此也就无法攻击其他站点了。

值得注意是,2019 年 10 月 20 日 Chrome 团队宣布安卓版的 Chrome 已经全面支持站点隔离,你可以参考

HTTPS:让数据传输更安全

前面分别从页面安全,系统安全角度说了,这里再说说网络安全。

起初设计 HTTP 协议的目的很单纯,就是为了传输超文本文件,那时候也没有太强的加密传输的数据需求,所以 HTTP 一直保持着明文传输数据的特征。但这样的话,在传输过程中的每一个环节,数据都有可能被窃取或者篡改,很容易出现中间人攻击。

具体来讲,在将 HTTP 数据提交给 TCP 层之后,数据会经过用户电脑、WiFi 路由器、运营商和目标服务器,在这中间的每个环节中,数据都有可能被窃取或篡改。比如用户电脑被黑客安装了恶意软件,那么恶意软件就能抓取和篡改所发出的 HTTP 请求的内容。或者用户一不小心连接上了 WiFi 钓鱼路由器,那么数据也都能被黑客抓取或篡改。

我们可以看出 HTTPS 并非是一个新的协议,通常 HTTP 直接和 TCP 通信,HTTPS 则先和安全层通信,然后安全层再和 TCP 层通信。也就是说 HTTPS 所有的安全核心都在安全层,它不会影响到上面的 HTTP 协议,也不会影响到下面的 TCP/IP,因此要搞清楚 HTTPS 是如何工作的,就要弄清楚安全层是怎么工作的。

总的来说,安全层有两个主要的职责:对发起 HTTP 请求的数据进行加密操作和对接收到 HTTP 的内容进行解密操作

https 协议过程:

  1. 首先浏览器向服务器发送对称加密套件列表、非对称加密套件列表(其实就是支持哪些加密算法)和随机数 client-random;
  2. 服务器保存随机数 client-random,选择对称加密和非对称加密的套件,然后生成随机数 service-random,向浏览器发送选择的加密套件、service-random 和公钥(这是 RSA 的公钥);
  3. 浏览器保存公钥,并生成随机数 pre-master,然后利用公钥对 pre-master 加密,并向服务器发送加密后的数据
  4. 最后服务器拿出自己的私钥,解密出 pre-master 数据,并返回确认消息。
  5. 服务器和浏览器就有了共同的 client-random、service-random 和 pre-master,然后服务器和浏览器会使用这三组随机数生成对称密钥,因为服务器和浏览器使用同一套方法来生成密钥,所以最终生成的密钥也是相同的。
  6. 生成对称加密的密钥之后,双方就可以使用对称加密的方式来传输数据了

需要特别注意的一点,pre-master 是经过公钥加密之后传输的,所以黑客无法获取到 pre-master,这样黑客就无法生成密钥,也就保证了黑客无法破解传输过程中的数据了

通过对称和非对称混合方式,我们完美地实现了数据的加密传输。不过这种方式依然存在着问题,比如我要打开极客时间的官网,但是黑客通过 DNS 劫持将极客时间官网的 IP 地址替换成了黑客的 IP 地址,这样我访问的其实是黑客的服务器了,黑客就可以在自己的服务器上实现公钥和私钥,而对浏览器来说,它完全不知道现在访问的是个黑客的站点。

因此还需要服务器向浏览器提供证明“你现在访问的服务器就是对的”,那怎么证明呢?

比如你要买房子,首先你需要给房管局提交你买房的材料,包括银行流水、银行证明、身份证等,然后房管局工作人员在验证无误后,会发给你一本盖了章的房产证,房产证上包含了你的名字、身份证号、房产地址、实际面积、公摊面积等信息。

在这个例子中,你之所以能证明房子是你自己的,是因为引进了房管局这个权威机构,并通过这个权威机构给你颁发一个证书:房产证。

同理,极客时间要证明这个服务器就是极客时间的,也需要使用权威机构颁发的证书,这个权威机构称为 CA(Certificate Authority),颁发的证书就称为数字证书(Digital Certificate)

而数字证书的作用:

  • 一个是通过数字证书向浏览器证明服务器的身份
  • 另一个是数字证书里面包含了服务器公钥。

相较于之前的 https 协议流程,添加了数字证书的流程有以下两点改变:

  • 服务器没有直接返回公钥给浏览器,而是返回了数字证书,而 RSA 公钥正是包含在数字证书中的;
  • 在浏览器端多了一个证书验证的操作,验证了证书之后,才继续后续流程。

通过引入数字证书,我们就实现了服务器的身份认证功能,这样即便黑客伪造了服务器,但是由于证书是没有办法伪造的,所以依然无法欺骗用户。

数字证书如何申请呢?

  • 首先极客时间需要准备一套私钥和公钥,私钥留着自己使用(不需要给 CA 机构);
  • 然后极客时间向 CA 机构提交公钥、公司、站点等信息并等待认证,这个认证过程可能是收费的
  • ;CA 通过线上、线下等多种渠道来验证极客时间所提供信息的真实性,如公司是否存在、企业是否合法、域名是否归属该企业等
  • ;如信息审核通过,CA 会向极客时间签发认证的数字证书,包含了极客时间的公钥、组织信息、CA 的信息、有效时间、证书序列号等,这些信息都是明文的,同时包含一个 CA 生成的签名。

最后一步数字签名的过程还需要解释下:首先 CA 使用 Hash 函数来计算极客时间提交的明文信息,并得出信息摘要;然后 CA 再使用它的私钥对信息摘要进行加密,加密后的密文就是 CA 颁给极客时间的数字签名

浏览器如何验证数字证书?

有了 CA 签名过的数字证书,当浏览器向极客时间服务器发出请求时,服务器会返回数字证书给浏览器。

浏览器接收到数字证书之后,会对数字证书进行验证。首先浏览器读取证书中相关的明文信息,采用 CA 签名时相同的 Hash 函数来计算并得到信息摘要 A;然后再利用对应 CA 的公钥(系统内置)解密签名数据,得到信息摘要 B;对比信息摘要 A 和信息摘要 B,如果一致,则可以确认证书是合法的,即证明了这个服务器是极客时间的;同时浏览器还会验证证书相关的域名信息、有效时间等信息。

这时候相当于验证了 CA 是谁,但是这个 CA 可能比较小众,浏览器不知道该不该信任它,然后浏览器会继续查找给这个 CA 颁发证书的 CA,再以同样的方式验证它上级 CA 的可靠性。通常情况下,操作系统中会内置信任的顶级 CA 的证书信息(包含公钥),如果这个 CA 链中没有找到浏览器内置的顶级的 CA,证书也会被判定非法。

浏览器是如何拿到 CA 的公钥的呢?

我们知道 CA 是一个机构,它的职责是给一些公司或者个人颁发数字证书,在颁发证书之前,有一个重要的环节,就是审核申请者所提交资料的合法性和合规性。 不过申请者的类型有很多:

如果申请者是个人,CA 只需要审核所域名的所有权就行了,审核域名所有权有很多种方法,在常用的方法是让申请者在域名上放一个文件(有时候调用微信的 sdk 也需要在根目录防止一个文件,应该原理是一样的),然后 CA 验证该文件是否存在,即可证明该域名是否是申请者的。我们把这类数字证书称为 DV,审核这种个人域名信息是最简单的,因此 CA 收取的费用也是最低的,有些 CA 甚至免费为个人颁发数字证书。

如果申请者是普通公司,那么 CA 除了验证域名的所有权之外,还需要验证公司公司的合法性,这类证书通常称为 OV。由于需要验证公司的信息,所有需要额外的资料,而且审核过程也更加复杂,申请 OV 证书的价格也更高,主要是由于验证公司的合法性是需要人工成本的。

如果申请者是一些金融机构、银行、电商平台等,所以还需额外的要验证一些经营资质是否合法合规,这类证书称为 EV。申请 EV 的价格非常高,甚至达到好几万一年,因为需要人工验证更多的内容。

好了,我们了解了证书有很多种不同的类型,DV 这种就可以自动审核,不过 OV、EV 这种类型的证书就需要人工验证了,而每个地方的验证方式又可能不同,比如你是一家美国本地的 CA 公司,要给中国的一些金融公司发放数字证书,这过程种验证证书就会遇到问题,因此就需要本地的 CA 机构,他们验证会更加容易。

因此,就全球就有很多家 CA 机构,然后就出现了一个问题,这些 CA 是怎么证明它自己是安全的?如果一个恶意的公司也成立了一个 CA 机构,然后给自己颁发证书,那么这就非常危险了,因此我们必须还要实现一个机制,让 CA 证明它自己是安全无公害的。

这就涉及到数字证书链了。

要讲数字证书链,就要了解我们的 CA 机构也是分两种类型的,中间 CA(Intermediates CAs)和根 CA(Root CAs),通常申请者都是向中间 CA 去申请证书的,而根 CA 作用就是给中间 CA 做认证,通常,一个根 CA 会认证很多中间的 CA,而这些中间 CA 又可以去认证其它的中间 CA。

比如你可以在 Chrome 上打开极客时间的官网,然后点击地址栏前面的那把小锁,你就可以看到.geekbang,org 的证书是由中间 CA GeoTrust RSA CA2018 颁发的,而中间 CA GeoTrust RSA CA2018 又是由根 CA DigiCert Global Root CA 颁发的,所以这个证书链就是:.geekbang,org—>GeoTrust RSA CA2018–>DigiCert Global Root CA。

因此浏览器验证极客时间的证书时,会先验证*.geekbang,org 的证书,如果合法再验证中间 CA 的证书,如果中间 CA 也是合法的,那么浏览器会继续验证这个中间 CA 的根证书。

这时候问题又来了,怎么证明根证书是合法的?

浏览器的做法很简单,它会查找系统的根证书,如果这个根证书在操作系统里面,那么浏览器就认为这个根证书是合法的,如果验证的根证书不在操作系统里面,那么就是不合法的。

而操作系统里面这些内置的根证书也不是随便内置的,这些根 CA 都是通过 WebTrust 国际安全审计认证。

那么什么又是 WebTrust 认证?

WebTrust(网络信任)认证是电子认证服务行业中唯一的国际性认证标准,主要对互联网服务商的系统及业务运作的商业惯例和信息隐私,交易完整性和安全性。WebTrust 认证是各大主流的浏览器、微软等大厂商支持的标准,是规范 CA 机构运营服务的国际标准。在浏览器厂商根证书植入项目中,必要的条件就是要通过 WebTrust 认证,才能实现浏览器与数字证书的无缝嵌入。

目前通过 WebTrust 认证的根 CA 有 Comodo,geotrust,rapidssl,symantec,thawte,digicert 等。也就是说,这些根 CA 机构的根证书都内置在个大操作系统中,只要能从数字证书链往上追溯到这几个根证书,浏览器会认为使用者的证书是合法的。

4、事件循环,消息队列,JS 编译原理,理解 Vitrual Dom、diff 算法等。

为什么有事件循环以及过程?

事件循环:渲染引擎不但要执行 js 还要渲染页面,但 js 是单线程,同一时间只能运行一个任务,那如何保证:响应及时,不卡顿,任务优先级呢?这就需要一个事件循环,但如何保证好高优先级的任务及时处理呢?这就有了宏任务和微任务……微任务就是宏任务执行后优先级比较高的任务,在处理下一个宏任务之前,需要清空微任务队列(微任务执行过程中,产生微任务则持续执行,若一直产生,则会导致俩死)

现代浏览器模型有一个浏览器主进程,一个网络进程,一个 GPU 进程,多个渲染进程和多个插件进程

而每个渲染进程都有一个主线程(线程依托与进程,但某一个线程崩溃会导致其依托的进程挂掉),并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是我们今天要讲的消息队列和事件循环系统。

事件循环系统解决了如何处理不同进程,不同线程发送至主线程的事件,前期都是先进先出,但有个问题,如何保证高优先级的任务先执行呢?如果用事件监听,则可能会频繁触发,如果采用异步并放入事件队列里,则会出现延迟(比如页面 dom 变化,如果处理不及时就会有卡顿)……基于此就出现了微任务……通常我们把任务队列里的任务理解为宏任务,而每一个宏任务又包含一个微任务队列,在执行宏任务过程中,如果有 dom 变化则会把该变化添加到微任务队列里,这样既不会影响宏任务的执行,也提高了效率。等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。

5、能独立完成前端技术选型、模块及插件开发及脚手架搭建。

6、理解网络模型、TCP/HTTP(S)、缓存、浏览器原理等,了解常见浏览器攻击及防御措施。

7、有 node 相关的后端开发经验,熟悉 Express,Egg,Nginx,Python 等

8、熟悉常见数据结构及算法、理解编译原理,异步开发等。

9、熟悉 TypeScript、AST、可以编写各种 loader 及插件,具有良好的编码风格等。

10、计算机二级、英语六级,可以流畅阅读英文文档,熟悉类 Unix 系统原理及常用终端编辑器。

自我评价: 1、具备良好的责任心、积极主动并有较强的自我驱动和学习能力。

2、关注新技术,对前端技术有浓厚兴趣,并在团队内多次分享,自我要求高、爱钻研 。

1、具备良好的责任心、积极主动并有较强的自我驱动和学习能力。 2、关注新技术,对前端技术有浓厚兴趣,并在团队内多次分享,自我要求高、爱钻研 。 3、良好的沟通能力和团队合作精神,优秀的分析和解决问题的能力 3、良好的沟通能力和团队合作精神,优秀的分析和解决问题的能力

isAsyncComponent && { props: options.props   [] }

插件的功能范围: 1、全局的方法或属性(其实就是定义一个全局函数,直接调用即可) 2、全局的指令、过滤器、过渡等(需要用 Vue.filter,Vue.directive 等注册) 3、全局混入一些资源,比如 vue-router(需要使用 Vue.mixin 注入组件) 4、添加原型对象 Vue 的实例方法,添加 Vue 实例方法,通过把它们添加到 Vue.prototype 上实现。

MyPlugin.install = function (Vue, options) { // 1. 添加全局方法或属性 Vue.myGlobalMethod = function () { // 逻辑… }

// 2. 添加全局资源 Vue.directive(‘my-directive’, { bind (el, binding, vnode, oldVnode) { // 逻辑… } … })

// 3. 注入组件选项 Vue.mixin({ created: function () { // 逻辑… } … })

// 4. 添加实例方法 Vue.prototype.$myMethod = function (methodOptions) { // 逻辑… } }

综上可以看出,插件必须有一个 install 方法,然后 Vue.use 是一个函数,这个函数接受一个参数 plugin,这个参数可以是对象也可以是函数,函数内部逻辑先检查插件数组内是否含有参数,如果含有相当于插件已经注册过了,如果不含,则检查参数 plugin.install 是否为函数,若是则直接执行,然后检查 plugin 是否为函数,若是则执行。最后将 plugin 压入插件数组。

最近的文章

经典的函数

经典函数收集数组去重// 原始方法一var arr1 = [ 1, 1, '1', '1' ]function unique1( arr ) { let arrLen = arr.length if ( arrLen <= 1 ) return debugger let resArr = [] for ( let i = 0; i < arrLen; i++ ) { for ( var j = 0, resLen = resArr.length; j < ...…

继续阅读
更早的文章

about echarts

EChartsECharts 是百度关于数据可视化的库,对外暴露一些接口,用来控制可视化图表的样式及展示的数据内容等等。相关的库还有HighCharts、阿里 G2、Chart.js等,还有  主打 3d 的three.js。ECharts 基本用法现在的项目多是用 webpack 构建工具,因此直接如下:// # 安装echartsnpm install echarts -S// 引入echarts,当然这是引入echarts的所有模块;如按需引入,需先引入主模块,然后再引入各个组件模...…

继续阅读