# 概念详解
# 1 BFC, IFC, GFC 和 FFC
BFC(Block formatting contexts): 块级格式化上下文
页面上的一个隔离的渲染区域,那么它是如何产生的呢? 可以触发 BFC 的元素有 float
,position
,overflow
,display:table-cell/inline-block/table-caption
;BFC 作用:比如说实现多栏布局
IFC(Inline formatting contexts):内联格式化上下文
IFC 的 line box (线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的 padding
/ margin
影响) IFC 中的 line box 一般左右都贴紧整个 IFC, 但是会因为 float
元素而扰乱。 float
元素会位于 IFC 与 line box 之间, 使得 line box 宽度缩短。 同一个 IFC 下的多个 line box 高度会不同 IFC 中时不可能有块级元素的,当插入块级元素时,(如 p
中插入 div
) 会产生两个匿名块与 div
分隔开,即产生两个 IFC, 每个 IFC 对外表现为块级元素, 与 div
垂直排列。 那么 IFC 一般有什么用呢?
水平居中: 当一个块要在环境中水平居中时,设置其为 inline-block
则会在外层产生 IFC, 通过 text-align
则可以使其水平居中
垂直居中:创建一个 IFC,用其中一个元素撑开父元素的高度,然后设置其 vertical-align: middle
,其他行内元素则可以在此父元素下垂直居中
GFC(GrideLayout formatting contexts):网格布局格式化上下文
当为一个元素设置 dispaly
值为 grid
的时候, 此元素将会获得一个独立的渲染区域,我们可以通过在网格容器(grid container)上定义网格定义行(grid definition rows) 和 网格定义列(grid definition columns)为每一个网格项目(grid item)定义位置和空间。那么 GFC 有什么用呢,和 table
又有什么区别呢?
首先同样是一个二维的表哥,但 GridLayout 会有更加丰富的属性来控制行列,控制对齐以及更为精细的渲染语义和控制。
FFC(Flex formattine contexts):自适应格式化上下文
, display
值为 flex
或者 inline-flex
的元素将会生成自适应容器(flex container),可惜这个牛逼的属性只有谷歌和火狐支持。Flex Box 由伸缩容器和伸缩项目组成。通过设置元素的 display
属性为 flex
或 inline-flex
可以得到一个伸缩容器。设置为 flex
的容器被渲染成一个块级元素,而设置为 inline-flex
的容器则渲染为一个行内元素。伸缩容器中年的每一个字元素都是一个伸缩项目。伸缩项目可以是任意数量的。伸缩容器外和伸缩项目内的一切元素都不受影响。简单地说,Flexbox
定义了伸缩容器内伸缩项目该如何布局。
# 2 作用域,作用域链和闭包
作用域:
指的是一个变量和函数的作用范围,JS中函数内部声明的所有变量在函数体内始终是可见的,在ES6前有全局作用域和局部作用域,但是没有块级作用域(catch只在其内部生效)es6块级作用域 (opens new window),局部变量的优先级高于全局变量。
作用域链:
每个函数都有自己的执行上下文环境,当代码在这个环境中执行时,会创建变量对象的作用域链,作用域链是一个对象列表或对象链,它保证了变量对象的有序访问。
作用域链的开始是当前代码执行环境的变量对象
,常被称之为“活跃对象(AO)”,变量的查找会从第一个链的对象开始,如果对象中包含变量属性,那么就停止查找,如果没有就会继续向上级作用域链查找,直到找到全局对象中
闭包:
红宝书上对于闭包的定义:闭包是指有权访问另外一个函数作用域中变量的函数。
其中 自由变量,指在函数中使用的,但既不是函数参数 arguments
也不是函数的局部变量,其实就是另外一个函数作用域中的变量。
function getOuter () {
var date = '1120';
function getDate(str) {
console.log(str + date); // 访问外部的date
}
return getDate('今天是:'); // 今天是:1120
}
其中 date
既不是参数 arguments
, 也不是局部变量,所以 date
是自由变量。
总结起来就是下面两点:
- 1、是一个函数(比如,内部函数从父函数中返回)
- 2、能访问上级函数作用域中的变量(哪怕上级函数上下文已经销毁)
经典闭包题解
由于作用域链机制的影响,闭包只能取得内部函数的最后一个值,这引起的一个副作用就是如果内部函数在一个循环中,那么变量的值始终为最后一个值。
var data = [];
for (var i = 0; i < 3; i++ ) {
data[i] = function () {
console.log(i);
}
}
data[0](); // 3
data[1](); // 3
data[2](); // 3
如果要强制返回预期的结果1,2,3怎么办?
方法1:立即执行函数
for (var i = 0; i < 3; i++) {
(function(num) {
setTimeout(function() {
console.log(num)
}, 100)
})(i);
}
// 0
// 1
// 2
方法2:返回一个匿名函数赋值
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = (function (num) {
return function() {
console.log(num)
}
})(i);
}
data[0](); // 0
data[1](); // 1
data[2](); // 2
无论是立即执行函数还是返回一个匿名函数赋值,原理上都是因为变量的按值传递,所以会将变量 i 的值赋值给实参 num, 在匿名函数的内部又创建了一个用于访问 num 的匿名函数,这样每个函数都有了一个 num 的副本,互不影响了。
方法3:使用ES6中的let
var data = [];
for (let i = 0; i < 3; i++) {
data[i] = function () {
console.log(i)
}
}
data[0](); // 0
data[1](); // 1
data[2](); // 2
# 3 原型 和 原型链
原型
每个对象拥有一个原型对象,对象以其原型为模板,从原型继承方法和属性,这些属性和方法定义在对象的构造器函数的 prototype
属性上,而非对象实例本身。
原型链
每个对象拥有一个原型对象,通过 __proto__
指针指向上一个原型,并从中继承方法和属性,同时原型对象也可能拥有原型,这样一层一层,最终指向 null
。
这种关系被称为原型链(prototype chain), 通过原型链一个对象会拥有定义在其他对象中的属性和方法。
总结:
Symbol
作为构造函数来说并不完整,因为不支持语法new Symbol()
,但其原型上拥有constructor
属性,即Symbol.prototype.constructor
。- 引用类型
constructor
属性值是可以修改的,但是对于基本类型来说是只读的,当然null
和undefined
没有constructor
属性。 __proto__
是每个实例上都有的属性,prototype
是构造函数的属性,在实例上并不存在,所以这两个并不一样,但p.__proto__
和Parent.prototype
指向同一个对象。__ptoto__
属性在ES6
时被标准化,但因为性能问题并不推荐使用, 推荐使用Object.getPrototypeOf()
- 每个对象拥有一个原型对象,通过
__proto__
指针指向上一个原型,并从中继承方法和属性,同时原型对象也可能拥有原型,这样一层一层,最终指向null
, 这就是原型链。
# 4 深浅拷贝
浅拷贝
创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。 如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。
Object.assign()
Object.assign()
方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。let a = { name: 'fangyuan', info: { address: '广东广州', age: 25 } } let b = Object.assign({}, a) console.log(b); // { // name: 'fangyuan', // info: { // address: '广东广州', // age: 25 // } // } a.name = 'fangyuan1'; a.info.age = 26; console.log(a); // { // name: 'fangyuan1', // info: { // address: '广东广州', // age: 26 // } // } console.log(b); // { // name: 'fangyuan', // info: { // address: '广东广州', // age: 26 // } // }
上面代码改变对象a之后,对象b的基本属性保持不变。但是当改变对象a中的对象
info
时, 对象b
相应的位置也发生了变化。Array.prototype.slice()
slice()
方法返回一个新的数组对象,这一对象是一个由begin
和end
(不包括end
) 决定的原数组的浅拷贝。原始数组不会被改变。let a = [0, '1', [2, 3]]; let b = a.slice(1); console.log(b); // ['1', [2, 3]] a[1] = '99'; a[2][0] = 4; console.log(a); // [0, '99', [4, 3]] console.log(b); // ['1', [4, 3]]
可以看出,改变
a[1]
之后b[0]
的值并没有发生变化,但改变a[2][0]
之后,相应的b[1][0]
的值也发生变化。说明slice()
方法是浅拷贝,相应的还有concat
等, 在工作中面对复杂数组结构要额外注意。
深拷贝
深拷贝会拷贝所有的属性, 并拷贝属性指向的动态分配的内存。当对象和它所引用的对象一起拷贝时即发生深拷贝。深拷贝相比于浅拷贝速度较慢并且花销较大。拷贝前后两个对象互不影响。
- JSON.parse(JSON.stringify(object))
let a = {
name: 'fangyuan',
info: {
address: '广东广州',
age: 25
}
}
let b = JSON.parse(JSON.stringify(a));
console.log(b);
// {
// name: 'fangyuan',
// info: {
// address: '广东广州',
// age: 25
// }
// }
a.name = 'fangyuan1';
a.info.age = 26;
console.log(a);
// {
// name: 'fangyuan',
// info: {
// address: '广东广州',
// age: 26
// }
// }
console.log(b);
// {
// name: 'fangyuan',
// info: {
// address: '广东广州',
// age: 25
// }
// }
但是该方法有以下几个问题:
- 会忽略 undefined
- 会忽略 symbol
- 不能序列化函数
- 不能解决循环引用的对象
- 不能正确处理 new Date()
- 不能处理正则
# 5 优雅降级 和 渐进增强
优雅降级(也称平稳退化):
web站点在所有新式浏览器中都能正常工作,如果用户使用的是老式浏览器,则代码会检查以确认它们是否能正常工作。由于 IE
独特的盒模型布局问题,针对不同版本的 IE
的 hack
实践过优雅降级,为那些无法支持功能的浏览器增加候选方案,使之在旧式浏览器上以某种形式降级体验却不至于完全失效。
在我们的日常开发中,一个典型的平稳退化的例子就是:首先针对 Firefox 或者 Chrome 等支持 W3C 标准的浏览器编写页面代码,然后修复 IE 中的异常或针对 IE 去除那些无法被实现的功能特色。
渐进增强:
指从最基本的可用性出发,在保证站点页面在低级浏览器中的可用性和可访问性的基础上,逐步增强功能及提高用户体验。
在我们的日常开发中,例如首先使用标记语言编写页面,然后通过样式表来控制页面样式等,都属于渐进增强的概念;其他更明显的行为包括使用 HTML5、CSS3 等新技术,针对高级浏览器为页面提高用户体验。
# 6 浏览器进程和线程
进程
学术上说,进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。我们这里讲进程比喻为工厂的车间,它代表CPU所能处理的单个任务。任一时刻,CPU总是运行一个进程,其他进程处于非运行状态。
线程
在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。后来,随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明了线程,线程是程序执行中单一的顺序控制流程,是程序执行流的最小单位。这里把线程比喻一个车间的工人,即一个车间可以允许由多个工人协同完成一个任务。
两者关系
- 进程是
操作系统分配资源
的最小单位,线程是程序执行
的最小单位。 - 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线。
- 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号)。
- 调度和切换:线程上下文切换比进程上下文切换要快得多。
浏览器是多线程的
浏览器是多线程的,浏览器之所以能够运行,是因为系统给它的进程分配了资源(cpu、内存),简单点理解,每打开一个Tab页,就相当于创建了一个独立的浏览器进程。
- Browser进程:浏览器的主进程(负责协调、主控),只有一个。作用有
- 负责浏览器界面显示,与用户交互。如前进,后退等
- 负责各个页面的管理,创建和销毁其他进程
- 将 Renderer进程得到的内存中的 Bitmap,绘制到用户界面上
- 网络资源的管理,下载等。
- 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建
- GPU进程:最多一个,用于3D绘制等
- 浏览器渲染进程(浏览器内核)(Renderer进程,内部是多线程的):默认每个Tab页面一个进程,互不影响。
- 页面渲染,脚本执行,事件处理等
浏览器内核(渲染进程)
请牢记,浏览器的渲染进程是多线程的
GUI 渲染线程
- 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
- 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
- 注意,GUI 渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行
JS 引擎线程
- 也称为JS内核,负责处理JavaScript脚本程序(例如V8引擎)
- JS引擎线程负责解析JavaScript脚本,运行代码
- JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
- 同样注意,GUI 渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
事件触发线程
- 归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,JS引擎忙不过来,需要浏览器另开线程协助)
- 当JS引擎执行代码块如 setTimeOut 时 (也可来自浏览器内核的其他线程,如鼠标点击、ajax异步请求等),会将对应任务添加到事件线程中
- 当对应的事件符合触发条件被触发时,该线程会把时间添加到待处理队列的队尾,等待JS引擎的处理
- 注意:由于JS的单线程关系,所以这些待处理队列中的事件都得等待JS引擎处理(当JS引擎空闲时才会去执行)
定时触发器线程
- 传说中的setInterval 与 setTimeout 所在线程
- 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的,如果处于阻塞线程状态就会影响计时的准确性)
- 因此通过单线程来计时并处罚金定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
- 注意:W3C 在 HTML 标准中规定,规定要求 setTimeout中低于4ms的时间间隔算为4ms。
异步 http 请求线程
- 在 XMLHttpRequest 在连接后是通过浏览器断开一个线程请求
- 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。
# 7 设计模式
设计模式的五大基本原则( SOLID
)。SOLID
所指的五大基本原则分别是:单一功能原则
、开放封闭原则
、里氏替换原则
、接口隔离原则
和依赖反转原则
。
SOLID
可以简单概括为六个字:即 高内聚、低耦合
:
- 高 层模块不依赖底层模块,即为依赖反转原则;
- 内 部修改关闭,外部扩展开放,即为开放封闭原则;
- 聚 合单一功能,即为单一功能原则;
- 低 知识要求,对外接口简单,即为迪米特法则;
- 耦 合多个接口,不如独立拆分,即为接口隔离原则;
- 合 成复用,子类继承可替换父类,即为里氏替换原则;
23种设计模式分为 创建型
、行为型
和 总结型
, 具体类型如下图:
设计模式说白了就是 封装变化
, 比如 创建型
封装了创建对象的变化过程, 结构型
将对象之间组合的变化封装, 行为型
则是抽离对象的变化行为。
下面分析集中常用的设计模式:
# 工厂模式
工厂模式分为好几种,这里就不一一讲解了,以下是一个简单工厂模式的例子;
class Man {
constructor (name) {
this.name = name;
}
alertName () {
alert(this.name)
}
}
class Factory {
static create (name) {
return new Man(name)
}
}
Factory.create('fangyuan').alertName()
当然工厂模式并不仅仅是用来 new
出实例。
可以想象一个场景。假设有一份很复杂的代码需要用户去调用,但是用户并不关心这些复杂的代码,只需要你提供给我一个接口去调用,用户只负责传递需要的参数,至于这些参数怎么使用,内部有什么逻辑是不关心的,只需要你最后返回我一个实例
。这个 构造过程就是工厂
。
工厂起到的作用就是隐藏了创建实例的复杂度,只需要提供一个接口,简单清晰。
在 Vue
源码中,你也可以看到工厂模式的使用,比如 创建异步组件:
export function createComponent (
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
// 逻辑处理...
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined, undefined, undefined, context,
{
Ctor, propsData, listeners, tag, children
},
asyncFactory
)
return vnode
}
在上述代码中,我们可以看到我们只需要调用 createComponent
传入参数就能创建一个组件实例,但是创建这个实例是很复杂的一个过程,工厂帮助我们隐藏了这个复杂的过程,只需要一句代码调用就能实现功能。
# 单例模式
单例模式很常用,比如全局缓存、全局状态管理等这些只需要一个对象,就可以使用单例模式。
单例模式的核心就是 保证全局只有一个对象
可以访问。因为 JS
是门无类的语言,所以别的语言实现单例的方式并不能套入 JS
中, 我们只需要用一个变量确保实例只创建一次就行,以下是如何实现单例模式的例子:
class Singleton {
constructor () {}
}
Singleton.getInstance = (function() {
let instance
return function () {
if (!instance) {
instance = new Singleton()
}
return instance
}
})()
let s1 = Singleton.getInstance()
let s2 = Singleton.getInstance()
console.log(s1 === s2) // true
在 Vuex
源码中,你也可以看到单例模式的使用,虽然它的实现方式不大一样,通过一个外部变量来控制只安装一次 Vuex
let Vue // bind on install
export function install (_Vue) {
if (Vue && _Vue === Vue) {
// 如果发现 Vue 有值,就不重新创建实例了
return
}
Vue = _Vue
applyMixin(Vue)
}
# 适配器模式
适配器用来解决两个接口不兼容的情况,不需要改变已有的接口,通过包装一层的方式实现两个接口的正常的协作。
以下是如何实现适配器模式的例子
class Plug {
getName () {
return '港版插头';
}
}
class Target {
constructor () {
this.plug = new Plug()
}
getName () {
return this.plug.getName() + ' 适配器转二脚插头';
}
}
let target = new Target();
target.getName(); // 港版插头 适配器转二脚插头
在 Vue
中, 我们其实经常使用到适配器模式。比如父组件传递给子组件一个时间戳属性,组件内部需要将时间戳转为正常的日期显示,一般会使用 computed
来做转换这件事情,这个过程就使用到了适配器模式。
# 装饰模式
装饰模式不需要改变已有的接口,作用是给对象添加功能。就像我们经常需要给手机戴个保护套防摔一样,不改变手机自身,给手机添加了保护套提供防摔功能。
以下是如何实现装饰模式的例子,使用了 ES7
中的装饰器语法
function readonly(target, key, descriptor) {
descriptor.writable = false;
return descriptor;
}
class Test {
@readonly // 装饰器 写法
name = 'fangyuan';
}
let t = new Test()
t.name = '111'; // 不可修改
在 React
中,装饰模式其实随处可见
import { connect } from 'react-redux'
class MyComponent extends React.Component {
// ...
}
export default connect(mapStateToProps)(MyComponent)
# 代理模式
代理是为了控制对对象的访问,不让外部直接访问到对象。在现实生活中,也有很对代理的场景。比如你需要买一件国外的产品,这时候你可以通过代购来购买产品。
在实际代码中代理的场景很多,也就不举框架中的例子,比如 事件代理
就用到了代理模式。
<ul id="ul">
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
<script>
let ul = document.querySelector('#ul');
ul.addEventListener('click', (event) => {
console.log(event.target)
})
</script>
因为存在太多的 li
, 不可能每个都去绑定事件。
这时候可以通过给父节点绑定一个事件,让父节点作为代理去拿到真是点击的节点。
# 发布订阅模式
发布-订阅模式也叫做观察者模式。通过一对一或者一对多的依赖关系,当对象发送改变,订阅方都会收到通知。在现实生活中,也有很多类似场景,比如我需要在购物网站上购买一个产品,但是发现该产品目前处于缺货状态,这时候我可以点击有货通知的按钮,让网站在产品有货的时候通过短信通知我。
在实际代码中其实发布-订阅模式也很常见,比如我们点击了一个按钮触发了点击事件就是使用了该模式
<ul id="ul"></ul>
<script>
let ul = document.querySeletor('#ul');
ul.addEventListener('click', (event) => {
console.log(event.target);
})
</script>
在 Vue
中,如何实现响应式也是使用了该模式。对于需要实现响应式的对象来说,在 get
的时候会进行依赖收集,当改变了对象的属性时,就会触发 派发更新
。
# 外观模式
外观模式提供了一个接口,隐藏了内部的逻辑,更加方便外部调用。
举个例子来说,我们现在需要实现一个兼容多种浏览器的添加事件方法。
function addEvent (ele, evType, fn, useCapture) {
if (ele.addEventListener) {
ele.addEventListener(evType, fn, useCapture);
return true;
} else if (ele.attachEvent) {
var r = ele.attachEvent('on' + evType, fn);
return r;
} else {
ele['on' + evType] = fn;
}
}
对于不同的浏览器,添加事件的方式可能会存在兼容问题。如果每次都需要去这样写一遍的话肯定是不能接受的,所以我们将这些判断逻辑统一封装在一个接口中,外部需要添加时间只需要调用 addEvent
即可。
# 9 直出和同构
直出,常常指代后端渲染,即我们请求的页面已经是把模板和数据组合好,直接吐出来给到浏览器。
与此相对的,是前端渲染。例如单页应用,我们拿到的只是一个简单的空 <html>
, 然后浏览器解析发现需要一些请求的数据和资源,发起二次请求。当然,这样一来一去,消耗和等待的时间便会很长。
同构又是什么呢?
直出渲染的后端可以有很多,像 PHP
, JSP
、Node.js
等都是可以的。而同构的意思是,前后端使用一套代码。所以简单来说,就是 Node.js
的胜出了,同构最明显的优势,则是方便维护。
# 10 单页面 与 多页应用
什么是单页应用?
其实很简单,单页应用与多页最简单的区别就是,单页应用,是一个 HTML
文件。
当我们需要更改页面的展示,我们会移除掉部分元素,然后将需要新增的内容填充进去,与画面的擦除重绘相似。
单页应用的好处是:
- 页面的数据状态都能够维持着。
- 部分回流重绘,比整个页面刷新的效果体验要好很多。
当然,单页应用也会有缺点:
- 不利于 SEO。
- 请求等到时间长。
事物都是有利有弊,单页应用的最大痛处在于 SEO
。 搜索引擎优化 (英语:search engine optimization,缩写为 SEO),是一种通过了解搜索引擎的运作规则来调整网站,以及提高目的的网站在有关搜索引擎内排名的方式。
而关于单页应用的 SEO
,也是有各种各样的方式优化的,大家也可以去了解。
什么是多页应用?
多页应用,更常见于相同的业务不同的页面的开发,这些页面或许没有很有的联系或者公用的数据,每个页面维护各自的状态。
如今 H5
的业务多了, 直出渲染也多了些,而多页面和支出也是不错的搭配。
- | 单页面应用 | 多页面应用 |
---|---|---|
组成 | 一个外壳页面和多个页面片段组成 | 多个完整页面构成 |
资源共用(css,js) | 共用,只需在外壳部分加载 | 不共用,每个页面都需要加载 |
刷新方式 | 页面局部刷新或更改 | 整页刷新 |
url 模式 | a.com/#/pageone a.com/#/pagetwo | a.com/pageone.html a.com/pagetwo.html |
用户体验 | 页面片段间的切换快,用户体验良好 | 页面切换加载缓慢,流畅度不够,用户体验比较差 |
页面跳转动画 | 容易实现 | 无法实现 |
数据传递 | 容易 | 依赖url传参、cookie、localStorage 等 |
搜索引擎优化(SEO) | 需要单独方案、实现较为困难,不利于 SEO 检索、可利用服务器端渲染 (SSR) 优化 | 实现方式简易 |
# 11 浏览器输入URL经历了那些历程?
分别为以下步骤:
- DNS 解析:将域名解析成IP地址
- TCP 连接:TCP 三次握手
- 发送 HTTP 请求
- 服务器处理请求并返回 HTTP 报文
- 断开连接: TCP 四次挥手
# URL 理解
URL (Uniform Resource Locator),统一资源定位符,用于定位互联网上资源,俗称网址。比如 http://www.1996f.cn | https://canuse.top
, 遵守以下的语法规则:
scheme://host.domain:port/path/filename 各部分解释如下:
scheme - 定义因特网服务的类型。常见的协议有 http、https、ftp、file,其中最常见的类型是 http,而https 则是进行加密的网络传输。
host - 定义域主机(http的默认主机是www)
domain - 定义因特网域名,比如 1996f.cn | canuse.top
port - 定义主机上的端口号(http 的默认端口号 80)
path - 定义服务器上的路径 (如果省略,则文档必须位于网站的根目录中)
filename - 定义文档 / 资源的名称
# 域名解析 (DNS)
在浏览器输入网址后,首先要经过域名解析,因为浏览器并不能直接通过域名找到对应的服务器,而是要通过 IP 地址。大家这里或许会有个疑问 - 计算机即可被赋予 ip 地址, 也可以被赋予 主机名和域名, 比如:www.1996f.cn | canuse.top
。那怎么一开始就赋予个 IP 地址? 这样就可以省去解析麻烦。 我们先来了解下什么是 IP 地址
1.IP地址
IP 地址是指互联网协议地址,是 IP Address的缩写。IP 地址是IP 协议提供的一种统一的地址格式,它为互联网上的每一个网络和每一台主机分配一个逻辑地址,以此来屏蔽物理地址的差异。IP 地址是一个32位的二进制,比如 127.0.0.1 为本机 IP。 域名就相当于 IP 地址乔装打扮的伪装者,带着一副面具。作用是便于记忆和沟通的一组服务器的地址。用户通常使用主机名或域名来访问对方的计算机,而不是直接通过IP地址访问。因为与IP地址的一组纯数字相比,用字母配合数字的表示形式来指定计算机更名符合人类的记忆习惯。但要让计算机去理解名称,相对而言就变得困难了。 因为计算机更擅长处理一长串数字。为了解决上述问题, DNS 服务出世了。
2.什么是域名解析
DNS 协议提供通过域名查找IP地址,或逆向从IP地址反查域名的服务。DNS 是一个网络服务器,我们的域名解析简单来说就是在 DNS 上记录一条信息记录。
例如 1996f.cn 255.255.255.255 (服务器外网IP地址) 80 (服务器端口号)
3.浏览器如何通过域名去查询 URL 对应的 IP ?
浏览器缓存:浏览器会按照一定的频率缓存 DNS 记录。
操作系统缓存:如果浏览器缓存中找不到需要的 DNS 记录,那就去操作系统中找。
路由缓存:路由器也有 DNS 缓存
ISP 的 DNS 服务器: ISP 是互联网服务提供商(Internet Service Provider)的简称,ISP 有专门的 DNS 服务器应对 DNS 查询请求。
根服务器: ISP 的 DNS 服务器还找不到的话,它就会向根服务器发出请求,进行递归查询(DNS 服务器先问根域名服务器.cn 域名服务器的 IP 地址,然后再问 .1996f 域名服务器。依此类推)
4.小结
浏览器通过向 DNS 服务器发送域名, DNS 服务器查询到与域名相对应的 IP 地址, 然后返回给浏览器,浏览器再将 IP 地址打在协议上,同时请求参数也会在协议搭载,然后一并发送给对应的服务器。接下来介绍向服务器发送 HTTP 请求阶段,HTTP 请求分为三个部分: TCP 三次握手、http 请求响应信息、关闭 tcp 连接。
# TCP 三次握手
在客户端发送数据之前会发起 TCP 三次握手用以同步客户端和服务端的序列号和确认号,并交换 TCP 窗口大小信息。
1.TCP 三次握手的过程如下:
客户端发送一个带 SYN=1, Seq=X 的数据包到服务器端口(第一次握手,由浏览器发起,告诉服务器我要发送请求了)
服务器发回一个带 SYN=1, ACK=X+1, Seq=Y的响应包以示传达确认信息(第二次握手,由服务器发起,告诉服务器我准备接受了,你赶紧发送吧)
客户端再回传一个带 ACK=Y+1, Seq = Z 的数据包,代表 “握手结束” (第三次握手,由浏览器发送,告诉服务器,我马上就发来,准备接受吧)
2.为啥需要三次握手
《计算机网络》中讲 “三次握手” 的目的是 “为了防止已失效的连接请求保温段突然又传送到了服务端,因而产生错误, 也就是说 为了确保连接有效
”
# 发送 HTTP 请求
TCP 三次握手结束后,开始发送HTTP请求报文。
请求报文由请求行(request line)、请求头(header)、请求体四个部分组成。如下图所示:
1.请求行包含请求方法、URL、协议版本
请求方法包含 8 种: GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS、TRACE。
URL 即请求地址,由 <协议>:// <主机>:<端口>/<路径>?<参数> 组成
协议版本即 http 版本号
POST /xxx/xxx.html HTTP/1.1
以上代码中 POST
代表请求方法, /xxx/xxx.html
代表 URL, HTTP/1.1
代表 协议和协议的版本。现在比较流行的是 HTTP1.1 版本
2.请求头包含请求的附加信息,由关键字/键对组成,每行一对,关键字和值用英文冒号“:”分隔
请求头部通知服务器有关于客户端请求的信息。它包含许多有关的客户端环境和请求正文的有用信息。其中比如: Host, 表示主机名,虚拟主机;Connection,HTTP/1.1 增加的,使用 keepalive,即持久连接,一个连接可以发多个请求;User-Agent,请求发出者,兼容性以及定制化需求。
3.请求体,可以承载多个请求参数的数据,包含回车符、换行符和请求数据,并不是所有请求都具有请求数据。
name=fangyuan&password=123&realName=fangyuan
上面代码,承载着 name、password、realName 三个请求参数
# 服务器处理请求并返回 HTTP 报文
1.服务器
服务器是网络环境中的高性能计算机,它侦听网络上的其他计算机(客户机)提交的服务请求,并提供相应的服务,比如网页服务、文件下载服务、邮件服务、视频服务。而客户端主要的功能是浏览网页、看视频、听音乐等等。两者姐在不同,前者是提供服务,后者是享受提供的服务。 每台服务器上都会安装处理请求的应用 ———— web server。 常见的 web server 产品有apache、nginx、IIS 或 Lighttpd 等。 web server 担任管控的角色,对于不同用户发送的请求,会结合配置文件,把不同请求委托给服务器上处理相应请求的程序进行处理(例如 CGI 脚本,JSP 脚本,servlets,ASP 脚本,服务器端 JavaScript,或者一些其他的服务器端技术等),然后返回后台程序处理产生的结果作为响应。
2.MVC 后台处理阶段
后台开发现在有很多框架,但大部分都还是按照 MVC 设计模式进行搭建的。MVC 是一个设计模式,将应用程序分为三个核心部件:模型(model) —— 视图(view)—— 控制器(controller),它们各自处理自己的任务,实现输入、处理和输出的分离。
1、视图 (view)
它是提供给用户的操作界面,是程序的外壳。
2、模型(model)
模型主要负责数据交互 在 MVC 的三个部件中,模型拥有最多的处理任务。一个模型能为多个视图提供数据。
3、控制器(controller)
它负责根据用户从“视图层”输入的指令,选取“模型层”中的数据,然后对其进行相应的操作,产生最终结果。 控制器属于管理者角色,从视图接收请求并决定调用哪个模型构件去处理请求,然后再确定用哪个视图来显示模型处理返回的数据。这三层是紧密联系在一起的,但又是互相独立的,每一层内部的变化不影响其它层。每一层都对外提供接口(Interface),供上面一层调用。至于这一阶段发生什吗?简而言之,首先浏览器发送过来的请求先经过控制器,控制器进行逻辑处理和请求分发,接着会调用模型,这一阶段模型会获取 redis db 以及 mysql 的数据,获取数据后将渲染好的页面,响应信息会以响应报文的形式返回给客户端,最后浏览器通过渲染引擎将网页呈现在用户面前。
3.http 响应报文
响应报文由响应行(request line)、响应头部(header)、响应主体三个部分组成。如下图所示:
响应行包含:协议版本,状态码,状态码描述:
状态码规则如下: 1xx 指示信息——表示请求已经接收,继续处理。2xx:成功——表示请求已被成功接收、理解、接受。3xx:重定向——要完成请求必须进行更近一步的操作。4xx:客户端错误——请求有语法错误或请求无法实现。5xx:服务器端错误——服务器未能实现合法的请求。
响应头部包含响应报文的附加信息,由 名 / 值 对组成
响应主体包含回车符、换行符和响应返回数据,并不是所有响应报文都有响应数据
# 浏览器解析渲染页面
浏览器拿到相应文本 HTML 后,接下来介绍下浏览器渲染机制。
浏览器解析渲染页面分为以下五个步骤:
根据 HTML 解析出 DOM 树
根据 CSS 解析生成 CSS 规则树
结合 DOM 树 和 CSS 规则树,生成渲染树
根据渲染树计算每一个节点的信息
根据计算好的信息绘制页面
1.根据 HTML 解析 DOM 树
根据 HTML 的内容, 将标签按照结构解析成为 DOM 树, DOM 树解析的过程是一个
深度优先遍历
。即 先构建当前节点的所有子节点,再构建下一个兄弟节点在读取 HTML 文档,构建 DOM 树的过程中,若遇到 script 标签,则 DOM 树的构建会暂停,直至脚本执行完毕。
2.根据 CSS 解析生成 CSS 规则树
解析 CSS 规则树时 js 执行 将暂停,直至 CSS 规则树就绪。
浏览器在 CSS 规则树生成之前不会进行渲染。
3.结合 DOM 树 和 CSS 规则树,生成渲染树
DOM 树 和 CSS 规则树全部准备好了以后,浏览器才会开始构建渲染树。
精简 CSS 并可以加快 CSS 规则树的构建,从而加快页面相应速度。
4.根据渲染树计算每一个节点的信息(布局)
布局:通过渲染树中渲染对象的信息,计算出每一个渲染对象的位置和尺寸。
回流:在布局完成后,发现了某个部分发生了变化影响来布局,那就需要倒回去重新渲染。
5.根据计算好的信息绘制页面
绘制阶段,系统会遍历呈现树,并调用呈现器的 “paint” 方法,将呈现器的内容显示在屏幕上。
重绘: 某个元素的背景颜色,文字颜色等,不影响元素周围或内部布局的属性,将只会引起浏览器的重绘。
回流: 某个元素的尺寸发生了变化,则需要重新计算渲染树,重新渲染。
# 断开连接
当数据传输完毕, 需要断开 TCP 连接, 此时发起来 TCP 四次挥手
发起方向被动方发送报文, Fin、Ack、Seq,表示已经没有数据传输了,并进入 FIN_WAIT_1 状态。 (第一次挥手:由浏览器发起的,发送给服务器,我请求报文发送完了,你准备关闭吧)
被动方发送报文,Ack、Seq,表示同意关闭请求。此时主机发起方进入FIN_WAIT_2 状态 (第二次挥手:由服务器发起的,告诉浏览器,我请求报文接收完了,我准备关闭了,你也准备吧)
被动方向发起方发送报文段,Fin、Ack、Seq,请求关闭连接。并进入LAST_ACK状态 (第三次挥手:由服务器发起,告诉浏览器,我响应报文发送完了,你准备关闭吧)
发起方向被动方发送报文段,Ack、Seq。然后进入等待 TIME_WAIT状态。被动方收到发起方的宝we暖以后关闭连接。发起方等待一定时间未收到回复,则正常关闭。 (第四次挥手:由浏览器发起,告诉浏览器,我响应报文接受完了没完准备关闭了,你也准备吧。)
# 参考文章
从输入页面地址到展示页面信息都发生了些啥 (opens new window)
TCP的三次握手四次挥手 (opens new window)
访问web,tcp传输全过程(三次握手、请求、数据传输、四次挥手) (opens new window)