前端基础知识与必备技能介绍

【长文】掌握一些前端必备的知识和技能是最基础的要求

本文由 墨然 发布于 2021-05-17

对于一个前端开发来说,掌握一些前端必备的知识和技能是最基础的。即使这些年来前端的技术栈不断地进行更新换代,但最基础的能力、核心的能力依然没有太大的变化。在日常工作中,常常使用到的还是 HTML/CSS/Javascript 相关的基础内容、代码开发和调试技巧的掌握等。

在介绍这些之前,我们还需要对前端领域有一个比较基本的了解。

1.1 前端都能做些什么

最近几年前端领域的变化可谓翻天覆地,我们从最简单的写页面,到如今的工程化、服务端、客户端等领域也都渐渐出现了前端开发的踪影。那么,我们要怎么理解前端这个职位呢?

1.1.1 前端是什么

对我来说,当初喜欢上前端的原因,大概是因为将前端理解为连接用户的最后一层。前端负责页面的展示和操作交互,是我们的产品与用户直接对话的一步。用户交互的界面很多,还有终端、操作系统等等,但对于用户来说,涉及前端的 Web、H5 页面、小程序等出现的频率更高一些。

前端领域的发展

最初的前端,就是写浏览器里面的页面。像我们常说的网站、网页,或者是论坛、贴吧等,都是前端实现的页面。那时候,前端主要控制页面的展示,和一些样式的兼容。随着网络速度和机器性能的提升,页面的交互逻辑逐渐复杂。

接下来是前端工程化的一些工具、插件、框架的出现,前端的开发效率逐步提升,同时浏览器的兼容性提升和所具备的能力更加丰富,前端能做到的事情也更多。大家都知道,前端是通过 HTML/CSS/Javascript 来写页面的,因此浏览器除了对 HTML/CSS 的渲染,还有作为页面的逻辑控制的 Javascript 引擎。目前为止,或许大部分的前端的工作内容还是基于浏览器,但随着浏览器的内核或者是 Javascript 的解析引擎被移植到各个环境,前端的涉猎范围也拓展到很多地方。

现在前端也有很多的插件或者库的支持,例如有了 Canvas 可以写网页游戏、各种图表插件 Echarts/d3 绘制图表,还有 WebGL 的支持、three.js 的封装库来写 3D 动画或是游戏。我们也常常看到前端的技术栈不停地更新,除了样式库 bootstrap、曾经打天下的 jQuery,还有如今各种框架之争 Vue、Angular、React,数据流的处理 Rxjs、用于 API 的查询语言 GraphQL。

前端领域在不断更新换代,作为前端开发,我们同样也需要不停地更新和迭代自己,跟随着世界的脚步走。每一步都走稳了,才是最踏实的成长方式。

前端在一个产品中的位置

一个完整的产品需要很多道工序,一个应用程序也对应很多层的开发。

一般来说,一个网页可能是静态页面,即:内容和样式都是前端写好的,部署到机器上,添加路由就可访问。如今页面大部分是动态生成的,即:页面打开后,需要拉取接口获取数据,然后重新更新到页面中。像一些直播弹幕、状态的查询等,常常是前端将后台的数据拉取回来后,再通过一些浏览器的 API 渲染到页面中。

浏览器网页的开发组成:前端 <=> (数据交互) <=> 后台。而如果是多终端的数据展示,则后台的数据需要同时提供给其他地方,例如常见的手机 APP:用户 <=> 终端 /webview 嵌 H5 <=> (数据交互) <=> 终端后台 <=> 数据库 <=> 管理后台 <=> 管理前端 <=> 运营人员

前端页面既可以作为展示,也可以作为管理,可用于分享。把一个产品做好的关键点,多在于产品设计本身的创造性和创新性,前端只是一种实现方式而已。即使这样,我很喜欢将前端的位置这样理解:肩负着与用户最近距离的接触,把最好的一面呈现给用户。

如果说我们想要让用户喜欢我们的产品,首先要做的就是要将它们以最完美的形态出现。前端的工作,正是要完美地控制展示效果、给到用户最友好的体验。

1.1.2 前端在程序员中的位置

如今前端可以借助 Node.js 去实现对更底层功能的开发,前端这个职位能做的事情也越来越多,也出现了大前端一说。我们开始需要写脚本、需要跑自动化任务,也可以与数据库直接进行交互,前端开发的能力范围也越来越大了。当然,其中也不乏一些开始负责部分后台服务的前端,仅仅因为使用了 Node.js 来写点接入层,便将自己称之为全栈。

我见过一些团队中,一些前端开发认为自己在业务中不够核心、地位比较低,便开始计划搞些 Node.js 配套服务,强行接管一些后台的功能服务。我也见过一些团队中,前端觉得自己只能写写页面、在前端领域已经到达天花板了,突然感觉职业危机出现了,于是乎大刀阔斧低要把 Node.js 带入团队中,用来做点除了写页面以外的事情。

但其实,前端与后台的区别又岂止仅仅是语言呢?前端对于单线程的 web、浏览器机制、动态语言的缺陷和优势、HTTP 协议、网络请求等掌握得很熟练,深入发展方向可以包括大型页面的性能优化、页面功能模块的抽象与组织、数据与渲染的拆离、前端工程化的规范化与效率提升等等。而后台本身更关注计算机资源、多进程、数据库等,需要熟练掌握多并发、队列、进程通信、事务、数据库索引等。这其中的区别,又岂是 Node.js 还是 C++/JAVA/GO 这么简单呢?

当然,有些时候前端接管一些接入层的工作,可以支援和解决后台开发人力不够的问题,我也不否认很多时候 Node.js 可以给团队带来更多的价值。但如果仅仅因为 “只会 Javascript” 就强行把 Node.js 带入团队中,这样的理由则过于牵强。前端开发什么时候开始,就完全等于 Javascript 开发呢?

的确,如今前端能做的事情很多,而随着前端工具框架能力越来越完善、开发效率越来越高,很多时候大家面临着简单地写写页面逻辑和交互、因为其余的工作都由工具库完成这样的情况。那么如果我们想要成为真正的全栈,不妨从最基础的计算机原理、编译原理、数据库设计等等开始学起,而不是仅仅从写 Javascript 变成了写 Node.js。语言其实只是其次,我们想要成为全栈的话,欠缺的还有很多。

Node.js 可以赋予我们更多的能力,但我们不要反过来被 Node.js 束缚住。相比于强行在团队中推行 Node.js,其实只要想,我们也一样可以学会后台,不管是哪种语言都好。毕竟对程序员来说学习能力是最重要的,而前端面对每年都更新的技术栈,本身也能快速学习一些新的知识和技能。

1.1.3 纯前端的进击

如果我们对自己的要求不只是一个纯前端,那么也可以往其他领域进行拓展。我们可以借助 Javascript 解析引擎移植带来的便利,来做更多的事情。

前端可以做哪些事情

的确,前端可以做的事情真的越来越多了。除了常见的 Web 页面,我们还可以做这些:

(1) 无处不在的 H5 页面

如今智能手机的普及,更是让 H5 出现在各种信息流中。H5 是什么呢,其实就是移动端的网页,主要用于信息分享、简单的功能、小游戏等等,加载和传播速度快的小页面。H5 页面主要依赖 App 里的浏览器内核,基本上每个 App 都会支持 H5 页面的。而 HTNL5 中 video、audio、canvas 等新媒体元素,以及 CSS3 中的动画效果,使得用户能在小小的屏幕页面里,获取到更加丰富类型的信息。

(2) App 开发

智能手机的普及,开拓了一大片 App 的市场。对终端的尝试,也是近年来前端圈子一直在做的事情。有了 react-native、weex 等各种 Native App 开发框架支持,前端小伙伴们也能偶尔朝终端 APP 插上一脚。同时 Flutter 的出现,也给大家带来了更多的机会。

(3) PC 应用

用 electron、NW.js 这样的框架,结合了 Node.js 和 Chromium 的能力,大大增加了前端开发实现跨系统的 PC 应用的便利性。网易云音乐的 PC 版便是 electron 的产品,我们用来写代码的 VS Code 同样也是基于 electron 进行开发,像小程序开发者工具则是基于 NW.js 框架实现。

(4) 服务端

在 Node.js 的强助力之下,前端小伙伴也能管理文件和资源,维护服务进程和数据库了。更多的时候,Node.js 会被用作服务端的接入层服务。因为异步的方式,有时候也比较适合高并发的服务。

(5) 小程序开发

从微信开始火起来的小程序,到后续的支付宝小程序、头条小程序、百度小程序、QQ 小程序等等,这种 Hybrid APP 的方式如今也找到了一个较友好的方向来进行:官方 APP 提供增强 WebView 的形式,给到开发者参与到 APP 生态中,共同补齐用户的产品生态建设能力。

(6) Serverless

如今各种云开发的能力在健全和推广,例如微信小程序的云开发能力,也补齐了前端开发对服务端开发和运维中缺失的一环,能真正意义上实现一人完成整个小程序,从设计到开发到上线到运维。云开发在这几年越来越流行,具备完整能力的云服务可以做到零部署、零维护,同时可以保证服务稳定性。以小程序云开发作为例子,云开发的能力一般包括:

  • 云函数:在云端运行的代码,微信私有协议天然鉴权,开发者只需编写业务逻辑代码
  • 数据库:一个既可在小程序前端操作,也能在云函数中读写的文档型数据库
  • 文件存储:在小程序前端直接上传 / 下载云端文件,在小程序云控制台可视化管理

拓展自己的能力

如果只会前端,其实局限性会比较大(比如切图不会切、设计也不行、后台不会写、机器运维也不懂),不管是从与合作伙伴协作(与交互设计对接、与后台联调等)、讨论需求的时候,背景知识的欠缺也容易使得沟通不顺畅。

那么,我们可以根据自己的兴趣,选择性地将技术栈拓展一些(后台、运维、设计、交互等方向都可以),学习点其他领域的东西。其实程序设计最终是相通的,而程序设计与我们的生活工作其实也可以是相通的。学习一些其他的思维模式、结构设计,对深入前端领域也好,对视野和思维的拓展也好,都会有很大的帮助。

前端开发中很多都是非科班出身的,我也一样。我们相对于科班程序员天然有一些基础的劣势,这些缺失的知识或许在哪一天就会限制住自身的发展。那么我们也可以提前做好准备,自己在业余时间去恶补这些知识,可能涉及计算机操作系统、编译原理、算法与数据结构等等。

1.2 基础知识的具备

对于程序员来说,专业能力不可或缺,它决定了我们能在这个行业里走多远。而对于前端开发来说,的确也需要掌握很多的基础知识,也在不断地学习新的知识。

属于前端的基础知识其实也不少,除了大家都知道的必备三剑客 Javascript/CSS/HTML,我们还需要知道浏览器机制、HTTP 协议与 Ajax 请求、代码编写和调试等技巧。如果你的目的是想要找到一份工作,那么可以更加关注《第7章 找工作要准备些什么》,本章我们主要介绍作为前端日常工作中需要掌握的一些基础知识。

1.2.1 CSS 和页面样式布局

页面样式其实大多数情况下都无法速成,需要通过不断地练习、反复地调试才能熟练掌握。但也有一些小伙伴会疑惑为什么写了很久的前端页面,还是写不好页面布局?

其实页面布局有一些很基础的规则,掌握这些规则,很多时候你的疑惑也能迎刃而解。

1、盒模型

盒模型指的是 CSS 基础盒模型。当浏览器对一个文档进行布局的时候,会将每个元素都表示为一个个矩形的盒子,CSS 控制这些盒子的尺寸、属性(颜色、背景、边框等)和位置,渲染引擎则将它们渲染出来。这个模型描述了元素所占空间的内容,每个盒子由四个部分组成:

  • margin:外边框边界
  • border:边框边界
  • padding:内边距边界
  • content:内容边界

如图下图所示:

CSS 的盒模型
CSS 的盒模型

我们浏览器对文档进行布局的时候,会将每个元素都表示为图中矩形的盒子。我们使用 CSS 样式来控制这些盒子的尺寸、属性(颜色、背景、边框等)和位置,渲染引擎会依据 CSS 规则树和 DOM 节点生成的渲染树进行渲染。

关于盒模型,这里介绍在开发过程中会遇到的常见问题和应用。

(1) 盒模型会发生 margin 外边距叠加。当两个或更多个垂直边距相遇时,它们有时被合并(折叠)成单个边距,需要注意的是:

  • 外边距(margin)的高度大小是各个边距中的最大值
  • 行内框、浮动框或绝对定位框之间的外边距不会叠加,相邻的兄弟姐妹、没有内容将父母与后代分开或是空块等三种情况,才会出现外边距叠加

(2) 我们在工作中会常见到一种 CSS 3 中新增的盒模型计算方式:box-sizing 属性。盒模型默认的值是 content-box, 新增的值是 padding-boxborder-box。具体的这里不多介绍,大家可以主要关注最常用的 border-box,在 border-box 模型下:

  • 元素宽高 (width/height) 等于:content + padding + border
  • 布局所占宽高等于元素宽高

2、内联元素与块状元素

内联元素和块状元素在页面中的布局效果有很大的不同,通常我们会使用 display 来进行调整。

内联元素又称行内元素等,表示位于行内的元素,特点包括:

  • 内联元素只能容纳文本或者其他内联元素,只有这些文本或元素可以与其位于同一行
  • 内联元素的宽度高度不起作用,因此给内联元素设置宽高是不生效的
  • 常见的内联元素:<a>/<span>/<i>/<strong>

块状元素一般是其他元素的容器,可同时容纳内联元素或者其他块状元素,特点包括:

  • 块状元素排斥其他元素与其位于同一行
  • 块状元素的宽度高度起作用
  • 常见的块状元素有:<div>/<p>/<h1>…<h6>/<ul>/<ol>

我们可以通过 display 属性去设置元素类型,常用的 display 属性包括:

  • block:块状元素,可以设置宽度 width 和高度 height
  • inline:内联元素,宽度高度不起作用
  • inline-block:可以理解为块状元素和内联元素的结合
    • 位于块状元素或者其他内联元素内
    • 可容纳其他块状元素或内联元素
    • 宽度高度起作用

使用 inline-block 可以很方便解决一些问题:使元素居中、给 inline 元素(<a>/<span>)设置宽高、将多个块状元素放在一行等。

3、元素定位

每一个前端开发都需要认识文档流,关于文档流:正常的文档流也叫普通流,在 HTML 里面为从上到下,从左到右的排版布局。我们常用的布局与 position 样式属性紧紧相关,其中 position 样式属性包括:

  • static(默认值):没有定位,元素出现在正常的流中(忽略 top/bottom/left/right 或者 z-index 声明)
  • inherit:规定应该从父元素继承 position 属性的值
  • relative:生成相对定位的元素,相对于其正常位置进行定位,relative 特点包括:
    • relative 元素保持原有文档流,但相对本身的原始位置发生位移,且占空间
    • relative 元素也遵循从上到下,从左到右的排版布局
    • 相对于其正常位置进行定位,在这里设置了 relative 的元素相对其原本位置(position: static)进行位移
    • relative 元素占有原本位置,因此下一个元素会排到该元素后方
    • relative 元素占位不会随着定位的改变而改变,也就是说 relative 在文档流中占有的位置与其原本位置(position: static)相同
  • absolute:生成绝对定位的元素,相对于 static 定位以外的第一个父元素进行定位。元素的位置通过 left/top/right/bottom 属性进行规定。absolute 特点包括:
    • absolute 元素脱离文档流
    • absolute 元素不占位,因此下一个符合普通流的元素会略过 absolute 元素排到其上一个元素的后方
    • absolute 元素的定位是相对于 static 定位以外的第一个父元素进行定位
  • fixed:生成绝对定位的元素,相对于浏览器窗口进行定位。元素的位置通过 left/top/right/bottom 属性进行规定。fixed 的特点:
    • fixed 元素脱离文档流
    • fixed 元素不占位
    • fixed 相对于浏览器窗口来定位,不管是否有 static 定位以外的父元素
    • absolute 元素会随着页面的滚动而滚动,而 fixed 不会

关于 position 样式属性在各种场景下的布局效果相对复杂,如果需要熟练掌握还是需要花不少的心思去练习和思考的。

4、元素堆叠

元素的堆叠方式和顺序,除了与 position 定位有关,也与 z-index 有关,有关 z-index 的说明:

  • 当同级元素不设置 z-index 或者 z-index 相等时,后面的元素会叠在前面的元素上方
  • 当同级元素 z-index 不同时,z-index 大的元素会叠在 z-index 小的元素上方

除了同级元素以外,z-index 值的设置效果还会受到父元素的 z-index 值的影响,它只决定同一父元素中的同级子元素的堆叠顺序,在此之外的场景会比较复杂,大家可以自己去实践下,篇幅关系我们不再这里拓展了。

z-index 样式属性比较常用于多个元素层级控制的时候,比如弹窗一般需要在最上层,就可以通过设置较大的 z-index 值来控制。
常见页面布局方式

目前来说,前端开发比较常见的布局方式主要有:

  • 传统布局方式
  • Flex 布局方式
  • Grid 布局方式

(1) 传统布局

传统布局方式基本上借助于上面提到的一些布局规则,结合 display/position/float 属性以及一些边距、x/y 轴距离等方式来进行布局。

由于文档流、盒模型、等前面都有介绍,这里我们主要补充一下 float 浮动布局方式。给元素的 float 属性赋值后,元素会脱离文档流,进行左右浮动,紧贴着父元素的边框或者是上一个同级同浮动元素的边框。

首先了解下 float 属性:

  • float 属性定义元素在哪个方向浮动
  • float 属性可应用于图像,使文本围绕在图像周围
  • float 与 block
    • 设置 float 浮动的元素自动获取 display: block 样式
    • 当一个元素浮动之后,不会影响到块级框的布局
  • float 与 inline-block
    • 当一个元素浮动之后,会影响内联框(通常是文本)的排列和布局
    • float 浮动若未指明宽度会尽可能地窄,而 inline-block 元素会带来空白问题

使用 float 属性,必然会遇到一个问题:本属于普通流中的元素浮动之后,包含框内部由于不存在其他普通流元素了,也就表现出高度为 0,又称为高度塌陷。

因此,我们也需要掌握 float 撑开父元素的方法:

  • 父元素使用 overflow: hidden(此时高度为 auto
    • 父元素 overflow: hidden 后,首先会计算 height: auto 的真实高度,由于其触发了 BFC,需要包含子元素,所以高度不是 0,而是子元素高度(关于 BFC 大家可以自行去了解一下)
  • 使父元素也成为浮动 float 元素
    • 将父容器也改成浮动定位,这样它就可以带着子元素一起浮动了
  • 使用 clear 清除浮动
    • 在浮动元素后方加入 clear: both 的元素,就可以清除浮动撑开父元素
    • 其中在样式中添加 clear: right,理解为不允许右边有浮动元素,由于上一个元素是浮动元素,因此该元素会自动下移一行来满足规则

通过传统方式布局的优势在于兼容性较好,在一些版本较低的浏览器上也能给到用户较友好的体验。但传统布局需要掌握的知识较多也相对复杂,因此 Flex 布局和 Grid 布局也主要用来解决传统布局的种种不便。

(2) Flex 布局

Flex 布局基于 Flexible Box 模型,通常被称为 flexbox,是一种一维的布局模型。对于 Flex 布局,我们主要需要掌握几个概念:

  • flexbox 的两根轴线:其中,主轴由 flex-direction 定义,交叉轴则垂直于主轴。
  • 起始和终止:传统布局的文档流是从左到右、从上到下的布局方式。而在 flexbox 中,我们使用起始和终止来描述布局方向和顺序。
  • Flex 容器:采用了 flexbox 的区域(display 属性值为 flex 或者 inline-flex)叫做 flex 容器,容器中的直系子元素就会变为 flex 元素。通过 flex-direction/flex-wrap/flex 等各种属性设置,我们可以方便地设置容器内元素的布局效果。

使用 Flex 布局可以:

  • 通过 flex-direction 调整 Flex 元素的排列方向(主轴的方向)
  • flex-wrap 实现多行 Flex 容器如何换行
  • 使用 justify-content 调整 Flex 元素在主轴上的对齐方式
  • 使用 align-items 调整 Flex 元素在交叉轴上如何对齐
  • 使用 align-content 调整多根轴线的对齐方式

Flex 布局的出现,解决了很多前端开发居中、排版的一些痛点,尤其是垂直居中,因此现在几乎成为了主流的布局方式。以前我们都是基于盒模型来布局,一般使用 display 属性 + position 属性 + float 属性。Flex 布局给 flexbox 的子元素之间提供了强大的空间分布和对齐能力。

除此之外,还可以对 Flex 元素设置排列顺序、放大比例、缩小比例等等。更多的使用方法,大家可以去网上进行相关的查询和学习。

(3) Grid 布局。

Grid 布局又称为网格布局,提供了一种二维布局的方式,它将一个页面划分为几个主要区域,以及定义这些区域的大小、位置、层次等关系。

我们知道 Flex 布局是基于轴线布局,与之相对,Grid 布局则是将容器划分成行和列,可以像表格一样按行或列来对齐元素。网格容器的子元素可以自己定位,像 CSS 定位的元素一样有重叠和层次关系。

同样的,对于 Grid 布局,我们也需要掌握几个概念:

  • 网格轨道与行列:一个网格轨道就是网格中任意两条线之间的空间,可以通过 grid-template-columnsgrid-template-rows 属性来定义网格中的行和列。
  • 网格线:当我们定义网格时,我们定义的是网格轨道,Grid 会为我们创建编号的网格线来让我们来定位每一个网格元素。网格线的编号顺序取决于定义的布局方向,水平网格线划分出行,垂直网格线划分出列。
  • 网格容器:采用了 Grid 布局的区域(display 属性值为 grid 或者 inline-grid)叫做网格容器,容器中的直系子元素就会变为网格元素。

使用 Grid 布局可以:

  • 实现网页的响应式布局
  • 实现灵活的 12 列布局(类似于 Bootstrap 的 CSS 布局方式)
  • 与其他布局方式结合,与 css 其它部分协同合作

通过 Grid 布局我们能实现任意组合不同布局,其设计可称得上目前最强大的布局方式,它与 Flex 布局是未来的趋势。其中,Grid 布局适用于较大规模的布局,Flex 布局则适合页面中的组件和较小规模布局。

以上这些内容属于比较基础的布局,我们在写 CSS 过程中会遇到很多的神奇现象,而要理解这些现象,就得知道浏览器布局的一些原理逻辑和设定。除了布局以外,很多页面开发也有对 CSS 3 动画的一些要求,这里篇幅关系不多介绍,大家可以通过互联网来学习更多的内容。

1.2.2 HTML 与 DOM

HTML 是一种相当简单的、由不同元素组成的标记语言,用来告知浏览器如何组织页面的。

文档对象模型(DOM)是 HTML 和 XML 文档的编程接口。DOM 将文档解析为一个由 DOM 节点和相关对象(包含属性和方法的对象)组成的结构集合。尽管我们通常会使用 JavaScript 来访问 DOM,但它并不是 JavaScript 的一部分,它也可以被其他语言使用。

1、HTML 元素

几乎所有网页都是有这样的结构组成:

1
2
3
4
<html lang="zh-cmn-Hans" style="font-size: 109.4px;">
<head></head>
<body></body>
</html>

其中:

(1) <html></html> 元素是页面的根元素,它描述完整的网页。
(2) lang="zh-cmn-Hans" style="font-size: 109.4px;" 是用来描述 <html> 元素的属性,属性常用来描述元素的额外信息。
(3) <head></head> 元素包含了我们想包含在 HTML 页面中、但不希望显示在网页里的内容,包括 CSS 样式、Javascript 脚本、元数据描述等。
(4) <body></body> 元素包含了我们访问页面时、所有显示在页面上的内容,也就是我们能看到的内容。

HTML 中的元素特别多,除了以上提到的,还包括 <text><div><a> 等以及各种自定义元素。

2、DOM 解析

我们常见的 HTML 元素,在浏览器中会被解析成节点。比如下面这样的 HTML 内容:

1
2
3
4
5
6
7
8
9
<html>
<head>
<title>文档标题</title>
</head>
<body>
<a href="xx.com/xx">我的链接</a>
<h1>我的标题</h1>
</body>
</html>

在浏览器中会被解析成节点,如图:

DOM 树节点
DOM 树节点

在控制台,我们也能比较清晰地看到这样的层级关系,如图:

在控制台查看 DOM 树节点
在控制台查看 DOM 树节点

节点树中的节点彼此拥有层级关系,父(parent)、子(child)和同胞(sibling)等术语用于描述这些关系。父节点拥有子节点,同级的子节点被称为同胞(兄弟或姐妹)。在节点树中,顶端节点被称为根(root)。节点树相关内容还包括:

  • 除了根节点(根节点没有父节点)以外,每个节点都有父节点
  • 一个节点可拥有任意数量的子节点
  • 相同父节点的子节点,互为同胞节点

通过 HTML DOM 相关接口,我们可以使用 JavaScript 来访问节点树中的所有节点,也可以创建或删除节点。因此,所有 HTML 元素(节点)均可被修改。DOM 接口主要用于操作 DOM 节点,如常见的增删查改:

  • document.getElementById(id):根据 id 获取元素
  • document.createElement(name):创建元素
  • parentNode.appendChild(node):添加子元素
  • element.innerHTML:设置/获取元素内容

通常什么时候会用呢,最常见的便是列表的维护,包括增加新的选项、删除某个、修改某个等等。在浏览器兼容性问题很多的时候,我们常常会使用 jQuery 来进行些 DOM 操作,如今兼容性问题逐渐变少,大家更倾向于用原生 DOM 接口来进行操作。

随着应用程序越来越复杂,DOM 操作越来越频繁,需要监听事件和在事件回调用更新页面的 DOM 操作也越来越多,性能消耗则会比较大。于是虚拟 DOM 的想法便被人提出,并在许多框架中都有实现。虚拟 DOM 其实是用来模拟真实 DOM 的中间产物,主要包括以下功能:

  • 用 JS 对象模拟 DOM 树,简化 DOM 对象
    • 简单来说,就是用一个对象模拟 DOM 元素,保留主要的一些 DOM 属性,其他的则去掉
    • 通过这种方式,可以减少 DOM Diff 时候的计算量
  • 使用虚拟 DOM,结合操作 DOM 的接口,来生成真实 DOM
    • 使用假 DOM 生成真 DOM,同时保持真实 DOM 对象的引用,一边下一个步骤的执行
  • 更新 DOM 时,比较两棵虚拟 DOM 树的差异,局部更新真实 DOM

3、DOM 事件流

DOM 事件流描述的就是各个元素从页面中接受事件的顺序。DOM 事件流(event flow)存在三个阶段:

  • 事件捕获阶段(从文档的根节点流向目标对象)
  • 处于目标阶段(在目标对向上被触发)
  • 事件冒泡阶段(再回溯到文档的根节点)。

关于 DOM 事件流以及以上的三个阶段,我们需要知道:

  • 事件捕获:
    • 当鼠标点击或者触发 DOM 事件时,浏览器会从根节点开始由外到内进行事件传播,即点击了子元素,如果父元素通过事件捕获方式注册了对应的事件的话,会先触发父元素绑定的事件
    • 在事件捕获的概念下在 p 元素上发生 click 事件的顺序应该是 document -> html -> body -> div -> p
  • 事件冒泡:
    • 与事件捕获恰恰相反,事件冒泡顺序是由内到外进行事件传播,直到根节点
    • 在事件冒泡的概念下在 p 元素上发生 click 事件的顺序应该是 p -> div -> body -> html -> document

DOM 标准事件流的触发的先后顺序为:先捕获再冒泡。也就是说,当触发 DOM 事件时会先进行事件捕获,捕获到事件源之后通过事件传播进行事件冒泡。不同的浏览器对此有着不同的实现,IE10 及以下不支持捕获型事件,所以就少了一个事件捕获阶段,IE11、Chrome 、Firefox、Safari 等浏览器则同时存在。

事件委托

基于事件冒泡机制,我们可以实现将子元素的事件委托给父级元素来进行处理。当我们需要对很多元素添加事件的时候,可以通过将事件添加到它们的父节点而将事件委托给父节点来触发处理函数。这样能解决什么问题呢:

  • 绑定子元素会绑定很多次的绑定,而绑定父元素只需要一次绑定
  • 将事件委托给父节点,这样我们对子元素的增加和删除、移动等,都不需要重新进行事件绑定

常见的使用方式还是以列表为例子,每个选项都可以进行编辑、删除、添加标签等功能,而把事件委托给父元素,不管我们新增、删除、更新选项,都不需手动去绑定和移除事件。比如:

1
2
3
4
5
6
7
8
9
10
// 给id为my-list的元素绑定click事件
document.getElementById('my-list').addEventListener('click', function (e) {
// 兼容性处理
var event = e || window.event;
var target = event.target || event.srcElement;
// 判断是否匹配目标元素
if (target.nodeName.toLocaleLowerCase === 'button') {
console.log("you clicked a button", target.innerHTML);
}
});

这个事件委托的过程实现方式为:

(1) 在父层(或更外层)元素上绑定某类事件。
(2) 事件触发的时候,检查源元素 event.target 是否符合预期。
(3) 如果事件发生在我们需要处理的元素里,则进行后续的处理。

如果在列表数量内容较大的时候,对成千上万节点进行事件监听,也是不小的性能消耗。使用事件委托的方式,我们可以大量减少浏览器对元素的监听,也是在前端性能优化中比较简单和基础的一个做法。

需要注意的是,如果我们直接在 document.body 上进行事件委托,可能会带来额外的问题。由于浏览器在进行页面渲染的时候会有合成的步骤,合成的过程会先将页面分成不同的合成层,而用户与浏览器进行交互的时候需要接收事件。此时,浏览器会将页面上具有事件处理程序的区域进行标记,被标记的区域会与主线程进行通信。

如果我们 document.body 上被绑定了事件,这时候整个页面都会被标记。即使我们的页面不关心某些部分的用户交互,合成器线程也必须与主线程进行通信,并在每次事件发生时进行等待。这种情况,我们可以使用 passive: true 选项来解决。

4、BOM

BOM 即 Browser Object Model,浏览器对象模型。BOM 主要处理浏览器窗口和框架,通常浏览器特定的 JavaScript 扩展也都被看做 BOM 的一部分。BOM 是各个浏览器厂商根据 DOM 在各自浏览器上的实现,表现为不同浏览器定义有差别、实现方式不同。Javacsript 是通过访问 BOM 对象来访问、控制、修改客户端 (浏览器)。

DOM(Document Object Model 文档对象模型)是为了操作文档出现的 API,包括 document。BOM(Browser Object Model 浏览器对象模型)是为了操作浏览器出现的 API,包括 window/location/history 等。

1.2.3 JavaScript

我们经常提到的 JavaScript,其实指的是 ECMAScript。ECMAScript 是形成 JavaScript 语言基础的脚本语言。而我们常说的 ES6/ES7,其实是一些 ECMAScript 新特性,主要是用来提升开发效率的语法糖。对于 Javascript,我们需要了解以下一些知识。

1、单线程的 Javascript

作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。如果 Javascript 是多线程,当页面更新内容的时候、用户又触发了交互,这时候线程间的同步问题会变得很复杂,为了避免复杂性,Javascript 被设计为单线程。

那么这样一个单线程的 Javascript,要如何高效地进行页面的交互和渲染处理呢?Javascript 只有一个线程,意味着任务需要一个接一个地进行,如果这时候我们有一个任务是等待用户输入,那在用户进行操作前,所有其他任务都会等待,页面处于假死状态,体验糟糕。因此,异步任务出现了。

在浏览器中,任务可以分为同步任务和异步任务两种。同步任务在主线程上排队执行,只有前一个任务执行完毕,才能执行后一个任务。异步任务进入 “任务队列” 的任务,当该任务完成后,”任务队列” 通知主线程,该任务才会进入主线程排队执行。主线程会在空闲时获取任务队列中的队列执行,这个模型也被称为 Event Loop。

异步任务机制会导致一些前端容易踩的坑,常见的有 setTimeoutsetInterval 的时间精确性。该类方法设置一个定时器,当定时器计时完成时需要执行回调函数,此时才把回调函数放入事件队列中。如果当回调函数放入队列时,假设队列中还有大量的事件没执行,此时就会造成任务执行时间不精确。

一般来说,可以使用系统时钟来补偿计时器不准确性。在每次回调任务结束的时候,根据最初的系统时间和该任务的执行时间进行差值比较,来修正后续的定时器时间。

2、原型和继承

Javascript 的原型和继承围绕着一点展开:几乎所有的对象都是 Object 的实例,Object 位于原型链的顶端。

原型对象

当谈到继承时,JavaScript 只有一种结构:对象。几乎所有 JavaScript 中的对象都是 Object 的实例,包括函数、数组、对象等。Javascript 中的对象之所以用途广泛,是因为它的值既可以是原始类型(numberstringbooleannullundefinedbigintsymbol),还可以是对象和函数。其中,函数也是一种特殊的对象,它同样拥有属性和值,除此之外还有 name(函数名)和 code(函数代码)两个隐藏属性,因此可被调用。

在一个对象中,属性的值同样可以为另外一个对象,因此我们可以通过这样的方式来实现继承:使用一个代表原型的属性,属性的值为被继承的对象,此时可以通过层层查找来得到原型链上的对象和属性。在 Javascript 中,该属性便是 __proto__,被继承的对象即原型对象 prototype。

创建一个对象包括了工厂模式、构造函数模式、原型模式等,可以使用以下方法:

  • 使用语法结构,即定义一个数组、函数、对象等
  • 使用构造器: new XXX()
  • 使用 Object.create
  • 使用 class 关键字

其中,最常见的便是使用构造函数来创建对象:

(1) 默认情况下,所有原型对象(prototype)自动获得一个 constructor 属性,指向与之关联的构造函数。
(2) 当我们创建对象时,Javascript 就会创建该构造函数的实例。
(3) 创建的实例通过将 __proto__ 指向构造函数的原型对象(prototype),来继承该原型对象的所有属性和方法。

构造函数、原型和实例的关系:

  • 每个原型对象(prototype)都拥有 constructor 属性,指向该原型对象的构造函数
  • 使用构造函数可以创建对象,创建的对象称为实例对象
  • 实例对象通过将 __proto__ 属性指向原型对象(prototype),实现了该原型对象的继承
  • 实例与构造函数原型之间有直接的关系,但与构造函数之间没有

关于__proto__prototype,很多时候我们容易搞混:

  • 每个对象都有__proto__ 属性来标识自己所继承的原型对象,但只有函数才有 prototype 属性
  • 通过 prototype__proto__,JavaScript 可以在两个对象之间创建一个关联,使得一个对象可以通过委托访问另一个对象的属性和函数

原型链

原型链是 Javascript 中主要的继承方式,可以通过原型继承多个引用类型的属性和方法。

我们知道,一个对象可通过 __proto__ 访问原型对象上的属性和方法,而该原型同样也可通过 __proto__ 访问它的原型对象,这样我们就在实例和原型之间构造了一条原型链。JavaScript 中的所有对象都来自 Object,因此默认情况下,任何函数的原型属性 __proto__ 都是 window.Object.prototypeprototype 原型对象同样会具有一个自己的原型,层层向上直到一个对象的原型为 null

关于原型链,我们需要知道:

  • 当试图访问一个对象的属性时,会优先在该对象上搜寻。如果找不到,还会依次层层向上搜索该对象的原型对象、该对象的原型对象的原型对象等(套娃告警)
  • 根据定义,null 没有原型,并作为这个原型链中的最后一个环节
  • __proto__ 的整个原型链被查看之后,浏览器才会认为该属性不存在,并给出属性值为 undefined 的结论
1
2
3
4
// 任何函数的原型属性 __proto__ 都是 Object.prototype
// Object.getPrototypeOf() 方法返回指定对象的原型
// 我们能看到,null 作为原型链中最后一个环节
Object.getPrototypeOf(Object.prototype) === null; // true

我们来看个具体的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 让我们假设我们有一个对象 o, 其有自己的属性 a 和 b:
var o = {a: 1, b: 2};
// o 的原型 o.__proto__有属性 b 和 c:
o.__proto__ = {b: 3, c: 4};
// 最后, o.__proto__.__proto__ 是 null.
// 这就是原型链的末尾,即 null,
// 根据定义,null 没有__proto__.
// 综上,整个原型链如下:
{a:1, b:2} ---> {b:3, c:4} ---> null

// 当我们在获取属性值的时候,就会触发原型链的查找:
console.log(o.a); // o.a => 1
console.log(o.b); // o.b => 2
console.log(o.c); // o.c => o.__proto__.c => 4
console.log(o.d); // o.c => o.__proto__.d => o.__proto__.__proto__ == null => undefined

属性的查找会带来性能问题:

  • 在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要
  • 试图访问不存在的属性时,会遍历整个原型链

继承

前面我们提到,在 Javascript 中原型链是主要的继承方式。总体上,Javascript 中实现继承的方式包括:

  • 原型链继承:把构造函数的原型赋值为另一个类型的实例
  • 盗用构造函数(经典继承):在子类构造函数中调用父类构造函数
  • 组合继承:通过原型链继承共享的属性和方法,通过构造函数继承实例属性
  • 原型式继承:将传入的对象作为创建的对象的原型,本质上是对给定对象执行浅复制
  • 寄生式继承:基于一个对象创建一个新对象,增强新对象后返回新对象
  • 寄生组合式继承

其中,原型链继承方式中引用类型的属性被所有实例共享,无法做到实例私有;盗用构造函数方式可以实现实例属性私有,但要求类型只能通过构造函数来定义;组合继承融合原型链继承和构造函数的优点,是 JavaScript 中最常用的继承模式,它长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Parent(name) {
// 私有属性,不共享
this.name = name;
}
// 需要复用、共享的方法定义在父类原型上
Parent.prototype.speak = function() {
console.log('hello');
}
function Child(name) {
Parent.call(this, name);
}
// 将子类的 __proto__ 指向父类原型
Child.__proto__ = Parent.prototype

3、函数执行上下文

Javascript 运行过程主要分成三个阶段:

(1) 语法分析阶段。分析代码是否有语法错误(SyntaxError),如果有语法错误将会在控制台报错,并终止执行。

(2) 编译阶段。每进入一个不同的运行环境时都会:

  • 创建一个新的执行上下文(Execution Context)
  • 创建一个新的词法环境(Lexical Environment),即作用域

(3) 执行阶段。Javascript在运行过程中会产生一个调用栈,调用栈遵循 LIFO(先进后出,后进先出)原则:

  • 将步骤 (2) 中创建的执行上下文压入执行栈,并成为正在运行的执行上下文
  • 执行代码
  • 在代码执行结束后,将其弹出执行栈

Javascript 运行环境

Javascript 运行环境包括全局环境、函数环境和 eval

第一次载入 Javascript 代码时,会创建一个全局环境。当函数被调用时,则进入该函数的运行环境。不同的函数运行环境不一样,即使是同一个函数,在被多次调用时也会创建多个不同的函数环境。

不同的运行环境中,变量和函数可访问的其他数据范围不同,各种的行为也有所区别。每进入一个不同的运行环境时,Javascript 都会创建一个新的执行上下文,该过程包括:建立作用域链(Scope Chain)、创建变量对象(VO,Variable Object)以及确定 this 的指向。

创建变量对象

每个上下文都有一个关联的变量对象,这个上下文中定义的所有变量和函数都存在于这个对象上。在浏览器中,全局环境中的变量对象是 window 对象,所有的全局变量和函数都是作为 window 对象的属性和方法创建的。相对应的,在 Node 中则是 global 对象。

创建变量对象过程将会创建 arguments 对象(仅函数环境下),同时会检查当前上下文的函数声明和变量声明:

  • 对于变量声明,给变量分配内存,初始化为 undefined(定义声明和赋值声明分开,执行阶段才执行赋值语句)
  • 对于函数声明,会在内存里创建函数对象,并且直接初始化为该函数对象

这也是我们常说的变量提升和函数提升,其中函数声明提升优先于变量声明。变量提升容易带来在预期外被覆盖掉的问题,同时还可能导致本应该被销毁的变量没有被销毁等情况,因此 ES6 中引入了 letconst 关键字,从而使 Javascript 也拥有了块级作用域。

当代码进入执行阶段后,我们在编译阶段创建的变量对象(VO)中变量属性会进行赋值,变量对象会转为活动对象(Active Object,简称 AO),此时活动对象才可被访问。这便是 VO -> AO 的过程,本质上变量对象和活动对象为一个对象,但在编译阶段该对象值仍为 undefined,且处于不可访问的状态。

this

Javascript 中 this 指针代表的是执行当前代码的对象的所有者,可简单理解为 this 永远指向最后调用它的那个对象。

根据 JavaScript 中函数的调用方式不同,this 分为以下情况:

  • 在全局执行环境中(在任何函数体外部),this 指向全局对象(在浏览器中为 window)
  • 在函数内部,this 的值取决于函数被调用的方式
    • 函数作为对象的方法被调用,this 指向调用这个方法的对象
    • 函数用作构造函数时(使用new关键字),它的 this 被绑定到正在构造的新对象
    • 在类的构造函数中,this 是一个常规对象,类中所有非静态的方法都会被添加到this的原型中
  • 在箭头函数中,this 执行它被创建时的环境
  • 使用 applycallbind 等方式调用:根据 API 不同,可切换函数执行的上下文环境,即 this 绑定的对象

4、作用域和闭包

我们常说的作用域即当前的执行上下文,在 ES5 后我们使用 Lexical Environment(词法环境)替代作用域来描述该执行上下文。词法环境由两个成员组成:环境记录(Environment Record)和和外部词法环境引用(Outer Lexical Environment,简称 Outer)。

每个词法环境的 Outer 记录了外层词法环境的引用,当在自身词法环境记录无法寻找到某个变量时,可以根据 Outer 向外层寻找。在最外层的全局词法环境中,Outer 为 null

当代码在一个环境中执行时,会通过 Outer 创建变量对象的一个作用域链,来保证对执行环境有权访问的变量和函数的有序访问。每个 JavaScript 执行环境都有一个和它关联在一起的作用域链,这个作用域链是一个对象列表或对象链。在函数执行过程中,变量的解析是沿着作用域链一级一级搜索的过程:

  • 从第一个对象开始,逐级向后回溯,直到找到同名变量为止
  • 找到后不再继续遍历,找不到就报错
  • 当函数执行结束之后,执行期上下文将被销毁(作用域链和激活对象均被销毁)

作用域链使得我们在函数内部可以直接读取外部以及全局变量,闭包使得我们可以从外部读取局部变量。

由于在 Javascript 语言中,只有函数内部的子函数才能读取局部变量。我们看下面的例子:

1
2
3
4
5
function B() {
var b = 2;
}
B();
alert(b); //undefined

在全局环境下无法访问函数B内的变量,这是因为全局函数的作用域链里,不含有函数 B 内的作用域。现在如果我们想要访问内部函数的变量,可以这样做:

1
2
3
4
5
6
7
8
9
function B() {
var b = 2;
function C() {
alert(b); //2
}
return C;
}
var A = B();
A(); //2

此处,A 变成一个闭包了。闭包是一种特殊的对象。它由两部分构成:函数,以及创建该函数的环境。环境由闭包创建时在作用域中的任何局部变量组成,在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。闭包的常见用途包括:

  • 用于从外部读取其他函数内部变量的函数
  • 可以使用闭包来模拟私有方法
  • 让这些变量的值始终保持在内存中(涉及垃圾回收机制,可能导致内存泄露问题)

5、事件循环机制(Event Loop)

JavaScript 有一个基于事件循环的并发模型,称为事件循环(Event Loop)。前面我们提到异步任务的存在,Event Loop 的设计解决了异步任务的同步问题。

要理解 Javascript 的事件循环设计,需要先了解执行栈和任务队列。

执行栈

函数执行过程会产生一个调用栈,调用栈可理解为一个存储函数调用的栈结构,遵循先进后出的原则:

  • 每调用一个函数,该函数会被添加进调用栈,并开始执行
  • 如果正在调用栈中执行的A函数还调用了 B 函数,那么B函数也将会被添加进调用栈
  • 一旦 B 函数被调用,便会立即执行
  • 当前函数执行完毕后,解释器将其清出调用栈,继续执行当前执行环境下的剩余的代码

由此可见,该函数调用栈栈底永远是全局执行上下文,栈顶则永远是当前执行上下文。在不考虑全局执行上下文时,我们可以理解为刚开始的时候调用栈是空的,每当有函数被调用,相应的执行上下文都会被添加到调用栈中。执行完函数中相关代码后,该执行上下文又会自动被调用栈移除,最后调用栈又回到了空的状态(不考虑全局执行上下文)。

由于栈的容量是有限制的,因此当我们没有合理调用函数的时候,可能会导致爆栈异常。

任务队列

JavaScript 运行时会包含一个待处理的任务队列,每一个任务都关联着一个用以处理这个任务的回调函数。

任务队列则遵循先进先出的原则,处理过程如下:

  • 运行时会从最先进入队列的任务开始处理队列中的任务
  • 被处理的任务会被移出队列,并作为输入参数来调用与之关联的函数,此时会产生一个函数执行栈
  • 函数会一直处理到执行栈再次为空,然后事件循环将会处理队列中的下一个任务

在掌握了 Javascript 的单线程设计,以及同步任务、异步任务、执行栈和任务队列这些概念之后,我们来学习下 Event Loop 机制。

浏览器的 Event Loop

在浏览器里,每当一个被监听的事件发生时,事件监听器绑定的相关任务就会被添加进任务队列。通过事件产生的任务是异步任务,常见的事件任务包括:

  • 用户交互事件产生的事件任务,比如 DOM 操作
  • 计时器产生的事件任务,比如 setTimeout
  • 异步请求产生的事件任务,比如 HTTP 请求

Javascript 的运行过程,可以借用一张经典的图来描述:

JavaScript 运行过程
JavaScript 运行过程

如图,主线程运行的时候,会产生堆(heap)和栈(stack),其中堆为内存、栈为函数调用栈。我们能看到,Event Loop 负责执行代码、收集和处理事件以及执行队列中的子任务,具体包括:

  • Javascript 有一个主线程和执行栈,所有的任务都会被放到调用栈等待主线程执行
  • 同步任务会被放在调用栈中,按照顺序等待主线程依次执行
  • 主线程之外存在一个任务队列,所有任务在主线程中以执行栈的方式运行
  • 同步任务都在主线程上执行,栈中代码在执行的时候会调用 Web API,此时会产生一些异步任务
  • 异步任务会在有了结果(比如被监听的事件发生时)后,将注册的回调函数放入任务队列中
  • 执行栈中任务执行完毕后,此时主线程处于空闲状态,会从任务队列中获取任务进行处理

上述过程会不断重复,这就是 JavaScript 的运行机制,称为事件循环机制(Event Loop)。

Node.js 中的 Event Loop

除了浏览器,Node.js 中同样存在 Event Loop。我们知道 Javascript 是单线程的,Event Loop 使 Node.js 可以通过将操作转移到系统内核中来执行非阻塞 I/O 操作。

Node.js 中的事件循环执行过程为:

(1) 当 Node.js 启动时将初始化事件循环,处理提供的输入脚本。
(2) 提供的输入脚本可以进行异步 API 调用,然后开始处理事件循环。
(3) 在事件循环的每次运行之间,Node.js 会检查它是否正在等待任何异步 I/O 或计时器,如果没有,则将其干净地关闭。

与浏览器不一样,Node.js 中事件循环分成不同的阶段:

  • timers:此阶段由 setTimeout() 和安排的回调 setInterval() 执行
  • pending callbacks:执行推迟到下一个循环迭代的 I/O 回调
  • idle/prepare:仅在 Node.js 内部使用
  • poll:检索新的 I/O 事件,执行与 I/O 相关的回调,节点将在此处阻塞
  • check:setImmediate() 在这里调用回调
  • close callbacks:一些关闭回调,例如 socket.on('close', ...)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ |
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘

由于事件循环阶段划分不一致,Node.js 和浏览器在对宏任务和微任务的处理上也不一样。

宏任务和微任务

事件循环中的异步任务队列有两种:宏任务(MacroTask)和微任务(MicroTask)队列:

  • 宏任务:包括 script 全部代码、setTimeoutsetIntervalsetImmediate(Node.js)、requestAnimationFrame(浏览器)、I/O操作、UI 渲染(浏览器)
  • 微任务:包括 process.nextTick(Node.js)、PromiseMutationObserver

在浏览器中,异步任务队列的执行过程如下:

(1) 宏任务队列一次只从队列中取一个任务执行,执行完后就去执行微任务队列中的任务。
(2) 微任务队列中所有的任务都会被依次取出来执行,直到微任务队列为空。
(3) 在执行完所有的微任务之后,执行下一个宏任务之前,浏览器会执行UI渲染操作、更新界面。

我们能看到,在浏览器中每个宏任务执行完成后,会执行微任务队列中的任务。而在 Node.js 中,事件循环分为 6 个阶段,微任务会在事件循环的各个阶段之间执行。也就是说,每当一个阶段执行完毕,就会去执行微任务队列的任务。

1.2.4 认识浏览器

浏览器的主要功能是展示网页资源,也即请求服务器并将结果显示在浏览器窗口中。

1、浏览器主要功能

浏览器的主要功能,则是通过向服务器请求并在浏览器窗口中展示这些资源内容,这些内容通常包括 HTML 文档、PDF、图像等,我们也可以通过插件的方式加载更多其他的资源类型。

一般来说,我们在浏览器中会用到以下功能:

  • 用于输入 URI 的地址栏
  • 刷新和停止按钮,来控制当前文档的加载
  • 后退和前进按钮,控制文档历史的快速访问
  • 书签和收藏选项

HTML 和 CSS 规范中规定了浏览器解析和渲染 HTML 文档的方式,曾经各个浏览器都只遵循其中一部分,因此前端开发经常需要兼容各种浏览器。现在这些问题已经得到改善,同时配合 Babel 等一些兼容性处理编译过程,我们可以更加关注网站的功能实现和优化。

从结构上来说,浏览器主要包括了八个子系统:

浏览器工作原理
浏览器工作原理

  • 用户界面:包括前面提到的用户主要功能地址栏,状态栏和工具栏等
  • 浏览器引擎:一个可嵌入的组件,它提供了用于查询和操作渲染引擎的高级界面
  • 渲染引擎:负责显示请求的内容,比如用于对 HTML 文档进行解析和布局,可以选择使用 CSS 样式化
  • 网络子系统:用于 HTTP 请求之类的网络调用,在独立于平台的界面后面针对不同平台使用不同的实现
  • JavaScript 解释器:用于解析和执行 JavaScript 代码
  • XML 解析器:用于解析和运行 XML 代码
  • 显示后端:用于绘制基本小部件和字体,例如组合框和窗口
  • 数据持久性子系统:即数据存储,该子系统在磁盘上存储与浏览会话相关的各种数据,包括书签,Cookie 和缓存

这些子系统组合构成了我们的浏览器,而谈到页面的加载和渲染,则离不开网络子系统、渲染引擎、JavaScript 解释器和浏览器引擎等。下面我们以前端开发最常使用的 Chrome 浏览器为例,进行更详细的介绍。

Chrome 多进程架构

关于进程和线程的概念,这里不多介绍,这些也都是开发需要掌握的基础内容,大家可以自行进行学习。

Chrome 使用了多进程架构,具有以下进程:

(1) 浏览器进程:控制和处理用户可见的 UI 部分(包括地址栏,书签,后退和前进按钮)和用户不可见的隐藏部分(例如网络请求和文件访问)。
(2) 渲染器进程:控制显示网站的选项卡中的内容。
(3) 插件进程:控制网站使用的插件(例如 Flash)。
(4) GPU 进程:与其他进程隔离地处理 GPU 任务。

我们知道,Chrome 等浏览器支持多个选项卡,每个选项卡在单独的渲染器进程中运行,选项卡之外的所有内容都由浏览器进程处理。其中,浏览器进程具有以下线程:

  • UI 线程:用于绘制浏览器的按钮和输入字段
  • 网络线程:用于处理网络请求,以及从服务器接收数据(包括 DNS 解析、TCP 建连、HTTP 建立等等)
  • 存储线程:用于控制对文件的访问

同样,渲染器进程也具有一些线程:

  • GUI 渲染线程:负责对浏览器界面进行渲染
  • JavaScript 引擎线程:负责解析和执行 JavaScript 脚本
  • 浏览器定时器触发线程:setTimeoutsetInterval 所在的线程
  • 浏览器事件触发线程:该线程负责处理浏览器事件,并将事件触发后需要执行的代码放置到 JavaScript 引擎中执行

2、页面导航

当我们去面试的时候,常常会被问一个问题:在浏览器里面输入 url,按下回车键,会发生什么?这是个或许平时我们不会思考的问题,但在了解之后会对整个网页渲染有更好的认识。当我们按下回车键,在 DNS 解析、TCP 连接建立后,浏览器就会发起一个 HTTP 请求,我们也可以从控制台看到:

在浏览器 Network 面板查看 HTTP 请求
在浏览器 Network 面板查看 HTTP 请求

在这里,我们能看到所有浏览器发起的网络请求,包括页面、图片、CSS 文件、XHR 请求等,还能看到请求的状态(200 成功、404 找不到、缓存、重定向等等)、耗时、请求头和内容、返回头和内容,等等等等。这里第一个就是我们的页面请求,返回 <html> 页面,然后浏览器会加载页面,同时页面中涉及的资源也会触发请求下载,包括我们看到的 png 图片、js 文件,这里没有 css 样式,大概是样式被直出到 <html> 页面里了。

从浏览器控制台可以看到当前页面的一些网络请求,但无法完整地看到整个请求和渲染的流程。

回到前面的问题,当我们在浏览器输入网页地址,按下回车键后,浏览器的处理过程如下(以 Chrome 为例):

(1) 首先由浏览器进程的 UI 线程进行处理。如果是 URI,UI 线程会发起网络请求来获取网站内容;如果不是,则进入搜索引擎。
(2) 请求过程由网络线程来完成(此处涉及之前提到的 HTTP 请求过程)。如果响应是 HTML 文件,则是将数据传递到渲染器进程;如果是其他文件,则意味着这是下载请求,此时会将数据传递到下载管理器。
(3) 确认浏览器应导航到请求站点后,网络线程通知 UI 线程数据准备就绪。
(4) UI 线程寻找一个渲染器进程来进行网页渲染,数据和渲染器进程都准备好后,HTML 数据通过 IPC 从浏览器进程传递到渲染器进程中。
(5) 渲染器进程接收 HTML 数据后,将开始加载资源并渲染页面。
(6) 渲染器进程完成渲染后,通过 IPC 通知浏览器进程页面已加载。

以上是用户在地址栏输入网站地址,到页面开始渲染的整体过程。如果当前页面跳转到其他网站,浏览器将调用一个单独的渲染进程来处理新导航,同时保留当前渲染进程来处理像 unload 这类事件。

其中,HTTP 的请求过程如下:

(1) DNS 域名解析(此处涉及 DNS 的寻址过程),找到网页的存放服务器。
(2) 浏览器与服务器建立 TCP 连接。
(3) 浏览器发起 HTTP 请求。
(4) 服务器响应 HTTP 请求,返回相应的资源内容。

3、浏览器渲染机制

我们的浏览器会解析三个东西:

  • 渲染引擎解析 HTML/SVG/XHTML 文件,解析这三种文件会产生一个 DOM Tree(DOM 节点树)
  • 渲染引擎解析 CSS,会产生 CSS Rule Tree(CSS 规则树)
  • JavaScript 解释器解析 Javascript 脚本,Javascript 脚本可以通过 DOM API 和 CSSOM API 来操作 DOM Tree 和 CSS Rule Tree

解析完成后,浏览器引擎会通过 DOM Tree 和 CSS Rule Tree 来构造 Render Tree(渲染树)。在这个过程中,像 headerdisplay:none 的元素,它们会存在 DOM Tree 中,但不会被添加到 Render Tree里。大致流程如下图:

DOM 渲染过程
DOM 渲染过程

渲染的流程基本上如下:

  • 解析(Parser):构建渲染树
  • 布局(Layout):定位坐标和大小、是否换行、各种 position/overflow/z-index 属性等计算
  • 绘制(Paint):判断元素渲染层级顺序
  • 光栅化(Raster):将计算后的信息转换为屏幕上的像素

渲染的过程中会触发重绘(Repaint)和重排(Reflow):

  • 重绘:屏幕的一部分要重画,比如某个 CSS 的背景色变了,但是元素的几何尺寸没有变
  • 重排:元素的几何尺寸变了(渲染树的一部分或全部发生了变化),需要重新验证并计算渲染树

为了不对每个小的变化都进行完整的布局计算,渲染器会将更改的元素和它的子元素进行脏位标记,表示该元素需要重新布局。其中,全局样式更改会触发全局布局,部分样式或元素更改会触发增量布局,增量布局是异步完成的,全局布局则会同步触发。

重排需要涉及变更的所有的结点几何尺寸和位置,成本比重绘的成本高得多的多。所以我们要注意以避免频繁地进行增加、删除、修改 DOM 结点、移动 DOM 的位置、Resize 窗口、滚动等操作,因为可能会导致性能降低。

光栅化

通过解析、计算和布局过程,浏览器获得了文档的结构、每个元素的样式、绘制顺序等信息。将这些信息转换为屏幕上的像素,这个过程被称为光栅化。

光栅化可以被 GPU 加速,光栅化后的位图会被存储在 GPU 内存中。根据前面介绍的渲染流程,当页面布局变更了会触发重排和重绘,还需要重新进行光栅化。此时如果页面中有动画,则主线程中过多的计算任务很可能会影响动画的性能。

因此,现代的浏览器通常使用合成的方式,将页面的各个部分分成若干层,分别对其进行栅格化(将它们分割成了不同的瓦片),并通过合成器线程进行页面的合成。过程如下:

(1) 当主线程创建了合成层并确定了绘制顺序,便将这些信息提交给合成线程。
(2) 合成器线程将每个图层栅格化,然后将每个图块发送给光栅线程。
(3) 光栅线程栅格化每个瓦片,并将它们存储在 GPU 内存中。
(4) 合成器线程通过 IPC 提交给浏览器进程,这些合成器帧被发送到 GPU 进程处理,并显示在屏幕上。

浏览器页面渲染示例图
浏览器页面渲染示例图

合成的真正目的是,在移动合成层的时候不用重新光栅化。因为有了合成器线程,页面才可以独立于主线程进行流畅的滚动。

到此,我们的页面便渲染完成。

4、浏览器加载顺序

览器在加载页面的时候会用到 GUI 渲染线程和 Javascript 引擎线程。GUI 渲染线程负责渲染浏览器界面 HTML 元素,Javascript 引擎线程主要负责处理 Javascript 脚本程序,它们之间是互斥的关系,当 Javascript 引擎执行时 GUI 线程会被挂起。

因此,正常的网页加载流程是这样的:

(1) 浏览器一边下载 HTML 网页,一边开始解析。
(2) 解析过程中,发现 <script> 标签。
(3) 暂停解析,网页渲染的控制权转交给 JavaScript 引擎。
(4) 如果 <script> 标签引用了外部脚本,就下载该脚本,否则就直接执行。
(5) 执行完毕,控制权交还渲染引擎,恢复往下解析 HTML 网页。

以前我们喜欢把外部的 CSS 和 Javascript 文件都集中放在一起,通常会放在 <head> 里。浏览器需要在解析到 <body> 标签的时候才开始渲染页面,因此把 Javascript 放在 <head> 里,意味着必须把所有 Javascript 代码都下载、解析和解释完成后,才能开始渲染页面。

如果外部脚本加载时间很长(比如一直无法完成下载),就会造成网页长时间失去响应,浏览器就会呈现 “假死” 状态,用户体验会变得很糟糕。因此,我们常常将 Javascript 放在 <body> 的最后面,可以避免资源阻塞,页面得以迅速展示。当然,我们还可以使用 defer/async/preload 等属性来标记 <script> 标签,来控制 Javascript 的加载顺序。

除此之外,浏览器在渲染页面的过程需要解析 HTML、CSS 得到 DOM 树和 CSS 规则树,它们结合才生成最终的渲染树并渲染(该部分内容会在浏览器的加载和渲染流程部分详细介绍)。因此,我们还常常将 CSS 放在 <head> 里,可用来避免浏览器渲染的重复计算。

5、浏览器缓存

当一个客户端请求 WEB 服务器,请求的内容可以从以下几个地方获取:服务器、浏览器缓存中或缓存服务器中,这取决于服务器端输出的页面信息。页面文件的三种缓存状态包括:

  • 最新的:选择不缓存页面,每次请求时都从服务器获取最新的内容
  • 未过期的:在给定的时间内缓存,如果用户刷新或页面过期则去服务器请求,否则将读取本地的缓存,这样可以提高浏览速度
  • 过期的:也就是陈旧的页面,当请求这个页面时,必须进行重新获取

浏览器会在第一次请求完服务器后得到响应,我们可以在服务器中设置这些响应,从而达到在以后的请求中尽量减少甚至不从服务器获取资源的目的。浏览器是依靠请求和响应中的的头信息来控制缓存的,包括:

  • ExpiresCache-Control
    • ExpiresCache-Control 就是服务端用来约定和客户端的有效时间的
    • 规定如果 max-ageExpires 同时存在,前者优先级高于后者
    • 若符合,浏览器相应 HTTP 200 (from cache)
  • Last-Modified/If-Modified-Since
    • 当有效期过后,检查服务端文件是否更新的第一种方式,要配合 Cache-Control 使用
  • ETag/If-None-Match
    • ETag 值在服务端和服务端代表该文件唯一的字符串对比(如果服务端该文件改变了,该值就会变)
    • 如果相同,则响应 HTTP 304,从缓存读数据
    • 如果不相同,相应 HTTP 200,返回更新后的数据,同时通过响应头更新 Last-Modified 的值(以备下次对比)

6、浏览器同源政策

同源政策的目的,是为了保证用户信息的安全,防止恶意的网站窃取数据。所谓“同源”指的是“三个相同”:协议相同、域名相同、端口相同。随着互联网的发展,“同源政策”越来越严格。目前,如果非同源,共有三种行为受到限制:Cookie/LocalStorage/IndexDB 无法读取、DOM 无法获得、AJAX 请求不能发送。

常见的跨域解决方案包括:

  • document.domain + iframe (只有在主域相同的时候才能使用该方法)
  • 动态创建 script (JSONP)
    • JSONP 包含两部分:回调函数和数据
    • 回调函数是当响应到来时要放在当前页面被调用的函数
    • 数据就是传入回调函数中的 json 数据,也就是回调函数的参数
  • location.hash + iframe
    • 原理是利用 location.hash 来进行传值
  • window.name + iframe
    • name 值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值
  • postMessage(HTML5 中的 XMLHttpRequest Level 2 中的 API)
  • CORS
  • websockets

其中,CORS 作为现在的主流解决方案,需要重点了解。CORS 是一个 W3C 标准,全称是 “跨域资源共享”(Cross-origin resource sharing)。它允许浏览器向跨源服务器,发出 XMLHttpRequest 请求,从而克服了 AJAX 只能同源使用的限制。

实现 CORS 通信的关键是服务器,只要服务端实现了 CORS 接口,就可以进行跨源通信。对于简单请求,浏览器直接发出 CORS 请求。具体来说,就是在头信息之中,增加一个 Origin 字段。 如果 Origin 指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段 (Access-Control-** 等等)。其中,简单请求的要求是:

  • 请求方法是以下三种方法之一:HEAD/GET/POST
  • HTTP 的头信息不超出以下几种字段:Accept/Accept-Language/Content-Language/Last-Event-ID/Content-Type(只限于三个值 application/x-www-form-urlencodedmultipart/form-datatext/plain

不符合以上要求的请求,成为非简单请求。非简答请求会在正式通信之前,增加一次 HTTP 查询请求(请求方法是 OPTIONS),称为“预检”请求(preflight)。通过 “预检” 请求,浏览器会先询问服务器当前域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 方法和头信息字段。当服务端给予肯定答复,浏览器才会发出正式的 XMLHttpRequest 请求,否则就报错。

1.2.5 网络协议基础

网络协议相关的概念,包括TCP/IP、DNS、HTTP等等基础知识,对于前端开发来说也是需要掌握的。

TCP/IP 协议

TCP/IP 提供点对点的链接机制,将数据应该如何封装、定址、传输、路由以及在目的地如何接收,都加以标准化。它将软件通信过程抽象化为四个抽象层,采取协议堆栈的方式,分别实现出不同通信协议。协议套组下的各种协议,依其功能不同,被分别归属到这四个层次结构之中,常被视为是简化的七层 OSI 模型。

  • TCP/IP 协议分层
    • 数据链路层:用来处理连接网络的硬件、设备驱动、网卡、光纤等
    • 网络层:用来处理在网络上滚动的数据包(选路线)
    • 传输层:TCP/UDP
    • 应用层:FTP/DNS域名系统/HTTP协议等
  • IP协议
    • 把各种数据包准确无误地传递
    • ARP协议、RARP 协议等

除了 TCP/IP 的基本概念以外,同样需要掌握 TCP/IP 协议的四个抽象层、七层 OSI 模型。另外,关于 TCP 连接,还需要知道 TCP/UDP 的区别、以及 TCP 的三次握手和四次挥手:

  • TCP 协议提供可靠传输服务,UDP 协议则可以更快地进行通信
  • 三个运行阶段:连接创建、数据传送和连接终止
  • 三次握手:建立一个 TCP 连接需要客户端和服务端总共发送三个包以确认连接存在
  • 四次挥手:TCP 连接的断开需要客户端和服务端总共发送四个包以确认连接关闭

这些内容比较基础,但作为前端开发也同样需要掌握,有时候我们需要优化应用的加载耗时、请求耗时等,或是定位一些偏底层的问题(请求异常、HTTP 连接无法建立等),这些内容都有一定的帮助。

DNS 域名系统

DNS 的全称是 Domain Name System,又称域名系统,它负责把 www.qq.com 这样的域名地址翻译成一个 IP(比如 14.18.180.206),而客户端与服务器建立 TCP 连接需要通过 IP 通信。

让客户端和服务器连接并不是靠域名进行的,在网络中每个终端之间实现连接和通信是通过一个唯一的 IP 地址来完成。在建立 TCP 连接前,我们需要找到建立连接的服务器,DNS 的解析过程可以让用户通过域名找到存放文件的服务器。

DNS 解析过程会进行递归查询,分别依次尝试从以下途径获取该域名对应的 IP 地址:

(1) 浏览器缓存:浏览器首先会在自己的缓存中查找。
(2) 系统缓存:在用户计算机系统 Hosts 文件 DNS 缓存中查找。
(3) 路由器缓存:进入路由器缓存中查找。
(4) ISP(互联网服务提供商)DNS 缓存:如果你用的是电信的网络,会进入电信的 DNS 缓存服务器中查找。
(5) 根域名服务器。
(6) 顶级域名服务器。
(7) 主域名服务器。

DNS 解析过程会根据上述步骤进行递归查询,如果当前步骤没查到,则自动跳转到到下一步骤通过下一个 DNS 服务器进行查找。

除此之外,我们在前后端联调过程中也常常需要配置 HOST,这个过程便是修改了浏览器缓存或是系统缓存。通过将特定域名指向我们自身的服务器 IP 地址,便可以实现通过域名访问本地环境、测试环境、预发布环境的服务器资源。之所以需要通过域名访问而不是直接使用 IP 地址进行访问,很多时候是因为避开浏览器的同源策略导致的跨域问题。

HTTP 请求与 TCP 协议

目前大多数 HTTP 请求都基于 TCP 协议。由客户端发起的 HTTP 请求,和服务器建立起 TCP 连接之后,HTTP 服务器会监听客户端发起的请求。收到请求后,服务器会进行回复,回复内容通常包括 HTTP 状态、响应消息等,更具体的会在下一节 HTTP 协议中进行介绍。

TCP 协议的目的是提供可靠的数据传输,它使用序号标识每端发出的字节的顺序,从而另一端接收数据时可以重建顺序,无惧传输时的包的乱序交付或丢包,可通过发送方检测到丢失的传输数据并重传这些数据。

因此,目前大多数 HTTP 连接基于 TCP 协议。不过,HTTP/3 中底层支撑是 QUIC 协议,该协议基于 UDP,有 UDP 特有的优势,同时它结合了 TCP 中的精华,实现了即快又可靠的协议。

1.2.6 Ajax

Ajax 不是 JavaScript 的规范,它是 Jesse James Garrett 提出的新术语:Asynchronous JavaScript and XML,意思是用 JavaScript 执行异步网络请求。

网络请求的发展

网络请求,是用来从服务端获取需要的信息,然后解析协议和内容,来进行页面渲染或者是信息获取的过程。前面已经大致说过关于浏览器渲染,以及完整的 HTTP 请求流程。

在很久以前,我们的网络请求除了静态资源(HTML/CSS/Javascript 等)文件的获取,主要用于表单的提交。我们在完成表单内容的填写之后,点击提交按钮,表单开始提交,浏览器就会刷新页面,然后在新页面里告诉你操作是成功了还是失败了。

除此之外,同步请求会阻塞进程,导致页面呈现假死状态,使得用户体验变得糟糕。为了避免这种情况,我们开始使用异步请求 + 回调的方式,来进行请求处理。

随着时间发展,Ajax 的应用越来越广,如今使用 Ajax 已经是前端开发的基本操作。但 Ajax 是一种解决方案,在前端中的具体实现依赖使用 XMLHttpRequest 相关 API。页面开始支持局部更新、动态加载,甚至还有懒加载、首屏加载等等,都是以 XMLHttpRequest 为前提。

XMLHttpRequest

XMLHttpRequest 让发送一个 HTTP 请求变得非常容易。你只需要简单的创建一个请求对象实例,打开一个 URL,然后发送这个请求。当传输完毕后,结果的 HTTP 状态以及返回的响应内容也可以从请求对象中获取。

来看个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var request = new XMLHttpRequest(); // 新建XMLHttpRequest对象

request.onreadystatechange = function() {
// 状态发生变化时,函数被回调
if (request.readyState == 4) {
// 成功完成
// 判断响应结果:
if (request.status == 200) {
// 成功,通过responseText拿到响应的文本
console.log(request.responseText);
} else {
// 失败,根据响应码判断失败原因:
console.log(request.status);
}
}
};

// 发送请求
// open的参数:
// 一:请求方法,包括get/post等
// 二:请求地址
// 三:表示是否异步请求,若为false则是同步请求,会阻塞进程
request.open("GET", "/api/categories", true);
request.send();

上面是处理一个 HTTP 请求的方法。我们通常会将它封装成一个通用的方法,方便调用。上面例子中使用200来判断是否成功,但有些时候 200-400(不包括400)的范围,都可以算是成功的。

我们将其封装起来,同时使用 ES6 的 Promise 的方式来操作的话,会变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function ajax({ method, url, params, contentType }) {
const xhr = new XMLHttpRequest();
const formData = new FormData();
Object.keys(params).forEach(key => {
formData.append(key, params[key]);
});
return new Promise((resolve, reject) => {
try {
xhr.open(method, url, false);
xhr.setRequestHeader("Content-Type", contentType);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 400) {
// 这里我们使用200-400来判断
resolve(xhr.responseText);
} else {
// 返回请求信息
reject(xhr);
}
}
};
xhr.send(formData);
} catch (err) {
reject(err);
}
});
}

这里使用了 FormData 来处理。通过 FormData 对象可以组装一组用 XMLHttpRequest 发送请求的键/值对。它可以更灵活方便的发送表单数据,因为可以独立于表单使用。如果你把表单的编码类型设置为 multipart/form-data,则通过 FormData 传输的数据格式和表单通过 submit() 方法传输的数据格式相同,也支持文件的上传和添加。

上面的代码也只是一个简单的例子,如果要封装成完善的库,我们通常还需要处理一些问题:

  • 浏览器兼容性
  • babel polyfill 处理 ES6
  • Get 方法通过将 params 转换拼接 URL 处理

除了 FormData 以外,我们同样可以通过其他方式来提交请求,可能涉及序列化等操作,具体方式会根据每个团队的后台情况不一样而不同。

1.2.7 HTTP 协议

HTTP 协议又称为超文本传输协议,它是一种通信协议,允许将超文本标记语言 (HTML) 文档从 Web 服务器传送到客户端的浏览器。

HTTP 协议特点

HTTP 协议的主要特点可概括如下:

  • 遵循经典的 “客户端 - 服务端” 模型
    • 客户端打开一个连接并发出请求,然后等待直到收到服务器端响应或超时
  • 简单
    • HTTP 被设计得简单、易读、可测,对新人友好
    • HTTP 报文(在 HTTP/2 之前)是语义可读的
  • 可拓展
    • HTTP 允许传输任意类型的数据对象
  • HTTP 连接
    • HTTP/1.0:默认为每一对 HTTP 请求 / 响应都打开一个单独的 TCP 连接
    • HTTP/1.1:引入了持久连接的概念,通过设置 Connection 头部为 keep-alive,网络连接可持久、不会关闭,使得对同一个服务器的请求可以继续在该连接上完成,当出现对服务器的后续请求时,该功能避免了建立或者重新建立连接
    • HTTP/2:通过在一个连接中复用消息的方式来让这个连接始终保持
  • 无状态
    • 在同一个连接中,两个执行成功的请求之间是没有关系的
    • HTTP 本质是无状态的,使用 Cookies 可以创建有状态的会话

URL 详解

URL (Uniform Resource Locator) 地址用于描述一个网络上的资源,基本格式如下:

1
schema://host[:port#]/path/.../[?query-string][#anchor]

其中包括:

  • scheme: 指定低层使用的协议 (例如:http/https/ftp)
  • host: HTTP 服务器的 IP 地址或者域名
  • port#: HTTP 服务器的默认端口是 80,这种情况下端口号可以省略。如果使用了别的端口,必须指明,例如 http://www.cnblogs.com:8080/
  • path: 访问资源的路径
  • query-string: 发送给 HTTP 服务器的数据
  • anchor: 锚

HTTP 报文

HTTP 报文有两种类型:Request 请求与 Response 响应,每种都有其特定的格式。

(1) Request请求

1
2
3
4
5
6
7
8
9
10
11
------------------
Request line
(包括:请求方法、请求的资源、HTTP协议的版本号)
------------------
Request header
(包括:Cache头域、Client头域、Cookie/Login头域、Entity头域、Miscellaneous头域、Transport头域等)
------------------
空行
------------------
Request body
------------------

(2) Response响应

1
2
3
4
5
6
7
8
9
10
11
------------------
Response line
(包括:HTTP协议的版本号、状态码、消息)
------------------
Response header
(包括:Cache头域、Cookie/Login头域、Entity头域、Miscellaneous头域、Transport头域、Location头域等)
------------------
空行
------------------
Response body
-----

对于无论是 Request 还是 Response 的 header,每个字段都需要去理解的,大家平时可多留意一下浏览器的请求。

HTTP 状态码

HTTP/1.1 中定义了 5 类状态码, 状态码由三位数字组成,第一个数字定义了响应的类别。

状态码 类别 表达内容
1XX 提示信息 表示请求已被成功接收,继续处理
2XX 成功 表示请求已被成功接收,理解,接受
3XX 重定向 要完成请求必须进行更进一步的处理
4XX 客户端错误 请求有语法错误或请求无法实现
5XX 服务器端错误 服务器未能实现合法的请求

一些常见的状态码也是需要掌握的:

  • 200: OK
  • 302: Found 重定向
  • 304: Not Modified 缓存
  • 400: Bad Request 客户端请求与语法错误
  • 403: Forbidden 服务器拒绝提供服务
  • 404: Not Found 请求资源不存在
  • 500: Internal Server Error 服务器发生了不可预期的错误
  • 503: Server Unavailable 服务器当前不能处理客户端的请求,一段时间后可能恢复正常

HTTP 请求方法

HTTP 常见的请求方法包括:

  • GET:通常用于获取资源
  • POST:通常用于提交资源,可能会导致状态更改或对服务器产生副作用
  • PUT:通常用于修改资源
  • DELETE:通常用于删除指定的资源
  • OPTION: 描述目标资源的通信选项,比如用于切换到 Websocket 通道

除了以上列出的,HTTP 方法还包括 HEAD/CONNECT/TRACE/PATCH,用来定义客户端的动作行为。一般来说,我们还需要理解 GET 和 POST 的区别,主要包括是否有 body、长度限制、是否可缓存等等。

HTTP Headers

HTTP 消息头允许客户端和服务器通过 request 和 response 传递附加信息,根据上下文可主要分为:

  • General headers:
    • 可以应用于请求和响应中,但是不能应用于消息内容自身
    • 常见包括:Date、Cache-Control、Connection
  • Request headers
    • 可在 HTTP 请求中使用,并且和请求主体无关
    • Accept、If-Modified-Since 允许执行条件请求
    • Cookie、User-Agent、Referer 描述了请求本身,以确保服务端能正确响应
  • Response headers:
    • 包含有关响应的补充信息
    • Age、Location、Server 被用于描述其位置或服务器本身(名称和版本等)
  • Entity headers:
    • 包含有关实体主体的更多信息
    • 常见包括:Content-Length,Content-Language,Content-Encoding

关于 HTTP 消息头相关内容,需要我们在开发和调试过程中根据实际场景才能了解得更加深入。其中,HTTP Cookie 和浏览器缓存也算是一个较常见的场景。

常见的 HTTP 协议场景

下面我们来介绍一下常见的一些 HTTP 协议场景。

HTTP Cookie

前面提到,HTTP 协议是无状态的,这带来了一个问题:用户没有办法在同一个网站中进行连续的交互,比如用户在某个电商网站上将两个商品分别加入了购物车,服务器无法知道这两个操作都来自同一个用户。

HTTP Cookie 可以解决这个问题,它可告知服务端两个请求是否来自同一浏览器,因此可保持用户的登录状态。

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

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

根据生命周期区分,Cookie 可包括两种:

(1) 最简单的 Cookie:浏览器关闭之后它会被自动删除。
(2) 持久性的 Cookie:通过指定过期时间(Expires)或者有效期(Max-Age),指定 Cookie 的一段有效时间。

Session

我们在实际开发中,谈到用户登录态,除了 Cookie 还常常会提到 Session。

Session 是从服务器角度出发,当客户端访问服务器时,将用户会话信息(Session)保存在服务器上,同时将标示 Session 的 SessionId 返回给客户端浏览器,浏览器将这个 SessionId 保存在内存中(比如 Cookie),下次请求可直接带给服务器。服务器会根据这个 SessionId,就能取得客户端的数据信息。

Token

既然谈到 Cookie 和 Session,Token 也可以一并了解下。

以前 Token 更多是在用户授权中使用,例如移动 App 通常采用 Token 进行验证。Token 和 Session 有第一定的类似,但是服务器不保存用户会话信息,而是生成一个 Token 保存在客户端,这个 Token 是加密并确保完整性和不变性的,修改后无效,可保存在客户端,下次请求带给服务器。服务器进行解密后,就能取得客户端的数据信息。

除此之外,Token 还支持跨域访问、无状态等,也能解决 Cookie 劫持 (CSRF) 的安全问题。

1.3 前端基础开发技巧

1.3.1 编写页面

打开浏览器,里面全都是网页或是网站。包括大家常用的百度、论坛、视频网站等等,当然现在使用 App 的占比上升了不少。不过对于常常需要搜索、或是某些应用的老用户来说,当然是大屏幕的视野宽、键盘的输入快,体验会更好。

页面查看

静态页面主要由 HTML、CSS 和 Javascript 这几种类型代码组成。一般来说,我们可以直接通过右键菜单来查看源码。

右键菜单

在定位某个元素的时候,我们也可以在该元素上右键选择“检查”,菜单如下:

浏览器右键“检查”菜单
浏览器右键“检查”菜单

其中:

  • 选择“查看网页源代码”:能看到当前页面的整个HTML文档,里面包括一些当前页面的代码
  • 选择“另存为”:可以将该页面以及相关的一些静态资源打包下载
  • 选择“检查”:可以检查当前选中的元素,同时打开控制台

接下里我们重点介绍控制台,因为这是前端开发必不可少的工具。

控制台

以Chrome浏览器为例,我们来介绍控制台的使用方式。

(1) 打开控制台

一般来说,我们可以有三个方法打开控制台:

  • 按键 F12
  • 右键菜单,选择“检查”
  • 快捷键 Shift + Ctrl + I

(2) 控制台菜单

我们来看控制台:

浏览器控制台
浏览器控制台

这是控制台的菜单,这边简单做介绍(从左往右):

  • 检查元素(箭头带个框的图标):与右键选择元素检查相似
  • 屏幕切换(手机镶在框上的图标):该功能主要用于 PC 端和移动端屏幕切换,适合 H5 的开发,并且可匹配多种机型
  • Element:查看元素,能看到页面中所有存在的元素
  • Console:输出,常用来输出一些信息,或是错误信息
  • Source:查看源文件,可支持打断点调试
    • 开启了 source map 后,更是可以浏览器直接更改本地文件
    • Sources 下的 Sources 查看浏览器页面中的源文件(HTML/Javascript/CSS/图片等),点击下面的 {} 大括号可以将代码转成可读格式,同时可给 Javascript 文件添加上断点
    • Sources 下的 Snippets 可以添加文件片段,可在浏览器中运行
  • Network:查看网络请求信息,包括静态资源的下载、Ajax 请求等

这里面主要介绍这几个,其他的在需要用到的时候大家再自行搜索。

(3) Element 查看元素

我们看看元素的查看,这里很方便的是,当我们选中 Element 里面某个元素,Chrome 便会突显出来,并且伴有常用的元素信息,宽高、padding、margin 等等。

浏览器控制台检查元素
浏览器控制台检查元素

同时,我们也可以看到下方的 Style,里面会有当前元素匹配中的一些 CSS 样式,我们也可以尝试在这里调整,调整成想要的样式之后,再更新到开发代码中。还可以通过 source map 匹配源文件的方式,直接在控制台修改源文件。

(4) Console 输出

我们常用几种方式来调试,包括打断点、debugger、alert () 和 console 等。console 打印的一些信息会出现在 Console 面板中,通过一些代码执行的日志和报错信息,我们可以定位到大概的问题,这种方式在生产环境的使用频率还是挺高的。

(5) Network

通过 Network 面板,我们可以看到页面发起的请求:

浏览器控制台 Network 面板
浏览器控制台 Network 面板

我们能在这里查看请求的内容、状态、服务端返回的内容等等。

页面组成

上面也提到过,我们的页面主要由 HTML、CSS 和 Javascript 组成,如果只用于静态数据的展示,或许只需要前两者就好了,一些公司的主页/官网也常常是简单的页面。

(1) HTML

HTML,指超文本标签语言。它被称为通向 WEB 技术世界的钥匙,因为我们的 CSS 和 Javascript,其实也属于 HTML 中的 <style><script> 标签而已。

更多时候,HTML 常常被用来表示 DOM 元素,例如 <div><p> 等。最简单的莫过于:

1
2
3
4
5
6
7
<html>
<head></head>
<body>
<h1>我的第一个标题</h1>
<p>我的第一个段落。</p>
</body>
</html>

我们的 里面包括两个子模快:

  • <head>:常包括一些样式、<meta> 标签、甚至是 <script>,不展示到页面
  • <body>:包括展示在页面的内容

(2) CSS

CSS 主要是给我们的 HTML 元素添加样式,可以通过几个方式匹配元素的样式:

  • DOM:像常用的 pul/li 等便是 DOM 匹配
  • class:类的匹配
  • id:id 标识符的匹配

class 针对一类元素的匹配,id 则是唯一标识符,若页面内有多个相同的 id,则只有第一个生效。给元素添加样式像是绘画的过程,绘制边框、大小、颜色等,都是通过样式来设置的。

(3) Javascript

JavaScript 是可插入 HTML 页面的编程代码。插入 HTML 页面后,可由所有的现代浏览器执行。我们常常使用 Javascript 来做以下事情:

  • 输出 HTML
  • 处理事件(点击、输入等)
  • 改变 HTML 内容和样式
  • 处理 HTTP 请求

1.3.2 代码调试

要验证我们编写的代码是否符合预期,我们需要在浏览器页面中进行调试。

浏览器(Chrome)调试

浏览器中调试功能又多又方便,但我们更主要是在 Chrome 下进行调试。Elements、Console、Sources、Network 这些前面也介绍过了,剩下的功能在性能调试的时候会用的多一些:

  • Performance:查看页面在浏览器运行时的性能表现,如 CPU\GPU 执行时间与内存占用等
  • Memory:可进行内存查看和分析
  • Application:会列出所有的资源,包括 Database 和 LocalStore 等,可以对存储的内容编辑和删除
  • Security:查看网站的安全性,有效证书等
  • Audits/Lighthouse:会针对目前网页提出若干条优化的建议,包括网络加载性能、界面性能等

打断点

我们来看看在浏览器中打断点的一些方式。

(1) 代码断点

常用的浏览器代码断点,在 Sources 面板 Javascript 文件行号处设置断点,如图我们下了个断点:

控制台断点调试
控制台断点调试

这里我们在请求发起的位置打了个断点,每次我们在搜索输入框输入的时候,都会发送请求,触发 debug 模式。

这里除了常规断点外,还有个条件断点 (右键 -> conditional breakpoint), 在设置的条件为 true 时才会断电,在循环中需要断点时比较有用。断点后可以查看堆栈、变量。

(2) 事件断点

元素上事件断点:某一事件/某类事件,从 Elements > Event Listeners 中进行。

(3) DOM 断点

一般是 DOM 结构改变时触发。有时候我们需要监听某个 DOM 被修改情况,而不关心是哪行代码做的修改(也可能有多处都会对其做修改),可以直接在 DOM 上设置断点。

在元素审查的 Elements 标签页中在某个元素上右键菜单里可以设置三种不同情况的断点:

  • 子节点修改
  • 自身属性修改
  • 自身节点被删除

(4) XHR 断点

右侧调试区有一个 XHR Breakpoints,点击 + 并输入 URL 包含的字符串,即可监听该 URL 的 Ajax 请求,输入内容就相当于 URL 的过滤器。

一旦 XHR 调用触发时就会在 request.send() 的地方中断。

(5) 全局事件断点

对事件发生时断点,不会限定在元素上,只要是事件发生,并且有 handler 就断点。

还可以对 resizeAjaxsetTimeout/setInterval 断点。

关于断点调试,除了几种工具和方式的了解以外,更多的需要在实际工作中进行实践才能掌握。用得好的话,谷歌源码也照样能趴出来里面的实现。

调试样式

样式的调试需要有个前提,就是对一些样式属性有很好的认识和理解,尤其涉及盒子模型、display 和定位等。样式的规则除了一些基本的逻辑能遵循(可参考前面1.2.1章节内容),更多的则是丰富的经验,多写、多练。

在此基础上,一般样式的调试逻辑大概会是这样:

  • 样式是否生效
  • 文件、相关样式代码是否加载
  • 是否被其他样式覆盖(优先级问题)

控制台查看元素样式
控制台查看元素样式

如上图,一般我们会在选中对应的元素后,从上往下来找到生效(或不生效)的样式,同时也可以定位到对应的源文件。

这里面如果是本地环境调试的话,在 source map 的支持下,我们甚至可以直接在浏览器中修改源文件,保存生效。

调试 Javascript 脚本

我们可以通过添加 alert()consoledebugger 相关代码的方式,进行调试日志输出和断点。

(1) alert

alert() 会在窗口中显示一个警告对话框,是特别古老的调试方式,以前有些浏览器不支持控制台调试的时候可以这么操作。

由于 alert() 会阻塞进程,并且输出内容只支持局限的参数类型,因此现在几乎没什么人会使用了。

(2) console

可以使用 console 来打日志。在生产环境的代码通常是编译后压缩后的代码,浏览器断点调试很不方便,或者是用户的反馈无法进行现场定位,此时通过日志获取需要的信息显得尤为重要。

console 常用的几个方法有:

  • console.log():打印字符串,以及对象、变量什么的都可以
  • console.info():打印以感叹号字符开始的信息,使用方法和 log 相同
  • console.error():打印一条错误信息

养成在代码中通过 console 打日志的习惯,在遇到问题的时候能更加高效地定位和解决。

(3) debugger

debugger 被调用时,执行暂停在 debugger 语句的位置,就像在脚本源代码中的断点一样。

断点的好处在于,我们可以直接看到代码的执行调用关系,也可以在断点位置查看上下文的变量情况,可以直观地看到问题在哪。

一般 Javascript 的调试逻辑,未按预期执行会有这样的原因:代码未执行到理想的位置。

这时候我们要思考这样的问题:

(1) 为事件触发执行 -> 检测事件是否被触发
(2) 在函数中执行 -> 检测函数是否被调用
(3) 在判断语句中执行 -> 检测判断是否正确

我们可以在这些地方进行输出,或者打下断点:

(1) 事件触发的地方
(2) 函数调用过程
(3) 判断语句(if 等)

以上是类似的推导逻辑,我们在写代码时,会有一个预期的执行顺序和期望,如果说不生效,则我们可以:

  • 从前往后执行步骤,看在哪一步分了岔路
  • 从后往前打下断点,看在哪一步走丢了

其实最重要的是思路需要清晰,如果说你连自己要做的功能,逻辑还没理清楚的话,编写的代码质量不会高,同时调试性能也会随着下降。

1.3.3 请求联调

一般来说,我们的日常联调通常有两种:浏览器查看请求,或是工具抓包查看(Fiddler)。

浏览器查看请求

我们来看浏览器的控制台:

控制台查看请求和响应头部
控制台查看请求和响应头部

(1) Network面板

Network 面板可以记录页面上的网络请求的详情信息,从发起网页页面请求 Request 后分析 HTTP 请求后得到的各个请求资源信息(包括状态、资源类型、大小、所用时间、Request 和 Response等),可以根据这个进行网络性能优化。

该面板主要包括 5 大块窗格,如图:

控制台 Network 面板组成
控制台 Network 面板组成

  • Controls:控制 Network 的外观和功能
  • Filters:控制 Requests Table 具体显示哪些内容
  • Overview:显示获取到资源的时间轴信息
  • Requests Table:按资源获取的前后顺序显示所有获取到的资源信息,点击资源名可以查看该资源的详细信息
  • Summary:显示总的请求数、数据传输量、加载时间信息

(2) 查看具体资源的详情

通过点击某个资源的Name可以查看该资源的详细信息,根据选择的资源类型显示的信息也不太一样,可能包括如下 Tab 信息:

  • Headers:该资源的 HTTP 头信息
  • Preview:根据你所选择的资源类型(JSON、图片、文本)显示相应的预览
  • Response:显示 HTTP 的 Response 信息
  • Cookies:显示资源 HTTP 的 Request 和 Response 过程中的 Cookies 信息
  • Timing:显示资源在整个请求生命周期过程中各部分花费的时间

一般来说,联调主要关注请求是否正确发送、回包是否是约定的格式,所以我们更多使用资源详情的查看,包括:

  • 查看 HTTP 头信息是否正确
  • 查看请求数据是否带上
  • 查看请求是否成功,分析 HTTP 状态码
  • 查看回包格式和内容是否正确

Fiddler

Fiddler 是一个 HTTP 的调试代理,以代理服务器的方式,监听系统的 HTTP 网络数据流动。Fiddler 可以也可以让你检查所有的 HTTP 通讯,设置断点,以及 Fiddle 所有的 “进出” 的数据(可用于抓包、修改请求等)。

通常来说,我们会使用它来解决一些问题:

(1) 查看请求详情(类似上方的浏览器 Network 面板)
(2) 请求失败时,抓包给后台查看问题
(3) 模拟请求
(4) 拦截请求,并更改请求内容
(5) 移动端的请求查看和抓包

具体的使用方式,大家也可以去网上搜一些相关的教程来学习。

全文完。


更多精彩文章