3、ES6 知识点及常考面试题
本章节我们将来学习 ES6 部分的内容。
Set和Map
数据类型:
数组:
优点:
1、按顺序排列,不考虑元素类型,
2、下标与值对应,
3、紧密型结构,
4、数组可以根据某个值找到相邻数据
缺点:
1、删除和插入都会影响整个长度,和改变数组各个元素位置
2、数组可能有重复
3、速度慢 查询添加 删除都需要遍历数组;
4、字符串数组:长度限定
对象:
优点:
1、对象是松散型集合,不需要考虑相邻关系,
2、有键值对;查询 添加 删除快
3、可以有多重集合:键名不能是对象:是字符串
缺点:
1、属性没有关联;
2、按照添加顺序遍历(顺序无法改变)
3、如果需要查询属性时需要遍历
map类型
优点:
1、有键值对,需要有长度;
2、具有 Iterator接口可使用for of 可以遍历属性列表;可遍历数值列表;
3、有api增删改查速度快
1、set(key,value):添加元素
2、get(key):获取元素
3、size:获取map的成员数
4、has(key):判断是否是成员;只能查找键
5、clear():清除所有数据
6、遍历map:Set 和 Map 结构也原生具有 Iterator 接口,可以直接使用for...of循环。
for(let obj of maps){console.log(obj);}//遍历对象
for(let key of a.keys()){ console.log(key);}//遍历属性名
for(let value of a.values()){ console.log(value); }//遍历值
for(let item of a.entries()){ console.log(item); }//返回所有成员的遍历器
forEach遍历map//先遍历值在遍历属性
a.forEach((value,key,list)=>{console.log(v,k,list)})
缺点:
1、按照添加顺序遍历(顺序无法改变);
2、普通的map结构不能使用对象作为属性储存值;否则会该对象属性变为强引用类型
WeakMap类型:
1、弱引用列表类型;可以使用对象作为属性储存值
2、长度是可变所以不可遍历;
3、将obj设置null;被维护的WeakMap列表中会自动清除它
Set
优点:1、插入,添加,删除速度快,无重复的值的列表(不能有键)
缺点:没有索引,不能用for循环;也不能用下标直接修改或获取;
WeakSet类型:
弱引用列表类型;长度是可变所以不可遍历;
将obj设置null;被维护的WeakSet列表中会自动清除它
Set和Map详解
Set:是一个不能有重复元素的集合(列表),重复添加无效
删除添加查找的速度高于数组,但无法找不着关系数组
Api:add(value)、delete(value)、has(value)、clear( )
新建set: let a=new Set( )
add(value):添加元素
//不允许重复的列表,删除添加查找的速度高于数组,但是无法寻找到关系数据
let a=new Set( )
a.add(2)
a.add(3)
a.add(5)
a.add(3)
console.log(a);//Set(3) {2, 3, 5}
delete(value):删除元素:
let a=new Set( );
a.add(2);
a.add(5);
a.add(3);
a.delete(2);//2是上边的值value
console.log(a);//Set(2) {5, 3}
has(value):判断列表中是否是有该值
let a=new Set( );
a.add(2);
a.add(3);
a.add(5);
console.log(a.has(3));//true判断列表中是否含有该值
clear( ):清除所有数据
数组去重:
let arr=[1,2,3,1,2,3,1,2,3];
let sets=new Set(arr);
arr=Array.from(sets);
console.log(arr);
Array.from( )方法从一个类似数组或可迭代对象中创建
map
和对象类似;但对象没有长度,map有长度、删除添加查找的速度高于对象
Api:set(key,value)、get(key)、Size、has(value)、delete( )、clear( )
set(key,value):添加元素
let map=new Map( );
map.set("name","xietian");
map.set("age",30);
map.set("sex","男");
get(key):获取元素
Size:获取map的成员数
let a=new Set([2,3,5,6,7,3,4,2,1,2,3,4,5]);
for(var i=0;i<a.size;i++){ }
console.log(a);//Set(7) {2, 3, 5, 6, 7,4,1}
For of, // 只能遍历Set和Map
let a=new Set([2,3,5,6,7,3,2,4,2,1,3,4,5]);
for(let value of a){
console.log(value);}//2,3,5,6,7,4,1
has(value):判断是否是成员
// Map hashMap
let map=new Map();
map.set("name","xietian");
map.set("age",30);
map.set("sex","男");
console.log(map.has("age"));//true判断是否有当前属性
delete( )
map.delete("age");
console.log(map)//Map(2) {"name" => "xietian", "sex" => "男"}
clear( ):清除所有数据;对象无法一次清空
map.clear( );
console.log(map);//Map(0) { }
const obj=new Map( );
obj.set( )
obj.clear( );
和对象类似;但对象没有长度,map有长度
遍历:
let map=new Map();
map.set("name","xietian");
map.set("age",30);
map.set("sex","男");
遍历对象
for(let value of map){
console.log(value);}
遍历属性名
for(let value of map.keys( )){
console.log(value);//name age sex
}
遍历值
for(let value of map.values( )){
console.log(value);//xietian 30 男
}
返回所有成员的遍历器
For of遍历map
for(let value of map.entries( )){
console.log(value); }
forEach遍历map
map.forEach(function(value,key,map){
console.log(value,key)});
箭头函数遍历map**
map.forEach((value,key,list)=>{
console.log(value,key,list})
//xietian name 30 "age" 男 sex
//Map(3) {"name" => "xietian", "age" => 30, "sex" => "男"}
对象访问器属性:setter与getter
getter和setter:访问器属性:既有方法也有属性的特征:只存在IE6以上
Getters和Setters使你可以快速获取或设置一个对象的数据。
一个对象拥有两个方法,分别用于获取和设置某个值,
你可以用它来隐藏那些不想让外界直接访问的属性。一个对象内,每个变量只能有一个getter或setter。(因此value可以有一个getter和一个setter,但是value绝没有两个getters)
删除getter或setter的唯一方法是:delete object[name]。delete可以删除一些常见的属性,getters和setters。
1、对数据的访问限制:a.value是对value变量的getter方法调用,如果在getter方法实现中抛出异常,可以阻止对value变量的访问;
2、对dom变量进行监听:window.name是一个跨域非常好用的dom属性(大名鼎鼎,详见百度),如果覆盖window.name的setter实现则可以实现跨页面的内存异步通信;
3、自己发挥想象力,能做的事情好多滴;
// setter和getter 是访问器属性
var obj={
_num:0,//这里始终没有存储值// num 没有存储值,存储在this._num;
// set方法有且仅有,必须有一个参数,不使用return返回内容
//设置setter:
set num(value){//存储用的内部属性名=value;
this._num=value;//如果只有set,没有get,只可写不可读
//当设置这个属性后随之需要执行的方法
},
//设置getter:
// get方法不能有参数,并且必须使用return返回值
get num(){// 如果没有set,只有get,表示该属性只读不可写
// 当获取这个属性时需要操作的内容(执行这个方法)
return this._num; // return 内部存储的这个属性;
}
}
// console.log(obj);
``
// // obj.num();//没有这个方法
``
obj.num=10;//这时候会调用set方法
console.log(obj.num);//0 //当num作为一种运算值使用时,调用get方法

注意setter和getter设置的属性一般是成对出现,对应的相应属性。
如果仅出现set,没有使用get,表示该属性只写,不能获取,如果仅出现get没有出现set,表示该属性只读,不可写值。
最后说明,setter和getter虽然很好用,但是目前ie6不支持,使用的时候要注意。
9、Generators生成器函数
1)写法
function* getNums(i) {
yield i;
let s=i+10;
yield s;
yield s+10;}
let a2= getNums(10);
console.log(a2.next( ).value);//10
console.log(a2.next( ).value);//20
console.log(a2.next( ).value);//30
console.log(a2.next( ).value);//undefined
function* getSum(a,b){
a++;
yield a;
b--;
yield b;
let sum=a+b;
yield sum;
return sum;
}
var sum=getSum(3,5);
var first=sum.next( );
while(!first.done){
console.log(first.value)//4-4 8
first=sum.next( );
}
2)yield是停止返回value的点,可控的,类似断点,将异步过程强制变同步阻塞过程。
3)next( ).value就是下一步的返回值直到yield返回
// 异步过程强制变为同步阻塞过程
function* setNums( ){
yield setTimeout(fn1,2000);
yield setTimeout(fn2,2000);
}
function fn1( ){
console.log("aaaa");
a.next( );
}
function fn2( ){
console.log("bbb");
}
var a=setNums( );
a.next( );
var、let 及 const 区别
涉及面试题:什么是提升?什么是暂时性死区?var、let 及 const 区别?
那么最后我们总结下这小节的内容:
- 函数提升优先于变量提升,函数提升会把整个函数挪到作用域顶部,变量提升只会把声明挪到作用域顶部
var
存在提升,我们能在声明之前使用。let
、const
因为暂时性死区的原因,不能在声明前使用var
在全局作用域下声明变量会导致变量挂载在window
上,其他两者不会let
和const
作用基本一致,但是后者声明的变量不能再次赋值
1. 如何在ES5环境下实现let
对于这个问题,我们可以直接查看babel
转换前后的结果,看一下在循环中通过let
定义的变量是如何解决变量提升的问题
babel在let定义的变量前加了道下划线,避免在块级作用域外访问到该变量,除了对变量名的转换,我们也可以通过自执行函数来模拟块级作用域
(function(){ for(var i = 0; i < 5; i ++){ console.log(i) // 0 1 2 3 4 }})();
console.log(i) // Uncaught ReferenceError: i is not defined
2. 如何在ES5环境下实现const
实现const的关键在于Object.defineProperty()
这个API,这个API用于在一个对象上增加或修改属性。通过配置属性描述符,可以精确地控制属性行为。Object.defineProperty()
接收三个参数:
Object.defineProperty(obj, prop, desc)
参数 | 说明 |
---|---|
obj | 要在其上定义属性的对象 |
prop | 要定义或修改的属性的名称 |
descriptor | 将被定义或修改的属性描述符 |
属性描述符 | 说明 | 默认值 |
---|---|---|
value | 该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined | undefined |
get | 一个给属性提供 getter 的方法,如果没有 getter 则为 undefined | undefined |
set | 一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。当属性值修改时,触发执行该方法 | undefined |
writable | 当且仅当该属性的writable为true时,value才能被赋值运算符改变。默认为 false | false |
enumerable | enumerable定义了对象的属性是否可以在 for...in 循环和 Object.keys() 中被枚举 | false |
Configurable | configurable特性表示对象的属性是否可以被删除,以及除value和writable特性外的其他特性是否可以被修改 | false |
对于const不可修改的特性,我们通过设置writable属性来实现
function _const(key, value) {
const desc = { value, writable: false}
Object.defineProperty(window, key, desc)}
_const('obj', {a: 1}) //定义objobj.b = 2
//可以正常给obj的属性赋值obj = {}
//抛出错误,提示对象read-only
模块化
涉及面试题:为什么要使用模块化?都有哪几种方式可以实现模块化,各有什么特点?
使用一个技术肯定是有原因的,那么使用模块化可以给我们带来以下好处
- 解决命名冲突
- 提供复用性
- 提高代码可维护性
立即执行函数:解决了命名冲突、污染全局作用域的问题
在早期,使用立即执行函数实现模块化是常见的手段,通过函数作用域解决了命名冲突、污染全局作用域的问题
(function(globalVariable){
globalVariable.test = function() {}
// ... 声明各种变量、函数都不会污染全局作用域
})(globalVariable)
AMD 和 CMD
鉴于目前这两种实现方式已经很少见到,所以不再对具体特性细聊,只需要了解这两者是如何使用的。
// AMD
define(['./a', './b'], function(a, b) {
// 加载模块完毕可以使用
a.do()
b.do()
})
// CMD
define(function(require, exports, module) {
// 加载模块
// 可以把 require 写在函数体的任意地方实现延迟加载
var a = require('./a')
a.doSomething()
})
CommonJS
CommonJS 最早是 Node 在使用,目前也仍然广泛使用,比如在 Webpack 中你就能见到它,当然目前在 Node 中的模块管理已经和 CommonJS 有一些区别了。
// a.js
module.exports = {
a: 1
}
// or
exports.a = 1
// b.js
var module = require('./a.js')
module.a // -> log 1
因为 CommonJS 还是会使用到的,所以这里会对一些疑难点进行解析
先说 require
吧
var module = require('./a.js')
module.a
// 这里其实就是包装了一层立即执行函数,这样就不会污染全局变量了,
// 重要的是 module 这里,module 是 Node 独有的一个变量
module.exports = {
a: 1
}
// module 基本实现
var module = {
id: 'xxxx', // 我总得知道怎么去找到他吧
exports: {} // exports 就是个空对象
}
// 这个是为什么 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
// 导出的东西
var a = 1
module.exports = a
return module.exports
};
// 然后当我 require 的时候去找到独特的
// id,然后将要使用的东西用立即执行函数包装下,over
另外虽然 exports
和 module.exports
用法相似,但是不能对 exports
直接赋值。因为 var exports = module.exports
这句代码表明了 exports
和 module.exports
享有相同地址,通过改变对象的属性值会对两者都起效,但是如果直接对 exports
赋值就会导致两者不再指向同一个内存地址,修改并不会对 module.exports
起效。
ES Module
ES Module 是原生实现的模块化方案,与 CommonJS 有以下几个区别
- CommonJS 支持动态导入,也就是
require(${path}/xx.js)
,后者目前不支持,但是已有提案 - CommonJS 是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
- CommonJS 在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想更新值,必须重新导入一次。但是 ES Module 采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化
- ES Module 会编译成
require/exports
来执行的
// 引入模块 API
import XXX from './a.js'
import { XXX } from './a.js'
// 导出模块 API
export function a() {}
export default function() {}
模块化的差异 AMD CommonJS ESModule
AMD依赖前置,也就是说依赖之前就写好了
ESmodule是静态的,加载的是一个接口
静态引入的好处:可以做代码的静态分析,webpack中的打包就是利用了静态依赖
AMD和commonJS都是动态的,可以实现动态加载,而且加载的是一个对象
// AMD 在Angular中就是非常好的体现
defined(['a','b'],function(a, b){
// 数组中放的是a模块和b模块
// 函数相当于一个c模块
})
Proxy
涉及面试题:Proxy 可以实现什么功能?
如果你平时有关注 Vue 的进展的话,可能已经知道了在 Vue3.0 中将会通过 Proxy
来替换原本的 Object.defineProperty
来实现数据响应式。 Proxy 是 ES6 中新增的功能,它可以用来自定义对象中的操作。
let p = new Proxy(target, handler)
target
代表需要添加代理的对象,handler
用来自定义对象中的操作,比如可以用来自定义 set
或者 get
函数。
接下来我们通过 Proxy
来实现一个数据响应式
let onWatch = (obj, setBind, getLogger) => {
let handler = {
get(target, property, receiver) {
getLogger(target, property)
return Reflect.get(target, property, receiver)
},
set(target, property, value, receiver) {
setBind(value, property)
return Reflect.set(target, property, value)
}
}
return new Proxy(obj, handler)
}
let obj = { a: 1 }
let p = onWatch(
obj,
(v, property) => {
console.log(`监听到属性${property}改变为${v}`)
},
(target, property) => {
console.log(`'${property}' = ${target[property]}`)
}
)
p.a = 2 // 监听到属性a改变
p.a // 'a' = 2
在上述代码中,我们通过自定义 set
和 get
函数的方式,在原本的逻辑中插入了我们的函数逻辑,实现了在对对象任何属性进行读写时发出通知。
当然这是简单版的响应式实现,如果需要实现一个 Vue 中的响应式,需要我们在 get
中收集依赖,在 set
派发更新,之所以 Vue3.0 要使用 Proxy
替换原本的 API 原因在于 Proxy
无需一层层递归为每个属性添加代理,一次即可完成以上操作,性能上更好,并且原本的实现有一些数据更新不能监听到,但是 Proxy
可以完美监听到任何方式的数据改变,唯一缺陷可能就是浏览器的兼容性不好了。
map, filter, reduce
涉及面试题:map, filter, reduce 各自有什么作用?
map
作用是生成一个新数组,遍历原数组,将每个元素拿出来做一些变换然后放入到新的数组中。
[1, 2, 3].map(v => v + 1) // -> [2, 3, 4]
另外 map
的回调函数接受三个参数,分别是当前索引元素,索引,原数组
['1','2','3'].map(parseInt)
- 第一轮遍历
parseInt('1', 0) -> 1
- 第二轮遍历
parseInt('2', 1) -> NaN
- 第三轮遍历
parseInt('3', 2) -> NaN
filter
的作用也是生成一个新数组,在遍历数组的时候将返回值为 true
的元素放入新数组,我们可以利用这个函数删除一些不需要的元素
let array = [1, 2, 4, 6]
let newArray = array.filter(item => item !== 6)
console.log(newArray) // [1, 2, 4]
和 map
一样,filter
的回调函数也接受三个参数,用处也相同。
最后我们来讲解 reduce
这块的内容,同时也是最难理解的一块内容。reduce
可以将数组中的元素通过回调函数最终转换为一个值。
如果我们想实现一个功能将函数里的元素全部相加得到一个值,可能会这样写代码
const arr = [1, 2, 3]
let total = 0
for (let i = 0; i < arr.length; i++) {
total += arr[i]
}
console.log(total) //6
但是如果我们使用 reduce
的话就可以将遍历部分的代码优化为一行代码
const arr = [1, 2, 3]
const sum = arr.reduce((acc, current) => acc + current, 0)
console.log(sum)
对于 reduce
来说,它接受两个参数,分别是回调函数和初始值,接下来我们来分解上述代码中 reduce
的过程
- 首先初始值为
0
,该值会在执行第一次回调函数时作为第一个参数传入 - 回调函数接受四个参数,分别为累计值、当前元素、当前索引、原数组,后三者想必大家都可以明白作用,这里着重分析第一个参数
- 在一次执行回调函数时,当前值和初始值相加得出结果
1
,该结果会在第二次执行回调函数时当做第一个参数传入 - 所以在第二次执行回调函数时,相加的值就分别是
1
和2
,以此类推,循环结束后得到结果6
想必通过以上的解析大家应该明白 reduce
是如何通过回调函数将所有元素最终转换为一个值的,当然 reduce
还可以实现很多功能,接下来我们就通过 reduce
来实现 map
函数
const arr = [1, 2, 3]
const mapArray = arr.map(value => value * 2)
const reduceArray = arr.reduce((acc, current) => {
acc.push(current * 2)
return acc
}, [])
console.log(mapArray, reduceArray) // [2, 4, 6]
如果你对这个实现还有困惑的话,可以根据上一步的解析步骤来分析过程。
4、JS 异步编程及常考面试题
在上一章节中我们了解了常见 ES6 语法的一些知识点。这一章节我们将会学习异步编程这一块的内容,鉴于异步编程是 JS 中至关重要的内容,所以我们将会用三个章节来学习异步编程涉及到的重点和难点,同时这一块内容也是面试常考范围,希望大家认真学习。
并发和并行区别
涉及面试题:并发与并行的区别?
异步和这小节的知识点其实并不是一个概念,但是这两个名词确实是很多人都常会混淆的知识点。其实混淆的原因可能只是两个名词在中文上的相似,在英文上来说完全是不同的单词。
并发是宏观概念,我分别有任务 A 和任务 B,在一段时间内通过任务间的切换完成了这两个任务,这种情况就可以称之为并发。
并行是微观概念,假设 CPU 中存在两个核心,那么我就可以同时完成任务 A、B。同时完成多个任务的情况就可以称之为并行。
回调函数(Callback)
涉及面试题:什么是回调函数?回调函数有什么缺点?如何解决回调地狱问题?
回调函数应该是大家经常使用到的,以下代码就是一个回调函数的例子:
ajax(url, () => {
// 处理逻辑
})
但是回调函数有一个致命的弱点,就是容易写出回调地狱(Callback hell)。假设多个请求存在依赖性,你可能就会写出如下代码:
ajax(url, () => {
// 处理逻辑
ajax(url1, () => {
// 处理逻辑
ajax(url2, () => {
// 处理逻辑
})
})
})
以上代码看起来不利于阅读和维护,当然,你可能会想说解决这个问题还不简单,把函数分开来写不就得了
function firstAjax() {
ajax(url1, () => {
// 处理逻辑
secondAjax()
})
}
function secondAjax() {
ajax(url2, () => {
// 处理逻辑
})
}
ajax(url, () => {
// 处理逻辑
firstAjax()
})
以上的代码虽然看上去利于阅读了,但是还是没有解决根本问题。
回调地狱的根本问题就是:
- 嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身
- 嵌套函数一多,就很难处理错误
当然,回调函数还存在着别的几个缺点,比如不能使用 try catch
捕获错误,不能直接 return
。在接下来的几小节中,我们将来学习通过别的技术解决这些问题。
Generator
涉及面试题:你理解的 Generator 是什么?
Generator
算是 ES6 中难理解的概念之一了,Generator
最大的特点就是可以控制函数的执行。在这一小节中我们不会去讲什么是 Generator
,而是把重点放在 Generator
的一些容易困惑的地方。
function *foo(x) {
let y = 2 * (yield (x + 1))
let z = yield (y / 3)
return (x + y + z)
}
let it = foo(5)
console.log(it.next()) // => {value: 6, done: false}
console.log(it.next(12)) // => {value: 8, done: false}
console.log(it.next(13)) // => {value: 42, done: true}
你也许会疑惑为什么会产生与你预想不同的值,接下来就让我为你逐行代码分析原因
- 首先
Generator
函数调用和普通函数不同,它会返回一个迭代器 - 当执行第一次
next
时,传参会被忽略,并且函数暂停在yield (x + 1)
处,所以返回5 + 1 = 6
- 当执行第二次
next
时,传入的参数等于上一个yield
的返回值,如果你不传参,yield
永远返回undefined
。此时let y = 2 * 12
,所以第二个yield
等于2 * 12 / 3 = 8
- 当执行第三次
next
时,传入的参数会传递给z
,所以z = 13, x = 5, y = 24
,相加等于42
Generator
函数一般见到的不多,其实也于他有点绕有关系,并且一般会配合 co 库去使用。当然,我们可以通过 Generator
函数解决回调地狱的问题,可以把之前的回调地狱例子改写为如下代码:
function *fetch() {
yield ajax(url, () => {})
yield ajax(url1, () => {})
yield ajax(url2, () => {})
}
let it = fetch()
let result1 = it.next()
let result2 = it.next()
let result3 = it.next()
Promise
涉及面试题:Promise 的特点是什么,分别有什么优缺点?什么是 Promise 链?Promise 构造函数执行和 then 函数执行有什么区别?
promise含义
所谓promise,简单说是一个容器,里面保存着一个异步操作的结果,从语法上说,promise是一个对象,从它可以获取异步操作的消息,promise提供了统一的API,各种异步操作都可以用同样的方法进行处理。
promise规范
(1)promise对象的状态不受外界影响,promise对象代表一个异步操作,用维护状态、传递状态的方式来使得回调函数能够及时调用,有三种状态,pending(进行中)、fulfilled(已成功)、rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态
(2)一旦状态改变就不会再变,任何时候都可以得到这个结果,promise对象的状态改变,只有两种可能:从pending变为fulfilled,从pending变为rejected。这时就称为resolved(已定型)。如果改变已经发生了,你再对promise对象添加回调函数,也会立即得到这个结果,这与事件(event)完全不同,事件的特点是:如果你错过了它,再去监听是得不到结果的。
(3)Promise
用链式调用的方式执行回调函数,也就是说每次调用 then
之后返回一个全新的 Promise
,原因也是因为状态不可变。如果你在 then
中 使用了 return
,那么 return
的值会被 Promise.resolve()
包装
Promise缺点
首先,一旦新建一个Promise就会立即执行,无法中途取消。
其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。
第三,当处于pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
promise API
方法:
1. Promise.resolve()—— 返回一个promise对象
注意点:
1、返回一个状态由给定value决定的Promise对象(有三种value类型)。
2、类型一,value值是一个Promise对象,则直接返回该对象
3、类型二,value值是thenable(即,带有then方法的对象),返回的Promise对象的最终状态由then方法执行决定
4、类型三,value值为空、基本类型或者不带then方法的对象,返回的Promise对象状态为fulfilled,并且将该value传递给对应的then方法。
5、使用场景:如果不知道一个值是否是Promise对象,使用Promise.resolve(value) 来返回一个Promise对象,这样就能将该value以Promise对象形式使用。
2. Promise.reject()——返回一个状态为失败的promise对象
返回一个状态为失败的Promise对象,并将给定的失败信息传递给对应的处理方法
3. Promise.all(iterable)—— 所有成功才成功,一个失败即失败
注意点:
1、该方法返回一个新的promise对象,只有所有的对象成功,才会触发成功,只要有一个失败,就会触发该对象失败。
2、如果该promise对象成功,会把一个包含iterable里所有promise返回值的数组作为成功回调的返回值,顺序跟iterable的顺序保持一致
3、如果该promise对象失败,它会把iterable里第一个触发失败的promise对象的错误信息作为它的失败错误信息。
4、Promise.all方法常被用于处理多个promise对象的状态集合。
4. Promise.race(iterable)——竞速,最快的一个
注意点:
1、只要有任意一个子promise成功或失败,就会触发父promise对应的状态,并返回该promise对象
2、第一个promise对象变为Fulfilled之后,并不会取消其他promise对象的执行。只是只有先完成的Promise才会被Promise.race后面的then处理。其它的Promise还是在执行的,只不过是不会进入到promise.race后面的then内。
原型方法:
1. Promise.prototype.then(resolved,rejected)
注意点:
1、then有两个参数,第一个参数是resolved状态的回调函数,第二个参数(可选)是rejected状态的回调函数
2、then返回的是一个新的 promise, 将以回调的返回值来resolve.
2. Promise.prototype.catch()
注意点:
1、添加一个拒绝(rejection) 回调到当前 promise, 返回一个新的promise,因此catch后面还可以接着调用then方法。(catch只是then的语法糖,相当于.then(null, rejection)的别名,用于指定发生错误时的回调函数。)
2、当这个回调函数被调用,新 promise 将以它的返回值来resolve,否则如果当前promise 进入fulfilled状态,则以当前promise的完成结果作为新promise的完成结果.(即如果在resolve后再throw错误,是不会被catch到的,因为状态改变后不可逆)
3、Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。(无论前面有多少个then,它们抛出的错误总是会被下一个catch语句捕获。)
4、当catch前后都有多个then的时候,只有catch前面的then们发生了错误才会进入catch,否则跳过catch,继续执行catch后面的then(只有rejected才会进入catch,否则跳过)
5、catch方法之中,还能再抛出错误。如果只有一个catch,当catch发生错误时,这个错误不会被捕获,也不会传递到外层。如果有多个catch连写,那么下一个catch就会捕获上一个catch的错误。
3. Promise.prototype.finally()
注意点
1、finally() 方法的回调函数不接受任何参数
2、无论成功还是失败都会执行
3 、finally() 方法总是会返回原来的值。
面试有关
1、说说promise规范
2、关于catch的一系列提问,如catch后还能不能再catch到错误,catch后还能不能继续写then,then后面的catch还能不能catch到then的错误之类的。
- 如果异步操作抛出错误,状态就会变为Rejected,就会调用catch方法指定的回调函数,处理这个错误。
- then方法指定的回调函数,如果运行中抛出错误,也会被catch方法捕获。
- 在resolve()后面抛出的错误会被忽略(如果前面已经是执行了resolve,那么后面throw 出来的error不会被catch到)
- catch方法返回的还是一个Promise对象,因此后面还可以接着调用then方法。
- 在异步函数中抛出的错误不会被catch捕获到(例如使用了setTimeout之类的,抛出来的错误不会被catch到)
- 有多个catch连写,如果在catch中继续throw出异常,那么后面的catch就会一直执行,如果不throw异常,则不会执行(catch其实是then的语法糖)
3、 现有4个接口地址/a,/b,/c,/d,需要测试出当中的相应速度(处理完成并返回结果)最快的一个接口的耗时,请写出实现过程,可以使用setTimeout来模拟异步请求。
var promise1 = new Promise(function(resolve, reject) {
setTimeout(resolve, 500, 'one');
});
var promise2 = new Promise(function(resolve, reject) {
setTimeout(resolve, 100, 'two');
});
Promise.race([promise1, promise2]).then(function(value) {
console.log(value);
// Both resolve, but promise2 is faster
});
// expected output: "two"
- setTimeout、async、promise执行顺序是如何的
https://blog.csdn.net/baidu_3...
async 及 await
涉及面试题:async 及 await 的特点,它们的优点和缺点分别是什么?await 原理是什么?
一个函数如果加上 async
,那么该函数就会返回一个 Promise
async function test() {
return "1"
}
console.log(test()) // -> Promise {<resolved>: "1"}
async
就是将函数返回值使用 Promise.resolve()
包裹了下,和 then
中处理返回值一样,并且 await
只能配套 async
使用
async function test() {
let value = await sleep()
}
async
和 await
可以说是异步终极解决方案了,相比直接使用 Promise
来说,优势在于处理 then
的调用链,能够更清晰准确的写出代码,毕竟写一大堆 then
也很恶心,并且也能优雅地解决回调地狱问题。当然也存在一些缺点,因为 await
将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await
会导致性能上的降低。
async function test() {
// 以下代码没有依赖性的话,完全可以使用 Promise.all 的方式
// 如果有依赖性的话,其实就是解决回调地狱的例子了
await fetch(url)
await fetch(url1)
await fetch(url2)
}
下面来看一个使用 await
的例子:
let a = 0
let b = async () => {
a = a + await 10
console.log('2', a) // -> '2' 10
}
b()
a++
console.log('1', a) // -> '1' 1
对于以上代码你可能会有疑惑,让我来解释下原因
- 首先函数
b
先执行,在执行到await 10
之前变量a
还是 0,因为await
内部实现了generator
,generator
会保留堆栈中东西,所以这时候a = 0
被保存了下来 - 因为
await
是异步操作,后来的表达式不返回Promise
的话,就会包装成Promise.reslove(返回值)
,然后会去执行函数外的同步代码 - 同步代码执行完毕后开始执行异步代码,将保存下来的值拿出来使用,这时候
a = 0 + 10
上述解释中提到了 await
内部实现了 generator
,其实 await
就是 generator
加上 Promise
的语法糖,且内部实现了自动执行 generator
。如果你熟悉 co 的话,其实自己就可以实现这样的语法糖。
常用定时器函数
涉及面试题:setTimeout、setInterval、requestAnimationFrame 各有什么特点?
异步编程当然少不了定时器了,常见的定时器函数有 setTimeout
、setInterval
、requestAnimationFrame
。我们先来讲讲最常用的setTimeout
,很多人认为 setTimeout
是延时多久,那就应该是多久后执行。
其实这个观点是错误的,因为 JS 是单线程执行的,如果前面的代码影响了性能,就会导致 setTimeout
不会按期执行。当然了,我们可以通过代码去修正 setTimeout
,从而使定时器相对准确
let period = 60 * 1000 * 60 * 2
let startTime = new Date().getTime()
let count = 0
let end = new Date().getTime() + period
let interval = 1000
let currentInterval = interval
function loop() {
count++
// 代码执行所消耗的时间
let offset = new Date().getTime() - (startTime + count * interval);
let diff = end - new Date().getTime()
let h = Math.floor(diff / (60 * 1000 * 60))
let hdiff = diff % (60 * 1000 * 60)
let m = Math.floor(hdiff / (60 * 1000))
let mdiff = hdiff % (60 * 1000)
let s = mdiff / (1000)
let sCeil = Math.ceil(s)
let sFloor = Math.floor(s)
// 得到下一次循环所消耗的时间
currentInterval = interval - offset
console.log('时:'+h, '分:'+m, '毫秒:'+s, '秒向上取整:'+sCeil, '代码执行时间:'+offset, '下次循环间隔'+currentInterval)
setTimeout(loop, currentInterval)
}
setTimeout(loop, currentInterval)
接下来我们来看 setInterval
,其实这个函数作用和 setTimeout
基本一致,只是该函数是每隔一段时间执行一次回调函数。
通常来说不建议使用 setInterval
。第一,它和 setTimeout
一样,不能保证在预期的时间执行任务。第二,它存在执行累积的问题,请看以下伪代码
function demo() {
setInterval(function(){
console.log(2)
},1000)
sleep(2000)
}
demo()
以上代码在浏览器环境中,如果定时器执行过程中出现了耗时操作,多个回调函数会在耗时操作结束以后同时执行,这样可能就会带来性能上的问题。
如果你有循环定时器的需求,其实完全可以通过 requestAnimationFrame
来实现
function setInterval(callback, interval) {
let timer
const now = Date.now
let startTime = now()
let endTime = startTime
const loop = () => {
timer = window.requestAnimationFrame(loop)
endTime = now()
if (endTime - startTime >= interval) {
startTime = endTime = now()
callback(timer)
}
}
timer = window.requestAnimationFrame(loop)
return timer
}
let a = 0
setInterval(timer => {
console.log(1)
a++
if (a === 3) cancelAnimationFrame(timer)
}, 1000)
首先 requestAnimationFrame
自带函数节流功能,基本可以保证在 16.6 毫秒内只执行一次(不掉帧的情况下),并且该函数的延时效果是精确的,没有其他定时器时间不准的问题,当然你也可以通过该函数来实现 setTimeout
。
防抖
即短时间内大量触发同一事件,只会执行一次函数,防抖常用于搜索框/滚动条的监听事件处理,如果不做防抖,每输入一个字/滚动屏幕,都会触发事件处理,造成性能浪费;实现原理为设置一个定时器,约定在xx毫秒后再触发事件处理,每次触发事件都会重新设置计时器,直到xx毫秒内无第二次操作,。
// func是用户传入需要防抖的函数
// wait是等待时间
const debounce = (func, wait = 50) => {
// 缓存一个定时器id
let timer = 0
// 这里返回的函数是每次用户实际调用的防抖函数
// 如果已经设定过定时器了就清空上一次的定时器
// 开始一个新的定时器,延迟执行用户传入的方法
return function(...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
func.apply(this, args)
}, wait)
}
}
节流
防抖是延迟执行
,而节流是间隔执行
,和防抖的区别在于,防抖每次触发事件都重置定时器,而节流在定时器到时间后再清空定时器,函数节流即每隔一段时间就执行一次
,实现原理为设置一个定时器,约定xx毫秒后执行事件,如果时间到了,那么执行函数并重置定时器
,
// func是用户传入需要防抖的函数
// wait是等待时间
const throttle = (func, wait = 50) => {
// 上一次执行该函数的时间
let lastTime = 0
return function(...args) {
// 当前时间
let now = +new Date()
// 将当前时间和上一次执行函数时间对比
// 如果差值大于设置的等待时间就执行函数
if (now - lastTime > wait) {
lastTime = now
func.apply(this, args)
}
}
}
setInterval(
throttle(() => {
console.log(1)
}, 500),
1
)
5、手写 Promise
在上一章节中我们了解了 Promise
的一些易错点,在这一章节中,我们会通过手写一个符合 Promise/A+ 规范的 Promise
来深入理解它,并且手写 Promise
也是一道大厂常考题,在进入正题之前,推荐各位阅读一下 Promise/A+ 规范,这样才能更好地理解这个章节的代码。
实现一个简易版 Promise
在完成符合 Promise/A+ 规范的代码之前,我们可以先来实现一个简易版 Promise
,因为在面试中,如果你能实现出一个简易版的 Promise
基本可以过关了。
那么我们先来搭建构建函数的大体框架
const PENDING = 'pending'
const RESOLVED = 'resolved'
const REJECTED = 'rejected'
function MyPromise(fn) {
const that = this
that.state = PENDING
that.value = null
that.resolvedCallbacks = []
that.rejectedCallbacks = []
// 待完善 resolve 和 reject 函数
// 待完善执行 fn 函数
}
- 首先我们创建了三个常量用于表示状态,对于经常使用的一些值都应该通过常量来管理,便于开发及后期维护
- 在函数体内部首先创建了常量
that
,因为代码可能会异步执行,用于获取正确的this
对象 - 一开始
Promise
的状态应该是pending
value
变量用于保存resolve
或者reject
中传入的值resolvedCallbacks
和rejectedCallbacks
用于保存then
中的回调,因为当执行完Promise
时状态可能还是等待中,这时候应该把then
中的回调保存起来用于状态改变时使用
接下来我们来完善 resolve
和 reject
函数,添加在 MyPromise
函数体内部
function resolve(value) {
if (that.state === PENDING) {
that.state = RESOLVED
that.value = value
that.resolvedCallbacks.map(cb => cb(that.value))
}
}
function reject(value) {
if (that.state === PENDING) {
that.state = REJECTED
that.value = value
that.rejectedCallbacks.map(cb => cb(that.value))
}
}
这两个函数代码类似,就一起解析了
- 首先两个函数都得判断当前状态是否为等待中,因为规范规定只有等待态才可以改变状态
- 将当前状态更改为对应状态,并且将传入的值赋值给
value
- 遍历回调数组并执行
完成以上两个函数以后,我们就该实现如何执行 Promise
中传入的函数了
try {
fn(resolve, reject)
} catch (e) {
reject(e)
}
- 实现很简单,执行传入的参数并且将之前两个函数当做参数传进去
- 要注意的是,可能执行函数过程中会遇到错误,需要捕获错误并且执行
reject
函数
最后我们来实现较为复杂的 then
函数
MyPromise.prototype.then = function(onFulfilled, onRejected) {
const that = this
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v
onRejected =
typeof onRejected === 'function'
? onRejected
: r => {
throw r
}
if (that.state === PENDING) {
that.resolvedCallbacks.push(onFulfilled)
that.rejectedCallbacks.push(onRejected)
}
if (that.state === RESOLVED) {
onFulfilled(that.value)
}
if (that.state === REJECTED) {
onRejected(that.value)
}
}
- 首先判断两个参数是否为函数类型,因为这两个参数是可选参数
当参数不是函数类型时,需要创建一个函数赋值给对应的参数,同时也实现了透传,比如如下代码
// 该代码目前在简单版中会报错 // 只是作为一个透传的例子 Promise.resolve(4).then().then((value) => console.log(value))
接下来就是一系列判断状态的逻辑,当状态不是等待态时,就去执行相对应的函数。如果状态是等待态的话,就往回调函数中
push
函数,比如如下代码就会进入等待态的逻辑new MyPromise((resolve, reject) => { setTimeout(() => { resolve(1) }, 0) }).then(value => { console.log(value) })
以上就是简单版 Promise
实现,接下来一小节是实现完整版 Promise
的解析,相信看完完整版的你,一定会对于 Promise
的理解更上一层楼。
实现一个符合 Promise/A+ 规范的 Promise
这小节代码需要大家配合规范阅读,因为大部分代码都是根据规范去实现的。
我们先来改造一下 resolve
和 reject
函数
function resolve(value) {
if (value instanceof MyPromise) {
return value.then(resolve, reject)
}
setTimeout(() => {
if (that.state === PENDING) {
that.state = RESOLVED
that.value = value
that.resolvedCallbacks.map(cb => cb(that.value))
}
}, 0)
}
function reject(value) {
setTimeout(() => {
if (that.state === PENDING) {
that.state = REJECTED
that.value = value
that.rejectedCallbacks.map(cb => cb(that.value))
}
}, 0)
}
- 对于
resolve
函数来说,首先需要判断传入的值是否为Promise
类型 - 为了保证函数执行顺序,需要将两个函数体代码使用
setTimeout
包裹起来
接下来继续改造 then
函数中的代码,首先我们需要新增一个变量 promise2
,因为每个 then
函数都需要返回一个新的 Promise
对象,该变量用于保存新的返回对象,然后我们先来改造判断等待态的逻辑
if (that.state === PENDING) {
return (promise2 = new MyPromise((resolve, reject) => {
that.resolvedCallbacks.push(() => {
try {
const x = onFulfilled(that.value)
resolutionProcedure(promise2, x, resolve, reject)
} catch (r) {
reject(r)
}
})
that.rejectedCallbacks.push(() => {
try {
const x = onRejected(that.value)
resolutionProcedure(promise2, x, resolve, reject)
} catch (r) {
reject(r)
}
})
}))
}
- 首先我们返回了一个新的
Promise
对象,并在Promise
中传入了一个函数 - 函数的基本逻辑还是和之前一样,往回调数组中
push
函数 - 同样,在执行函数的过程中可能会遇到错误,所以使用了
try...catch
包裹 - 规范规定,执行
onFulfilled
或者onRejected
函数时会返回一个x
,并且执行Promise
解决过程,这是为了不同的Promise
都可以兼容使用,比如 JQuery 的Promise
能兼容 ES6 的Promise
接下来我们改造判断执行态的逻辑
if (that.state === RESOLVED) {
return (promise2 = new MyPromise((resolve, reject) => {
setTimeout(() => {
try {
const x = onFulfilled(that.value)
resolutionProcedure(promise2, x, resolve, reject)
} catch (reason) {
reject(reason)
}
})
}))
}
- 其实大家可以发现这段代码和判断等待态的逻辑基本一致,无非是传入的函数的函数体需要异步执行,这也是规范规定的
- 对于判断拒绝态的逻辑这里就不一一赘述了,留给大家自己完成这个作业
最后,当然也是最难的一部分,也就是实现兼容多种 Promise
的 resolutionProcedure
函数
function resolutionProcedure(promise2, x, resolve, reject) {
if (promise2 === x) {
return reject(new TypeError('Error'))
}
}
首先规范规定了 x
不能与 promise2
相等,这样会发生循环引用的问题,比如如下代码
let p = new MyPromise((resolve, reject) => {
resolve(1)
})
let p1 = p.then(value => {
return p1
})
然后需要判断 x
的类型
if (x instanceof MyPromise) {
x.then(function(value) {
resolutionProcedure(promise2, value, resolve, reject)
}, reject)
}
这里的代码是完全按照规范实现的。如果 x
为 Promise
的话,需要判断以下几个情况:
- 如果
x
处于等待态,Promise
需保持为等待态直至x
被执行或拒绝 - 如果
x
处于其他状态,则用相同的值处理Promise
当然以上这些是规范需要我们判断的情况,实际上我们不判断状态也是可行的。
接下来我们继续按照规范来实现剩余的代码
let called = false
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
try {
let then = x.then
if (typeof then === 'function') {
then.call(
x,
y => {
if (called) return
called = true
resolutionProcedure(promise2, y, resolve, reject)
},
e => {
if (called) return
called = true
reject(e)
}
)
} else {
resolve(x)
}
} catch (e) {
if (called) return
called = true
reject(e)
}
} else {
resolve(x)
}
- 首先创建一个变量
called
用于判断是否已经调用过函数 - 然后判断
x
是否为对象或者函数,如果都不是的话,将x
传入resolve
中 - 如果
x
是对象或者函数的话,先把x.then
赋值给then
,然后判断then
的类型,如果不是函数类型的话,就将x
传入resolve
中 - 如果
then
是函数类型的话,就将x
作为函数的作用域this
调用之,并且传递两个回调函数作为参数,第一个参数叫做resolvePromise
,第二个参数叫做rejectPromise
,两个回调函数都需要判断是否已经执行过函数,然后进行相应的逻辑 - 以上代码在执行的过程中如果抛错了,将错误传入
reject
函数中
以上就是符合 Promise/A+ 规范的实现了,如果你对于这部分代码尚有疑问,欢迎在评论中与我互动。
6、Event Loop
在前两章节中我们了解了 JS 异步相关的知识。在实践的过程中,你是否遇到过以下场景,为什么 setTimeout
会比 Promise
后执行,明明代码写在 Promise
之前。这其实涉及到了 Event Loop 相关的知识,这一章节我们会来详细地了解 Event Loop 相关知识,知道 JS 异步运行代码的原理,并且这一章节也是面试常考知识点。
进程与线程
涉及面试题:进程与线程区别?JS 单线程带来的好处?
相信大家经常会听到 JS 是单线程执行的,但是你是否疑惑过什么是线程?
(1)进程
进程是程序的一次执行过程,是一个动态概念,是程序在执行过程中分配和管理资源的基本单位,每一个进程都有一个自己的地址空间,至少有 5 种基本状态,它们是:初始态,执行态,等待状态,就绪状态,终止状态。
(2)线程
线程是CPU调度和分派的基本单位,它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
(3)联系
线程是进程的一部分,一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。
(4)区别:理解它们的差别,我从资源使用的角度出发。(所谓的资源就是计算机里的中央处理器,内存,文件,网络等等)
根本区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位
在开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。
所处环境:在操作系统中能同时运行多个进程(程序);而在同一个进程(程序)中有多个线程同时执行(通过CPU调度,在每个时间片中只有一个线程执行)
内存分配方面:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源。
包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
把这些概念拿到浏览器中来说,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。
上文说到了 JS 引擎线程和渲染线程,大家应该都知道,在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这其中的原因是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。这其实也是一个单线程的好处,得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间,没有锁的问题的好处。当然前面两点在服务端中更容易体现,对于锁的问题,形象的来说就是当我读取一个数字 15 的时候,同时有两个操作对数字进行了加减,这时候结果就出现了错误。解决这个问题也不难,只需要在读取的时候加锁,直到读取完毕之前都不能进行写入操作。
执行栈
涉及面试题:什么是执行栈?
可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。
执行栈可视化
当开始执行 JS 代码时,首先会执行一个 main
函数,然后执行我们的代码。根据先进后出的原则,后执行的函数会先弹出栈,在图中我们也可以发现,foo
函数后执行,当执行完毕后就从栈中弹出了。
平时在开发中,大家也可以在报错中找到执行栈的痕迹
function foo() {
throw new Error('error')
}
function bar() {
foo()
}
bar()
函数执行顺序
大家可以在上图清晰的看到报错在 foo
函数,foo
函数又是在 bar
函数中调用的。
当我们使用递归的时候,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题
function bar() {
bar()
}
bar()
爆栈
浏览器中的 Event Loop
涉及面试题:异步代码执行顺序?解释一下什么是 Event Loop ?
当我们执行 JS 代码的时候其实就是往执行栈中放入函数,其实当遇到异步的代码时,会被挂起并在需要执行的时候加入到 Task(有多种 Task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。
事件循环
不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs
,macrotask 称为 task
。下面来看以下代码的执行顺序:
所以 Event Loop 执行顺序如下所示:
- 首先执行同步代码,这属于宏任务
- 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
- 执行所有微任务
- 当执行完所有微任务后,如有必要会渲染页面
- 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是
setTimeout
中的回调函数
所以以上代码虽然 setTimeout
写在 Promise
之前,但是因为 Promise
属于微任务而 setTimeout
属于宏任务,所以会有以上的打印。
微任务包括 process.nextTick
,promise
,MutationObserver
。
宏任务包括 script
, setTimeout
,setInterval
,setImmediate
,I/O
,UI rendering
。
这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script
,浏览器会先执行一个宏任务,接下来有异步代码的话才会先执行微任务。
Node 中的 Event Loop
涉及面试题:Node 中的 Event Loop 和浏览器中的有什么区别?process.nexttick 执行顺序?
Node 中的 Event Loop 和浏览器中的是完全不相同的东西。
Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
timer
timers 阶段会执行 setTimeout
和 setInterval
回调,并且是由 poll 阶段控制的。
同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。
I/O
I/O 阶段会处理一些上一轮循环中的少数未执行的 I/O 回调
idle, prepare
idle, prepare 阶段内部实现,这里就忽略不讲了。
poll
poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情
- 回到 timer 阶段执行回调
- 执行 I/O 回调
并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情
- 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
如果 poll 队列为空时,会有两件事发生
- 如果有
setImmediate
回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调 - 如果没有
setImmediate
回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去
- 如果有
当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。
check
check 阶段执行 setImmediate
close callbacks
close callbacks 阶段执行 close 事件
在以上的内容中,我们了解了 Node 中的 Event Loop 的执行顺序,接下来我们将会通过代码的方式来深入理解这块内容。
首先在有些情况下,定时器的执行顺序其实是随机的
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
对于以上代码来说,setTimeout
可能执行在前,也可能执行在后
- 首先
setTimeout(fn, 0) === setTimeout(fn, 1)
,这是由源码决定的 - 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行
setTimeout
回调 - 那么如果准备时间花费小于 1ms,那么就是
setImmediate
回调先执行了
当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
在上述代码中,setImmediate
永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate
回调,所以就直接跳转到 check 阶段去执行回调了。
上面介绍的都是 macrotask 的执行情况,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask
setTimeout(() => {
console.log('timer21')
}, 0)
Promise.resolve().then(function() {
console.log('promise1')
})
对于以上代码来说,其实和浏览器中的输出是一样的,microtask 永远执行在 macrotask 前面。
最后我们来讲讲 Node 中的 process.nextTick
,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
对于以上代码,大家可以发现无论如何,永远都是先把 nextTick 全部打印出来。
7、JS 进阶知识点及常考面试题
在这一章节中,我们将会学习到一些原理相关的知识,不会解释涉及到的知识点的作用及用法,如果大家对于这些内容还不怎么熟悉,推荐先去学习相关的知识点内容再来学习原理知识。
手写 call、apply 及 bind 函数
涉及面试题:call、apply 及 bind 函数内部实现是怎么样的?
首先从以下几点来考虑如何实现这几个函数
- 不传入第一个参数,那么上下文默认为
window
- 改变了
this
指向,让新的对象可以执行该函数,并能接受参数
那么我们先来实现 call
Function.prototype.myCall = function(context) {
if (typeof this !== 'function') {
throw new TypeError('Error')
}
context = context || window
context.fn = this
const args = [...arguments].slice(1)
const result = context.fn(...args)
delete context.fn
return result
}
以下是对实现的分析:
- 首先
context
为可选参数,如果不传的话默认上下文为window
- 接下来给
context
创建一个fn
属性,并将值设置为需要调用的函数 - 因为
call
可以传入多个参数作为调用函数的参数,所以需要将参数剥离出来 - 然后调用函数并将对象上的函数删除
以上就是实现 call
的思路,apply
的实现也类似,区别在于对参数的处理,所以就不一一分析思路了
Function.prototype.myApply = function(context) {
if (typeof this !== 'function') {
throw new TypeError('Error')
}
context = context || window
context.fn = this
let result
// 处理参数和 call 有区别
if (arguments[1]) {
result = context.fn(...arguments[1])
} else {
result = context.fn()
}
delete context.fn
return result
}
bind
的实现对比其他两个函数略微地复杂了一点,因为 bind
需要返回一个函数,需要判断一些边界问题,以下是 bind
的实现
Function.prototype.myBind = function (context) {
if (typeof this !== 'function') {
throw new TypeError('Error')
}
const _this = this
const args = [...arguments].slice(1)
// 返回一个函数
return function F() {
// 因为返回了一个函数,我们可以 new F(),所以需要判断
if (this instanceof F) {
return new _this(...args, ...arguments)
}
return _this.apply(context, args.concat(...arguments))
}
}
以下是对实现的分析:
- 前几步和之前的实现差不多,就不赘述了
bind
返回了一个函数,对于函数来说有两种方式调用,一种是直接调用,一种是通过new
的方式,我们先来说直接调用的方式- 对于直接调用来说,这里选择了
apply
的方式实现,但是对于参数需要注意以下情况:因为bind
可以实现类似这样的代码f.bind(obj, 1)(2)
,所以我们需要将两边的参数拼接起来,于是就有了这样的实现args.concat(...arguments)
- 最后来说通过
new
的方式,在之前的章节中我们学习过如何判断this
,对于new
的情况来说,不会被任何方式改变this
,所以对于这种情况我们需要忽略传入的this
new
涉及面试题:new 的原理是什么?通过 new 的方式创建对象和通过字面量创建有什么区别?
首先我们要知道
new
做了什么1.创建一个新对象,并继承其构造函数的
prototype
,这一步是为了继承构造函数原型上的属性和方法2.执行构造函数,方法内的
this
被指定为该新实例,这一步是为了执行构造函数内的赋值操作3.返回新实例(规范规定,如果构造方法返回了一个对象,那么返回该对象,否则返回第一步创建的新对象)
在调用 new
的过程中会发生以上四件事情:
- 新生成了一个对象
- 链接到原型
- 绑定 this
- 返回新对象
根据以上几个过程,我们也可以试着来自己实现一个 new
function create() {
let obj = {}
let Con = [].shift.call(arguments)
obj.__proto__ = Con.prototype
let result = Con.apply(obj, arguments)
return result instanceof Object ? result : obj
}
以下是对实现的分析:
- 创建一个空对象
- 获取构造函数
- 设置空对象的原型
- 绑定
this
并执行构造函数 - 确保返回值为对象
对于对象来说,其实都是通过 new
产生的,无论是 function Foo()
还是 let a = { b : 1 }
。
对于创建一个对象来说,更推荐使用字面量的方式创建对象(无论性能上还是可读性)。因为你使用 new Object()
的方式创建对象需要通过作用域链一层层找到 Object
,但是你使用字面量的方式就没这个问题。
function Foo() {}
// function 就是个语法糖
// 内部等同于 new Function()
let a = { b: 1 }
// 这个字面量内部也是使用了 new Object()
instanceof 的原理
涉及面试题:instanceof 的原理是什么?
instanceof
可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的 prototype
。
我们也可以试着实现一下 instanceof
function myInstanceof(left, right) {
let prototype = right.prototype
left = left.__proto__
while (true) {
if (left === null || left === undefined)
return false
if (prototype === left)
return true
left = left.__proto__
}
}
以下是对实现的分析:
- 首先获取类型的原型
- 然后获得对象的原型
- 然后一直循环判断对象的原型是否等于类型的原型,直到对象原型为
null
,因为原型链最终为null
为什么 0.1 + 0.2 != 0.3
涉及面试题:为什么 0.1 + 0.2 != 0.3?如何解决这个问题?
先说原因,因为 JS 采用 IEEE 754 双精度版本(64位),并且只要采用 IEEE 754 的语言都有该问题。
我们都知道计算机是通过二进制来存储东西的,那么 0.1
在二进制中会表示为
// (0011) 表示循环
0.1 = 2^-4 * 1.10011(0011)
我们可以发现,0.1
在二进制中是无限循环的一些数字,其实不只是 0.1
,其实很多十进制小数用二进制表示都是无限循环的。这样其实没什么问题,但是 JS 采用的浮点数标准却会裁剪掉我们的数字。
IEEE 754 双精度版本(64位)将 64 位分为了三段
- 第一位用来表示符号
- 接下去的 11 位用来表示指数
- 其他的位数用来表示有效位,也就是用二进制表示
0.1
中的10011(0011)
那么这些循环的数字被裁剪了,就会出现精度丢失的问题,也就造成了 0.1
不再是 0.1
了,而是变成了 0.100000000000000002
0.100000000000000002 === 0.1 // true
那么同样的,0.2
在二进制也是无限循环的,被裁剪后也失去了精度变成了 0.200000000000000002
0.200000000000000002 === 0.2 // true
所以这两者相加不等于 0.3
而是 0.300000000000000004
0.1 + 0.2 === 0.30000000000000004 // true
那么可能你又会有一个疑问,既然 0.1
不是 0.1
,那为什么 console.log(0.1)
却是正确的呢?
因为在输入内容的时候,二进制被转换为了十进制,十进制又被转换为了字符串,在这个转换的过程中发生了取近似值的过程,所以打印出来的其实是一个近似值,你也可以通过以下代码来验证
console.log(0.100000000000000002) // 0.1
那么说完了为什么,最后来说说怎么解决这个问题吧。其实解决的办法有很多,这里我们选用原生提供的方式来最简单的解决问题
parseFloat((0.1 + 0.2).toFixed(10)) === 0.3 // true
垃圾回收机制
涉及面试题:V8 下的垃圾回收机制是怎么样的?
(1)垃圾回收概述
垃圾回收机制(GC:Garbage Collection),执行环境负责管理代码执行过程中使用的内存。垃圾收集器会定期(周期性)找出那些不在继续使用的变量,然后释放其内存。但是这个过程不是实时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行。
(2)垃圾回收策略:
2种最为常用:标记清除和引用计数,其中标记清除更为常用。
标记清除(mark-and-sweep):
当变量进入作用域时,进行标记,对于脱离作用域的变量进行标记并回收。到目前为止,IE、Firefox、Opera、Chrome、Safari的js实现使用的都是标记清除的垃圾回收策略或类似的策略,只不过垃圾收集的时间间隔互不相同。
当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占的内存,因为只要执行流进入相应的环境,就可能用到它们。而当变量离开环境时,这将其 标记为“离开环境”。
引用计数:引用计数是跟踪记录每个值被引用的次数。就是变量的引用次数,被引用一次则加1,当这个引用计数为0时,被视为准备回收的对象,每当过一段时间开始垃圾回收的时候,就把被引用数为0的变量回收。引用计数方法可能导致循环引用,类似死锁,导致内存泄露。
在这个例子中,objA和objB通过各自的属性相互引用;也就是说这两个对象的引用次数都是2。在采用引用计数的策略中,由于函数执行之后,这两个对象都离开了作用域,函数执行完成之后,objA和objB还将会继续存在,因为他们的引用次数永远不会是0。这样的相互引用如果说很大量的存在就会导致大量的内存泄露。
(3)内存泄漏的原因
1、全局变量引起的内存泄露
2、闭包引起的内存泄露:慎用闭包
3、dom清空或删除时,事件未清除导致的内存泄漏
3、循环引用带来的内存泄露
(4)如何减少垃圾回收开销
由于每次的垃圾回收开销都相对较大,并且由于机制的一些不完善的地方,可能会导致内存泄露,我们可以利用一些方法减少垃圾回收,并且尽量避免循环引用。
在对象结束使用后 ,令obj = null。这样利于解除循环引用,使得无用变量及时被回收;
js中开辟空间的操作有new(), [ ], { }, function (){..}。最大限度的实现对象的重用;
慎用闭包。闭包容易引起内存泄露。本来在函数返回之后,之前的空间都会被回收。但是由于闭包可能保存着函数内部变量的引用,且闭包在外部环境,就会导致函数内部的变量不能够销毁。
(5)垃圾回收的缺陷
和其他语言一样,javascript的GC策略也无法避免一个问题:GC时,停止响应其他操作,这是为了安全考虑。而Javascript的GC在100ms甚至以上,对一般的应用还好,但对于JS游戏,动画对连贯性要求比较高的应用,就麻烦了。这就是新引擎需要优化的点:避免GC造成的长时间停止响应。
(6)优化GC
分代回收(Generation GC):与Java回收策略思想是一致的。目的是通过区分“临时”与“持久”对象;多回收“临时对象”区(young generation),少回收“持久对象”区(tenured generation),减少每次需遍历的对象,从而减少每次GC的耗时。
增量GC:这个方案的思想很简单,就是“每次处理一点,下次再处理一点,如此类推。
新生代算法
新生代中的对象一般存活时间较短,使用 Scavenge GC 算法。
在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了。
老生代算法
老生代中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除算法和标记压缩算法。
在讲算法前,先来说下什么情况下对象会出现在老生代空间中:
- 新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。
- To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。
老生代中的空间很复杂,有如下几个空间
enum AllocationSpace {
// TODO(v8:7464): Actually map this space's memory as read-only.
RO_SPACE, // 不变的对象空间
NEW_SPACE, // 新生代用于 GC 复制算法的空间
OLD_SPACE, // 老生代常驻对象空间
CODE_SPACE, // 老生代代码对象空间
MAP_SPACE, // 老生代 map 对象
LO_SPACE, // 老生代大空间对象
NEW_LO_SPACE, // 新生代大空间对象
FIRST_SPACE = RO_SPACE,
LAST_SPACE = NEW_LO_SPACE,
FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};
在老生代中,以下情况会先启动标记清除算法:
- 某一个空间没有分块的时候
- 空间中被对象超过一定限制
- 空间不能保证新生代中的对象移动到老生代中
在这个阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题。为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行,你可以点击 该博客 详细阅读。
清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。
8、浏览器基础知识点及常考面试
这一章节我们将会来学习浏览器的一些基础知识点,包括:事件机制、跨域、存储相关,这几个知识点也是面试经常会考到的内容。
事件机制
涉及面试题:事件的触发过程是怎么样的?知道什么是事件代理嘛?
事件触发三阶段
事件触发有三个阶段:
window
往事件触发处传播,遇到注册的捕获事件会触发- 传播到事件触发处时触发注册的事件
- 从事件触发处往
window
传播,遇到注册的冒泡事件会触发
事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个 body
中的子节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行。
// 以下会先打印冒泡然后是捕获
node.addEventListener(
'click',
event => {
console.log('冒泡')
},
false
)
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
注册事件
通常我们使用 addEventListener
注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值 useCapture
参数来说,该参数默认值为 false
,useCapture
决定了注册的事件是捕获事件还是冒泡事件。对于对象参数来说,可以使用以下几个属性
capture
:布尔值,和useCapture
作用一样once
:布尔值,值为true
表示该回调只会调用一次,调用后会移除监听passive
:布尔值,表示永远不会调用preventDefault
一般来说,如果我们只希望事件只触发在目标上,这时候可以使用 stopPropagation
来阻止事件的进一步传播。通常我们认为 stopPropagation
是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。stopImmediatePropagation
同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件。
node.addEventListener(
'click',
event => {
event.stopImmediatePropagation()
console.log('冒泡')
},
false
)
// 点击 node 只会执行上面的函数,该函数不会执行
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
事件代理
如果一个节点中的子节点是动态生成的,那么子节点需要注册事件的话应该注册在父节点上
<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>
事件代理的方式相较于直接给目标注册事件来说,有以下优点:
- 节省内存
- 不需要给子节点注销事件
跨域
涉及面试题:什么是跨域?为什么浏览器要使用同源策略?你有几种方式可以解决跨域问题?了解预检请求嘛?
因为浏览器出于安全考虑,有同源策略。也就是说,如果协议、域名或者端口有一个不同就是跨域,Ajax 请求会失败。
那么是出于什么安全考虑才会引入这种机制呢? 其实主要是用来防止 CSRF 攻击的。简单点说,CSRF 攻击是利用用户的登录态发起恶意请求。
也就是说,没有同源策略的情况下,A 网站可以被任意其他来源的 Ajax 访问到内容。如果你当前 A 网站还存在登录态,那么对方就可以通过 Ajax 获得你的任何信息。当然跨域并不能完全阻止 CSRF。
然后我们来考虑一个问题,请求跨域了,那么请求到底发出去没有? 请求必然是发出去了,但是浏览器拦截了响应。你可能会疑问明明通过表单的方式可以发起跨域请求,为什么 Ajax 就不会。因为归根结底,跨域是为了阻止用户读取到另一个域名下的内容,Ajax 可以获取响应,浏览器认为这不安全,所以拦截了响应。但是表单并不会获取新的内容,所以可以发起跨域请求。同时也说明了跨域并不能完全阻止 CSRF,因为请求毕竟是发出去了。
接下来我们将来学习几种常见的方式来解决跨域的问题。
1、JSONP
JSONP 的原理很简单,就是利用 标签没有跨域限制的漏洞。通过
标签指向一个需要访问的地址并提供一个回调函数来接收数据当需要通讯时。
<script src="http://domain/api?param1=a¶m2=b&callback=jsonp"></script>
<script>
function jsonp(data) {
console.log(data)
}
</script>
JSONP 使用简单且兼容性不错,但是只限于 get
请求。
在开发中可能会遇到多个 JSONP 请求的回调函数名是相同的,这时候就需要自己封装一个 JSONP,以下是简单实现
function jsonp(url, jsonpCallback, success) {
let script = document.createElement('script')
script.src = url
script.async = true
script.type = 'text/javascript'
window[jsonpCallback] = function(data) {
success && success(data)
}
document.body.appendChild(script)
}
jsonp('http://xxx', 'callback', function(value) {
console.log(value)
})
2、CORS
CORS 需要浏览器和后端同时支持。IE 8 和 9 需要通过 XDomainRequest
来实现。
浏览器会自动进行 CORS 通信,实现 CORS 通信的关键是后端。只要后端实现了 CORS,就实现了跨域。
服务端设置 Access-Control-Allow-Origin
就可以开启 CORS。 该属性表示哪些域名可以访问资源,如果设置通配符则表示所有网站都可以访问资源。
虽然设置 CORS 和前端没什么关系,但是通过这种方式解决跨域问题的话,会在发送请求时出现两种情况,分别为简单请求和复杂请求。
另外,如果面试官问:“CORS为什么支持跨域的通信?”
答案:跨域时,浏览器会拦截Ajax请求,并在http头中加Origin。
简单请求
以 Ajax 为例,当满足以下条件时,会触发简单请求
使用下列方法之一:
GET
HEAD
POST
Content-Type
的值仅限于下列三者之一:text/plain
multipart/form-data
application/x-www-form-urlencoded
请求中的任意 XMLHttpRequestUpload
对象均没有注册任何事件监听器; XMLHttpRequestUpload
对象可以使用 XMLHttpRequest.upload
属性访问。
复杂请求
那么很显然,不符合以上条件的请求就肯定是复杂请求了。
对于复杂请求来说,首先会发起一个预检请求,该请求是 option
方法的,通过该请求来知道服务端是否允许跨域请求。
对于预检请求来说,如果你使用过 Node 来设置 CORS 的话,可能会遇到过这么一个坑。
以下以 express 框架举例:
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Methods', 'PUT, GET, POST, DELETE, OPTIONS')
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept, Authorization, Access-Control-Allow-Credentials'
)
next()
})
该请求会验证你的 Authorization
字段,没有的话就会报错。
当前端发起了复杂请求后,你会发现就算你代码是正确的,返回结果也永远是报错的。因为预检请求也会进入回调中,也会触发 next
方法,因为预检请求并不包含 Authorization
字段,所以服务端会报错。
想解决这个问题很简单,只需要在回调中过滤 option
方法即可
res.statusCode = 204
res.setHeader('Content-Length', '0')
res.end()
3、document.domain
该方式只能用于二级域名相同的情况下,比如 a.test.com
和 b.test.com
适用于该方式。
只需要给页面添加 document.domain = 'test.com'
表示二级域名都相同就可以实现跨域
4、postMessage
H5中新增的postMessage()方法,可以用来做跨域通信。既然是H5中新增的,那就一定要提到。
场景:窗口 A (http:A.com
)向跨域的窗口 B (http:B.com
)发送信息。步骤如下。
(1)在A窗口中操作如下:向B窗口发送数据:
// 窗口A(http:A.com)向跨域的窗口B(http:B.com)发送信息
Bwindow.postMessage('data', 'http://B.com'); //这里强调的是B窗口里的window对象
(2)在B窗口中操作如下:
// 在窗口B中监听 message 事件
Awindow.addEventListener('message', function (event) { //这里强调的是A窗口里的window对象
console.log(event.origin); //获取 :url。这里指:http://A.com
console.log(event.source); //获取:A window对象
console.log(event.data); //获取传过来的数据
}, false);
这种方式通常用于获取嵌入页面中的第三方页面数据。一个页面发送消息,另一个页面判断来源并接收消息
// 发送消息端
window.parent.postMessage('message', 'http://test.com')
// 接收消息端
var mc = new MessageChannel()
mc.addEventListener('message', event => {
var origin = event.origin || event.originalEvent.origin
if (origin === 'http://test.com') {
console.log('验证通过')
}
})
5、Hash
url的#
后面的内容就叫Hash。Hash的改变,页面不会刷新。这就是用 Hash 做跨域通信的基本原理。
补充:url的?
后面的内容叫Search。Search的改变,会导致页面刷新,因此不能做跨域通信。
使用举例:
场景:我的页面 A 通过iframe或frame嵌入了跨域的页面 B。
现在,我这个A页面想给B页面发消息,怎么操作呢?
(1)首先,在我的A页面中:
//伪代码
var B = document.getElementsByTagName('iframe');
B.src = B.src + '#' + 'jsonString'; //我们可以把JS 对象,通过 JSON.stringify()方法转成 json字符串,发给 B
(2)然后,在B页面中:
// B中的伪代码
window.onhashchange = function () { //通过onhashchange方法监听,url中的 hash 是否发生变化
var data = window.location.hash;
};
6、websocket
全双工(full-duplex)通信自然可以实现多个标签页之间的通信,
相信网上通过websocket实现聊天室的教程也不少(用来实现双向通信,客户端和服务端实时通信)
//初始化一个node项目:node init,一路确认就可以,文件夹会自动创建一个package.json文件
监听
//获得WebSocketServerr类型
var WebSocketServer = require(‘ws’).Server;
//创建WebSocketServer对象实例,监听指定端口
var wss = new WebSocketServer({ port:8080 });
//创建保存所有已连接到服务器的客户端对象的数组
var clients=[];
//为服务器添加connection事件监听,当有客户端连接到服务端时,立刻将客户端对象保存进数组中
wss.on('connection', function (client) {
console.log("一个客户端连接到服务器")
if(clients.indexOf(client)===-1){//如果是首次连接
clients.push(client) //就将当前连接保存到数组备用
console.log("有"+clients.length+"客户端在线")
//为每个client对象绑定message事件,当某个客户端发来消息时,自动触发
client.on('message',function(msg){
console.log('收到消息'+msg)
//遍历clients数组中每个其他客户端对象,并发送消息给其他客户端
for(var c of clients){
if(c!=client){//把消息发给别人
c.send(msg);
}
}
})
}
})
a页面发送信息
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<!-- 这个页面是用来发送信息的 -->
<input type="text" id="msg">
<button id="send">发送</button>
<script>
//建立到服务端webSoket连接
var ws=new WebSocket("ws://localhost:8080")
send.onclick=function(){
if(msg.value.trim()!=''){//如果msg输入框内容不是空的
ws.send(msg.value.trim()) //将msg输入框中的内容发送给服务器
}
}
</script>
</body>
</html>
接收a页面发送信息
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<!-- 这个标签页是用来接收信息的 -->
<h1 >收到的消息:<p id="recMsg"></p></h1>
<script>
//建立到服务端webSoket连接
var ws=new WebSocket("ws://localhost:8080")
//当连接被打开时,注册接收消息的处理函数
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<!-- 这个标签页是用来接收信息的 -->
<h1 >收到的消息:<p id="recMsg"></p></h1>
<script>
//建立到服务端webSoket连接
var ws=new WebSocket("ws://localhost:8080")
//当连接被打开时,注册接收消息的处理函数
ws.onopen=function(event) {
//当有消息发过来时,就将消息放到显示元素上
ws.onmessage=function(event) {
recMsg.innerHTML=event.data;
}
}
</script>
</body>
</html>
7、localstorage
localstorage是浏览器多个标签共用的存储空间,所以可以用来实现多标签之间的通信
(ps:session是会话级的存储空间,每个标签页都是单独的)。
直接在window对象上添加监听即可:
window.onstorage = (e) => {console.log(e)}
// 或者这样
window.addEventListener(‘storage’, (e) => console.log(e))
onstorage以及storage事件,针对都是非当前页面对localStorage进行修改时才会触发,
当前页面修改localStorage不会触发监听函数。然后就是在对原有的数据的值进行修改时才会触发,
比如原本已经有一个key会a值为b的localStorage,
你再执行:localStorage.setItem(‘a’, ‘b’)代码,同样是不会触发监听函数的。
localStorage、sessionStorage、cookie、indexDB
涉及面试题:有几种方式可以实现存储功能,分别有什么优缺点?什么是 Service Worker?
我们先来通过表格学习下这几种存储方式的区别
特性 | cookie | localStorage | sessionStorage | indexDB |
---|---|---|---|---|
数据生命周期 | 一般由服务器生成,可以设置过期时间 | 除非被清理,否则一直存在 | 页面关闭就清理 | 除非被清理,否则一直存在 |
数据存储大小 | 4K | 5M | 5M | 无限 |
与服务端通信 | 每次都会携带在 header 中,对于请求性能影响 | 不参与 | 不参与 | 不参与 |
从上表可以看到,cookie
已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStorage
和 sessionStorage
。对于不怎么改变的数据尽量使用 localStorage
存储,否则可以用 sessionStorage
存储。
对于 cookie
来说,我们还需要注意安全性。
属性 | 作用 |
---|---|
value | 如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识 |
http-only | 不能通过 JS 访问 Cookie,减少 XSS 攻击 |
secure | 只能在协议为 HTTPS 的请求中携带 |
same-site | 规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击 |
Html5中本地存储概念是什么,优点,与cookie有什么区别
html5中的Web Storage包括了两种存储方式:sessionStorage和localStorage。
sessionStorage用于本地存储—个会话(session)中的数据,这些数据只有在同—个会话中的页面才能访问并且当会话结束后数据也随之销毁。因此sessionStorage不是—种持久化的本地存储,仅仅是会话级别的存储。
而localStorage用于持久化的本地存储,除非主动删除数据,否则数据是永远不会过期的;
cookie是网站为了标示用户身份而储存在用户本地终端(Client Side)上的数据(通常经过加密)。window.localStorage.removeItem('key')
区别:
1、 cookie数据始终在同源的http请求中携带(即使不需要),即cookie在浏览器和服务器间来回传递。
而sessionStorage和localStorage不会自动把数据发给服务器,仅在本地保存。
cookie数据还有路径(path)的概念,可以限制cookie只属于某个路径下。
2、 存储大小限制也不同,
cookie数据不能超过4k,同时因为每次http请求都会携带cookie,所以cookie只适合保存很小的数据,如会话标识。
sessionStorage和localStorage 虽然也有存储大小的限制,但比cookie大得多,可以达到5M或更大。
3、 数据有效期不同,
sessionStorage:仅在当前浏览器窗口关闭前有效,自然也就不可能持久保持;
localStorage:始终有效,窗口或浏览器关闭也—直保存,因此用作持久数据;
cookie只在设置的cookie过期时间之前—直有效,即使窗口或浏览器关闭。
4、 作用域不同,
sessionStorage不在不同的浏览器窗口中共享,即使是同—个页面;
localStorage 在所有同源窗口中都是共享的;
cookie也是在所有同源窗口中都是共享的。
什么是认证(Authentication)
- 通俗地讲就是验证当前用户的身份,证明“你是你自己”(比如:你每天上下班打卡,都需要通过指纹打卡,当你的指纹和系统里录入的指纹相匹配时,就打卡成功)
- 互联网中的认证:
- 用户名密码登录
- 邮箱发送登录链接
- 手机号接收验证码
- 只要你能收到邮箱/验证码,就默认你是账号的主人
什么是授权
- 用户授予第三方应用访问该用户某些资源的权限
- 你在安装手机应用的时候,APP 会询问是否允许授予权限(访问相册、地理位置等权限)
- 你在访问微信小程序时,当登录时,小程序会询问是否允许授予权限(获取昵称、头像、地区、性别等个人信息)
- 实现授权的方式有:
cookie
、session
、token
、OAuth
什么是凭证(Credentials)
- 实现认证和授权的前提是需要一种媒介(证书) 来标记访问者的身份
- 在战国时期,商鞅变法,发明了照身帖。照身帖由官府发放,是一块打磨光滑细密的竹板,上面刻有持有人的头像和籍贯信息。国人必须持有,如若没有就被认为是黑户,或者间谍之类的。
- 在现实生活中,每个人都会有一张专属的居民身份证,是用于证明持有人身份的一种法定证件。通过身份证,我们可以办理手机卡/银行卡/个人贷款/交通出行等等,这就是认证的凭证。
- 在互联网应用中,一般网站(如掘金)会有两种模式,游客模式和登录模式。游客模式下,可以正常浏览网站上面的文章,一旦想要点赞/收藏/分享文章,就需要登录或者注册账号。当用户登录成功后,服务器会给该用户使用的浏览器颁发一个令牌(token),这个令牌用来表明你的身份,每次浏览器发送请求时会带上这个令牌,就可以使用游客模式下无法使用的功能。
什么是 Cookie
- HTTP 是无状态的协议(对于事务处理没有记忆能力,每次客户端和服务端会话完成时,服务端不会保存任何会话信息):每个请求都是完全独立的,服务端无法确认当前访问者的身份信息,无法分辨上一次的请求发送者和这一次的发送者是不是同一个人。所以服务器与浏览器为了进行会话跟踪(知道是谁在访问我),就必须主动的去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器。而这个状态需要通过 cookie 或者 session 去实现。
- cookie 存储在客户端: cookie 是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。
- cookie 是不可跨域的: 每个 cookie 都会绑定单一的域名,无法在别的域名下获取使用,一级域名和二级域名之间是允许共享使用的(靠的是 domain)。
cookie 重要的属性属性说明name=value键值对,设置 Cookie 的名称及相对应的值,都必须是字符串类型
- 如果值为 Unicode 字符,需要为字符编码。
- 如果值为二进制数据,则需要使用 BASE64 编码。domain指定 cookie 所属域名,默认是当前域名path**指定 cookie 在哪个路径(路由)下生效,默认是 '/'。如果设置为
/abc
,则只有/abc
下的路由可以访问到该 cookie,如:/abc/read
。maxAgecookie 失效的时间,单位秒。如果为整数,则该 cookie 在 maxAge 秒后失效。如果为负数,该 cookie 为临时 cookie ,关闭浏览器即失效,浏览器也不会以任何形式保存该 cookie 。如果为 0,表示删除该 cookie 。默认为 -1。 - 比 expires 好用。expires过期时间,在设置的某个时间点后该 cookie 就会失效。一般浏览器的 cookie 都是默认储存的,当关闭浏览器结束这个会话的时候,这个 cookie 也就会被删除secure该 cookie 是否仅被使用安全协议传输。安全协议有 HTTPS,SSL等,在网络上传输数据之前先将数据加密。默认为false。当 secure 值为 true 时,cookie 在 HTTP 中是无效,在 HTTPS 中才有效。httpOnly**如果给某个 cookie 设置了 httpOnly 属性,则无法通过 JS 脚本 读取到该 cookie 的信息,但还是能通过 Application 中手动修改 cookie,所以只是在一定程度上可以防止 XSS 攻击,不是绝对的安全
什么是 Session
- session 是另一种记录服务器和客户端会话状态的机制
- session 是基于 cookie 实现的,session 存储在服务器端,sessionId 会被存储到客户端的cookie 中
session.png
- session 认证流程:
- 用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建对应的 Session
- 请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器
- 浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名
- 当用户第二次访问服务器的时候,请求会自动判断此域名下是否存在 Cookie 信息,如果存在自动将 Cookie 信息也发送给服务端,服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作。
根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来验证用户登录状态。
什么是 Token(令牌)
Acesss Token
- 访问资源接口(API)时所需要的资源凭证
- 简单 token 的组成: uid(用户唯一的身份标识)、time(当前时间的时间戳)、sign(签名,token 的前几位以哈希算法压缩成的一定长度的十六进制字符串)
- 特点:
- 服务端无状态化、可扩展性好
- 支持移动端设备
- 安全
- 支持跨程序调用
- token 的身份验证流程:
- 客户端使用用户名跟密码请求登录
- 服务端收到请求,去验证用户名与密码
- 验证成功后,服务端会签发一个 token 并把这个 token 发送给客户端
- 客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage 里
- 客户端每次向服务端请求资源的时候需要带着服务端签发的 token
- 服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据
- 每一次请求都需要携带 token,需要把 token 放到 HTTP 的 Header 里
- 基于 token 的用户认证是一种服务端无状态的认证方式,服务端不用存放 token 数据。用解析 token 的计算时间换取 session 的存储空间,从而减轻服务器的压力,减少频繁的查询数据库
- token 完全由应用管理,所以它可以避开同源策略
Refresh Token
- 另外一种 token——refresh token
- refresh token 是专用于刷新 access token 的 token。如果没有 refresh token,也可以刷新 access token,但每次刷新都要用户输入登录用户名与密码,会很麻烦。有了 refresh token,可以减少这个麻烦,客户端直接用 refresh token 去更新 access token,无需用户进行额外的操作。
- Access Token 的有效期比较短,当 Acesss Token 由于过期而失效时,使用 Refresh Token 就可以获取到新的 Token,如果 Refresh Token 也失效了,用户就只能重新登录了。
- Refresh Token 及过期时间是存储在服务器的数据库中,只有在申请新的 Acesss Token 时才会验证,不会对业务接口响应时间造成影响,也不需要向 Session 一样一直保持在内存中以应对大量的请求。
Token 和 Session 的区别
- Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息。而 Token 是令牌,访问资源接口(API)时所需要的资源凭证。Token 使服务端无状态化,不会存储会话信息。
- Session 和 Token 并不矛盾,作为身份认证 Token 安全性比 Session 好,因为每一个请求都有签名还能防止监听以及重放攻击,而 Session 就必须依赖链路层来保障通讯安全了。如果你需要实现有状态的会话,仍然可以增加 Session 来在服务器端保存一些状态。
- 所谓 Session 认证只是简单的把 User 信息存储到 Session 里,因为 SessionID 的不可预测性,暂且认为是安全的。而 Token ,如果指的是 OAuth Token 或类似的机制的话,提供的是 认证 和 授权 ,认证是针对用户,授权是针对 App 。其目的是让某 App 有权利访问某用户的信息。这里的 Token 是唯一的。不可以转移到其它 App上,也不可以转到其它用户上。Session 只提供一种简单的认证,即只要有此 SessionID ,即认为有此 User 的全部权利。是需要严格保密的,这个数据应该只保存在站方,不应该共享给其它网站或者第三方 App。所以简单来说:如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 Token 。如果永远只是自己的网站,自己的 App,用什么就无所谓了。
什么是 JWT
- JSON Web Token(简称 JWT)是目前最流行的跨域认证解决方案。
- 是一种认证授权机制。
- JWT 是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准(RFC 7519)。JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源。比如用在用户登录上。
- 可以使用 HMAC 算法或者是 RSA 的公/私秘钥对 JWT 进行签名。因为数字签名的存在,这些传递的信息是可信的。
- 阮一峰老师的 JSON Web Token 入门教程 讲的非常通俗易懂,这里就不再班门弄斧了
生成 JWT
jwt.io/www.jsonwebtoken.io/
JWT 的原理
- JWT 认证流程:
- post/user/login登录输入用户名密码进行登录
- 服务器端使用密钥创建JWT
- 把JWT返回给浏览器
- 发送请求时
- 在发给浏览器的认识头里面发送JWT
- 服务
- 会检查JWT的签名,从JWT获取用户信息
- 把响应发送给客户端
- 客户端将 token 保存到本地(通常使用 localstorage,也可以使用 cookie)
当用户希望访问一个受保护的路由或者资源的时候,需要请求头的 Authorization 字段中使用Bearer 模式添加 JWT,其内容看起来是下面这样
Authorization: Bearer复制代码
- 服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为
- 因为 JWT 是自包含的(内部包含了一些会话信息),因此减少了需要查询数据库的需要
- 因为 JWT 并不使用 Cookie 的,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)
- 因为用户的状态不再存储在服务端的内存中,所以这是一种无状态的认证机制
JWT 的使用方式
- 客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。
方式一
- 当用户希望访问一个受保护的路由或者资源的时候,可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求头信息的 Authorization 字段里,使用 Bearer 模式添加 JWT。
GET /calendar/v1/events
Host: api.example.com
Authorization: Bearer <token>
- 用户的状态不会存储在服务端的内存中,这是一种 无状态的认证机制
- 服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为。
- 由于 JWT 是自包含的,因此减少了需要查询数据库的需要
- JWT 的这些特性使得我们可以完全依赖其无状态的特性提供数据 API 服务,甚至是创建一个下载流服务。
- 因为 JWT 并不使用 Cookie ,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)
方式二
- 跨域的时候,可以把 JWT 放在 POST 请求的数据体里。
方式三
- 通过 URL 传输
http://www.example.com/user?token=xxx
项目中使用 JWT
项目地址: https://github.com/yjdjiayou/...
Token 和 JWT 的区别
相同:
- 都是访问资源的令牌
- 都可以记录用户的信息
- 都是使服务端无状态化
- 都是只有验证成功后,客户端才能访问服务端上受保护的资源
区别:
- Token:服务端验证客户端发送过来的 Token 时,还需要查询数据库获取用户信息,然后验证 Token 是否有效。
- JWT:将 Token 和 Payload 加密后存储于客户端,服务端只需要使用密钥解密进行校验(校验也是 JWT 自己实现的)即可,不需要查询或者减少查询数据库,因为 JWT 自包含了用户信息和加密的数据。
常见的前后端鉴权方式
Session-Cookie
Token
验证(包括JWT
,SSO
)OAuth2.0
(开放授权)
常见的加密算法
image.png
- 哈希算法(Hash Algorithm)又称散列算法、散列函数、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法。哈希算法将数据重新打乱混合,重新创建一个哈希值。
- 哈希算法主要用来保障数据真实性(即完整性),即发信人将原始消息和哈希值一起发送,收信人通过相同的哈希函数来校验原始数据是否真实。
- 哈希算法通常有以下几个特点:
- 正像快速:原始数据可以快速计算出哈希值
- 逆向困难:通过哈希值基本不可能推导出原始数据
- 输入敏感:原始数据只要有一点变动,得到的哈希值差别很大
- 冲突避免:很难找到不同的原始数据得到相同的哈希值,宇宙中原子数大约在 10 的 60 次方到 80 次方之间,所以 2 的 256 次方有足够的空间容纳所有的可能,算法好的情况下冲突碰撞的概率很低:
- 2 的 128 次方为 340282366920938463463374607431768211456,也就是 10 的 39 次方级别
- 2 的 160 次方为 1.4615016373309029182036848327163e+48,也就是 10 的 48 次方级别
- 2 的 256 次方为 1.1579208923731619542357098500869 × 10 的 77 次方,也就是 10 的 77 次方
注意:
- 以上不能保证数据被恶意篡改,原始数据和哈希值都可能被恶意篡改,要保证不被篡改,可以使用RSA 公钥私钥方案,再配合哈希值。
- 哈希算法主要用来防止计算机传输过程中的错误,早期计算机通过前 7 位数据第 8 位奇偶校验码来保障(12.5% 的浪费效率低),对于一段数据或文件,通过哈希算法生成 128bit 或者 256bit 的哈希值,如果校验有问题就要求重传。
9、浏览器缓存机制
注意:该知识点属于性能优化领域,并且整一章节都是一个面试题。
缓存可以说是性能优化中简单高效的一种优化方式了,它可以显著减少网络传输所带来的损耗。
对于一个数据请求来说,可以分为发起网络请求、后端处理、浏览器响应三个步骤。浏览器缓存可以帮助我们在第一和第三步骤中优化性能。比如说直接使用缓存而不发起请求,或者发起了请求但后端存储的数据和前端一致,那么就没有必要再将数据回传回来,这样就减少了响应数据。
接下来的内容中我们将通过以下几个部分来探讨浏览器缓存机制:
- 缓存位置
- 缓存策略
- 实际场景应用缓存策略
缓存位置
从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络
- Service Worker
- Memory Cache
- Disk Cache
- Push Cache
- 网络请求
Service Worker
在上一章节中我们已经介绍了 Service Worker 的内容,这里就不演示相关的代码了。
Service Worker 的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。
当 Service Worker 没有命中缓存的时候,我们需要去调用 fetch
函数获取数据。也就是说,如果我们没有在 Service Worker 命中缓存的话,会根据缓存查找优先级去查找数据。但是不管我们是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示我们是从 Service Worker 中获取的内容。
Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。
Service Worker 实现缓存功能一般分为三个步骤:首先需要先注册 Service Worker,然后监听到 install
事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。以下是这个步骤的实现:
// index.js
if (navigator.serviceWorker) {
navigator.serviceWorker
.register('sw.js')
.then(function(registration) {
console.log('service worker 注册成功')
})
.catch(function(err) {
console.log('servcie worker 注册失败')
})
}
// sw.js
// 监听 `install` 事件,回调中缓存所需文件
self.addEventListener('install', e => {
e.waitUntil(
caches.open('my-cache').then(function(cache) {
return cache.addAll(['./index.html', './index.js'])
})
)
})
// 拦截所有请求事件
// 如果缓存中已经有请求的数据就直接用缓存,否则去请求数据
self.addEventListener('fetch', e => {
e.respondWith(
caches.match(e.request).then(function(response) {
if (response) {
return response
}
console.log('fetch source')
})
)
})
打开页面,可以在开发者工具中的 Application
看到 Service Worker 已经启动了
在 Cache 中也可以发现我们所需的文件已被缓存
当我们重新刷新页面可以发现我们缓存的数据是从 Service Worker 中读取的
小结
以上就是浏览器基础知识点的内容了。
Memory Cache
Memory Cache 也就是内存中的缓存,读取内存中的数据肯定比磁盘快。但是内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。 一旦我们关闭 Tab 页面,内存中的缓存也就被释放了。
当我们访问过页面以后,再次刷新页面,可以发现很多数据都来自于内存缓存
从内存中读取缓存
那么既然内存缓存这么高效,我们是不是能让数据都存放在内存中呢?
先说结论,这是不可能的。首先计算机中的内存一定比硬盘容量小得多,操作系统需要精打细算内存的使用,所以能让我们使用的内存必然不多。内存中其实可以存储大部分的文件,比如说 JSS、HTML、CSS、图片等等。但是浏览器会把哪些文件丢进内存这个过程就很玄学了,我查阅了很多资料都没有一个定论。
当然,我通过一些实践和猜测也得出了一些结论:
- 对于大文件来说,大概率是不存储在内存中的,反之优先
- 当前系统内存使用率高的话,文件优先存储进硬盘
Disk Cache
Disk Cache 也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上。
在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。
Push Cache
Push Cache 是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。并且缓存时间也很短暂,只在会话(Session)中存在,一旦会话结束就被释放。
Push Cache 在国内能够查到的资料很少,也是因为 HTTP/2 在国内不够普及,但是 HTTP/2 将会是日后的一个趋势。这里推荐阅读 HTTP/2 push is tougher than I thought 这篇文章,但是内容是英文的,我翻译一下文章中的几个结论,有能力的同学还是推荐自己阅读
- 所有的资源都能被推送,但是 Edge 和 Safari 浏览器兼容性不怎么好
- 可以推送
no-cache
和no-store
的资源 - 一旦连接被关闭,Push Cache 就被释放
- 多个页面可以使用相同的 HTTP/2 连接,也就是说能使用同样的缓存
- Push Cache 中的缓存只能被使用一次
- 浏览器可以拒绝接受已经存在的资源推送
- 你可以给其他域名推送资源
网络请求
如果所有缓存都没有命中的话,那么只能发起请求来获取资源了。
那么为了性能上的考虑,大部分的接口都应该选择好缓存策略,接下来我们就来学习缓存策略这部分的内容。
缓存策略
通常浏览器缓存策略分为两种:强缓存和协商缓存,并且缓存策略都是通过设置 HTTP Header 来实现的。
强缓存
强缓存可以通过设置两种 HTTP Header 实现:Expires
和 Cache-Control
。强缓存表示在缓存期间不需要请求,state code
为 200。
Expires
Expires: Wed, 22 Oct 2018 08:41:00 GMT
Expires
是 HTTP/1 的产物,表示资源会在 Wed, 22 Oct 2018 08:41:00 GMT
后过期,需要再次请求。并且 Expires
受限于本地时间,如果修改了本地时间,可能会造成缓存失效。
Cache-control
Cache-control: max-age=30
Cache-Control
出现于 HTTP/1.1,优先级高于 Expires
。该属性值表示资源会在 30 秒后过期,需要再次请求。
Cache-Control
可以在请求头或者响应头中设置,并且可以组合使用多种指令
多种指令配合流程图
从图中我们可以看到,我们可以将多个指令配合起来一起使用,达到多个目的。比如说我们希望资源能被缓存下来,并且是客户端和代理服务器都能缓存,还能设置缓存失效时间等等。
接下来我们就来学习一些常见指令的作用
常见指令作用
协商缓存
如果缓存过期了,就需要发起请求验证资源是否有更新。协商缓存可以通过设置两种 HTTP Header 实现:Last-Modified
和 ETag
。
当浏览器发起请求验证资源时,如果资源没有做改变,那么服务端就会返回 304 状态码,并且更新浏览器缓存有效期。
协商缓存
Last-Modified 和 If-Modified-Since
Last-Modified
表示本地文件最后修改日期,If-Modified-Since
会将 Last-Modified
的值发送给服务器,询问服务器在该日期后资源是否有更新,有更新的话就会将新的资源发送回来,否则返回 304 状态码。
last-Modified 是一个响应首部,其中包含源头服务器认定的资源做出修改的日期及时间。 它通常被用作一个验证器来判断接收到的或者存储的资源是否彼此一致。由于精确度比 ETag 要低,所以这是一个备用机制。包含有 If-Modified-Since 或 If-Unmodified-Since 首部的条件请求会使用这个字段。
基于客户端和服务端协商的缓存机制
Last-Modified ----response header
If-Modified-Since----request header
需要与cache-control共同使用
max-age的优先级高于Last-Modified
缺点
某些服务端不能获取精确的修改时间
文件修改时间改了,但文件内容却没有变
因为以上这些弊端,所以在 HTTP / 1.1 出现了 ETag
。
ETag 和 If-None-Match
ETag
类似于文件指纹,If-None-Match
会将当前 ETag
发送给服务器,询问该资源 ETag
是否变动,有变动的话就将新的资源发送回来。并且 ETag
优先级比 Last-Modified
高。
ETagHTTP响应头是资源的特定版本的标识符。这可以让缓存更高效,并节省带宽,因为如果内容没有改变,Web服务器不需要发送完整的响应。而如果内容发生了变化,使用ETag有助于防止资源的同时更新相互覆盖(“空中碰撞”)
文件内容的hash值
etag--response header
if-none-match -- request header
要与cache-control共同使用
4.6.4两者对比
首先在精确度上,Etag要优于Last-Modified。
第二在性能上,Etag要逊于Last-Modified,毕竟Last-Modified只需要记录时间,而Etag需要服务器通过算法来计算出一个hash值。
第三在优先级上,服务器校验优先考虑Etag
4.6.2 Last-Modified和If-Modified-Since
4.6.3 Etag/If-None-Match
实际场景应用缓存策略
单纯了解理论而不付诸于实践是没有意义的,接下来我们来通过几个场景学习下如何使用这些理论。
频繁变动的资源
对于频繁变动的资源,首先需要使用 Cache-Control: no-cache
使浏览器每次都请求服务器,然后配合 ETag
或者 Last-Modified
来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。
代码文件
这里特指除了 HTML 外的代码文件,因为 HTML 文件一般不缓存或者缓存时间很短。
一般来说,现在都会使用工具来打包代码,那么我们就可以对文件名进行哈希处理,只有当代码修改后才会生成新的文件名。基于此,我们就可以给代码文件设置缓存有效期一年 Cache-Control: max-age=31536000
,这样只有当 HTML 文件中引入的文件名发生了改变才会去下载最新的代码文件,否则就一直使用缓存。
10、V8引擎机制
V8执行js
浏览器渲染机制
1. 浏览器的渲染过程是怎样的
大体流程如下:
1.HTML和CSS经过各自解析,生成DOM树和CSSOM树
2.合并成为渲染树
3.根据渲染树进行布局
4.最后调用GPU进行绘制,显示在屏幕上
11、浏览器渲染原理
浏览器接收到 HTML 文件并转换为 DOM 树
当我们打开一个网页时,浏览器都会去请求对应的 HTML 文件。虽然平时我们写代码时都会分为 JS、CSS、HTML 文件,也就是字符串,但是计算机硬件是不理解这些字符串的,所以在网络中传输的内容其实都是 0
和 1
这些字节数据。当浏览器接收到这些字节数据以后,它会将这些字节数据转换为字符串,也就是我们写的代码。
当数据转换为字符串以后,浏览器会先将这些字符串通过词法分析转换为标记(token),这一过程在词法分析中叫做标记化(tokenization)。
那么什么是标记呢?这其实属于编译原理这一块的内容了。简单来说,标记还是字符串,是构成代码的最小单位。这一过程会将代码分拆成一块块,并给这些内容打上标记,便于理解这些最小单位的代码是什么意思。
当结束标记化后,这些标记会紧接着转换为 Node,最后这些 Node 会根据不同 Node 之前的联系构建为一颗 DOM 树。
以上就是浏览器从网络中接收到 HTML 文件然后一系列的转换过程。
将 CSS 文件转换为 CSSOM 树
其实转换 CSS 到 CSSOM 树的过程和上一小节的过程是极其类似的
在这一过程中,浏览器会确定下每一个节点的样式到底是什么,并且这一过程其实是很消耗资源的。因为样式你可以自行设置给某个节点,也可以通过继承获得。在这一过程中,浏览器得递归 CSSOM 树,然后确定具体的元素到底是什么样式。
如果你有点不理解为什么会消耗资源的话,我这里举个例子
<div>
<a> <span></span> </a>
</div>
<style>
span {
color: red;
}
div > a > span {
color: red;
}
</style>
对于第一种设置样式的方式来说,浏览器只需要找到页面中所有的 span
标签然后设置颜色,但是对于第二种设置样式的方式来说,浏览器首先需要找到所有的 span
标签,然后找到 span
标签上的 a
标签,最后再去找到 div
标签,然后给符合这种条件的 span
标签设置颜色,这样的递归过程就很复杂。所以我们应该尽可能的避免写过于具体的 CSS 选择器,然后对于 HTML 来说也尽量少的添加无意义标签,保证层级扁平。
生成渲染树
当我们生成 DOM 树和 CSSOM 树以后,就需要将这两棵树组合为渲染树。
在这一过程中,不是简单的将两者合并就行了。渲染树只会包括需要显示的节点和这些节点的样式信息,如果某个节点是 display: none
的,那么就不会在渲染树中显示。
当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做回流),然后调用 GPU 绘制,合成图层,显示在屏幕上。对于这一部分的内容因为过于底层,还涉及到了硬件相关的知识,这里就不再继续展开内容了。
那么通过以上内容,我们已经详细了解到了浏览器从接收文件到将内容渲染在屏幕上的这一过程。接下来,我们将会来学习上半部分遗留下来的一些知识点。
为什么操作 DOM 慢
想必大家都听过操作 DOM 性能很差,但是这其中的原因是什么呢?
因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,其实这个操作涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。操作 DOM 次数一多,也就等同于一直在进行线程之间的通信,并且操作 DOM 可能还会带来重绘回流的情况,所以也就导致了性能上的问题。
经典面试题:插入几万个 DOM,如何实现页面不卡顿?
对于这道题目来说,首先我们肯定不能一次性把几万个 DOM 全部插入,这样肯定会造成卡顿,所以解决问题的重点应该是如何分批次部分渲染 DOM。大部分人应该可以想到通过 requestAnimationFrame
的方式去循环的插入 DOM,其实还有种方式去解决这个问题:虚拟滚动(virtualized scroller)。
这种技术的原理就是只渲染可视区域内的内容,非可见区域的那就完全不渲染了,当用户在滚动的时候就实时去替换渲染的内容。
从上图中我们可以发现,即使列表很长,但是渲染的 DOM 元素永远只有那么几个,当我们滚动页面的时候就会实时去更新 DOM,这个技术就能顺利解决这道经典面试题。如果你想了解更多的内容可以了解下这个 react-virtualized。
什么情况阻塞渲染
首先渲染的前提是生成渲染树,所以 HTML 和 CSS 肯定会阻塞渲染。如果你想渲染的越快,你越应该降低一开始需要渲染的文件大小,并且扁平层级,优化选择器。
然后当浏览器在解析到 script
标签时,会暂停构建 DOM,完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件,这也是都建议将 script
标签放在 body
标签底部的原因。
当然在当下,并不是说 script
标签必须放在底部,因为你可以给 script
标签添加 defer
或者 async
属性。
当 script
标签加上 defer
属性以后,表示该 JS 文件会并行下载,但是会放到 HTML 解析完成后顺序执行,所以对于这种情况你可以把 script
标签放在任意位置。
对于没有任何依赖的 JS 文件可以加上 async
属性,表示 JS 文件下载和解析不会阻塞渲染。
重绘(Repaint)和回流(Reflow)
重绘和回流会在我们设置节点样式时频繁出现,同时也会很大程度上影响性能。
- 重绘是当节点需要更改外观而不会影响布局的,比如改变
color
就叫称为重绘 - 回流是布局或者几何属性需要改变就称为回流。
回流必定会发生重绘,重绘不一定会引发回流。回流所需的成本比重绘高的多,改变父节点里的子节点很可能会导致父节点的一系列回流。
以下几个动作可能会导致性能问题:
- 改变
window
大小 - 改变字体
- 添加或删除样式
- 文字改变
- 定位或者浮动
- 盒模型
并且很多人不知道的是,重绘和回流其实也和 Eventloop 有关。
- 当 Eventloop 执行完 Microtasks 后,会判断
document
是否需要更新,因为浏览器是 60Hz 的刷新率,每 16.6ms 才会更新一次。 - 然后判断是否有
resize
或者scroll
事件,有的话会去触发事件,所以resize
和scroll
事件也是至少 16ms 才会触发一次,并且自带节流功能。 - 判断是否触发了 media query
- 更新动画并且发送事件
- 判断是否有全屏操作事件
- 执行
requestAnimationFrame
回调 - 执行
IntersectionObserver
回调,该方法用于判断元素是否可见,可以用于懒加载上,但是兼容性不好 - 更新界面
- 以上就是一帧中可能会做的事情。如果在一帧中有空闲时间,就会去执行
requestIdleCallback
回调。
以上内容来自于 HTML 文档。
既然我们已经知道了重绘和回流会影响性能,那么接下来我们将会来学习如何减少重绘和回流的次数。
减少重绘和回流
- 使用
transform
替代top
<div class="test"></div>
<style>
.test {
position: absolute;
top: 10px;
width: 100px;
height: 100px;
background: red;
}
</style>
<script>
setTimeout(() => {
// 引起回流
document.querySelector('.test').style.top = '100px'
}, 1000)
</script>
- 使用
visibility
替换display: none
,因为前者只会引起重绘,后者会引发回流(改变了布局) 不要把节点的属性值放在一个循环里当成循环里的变量
for(let i = 0; i < 1000; i++) { // 获取 offsetTop 会导致回流,因为需要去获取正确的值 console.log(document.querySelector('.test').style.offsetTop) }
- 不要使用
table
布局,可能很小的一个小改动会造成整个table
的重新布局 - 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用
requestAnimationFrame
- CSS 选择符从右往左匹配查找,避免节点层级过多
将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点。比如对于
video
标签来说,浏览器会自动将该节点变为图层。设置节点为图层的方式有很多,我们可以通过以下几个常用属性可以生成新图层
will-change
video
、iframe
标签
思考题
思考题:在不考虑缓存和优化网络协议的前提下,考虑可以通过哪些方式来最快的渲染页面,也就是常说的关键渲染路径,这部分也是性能优化中的一块内容。
首先你可能会疑问,那怎么测量到底有没有加快渲染速度呢
当发生 DOMContentLoaded
事件后,就会生成渲染树,生成渲染树就可以进行渲染了,这一过程更大程度上和硬件有关系了。
提示如何加速:
- 从文件大小考虑
- 从
script
标签使用上来考虑 - 从 CSS、HTML 的代码书写上来考虑
- 从需要下载的内容是否需要在首屏使用上来考虑
以上提示大家都可以从文中找到,同时也欢迎大家踊跃在评论区写出你的答案。
12、安全防范知识点
这一章我们将来学习安全防范这一块的知识点。总的来说安全是很复杂的一个领域,不可能通过一个章节就能学习到这部分的内容。在这一章节中,我们会学习到常见的一些安全问题及如何防范的内容,在当下其实安全问题越来越重要,已经逐渐成为前端开发必备的技能了。
XSS
涉及面试题:什么是 XSS 攻击?如何防范 XSS 攻击?什么是 CSP?
XSS 简单点来说,就是攻击者想尽一切办法将可以执行的代码注入到网页中。
XSS 可以分为多种类型,但是总体上我认为分为两类:持久型和非持久型。
持久型也就是攻击的代码被服务端写入进数据库中,这种攻击危害性很大,因为如果网站访问量很大的话,就会导致大量正常访问页面的用户都受到攻击。
举个例子,对于评论功能来说,就得防范持久型 XSS 攻击,因为我可以在评论中输入以下内容
这种情况如果前后端没有做好防御的话,这段评论就会被存储到数据库中,这样每个打开该页面的用户都会被攻击到。
非持久型相比于前者危害就小的多了,一般通过修改 URL 参数的方式加入攻击代码,诱导用户访问链接从而进行攻击。
举个例子,如果页面需要从 URL 中获取某些参数作为内容的话,不经过过滤就会导致攻击代码被执行
<!-- http://www.domain.com?name=<script>alert(1)</script> -->
<div>{{name}}</div>
但是对于这种攻击方式来说,如果用户使用 Chrome 这类浏览器的话,浏览器就能自动帮助用户防御攻击。但是我们不能因此就不防御此类攻击了,因为我不能确保用户都使用了该类浏览器。
对于 XSS 攻击来说,通常有两种方式可以用来防御。
转义字符
首先,对于用户的输入应该是永远不信任的。最普遍的做法就是转义输入输出的内容,对于引号、尖括号、斜杠进行转义
function escape(str) {
str = str.replace(/&/g, '&')
str = str.replace(/</g, '<')
str = str.replace(/>/g, '>')
str = str.replace(/"/g, '&quto;')
str = str.replace(/'/g, ''')
str = str.replace(/`/g, '`')
str = str.replace(/\//g, '/')
return str
}
通过转义可以将攻击代码 alert(1)
变成
// -> <script>alert(1)</script>
escape('<script>alert(1)</script>')
但是对于显示富文本来说,显然不能通过上面的办法来转义所有字符,因为这样会把需要的格式也过滤掉。对于这种情况,通常采用白名单过滤的办法,当然也可以通过黑名单过滤,但是考虑到需要过滤的标签和标签属性实在太多,更加推荐使用白名单的方式。
const xss = require('xss')
let html = xss('<h1 id="title">XSS Demo</h1><script>alert("xss");</script>')
// -> <h1>XSS Demo</h1><script>alert("xss");</script>
console.log(html)
以上示例使用了 js-xss
来实现,可以看到在输出中保留了 h1
标签且过滤了 script
标签。
CSP
CSP 本质上就是建立白名单,开发者明确告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截是由浏览器自己实现的。我们可以通过这种方式来尽量减少 XSS 攻击。
通常可以通过两种方式来开启 CSP:
- 设置 HTTP Header 中的
Content-Security-Policy
- 设置
meta
标签的方式 ``
这里以设置 HTTP Header 来举例
只允许加载本站资源
Content-Security-Policy: default-src ‘self’
只允许加载 HTTPS 协议图片
Content-Security-Policy: img-src https://*
允许加载任何来源框架
Content-Security-Policy: child-src 'none'
当然可以设置的属性远不止这些,你可以通过查阅 文档 的方式来学习,这里就不过多赘述其他的属性了。
对于这种方式来说,只要开发者配置了正确的规则,那么即使网站存在漏洞,攻击者也不能执行它的攻击代码,并且 CSP 的兼容性也不错。
CSRF
涉及面试题:什么是 CSRF 攻击?如何防范 CSRF 攻击?
CSRF 中文名为跨站请求伪造。原理就是攻击者构造出一个后端请求地址,诱导用户点击或者通过某些途径自动发起请求。如果用户是在登录状态下的话,后端就以为是用户在操作,从而进行相应的逻辑。
举个例子,假设网站中有一个通过 GET
请求提交用户评论的接口,那么攻击者就可以在钓鱼网站中加入一个图片,图片的地址就是评论接口

那么你是否会想到使用 POST
方式提交请求是不是就没有这个问题了呢?其实并不是,使用这种方式也不是百分百安全的,攻击者同样可以诱导用户进入某个页面,在页面中通过表单提交 POST
请求。
如何防御
防范 CSRF 攻击可以遵循以下几种规则:
- Get 请求不对数据进行修改
- 不让第三方网站访问到用户 Cookie
- 阻止第三方网站请求接口
- 请求时附带验证信息,比如验证码或者 Token
SameSite
可以对 Cookie 设置 SameSite
属性。该属性表示 Cookie 不随着跨域请求发送,可以很大程度减少 CSRF 的攻击,但是该属性目前并不是所有浏览器都兼容。
验证 Referer
对于需要防范 CSRF 的请求,我们可以通过验证 Referer 来判断该请求是否为第三方网站发起的。
Token
服务器下发一个随机 Token,每次发起请求时将 Token 携带上,服务器验证 Token 是否有效。
点击劫持
涉及面试题:什么是点击劫持?如何防范点击劫持?
点击劫持是一种视觉欺骗的攻击手段。攻击者将需要攻击的网站通过 iframe
嵌套的方式嵌入自己的网页中,并将 iframe
设置为透明,在页面中透出一个按钮诱导用户点击。
对于这种攻击方式,推荐防御的方法有两种。
X-FRAME-OPTIONS
X-FRAME-OPTIONS
是一个 HTTP 响应头,在现代浏览器有一个很好的支持。这个 HTTP 响应头 就是为了防御用 iframe
嵌套的点击劫持攻击。
该响应头有三个值可选,分别是
DENY
,表示页面不允许通过iframe
的方式展示SAMEORIGIN
,表示页面可以在相同域名下通过iframe
的方式展示ALLOW-FROM
,表示页面可以在指定来源的iframe
中展示
JS 防御
对于某些远古浏览器来说,并不能支持上面的这种方式,那我们只有通过 JS 的方式来防御点击劫持了。
<head>
<style id="click-jack">
html {
display: none !important;
}
</style>
</head>
<body>
<script>
if (self == top) {
var style = document.getElementById('click-jack')
document.body.removeChild(style)
} else {
top.location = self.location
}
</script>
</body>
以上代码的作用就是当通过 iframe
的方式加载页面时,攻击者的网页直接不显示所有内容了。
中间人攻击
涉及面试题:什么是中间人攻击?如何防范中间人攻击?
中间人攻击是攻击方同时与服务端和客户端建立起了连接,并让对方认为连接是安全的,但是实际上整个通信过程都被攻击者控制了。攻击者不仅能获得双方的通信信息,还能修改通信信息。
通常来说不建议使用公共的 Wi-Fi,因为很可能就会发生中间人攻击的情况。如果你在通信的过程中涉及到了某些敏感信息,就完全暴露给攻击方了。
当然防御中间人攻击其实并不难,只需要增加一个安全通道来传输信息。HTTPS 就可以用来防御中间人攻击,但是并不是说使用了 HTTPS 就可以高枕无忧了,因为如果你没有完全关闭 HTTP 访问的话,攻击方可以通过某些方式将 HTTPS 降级为 HTTP 从而实现中间人攻击。
小结
在这一章中,我们学习到了一些常见的前端安全方面的知识及如何防御这些攻击。但是安全的领域相当大,这些内容只是沧海一粟,如果大家对于安全有兴趣的话,可以阅读 这个仓库的内容 来学习和实践这方面的知识。
13、从 V8 中看 JS 性能优化
注意:该知识点属于性能优化领域
在学习如何性能优化之前,我们先来了解下如何测试性能问题,毕竟是先有问题才会去想着该如何改进。
测试性能工具
- 可以通过 Audit 工具 run Audit获得网站的多个指标的性能报告
- 可以通过 Performance 工具了解网站的性能瓶颈
- 可以通过 Performance API 具体测量时间
- 为了减少编译时间,我们可以采用减少代码文件的大小或者减少书写嵌套函数的方式
- 为了让 V8 优化代码,我们应该尽可能保证传入参数的类型一致。这也给我们带来了一个思考,这是不是也是使用 TypeScript 能够带来的好处之一。
项目怎么进行优化
延迟:
CDN
带宽
js延迟、按需加载
var obj ={};
/**
* 按需加载JS
* @param {string} url 脚本地址
* @param {function} callback 回调函数
*/
export function dynamicLoadJs (url, callback) {
if(obj[url]){
callback();
return;
}
obj[url]=true;
var head = document.getElementsByTagName('head')[0]
var script = document.createElement('script')
script.type = 'text/javascript'
script.src = url
if (typeof (callback) === 'function') {
script.onload = script.onreadystatechange = function () {
if (!this.readyState || this.readyState === 'loaded' || this.readyState === 'complete') {
callback()
script.onload = script.onreadystatechange = null
}
}
}
head.appendChild(script)
}
每次加载资源后,需要缓存,防止重复多次加载;
图片按需加载
//获取元素是否在可视区域
function isElementInViewport(el) {
var rect = el.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
rect.right <=
(window.innerWidth || document.documentElement.clientWidth)
);
}
function checkImg() {
let imgs = document.querySelectorAll("img[lazy]");
Array.from(imgs).forEach(ele => {
if (isElementInViewport(ele)) {
loadImg(ele)
}
})
}
function loadImg(el) {
if (!el.src) {
let source = el.dataset.src;
el.src = source;
}
}

资源预加载
提前加载资源
<link rel="preload" as="font" href="https://at.alicdn.com/t/font_zck90zmlh7hf47vi.woff">
<link rel="preload" as="script" href="https://a.xxx.com/xxx/PcCommon.js">
<link rel="preload" as="script" href="https://a.xxx.com/xxx/TabsPc.js">
预加载css
<!-- 使用 link 标签静态标记需要预加载的资源 -->
<link rel="preload" href="/path/to/style.css" as="style">
<!-- 或使用脚本动态创建一个 link 标签后插入到 head 头部 -->
<script>
const link = document.createElement('link');
//link.rel = 'stylesheet';
link.rel = 'preload';
link.as = 'style';
link.href = '/path/to/style.css';
document.head.appendChild(link);
</script>
preload 是告诉浏览器页面必定需要的资源,浏览器一定会加载这些资源;
prefetch 是告诉浏览器页面可能需要的资源,浏览器不一定会加载这些资源。
preload 是确认会加载指定资源,如在我们的场景中,x-report.js 初始化后一定会加载 PcCommon.js 和 TabsPc.js, 则可以预先 preload 这些资源;
prefetch 是预测会加载指定资源,如在我们的场景中,我们在页面加载后会初始化首屏组件,当用户滚动页面时,会拉取第二屏的组件,若能预测用户行为,则可以 prefetch 下一屏的组件。
DNS解析
DNS解析流程
- 查找浏览器缓存。
- 查找系统缓存。
- 查找路由器缓存。
- 查找ISP DNS 缓存。
- 迭代查询。
优化思路
- 减少DNS查找,避免重定向
- 使用浏览器DNS缓存 、计算机DNS缓存、 服务器DNS缓存,防止DNS迭代查询;
- 使用Keep-Alive特性 来减少DNS查找的频率;
- 使用较少的域名(服务器主机)来减少DNS查找的数量。
影响DNS缓存的因素
- 首先,服务器可以表明记录可以被缓存多久。查找返回的DNS记录包含了一个存活时间(Time-to-live,TTL)值。该值告诉客户端可以对该记录缓存多久。
- 尽管操作系统缓存会考虑 TTL 值,但浏览器通常忽略该值,并设置它自己的时间限制。
- 此外,HTTP 协议中的 Keep-Alive 特性可以同时覆盖 TTL 和浏览器的时间限制。换句话说,只要浏览器和Web服务器愉快地通信着,并保持 TCP 连接打开的状态,就没有理由进行 DNS 查找。
- 浏览器对缓存的 DNS 记录的数量也有限制,而不管缓存记录的时间。如果用户在短时间内访问了很多具有不同域名的网站,较早的 DNS 记录将被丢弃,必须重新查找该域名。
- 不过,要记得即便浏览器丢弃了 DNS 记录,操作系统可能依然保持着该记录,这能扭转一下局面,因为无需通过网络发送查询,从而避免了明显的延迟。
- 当客户端的DNS缓存为空时(浏览器与操作系统缓存为空时),DNS查找的数量与Web页面中唯一主机名的数量相等。减少唯一主机名的数量就可以减少DNS查找的数量。
- 但减少唯一主机名的数量会潜在地减少页面中并行下载的数量。(HTTP1.1,浏览器对于同一个服务器的并行连接数有限制)因此,虽然避免 DNS 查找降低了相应时间,但减少并行下载可能会增加响应时间。所以,在减少 DNS 查找与允许高速并行下载之间做权衡,建议使用 2-4 个服务器主机。
DNS的预解析
可以通过用meta信息来告知浏览器, 我这页面要做DNS预解析
<meta http-equiv="x-dns-prefetch-control" content="on" />
可以使用link标签来强制对DNS做预解析:
<link rel="dns-prefetch" href="http://ke.qq.com/" />
TCP/TLS
- =减少页面重定向 页面重定向性能消耗较大
- 减少 Rewrite
- SPA,减少tcp请求
- cdn:更低的延迟
- http2.0
http2.0优点
- 二进制分帧
- 首部压缩
- 多路复用
- 请求优先级
- 服务器推送
- 更安全
- 流量控制
二进制传输
HTTP2.0中所有加强性能的核心是二进制传输,在HTTP1.x中,我们是通过文本的方式传输数据。基于文本的方式传输数据存在很多缺陷,文本的表现形式有多样性,因此要做到健壮性考虑的场景必然有很多,但是二进制则不同,只有0和1的组合,因此选择了二进制传输,实现方便且健壮。
在HTTP2.0中引入了新的编码机制,所有传输的数据都会被分割,并采用二进制格式编码。
首部压缩
在HTTP1.0中,我们使用文本的形式传输header,在header中携带cookie的话,每次都需要重复传输几百到几千的字节,这着实是一笔不小的开销。
在HTTP2.0中,我们使用了HPACK(HTTP2头部压缩算法)压缩格式对传输的header进行编码,减少了header的大小。并在两端维护了索引表,用于记录出现过的header,后面在传输过程中就可以传输已经记录过的header的键名,对端收到数据后就可以通过键名找到对应的值。
多路复用
在HTTP1.0中,我们经常会使用到雪碧图、使用多个域名等方式来进行优化,都是因为浏览器限制了同一个域名下的请求数量,当页面需要请求很多资源的时候,队头阻塞(Head of line blocking)会导致在达到最大请求时,资源需要等待其他资源请求完成后才能继续发送。
HTTP2.0中,有两个概念非常重要:帧(frame)和流(stream)。
帧是最小的数据单位,每个帧会标识出该帧属于哪个流,流是多个帧组成的数据流。
所谓多路复用,即在一个TCP连接中存在多个流,即可以同时发送多个请求,对端可以通过帧中的表示知道该帧属于哪个请求。在客户端,这些帧乱序发送,到对端后再根据每个帧首部的流标识符重新组装。通过该技术,可以避免HTTP旧版本的队头阻塞问题,极大提高传输性能。
服务器Push
在HTTP2.0中,服务端可以在客户端某个请求后,主动推送其他资源。
可以想象一下,某些资源客户端是一定会请求的,这时就可以采取服务端push的技术,提前给客户端推送必要的资源,就可以相对减少一点延迟时间。在浏览器兼容的情况下也可以使用prefetch。
更安全
HTTP2.0使用了tls的拓展ALPN做为协议升级,除此之外,HTTP2.0对tls的安全性做了近一步加强,通过黑名单机制禁用了几百种不再安全的加密算法。
HTTP1.x主要缺点
- HTTP/1.0一次只允许在一个TCP连接上发起一个请求,HTTP/1.1使用的流水线技术也只能部分处理请求并发,仍然会存在队列头阻塞问题,因此客户端在需要发起多次请求时,通常会采用建立多连接来减少延迟。
- 单向请求,只能由客户端发起。
- 请求报文与响应报文首部信息冗余量大。
- 数据未压缩,导致数据的传输量大。
静态资源
- 数据压缩传输(gzip,broti,http2.0)
- webpack:tree shaking,。。。
- 移除不必要的lib引用,或迁移到CDN (webpack-bundle-analyzer)
14、性能优化琐碎事
注意:该知识点属于性能优化领域。
总的来说性能优化这个领域的很多内容都很碎片化,这一章节我们将来学习这些碎片化的内容。
图片优化
计算图片大小
对于一张 100 100 像素的图片来说,图像上有 10000 个像素点,如果每个像素的值是 RGBA 存储的话,那么也就是说每个像素有 4 个通道,每个通道 1 个字节(8 位 = 1个字节),所以该图片大小大概为 39KB(10000 1 * 4 / 1024)。
但是在实际项目中,一张图片可能并不需要使用那么多颜色去显示,我们可以通过减少每个像素的调色板来相应缩小图片的大小。
了解了如何计算图片大小的知识,那么对于如何优化图片,想必大家已经有 2 个思路了:
- 减少像素点
- 减少每个像素点能够显示的颜色
图片加载优化
- 不用图片。很多时候会使用到很多修饰类图片,其实这类修饰图片完全可以用 CSS 去代替。
- 对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片。
- 小图使用 base64 格式
- 将多个图标文件整合到一张图片中(雪碧图)
选择正确的图片格式:
- 对于能够显示 WebP 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点就是兼容性并不好
- 小图使用 PNG,其实对于大部分图标这类图片,完全可以使用 SVG 代替
- 照片使用 JPEG
DNS 预解析
DNS 解析也是需要时间的,可以通过预解析的方式来预先获得域名所对应的 IP。
<link rel="dns-prefetch" href="//yuchengkai.cn">
节流
防抖是延迟执行
,而节流是间隔执行
,和防抖的区别在于,防抖每次触发事件都重置定时器,而节流在定时器到时间后再清空定时器,函数节流即每隔一段时间就执行一次
,实现原理为设置一个定时器,约定xx毫秒后执行事件,如果时间到了,那么执行函数并重置定时器
,
// func是用户传入需要防抖的函数
// wait是等待时间
const throttle = (func, wait = 50) => {
// 上一次执行该函数的时间
let lastTime = 0
return function(...args) {
// 当前时间
let now = +new Date()
// 将当前时间和上一次执行函数时间对比
// 如果差值大于设置的等待时间就执行函数
if (now - lastTime > wait) {
lastTime = now
func.apply(this, args)
}
}
}
setInterval(
throttle(() => {
console.log(1)
}, 500),
1
)
防抖
即短时间内大量触发同一事件,只会执行一次函数,防抖常用于搜索框/滚动条的监听事件处理,如果不做防抖,每输入一个字/滚动屏幕,都会触发事件处理,造成性能浪费;实现原理为设置一个定时器,约定在xx毫秒后再触发事件处理,每次触发事件都会重新设置计时器,直到xx毫秒内无第二次操作,。
// func是用户传入需要防抖的函数
// wait是等待时间
const debounce = (func, wait = 50) => {
// 缓存一个定时器id
let timer = 0
// 这里返回的函数是每次用户实际调用的防抖函数
// 如果已经设定过定时器了就清空上一次的定时器
// 开始一个新的定时器,延迟执行用户传入的方法
return function(...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
func.apply(this, args)
}, wait)
}
}
预加载
在开发中,可能会遇到这样的情况。有些资源不需要马上用到,但是希望尽早获取,这时候就可以使用预加载。
预加载其实是声明式的 fetch
,强制浏览器请求资源,并且不会阻塞 onload
事件,可以使用以下代码开启预加载
<link rel="preload" href="http://example.com">
预加载可以一定程度上降低首屏的加载时间,因为可以将一些不影响首屏但重要的文件延后加载,唯一缺点就是兼容性不好。
预渲染
可以通过预渲染将下载的文件预先在后台渲染,可以使用以下代码开启预渲染
<link rel="prerender" href="http://example.com">
预渲染虽然可以提高页面的加载速度,但是要确保该页面大概率会被用户在之后打开,否则就是白白浪费资源去渲染。
懒执行
懒执行就是将某些逻辑延迟到使用时再计算。该技术可以用于首屏优化,对于某些耗时逻辑并不需要在首屏就使用的,就可以使用懒执行。懒执行需要唤醒,一般可以通过定时器或者事件的调用来唤醒。
懒加载
懒加载就是将不关键的资源延后加载。
懒加载的原理就是只加载自定义区域(通常是可视区域,但也可以是即将进入可视区域)内需要加载的东西。对于图片来说,先设置图片标签的 src
属性为一张占位图,将真实的图片资源放入一个自定义属性中,当进入自定义区域时,就将自定义属性替换为 src
属性,这样图片就会去下载资源,实现了图片懒加载。
懒加载不仅可以用于图片,也可以使用在别的资源上。比如进入可视区域才开始播放视频等等。
CDN
CDN 的原理是尽可能的在各个地方分布机房缓存数据,这样即使我们的根服务器远在国外,在国内的用户也可以通过国内的机房迅速加载资源。
因此,我们可以将静态资源尽量使用 CDN 加载,由于浏览器对于单个域名有并发请求上限,可以考虑使用多个 CDN 域名。并且对于 CDN 加载静态资源需要注意 CDN 域名要与主站不同,否则每次请求都会带上主站的 Cookie,平白消耗流量。
15、实现小型打包工具
原本小册计划中是没有这一章节的,Webpack 工作原理应该是上一章节包含的内容。但是考虑到既然讲到工作原理,必然需要讲解源码,但是 Webpack 的源码很难读,不结合源码干巴巴讲原理又没有什么价值。所以在这一章节中,我将会带大家来实现一个几十行的迷你打包工具,该工具可以实现以下两个功能
- 将 ES6 转换为 ES5
- 支持在 JS 文件中
import
CSS 文件
通过这个工具的实现,大家可以理解到打包工具的原理到底是什么。
实现
因为涉及到 ES6 转 ES5,所以我们首先需要安装一些 Babel 相关的工具
yarn add babylon babel-traverse babel-core babel-preset-env
接下来我们将这些工具引入文件中
const fs = require('fs')
const path = require('path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const { transformFromAst } = require('babel-core')
首先,我们先来实现如何使用 Babel 转换代码
function readCode(filePath) {
// 读取文件内容
const content = fs.readFileSync(filePath, 'utf-8')
// 生成 AST
const ast = babylon.parse(content, {
sourceType: 'module'
})
// 寻找当前文件的依赖关系
const dependencies = []
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value)
}
})
// 通过 AST 将代码转为 ES5
const { code } = transformFromAst(ast, null, {
presets: ['env']
})
return {
filePath,
dependencies,
code
}
}
- 首先我们传入一个文件路径参数,然后通过
fs
将文件中的内容读取出来 - 接下来我们通过
babylon
解析代码获取 AST,目的是为了分析代码中是否还引入了别的文件 - 通过
dependencies
来存储文件中的依赖,然后再将 AST 转换为 ES5 代码 - 最后函数返回了一个对象,对象中包含了当前文件路径、当前文件依赖和当前文件转换后的代码
接下来我们需要实现一个函数,这个函数的功能有以下几点
- 调用
readCode
函数,传入入口文件 - 分析入口文件的依赖
- 识别 JS 和 CSS 文件
function getDependencies(entry) {
// 读取入口文件
const entryObject = readCode(entry)
const dependencies = [entryObject]
// 遍历所有文件依赖关系
for (const asset of dependencies) {
// 获得文件目录
const dirname = path.dirname(asset.filePath)
// 遍历当前文件依赖关系
asset.dependencies.forEach(relativePath => {
// 获得绝对路径
const absolutePath = path.join(dirname, relativePath)
// CSS 文件逻辑就是将代码插入到 `style` 标签中
if (/\.css$/.test(absolutePath)) {
const content = fs.readFileSync(absolutePath, 'utf-8')
const code = `
const style = document.createElement('style')
style.innerText = ${JSON.stringify(content).replace(/\\r\\n/g, '')}
document.head.appendChild(style)
`
dependencies.push({
filePath: absolutePath,
relativePath,
dependencies: [],
code
})
} else {
// JS 代码需要继续查找是否有依赖关系
const child = readCode(absolutePath)
child.relativePath = relativePath
dependencies.push(child)
}
})
}
return dependencies
}
- 首先我们读取入口文件,然后创建一个数组,该数组的目的是存储代码中涉及到的所有文件
- 接下来我们遍历这个数组,一开始这个数组中只有入口文件,在遍历的过程中,如果入口文件有依赖其他的文件,那么就会被
push
到这个数组中 - 在遍历的过程中,我们先获得该文件对应的目录,然后遍历当前文件的依赖关系
在遍历当前文件依赖关系的过程中,首先生成依赖文件的绝对路径,然后判断当前文件是 CSS 文件还是 JS 文件
- 如果是 CSS 文件的话,我们就不能用 Babel 去编译了,只需要读取 CSS 文件中的代码,然后创建一个
style
标签,将代码插入进标签并且放入head
中即可 - 如果是 JS 文件的话,我们还需要分析 JS 文件是否还有别的依赖关系
- 最后将读取文件后的对象
push
进数组中
- 如果是 CSS 文件的话,我们就不能用 Babel 去编译了,只需要读取 CSS 文件中的代码,然后创建一个
现在我们已经获取到了所有的依赖文件,接下来就是实现打包的功能了
function bundle(dependencies, entry) {
let modules = ''
// 构建函数参数,生成的结构为
// { './entry.js': function(module, exports, require) { 代码 } }
dependencies.forEach(dep => {
const filePath = dep.relativePath || entry
modules += `'${filePath}': (
function (module, exports, require) { ${dep.code} }
),`
})
// 构建 require 函数,目的是为了获取模块暴露出来的内容
const result = `
(function(modules) {
function require(id) {
const module = { exports : {} }
modules[id](module, module.exports, require)
return module.exports
}
require('${entry}')
})({${modules}})
`
// 当生成的内容写入到文件中
fs.writeFileSync('./bundle.js', result)
}
这段代码需要结合着 Babel 转换后的代码来看,这样大家就能理解为什么需要这样写了
// entry.js
var _a = require('./a.js')
var _a2 = _interopRequireDefault(_a)
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj }
}
console.log(_a2.default)
// a.js
Object.defineProperty(exports, '__esModule', {
value: true
})
var a = 1
exports.default = a
Babel 将我们 ES6 的模块化代码转换为了 CommonJS(如果你不熟悉 CommonJS 的话,可以阅读这一章节中关于 模块化的知识点) 的代码,但是浏览器是不支持 CommonJS 的,所以如果这段代码需要在浏览器环境下运行的话,我们需要自己实现 CommonJS 相关的代码,这就是 bundle
函数做的大部分事情。
接下来我们再来逐行解析 bundle
函数
- 首先遍历所有依赖文件,构建出一个函数参数对象
- 对象的属性就是当前文件的相对路径,属性值是一个函数,函数体是当前文件下的代码,函数接受三个参数
module
、
exports
、
require
module
参数对应 CommonJS 中的module
exports
参数对应 CommonJS 中的module.export
require
参数对应我们自己创建的require
函数
- 接下来就是构造一个使用参数的函数了,函数做的事情很简单,就是内部创建一个
require
函数,然后调用require(entry)
,也就是require('./entry.js')
,这样就会从函数参数中找到./entry.js
对应的函数并执行,最后将导出的内容通过module.export
的方式让外部获取到 - 最后再将打包出来的内容写入到单独的文件中
如果你对于上面的实现还有疑惑的话,可以阅读下打包后的部分简化代码
;(function(modules) {
function require(id) {
// 构造一个 CommonJS 导出代码
const module = { exports: {} }
// 去参数中获取文件对应的函数并执行
modules[id](module, module.exports, require)
return module.exports
}
require('./entry.js')
})({
'./entry.js': function(module, exports, require) {
// 这里继续通过构造的 require 去找到 a.js 文件对应的函数
var _a = require('./a.js')
console.log(_a2.default)
},
'./a.js': function(module, exports, require) {
var a = 1
// 将 require 函数中的变量 module 变成了这样的结构
// module.exports = 1
// 这样就能在外部取到导出的内容了
exports.default = a
}
// 省略
})
小结
虽然实现这个工具只写了不到 100 行的代码,但是打包工具的核心原理就是这些了
- 找出入口文件所有的依赖关系
- 然后通过构建 CommonJS 代码来获取
exports
导出的内容。
16、React 和 Vue 两大框架之间的相爱相杀
React 和 Vue 应该是国内当下最火热的前端框架,当然 Angular 也是一个不错的框架,但是这个产品国内使用的人很少再加上我对 Angular 也不怎么熟悉,所以框架的章节中不会涉及到 Angular 的内容。
这一章节,我们将会来学习以下几个内容
- MVVM 是什么
- Virtual DOM 是什么
- 前端路由是如何跳转的
- React 和 Vue 之间的区别
MVC和MVVM 、MVP
涉及面试题:什么是 MVVM?比之 MVC 有什么区别?
首先先申明一点,不管是 React 还是 Vue,它们都不是 MVVM 框架,只是有借鉴 MVVM 的思路。文中拿 Vue 举例也是为了更好地理解 MVVM 的概念。
MVC,MVP,MVVM是三种常见的前端架构模式,通过分离关注点来改进代码组织方式。MVC模式是MVP,MVVM模式的基础,这两种模式更像是MVC模式的优化改良版,他们三个的MV即Model,view都是相同的,不同的是MV之间的桥梁连接部分。
一、MVC
视图(View):用户界面,只负责渲染 HTML
控制器(Controller):业务逻辑,负责调度 model 和 view
模型(Model):数据保存,只负责存储数据、请求数据、更新数据
MVC允许在不改变视图情况下改变视图对用户输入的响应方式,用户对View操作交给Controller处理在Controller中响应View的事件调用Model的接口对数据进行操作,一旦Model发生变化便通知相关视图View进行更新。
接受用户指令时,MVC 可以分成两种方式。一种是通过 View 接受输入,传递给 Controller。另一种是直接通过controller接受指令。此处只画了第一种情况。
但是 MVC 有一个巨大的缺陷就是控制器承担的责任太大了,随着项目愈加复杂,控制器中的代码会越来越臃肿,导致出现不利于维护的情况。
二、MVP
MVP 模式将 Controller 改名为 Presenter,同时改变了通信方向。
与MVC最大的区别就是View和Model层完全解耦,不在有依赖关系,而是通过Presenter做桥梁,用于操作view层发出的事件传递到presenter层中,presenter层去操作model层,并且将数据返回给view层,整个过程中view层和model层完全没有联系。
三、MVVM
MVVM 模式将 Presenter 改名为 ViewModel,基本上与 MVP 模式完全一致。唯一的区别是,它采用双向绑定(data-binding),View的变动,自动反映在 ViewModel,反之亦然。
这里我们拿典型的MVVM模式的代表,Vue,来举例:
<div id="app-5">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">逆转消息</button>
</div>
var app5 = new Vue({
el: '#app-5',
data: {
message: 'Hello Vue.js!'
},
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
})
这里的html部分相当于View层,可以看到这里的View通过通过模板语法来声明式的将数据渲染进DOM元素,当ViewModel对Model进行更新时,通过数据绑定更新到View。
Vue实例中的data相当于Model层,而ViewModel层的核心是Vue中的双向数据绑定,即Model变化时VIew可以实时更新,View变化也能让Model发生变化。
整体看来,MVVM比MVC精简很多,不仅简化了业务与界面的依赖,还解决了数据频繁更新的问题,不用再用选择器操作DOM元素。因为在MVVM中,View不知道Model的存在,Model和ViewModel也观察不到View,这种低耦合模式提高代码的可重用性。
除了以上三个部分,其实在 MVVM 中还引入了一个隐式的 Binder 层,实现了 View 和 ViewModel 的绑定。这个隐式的 Binder 层就是 Vue 通过解析模板中的插值和指令从而实现 View 与 ViewModel 的绑定。
对于 MVVM 来说,其实最重要的并不是通过双向绑定或者其他的方式将 View 与 ViewModel 绑定起来,而是通过 ViewModel 将视图中的状态和用户的行为分离出一个抽象,这才是 MVVM 的精髓。很简单,就是用户看到的视图
- Model 同样很简单,一般就是本地数据和数据库中的数据
基本上,我们写的产品就是通过接口从数据库中读取数据,然后将数据经过处理展现到用户看到的视图上。当然我们还可以从视图上读取用户的输入,然后又将用户的输入通过接口写入到数据库中。但是,如何将数据展示到视图上,然后又如何将用户的输入写入到数据中,不同的人就产生了不同的看法,从此出现了很多种架构设计。
传统的 MVC 架构适用大型项目Model(业务模型)(放业务数据;存储视图模型) view(用户界面css html) controller(控制器)(处理用户交互)
通常是使用控制器更新模型,视图从模型中获取数据去渲染。当用户有输入时,会通过控制器去更新模型,并且通知视图进行更新。
MVC
但是 MVC 有一个巨大的缺陷就是控制器承担的责任太大了,随着项目愈加复杂,控制器中的代码会越来越臃肿,导致出现不利于维护的情况。
在 MVVM 架构中,引入了 ViewModel 的概念。ViewModel 只关心数据和业务的处理,不关心 View 如何处理数据,在这种情况下,View 和 Model 都可以独立出来,任何一方改变了也不一定需要改变另一方,并且可以将一些可复用的逻辑放在一个 ViewModel 中,让多个 View 复用这个 ViewModel。
以 Vue 框架来举例,ViewModel 就是组件的实例。View 就是模板,Model 的话在引入 Vuex 的情况下是完全可以和组件分离的。
除了以上三个部分,其实在 MVVM 中还引入了一个隐式的 Binder 层,实现了 View 和 ViewModel 的绑定。
同样以 Vue 框架来举例,这个隐式的 Binder 层就是 Vue 通过解析模板中的插值和指令从而实现 View 与 ViewModel 的绑定。
对于 MVVM 来说,其实最重要的并不是通过双向绑定或者其他的方式将 View 与 ViewModel 绑定起来,而是通过 ViewModel 将视图中的状态和用户的行为分离出一个抽象,这才是 MVVM 的精髓。
Virtual DOM
涉及面试题:什么是 Virtual DOM?为什么 Virtual DOM 比原生 DOM 快?
想必大家都听过操作 DOM 性能很差,但是这其中的原因是什么呢?
因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,其实这个操作涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。操作 DOM 次数一多,也就等同于一直在进行线程之间的通信,并且操作 DOM 可能还会带来重绘、回流的情况,所以也就导致了性能上的问题。那么既然 DOM 可以通过 JS 对象来模拟,反之也可以通过 JS 对象来渲染出对应的 DOM。当然了,通过 JS 来模拟 DOM 并且渲染对应的 DOM 只是第一步,难点在于如何判断新旧两个 JS 对象的最小差异并且实现局部更新 DOM。
首先 DOM 是一个多叉树的结构,如果需要完整的对比两颗树的差异,那么需要的时间复杂度会是 O(n ^ 3),这个复杂度肯定是不能接受的。于是 React 团队优化了算法,实现了 O(n) 的复杂度来对比差异。 实现 O(n) 复杂度的关键就是只对比同层的节点,而不是跨层对比,这也是考虑到在实际业务中很少会去跨层的移动 DOM 元素。 所以判断差异的算法就分为了两步
- 首先从上至下,从左往右遍历对象,也就是树的深度遍历,这一步中会给每个节点添加索引,便于最后渲染差异
- 一旦节点有子元素,就去判断子元素是否有不同
- 当我们判断出以上的差异后,就可以把这些差异记录下来。当对比完两棵树以后,就可以通过差异去局部更新 DOM,实现性能的最优化。
当然了 Virtual DOM 提高性能是其中一个优势,其实最大的优势还是在于:
- 将 Virtual DOM 作为一个兼容层,让我们还能对接非 Web 端的系统,实现跨端开发。
- 同样的,通过 Virtual DOM 我们可以渲染到其他的平台,比如实现 SSR、同构渲染等等。
- 实现组件的高度抽象化
路由原理
涉及面试题:前端路由原理?两种实现方式有什么区别?
前端路由实现起来其实很简单,本质就是监听 URL 的变化,然后匹配路由规则,显示相应的页面,并且无须刷新页面。目前前端使用的路由就只有两种实现方式
- Hash 模式
- History 模式
路由原理:本质就是监听 URL 的变化,然后匹配路由规则,显示相应的页面,并且无须刷新
路由需要实现三个功能:
①浏览器地址变化,切换页面;
②点击浏览器【后退】、【前进】按钮,网页内容跟随变化;
③刷新浏览器,网页加载当前路由对应内容
- hash 模式
点击跳转或者浏览器历史跳转当#
后面的哈希值发生变化时,不会向服务器请求数据,可以通过hashchange
事件来监听到 URL 的变化,从而进行跳转页面
手动刷新不会触发hashchange事件,可以采用load事件监听解析URL
匹配相应的路由规则,跳转到相应的页面,然后通过DOM替换更改页面内容
this.mode==='hash"
location.hash=path
history.pushState({path},"","?"+path)
- history 模式
利用history API实现url地址改变, 网页内容改变
History.back()、History.forward()、History.go()移动到以前访问过的页面时,页面通常是从浏览器缓存之中加载,而不是重新要求服务器发送新的网页
History.pushState()
用于在历史中添加一条记录。该方法接受三个参数,依次为一个与添加的记录相关联的状态对象:state;新页面的标题title;必须与当前页面处在同一个域下的新的网址url
该方法不会触发页面刷新,只是导致 History 对象发生变化,地址栏会有反应,不会触发hashchange事件History.replaceState()
方法用来修改 History 对象的当前记录
每当同一个文档的浏览历史(即history
对象)出现变化时,就会触发popstate
事件。pushState()
方法或replaceState()
不会触发
前进后退 history.back() history.forword() history.go()会触发
回调函数中可以获取event.state
页面第一次加载的时候,浏览器不会触发popstate
事件。
两种模式对比
- Hash 模式只可以更改
#
后面的内容,History 模式可以通过 API 设置任意的同源 URL - History 模式可以通过 API 添加任意类型的数据到历史记录中,Hash 模式只能更改哈希值,也就是字符串
- Hash 模式无需后端配置,并且兼容性好。History 模式在用户手动输入地址或者刷新页面的时候会发起 URL 请求,后端需要配置
index.html
页面用于匹配不到静态资源的时候
Vue 和 React 之间的区别
Vue 的表单可以使用 v-model
支持双向绑定,相比于 React 来说开发上更加方便,当然了 v-model
其实就是个语法糖,本质上和 React 写表单的方式没什么区别。
改变数据方式不同,Vue 修改状态相比来说要简单许多,React 需要使用 setState
来改变状态,并且使用这个 API 也有一些坑点。并且 Vue 的底层使用了依赖追踪,页面更新渲染已经是最优的了,但是 React 还是需要用户手动去优化这方面的问题。
React 16以后,有些钩子函数会执行多次,这是因为引入 Fiber 的原因,这在后续的章节中会讲到。
React 需要使用 JSX,有一定的上手成本,并且需要一整套的工具链支持,但是完全可以通过 JS 来控制页面,更加的灵活。Vue 使用了模板语法,相比于 JSX 来说没有那么灵活,但是完全可以脱离工具链,通过直接编写 render
函数就能在浏览器中运行。
在生态上来说,两者其实没多大的差距,当然 React 的用户是远远高于 Vue 的。
在上手成本上来说,Vue 一开始的定位就是尽可能的降低前端开发的门槛,然而 React 更多的是去改变用户去接受它的概念和思想,相较于 Vue 来说上手成本略高。
小结
这一章节中我们学习了几大框架中的相似点,也对比了 React 和 Vue 之间的区别。其实我们可以发现,React 和 Vue 虽然是两个不同的框架,但是他们的底层原理都是很相似的,无非在上层堆砌了自己的概念上去。所以我们无需去对比到底哪个框架牛逼,引用尤大的一句话
说到底,就算你证明了 A 比 B 牛逼,也不意味着你或者你的项目就牛逼了... 比起争这个,不如多想想怎么让自己变得更牛逼吧。
17、Vue 常考基础知识点
这一章节我们将来学习 Vue 的一些经常考到的基础知识点。
//组件的data 选项必须是一个函数,因此每个实例可以维护一份被返回对象的独立的拷贝,不然一个实例的data改变所有实例的date的引用都会改变,:
生命周期函数
Vue实例有一个完整的生命周期,也就是从开始创建、初始化数据、编译模板、挂载Dom、渲染→更新→渲染、销毁等一系列过程,我们称这是Vue的生命周期。通俗说就是Vue实例从创建到销毁的过程,就是生命周期。
每一个组件或者实例都会经历一个完整的生命周期,总共分为三个阶段:初始化、运行中、销毁。
- 实例、组件通过new Vue() 创建出来之后会初始化事件和生命周期,然后就会执行beforeCreate钩子函数,这个时候,数据还没有挂载呢,只是一个空壳,无法访问到数据和真实的dom,一般不做操作
- 挂载数据,绑定事件等等,然后执行created函数,这个时候已经可以使用到数据,也可以更改数据,在这里更改数据不会触发updated函数,在这里可以在渲染前倒数第二次更改数据的机会,不会触发其他的钩子函数,一般可以在这里做初始数据的获取
- 接下来开始找实例或者组件对应的模板,编译模板为虚拟dom放入到render函数中准备渲染,然后执行beforeMount钩子函数,在这个函数中虚拟dom已经创建完成,马上就要渲染,在这里也可以更改数据,不会触发updated,在这里可以在渲染前最后一次更改数据的机会,不会触发其他的钩子函数,一般可以在这里做初始数据的获取
- 接下来开始render,渲染出真实dom,然后执行mounted钩子函数,此时,组件已经出现在页面中,数据、真实dom都已经处理好了,事件都已经挂载好了,可以在这里操作真实dom等事情...
- 当组件或实例的数据更改之后,会立即执行beforeUpdate,然后vue的虚拟dom机制会重新构建虚拟dom与上一次的虚拟dom树利用diff算法进行对比之后重新渲染,一般不做什么事儿
- 当更新完成后,执行updated,数据已经更改完成,dom也重新render完成,可以操作更新后的虚拟dom
- 当经过某种途径调用$destroy方法后,立即执行beforeDestroy,一般在这里做一些善后工作,例如清除计时器、清除非指令绑定的事件等等
- 组件的数据绑定、监听...去掉后只剩下dom空壳,这个时候,执行destroyed,在这里做善后工作也可以
beforeCreate:此时获取不到prop和data中的数据;
created:可以获取到prop和data中的数据;
beforeMount:获取到了VDOM;
mounted:VDOM解析成了真实DOM;
beforeUpdate:在更新之前调用;
updated:在更新之后调用;
keep-alive:切换组件之后,组件放进activated,之前的组件放进deactivated;
beforeDestory:在组件销毁之前调用,可以解决内存泄露的问题,如setTimeout和setInterval造成的问题。 destory:组件销毁之后调用。
beforeCreate (使用的频率较低)
在实例创建以前
data 数据访问不到
created (使用频率高)
实例创建完
能拿到data下的数据,能修改data的数据
修改数据不会触发updated , beforeUpdate 钩子函数
取不到最终渲染完成的dom
beforeMount (在挂载之前)
编译模板已经结束
可以访问data数据
可以修改数据,修改数据不会触发updated , beforeUpdate 钩子函数
mounted (挂载)
真实的dom节点已经渲染到页面,可以操作渲染后的dom
可以访问和更改数据,会触发updated , beforeUpdate 钩子函数
beforeUpdate
修改之前会被调用
updated
修改数据之后会被调用
beforeDetroy
实例卸载之前会被调用,可以清理一些资源,防止内存泄漏
destroyed
computed: 基于现有的属性,衍生出来一个新的属性,如果数据不发生变化,会重缓存中读取
watch:观测data下数据的变化,当数据有变化时,会执行对应的函数
Vuex
1.简要介绍Vuex原理
Vuex实现了一个单向数据流,在全局拥有一个State存放数据,当组件要更改State中的数据时,必须通过Mutation进行,Mutation同时提供了订阅者模式供外部插件调用获取State数据的更新。而当所有异步操作(常见于调用后端接口异步获取更新数据)或批量的同步操作需要走action,但action也是无法直接修改State的,还是需要通过Mutation来修改State的数据。最后,根据State的变化,渲染到视图上。
2.简要介绍各模块在流程中的功能:
Vue Components
:Vue组件。HTML页面上,负责接收用户操作等交互行为,执行dispatch方法触发对应action进行回应。dispatch:
操作行为触发方法,是唯一能执行action的方法。actions:
操作行为处理模块,由组件中的 $store.dispatch(‘action 名称’,data)来触发。然后由commit()来触发mutation的调用 , 间接更新 state。负责处理Vue Components接收到的所有交互行为。commit:
状态改变提交操作方法。对mutation进行提交,是唯一能执行mutation的方法。mutations:
状态改变操作方法,由actions中的 commit(‘mutation 名称’)来触发。是Vuex修改state的唯一推荐方法。该方法只能进行同步操作,且方法名只能全局唯一。state:
页面状态管理容器对象。集中存储Vue components中data对象的零散数据,全局唯一,以进行统一的状态管理。getters:
Vue Components通过该方法读取全局state对象。
组件之间数据共享
总结概述
组件是 vue.js最强大的功能之一,而组件实例的作用域是相互独立的,这就意味着不同组件之间的数据无法相互引用。针对不同的使用场景,如何选择行之有效的通信方式?
1:props emit 缺点:如果组件嵌套层次多的话,数据传递比较繁琐
2:provide inject (依赖注入),缺点:不支持响应式
3:this.$root this.$parent this.$refs
4: eventbus 缺点:数据不支持响应式
5: vuex 缺点:数据的读取和修改需要按照流程来操作,不适合小型项目
父子通信:
父组件向子组件传递数据可以通过 props
;
子组件向父组件是通过 $emit
、$on
事件;provide / inject
;
还可以通过 $root
、$parent
、$refs
属性相互访问组件实例;
兄弟通信: eventbus
;Vuex
;
跨级通信: eventbus
;Vuex
;provide / inject;
一、 props/ $emit
父组件A通过props
的方式向子组件B传递,B to A 通过在 B 组件中 $emit,
A 组件中 v-on 的方式实现。
1、父组件向子组件传值
用“v-bind”
来告诉 Vue传给prop
一个什么类型的值; 子组件用props获取数据;
prop
只可以从上一级组件传递到下一级组件(父子组件),即所谓的单向数据流。
而且 prop
只读,不可被修改,所有修改都会失效并警告。
缺点:如果组件嵌套层次多的话,数据传递比较繁琐。
<div id="app">
<!-- 需要用“v-bind”来告诉 Vue传给prop一个什么类型的值;
左边":"后为父组件传入的变量名;右边为子组件props接受的变量名-->
<root-temp :catalogue.keyWord="catalogue" :articles="articleList"></root-temp>
</div>
<script type="text/template" id="childTemp">
<div>
<!-- 获取一个数组 -->
<li v-for="(item, index) in articles" :key="index">{{item}}</li>
</ol>
</div>
</script>
<script>
//全局注册的父组件
Vue.component('root-temp', {
props: ["articles"],
//子组件
template: "#childTemp",
})
var vm = new Vue({
el: "#app",
data: {
articleList: ['红楼梦', '西游记', '三国演义',"水浒传"]
},
})
</script>
</body>
</html>
2、子组件向父组件传值(通过事件形式)
对于$emit
我觉得理解是这样的: $emit
绑定一个自定义事件, 当这个语句被执行时, 就会将参数arg传递给父组件,父组件通过v-on监听并接收参数。通过一个例子,说明子组件如何向父组件传递数据。
总结:在子组件中对加减事件进行侦听,操作数据驱动显示;数据改变时,使用$emit
向外发布“counterchange
”事件,并将数据放在第二个参数传递;在父组件里通过@counterchange="handleChange"
就可以订阅handleChange
事件了;handleChange
函数拿到的参数赋给实例data选项中的一个属性;就可以在父级组件之间使用。
缺点:如果组件嵌套层次多的话,数据传递比较繁琐。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="../../vue.js"></script>
</head>
<body>
<div id="app">
{{count}}
<gp-counter @counterchange="handleChange"></gp-counter>
</div>
<script type="text/template" id="counterTemp">
<div>
<h1>gp-counter</h1>
<div>
<button @click="decrement(1)">-</button>
<button @click="increment(1)">+</button>
</div>
</div>
</script>
<script>
Vue.component('gp-counter', {
template: "#counterTemp",
//组件的data 选项必须是一个函数,因此每个实例可以维护一份被返回对象的独立的拷贝,不然一个实例的data改变所有实例的date的引用都会改变,:
data() {
return {
//子组件将要向外发送的数据:给个初始值为0
count: 0
}
},
methods: {
//子组件中对加减事件进行侦听,操作数据驱动显示
increment(num) {
this.count++;
//数据改变时,使用$emit向外发布“counterchange”事件,并将数据放在第二个参数传递;
this.$emit('counterchange', this.count)
},
decrement(num) {
this.count--;
this.$emit('counterchange', this.count)
}
}
})
var vm = new Vue({
el: "#app",
data: {
count: 0
},
methods: {
handleChange(num) {
this.count = num;
}
}
})
</script>
</body>
</html>
二、provide/ inject
概念:provide/ inject 是vue2.0新增的api,简单来说就是父组件中通过provide来提供变量, 然后再子组件中通过inject来注入变量;主要解决了跨级组件间的通信问题,不过它的使用场景,主要是子组件获取上级组件的状态,跨级组件间建立了一种主动提供与依赖注入的关系。接下来就用一个例子来验证上面的描述: 假设有三个组件: A、B、C 其中 C是B的子组件,B是A的子组件
const compc = {
//利用inject实现注入变量username
inject: ['username'],
template: '<h2>“孙子”组件------{{username}}</h2>'
}
var vm = new Vue({
el: "#app",
data: {
username: 'apple'
},
//provide函数结合data选项配置数据
provide: function () {
return {
username: this.username
}
},
components: {
compa
}
})
!
通过对比来看这里不论子组件嵌套有多深, 只要调用了inject 那么就可以注入provide中的数据,而不局限于只能从当前父组件的props属性中回去数据;注意配置和注入的变量名要保持一致。
缺点:provide inject
(依赖注入)不支持响应式
三、$root
、$parent
、$refs
1、$root
Vue 子组件可以通过$root
属性获取vue的根实例,比如在简单的项目中将公共数据放再vue根实例上(可以理解为一个全局 store
),因此可以代替vuex实现状态管理;
2、$parent
属性可以用来从一个子组件访问父组件的实例,可以替代将数据以 prop
的方式传入子组件的方式;当变更父级组件的数据的时候,容易造成调试和理解难度增加;
3、在子组件上使用ref
特性后,this.$refs
属性可以直接访问该子组件。可以代替事件$emit
和$on
的作用。使用方式是通过 ref
特性为这个子组件赋予一个 ID 引用,再通过this.$refs.testId
获取指定元素。注意:$refs
只会在组件渲染完成之后生效,并且它们不是响应式的。这仅作为一个用于直接操作子组件的“逃生舱”——你应该避免在模板或计算属性中访问 $refs
。
root 和parent 的区别
root 和parent 都能够实现访问父组件的属性和方法,
两者的区别在于,如果存在多级子组件,通过parent 访问得到的是它最近一级的父组件,通过root 访问得到的是根父组件。
所有子组件都可以将这个实例作为一个全局 store 来访问或使用
// 获取根组件的数据
this.$root.message
// 写入根组件的数据
this.$root.message= 2
// 访问根组件的计算属性
this.$root.selfdate
// 调用根组件的方法
this.$root.selfmethods()
$refs
访问子组件实例;通过在子组件标签定义 ref 属性,在父组件中可以使用$refs
访问子组件实例
<div id="app">
<button @click="add">通过ref访问子组件</button>
<input type="text" ref="inputDate"/>
</div>
<script>
var app = new Vue({
el: '#app',
methods: {
add:function(){
console.log("获取子组件的input.value---->",this.$refs.inputDate.value)
this.$refs.inputDate.value ="test"; //this.$refs.inputDate 减少获取dom节点的消耗
console.log("获取更改后的子组件input.value---->",this.$refs.inputDate.value)
}
}
})
</script>
四、eventBus
eventBus 又称为事件总线,在vue中可以使用它来作为沟通桥梁的概念, 就像是所有组件共用相同的事件中心,可以向该中心注册发送事件或接收事件, 所以组件都可以通知其他组件。在Vue的项目中怎么使用eventBus来实现组件之间的数据通信呢?具体通过下面几个步骤
1. 实例初始化
首先需要创建一个事件总线并将其导出, 以便其他模块可以使用或者监听它.
var eventbus = new Vue();
2. 发送事件
methods: {
handleClick() {
eventbus.$emit('message', 'hello world')
}
}
3. 接收事件
const compb = {
template: '<h3>EventBus-componentb</h3>',
mounted() {
eventbus.$on('message', function (msg) {
console.log(msg)
})
}
}
4. 移除事件监听
如果需要移除事件的监听:
mounted() {
eventbus.$off('message', function (msg) {
console.log(msg)
})
}
**缺点:**`eventbus` 方式数据不支持响应式;当项目较大,维护起来也比较困难。
五、Vuex
1.简要介绍Vuex原理
Vuex实现了一个单向数据流,在全局拥有一个State存放数据,当组件要更改State中的数据时,必须通过Mutation进行,Mutation同时提供了订阅者模式供外部插件调用获取State数据的更新。而当所有异步操作(常见于调用后端接口异步获取更新数据)或批量的同步操作需要走action,但action也是无法直接修改State的,还是需要通过Mutation来修改State的数据。最后,根据State的变化,渲染到视图上。
2.简要介绍各模块在流程中的功能:
Vue Components
:Vue组件。HTML页面上,负责接收用户操作等交互行为,执行dispatch方法触发对应action进行回应。dispatch:
操作行为触发方法,是唯一能执行action的方法。actions:
操作行为处理模块,由组件中的 $store.dispatch('action 名称',data)来触发。然后由commit()来触发mutation的调用 , 间接更新 state。负责处理Vue Components接收到的所有交互行为。commit:
状态改变提交操作方法。对mutation进行提交,是唯一能执行mutation的方法。mutations:
状态改变操作方法,由actions中的 commit('mutation 名称')来触发。是Vuex修改state的唯一推荐方法。该方法只能进行同步操作,且方法名只能全局唯一。state:
页面状态管理容器对象。集中存储Vue components中data对象的零散数据,全局唯一,以进行统一的状态管理。getters:
Vue Components通过该方法读取全局state对象。
浏览器适配
postcss-px-to-viewpor
https://www.npmjs.com/package...
https://www.postcss.parts/
移动端滚动条
better-scroll:
https://github.com/ustbhuangy...
虚拟DOM
一、真实DOM和其解析流程
浏览器渲染引擎工作流程都差不多,大致分为5步,创建DOM树——创建StyleRules——创建Render树——布局Layout——绘制Painting
第一步,用HTML分析器,分析HTML元素,构建一颗DOM树(标记化和树构建)。
第二步,用CSS分析器,分析CSS文件和元素上的inline样式,生成页面的样式表。
第三步,将DOM树和样式表,关联起来,构建一颗Render树(这一过程又称为Attachment)。每个DOM节点都有attach方法,接受样式信息,返回一个render对象(又名renderer)。这些render对象最终会被构建成一颗Render树。
第四步,有了Render树,浏览器开始布局,为每个Render树上的节点确定一个在显示屏上出现的精确坐标。
第五步,Render树和节点显示坐标都有了,就调用每个节点paint方法,把它们绘制出来。
DOM树的构建是文档加载完成开始的?
构建DOM数是一个渐进过程,为达到更好用户体验,渲染引擎会尽快将内容显示在屏幕上。它不必等到整个HTML文档解析完毕之后才开始构建render树和布局。
Render树是DOM树和CSSOM树构建完毕才开始构建的吗?
这三个过程在实际进行的时候又不是完全独立,而是会有交叉。会造成一边加载,一遍解析,一遍渲染的工作现象。
CSS的解析是从右往左逆向解析的(从DOM树的下-上解析比上-下解析效率高),嵌套标签越多,解析越慢。
二、JS操作真实DOM的代价!
用我们传统的开发模式,原生JS或JQ操作DOM时,浏览器会从构建DOM树开始从头到尾执行一遍流程。在一次操作中,我需要更新10个DOM节点,浏览器收到第一个DOM请求后并不知道还有9次更新操作,因此会马上执行流程,最终执行10次。例如,第一次计算完,紧接着下一个DOM更新请求,这个节点的坐标值就变了,前一次计算为无用功。计算DOM节点坐标值等都是白白浪费的性能。即使计算机硬件一直在迭代更新,操作DOM的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户体验。
为什么需要虚拟DOM,它有什么好处?
Web界面由DOM树(树的意思是数据结构)来构建,当其中一部分发生变化时,其实就是对应某个DOM节点发生了变化。
虚拟DOM就是为了解决浏览器性能问题而被设计出来的。如前,若一次操作中有10次更新DOM的动作,虚拟DOM不会立即操作DOM,而是将这10次更新的diff内容保存到本地一个JS对象中,最终将这个JS对象一次性attch到DOM树上,再进行后续操作,避免大量无谓的计算量。所以,用JS对象模拟DOM节点的好处是,页面的更新可以先全部反映在JS对象(虚拟DOM)上,操作内存中的JS对象的速度显然要更快,等更新完成后,再将最终的JS对象映射成真实的DOM,交由浏览器去绘制。
组件通信
1:props emit 缺点:如果组件嵌套层次多的话,数据传递比较繁琐
2:provide inject (依赖注入),缺点:不支持响应式
3:this.$root this.$parent this.$refs
4: eventbus 缺点:数据不支持响应式
5: vuex 缺点:数据的读取和修改需要按照流程来操作,不适合小型项目
我们可以按组件之间的三种关系简单归纳一下组件之间的通信方式:
进行父子组件通信:
1、父组件向子组件传递数据可以通过 props;缺点:如果组件嵌套层次多的话,数据传递比较繁琐
2、子组件向父组件是通过 $emit、$on事件;缺点:如果组件嵌套层次多的话,数据传递比较繁琐
3、provide / inject ;(依赖注入)
provide函数结合vue实例中data选项来配置属性数据 利用inject实现向组件中注入变量
4、还可以通过 $root、$parent、$refs属性相互访问组件实例;
root 和parent 的区别
root 和parent 都能够实现访问父组件的属性和方法,两者的区别在于,如果存在多级子组件,通过parent 访问得到的是它最近一级的父组件,通过root 访问得到的是根父组件。
所有子组件都可以将这个实例作为一个全局 store 来访问或使用
// 获取根组件的数据
this.$root.message
$refs
访问子组件实例;通过在子组件标签定义 ref 属性,在父组件中可以使用$refs
访问子组件实例
兄弟组件通信: eventbus ;Vuex;
跨级组件通信: eventbus ;Vuex;provide / inject;
eventBus 又称为事件总线,在vue中可以使用它来作为沟通桥梁的概念, 就像是所有组件共用相同的事件中心,可以向该中心注册发送事件或接收事件, 所以组件都可以通知其他组件。
- 实例初始化 var eventbus = new Vue();
- 发送事件 eventbus.$emit('message', 'hello world')
- 接受事件 eventbus.$on('message', function (msg) { console.log(msg) })
Vuex
1.简要介绍Vuex原理
Vuex实现了一个单向数据流,在全局拥有一个State存放数据,当组件要更改State中的数据时,必须通过Mutation进行,Mutation同时提供了订阅者模式供外部插件调用获取State数据的更新。而当所有异步操作(常见于调用后端接口异步获取更新数据)或批量的同步操作需要走action,但action也是无法直接修改State的,还是需要通过Mutation来修改State的数据。最后,根据State的变化,渲染到视图上。
2.简要介绍各模块在流程中的功能:
Vue Components:Vue组件。HTML页面上,负责接收用户操作等交互行为,执行dispatch方法触发对应action进行回应。
dispatch:操作行为触发方法,是唯一能执行action的方法。
actions:操作行为处理模块,由组件中的 $store.dispatch(‘action 名称’,data)来触发。然后由commit()来触发mutation的调用 , 间接更新 state。负责处理Vue Components接收到的所有交互行为。
commit:状态改变提交操作方法。对mutation进行提交,是唯一能执行mutation的方法。
mutations:状态改变操作方法,由actions中的 commit(‘mutation 名称’)来触发。是Vuex修改state的唯一推荐方法。该方法只能进行同步操作,且方法名只能全局唯一。
改变状态的唯一途经就是通过mutation,这样可以让共享状态易于预测。使用commit方法触发mutation处理函数;mutation的同步特性是出于调试的目的,可以借助开发者工具生成快照方便地调试应用。
state:页面状态管理容器对象。集中存储Vue components中data对象的零散数据,全局唯一,以进行统一的状态管理。
getters:Vue Components通过该方法读取全局state对象。
extend 能做什么
这个 API 很少用到,作用是扩展组件生成一个构造器,通常会与 $mount
一起使用。
// 创建组件构造器
let Component = Vue.extend({
template: '<div>test</div>'
})
// 挂载到 #app 上
new Component().$mount('#app')
// 除了上面的方式,还可以用来扩展已有的组件
let SuperComponent = Vue.extend(Component)
new SuperComponent({
created() {
console.log(1)
}
})
new SuperComponent().$mount('#app')
mixin 和 mixins 区别
mixin
用于全局混入,会影响到每个组件实例,通常插件都是这样做初始化的。
Vue.mixin({
beforeCreate() {
// ...逻辑
// 这种方式会影响到每个组件的 beforeCreate 钩子函数
}
})
虽然文档不建议我们在应用中直接使用 mixin
,但是如果不滥用的话也是很有帮助的,比如可以全局混入封装好的 ajax
或者一些工具函数等等。
mixins
应该是我们最常使用的扩展组件的方式了。如果多个组件中有相同的业务逻辑,就可以将这些逻辑剥离出来,通过 mixins
混入代码,比如上拉下拉加载数据这种逻辑等等。
另外需要注意的是 mixins
混入的钩子函数会先于组件内的钩子函数执行,并且在遇到同名选项的时候也会有选择性的进行合并,具体可以阅读 文档。
computed 和 watch 和methods区别
computed
是计算属性,依赖其他属性计算值,并且 computed
的值有缓存,只有当计算值变化才会返回内容。
watch
监听到值的变化就会执行回调,在回调中可以进行一些逻辑操作。
所以一般来说需要依赖别的属性来动态获得值的时候可以使用 computed
,对于监听到值的变化需要做一些复杂业务逻辑的情况可以使用 watch
。
另外 computed
和 watch
还都支持对象的写法,这种方式知道的人并不多。
vm.$watch('obj', {
// 深度遍历
deep: true,
// 立即触发
immediate: true,
// 执行的函数
handler: function(val, oldVal) {}
})
var vm = new Vue({
data: { a: 1 },
computed: {
aPlus: {
// this.aPlus 时触发
get: function () {
return this.a + 1
},
// this.aPlus = 1 时触发
set: function (v) {
this.a = v - 1
}
}
}
})
用 computed 属性方法编写的逻辑运算,在调用时直接将返回时 areas 视为一个变量值就可使用,无需进行函数调用。
computed 具有缓存功能,在系统刚运行的时候调用一次。只有只有当计算结果发生变化才会被调用。比如,我们在长度框与宽度框更改值的时候每次更改 computed 都会被调用一次,很浪费资源。
用 methods 方法编写的逻辑运算,在调用时 add() 一定要加“()”,methods 里面写的多位方法,调用方法一定要有()。
methods方法页面刚加载时调用一次,以后只有被调用的时候才会被调用。我们在长度框和宽度框的值输入完以后,点击“+” methods 方法调用一次。这里很明显我们采用 methods 会更节省资源。
keep-alive 组件有什么作用
如果你需要在组件切换的时候,保存一些组件的状态防止多次渲染,就可以使用 keep-alive
组件包裹需要保存的组件。
对于 keep-alive
组件来说,它拥有两个独有的生命周期钩子函数,分别为 activated
和 deactivated
。用 keep-alive
包裹的组件在切换时不会进行销毁,而是缓存到内存中并执行 deactivated
钩子函数,命中缓存渲染后会执行 actived
钩子函数。
v-show 与 v-if 区别
v-show
只是在 display: none
和 display: block
之间切换。无论初始条件是什么都会被渲染出来,后面只需要切换 CSS,DOM 还是一直保留着的。所以总的来说 v-show
在初始渲染时有更高的开销,但是切换开销很小,更适合于频繁切换的场景。
v-if
的话就得说到 Vue 底层的编译了。当属性初始为 false
时,组件就不会被渲染,直到条件为 true
,并且切换条件时会触发销毁/挂载组件,所以总的来说在切换时开销更高,更适合不经常切换的场景。
并且基于 v-if
的这种惰性渲染机制,可以在必要的时候才去渲染组件,减少整个页面的初始渲染开销。
组件中 data 什么时候可以使用对象
这道题目其实更多考的是 JS 功底。
组件复用时所有组件实例都会共享 data
,如果 data
是对象的话,就会造成一个组件修改 data
以后会影响到其他所有组件,所以需要将 data
写成函数,每次用到就调用一次函数获得新的数据。
当我们使用 new Vue()
的方式的时候,无论我们将 data
设置为对象还是函数都是可以的,因为 new Vue()
的方式是生成一个根组件,该组件不会复用,也就不存在共享 data
的情况了。
18、Vue 常考进阶知识点
这一章节我们将来学习 Vue 的一些经常考到的进阶知识点。这些知识点相对而言理解起来会很有难度,可能需要多次阅读才能理解。
vue整体概括
初始化 Vue 实例 ==》 设置数据劫持(Object.defineProperty) ==》模板编译(compile) ==》渲染(render function) ==》转化为虚拟 DOM(Object) ==》对比新老虚拟DOM(patch、diff)==》 更新视图(真实 dom)
1、传入实例参数
当我们开始写 Vue 项目时,首先初始化一个 Vue 实例,传入一个对象参数,参数中包括一下几个重要属性:
1{
2 el: '#app',
3 data: {
4 student: {
5 name: '公众号:小鹿动画学编程',
6 age: 20,
7 }
8 }
9 computed:{
10 ...
11 }
12 ...
13}
1) el:将渲染好的 DOM 挂载到页面中(可以传入一个 id,也可以传入一个 dom 节点)。
2) data:页面所需要的数据(对象类型,至于为什么,会在数据劫持内容说明)。
3) computed:计算属性,随着 data 中的数据变化,来更新页面关联的计算属性。
4) methods:实例所用到的方法集合。
除此之外,还有一些生命周期钩子函数等其他内容。
2、设置数据劫持
所谓的数据劫持,当 Vue 实例上的 data 中的数据改变时,对应的视图所用到的 data 中数据也会在页面改变。所以我们需要给 data 中的所有数据设置一个监听器,监听 data 的改变和获取,一旦数据改变,监听器会触发,通知页面,要改变数据了。
1 Object.defineProperty(obj, key, {
2 get() {
3 return value;
4 },
5 set: newValue => {
6 console.log(---------------更新视图--------------------)
7 }
8 }
数据劫持的实现就是给每一个 data绑定 Object.defineProperty()。对于 Object.defineProperty()的用法,自己详细看 MDN ,这也是 MVVM的核心实现 API,下遍很多东西都是围绕着它转。
3、模板编译(compile)
拿到传入 dom 对象和 data 数据了,如果将这些 data 渲染到 HTML 所对应的 {{student.age}}、v-model="student.name" 等标签中,这个过程就是模板编译的过程,主要解析模板中的指令、class、style等等数据。
1// 把当前节点放到内存中去(因为频繁渲染造成回流和重绘)
2let fragment = this.nodefragment(this.el);
3
4// 把节点在内存中替换(编译模板,数据编译)
5this.compile(fragment);
6
7// 把内容塞回页面
8this.el.appendChild(fragment);
我们通过 el 拿到 dom 对象,然后将这个当前的 dom 节点拿到内存中去,然后将数据和 dom 节点进行替换合并,然后再把结果塞会到页面中。下面会根据代码实现,具体展开分享。
4、虚拟 DOM(Virtual DOM)
所谓虚拟 DOM,其实就是一个 javascript对象,说白了就是对真实 DOM 的一个描述对象,和真实 dom做一个映射。
1// 真实 DOM
2<div>
3 <span>HelloWord</span>
4</div>
5
6
7// 虚拟 DOM —— 以上的真实 DOM 被虚拟 DOM 表示如下:
8{
9 children:(1) [{…}] // 子元素
10 domElement: div // 对应的真实 dom
11 key: undefined // key 值
12 props: {} // 标签对应的属性
13 text: undefined // 文本内容
14 type: "div" // 节点类型
15 ...
16}
一旦页面数据有变化,我们不直接操作更新真实 DOM,而是更新虚拟 DOM,又因为虚拟 DOM和真实 DOM有映射关系,所有真实 DOM也被简洁更新,避免了回流和重绘造成性能上的损失。
对于虚拟 DOM,主要核心涉及到 diff算法,新老虚拟结点如何检查差异的,然后又是如何进行更新的,后边会展开一点点讲。
5、对比新老虚拟 DOM(patch)
patch 主要是对更新后的新节点和更新前的节点进行比对,比对的核心算法就是 diff 算法,比如新节点的属性值不同,新节点又增加了一个子元素等变化,都需要通过这个过程,将最后新的虚拟 DOM 更新到视图上,呈现最新的变化,这个过程是一个核心部分,面试也是经常问到的。
6、更新视图(update view)
当第一次加载 Vue 实例的时候,我们将渲染好的数据挂载到页面中。当我们已经将实例挂载到了真实 dom 上,我们更新数据时,新老节点对比完成,拿到对比的最新数据状态,然后更新到视图上去。
Vue 2.0 的数据依赖实现原理简析
以上就是在Vue
实例初始化的过程中实现依赖管理的分析。大致的总结下就是:
initState
的过程中,将props
,computed
,data
等属性通过Object.defineProperty
来改造其getter/setter
属性,并为每一个响应式属性实例化一个observer
观察者。这个observer
内部dep
记录了这个响应式属性的所有依赖。- 当响应式属性调用
setter
函数时,通过dep.notify()
方法去遍历所有的依赖,调用watcher.update()
去完成数据的动态响应。
这篇文章主要从初始化的数据
首先让我们从最简单的一个实例Vue
入手:
const app = new Vue({
// options 传入一个选项obj.这个obj即对于这个vue实例的初始化
})
通过查阅文档,我们可以知道这个options
可以接受:
选项/数据
- data
- props
- propsData(方便测试使用)
- computed
- methods
- watch
- 选项 / DOM
- 选项 / 生命周期钩子
- 选项 / 资源
- 选项 / 杂项
具体未展开的内容请自行查阅相关文档,接下来让我们来看看传入的选项/数据
是如何管理数据之间的相互依赖的。
const app = new Vue({
el: '#app',
props: {
a: {
type: Object,
default () {
return {
key1: 'a',
key2: {
a: 'b'
}
}
}
}
},
data: {
msg1: 'Hello world!',
arr: {
arr1: 1
}
},
watch: {
a (newVal, oldVal) {
console.log(newVal, oldVal)
}
},
methods: {
go () {
console.log('This is simple demo')
}
}
})
我们使用Vue
这个构造函数去实例化了一个vue
实例app
。传入了props
, data
, watch
, methods
等属性。在实例化的过程中,Vue
提供的构造函数就使用我们传入的options
去完成数据的依赖管理,初始化的过程只有一次,但是在你自己的程序当中,数据的依赖管理的次数不止一次。
那Vue
的构造函数到底是怎么实现的呢?Vue
// 构造函数
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
// 对Vue这个class进行mixin,即在原型上添加方法
// Vue.prototype.* = function () {}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
当我们调用new Vue
的时候,事实上就调用的Vue
原型上的_init
方法.
// 原型上提供_init方法,新建一个vue实例并传入options参数
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// a uid
vm._uid = uid++
let startTag, endTag
// a flag to avoid this being observed
vm._isVue = true
// merge options
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options)
} else {
// 将传入的这些options选项挂载到vm.$options属性上
vm.$options = mergeOptions(
// components/filter/directive
resolveConstructorOptions(vm.constructor),
// this._init()传入的options
options || {},
vm
)
}
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
initProxy(vm)
} else {
vm._renderProxy = vm
}
// expose real self
vm._self = vm // 自身的实例
// 接下来所有的操作都是在这个实例上添加方法
initLifecycle(vm) // lifecycle初始化
initEvents(vm) // events初始化 vm._events, 主要是提供vm实例上的$on/$emit/$off/$off等方法
initRender(vm) // 初始化渲染函数,在vm上绑定$createElement方法
callHook(vm, 'beforeCreate') // 钩子函数的执行, beforeCreate
initInjections(vm) // resolve injections before data/props
initState(vm) // Observe data添加对data的监听, 将data转化为getters/setters
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created') // 钩子函数的执行, created
// vm挂载的根元素
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
其中在this._init()
方法中调用initState(vm)
,完成对vm
这个实例的数据的监听,也是本文所要展开说的具体内容。
export function initState (vm: Component) {
// 首先在vm上初始化一个_watchers数组,缓存这个vm上的所有watcher
vm._watchers = []
// 获取options,包括在new Vue传入的,同时还包括了Vue所继承的options
const opts = vm.$options
// 初始化props属性
if (opts.props) initProps(vm, opts.props)
// 初始化methods属性
if (opts.methods) initMethods(vm, opts.methods)
// 初始化data属性
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
// 初始化computed属性
if (opts.computed) initComputed(vm, opts.computed)
// 初始化watch属性
if (opts.watch) initWatch(vm, opts.watch)
}
initProps
我们在实例化app
的时候,在构造函数里面传入的options
中有props
属性:
props: {
a: {
type: Object,
default () {
return {
key1: 'a',
key2: {
a: 'b'
}
}
}
}
}
function initProps (vm: Component, propsOptions: Object) {
// propsData主要是为了方便测试使用
const propsData = vm.$options.propsData || {}
// 新建vm._props对象,可以通过app实例去访问
const props = vm._props = {}
// cache prop keys so that future props updates can iterate using Array
// instead of dynamic object key enumeration.
// 缓存的prop key
const keys = vm.$options._propKeys = []
const isRoot = !vm.$parent
// root instance props should be converted
observerState.shouldConvert = isRoot
for (const key in propsOptions) {
// this._init传入的options中的props属性
keys.push(key)
// 注意这个validateProp方法,不仅完成了prop属性类型验证的,同时将prop的值都转化为了getter/setter,并返回一个observer
const value = validateProp(key, propsOptions, propsData, vm)
// 将这个key对应的值转化为getter/setter
defineReactive(props, key, value)
// static props are already proxied on the component's prototype
// during Vue.extend(). We only need to proxy props defined at
// instantiation here.
// 如果在vm这个实例上没有key属性,那么就通过proxy转化为proxyGetter/proxySetter, 并挂载到vm实例上,可以通过app._props[key]这种形式去访问
if (!(key in vm)) {
proxy(vm, `_props`, key)
}
}
observerState.shouldConvert = true
}
接下来看下validateProp(key, propsOptions, propsData, vm)
方法内部到底发生了什么。
export function validateProp (
key: string,
propOptions: Object, // $options.props属性
propsData: Object, // $options.propsData属性
vm?: Component
): any {
const prop = propOptions[key]
// 如果在propsData测试props上没有缓存的key
const absent = !hasOwn(propsData, key)
let value = propsData[key]
// 处理boolean类型的数据
// handle boolean props
if (isType(Boolean, prop.type)) {
if (absent && !hasOwn(prop, 'default')) {
value = false
} else if (!isType(String, prop.type) && (value === '' || value === hyphenate(key))) {
value = true
}
}
// check default value
if (value === undefined) {
// default属性值,是基本类型还是function
// getPropsDefaultValue见下面第一段代码
value = getPropDefaultValue(vm, prop, key)
// since the default value is a fresh copy,
// make sure to observe it.
const prevShouldConvert = observerState.shouldConvert
observerState.shouldConvert = true
// 将value的所有属性转化为getter/setter形式
// 并添加value的依赖
// observe方法的分析见下面第二段代码
observe(value)
observerState.shouldConvert = prevShouldConvert
}
if (process.env.NODE_ENV !== 'production') {
assertProp(prop, key, value, vm, absent)
}
return value
}
// 获取prop的默认值
function getPropDefaultValue (vm: ?Component, prop: PropOptions, key: string): any {
// no default, return undefined
// 如果没有default属性的话,那么就返回undefined
if (!hasOwn(prop, 'default')) {
return undefined
}
const def = prop.default
// the raw prop value was also undefined from previous render,
// return previous default value to avoid unnecessary watcher trigger
if (vm && vm.$options.propsData &&
vm.$options.propsData[key] === undefined &&
vm._props[key] !== undefined) {
return vm._props[key]
}
// call factory function for non-Function types
// a value is Function if its prototype is function even across different execution context
// 如果是function 则调用def.call(vm)
// 否则就返回default属性对应的值
return typeof def === 'function' && getType(prop.type) !== 'Function'
? def.call(vm)
: def
}
Vue
提供了一个observe
方法,在其内部实例化了一个Observer
类,并返回Observer
的实例。每一个Observer
实例对应记录了props
中这个的default value
的所有依赖(仅限object
类型),这个Observer
实际上就是一个观察者,它维护了一个数组this.subs = []
用以收集相关的subs(订阅者)
(即这个观察者的依赖)。通过将default value
转化为getter/setter
形式,同时添加一个自定义__ob__
属性,这个属性就对应Observer
实例。
说起来有点绕,还是让我们看看我们给的demo
里传入的options
配置:
props: {
a: {
type: Object,
default () {
return {
key1: 'a',
key2: {
a: 'b'
}
}
}
}
}
在往上数的第二段代码里面的方法obervse(value)
,即对{key1: 'a', key2: {a: 'b'}}
进行依赖的管理,同时将这个obj
所有的属性值都转化为getter/setter
形式。此外,Vue
还会将props
属性都代理到vm
实例上,通过vm.a
就可以访问到这个属性。
此外,还需要了解下在Vue
中管理依赖的一个非常重要的类: Dep
export default class Dep {
constructor () {
this.id = uid++
this.subs = []
}
addSub () {...} // 添加订阅者(依赖)
removeSub () {...} // 删除订阅者(依赖)
depend () {...} // 检查当前Dep.target是否存在以及判断这个watcher已经被添加到了相应的依赖当中,如果没有则添加订阅者(依赖),如果已经被添加了那么就不做处理
notify () {...} // 通知订阅者(依赖)更新
}
在Vue
的整个生命周期当中,你所定义的响应式的数据上都会绑定一个Dep
实例去管理其依赖。它实际上就是观察者
和订阅者
联系的一个桥梁。
刚才谈到了对于依赖的管理,它的核心之一就是观察者Observer
这个类:
export class Observer {
value: any;
dep: Dep;
vmCount: number; // number of vms that has this object as root $data
constructor (value: any) {
this.value = value
// dep记录了和这个value值的相关依赖
this.dep = new Dep()
this.vmCount = 0
// value其实就是vm._data, 即在vm._data上添加__ob__属性
def(value, '__ob__', this)
// 如果是数组
if (Array.isArray(value)) {
// 首先判断是否能使用__proto__属性
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
// 遍历数组,并将obj类型的属性改为getter/setter实现
this.observeArray(value)
} else {
// 遍历obj上的属性,将每个属性改为getter/setter实现
this.walk(value)
}
}
/**
* Walk through each property and convert them into
* getter/setters. This method should only be called when
* value type is Object.
*/
// 将每个property对应的属性都转化为getter/setters,只能是当这个value的类型为Object时
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
/**
* Observe a list of Array items.
*/
// 监听array中的item
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}
walk
方法里面调用defineReactive
方法:通过遍历这个object
的key
,并将对应的value
转化为getter/setter
形式,通过闭包维护一个dep
,在getter
方法当中定义了这个key
是如何进行依赖的收集,在setter
方法中定义了当这个key
对应的值改变后,如何完成相关依赖数据的更新。但是从源码当中,我们却发现当getter
函数被调用的时候并非就一定会完成依赖的收集,其中还有一层判断,就是Dep.target
是否存在。
/**
* Define a reactive property on an Object.
*/
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: Function
) {
// 每个属性新建一个dep实例,管理这个属性的依赖
const dep = new Dep()
// 或者属性描述符
const property = Object.getOwnPropertyDescriptor(obj, key)
// 如果这个属性是不可配的,即无法更改
if (property && property.configurable === false) {
return
}
// cater for pre-defined getter/setters
const getter = property && property.get
const setter = property && property.set
// 递归去将val转化为getter/setter
// childOb将子属性也转化为Observer
let childOb = observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
// 定义getter -->> reactiveGetter
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
// 定义相应的依赖
if (Dep.target) {
// Dep.target.addDep(this)
// 即添加watch函数
// dep.depend()及调用了dep.addSub()只不过中间需要判断是否这个id的dep已经被包含在内了
dep.depend()
// childOb也添加依赖
if (childOb) {
childOb.dep.depend()
}
if (Array.isArray(value)) {
dependArray(value)
}
}
return value
},
// 定义setter -->> reactiveSetter
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
// 对得到的新值进行observe
childOb = observe(newVal)
// 相应的依赖进行更新
dep.notify()
}
})
}
在上文中提到了Dep
类是链接观察者
和订阅者
的桥梁。同时在Dep
的实现当中还有一个非常重要的属性就是Dep.target
,它事实就上就是一个订阅者,只有当Dep.target
(订阅者)存在的时候,调用属性的getter
函数的时候才能完成依赖的收集工作。
Dep.target = null
const targetStack = []
export function pushTarget (_target: Watcher) {
if (Dep.target) targetStack.push(Dep.target)
Dep.target = _target
}
export function popTarget () {
Dep.target = targetStack.pop()
}
那么Vue
是如何来实现订阅者
的呢?Vue
里面定义了一个类: Watcher
,在Vue
的整个生命周期当中,会有4类地方会实例化Watcher
:
Vue
实例化的过程中有watch
选项Vue
实例化的过程中有computed
计算属性选项Vue
原型上有挂载$watch
方法: Vue.prototype.$watch,可以直接通过实例调用this.$watch
方法Vue
生成了render
函数,更新视图时
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: Object
) {
// 缓存这个实例vm
this.vm = vm
// vm实例中的_watchers中添加这个watcher
vm._watchers.push(this)
// options
if (options) {
this.deep = !!options.deep
this.user = !!options.user
this.lazy = !!options.lazy
this.sync = !!options.sync
} else {
this.deep = this.user = this.lazy = this.sync = false
}
this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.lazy // for lazy watchers
....
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = function () {}
}
}
// 通过get方法去获取最新的值
// 如果lazy为true, 初始化的时候为undefined
this.value = this.lazy
? undefined
: this.get()
}
get () {...}
addDep () {...}
update () {...}
run () {...}
evaluate () {...}
run () {...}
Watcher
接收的参数当中expOrFn
定义了用以获取watcher
的getter
函数。expOrFn
可以有2种类型:string
或function
.若为string
类型,首先会通过parsePath
方法去对string
进行分割(仅支持.
号形式的对象访问)。在除了computed
选项外,其他几种实例化watcher
的方式都是在实例化过程中完成求值及依赖的收集工作:this.value = this.lazy ? undefined : this.get()
.在Watcher
的get
方法中:
!!!前方高能
get () {
// pushTarget即设置当前的需要被执行的watcher
pushTarget(this)
let value
const vm = this.vm
if (this.user) {
try {
// $watch(function () {})
// 调用this.getter的时候,触发了属性的getter函数
// 在getter中进行了依赖的管理
value = this.getter.call(vm, vm)
console.log(value)
} catch (e) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
}
} else {
// 如果是新建模板函数,则会动态计算模板与data中绑定的变量,这个时候就调用了getter函数,那么就完成了dep的收集
// 调用getter函数,则同时会调用函数内部的getter的函数,进行dep收集工作
value = this.getter.call(vm, vm)
}
// "touch" every property so they are all tracked as
// dependencies for deep watching
// 让每个属性都被作为dependencies而tracked, 这样是为了deep watching
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
return value
}
一进入get
方法,首先进行pushTarget(this)
的操作,此时Vue
当中Dep.target = 当前这个watcher
,接下来进行value = this.getter.call(vm, vm)
操作,在这个操作中就完成了依赖的收集工作。还是拿文章一开始的demo
来说,在vue
实例化的时候传入了watch
选项:
props: {
a: {
type: Object,
default () {
return {
key1: 'a',
key2: {
a: 'b'
}
}
}
}
},
watch: {
a (newVal, oldVal) {
console.log(newVal, oldVal)
}
},
在Vue
的initState()
开始执行后,首先会初始化props
的属性为getter/setter
函数,然后在进行initWatch
初始化的时候,这个时候初始化watcher
实例,并调用get()
方法,设置Dep.target = 当前这个watcher实例
,进而到value = this.getter.call(vm, vm)
的操作。在调用this.getter.call(vm, vm)
的方法中,便会访问props
选项中的a
属性即其getter
函数。在a
属性的getter
函数执行过程中,因为Dep.target
已经存在,那么就进入了依赖收集
的过程:
if (Dep.target) {
// Dep.target.addDep(this)
// 即添加watch函数
// dep.depend()及调用了dep.addSub()只不过中间需要判断是否这个id的dep已经被包含在内了
dep.depend()
// childOb也添加依赖
if (childOb) {
childOb.dep.depend()
}
if (Array.isArray(value)) {
dependArray(value)
}
}
dep
是一开始初始化的过程中,这个属性上的dep
属性。调用dep.depend()
函数:
depend () {
if (Dep.target) {
// Dep.target为一个watcher
Dep.target.addDep(this)
}
}
Dep.target
也就刚才的那个watcher
实例,这里也就相当于调用了watcher
实例的addDep
方法: watcher.addDep(this)
,并将dep
观察者传入。在addDep
方法中完成依赖收集:
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
这个时候依赖完成了收集,当你去修改a
属性的值时,会调用a
属性的setter
函数,里面会执行dep.notify()
,它会遍历所有的订阅者,然后调用订阅者上的update
函数。
initData
过程和initProps
类似,具体可参见源码。
initComputed
以上就是在initProps
过程中Vue
是如何进行依赖收集的,initData
的过程和initProps
类似,下来再来看看initComputed
的过程. 在computed
属性初始化的过程当中,会为每个属性实例化一个watcher
:
const computedWatcherOptions = { lazy: true }
function initComputed (vm: Component, computed: Object) {
// 新建_computedWatchers属性
const watchers = vm._computedWatchers = Object.create(null)
for (const key in computed) {
const userDef = computed[key]
// 如果computed为funtion,即取这个function为getter函数
// 如果computed为非function.则可以单独为这个属性定义getter/setter属性
let getter = typeof userDef === 'function' ? userDef : userDef.get
// create internal watcher for the computed property.
// lazy属性为true
// 注意这个地方传入的getter参数
// 实例化的过程当中不去完成依赖的收集工作
watchers[key] = new Watcher(vm, getter, noop, computedWatcherOptions)
// component-defined computed properties are already defined on the
// component prototype. We only need to define computed properties defined
// at instantiation here.
if (!(key in vm)) {
defineComputed(vm, key, userDef)
}
}
}
但是这个watcher
在实例化的过程中,由于传入了{lazy: true}
的配置选项,那么一开始是不会进行求值与依赖收集的: this.value = this.lazy ? undefined : this.get()
.在initComputed
的过程中,Vue
会将computed
属性定义到vm
实例上,同时将这个属性定义为getter/setter
。当你访问computed
属性的时候调用getter
函数:
function createComputedGetter (key) {
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
// 是否需要重新计算
if (watcher.dirty) {
watcher.evaluate()
}
// 管理依赖
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
}
}
在watcher
存在的情况下,首先判断watcher.dirty
属性,这个属性主要是用于判断这个computed
属性是否需要重新求值,因为在上一轮的依赖收集的过程当中,观察者已经将这个watcher
添加到依赖数组当中了,如果观察者发生了变化,就会dep.notify()
,通知所有的watcher
,而对于computed
的watcher
接收到变化的请求后,会将watcher.dirty = true
即表明观察者发生了变化,当再次调用computed
属性的getter
函数的时候便会重新计算,否则还是使用之前缓存的值。
initWatch
initWatch
的过程中其实就是实例化new Watcher
完成观察者的依赖收集的过程,在内部的实现当中是调用了原型上的Vue.prototype.$watch
方法。这个方法也适用于vm
实例,即在vm
实例内部调用this.$watch
方法去实例化watcher
,完成依赖的收集,同时监听expOrFn
的变化。
总结:
以上就是在Vue
实例初始化的过程中实现依赖管理的分析。大致的总结下就是:
initState
的过程中,将props
,computed
,data
等属性通过Object.defineProperty
来改造其getter/setter
属性,并为每一个响应式属性实例化一个observer
观察者。这个observer
内部dep
记录了这个响应式属性的所有依赖。- 当响应式属性调用
setter
函数时,通过dep.notify()
方法去遍历所有的依赖,调用watcher.update()
去完成数据的动态响应。
这篇文章主要从初始化的数据层面上分析了Vue
是如何管理依赖来到达数据的动态响应。下一篇文章来分析下Vue
中模板中的指令和响应式数据是如何关联来实现由数据驱动视图,以及数据是如何响应视图变化的。
\#深入阅读Vue.js源码
diff
1、patch (oldVnode, vnode)
函数接收两个参数 oldVnode
和 Vnode
分别代表新的节点和之前的旧节点
如果两个节点都是一样的,那么就执行 patchVnode
深入检查他们的子节点。如果两个节点不一样那就说明 Vnode
完全被改变了,就可以直接替换 oldVnode
。
1、patchVnode(oldVnode, vnode)([src/core/vdom/patch.js]
:更新真实
dom节点的
data属性,相当于对
dom节点进行了预处理的操作
这其中的diff
过程中又分了好几种情况
- 首先进行文本节点的判断,若新老dom节点文本不同,那么就会直接进行文本节点的替换;
- 在新dom节点情况下,进入子节点的
diff
; - 当老子节点和新子节点都存在且不相同的情况下,调用
updateChildren
对子节点进行diff
; - 若老子节点不存在,新子节点存在,先清空老节点的文本节点,同时调用
addVnodes
方法将新子节点添加到elm
真实dom
节点当中; - 若老子节点存在,新子节点不存在,则删除
elm
真实节点下的老子节点; - 若老节点有文本节点,而新节点没有,就清空这个文本节点。
2、updateChildren
( parentElm, oldCh, newCh) 最重要的环节:
会将新老DOM节点提取出来首先给老子节点和新子节点
分别分配一个startIndex
和endIndex
来作为遍历的索引,
当老子节点或者新子节点遍历完后
(遍历完的条件就是老子节点或者新子节点的startIndex >= endIndex
),
就停止老子节点
和新子节点的diff
过程。
接下来通过实例来看下整个diff
的过程(节点属性中不带key
的情况):
首先从第一个节点开始比较,不管是老子节点还是新子节点的起始或者终止节点都不存在
sameVnode
,同时节点属性中是不带key
标记的,因此第一轮的diff
完后,新子节点的startVnode
被添加到oldStartVnode
的前面,同时新节点起始索引+1;function` `sameVnode (a, b) { ``return` `( ``a.key === b.key && ``// key值 ``a.tag === b.tag && ``// 标签名 ``a.isComment === b.isComment && ``// 是否为注释节点 ``// 是否都定义了data,data包含一些具体信息,例如onclick , style ``isDef(a.data) === isDef(b.data) && ``sameInputType(a, b) ``// 当标签是的时候,type必须相同 ``) }
第二轮的
diff
中,满足sameVnode(oldStartVnode, newStartVnode)
,因此对这2个
vnode
进行diff
,最后将
patch
标记的差异反应到当前比较的老节点上同时
oldStartVnode
和newStartIndex
+1位第三轮的
diff
中,满足sameVnode(最后位置老节点, 当前比较位置新节点)
,那么首先对
oldEndVnode
和newStartVnode
进行diff
,并对
oldEndVnode
进行patch
,并完成
oldEndVnode
移位的操作,最后newStartIndex
+1,oldStartVnode
后移一位;
[- 遍历的过程结束后,
newStartIndex > newEndIndex
,说明此时老子节点存在多余的节点,那么最后就需要将这些多余的节点删除。
在vnode
不带key
的情况下,
每一轮的diff
过程当中都是起始
和结束
节点进行比较,直到oldCh
或者newCh
被遍历完。
而当为vnode
引入key
属性后,在每一轮的diff
过程中,
当起始
和结束
节点都没有找到sameVnode
时,首先对oldCh
中进行key
值与索引的映射:
3、createKeyToOldIdx
方法 key
用以将老子节点作为键
,而对应的节点的索引作为值
。然后再判断在newStartVnode
的属性中是否有key
,且是否在oldKeyToIndx
中找到对应的节点。
- 如果不存在这个
key
,那么就将这个newStartVnode
作为新的节点创建且插入到原有的root
的子节点前面: 如果存在这个
key
,// 将找到的key一致的oldVnode再和newStartVnode,然后再进行
diff
的过程:通过以上分析,给
vdom
上添加key
属性后,遍历diff
的过程中,当起始点
,结束点
的搜寻
及diff
出现还是无法匹配的情况下时,就会用key
来作为唯一标识,来进行diff
,这样就可以提高diff
效率。
注意在第一轮的diff
过后oldCh
上的B节点
被删除了,但是newCh
上的B节点
上elm
属性保持对oldCh
上B节点
的elm
引用。
Vue 双向绑定原理分析
当我们学习angular或者vue的时候,其双向绑定为我们开发带来了诸多便捷,今天我们就来分析一下vue双向绑定的原理。
首先我们为每个vue属性用Object.defineProperty()实现数据劫持,为每个属性分配一个订阅者集合的管理数组dep;然后在编译的时候在该属性的数组dep中添加订阅者,v-model会添加一个订阅者,{{}}也会,v-bind也会,只要用到该属性的指令理论上都会,接着为input会添加监听事件,修改值就会为该属性赋值,触发该属性的set方法,在set方法内通知订阅者数组dep,订阅者数组循环调用各订阅者的update方法更新视图。
1.vue双向绑定原理
vue.js 则是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()
来劫持各个属性的setter
,getter
,在数据变动时发布消息给订阅者,触发相应的监听回调。我们先来看Object.defineProperty()这个方法:
var obj = {};
Object.defineProperty(obj, 'name', {
get: function() {
console.log('我被获取了')
return val;
},
set: function (newVal) {
console.log('我被设置了')
}
})
obj.name = 'fei';//在给obj设置name属性的时候,触发了set这个方法
var val = obj.name;//在得到obj的name属性,会触发get方法
已经了解到vue是通过数据劫持的方式来做数据绑定的,其中最核心的方法便是通过Object.defineProperty()
来实现对属性的劫持,那么在设置或者获取的时候我们就可以在get或者set方法里加入其他的触发函数,达到监听数据变动的目的,无疑这个方法是本文中最重要、最基础的内容之一。
2.实现最简单的双向绑定
我们知道通过Object.defineProperty()可以实现数据劫持,使得属性在赋值的时候触发set方法,
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<div id="demo"></div>
<input type="text" id="inp">
<script>
var obj = {};
var demo = document.querySelector('#demo')
var inp = document.querySelector('#inp')
Object.defineProperty(obj, 'name', {
get: function() {
return val;
},
set: function (newVal) {//当该属性被赋值的时候触发
inp.value = newVal;
demo.innerHTML = newVal;
}
})
inp.addEventListener('input', function(e) {
// 给obj的name属性赋值,进而触发该属性的set方法
obj.name = e.target.value;
});
obj.name = 'fei';//在给obj设置name属性的时候,触发了set这个方法
</script>
</body>
</html>
当然要是这么粗暴,肯定不行,性能会出很多的问题。
3.讲解vue如何实现
先看原理图
3.1 observer用来实现对每个vue中的data中定义的属性循环用Object.defineProperty()实现数据劫持,以便利用其中的setter和getter,然后通知订阅者,订阅者会触发它的update方法,对视图进行更新。
3.2 我们介绍为什么要订阅者,在vue中v-model,v-name,{{}}等都可以对数据进行显示,也就是说假如一个属性都通过这三个指令了,那么每当这个属性改变的时候,相应的这个三个指令的html视图也必须改变,于是vue中就是每当有这样的可能用到双向绑定的指令,就在一个Dep中增加一个订阅者,其订阅者只是更新自己的指令对应的数据,也就是v-model='name'和{{name}}有两个对应的订阅者,各自管理自己的地方。每当属性的set方法触发,就循环更新Dep中的订阅者。
4.vue代码实现
4.1 observer实现,主要是给每个vue的属性用Object.defineProperty(),代码如下:
function defineReactive (obj, key, val) {
var dep = new Dep();
Object.defineProperty(obj, key, {
get: function() {
//添加订阅者watcher到主题对象Dep
if(Dep.target) {
// JS的浏览器单线程特性,保证这个全局变量在同一时间内,只会有同一个监听器使用
dep.addSub(Dep.target);
}
return val;
},
set: function (newVal) {
if(newVal === val) return;
val = newVal;
console.log(val);
// 作为发布者发出通知
dep.notify();//通知后dep会循环调用各自的update方法更新视图
}
})
}
function observe(obj, vm) {
Object.keys(obj).forEach(function(key) {
defineReactive(vm, key, obj[key]);
})
}
4.2实现compile:
compile的目的就是解析各种指令成真正的html。
function Compile(node, vm) {
if(node) {
this.$frag = this.nodeToFragment(node, vm);
return this.$frag;
}
}
Compile.prototype = {
nodeToFragment: function(node, vm) {
var self = this;
var frag = document.createDocumentFragment();
var child;
while(child = node.firstChild) {
console.log([child])
self.compileElement(child, vm);
frag.append(child); // 将所有子节点添加到fragment中
}
return frag;
},
compileElement: function(node, vm) {
var reg = /\{\{(.*)\}\}/;
//节点类型为元素(input元素这里)
if(node.nodeType === 1) {
var attr = node.attributes;
// 解析属性
for(var i = 0; i < attr.length; i++ ) {
if(attr[i].nodeName == 'v-model') {//遍历属性节点找到v-model的属性
var name = attr[i].nodeValue; // 获取v-model绑定的属性名
node.addEventListener('input', function(e) {
// 给相应的data属性赋值,进而触发该属性的set方法
vm[name]= e.target.value;
});
new Watcher(vm, node, name, 'value');//创建新的watcher,会触发函数向对应属性的dep数组中添加订阅者,
}
};
}
//节点类型为text
if(node.nodeType === 3) {
if(reg.test(node.nodeValue)) {
var name = RegExp.$1; // 获取匹配到的字符串
name = name.trim();
new Watcher(vm, node, name, 'nodeValue');
}
}
}
}
4.3 watcher实现
function Watcher(vm, node, name, type) {
Dep.target = this;
this.name = name;
this.node = node;
this.vm = vm;
this.type = type;
this.update();
Dep.target = null;
}
Watcher.prototype = {
update: function() {
this.get();
this.node[this.type] = this.value; // 订阅者执行相应操作
},
// 获取data的属性值
get: function() {
console.log(1)
this.value = this.vm[this.name]; //触发相应属性的get
}
}
4.4 实现Dep来为每个属性添加订阅者
function Dep() {
this.subs = [];
}
Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
})
}
}
这样一来整个数据的双向绑定就完成了。
5.梳理
首先我们为每个vue属性用Object.defineProperty()实现数据劫持,为每个属性分配一个订阅者集合的管理数组dep;然后在编译的时候在该属性的数组dep中添加订阅者,v-model会添加一个订阅者,{{}}也会,v-bind也会,只要用到该属性的指令理论上都会,接着为input会添加监听事件,修改值就会为该属性赋值,触发该属性的set方法,在set方法内通知订阅者数组dep,订阅者数组循环调用各订阅者的update方法更新视图。
编译过程
想必大家在使用 Vue 开发的过程中,基本都是使用模板的方式。那么你有过「模板是怎么在浏览器中运行的」这种疑虑嘛?
首先直接把模板丢到浏览器中肯定是不能运行的,模板只是为了方便开发者进行开发。Vue 会通过编译器将模板通过几个阶段最终编译为 render
函数,然后通过执行 render
函数生成 Virtual DOM 最终映射为真实 DOM。
接下来我们就来学习这个编译的过程,了解这个过程中大概发生了什么事情。这个过程其中又分为三个阶段,分别为:
- 将模板解析为 AST
- 优化 AST
- 将 AST 转换为
render
函数
在第一个阶段中,最主要的事情还是通过各种各样的正则表达式去匹配模板中的内容,然后将内容提取出来做各种逻辑操作,接下来会生成一个最基本的 AST 对象
{
// 类型
type: 1,
// 标签
tag,
// 属性列表
attrsList: attrs,
// 属性映射
attrsMap: makeAttrsMap(attrs),
// 父节点
parent,
// 子节点
children: []
}
然后会根据这个最基本的 AST 对象中的属性,进一步扩展 AST。
当然在这一阶段中,还会进行其他的一些判断逻辑。比如说对比前后开闭标签是否一致,判断根组件是否只存在一个,判断是否符合 HTML5 Content Model 规范等等问题。
接下来就是优化 AST 的阶段。在当前版本下,Vue 进行的优化内容其实还是不多的。只是对节点进行了静态内容提取,也就是将永远不会变动的节点提取了出来,实现复用 Virtual DOM,跳过对比算法的功能。在下一个大版本中,Vue 会在优化 AST 的阶段继续发力,实现更多的优化功能,尽可能的在编译阶段压榨更多的性能,比如说提取静态的属性等等优化行为。
最后一个阶段就是通过 AST 生成 render
函数了。其实这一阶段虽然分支有很多,但是最主要的目的就是遍历整个 AST,根据不同的条件生成不同的代码罢了。
NextTick 原理分析
nextTick
可以让我们在下次 DOM 更新循环结束之后执行延迟回调,用于获得更新后的 DOM。
在 Vue 2.4 之前都是使用的 microtasks,但是 microtasks 的优先级过高,在某些情况下可能会出现比事件冒泡更快的情况,但如果都使用 macrotasks 又可能会出现渲染的性能问题。所以在新版本中,会默认使用 microtasks,但在特殊情况下会使用 macrotasks,比如 v-on。
对于实现 macrotasks ,会先判断是否能使用 setImmediate
,不能的话降级为 MessageChannel
,以上都不行的话就使用 setTimeout
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
} else if (
typeof MessageChannel !== 'undefined' &&
(isNative(MessageChannel) ||
// PhantomJS
MessageChannel.toString() === '[object MessageChannelConstructor]')
) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
以上代码很简单,就是判断能不能使用相应的 API。
小结
以上就是 Vue 的几个高频核心问题了,如果你还想了解更多的源码相关的细节,强烈推荐黄老师的 Vue 技术揭秘。
19、React 常考基础知识点
这一章节我们将来学习 React 的一些经常考到的基础知识点。
React的特点和优势
- 虚拟DOM
我们以前操作dom的方式是通过document.getElementById()的方式,这样的过程实际上是先去读取html的dom结构,将结构转换成变量,再进行操作
而reactjs定义了一套变量形式的dom模型,一切操作和换算直接在变量中,这样减少了操作真实dom,性能真实相当的高,和主流MVC框架有本质的区别,并不和dom打交道
- 组件系统
react最核心的思想是将页面中任何一个区域或者元素都可以看做一个组件 component
那么什么是组件呢?
组件指的就是同时包含了html、css、js、image元素的聚合体
使用react开发的核心就是将页面拆分成若干个组件,并且react一个组件中同时耦合了css、js、image,这种模式整个颠覆了过去的传统的方式
- 单向数据流
其实reactjs的核心内容就是数据绑定,所谓数据绑定指的是只要将一些服务端的数据和前端页面绑定好,开发者只关注实现业务就行了
- JSX 语法
在vue中,我们使用render函数来构建组件的dom结构性能较高,因为省去了查找和编译模板的过程,但是在render中利用createElement创建结构的时候代码可读性较低,较为复杂,此时可以利用jsx语法来在render中创建dom,解决这个问题,但是前提是需要使用工具来编译jsx
生命周期
在 V16 版本中引入了 Fiber 机制。这个机制一定程度上的影响了部分生命周期的调用,并且也引入了新的 2 个 API 来解决问题,关于 Fiber 的内容将会在下一章节中讲到。
在之前的版本中,如果你拥有一个很复杂的复合组件,然后改动了最上层组件的 state
,那么调用栈可能会很长
调用栈过长,再加上中间进行了复杂的操作,就可能导致长时间阻塞主线程,带来不好的用户体验。Fiber 就是为了解决该问题而生。
Fiber 本质上是一个虚拟的堆栈帧,新的调度器会按照优先级自由调度这些帧,从而将之前的同步渲染改成了异步渲染,在不影响体验的情况下去分段计算更新。
对于如何区别优先级,React 有自己的一套逻辑。对于动画这种实时性很高的东西,也就是 16 ms 必须渲染一次保证不卡顿的情况下,React 会每 16 ms(以内) 暂停一下更新,返回来继续渲染动画。
对于异步渲染,现在渲染有两个阶段:reconciliation
和 commit
。前者过程是可以打断的,后者不能暂停,会一直更新界面直到完成。
Reconciliation 阶段
componentWillMount
componentWillReceiveProps
shouldComponentUpdate
componentWillUpdate
Commit 阶段
componentDidMount
componentDidUpdate
componentWillUnmount
因为 Reconciliation 阶段是可以被打断的,所以 Reconciliation 阶段会执行的生命周期函数就可能会出现调用多次的情况,从而引起 Bug。由此对于 Reconciliation 阶段调用的几个函数,除了 shouldComponentUpdate
以外,其他都应该避免去使用,并且 V16 中也引入了新的 API 来解决这个问题。
getDerivedStateFromProps
用于替换 componentWillReceiveProps
,该函数会在初始化和 update
时被调用
getSnapshotBeforeUpdate
用于替换 componentWillUpdate
,该函数会在 update
后 DOM 更新前被调用,用于读取最新的 DOM 数据。
React中组件也有生命周期,也就是说也有很多钩子函数供我们使用, 组件的生命周期,我们会分为四个阶段,初始化、运行中、销毁、错误处理(16.3之后)
一、初始化阶段
1、设置组件的默认属性
static defaultProps = {
name: 'sls', age:23 }; //or Counter.defaltProps={name:'sls'}
2、constructor 设置组件的初始化状态
constructor() {
super();
this.state = {number: 0}
}
3、getDerivedStateFromProps()
该函数会在初始化和 update
时被调用
4、componentWillMount()
组件即将被渲染到页面之前触发,此时可以进行开启定时器、向服务器发送请求等操作
5、render()
组件渲染
6、componentDidMount()
组件已经被渲染到页面中后触发:此时页面中有了真正的DOM的元素,可以进行DOM相关的操作
二、运行中阶段
props
或state
的改变可能会引起组件的更新,组件重新渲染的过程中会调用以下方法:
1、componentWillReceiveProps()
组件接收到属性时触发
3、 getDerivedStateFromProps()
4、shouldComponentUpdate()
当组件接收到新属性,或者组件的状态发生改变时触发。组件首次渲染时并不会触发
shouldComponentUpdate(newProps, newState) {
if (newProps.number < 5) return true; return false }
//该钩子函数可以接收到两个参数,新的属性和状态,返回true/false来控制组件是否需要更新。
一般我们通过该函数来优化性能:
一个React项目需要更新一个小组件时,很可能需要父组件更新自己的状态。而一个父组件的重新更新会造成它旗下所有的子组件重新执行render()方法,形成新的虚拟DOM,再用diff算法对新旧虚拟DOM进行结构和属性的比较,决定组件是否需要重新渲染
无疑这样的操作会造成很多的性能浪费,所以我们开发者可以根据项目的业务逻辑,在shouldComponentUpdate()中加入条件判断,从而优化性能
例如React中的就提供了一个PureComponent的类,当我们的组件继承于它时,组件更新时就会默认先比较新旧属性和状态,从而决定组件是否更新。值得注意的是,PureComponent进行的是浅比较,所以组件状态或属性改变时,都需要返回一个新的对象或数组
5、componentWillUpdate()
组件即将被更新时触发
6、render()
7、getSnapshotBeforeUpdate()
8、componentDidUpdate()
组件被更新完成后触发。页面中产生了新的DOM的元素,可以进行DOM操作
三、销毁阶段
componentWillUnmount()
组件被销毁时触发。这里我们可以进行一些清理操作,例如清理定时器,取消Redux的订阅事件等等。
1. componentWillReceiveProps() / UNSAFE_componentWillReceiveProps()
2.
3. shouldComponentUpdate()
4. componentWillUpdate() / UNSAFE_componentWillUpdate()
5. render()
6. getSnapshotBeforeUpdate()
7. componentDidUpdate()
卸载阶段
- componentWillUnmount()
错误处理
- componentDidCatch()
各生命周期详解
1.constructor(props)
React组件的构造函数在挂载之前被调用。在实现React.Component
构造函数时,需要先在添加其他内容前,调用super(props)
,用来将父组件传来的props
绑定到这个类中,使用this.props
将会得到。
官方建议不要在constructor
引入任何具有副作用和订阅功能的代码,这些应当使用componentDidMount()
。
constructor
中应当做些初始化的动作,如:
初始化state
,
将事件处理函数绑定到类实例上,
但也不要使用setState()
。
如果没有必要初始化state或绑定方法,则不需要构造constructor
,
或者把这个组件换成纯函数写法。
当然也可以利用props
初始化state
,在之后修改state
不会对props
造成任何修改,但仍然建议大家提升状态到父组件中,或使用redux
统一进行状态管理。
constructor(props) {
super(props);
this.state = {
isLiked: props.isLiked
};
}
2. getDerivedStateFromProps(nextProps, prevState)
getDerivedStateFromProps
是react16.3之后新增,
在组件实例化后,和接受新的props
后被调用。
他必须返回一个对象来更新状态,或者返回null表示新的props不需要任何state的更新。
如果是由于父组件的props
更改,所带来的重新渲染,也会触发此方法。
调用setState()
不会触发getDerivedStateFromProps()
。
之前这里都是使用constructor
+componentWillRecieveProps
完成相同的功能的
3.componentWillMount()
componentWillMount()
将在React未来版本(官方说法 17.0)中被弃用。UNSAFE_componentWillMount()
在组件挂载前被调用,
在这个方法中调用setState()
不会起作用,是由于他在render()
前被调用。
为了避免副作用和其他的订阅,官方都建议使用componentDidMount()
代替。
这个方法是用于在服务器渲染上的唯一方法。
这个方法因为是在渲染之前被调用,也是惟一一个可以直接同步修改state的地方。
4.render()
render()方法是必需的。当他被调用时,他将计算this.props
和this.state
,并返回以下一种类型:
- React元素。通过jsx创建,既可以是dom元素,也可以是用户自定义的组件。
- 字符串或数字。他们将会以文本节点形式渲染到dom中。
- Portals。react 16版本中提出的新的解决方案,可以使组件脱离父组件层级直接挂载在DOM树的任何位置。
- null,什么也不渲染
- 布尔值。也是什么都不渲染。
当返回null
,false
,ReactDOM.findDOMNode(this)
将会返回null,什么都不会渲染。
render()
方法必须是一个纯函数,他不应该改变state
,也不能直接和浏览器进行交互,应该将事件放在其他生命周期函数中。
如果shouldComponentUpdate()
返回false
,render()
不会被调用。
5.componentDidMount
componentDidMount
在组件被装配后立即调用。初始化获得DOM节点应该进行到这里。
通常在这里进行ajax请求
如果要初始化第三方的dom库,也在这里进行初始化。只有到这里才能获取到真实的dom.
6.componentWillReceiveProps()
官方建议使用getDerivedStateFromProps
函数代替componentWillReceiveProps
。当组件挂载后,接收到新的props
后会被调用。如果需要更新state
来响应props
的更改,则可以进行this.props
和nextProps
的比较,并在此方法中使用this.setState()
。
如果父组件会让这个组件重新渲染,即使props
没有改变,也会调用这个方法。
React不会在组件初始化props时调用这个方法。调用this.setState
也不会触发。
7.shouldComponentUpdate(nextProps, nextState)
调用shouldComponentUpdate
使React知道,组件的输出是否受state
和props
的影响。默认每个状态的更改都会重新渲染,大多数情况下应该保持这个默认行为。
在渲染新的props
或state
前,shouldComponentUpdate
会被调用。默认为true
。
这个方法不会在初始化时被调用,
也不会在forceUpdate()
时被调用。
返回false
不会阻止子组件在state
更改时重新渲染。
如果shouldComponentUpdate()
返回false
,componentWillUpdate
,render
和componentDidUpdate
不会被调用。
官方并不建议在shouldComponentUpdate()
中进行深度查询或使用JSON.stringify()
,他效率非常低,并且损伤性能。
8.UNSAFE_componentWillUpdate(nextProps, nextState)
在渲染新的state
或props
时,UNSAFE_componentWillUpdate
会被调用,将此作为在更新发生之前进行准备的机会。
这个方法不会在初始化时被调用。
不能在这里使用this.setState(),
也不能做会触发视图更新的操作。
如果需要更新state
或props
,调用getDerivedStateFromProps
。
9.getSnapshotBeforeUpdate()
在react render()
后的输出被渲染到DOM之前被调用。
它使您的组件能够在它们被潜在更改之前捕获当前值(如滚动位置)。
这个生命周期返回的任何值都将作为参数传递给componentDidUpdate()。
10.componentDidUpdate(prevProps, prevState, snapshot)
在更新发生后立即调用componentDidUpdate()
。
此方法不用于初始渲染。
当组件更新时,将此作为一个机会来操作DOM。
只要您将当前的props与以前的props进行比较(例如,如果props没有改变,则可能不需要网络请求),这也是做网络请求的好地方。
如果组件实现getSnapshotBeforeUpdate()
生命周期,则它返回的值将作为第三个“快照”参数传递给componentDidUpdate()
。否则,这个参数是undefined
。
11.componentWillUnmount()
在组件被卸载并销毁之前立即被调用。
在此方法中执行任何必要的清理,
例如使定时器无效,
取消网络请求或清理在componentDidMount
中创建的任何监听。
12.componentDidCatch(error, info)
错误边界是React组件,可以在其子组件树中的任何位置捕获JavaScript错误,记录这些错误并显示回退UI,而不是崩溃的组件树。
错误边界在渲染期间,生命周期方法以及整个树下的构造函数中捕获错误。
如果类组件定义了此生命周期方法,则它将成错误边界。
在它中调用setState()
可以让你在下面的树中捕获未处理的JavaScript错误,并显示一个后备UI。
只能使用错误边界从意外异常中恢复; 不要试图将它们用于控制流程。
错误边界只会捕获树中下面组件中的错误。错误边界本身不能捕获错误。
13.PureComponent
PureComponnet
里如果接收到的新属性或者是更改后的状态和原属性、原状态相同的话,就不会去重新render了
在里面也可以使用shouldComponentUpdate
,而且。
是否重新渲染以shouldComponentUpdate
的返回值为最终的决定因素。
import React, { PureComponent } from 'react'
class YourComponent extends PureComponent {
……
}
setState
setState有时表现出异步,有时表现出同步
setState只在合成事件和钩子函数中是“异步”的,在原生事件和setTimeout 中都是同步的。
setState 的“异步”并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是合成事件和钩子函数的调用顺序在更新之前,导致在合成事件和钩子函数中没法立马拿到更新后的值,形成了所谓的“异步”,当然可以通过第二个参数 setState(partialState, callback) 中的callback拿到更新后的结果。
setState 的批量更新优化也是建立在“异步”(合成事件、钩子函数)之上的,在原生事件和setTimeout 中不会批量更新,在“异步”中如果对同一个值进行多次setState,setState的批量更新策略会对其进行覆盖,取最后一次的执行,如果是同时setState多个不同的值,在更新时会对其进行合并批量更新
setState
在 React 中是经常使用的一个 API,但是它存在一些的问题经常会导致初学者出错,核心原因就是因为这个 API 是异步的。
首先 setState
的调用并不会马上引起 state
的改变,并且如果你一次调用了多个 setState
,那么结果可能并不如你期待的一样。
handle() {
// 初始化 `count` 为 0
console.log(this.state.count) // -> 0
this.setState({ count: this.state.count + 1 })
this.setState({ count: this.state.count + 1 })
this.setState({ count: this.state.count + 1 })
console.log(this.state.count) // -> 0
}
第一,两次的打印都为 0,因为 setState
是个异步 API,只有同步代码运行完毕才会执行。setState
异步的原因我认为在于,setState
可能会导致 DOM 的重绘,如果调用一次就马上去进行重绘,那么调用多次就会造成不必要的性能损失。设计成异步的话,就可以将多次调用放入一个队列中,在恰当的时候统一进行更新过程。
第二,虽然调用了三次 setState
,但是 count
的值还是为 1。因为多次调用会合并为一次,只有当更新结束后 state
才会改变,三次调用等同于如下代码
Object.assign(
{},
{ count: this.state.count + 1 },
{ count: this.state.count + 1 },
{ count: this.state.count + 1 },
)w
当然你也可以通过以下方式来实现调用三次 setState
使得 count
为 3
handle() {
this.setState((prevState) => ({ count: prevState.count + 1 }))
this.setState((prevState) => ({ count: prevState.count + 1 }))
this.setState((prevState) => ({ count: prevState.count + 1 }))
}
如果你想在每次调用 setState
后获得正确的 state
,可以通过如下代码实现
handle() {
this.setState((prevState) => ({ count: prevState.count + 1 }), () => {
console.log(this.state)
})
}
通信
其实 React 中的组件通信基本和 Vue 中的一致。同样也分为以下三种情况:
- 父子组件通信
- 兄弟组件通信
- 跨多层级组件通信
- 任意组件
父子通信
父组件通过 props
传递数据给子组件,子组件通过调用父组件传来的函数传递数据给父组件,这两种方式是最常用的父子通信实现办法。
这种父子通信方式也就是典型的单向数据流,父组件通过 props
传递数据,子组件不能直接修改 props
, 而是必须通过调用父组件函数的方式告知父组件修改数据。
兄弟组件通信
对于这种情况可以通过共同的父组件来管理状态和事件函数。比如说其中一个兄弟组件调用父组件传递过来的事件函数修改父组件中的状态,然后父组件将状态传递给另一个兄弟组件。
跨多层次组件通信
如果你使用 16.3 以上版本的话,对于这种情况可以使用 Context API。
// 创建 Context,可以在开始就传入值
const StateContext = React.createContext()
class Parent extends React.Component {
render () {
return (
// value 就是传入 Context 中的值
<StateContext.Provider value='yck'>
<Child />
</StateContext.Provider>
)
}
}
class Child extends React.Component {
render () {
return (
<ThemeContext.Consumer>
// 取出值
{context => (
name is { context }
)}
</ThemeContext.Consumer>
);
}
}
context.js
import React from 'react'
const {
Provider,
Consumer: MapCounsumer
} = React.createContext();
class MapProvider extends React.Component {
constructor() {
super();
this.state = {
showMap: JSON.parse(localStorage.getItem('showMap')) || false
}
}
changeStatus = () => {
this.setState((preState) => {
return {
showMap: !preState.showMap
}
}, () => {
localStorage.setItem('showMap', this.state.showMap)
})
}
render() {
return (
<Provider value={{
showMap: this.state.showMap,
changeStatus: this.changeStatus
}}>
{
this.props.children
}
</Provider>
)
}
}
export {
MapProvider,
MapCounsumer
}
//index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import "assets/styles/reset.css"
import { MapProvider } from './context/MapContext'
ReactDOM.render(
<MapProvider>
<App />
</MapProvider>
,
document.getElementById('root')
);
more.js
import React, { Component } from 'react'
import {Switch} from 'antd-mobile'
import {MapCounsumer} from 'context/MapContext'
export default class More extends Component {
render() {
return (
<MapCounsumer>
{
({showMap,changeStatus})=>{
return (
<>
地图:
<Switch checked={showMap} onChange={changeStatus}></Switch>
</>
)
}
}
</MapCounsumer>
)
}
}
任意组件
这种方式可以通过 Redux 或者 Event Bus 解决,另外如果你不怕麻烦的话,可以使用这种方式解决上述所有的通信情况
redux和mobx区别
Redux的流程:
1.store通过reducer创建了初始状态;
2.view通过store.getState()将store中保存的state挂载在了自己的状态上;
3.用户产生了操作,调用了actions 的方法;
4.actions的方法被调用,创建了带有标示性信息的action;
5.actions将action通过调用store.dispatch方法发送到了reducer中;
6.reducer接收到action并根据标识信息判断之后返回了新的state;
7.store的state被reducer更改为新state的时候,store.subscribe方法里的回调函数会执行,此时就可以通知view去重新获取state;
react-redux提供两个核心的api:
- Provider: 提供store
- connect: 用于连接容器组件和展示组件
Provider
根据单一store原则 ,一般只会出现在整个应用程序的最顶层。
connect
语法格式为
connect(mapStateToProps?, mapDispatchToProps?, mergeProps?, options?)(component)
一般来说只会用到前面两个,它的作用是:
- 把
store.getState()
的状态转化为展示组件的props
- 把
actionCreators
转化为展示组件props
上的方法
- 把
特别强调:
官网上的第二个参数为mapDispatchToProps, 实际上就是actionCreators
只要上层中有Provider
组件并且提供了store
, 那么,子孙级别的任何组件,要想使用store
里的状态,都可以通过connect
方法进行连接。如果只是想连接actionCreators
,可以第一个参数传递为null
两者对比:
1、redux将数据保存在单一的store中,mobx将数据保存在分散的多个store中
2、redux使用 扁平的对象 保存数据,需要手动处理变化后的操作;mobx使用observable保存数据,数据变化后自动处理响应的操作
3、redux使用不可变状态,这意味着状态是只读的,不能直接去修改它,而是应该返回一个新的状态,同时使用纯函数;mobx中的状态是可变的,可以直接对其进行修改
4、mobx相对来说比较简单,mobx更多的使用面向对象的编程思维;redux会比较复杂,因为其中的函数式编程思想掌握起来不是那么容易,同时需要借助一系列的中间件来处理异步和副作用
5、mobx调试会比较困难;而redux提供能够进行时间回溯的开发工具,让调试变得更加的容易
场景辨析:
基于以上区别,我们可以简单得分析一下两者的不同使用场景.
1、mobx更适合数据不复杂的应用: mobx难以调试,很多状态无法回溯,面对复杂度高的应用时,往往力不从心.
2、redux适合有回溯需求的应用: 比如一个画板应用、一个表格应用,很多时候需要撤销、重做等操作,由于redux不可变的特性,天然支持这些操作.
3、mobx适合短平快的项目: mobx上手简单,样板代码少,可以很大程度上提高开发效率.
4、当然mobx和redux也并不一定是非此即彼的关系,你也可以在项目中用redux作为全局状态管理,用mobx作为组件局部状态管理器来用
简述 flux 思想
Flux 的最大特点,就是数据的"单向流动"。
用户访问 View
View 发出用户的 Action
Dispatcher 收到 Action,要求 Store 进行相应的更新
Store 更新后,发出一个"change"事件
View 收到"change"事件后,更新页面
shouldComponentUpdate和componentWillReciveProps
shouldComponentUpdate 这个方法用来判断是否需要调用 render 方法重新描绘 dom。因为 dom 的描绘非常消耗性能,如果我们能在 shouldComponentUpdate 方法中能够写出更优化的 dom diff 算法,可以极大的提高性能
componentWillReceiveProps在初始化render的时候不会执行,它会在Component接受到新的状态(Props)时被触发,一般用于父组件状态更新时子组件的重新渲染。React 16之后这个生命周期被废弃.
React合成事件和原生事件区别
React合成事件机制:React并不是将click事件直接绑定在dom上面,而是采用事件冒泡的形式冒泡到document上面,然后将事件内容封装交给中间层,最后将事件交给正式的函数运行和处理--
Hooks的原理是什么?
代码可读性更强,原本同一块功能的代码逻辑被拆分在了不同的生命周期函数中,容易使开发者不利于维护和迭代,通过 React Hooks 可以将功能代码聚合,方便阅读维护
组件树层级变浅,在原本的代码中,我们经常使用 HOC/render props 等方式来复用组件的状态,增强功能等,无疑增加了组件树层数及渲染,而在 React Hooks 中,这些功能都可以通过强大的自定义的 Hooks 来实现
1、useState 保存组件状态
2、useEffect 处理副作用
3、useContext 减少组件层级
4、useReducer
在使用上几乎跟 Redux/React-Redux 一模一样,唯一缺少的就是无法使用 redux 提供的中间件。用法也很简单
5、useCallback 记忆函数
function App() {
const memoizedHandleClick = useCallback(() => {
console.log('Click happened')
}, []); // 空数组代表无论什么情况下该函数都不会发生改变
return <SomeComponent onClick={memoizedHandleClick}>Click Me</SomeComponent>;
}
通过 useCallback 获得一个记忆后的函数。
第二个参数传入一个数组,数组中的每一项一旦值或者引用发生改变,useCallback 就会重新返回一个新的记忆函数提供给后面进行渲染。
6、useMemo 记忆组件:
只有在第二个参数数组的值发生变化时,才会触发子组件的更新。
useCallback 的功能完全可以由 useMemo 所取代,
唯一的区别是:useCallback 不会执行第一个参数函数,而是将它返回给你,而 useMemo 会执行第一个函数并且将函数执行结果返回给你。所以在前面的例子中,可以返回 handleClick 来达到存储函数的目的。
所以 useCallback 常用记忆事件函数,生成记忆后的事件函数并传递给子组件使用。而 useMemo 更适合经过函数计算得到一个确定的值,比如记忆组件。
7、useRef 保存引用值:
useRef 创建一个引用,不是拷贝
8、useImperativeHandle 透传 Ref
9、通过 useImperativeHandle 用于让父组件获取子组件内的DOM
10、useLayoutEffect 同步执行副作用
会在 DOM 更新之后同步执行;useLayoutEffect 会在 render,DOM 更新之后同步触发函数,会优于 useEffect 异步触发函数。
render为什么会执行两次
父组件发生变化 子组件即使props state没发生变化也会执行render 只是diff后不再渲染dom
为了解决这个问题可以通过使用PureComponent 类
PureComponent 是优化 React 应用程序最重要的方法之一。只要把继承类从 Component 换成 PureComponent ,当组件更新时,如果组件的 props 和 state 都没发生改变,render 方法就不会触发,省去 Virtual DOM 的生成和比对过程可以减少不必要的 render 操作的次数,从而提高性能,而且可以少写 shouldComponentUpdate 函数,节省了点代码。
React 受控组件和非受控组件
在React中,所谓受控组件和非受控组件,是针对表单而言的
受控组件,表单元素的修改会实时映射到状态值上,此时就可以对输入的内容进行校验
受控组件必须要在表单上使用onChange事件来绑定对应的事件
非受控组件即不受状态的控制,获取数据就是相当于操作DOM
react中如何实现命名插槽
在Vue中,假如我们需要让子组件的一部分内容,被父组件控制,而不是被子组件控制,那么我们会采用插槽的写法 <slot></slot>
在 React 里也有类似的写法,父组件写法是相同的,但子组件是采用 {this.props.children} 来实现
react中具名插槽只要使用属性传递就行
如何理解的jsx
所谓的 JSX 其实就是 JavaScript 对象,就是用 JavaScript 对象来表现一个 DOM 元素的结构。JSX就是让 JavaScript 语言能够支持这种直接在 JavaScript 代码里面编写类似 HTML 标签结构的语法,这样写起来就方便很多。编译的过程会把类似 HTML 的 JSX 结构转换成 JavaScript 的对象结构。
shouldComponentUpdate参数
shouldComponentUpdate函数是重渲染时render()函数调用前被调用的函数,它接受两个参数:nextProps和nextState,分别表示下一个props和下一个state的值。并且,当函数返回false时候,阻止接下来的render()函数的调用,阻止组件重渲染,而返回true时,组件照常重渲染。
react的render
只要组件的状态(props或者state)发生了更改,那么组件就会默认执行render函数重新进行渲染(你也可以通过重写shouldComponentUpdate手动阻止这件事的发生)。同时要注意的事情是,执行render函数并不意味着浏览器中的真实 DOM 树需要修改。浏览器中的真实 DOM 是否需要发生修改,是由 React 最后比较 Virtual Tree 决定的。 我们都知道修改浏览器中的真实 DOM 是非常耗费性能的一件事,于是 React 为我们做出了优化。但是执行render的代价仍然需要我们自己承担
注意点
在redux中,reducer是纯函数,需要返回一个全新的对象才会触发redux对state的修改,如果是在原对象上进行修改则无法触发,因为redux会先比较前后两份state的引用,相同就return
小结
总的来说这一章节的内容更多的偏向于 React 的基础,另外 React 的面试题还会经常考到 Virtual DOM 中的内容,所以这块内容大家也需要好好准备。
下一章节我们将来了解一些 React 的进阶知识内容。
20、React 常考进阶知识点
这一章节我们将来学习 React 的一些经常考到的进阶知识点,并且这章节还需要配合第十九章阅读,其中的内容经常会考到。
React码源
如今,主流的前端框架React,Vue和Angular在前端领域已成三足鼎立之势,基于前端技术栈的发展现状,大大小小的公司或多或少也会使用其中某一项或者多项技术栈,那么掌握并熟练使用其中至少一种也成为了前端人员必不可少的技能饭碗。当然,框架的部分实现细节也常成为面试中的考察要点,因此,一方面为了应付面试官的连番追问,另一方面为了提升自己的技能水平,还是有必要对框架的底层实现原理有一定的涉猎。
面试考点
看完以上内容,按道理来说以下几个可能的面试考点应该就不成问题了,或者说至少也不会遇到一个字也回答不了的尴尬局面,试试看吧:
- 在React中为何能够支持
jsx
语法 - 类组件的
render
方法执行后最终返回的结果是什么 - 手写代码实现一个
createElement
方法 - 如何判断一个对象是不是
React Element
- 如何区分类组件和函数定义组件
Component
和PureComponent
之间的关系- 如何区分
Component
和PureComponent
1、准备阶段
在facebook的github上,目前React的最新版本为v16.12.0
,我们知道在React的v16
版本之后引入了新的Fiber
架构,这种架构使得任务拥有了暂停
和恢复
机制,将一个大的更新任务拆分为一个一个执行单元,充分利用浏览器在每一帧的空闲时间执行任务,无空闲时间则延迟执行,从而避免了任务的长时间运行导致阻塞主线程同步任务的执行。为了了解这种Fiber
架构,这里选择了一个比较适中的v16.10.2
的版本,没有选择最新的版本是因为在最新版本中移除了一些旧的兼容处理方案,虽说这些方案只是为了兼容,但是其思想还是比较先进的,值得我们推敲学习,所以先将其保留下来,这里选择v16.10.2
版本的另外一个原因是React在v16.10.0
的版本中涉及到两个比较重要的优化点:
在上图中指出,在任务调度(Scheduler)阶段有两个性能的优化点,解释如下:
- 将任务队列的内部数据结构转换成最小二叉堆的形式以提升队列的性能(在最小堆中我们能够以最快的速度找到最小的那个值,因为那个值一定在堆的顶部,有效减少整个数据结构的查找时间)。
- 使用周期更短的
postMessage
循环的方式而不是使用requestAnimationFrame
这种与帧边界对齐的方式(这种优化方案指得是在将任务进行延迟后恢复执行的阶段,前后两种方案都是宏任务,但是宏任务也有顺序之分,postMessage
的优先级比requestAnimationFrame
高,这也就意味着延迟任务能够更快速地恢复并执行)。
当然现在不太理解的话没关系,后续会有单独的文章来介绍任务调度这一块内容,遇到上述两个优化点的时候会进行详细说明,在开始阅读源码之前,我们可以使用create-react-app
来快速搭建一个React项目,后续的示例代码可以在此项目上进行编写:
// 项目搭建完成后React默认为最新版v16.12.0
create-react-app react-learning
// 为了保证版本一致,手动将其修改为v16.10.2
npm install --save react@16.10.2 react-dom@16.10.2
// 运行项目
npm start
执行以上步骤后,不出意外的话,浏览器中会正常显示出项目的默认界面。得益于在Reactv16.8
版本之后推出的React Hooks
功能,让我们在原来的无状态函数组件中也能进行状态管理,以及使用相应的生命周期钩子,甚至在新版的create-react-app
脚手架中,根组件App
已经由原来的类组件的写法升级为了推荐的函数定义组件的方式,但是原来的类组件的写法并没有被废弃掉,事实上我们项目中还是会大量充斥着类组件的写法,因此为了了解这种类组件的实现原理,我们暂且将App
根组件的函数定义的写法回退到类组件的形式,并对其内容进行简单修改:
// src -> App.js
import React, {Component} from 'react';
function List({data}) {
return (
<ul className="data-list">
{
data.map(item => {
return <li className="data-item" key={item}>{item}</li>
})
}
</ul>
);
}
export default class App extends Component {
constructor(props) {
super(props);
this.state = {
data: [1, 2, 3]
};
}
render() {
return (
<div className="container">
<h1 className="title">React learning</h1>
<List data={this.state.data} />
</div>
);
}
}
经过以上简单修改后,然后我们通过调用
// src -> index.js
ReactDOM.render(<App />, document.getElementById('root'));
来将组件挂载到DOM容器中,最终得到App
组件的DOM结构如下所示:
<div class="container">
<h1 class="title">React learning</h1>
<ul class="data-list">
<li class="data-item">1</li>
<li class="data-item">2</li>
<li class="data-item">3</li>
</ul>
</div>
因此我们分析React源码的入口也将会是从ReactDOM.render
方法开始一步一步分析组件渲染的整个流程,但是在此之前,我们有必要先了解几个重要的前置知识点,这几个知识点将会更好地帮助我们理解源码的函数调用栈中的参数意义和其他的一些细节。
2、前置知识
首先我们需要明确的是,在上述示例中,App
组件的render
方法返回的是一段HTML
结构,在普通的函数中这种写法是不支持的,所以我们一般需要相应的插件来在背后支撑,在React中为了支持这种jsx
语法提供了一个Babel
预置工具包@babel/preset-react
,其中这个preset
又包含了两个比较核心的插件:
@babel/plugin-syntax-jsx
:这个插件的作用就是为了让Babel
编译器能够正确解析出jsx
语法。
@babel/plugin-transform-react-jsx
:在解析完jsx
语法后,因为其本质上是一段HTML
结构,因此为了让JS
引擎能够正确识别,我们就需要通过该插件将jsx
语法编译转换为另外一种形式。在默认情况下,会使用React.createElement
来进行转换,当然我们也可以在.babelrc
文件中来进行手动设置。
// .babelrc
{
"plugins": [
["@babel/plugin-transform-react-jsx", {
"pragma": "Preact.h", // default pragma is React.createElement
"pragmaFrag": "Preact.Fragment", // default is React.Fragment
"throwIfNamespace": false // defaults to true
}]
]
}
这里为了方便起见,我们可以直接使用Babel官方实验室来查看转换后的结果,对应上述示例,转换后的结果如下所示:
// 转换前
render() {
return (
<div className="container">
<h1 className="title">React learning</h1>
<List data={this.state.data} />
</div>
);
}
// 转换后
render() {
return React.createElement("div", {
className: "content"
},
React.createElement("header", null, "React learning"),
React.createElement(List, { data: this.state.data }));
}
可以看到jsx
语法最终被转换成由React.createElement
方法组成的嵌套调用链,可能你之前已经了解过这个API
,或者接触过一些伪代码实现,这里我们就基于源码,深入源码内部来看看其背后为我们做了哪些事情。
2.1 createElement & ReactElement
为了保证源码的一致性,也建议你将React版本和笔者保持一致,采用v16.10.2
版本,可以通过facebook的github官方渠道进行获取,下载下来之后我们通过如下路径来打开我们需要查看的文件:
// react-16.10.2 -> packages -> react -> src -> React.js
在React.js
文件中,我们直接跳转到第63
行,可以看到React
变量作为一个对象字面量,包含了很多我们所熟知的方法,包括在v16.8
版本之后推出的React Hooks
方法:
const React = {
Children: {
map,
forEach,
count,
toArray,
only,
},
createRef,
Component,
PureComponent,
createContext,
forwardRef,
lazy,
memo,
// 一些有用的React Hooks方法
useCallback,
useContext,
useEffect,
useImperativeHandle,
useDebugValue,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
Fragment: REACT_FRAGMENT_TYPE,
Profiler: REACT_PROFILER_TYPE,
StrictMode: REACT_STRICT_MODE_TYPE,
Suspense: REACT_SUSPENSE_TYPE,
unstable_SuspenseList: REACT_SUSPENSE_LIST_TYPE,
// 重点先关注这里,生产模式下使用后者
createElement: __DEV__ ? createElementWithValidation : createElement,
cloneElement: __DEV__ ? cloneElementWithValidation : cloneElement,
createFactory: __DEV__ ? createFactoryWithValidation : createFactory,
isValidElement: isValidElement,
version: ReactVersion,
unstable_withSuspenseConfig: withSuspenseConfig,
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: ReactSharedInternals,
这里我们暂且先关注createElement
方法,在生产模式下它来自于与React.js
同级别的ReactElement.js
文件,我们打开该文件,并直接跳转到第312
行,可以看到createElement
方法的函数定义(去除了一些__DEV__
环境才会执行的代码):
/**
* 该方法接收包括但不限于三个参数,与上述示例中的jsx语法经过转换之后的实参进行对应
* @param type 表示当前节点的类型,可以是原生的DOM标签字符串,也可以是函数定义组件或者其它类型
* @param config 表示当前节点的属性配置信息
* @param children 表示当前节点的子节点,可以不传,也可以传入原始的字符串文本,甚至可以传入多个子节点
* @returns 返回的是一个ReactElement对象
*/
export function createElement(type, config, children) {
let propName;
// Reserved names are extracted
// 用于存放config中的属性,但是过滤了一些内部受保护的属性名
const props = {};
// 将config中的key和ref属性使用变量进行单独保存
let key = null;
let ref = null;
let self = null;
let source = null;
// config为null表示节点没有设置任何相关属性
if (config != null) {
// 有效性判断,判断 config.ref !== undefined
if (hasValidRef(config)) {
ref = config.ref;
}
// 有效性判断,判断 config.key !== undefined
if (hasValidKey(config)) {
key = '' + config.key;
}
self = config.__self === undefined ? null : config.__self;
source = config.__source === undefined ? null : config.__source;
// Remaining properties are added to a new props object
// 用于将config中的所有属性在过滤掉内部受保护的属性名后,将剩余的属性全部拷贝到props对象中存储
// const RESERVED_PROPS = {
// key: true,
// ref: true,
// __self: true,
// __source: true,
// };
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
!RESERVED_PROPS.hasOwnProperty(propName)
) {
props[propName] = config[propName];
}
}
}
// Children can be more than one argument, and those are transferred onto
// the newly allocated props object.
// 由于子节点的数量不限,因此从第三个参数开始,判断剩余参数的长度
// 具有多个子节点则props.children属性存储为一个数组
const childrenLength = arguments.length - 2;
if (childrenLength === 1) {
// 单节点的情况下props.children属性直接存储对应的节点
props.children = children;
} else if (childrenLength > 1) {
// 多节点的情况下则根据子节点数量创建一个数组
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
props.children = childArray;
}
// Resolve default props
// 此处用于解析静态属性defaultProps
// 针对于类组件或函数定义组件的情况,可以单独设置静态属性defaultProps
// 如果有设置defaultProps,则遍历每个属性并将其赋值到props对象中(前提是该属性在props对象中对应的值为undefined)
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
// 最终返回一个ReactElement对象
return ReactElement(
type,
key,
ref,
self,
source,
ReactCurrentOwner.current,
props,
);
}
经过上述分析我们可以得出,在类组件的render
方法中最终返回的是由多个ReactElement
对象组成的多层嵌套结构,所有的子节点信息均存放在父节点的props.children
属性中。我们将源码定位到ReactElement.js
的第111
行,可以看到ReactElement
函数的完整实现:
/**
* 为一个工厂函数,每次执行都会创建并返回一个ReactElement对象
* @param type 表示节点所对应的类型,与React.createElement方法的第一个参数保持一致
* @param key 表示节点所对应的唯一标识,一般在列表渲染中我们需要为每个节点设置key属性
* @param ref 表示对节点的引用,可以通过React.createRef()或者useRef()来创建引用
* @param self 该属性只有在开发环境才存在
* @param source 该属性只有在开发环境才存在
* @param owner 一个内部属性,指向ReactCurrentOwner.current,表示一个Fiber节点
* @param props 表示该节点的属性信息,在React.createElement中通过config,children参数和defaultProps静态属性得到
* @returns 返回一个ReactElement对象
*/
const ReactElement = function(type, key, ref, self, source, owner, props) {
const element = {
// This tag allows us to uniquely identify this as a React Element
// 这里仅仅加了一个$$typeof属性,用于标识这是一个React Element
$$typeof: REACT_ELEMENT_TYPE,
// Built-in properties that belong on the element
type: type,
key: key,
ref: ref,
props: props,
// Record the component responsible for creating this element.
_owner: owner,
};
...
return element;
};
一个ReactElement
对象的结构相对而言还是比较简单,主要是增加了一个$$typeof
属性用于标识该对象是一个React Element
类型。REACT_ELEMENT_TYPE
在支持Symbol
类型的环境中为symbol
类型,否则为number
类型的数值。与REACT_ELEMENT_TYPE
对应的还有很多其他的类型,均存放在shared/ReactSymbols
目录中,这里我们可以暂且只关心这一种,后面遇到其他类型再来细看。
2.2 Component & PureComponent
了解完ReactElement
对象的结构之后,我们再回到之前的示例,通过继承React.Component
我们将App
组件修改为了一个类组件,我们不妨先来研究下React.Component
的底层实现。React.Component
的源码存放在packages/react/src/ReactBaseClasses.js
文件中,我们将源码定位到第21
行,可以看到Component
构造函数的完整实现:
/**
* 构造函数,用于创建一个类组件的实例
* @param props 表示所拥有的属性信息
* @param context 表示所处的上下文信息
* @param updater 表示一个updater对象,这个对象非常重要,用于处理后续的更新调度任务
*/
function Component(props, context, updater) {
this.props = props;
this.context = context;
// If a component has string refs, we will assign a different object later.
// 该属性用于存储类组件实例的引用信息
// 在React中我们可以有多种方式来创建引用
// 通过字符串的方式,如:<input type="text" ref="inputRef" />
// 通过回调函数的方式,如:<input type="text" ref={(input) => this.inputRef = input;} />
// 通过React.createRef()的方式,如:this.inputRef = React.createRef(null); <input type="text" ref={this.inputRef} />
// 通过useRef()的方式,如:this.inputRef = useRef(null); <input type="text" ref={this.inputRef} />
this.refs = emptyObject;
// We initialize the default updater but the real one gets injected by the
// renderer.
// 当state发生变化的时候,需要updater对象去处理后续的更新调度任务
// 这部分涉及到任务调度的内容,在后续分析到任务调度阶段的时候再来细看
this.updater = updater || ReactNoopUpdateQueue;
}
// 在原型上新增了一个isReactComponent属性用于标识该实例是一个类组件的实例
// 这个地方曾经有面试官考过,问如何区分函数定义组件和类组件
// 函数定义组件是没有这个属性的,所以可以通过判断原型上是否拥有这个属性来进行区分
Component.prototype.isReactComponent = {};
/**
* 用于更新状态
* @param partialState 表示下次需要更新的状态
* @param callback 在组件更新之后需要执行的回调
*/
Component.prototype.setState = function(partialState, callback) {
...
this.updater.enqueueSetState(this, partialState, callback, 'setState');
};
/**
* 用于强制重新渲染
* @param callback 在组件重新渲染之后需要执行的回调
*/
Component.prototype.forceUpdate = function(callback) {
this.updater.enqueueForceUpdate(this, callback, 'forceUpdate');
};
上述内容中涉及到任务调度的会在后续讲解到调度阶段的时候再来细讲,现在我们知道可以通过原型上的isReactComponent
属性来区分函数定义组件和类组件。事实上,在源码中就是通过这个属性来区分Class Component
和Function Component
的,可以找到以下方法:
// 返回true则表示类组件,否则表示函数定义组件
function shouldConstruct(Component) {
return !!(Component.prototype && Component.prototype.isReactComponent);
}
与Component
构造函数对应的,还有一个PureComponent
构造函数,这个我们应该还是比较熟悉的,通过浅比较判断组件前后传递的属性是否发生修改来决定是否需要重新渲染组件,在一定程度上避免组件重渲染导致的性能问题。同样的,在ReactBaseClasses.js
文件中,我们来看看PureComponent
的底层实现:
// 通过借用构造函数,实现典型的寄生组合式继承,避免原型污染
function ComponentDummy() {}
ComponentDummy.prototype = Component.prototype;
function PureComponent(props, context, updater) {
this.props = props;
this.context = context;
// If a component has string refs, we will assign a different object later.
this.refs = emptyObject;
this.updater = updater || ReactNoopUpdateQueue;
}
// 将PureComponent的原型指向借用构造函数的实例
const pureComponentPrototype = (PureComponent.prototype = new ComponentDummy());
// 重新设置构造函数的指向
pureComponentPrototype.constructor = PureComponent;
// Avoid an extra prototype jump for these methods.
// 将Component.prototype和PureComponent.prototype进行合并,减少原型链查找所浪费的时间(原型链越长所耗费的时间越久)
Object.assign(pureComponentPrototype, Component.prototype);
// 这里是与Component的区别之处,PureComponent的原型上拥有一个isPureReactComponent属性
pureComponentPrototype.isPureReactComponent = true;
通过以上分析,我们就可以初步得出Component
和PureComponent
之间的差异,可以通过判断原型上是否拥有isPureReactComponent
属性来进行区分,当然更细粒度的区分,还需要在阅读后续的源码内容之后才能见分晓。
揭秘ReactDOM.render
在上一篇文章中我们通过create-react-app
脚手架快速搭建了一个简单的示例,并基于该示例讲解了在类组件中React.Component
和React.PureComponent
背后的实现原理。同时我们也了解到,通过使用 Babel 预置工具包@babel/preset-react
可以将类组件中render
方法的返回值和函数定义组件中的返回值转换成使用React.createElement
方法包装而成的多层嵌套结构,并基于源码逐行分析了React.createElement
方法背后的实现过程和ReactElement
构造函数的成员结构,最后根据分析结果总结出了几道面试中可能会碰到或者自己以前遇到过的面试考点。上篇文章中的内容相对而言还是比较简单基础,主要是为本文以及后续的任务调度相关内容打下基础,帮助我们更好地理解源码的用意。本文就结合上篇文章的基础内容,从组件渲染的入口点ReactDOM.render
方法开始,一步一步深入源码,揭秘ReactDOM.render
方法背后的实现原理,如有错误,还请指出。
源码中有很多判断类似__DEV__变量的控制语句,用于区分开发环境和生产环境,笔者在阅读源码的过程中不太关心这些内容,就直接略过了,有兴趣的小伙伴儿可以自己研究研究。
render VS hydrate
本系列的源码分析是基于 Reactv16.10.2
版本的,为了保证源码一致还是建议你选择相同的版本,下载该版本的地址和笔者选择该版本的具体原因可以在上一篇文章的准备阶段小节中查看,这里就不做过多讲解了。项目示例本身也比较简单,可以按照准备阶段的步骤自行使用create-react-app
快速将一个简单的示例搭建起来,然后我们定位到src/index.js
文件下,可以看到如下代码:
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
...
ReactDOM.render(<App />, document.getElementById('root'));
...
该文件即为项目的主入口文件,App
组件即为根组件,ReactDOM.render
就是我们要开始分析源码的入口点。我们通过以下路径可以找到ReactDOM
对象的完整代码:
packages -> react-dom -> src -> client -> ReactDOM.js
然后我们将代码定位到第632
行,可以看到ReactDOM
对象包含了很多我们可能使用过的方法,例如render
、createPortal
、findDOMNode
,hydrate
和unmountComponentAtNode
等。本文中我们暂且只关心render
方法,但为了方便对比,也可以简单看下hydrate
方法:
const ReactDOM: Object = {
...
/**
* 服务端渲染
* @param element 表示一个ReactNode,可以是一个ReactElement对象
* @param container 需要将组件挂载到页面中的DOM容器
* @param callback 渲染完成后需要执行的回调函数
*/
hydrate(element: React$Node, container: DOMContainer, callback: ?Function) {
invariant(
isValidContainer(container),
'Target container is not a DOM element.',
);
...
// TODO: throw or warn if we couldn't hydrate?
// 注意第一个参数为null,第四个参数为true
return legacyRenderSubtreeIntoContainer(
null,
element,
container,
true,
callback,
);
},
/**
* 客户端渲染
* @param element 表示一个ReactElement对象
* @param container 需要将组件挂载到页面中的DOM容器
* @param callback 渲染完成后需要执行的回调函数
*/
render(
element: React$Element<any>,
container: DOMContainer,
callback: ?Function,
) {
invariant(
isValidContainer(container),
'Target container is not a DOM element.',
);
...
// 注意第一个参数为null,第四个参数为false
return legacyRenderSubtreeIntoContainer(
null,
element,
container,
false,
callback,
);
},
...
};
发现没,render
方法的第一个参数就是我们在上篇文章中讲过的ReactElement
对象,所以说上篇文章的内容就是为了在这里打下基础的,便于我们对参数的理解。事实上,在源码中几乎所有方法参数中的element
字段均可以传入一个ReactElement
实例,这个实例就是通过 Babel 编译器在编译过程中使用React.createElement
方法得到的。接下来在render
方法中调用legacyRenderSubtreeIntoContainer
来正式进入渲染流程,不过这里需要留意一下的是,render
方法和hydrate
方法在执行legacyRenderSubtreeIntoContainer
时,第一个参数的值均为null
,第四个参数的值恰好相反。
然后将代码定位到第570
行,进入legacyRenderSubtreeIntoContainer
方法的具体实现:
/**
* 开始构建FiberRoot和RootFiber,之后开始执行更新任务
* @param parentComponent 父组件,可以把它当成null值来处理
* @param children ReactDOM.render()或者ReactDOM.hydrate()中的第一个参数,可以理解为根组件
* @param container ReactDOM.render()或者ReactDOM.hydrate()中的第二个参数,组件需要挂载的DOM容器
* @param forceHydrate 表示是否融合,用于区分客户端渲染和服务端渲染,render方法传false,hydrate方法传true
* @param callback ReactDOM.render()或者ReactDOM.hydrate()中的第三个参数,组件渲染完成后需要执行的回调函数
* @returns {*}
*/
function legacyRenderSubtreeIntoContainer(
parentComponent: ?React$Component<any, any>,
children: ReactNodeList,
container: DOMContainer,
forceHydrate: boolean,
callback: ?Function,
) {
...
// TODO: Without `any` type, Flow says "Property cannot be accessed on any
// member of intersection type." Whyyyyyy.
// 在第一次执行的时候,container上是肯定没有_reactRootContainer属性的
// 所以第一次执行时,root肯定为undefined
let root: _ReactSyncRoot = (container._reactRootContainer: any);
let fiberRoot;
if (!root) {
// Initial mount
// 首次挂载,进入当前流程控制中,container._reactRootContainer指向一个ReactSyncRoot实例
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(
container,
forceHydrate,
);
// root表示一个ReactSyncRoot实例,实例中有一个_internalRoot方法指向一个fiberRoot实例
fiberRoot = root._internalRoot;
// callback表示ReactDOM.render()或者ReactDOM.hydrate()中的第三个参数
// 重写callback,通过fiberRoot去找到其对应的rootFiber,然后将rootFiber的第一个child的stateNode作为callback中的this指向
// 一般情况下我们很少去写第三个参数,所以可以不必关心这里的内容
if (typeof callback === 'function') {
const originalCallback = callback;
callback = function() {
const instance = getPublicRootInstance(fiberRoot);
originalCallback.call(instance);
};
}
// Initial mount should not be batched.
// 对于首次挂载来说,更新操作不应该是批量的,所以会先执行unbatchedUpdates方法
// 该方法中会将executionContext(执行上下文)切换成LegacyUnbatchedContext(非批量上下文)
// 切换上下文之后再调用updateContainer执行更新操作
// 执行完updateContainer之后再将executionContext恢复到之前的状态
unbatchedUpdates(() => {
updateContainer(children, fiberRoot, parentComponent, callback);
});
} else {
// 不是首次挂载,即container._reactRootContainer上已经存在一个ReactSyncRoot实例
fiberRoot = root._internalRoot;
// 下面的控制语句和上面的逻辑保持一致
if (typeof callback === 'function') {
const originalCallback = callback;
callback = function() {
const instance = getPublicRootInstance(fiberRoot);
originalCallback.call(instance);
};
}
// Update
// 对于非首次挂载来说,是不需要再调用unbatchedUpdates方法的
// 即不再需要将executionContext(执行上下文)切换成LegacyUnbatchedContext(非批量上下文)
// 而是直接调用updateContainer执行更新操作
updateContainer(children, fiberRoot, parentComponent, callback);
}
return getPublicRootInstance(fiberRoot);
}
上面代码的内容稍微有些多,咋一看可能不太好理解,我们暂且可以不用着急看完整个函数内容。试想当我们第一次启动运行项目的时候,也就是第一次执行ReactDOM.render
方法的时候,这时去获取container._reactRootContainer
肯定是没有值的,所以我们先关心第一个if
语句中的内容:
if (!root) {
// Initial mount
// 首次挂载,进入当前流程控制中,container._reactRootContainer指向一个ReactSyncRoot实例
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(
container,
forceHydrate,
);
...
}
这里通过调用legacyCreateRootFromDOMContainer
方法将其返回值赋值给container._reactRootContainer
,我们将代码定位到同文件下的第517
行,去看看legacyCreateRootFromDOMContainer
的具体实现:
/**
* 创建并返回一个ReactSyncRoot实例
* @param container ReactDOM.render()或者ReactDOM.hydrate()中的第二个参数,组件需要挂载的DOM容器
* @param forceHydrate 是否需要强制融合,render方法传false,hydrate方法传true
* @returns {ReactSyncRoot}
*/
function legacyCreateRootFromDOMContainer(
container: DOMContainer,
forceHydrate: boolean,
): _ReactSyncRoot {
// 判断是否需要融合
const shouldHydrate =
forceHydrate || shouldHydrateDueToLegacyHeuristic(container);
// First clear any existing content.
// 针对客户端渲染的情况,需要将container容器中的所有元素移除
if (!shouldHydrate) {
let warned = false;
let rootSibling;
// 循环遍历每个子节点进行删除
while ((rootSibling = container.lastChild)) {
...
container.removeChild(rootSibling);
}
}
...
// Legacy roots are not batched.
// 返回一个ReactSyncRoot实例
// 该实例具有一个_internalRoot属性指向fiberRoot
return new ReactSyncRoot(
container,
LegacyRoot,
shouldHydrate
? {
hydrate: true,
}
: undefined,
);
}
/**
* 根据nodeType和attribute判断是否需要融合
* @param container DOM容器
* @returns {boolean}
*/
function shouldHydrateDueToLegacyHeuristic(container) {
const rootElement = getReactRootElementInContainer(container);
return !!(
rootElement &&
rootElement.nodeType === ELEMENT_NODE &&
rootElement.hasAttribute(ROOT_ATTRIBUTE_NAME)
);
}
/**
* 根据container来获取DOM容器中的第一个子节点
* @param container DOM容器
* @returns {*}
*/
function getReactRootElementInContainer(container: any) {
if (!container) {
return null;
}
if (container.nodeType === DOCUMENT_NODE) {
return container.documentElement;
} else {
return container.firstChild;
}
}
其中在shouldHydrateDueToLegacyHeuristic
方法中,首先根据container
来获取 DOM 容器中的第一个子节点,获取该子节点的目的在于通过节点的nodeType
和是否具有ROOT_ATTRIBUTE_NAME
属性来区分是客户端渲染还是服务端渲染,ROOT_ATTRIBUTE_NAME
位于packages/react-dom/src/shared/DOMProperty.js
文件中,表示data-reactroot
属性。我们知道,在服务端渲染中有别于客户端渲染的是,node
服务会在后台先根据匹配到的路由生成完整的HTML
字符串,然后再将HTML
字符串发送到浏览器端,最终生成的HTML
结构简化后如下:
<body>
<div id="root">
<div data-reactroot=""></div>
</div>
</body>
在客户端渲染中是没有data-reactroot
属性的,因此就可以区分出客户端渲染和服务端渲染。在 React 中的nodeType
主要包含了五种,其对应的值和W3C
中的nodeType
标准是保持一致的,位于与DOMProperty.js
同级的HTMLNodeType.js
文件中:
// 代表元素节点
export const ELEMENT_NODE = 1;
// 代表文本节点
export const TEXT_NODE = 3;
// 代表注释节点
export const COMMENT_NODE = 8;
// 代表整个文档,即document
export const DOCUMENT_NODE = 9;
// 代表文档片段节点
export const DOCUMENT_FRAGMENT_NODE = 11;
经过以上分析,现在我们就可以很容易地区分出客户端渲染和服务端渲染,并且在面试中如果被问到两种渲染模式的区别,我们就可以很轻松地在源码级别上说出两者的实现差异,让面试官眼前一亮。怎么样,到目前为止,其实还是觉得挺简单的吧?
React高性能的体现:虚拟DOM
React高性能的原理:
在Web开发中我们总需要将变化的数据实时反应到UI上,这时就需要对DOM进行操作。而复杂或频繁的DOM操作通常是性能瓶颈产生的原因(如何进行高性能的复杂DOM操作通常是衡量一个前端开发人员技能的重要指标)。
React为此引入了虚拟DOM(Virtual DOM)的机制:在浏览器端用Javascript实现了一套DOM API。基于React进行开发时所有的DOM构造都是通过虚拟DOM进行,每当数据变化时,React都会重新构建整个DOM树,然后React将当前整个DOM树和上一次的DOM树进行对比,得到DOM结构的区别,然后仅仅将需要变化的部分进行实际的浏览器DOM更新。而且React能够批处理虚拟DOM的刷新,在一个事件循环(Event Loop)内的两次数据变化会被合并,例如你连续的先将节点内容从A-B,B-A,React会认为A变成B,然后又从B变成A UI不发生任何变化,而如果通过手动控制,这种逻辑通常是极其复杂的。
尽管每一次都需要构造完整的虚拟DOM树,但是因为虚拟DOM是内存数据,性能是极高的,部而对实际DOM进行操作的仅仅是Diff分,因而能达到提高性能的目的。这样,在保证性能的同时,开发者将不再需要关注某个数据的变化如何更新到一个或多个具体的DOM元素,而只需要关心在任意一个数据状态下,整个界面是如何Render的。
React Fiber
在react 16之后发布的一种react 核心算法,React Fiber是对核心算法的一次重新实现(官网说法)。之前用的是diff算法。
在之前React中,更新过程是同步的,这可能会导致性能问题。
当React决定要加载或者更新组件树时,会做很多事,比如调用各个组件的生命周期函数,计算和比对Virtual DOM,最后更新DOM树,这整个过程是同步进行的,也就是说只要一个加载或者更新过程开始,中途不会中断。因为JavaScript单线程的特点,如果组件树很大的时候,每个同步任务耗时太长,就会出现卡顿。
React Fiber的方法其实很简单——分片。把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会。
HOC 是什么?相比 mixins 有什么优点?
很多人看到高阶组件(HOC)这个概念就被吓到了,认为这东西很难,其实这东西概念真的很简单,我们先来看一个例子。
function add(a, b) {
return a + b
}
现在如果我想给这个 add
函数添加一个输出结果的功能,那么你可能会考虑我直接使用 console.log
不就实现了么。说的没错,但是如果我们想做的更加优雅并且容易复用和扩展,我们可以这样去做:
function withLog (fn) {
function wrapper(a, b) {
const result = fn(a, b)
console.log(result)
return result
}
return wrapper
}
const withLogAdd = withLog(add)
withLogAdd(1, 2)
其实这个做法在函数式编程里称之为高阶函数,大家都知道 React 的思想中是存在函数式编程的,高阶组件和高阶函数就是同一个东西。我们实现一个函数,传入一个组件,然后在函数内部再实现一个函数去扩展传入的组件,最后返回一个新的组件,这就是高阶组件的概念,作用就是为了更好的复用代码。
其实 HOC 和 Vue 中的 mixins 作用是一致的,并且在早期 React 也是使用 mixins 的方式。但是在使用 class 的方式创建组件以后,mixins 的方式就不能使用了,并且其实 mixins 也是存在一些问题的,比如:
- 隐含了一些依赖,比如我在组件中写了某个
state
并且在mixin
中使用了,就这存在了一个依赖关系。万一下次别人要移除它,就得去mixin
中查找依赖 - 多个
mixin
中可能存在相同命名的函数,同时代码组件中也不能出现相同命名的函数,否则就是重写了,其实我一直觉得命名真的是一件麻烦事。。 - 雪球效应,虽然我一个组件还是使用着同一个
mixin
,但是一个mixin
会被多个组件使用,可能会存在需求使得mixin
修改原本的函数或者新增更多的函数,这样可能就会产生一个维护成本
HOC 解决了这些问题,并且它们达成的效果也是一致的,同时也更加的政治正确(毕竟更加函数式了)。
事件机制
React 其实自己实现了一套事件机制,首先我们考虑一下以下代码:
const Test = ({ list, handleClick }) => ({
list.map((item, index) => (
<span onClick={handleClick} key={index}>{index}</span>
))
})
以上类似代码想必大家经常会写到,但是你是否考虑过点击事件是否绑定在了每一个标签上?事实当然不是,JSX 上写的事件并没有绑定在对应的真实 DOM 上,而是通过事件代理的方式,将所有的事件都统一绑定在了 document
上。这样的方式不仅减少了内存消耗,还能在组件挂载销毁时统一订阅和移除事件。
另外冒泡到 document
上的事件也不是原生浏览器事件,而是 React 自己实现的合成事件(SyntheticEvent)。因此我们如果不想要事件冒泡的话,调用 event.stopPropagation
是无效的,而应该调用 event.preventDefault
。
那么实现合成事件的目的是什么呢?总的来说在我看来好处有两点,分别是:
- 合成事件首先抹平了浏览器之间的兼容问题,另外这是一个跨浏览器原生事件包装器,赋予了跨浏览器开发的能力
- 对于原生浏览器事件来说,浏览器会给监听器创建一个事件对象。如果你有很多的事件监听,那么就需要分配很多的事件对象,造成高额的内存分配问题。但是对于合成事件来说,有一个事件池专门来管理它们的创建和销毁,当事件需要被使用时,就会从池子中复用对象,事件回调结束后,就会销毁事件对象上的属性,从而便于下次复用事件对象。
21、Webpack 性能优化
核心概念
Entry :入口
output:出口
Plugins: 插件,执行范围更广的任务。插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量
Loader:翻译官,把浏览器不识别的内容做转换
研发环境
依赖包:启动 webpack-dev-server --config webpack.config.dev.js
webpack webpack-cli //基础依赖
webpack-dev-server -D //热更新
//将css文件提取为独立的文件的插件
mini-css-extract-plugin -D
html-webpack-plugin -D // 将模板文件和js文件整合到一块
CopyWebpackPlugin -D //将静态资源拷贝到dist目录下
sass-loader node-sass -D //解析scss文件
node-sass --sass_binary_site=https://npm.taobao.org/mirrors/node-sass/
const path = require('path')
const htmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
//开发模式:development,代码调试, 生产模式:production ,代码压缩,优化
//如果mode不配置,会有警告提示信息
mode: "development",//开发模式
//代码调试,可以看到源代码
devtool: 'inline-source-map',
//入口文件,如果不配置,默然找根目录下 /src/index.js
// entry:"./src/js/index.js",多页面
entry: {
'main': "./src/js/index.js",
'detail': "./src/js/detai.js"
},
//打包完成,输出文件的位置(不能写相对路径),可以不配置,默认输出到dist目录
output: {
path: path.resolve(__dirname, './dev'),
filename: '[name]-[hash].js'
},
//支持热更新
devServer: {
port: 9099,
//打开浏览器
open: true
},
//插件
plugins: [
//它将创建一个html文件,将打包好的各种如js、css模块引用进去,并通过提供的各种参数完成多种处理
new htmlWebpackPlugin({
title: 'webpack demo home',
//模板文件路径
template: "./src/views/index.html",
//自动存储到output 配置的目录
filename: 'index.html',
//这个就表示当前实例仅引入chunk名为main的模块
chunks: ['main']
}),
//自动更新根据文件名自动更换css文件
new MiniCssExtractPlugin({
//页面引用的样式文件名
filename: '[name]-[hash].css',
// chunkFilename: 'common.css',
}),
new CopyWebpackPlugin([
{
from: './src/static', to: './static' //相对于output 里面配置的path:./
}
])
],
//翻译官,把浏览器不识别的内容做转换
module: {
//解析规则
rules: [
//解析scss
{
test: /\.s[ac]ss$/,
//执行顺序:从右向左
use: [
// 将 JS 字符串生成为 style 节点
'style-loader',
// MiniCssExtractPlugin.loader,
//将 CSS 转化成 CommonJS 模块
'css-loader',
//把.scss文件文件转换为.css文件
'sass-loader'
]
},
//解析html
{
test: /\.html$/,
use: ['string-loader']
}
]
}
}
在这一的章节中,我不会浪费篇幅给大家讲如何写配置文件。如果你想学习这方面的内容,那么完全可以去官网学习。在这部分的内容中,我们会聚焦于以下两个知识点,并且每一个知识点都属于高频考点:
- 有哪些方式可以减少 Webpack 的打包时间
- 有哪些方式可以让 Webpack 打出来的包更小
1、noParse:
该配置是作为 module 的一个属性值,即不解析某些模块,所谓不解析,就是不去分析某个模块中的依赖关系,即不去管某个文件是否 import(依赖)了某个文件,对于一些独立的库,比如 jquery,其根本不存在依赖关系,jquery 不会去引入其他的库(要根据自己对某个模块的了解去判断是否要解析该模块),所以我们可以让 webpack 不去解析 jquery 的依赖关系,提高打包速度,如:
module.exports = {
module: {
noParse:/jquery/,//不去解析jquery中的依赖库
}
}
noParse 是 module 配置中的一个属性,其属性值为一个正则表达式,填入不被解析的模块名称。
为了更清楚的展示 noParse 的作用,假设我们在入口文件 index.js 中引入 bar.js 模块,同时这个 bar.js 模块中也引入了 foo.js 模块,foo.js 不再依赖其他模块了,那么在不使用 noParse 的情况下,webpack 打包的时候,会先去分析 index.js 模块,发现其引入了 bar.js 模块,然后接着分析 bar.js 模块,发现其引入了 foo.js 模块,接着分析 foo.js 模块。
Entrypoint index = index.js
[./src/bar.js] 55 bytes {index} [built]
[./src/foo.js] 21 bytes {index} [built]
[./src/index.js] 81 bytes {index} [built]
而此时如果使用了 noParse: /bar/,那么 webpack 打包的时候,会先去分析 index.js 模块,发现其引入了 bar.js 模块,但是由于 noParse 的作用,将不再继续解析 bar.js 模块了,即不会去分析 bar.js 中引入的 foo.js 模块了。
Entrypoint index = index.js
[./src/bar.js] 55 bytes {index} [built]
[./src/index.js] 81 bytes {index} [built]
2、exclude:
2、exclude: 在 loader 中使用 exclude 排除对某些目录中的文件处理,即引入指定目录下的文件时候,不使用对应的 loader 进行处理,exclude 是 loader 配置中的一个属性,属性值为正则表达式,如:
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"],
plugins: ["@babel/plugin-transform-runtime"]
}
}
],
exclude: /node_modules/
}
]
}
}
3、使用 IgnorePlugin
3、使用 IgnorePlugin 来忽略某个模块中某些目录中的模块引用,比如在引入某个模块的时候,该模块会引入大量的语言包,而我们不会用到那么多语言包,如果都打包进项目中,那么就会影响打包速度和最终包的大小,然后再引入需要使用的语言包即可,如:
项目根目录下有一个 time 包,其中有一个 lang 包,lang 包中包含了各种语言输出对应时间的 js 文件,time 包下的 index.js 会引入 lang 包下所有的 js 文件,那么当我们引入 time 模块的时候,就会将 lang 包下的所有 js 文件都打包进去,添加如下配置:
const webpack = require("webpack");
module.exports = {
plugins: [
new webpack.IgnorePlugin(/lang/, /time/)
]
}
引入 time 模块的时候,如果 time 模块中引入了其中的 lang 模块中的内容,那么就忽略掉,即不引入 lang 模块中的内容,需要注意的是,这 /time/ 只是匹配文件夹和 time 模块的具体目录位置无关,即只要是引入了目录名为 time 中的内容就会生效。
4、使用 HappyPack:
4、使用 HappyPack:由于在打包过程中有大量的文件需要交给 loader 进行处理,包括解析和转换等操作,而由于 js 是单线程的,所以这些文件只能一个一个地处理,而 HappyPack 的工作原理就是充分发挥 CPU 的多核功能,将任务分解给多个子进程去并发执行,子进程处理完后再将结果发送给主进程,happypack 主要起到一个任务劫持的作用,在创建 HappyPack 实例的时候要传入对应文件的 loader,即 use 部分,loader 配置中将使用经过 HappyPack 包装后的 loader 进行处理,如:
module.exports = {
plugins: [
new HappyPack({ // 这里对处理css文件的loader进行包装
id: "css",// 之前的loader根据具体的id进行引入
use: ["style-loader","css-loader"],
threads: 5 // 设置开启的进程数
})
],
module: {
rules: [
{
test: /\.css$/, // 匹配以.css结尾的文件
use: ["happypack/loader?id=css"] //根据happypack实例中配置的id引入包装后的laoder,这里的happyPack的h可以大写也可以小写
}
]
}
}
webpack 要打包的文件非常多的时候才需要使用 happypack 进行优化,因为开启多进程也是需要耗时间的,所以文件少的时候,使用 happypack 返回更耗时
5、抽离公共模块:
5、抽离公共模块: 对于多入口情况,如果某个或某些模块,被两个以上文件所依赖,那么可以将这个模块单独抽离出来,不需要将这些公共的代码都打包进每个输出文件中,这样会造成代码的重复和流量的浪费,即如果有两个入口文件 index.js 和 other.js,它们都依赖了 foo.js,那么如果不抽离公共模块,那么 foo.js 中的代码都会打包进最终输出的 index.js 和 other.js 中去,即有两份 foo.js 了。抽离公共模块也很简单,直接在 optimization 中配置即可,如:
module.exports = {
splitChunks: { // 分割代码块,即抽离公共模块
cacheGroups: { // 缓存组
common: { // 组名为common可自定义
chunks: "initial",
minSize: 0, // 文件大小为0字节以上才抽离
minChunks: 2, // 被引用过两次才抽离
name: "common/foo", // 定义抽离出的文件的名称
}
}
}
}
这样就会将公共的 foo.js 模块抽离到 common 目录下 foo.js 中了,但是如果我们也有多个文件依赖了第三方模块如 jquery,如果按以上配置,那么 jquery 也会被打包进 foo.js 中,会导致代码混乱,所以我们希望将 jquery 单独抽出来,即与 foo.js 分开,我们可以复制一份以上配置,并通过设置抽离代码权重的方式来实现,即优先抽离出 jquery,如:
module.exports = {
splitChunks: { // 分割代码块,即抽离公共模块
cacheGroups: { // 缓存组
common: { // 组名为common可自定义
chunks: "initial",
minSize: 0, // 文件大小为0字节以上才抽离
minChunks: 2, // 被引用过两次才抽离
name: "common/foo", // 定义抽离出的文件的名称
},
verdor: {
test: /node_modules/,
priority: 1, // 设置打包权重,即优先抽离第三方模块
chunks: "initial",
minSize: 0, // 文件大小为0字节以上才抽离
minChunks: 2, // 被引用过两次才抽离
name: "common/jquery", // 定义抽离出的文件的名称
}
}
}
}
这样就会在 common 目录下同时抽离出 foo.js 和 jquery.js 了,需要注意的是,代码的抽离必须是该模块没有被排除打包,即该模块会被打包进输出 bundle 中,如果第三方模块已经通过 externals 排除打包,则以上 vendor 配置无效。
6、按需加载
6、按需加载,即在需要使用的时候才打包输出,webpack 提供了 import() 方法,传入要动态加载的模块,来动态加载指定的模块,当 webpack 遇到 import()语句的时候,不会立即去加载该模块,而是在用到该模块的时候,再去加载,也就是说打包的时候会一起打包出来,但是在浏览器中加载的时候并不会立即加载,而是等到用到的时候再去加载,比如,点击按钮后才会加载某个模块,如:
const button = document.createElement("button");
button.innerText = "点我"
button.addEventListener("click", () => { // 点击按钮后加载foo.js
import("./foo").then((res) => { // import()返回的是一个Promise对象
console.log(res);
});
});
document.body.appendChild(button);
从中可以看到,import() 返回的是一个 Promise 对象,其主要就是利用 JSONP 实现动态加载,返回的 res 结果不同的 export 方式会有不同,如果使用的 module.exports 输出,那么返回的 res 就是 module.exports 输出的结果;如果使用的是 ES6 模块输出,即 export default 输出,那么返回的 res 结果就是 res.default,如:
// ES6模块输出,res结果为
{default: "foo", __esModule: true, Symbol(Symbol.toStringTag): "Module"}
7、开启模块热更新:
7、开启模块热更新: 模块热更新可以做到在不刷新网页的情况下,更新修改的模块,只编译变化的模块,而不用全部模块重新打包,大大提高开发效率,在未开启热更新的情况下,每次修改了模块,都会重新打包。
要开启模块热更新,那么只需要在 devServer 配置中添加 hot:true 即可。当然仅仅开启模块热更新是不够的,我们需要做一些类似监听的操作,当监听的模块发生变化的时候,重新加载该模块并执行,如:
module.exports = {
devServer: {
hot: true // 开启热更新
}
}
----------
import foo from "./foo";
console.log(foo);
if (module.hot) {
module.hot.accept("./foo", () => { // 监听到foo模块发生变化的时候
const foo = require("./foo"); // 重新引入该模块并执行
console.log(foo);
});
}
如果不使用 module.hot.accept 监听,那么当修改 foo 模块的时候还是会刷新页面的
减少 Webpack 打包后的文件体积
注意:该内容也属于性能优化领域。
按需加载
想必大家在开发 SPA 项目的时候,项目中都会存在十几甚至更多的路由页面。如果我们将这些页面全部打包进一个 JS 文件的话,虽然将多个请求合并了,但是同样也加载了很多并不需要的代码,耗费了更长的时间。那么为了首页能更快地呈现给用户,我们肯定是希望首页能加载的文件体积越小越好,这时候我们就可以使用按需加载,将每个路由页面单独打包为一个文件。当然不仅仅路由可以按需加载,对于 loadash
这种大型类库同样可以使用这个功能。
按需加载的代码实现这里就不详细展开了,因为鉴于用的框架不同,实现起来都是不一样的。当然了,虽然他们的用法可能不同,但是底层的机制都是一样的。都是当使用的时候再去下载对应文件,返回一个 Promise
,当 Promise
成功以后去执行回调。
Scope Hoisting
Scope Hoisting 会分析出模块之间的依赖关系,尽可能的把打包出来的模块合并到一个函数中去。
比如我们希望打包两个文件
// test.js
export const a = 1
// index.js
import { a } from './test.js'
对于这种情况,我们打包出来的代码会类似这样
[
/* 0 */
function (module, exports, require) {
//...
},
/* 1 */
function (module, exports, require) {
//...
}
]
但是如果我们使用 Scope Hoisting 的话,代码就会尽可能的合并到一个函数中去,也就变成了这样的类似代码
[
/* 0 */
function (module, exports, require) {
//...
}
]
这样的打包方式生成的代码明显比之前的少多了。如果在 Webpack4 中你希望开启这个功能,只需要启用 optimization.concatenateModules
就可以了。
module.exports = {
optimization: {
concatenateModules: true
}
}
Tree Shaking
Tree Shaking 可以实现删除项目中未被引用的代码,比如
// test.js
export const a = 1
export const b = 2
// index.js
import { a } from './test.js'
对于以上情况,test
文件中的变量 b
如果没有在项目中使用到的话,就不会被打包到文件中。
如果你使用 Webpack 4 的话,开启生产环境就会自动启动这个优化功能。
外围扩展 externals
webpack的配置选项,externals 配置选项提供了「从输出的 bundle 中排除依赖」的方法。相反,所创建的 bundle 依赖于那些存在于用户环境(consumer's environment)中的依赖。此功能通常对 library 开发人员来说是最有用的,然而也会有各种各样的应用程序用到它。
externals: [
{
// String
react: 'react',
// Object
lodash: {
commonjs: 'lodash',
amd: 'lodash',
root: '_' // indicates global variable
},
// Array
subtract: ['./math', 'subtract']
},
// Function
function (context, request, callback) {
if (/^yourregex$/.test(request)) {
return callback(null, 'commonjs ' + request);
}
callback();
},
// Regex
/^(jquery|\$)$/i
],
静态资源发布CDN后webpack的配置
output: {
path: path.resolve(__dirname, 'public/assets'),
publicPath: 'https://cdn.example.com/assets/'
}
使用 HappyPack:
由于在打包过程中有大量的文件需要交给 loader 进行处理,包括解析和转换等操作,而由于 js 是单线程的,所以这些文件只能一个一个地处理,而 HappyPack 的工作原理就是充分发挥 CPU 的多核功能,将任务分解给多个子进程去并发执行,子进程处理完后再将结果发送给主进程,happypack 主要起到一个任务劫持的作用,在创建 HappyPack 实例的时候要传入对应文件的 loader,即 use 部分,loader 配置中将使用经过 HappyPack 包装后的 loader 进行处理,如:
module.exports = {
plugins: [
new HappyPack({ // 这里对处理css文件的loader进行包装
id: "css",// 之前的loader根据具体的id进行引入
use: ["style-loader","css-loader"],
threads: 5 // 设置开启的进程数
})
],
module: {
rules: [
{
test: /\.css$/, // 匹配以.css结尾的文件
use: ["happypack/loader?id=css"] //根据happypack实例中配置的id引入包装后的laoder,这里的happyPack的h可以大写也可以小写
}
]
}
}
webpack 要打包的文件非常多的时候才需要使用 happypack 进行优化,因为开启多进程也是需要耗时间的,所以文件少的时候,使用 happypack 返回更耗时
静态资源上传到CDN
/**
* 发布静态文件到CDN上,并且修改HTML引用文件为CDN地址
*/
const fs = require('fs');
const emitter = require('events');
const Client = require('ftp');
const c = new Client();
class MyEmitter extends emitter { }
const EventEmitter = new MyEmitter();
const bucket = 'source';
const project = 'test'; // 项目名称
var projectPath = `${bucket}/${project}/`;
// static 静态资源目录
var staticPath = `${projectPath}static/`
//css 文件夹目录
var cssPath = `${staticPath}css/`;
//img 文件夹目录
var imgPath = `${staticPath}img/`;
//js 文件夹目录
var jsPath = `${staticPath}js/`;
const FTP_CONFIG = {
host: '192.168.1.170', port: 2121,
user: 'username', password: '1qaz2wsx3edc'
};
const uploadList = [];
function travel(type) {
const files = fs.readdirSync(`./dist/static/${type}`);
for (let i = 0; i < files.length; i += 1) {
uploadList.push({
name: `./static/${type}/${files[i]}`,
path: `./dist/static/${type}/${files[i]}`
});
}
}
function mkdir(path, cb) {
c.mkdir(path, function (err) {
if (err) {
console.log(err.message)
} else {
console.log(`created path ${path}`)
}
cb();
})
}
travel('css');
travel('js');
travel('img');
c.on('ready', () => {
//create project path
mkdir(projectPath, function () {
EventEmitter.emit('PROJECT_PATH_CREATED')
})
// create project static path
EventEmitter.on('PROJECT_PATH_CREATED', function () {
mkdir(staticPath, function () {
EventEmitter.emit('STATIC_PATH_CREATED')
})
})
// create css path
EventEmitter.on('STATIC_PATH_CREATED', function () {
mkdir(cssPath, function () {
EventEmitter.emit('CSS_PATH_CREATED')
})
})
// create image path
EventEmitter.on('CSS_PATH_CREATED', function () {
mkdir(imgPath, function () {
EventEmitter.emit('IMG_PATH_CREATED')
})
})
// create js path
EventEmitter.on('IMG_PATH_CREATED', function () {
mkdir(jsPath, function () {
EventEmitter.emit('JS_PATH_CREATED')
})
})
//update static resource file
EventEmitter.on('JS_PATH_CREATED', function () {
var count = 0;
for (let i = 0; i < uploadList.length; i += 1) {
c.put(uploadList[i].path, `${bucket}/${project}/${uploadList[i].name}`, (err) => {
count++
if (!err) {
console.log(`upload success ${uploadList[i].name}`)
} else if (err.message.indexOf('Overwrite permission denied')) {
console.log(`文件 ${uploadList[i].name}已存在,不给予上传!`);
} else if (err) {
console.log(err.message);
}
if (count == uploadList.length) {
console.log('upload complete!')
}
});
}
})
});
c.connect(FTP_CONFIG);
DllPlugin + DllReferencePlugin
减少构建时间,进行分离打包。
DllPlugin这个插件是在一个额外的独立的 webpack 设置中创建一个只有 dll 的 bundle(dll-only-bundle)。 这个插件会生成一个名为 manifest.json 的文件,这个文件是用来让 DLLReferencePlugin 映射到相关的依赖上去的。
webpack.vendor.config.js
new webpack.DllPlugin({
context: __dirname,
name: "[name]_[hash]",
path: path.join(__dirname, "manifest.json"),
})
webpack.app.config.js
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require("./manifest.json"),
name: "./my-dll.js",
scope: "xyz",
sourceType: "commonjs2"
})
CommonsChunkPlugin (<4.0)通过将公共模块拆出来缓存
通过将公共模块拆出来,最终合成的文件能够在最开始的时候加载一次,便存到缓存中供后续使用。这个带来速度上的提升,因为浏览器会迅速将公共的代码从缓存中取出来,而不是每次访问一个新页面时,再去加载一个更大的文件。
如果把公共文件提取出一个文件,那么当用户访问了一个网页,加载了这个公共文件,再访问其他依赖公共文件的网页时,就直接使用文件在浏览器的缓存,这样公共文件就只用被传输一次。
entry: {
vendor: ["jquery", "other-lib"], // 明确第三方库
app: "./entry"
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: "vendor",
// filename: "vendor.js"
// (给 chunk 一个不同的名字)
minChunks: Infinity,
// (随着 entry chunk 越来越多,
// 这个配置保证没其它的模块会打包进 vendor chunk)
})
]
// 打包后的文件
<script src="vendor.js" charset="utf-8"></script>
<script src="app.js" charset="utf-8"></script>
SplitChunkPlugin
起初,chunks(代码块)和导入他们中的模块通过webpack内部的父子关系图连接.在webpack3中,通过CommonsChunkPlugin来避免他们之间的依赖重复。而在webpack4中CommonsChunkPlugin被移除,取而代之的是 optimization.splitChunks 和 optimization.runtimeChunk 配置项,下面展示它们将如何工作。
在默认情况下,SplitChunksPlugin 仅仅影响按需加载的代码块,因为更改初始块会影响HTML文件应包含的脚本标记以运行项目。
webpack将根据以下条件自动拆分代码块:
- 会被共享的代码块或者 node_mudules 文件夹中的代码块
- 体积大于30KB的代码块(在gz压缩前)
- 按需加载代码块时的并行请求数量不超过5个
- 加载初始页面时的并行请求数量不超过3个
optimization: {
splitChunks: {
chunks: 'all', // 只对异步加载的模块进行拆分,可选值还有all | initial
minSize: 0, // 模块最少大于30KB才拆分
maxSize: 0, // 模块大小无上限,只要大于30KB都拆分
minChunks: 1, // 模块最少引用一次才会被拆分
maxAsyncRequests: 5, // 异步加载时同时发送的请求数量最大不能超过5,超过5的部分不拆分
maxInitialRequests: 3, // 页面初始化时同时发送的请求数量最大不能超过3,超过3的部分不拆分
automaticNameDelimiter: '-', // 默认的连接符
name: true, // 拆分的chunk名,设为true表示根据模块名和CacheGroup的key来自动生成,使用上面连接符连接
cacheGroups: { // 缓存组配置,上面配置读取完成后进行拆分,如果需要把多个模块拆分到一个文件,就需要缓存,所以命名为缓存组
vendors: { // 自定义缓存组名
name: 'venders',
test: /[\\/]node_modules[\\/]/, // 检查node_modules目录,只要模块在该目录下就使用上面配置拆分到这个组
priority: -10 // 权重-10,决定了哪个组优先匹配,例如node_modules下有个模块要拆分,同时满足vendors和default组,此时就会分到vendors组,因为-10 > -20
},
commons: { // 默认缓存组名
name: 'commons',
minChunks: 2, // 最少引用两次才会被拆分
priority: -20, // 权重-5
reuseExistingChunk: true, // 如果主入口中引入了两个模块,其中一个正好也引用了后一个,就会直接复用,无需引用两次
test: /[\\/]src[\\/]/,
minSize: 0,
chunks: "all"
}
}
}
}
。
UglifyJSPlugin分析JS代码语法树\去掉无效代码
基本上脚手架都包含了该插件,该插件会分析JS代码语法树,理解代码的含义,从而做到去掉无效代码、去掉日志输入代码、缩短变量名等优化。
const UglifyJSPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
//...
plugins: [
new UglifyJSPlugin({
compress: {
warnings: false, //删除无用代码时不输出警告
drop_console: true, //删除所有console语句,可以兼容IE
collapse_vars: true, //内嵌已定义但只使用一次的变量
reduce_vars: true, //提取使用多次但没定义的静态值到变量
},
output: {
beautify: false, //最紧凑的输出,不保留空格和制表符
comments: false, //删除所有注释
}
})
]
CSS样式处理
- css-loader: 处理 css 文件
- style-loader: 把 js 中 import 导入的样式文件代码,打包到 js 文件中,运行 js 文件时,将样式自动插入到<style>标签中
- mini-css-extract-plugin: 把 js 中 import 导入的样式文件代码,打包成一个实际的 css 文件,结合 html-webpack-plugin,在 dist/index.html 中以 link 插入 css 文件;默认将 js 中 import 的多个 css 文件,打包时合成一个
yarn add mini-css-extract-plugin -D
yarn add css-loader -D
yarn add optimize-css-assets-webpack-plugin -D
ExtractTextPlugin 从 bundle 中提取文本(CSS)到单独的文件,PurifyCSSPlugin纯化CSS(其实用处没多大)
css-loader 用法:
module.exports = {
module: {
rules: [
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
}
]
},
plugins: [
...,
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[id].css',
}),
]
};
style-loader用法:
module.exports = {
module: {
rules: [
{
test: /\.css$/i,
use: [
//执行顺序,从有向左
{ loader: "style-loader" },
{ loader: "css-loader" }
]
},
plugins: [
...,
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[id].css',
}),
]
};
css 压缩优化:
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
。。。
optimization: {
minimizer: [new OptimizeCSSAssetsPlugin({})],
}
DefinePlugin
DefinePlugin能够自动检测环境变化,效率高效。
在前端开发中,在不同的应用环境中,需要不同的配置。如:开发环境的API Mocker、测试流程中的数据伪造、打印调试信息。如果使用人工处理这些配置信息,不仅麻烦,而且容易出错。
使用DefinePlugin配置的全局常量
注意,因为这个插件直接执行文本替换,给定的值必须包含字符串本身内的实际引号。通常,有两种方式来达到这个效果,使用 ' "production" ', 或者使用
JSON.stringify('production')。
new webpack.DefinePlugin({
// 当然,在运行node服务器的时候就应该按环境来配置文件
// 下面模拟的测试环境运行配置
'process.env':JSON.stringify('dev'),
WP_CONF: JSON.stringify('dev'),
}),
测试DefinePlugin:编写
if (WP_CONF === 'dev') {
console.log('This is dev');
} else {
console.log('This is prod');
}
打包后WP_CONF === 'dev'会编译为false
if (false) {
console.log('This is dev');
} else {
console.log('This is prod');
}
清除不可达代码
当使用了DefinePlugin插件后,打包后的代码会有很多冗余。可以通过UglifyJsPlugin清除不可达代码。
[
new UglifyJsPlugin({
uglifyOptions: {
compress: {
warnings: false, // 去除warning警告
dead_code: true, // 去除不可达代码
},
warnings: false
}
})
]
最后的打包打包代码会变成console.log('This is prod')
附Uglify文档:github.com/mishoo/Ugli…
使用DefinePlugin区分环境 + UglifyJsPlugin清除不可达代码,以减轻打包代码体积
HappyPack
HappyPack可以开启多进程Loader转换,将任务分解给多个子进程,最后将结果发给主进程。
使用
exports.plugins = [
new HappyPack({
id: 'jsx',
threads: 4,
loaders: [ 'babel-loader' ]
}),
new HappyPack({
id: 'styles',
threads: 2,
loaders: [ 'style-loader', 'css-loader', 'less-loader' ]
})
];
exports.module.rules = [
{
test: /\.js$/,
use: 'happypack/loader?id=jsx'
},
{
test: /\.less$/,
use: 'happypack/loader?id=styles'
},
]
ParallelUglifyPlugin
ParallelUglifyPlugin可以开启多进程压缩JS文件
import ParallelUglifyPlugin from 'webpack-parallel-uglify-plugin';
module.exports = {
plugins: [
new ParallelUglifyPlugin({
test,
include,
exclude,
cacheDir,
workerCount,
sourceMap,
uglifyJS: {
},
uglifyES: {
}
}),
],
};
压缩图片资源
图像占了页面大小的一半以上。虽然它们不像JavaScript那样重要(例如,它们不会阻塞呈现),但它们仍然占用了很大一部分带宽。在 webpack 中可以使用 url-loader、svg-url-loader 和 image-webpack-loader 来优化它们。
url-loader
参见:项目 webpack-css
url-loader 可以将小型静态文件内联到应用程序中。如果不进行配置,它将把接受一个传递的文件,将其放在已编译的包旁边,并返回该文件的url。但是,如果指定 limit 选项,它将把小于这个限制的文件编码为Base64 数据的 url 并返回这个url,这会将图像内联到 JavaScript 代码中,从而可以减少一个HTTP请求。
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.(jpe?g|png|gif)$/,
loader: 'url-loader',
options: {
// Inline files smaller than 10 kB (10240 bytes)
limit: 10 * 1024,
},
},
],
}
};
// Home.js
import logo from '../assets/images/logo.png';
注意:需要在增大代码体积和减少 HTTP 请求数之前进行权衡。
svg-url-loader
svg-url-loader 的工作原理与 url-loader 类似 — 只是它使用的是URL编码而不是Base64编码来编码文件。这对SVG图像很有用 — 因为SVG文件只是纯文本,这种编码更高效。
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.svg$/,
loader: 'svg-url-loader',
options: {
// Inline files smaller than 10 kB (10240 bytes)
limit: 10 * 1024,
// Remove the quotes from the url
// (they’re unnecessary in most cases)
noquotes: true,
},
},
],
},
};
注意: svg-url-loader 有一些选项可以改进Internet Explorer的支持,但会使其他浏览器的内联更加糟糕。如果需要支持此浏览器,请应用 iesafe: true 选项。
image-webpack-loader
image-webpack-loader 可支持JPG、PNG、GIF和SVG图像的压缩。
这个加载器不嵌入图像到应用程序,所以它必须与 url-loader 和 svg-url-loader 成对工作。为了避免将其复制粘贴到两个规则中(一个用于JPG/PNG/GIF图像,另一个用于SVG图像),我们通过 enforce: 'pre' 将这个加载器设为一个单独的规则:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.(jpe?g|png|gif|svg)$/,
loader: 'image-webpack-loader',
// This will apply the loader before the other ones
enforce: 'pre',
},
],
},
};
压缩图片
参考:https://www.npmjs.com/package...
yarn add image-webpack-loader --dev
//添加loader
module: {
rules: [
{
test: /\.(png|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 100,
},
},
{
loader: 'image-webpack-loader',
options: {
pngquant: {
quality: [0.25, 0.60],
speed: 4
},
},
},
],
}
]
...
}
使用 Tree Shaking:描述移除 JavaScript 上下文中的未引用代码
参考:https://webpack.js.org/guides...
tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import 和 export。这个术语和概念实际上是由 ES2015 模块打包工具 rollup 普及起来的。
+ mode: 'development',
+ optimization: {
+ usedExports: true,
+ },
};
webpack 2 正式版本内置支持 ES2015 模块(也叫做 harmony modules)和未使用模块检测能力。新的 webpack 4 正式版本扩展了此检测能力,通过 package.json 的 "sideEffects" 属性作为标记,向 compiler 提供提示,表明项目中的哪些文件是 "pure(纯的 ES2015 模块)",由此可以安全地删除文件中未使用的部分。
test & include & exclude
减小文件搜索范围,从而提升速度
示例
{
test: /\.css$/,
include: [
path.resolve(__dirname, "app/styles"),
path.resolve(__dirname, "vendor/styles")
]
}
小结
在这一章节中,我们学习了如何使用 Webpack 去进行性能优化以及如何减少打包时间。
Webpack 的版本更新很快,各个版本之间实现优化的方式可能都会有区别,所以我没有使用过多的代码去展示如何实现一个功能。这一章节的重点是学习到我们可以通过什么方式去优化,具体的代码实现可以查找具体版本对应的代码即可。
22、监控
前端监控一般分为三种,分别为页面埋点、性能监控以及异常监控。
这一章节我们将来学习这些监控相关的内容,但是基本不会涉及到代码,只是让大家了解下前端监控该用什么方式实现。毕竟大部分公司都只是使用到了第三方的监控工具,而不是选择自己造轮子。
PV是网站分析的一个术语,用以衡量网站用户访问的网页的数量。对于广告主,PV值可预期它可以带来多少广告收入。一般来说,PV与来访者的数量成正比,但是PV并不直接决定页面的真实来访者数量,如同一个来访者通过不断的刷新页面,也可以制造出非常高的PV。
1、什么是PV值
PV(page view)即页面浏览量或点击量,是衡量一个网站或网页用户访问量。具体的说,PV值就是所有访问者在24小时(0点到24点)内看了某个网站多少个页面或某个网页多少次。PV是指页面刷新的次数,每一次页面刷新,就算做一次PV流量。
度量方法就是从浏览器发出一个对网络服务器的请求(Request),网络服务器接到这个请求后,会将该请求对应的一个网页(Page)发送给浏览器,从而产生了一个PV。那么在这里只要是这个请求发送给了浏览器,无论这个页面是否完全打开(下载完成),那么都是应当计为1个PV。
2、什么是UV值
UV(unique visitor)即独立访客数,指访问某个站点或点击某个网页的不同IP地址的人数。在同一天内,UV只记录第一次进入网站的具有独立IP的访问者,在同一天内再次访问该网站则不计数。UV提供了一定时间内不同观众数量的统计指标,而没有反应出网站的全面活动。通过IP和cookie是判断UV值的两种方式:
用Cookie分析UV值
当客户端第一次访问某个网站服务器的时候,网站服务器会给这个客户端的电脑发出一个Cookie,通常放在这个客户端电脑的C盘当中。在这个Cookie中会分配一个独一无二的编号,这其中会记录一些访问服务器的信息,如访问时间,访问了哪些页面等等。当你下次再访问这个服务器的时候,服务器就可以直接从你的电脑中找到上一次放进去的Cookie文件,并且对其进行一些更新,但那个独一无二的编号是不会变的。
3、IP即独立IP数
IP可以理解为独立IP的访问用户,指1天内使用不同IP地址的用户访问网站的数量,同一IP无论访问了几个页面,独立IP数均为1。但是假如说两台机器访问而使用的是同一个IP,那么只能算是一个IP的访问。
IP和UV之间的数据不会有太大的差异,通常UV量和比IP量高出一点,每个UV相对于每个IP更准确地对应一个实际的浏览者。
①UV大于IP
这种情况就是在网吧、学校、公司等,公用相同IP的场所中不同的用户,或者多种不同浏览器访问您网站,那么UV数会大于IP数。
②UV小于IP
在家庭中大多数电脑使用ADSL拨号上网,所以同一个用户在家里不同时间访问您网站时,IP可能会不同,因为它会根据时间变动IP,即动态的IP地址,但是实际访客数唯一,便会出现UV数小于IP数。
页面埋点
页面埋点应该是大家最常写的监控了,一般起码会监控以下几个数据:
- PV / UV
- 停留时长
- 流量来源
- 用户交互
对于这几类统计,一般的实现思路大致可以分为两种,分别为手写埋点和无埋点的方式。
相信第一种方式也是大家最常用的方式,可以自主选择需要监控的数据然后在相应的地方写入代码。这种方式的灵活性很大,但是唯一的缺点就是工作量较大,每个需要监控的地方都得插入代码。
另一种无埋点的方式基本不需要开发者手写埋点了,而是统计所有的事件并且定时上报。这种方式虽然没有前一种方式繁琐了,但是因为统计的是所有事件,所以还需要后期过滤出需要的数据。
异常监控
对于异常监控来说,以下两种监控是必不可少的,分别是代码报错以及接口异常上报。
脚本错误一般分为两种:语法错误,运行时错误。
语法错误一般在编码时就有观测
Uncaught SyntaxError: Invalid or unexpected token xxx
运行时错误:
window.onerror-
除跨域脚本、网络异常、浏览器兼容
缺陷
- 对于跨域的代码错误会显示
Script error.
对于这种情况我们需要给script
标签添加crossorigin
属性 - 对于某些浏览器可能不会显示调用栈信息,这种情况可以通过
arguments.callee.caller
来做栈递归 - 对于 onerror 这种全局捕获,最好写在所有 JS 脚本的前面,因为你无法保证你写的代码是否出错,如果写在后面,一旦发生错误的话是不会被 onerror 捕获到的。
onerror 是无法捕获到网络异常的错误。
当我们遇到
用户访问网站,图片 CDN 无法服务,图片加载不出来
报 404 网络请求异常的时候,onerror 是无法帮助我们捕获到异常的。
由于网络请求异常不会事件冒泡,因此必须在捕获阶段将其捕捉到才行,
<script> window.addEventListener( 'error', (msg, rl, row, col, error) => { console.log('我知道 404 错误了'); console.log(msg, url, row, col, error); return true;} , true);//捕获 </script>
但是这种方式虽然可以捕捉到网络请求的异常,但是无法判断 HTTP 的状态是 404 还是其他比如 500 等等,
所以还需要配合服务端日志才进行排查分析才可以。
catch:捕获异步代码、promise
try catch
:捕获 async await
缺陷:只能捕获捉到运行时非异步错误,对于语法错误和异步错误就显得无能为力,捕捉不到。
语法错误:一般不会带上线
异步错误:如果错误发生在setTimeout中;除非你在异步代码 setTimeout 函数中再套上一层 try-catch,否则就无法感知到其错误,但这样代码写起来比较啰嗦。
Promise 全局异常捕获事件 unhandledrejection
所以如果你的应用用到很多的 Promise 实例的话,特别是你在一些基于 promise 的异步库比如 axios 等一定要小心,因为你不知道什么时候这些异步请求会抛出异常而你并没有处理它,所以你最好添加一个
`window.addEventListener("unhandledrejection",
function(e){
e.preventDefault()
console.log('我知道 promise 的错误了');
console.log(e.reason);
return true;});
Promise.reject('promise error');
new Promise((resolve, reject) => {
reject('promise error');});
new Promise((resolve) => {resolve();}).then(() => {throw 'promise error'});`
接口异常:列举状态码的类型可以立即上报出错
另外接口异常就相对来说简单了,可以列举出出错的状态码。一旦出现此类的状态码就可以立即上报出错。接口异常上报可以让开发人员迅速知道有哪些接口出现了大面积的报错,以便迅速修复问题。
异常上报方式
但是要注意线上运行的代码都是压缩过的,需要在打包时生成 sourceMap 文件便于 debug。
/***
@param {String} msg 错误信息
* @param {String} url 出错文件
* @param {Number} row 行号
* @param {Number} col 列号
* @param {Object} error 错误详细信息
*/window.onerror = function (msg, url, row, col, error) {
console.log('我知道错误了');
console.log({msg, url, row, col, error})
return true;};
//window.onerror 函数只有在返回 true 的时候,异常才不会向上抛出
error;
//否则控制台显示:Uncaught Error: xxxxx
对于捕获的错误需要上传给服务器,通常可以通过 img
标签的 src
发起一个请求。
监控拿到报错信息之后,接下来就需要将捕捉到的错误信息发送到信息收集平台上,常用的发送形式主要有两种:
- 通过 Ajax 发送数据
- 动态创建 img 标签的形式
实例 - 动态创建 img 标签进行上报
function report(error) {
var reportUrl = 'http://xxxx/report';
new Image().src = reportUrl + 'error=' + error;
}
vue项目的异常处理
vue提供了一个全局配置 errorHandle,,用于收集Vue运行时发生的错误。
Vue.config.errorHandler = function (err, vm, info) {
// handle error
// `info` 是 Vue 特定的错误信息,比如错误所在的生命周期钩子
// 只在 2.2.0+ 可用 let componentName = formatComponentName(vm);}
// 获取组件的名称
function formatComponentName(vm) {
if (vm.$root === vm) return 'root';
let name = vm._isVue ? (vm.$options && vm.$options.name) ||
(vm.$options && vm.$options._componentTag): vm.name;
return ( (name ? 'component <' + name + '>' : 'anonymous component') +(vm._isVue && vm.$options && vm.$options.__file ? ' at ' + (vm.$options && vm.$options.__file): '') );}
拿到vue对应的实例之后,就可以获取vue对应的组件名称,定位到是哪一个组件报错;
性能监控
性能监控可以很好的帮助开发者了解在各种真实环境下,页面的性能情况是如何的。
对于性能监控来说,我们可以直接使用浏览器自带的 Performance API 来实现这个功能。
对于性能监控来说,其实我们只需要调用 performance.getEntriesByType('navigation')
这行代码就行了。对,你没看错,一行代码我们就可以获得页面中各种详细的性能相关信息
我们可以发现这行代码返回了一个数组,内部包含了相当多的信息,从数据开始在网络中传输到页面加载完成都提供了相应的数据。
- [PerformanceNavigationTiming]
- 0: PerformanceNavigationTiming
- unloadEventStart: 0
- unloadEventEnd: 0
- domInteractive: 3514.37999999996
- domContentLoadedEventStart: 3514.4899999999666
- domContentLoadedEventEnd: 3514.499999999998
- domComplete: 5213.5649999999605
- loadEventStart: 5213.635000000011
- loadEventEnd: 5213.774999999998
- type: "navigate"
- redirectCount: 0
- initiatorType: "navigation"
- nextHopProtocol: "http/1.1"
- workerStart: 0
- redirectStart: 0
- redirectEnd: 0
- fetchStart: 12.564999999995052
- domainLookupStart: 12.564999999995052
- domainLookupEnd: 12.564999999995052
- connectStart: 12.564999999995052
- connectEnd: 12.564999999995052
- secureConnectionStart: 0
- requestStart: 16.705000000001746
- responseStart: 398.3299999999872
- responseEnd: 399.4449999999574
- transferSize: 0
- encodedBodySize: 710
- decodedBodySize: 1256
- serverTiming: Array(0)
length: 0
- __proto__: Array(0)
- name: "https://www.gaosiedu.com/"
- entryType: "navigation"
- startTime: 0
- duration: 5213.774999999998
- __proto__: PerformanceNavigationTiming
- length: 1
- __proto__: Array(0)
1、为什么要监控性能?
性能 | 收益 |
---|---|
Google 延迟 400ms | 搜索量下降 0.59% |
Bing 延迟 2s | 收入下降 4.3% |
Yahoo 延迟 400ms | 流量下降 5-9% |
Mozilla 页面打开减少 2.2s | 下载量提升 15.4% |
Netflix 开启 Gzip | 性能提升 13.25% 带宽减少50% |
根本原因还是在于性能影响了用户体验。加载的延迟、操作的卡顿等都会影响用户的使用体验。尤其是移动端,用户对页面响应延迟和连接中断的容忍度很低。我们需要一套性能监控系统持续监控、评估、预警页面性能状况、发现瓶颈,指导优化工作的进行。
2、 有什么可用的工具?
Page Speed、webpagetest,是谷歌开发的分析和优化网页的工具,
为了持续监控不同网络环境下用户访问情况与页面各功能可用状况,我们选择在页面中植入 JS 来监控线上真实用户访问性能,同时利用已有的分析工具作为辅助,形成一套完整多元的数据监控体系,为产品线的评估与优化提供可靠的数据。
3、 真实用户性能监控
关于不同监控方式的简单对比可以查看下表:
类型 | 优点 | 缺点 | 示例 |
---|---|---|---|
非侵入式 | 指标齐全、客户端主动监测、竞品监控 | 无法知道性能影响用户数、采样少容易失真、无法监控复杂应用与细分功能 | Pagespeed、PhantomJS、UAQ |
侵入式 | 真实海量用户数据、能监控复杂应用与业务功能、用户点击与区域渲染 | 需插入脚本统计、网络指标不全、无法监控竞品 | DP 、Google 统计 |
4、 如何采集性能数据?
线上监控哪些指标呢?如何更好地反映用户感知?
对于用户来说他感觉到的为什么页面打不开、为什么按钮点击不了、为什么图片显示这么慢。
而对于工程师来说,可能关注的是 DNS 查询、TCP 连接、服务响应等浏览器加载过程指标。
我们根据用户的痛点,
将浏览器加载过程抽取出四个关键指标,即白屏时间、首屏时间、用户可操作、总下载时间
这些指标是如何统计的呢?
确定统计起点
我们需要在用户输入 URL 或者点击链接的时候就开始统计,因为这样才能衡量用户的等待时间。如果你的用户高端浏览器占比很高,那么可以直接使用Navigation Timing接口来获取统计起点以及加载过程中的各个阶段耗时。另外也可以通过 cookie 记录时间戳的方式来统计,需要注意的是 Cookie 方式只能统计到站内跳转的数据。
统计白屏时间
白屏时间是用户首次看到内容的时间,也叫做首次渲染时间,chrome 高版本有 firstPaintTime 接口来获取这个耗时,但大部分浏览器并不支持,必须想其他办法来监测。仔细观察 WebPagetest 视图分析发现,白屏时间出现在头部外链资源加载完附近,因为浏览器只有加载并解析完头部资源才会真正渲染页面。基于此我们可以通过获取头部资源加载完的时刻来近似统计白屏时间。尽管并不精确,但却考虑了影响白屏的主要因素:首字节时间和头部资源加载时间。
如何统计头部资源加载呢?我们发现头部内嵌的 JS 通常需等待前面的 JS\CSS 加载完才会执行,是不是可以在浏览器 head 内底部加一句 JS 统计头部资源加载结束点呢?可以通过一个简单的示例进行测试:
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8"/>
<script>
var start_time = +new Date; //测试时间起点,实际统计起点为 DNS 查询
</script>
<!-- 3s 后这个 js 才会返回 -->
<script src="script.php"></script>
<script>
var end_time = +new Date; //时间终点
var headtime = end_time - start_time; //头部资源加载时间
console.log(headtime);
</script>
</head>
<body>
<p>在头部资源加载完之前页面将是白屏</p>
<p>script.php 被模拟设置 3s 后返回,head 底部内嵌 JS 等待前面 js 返回后才执行</p>
<p>script.php 替换成一个执行长时间循环的 js 效果也一样</p>
</body>
</html>
经测试发现,统计的头部加载时间正好跟头部资源下载时间相近,而且换成一个执行时间很长的 JS 也会等到 JS 执行完才统计。说明此方法是可行的(具体原因可查看浏览器渲染原理及 JS 单线程相关介绍)。
统计首屏时间
首屏时间的统计比较复杂,因为涉及图片等多种元素及异步渲染等方式。观察加载视图可发现,影响首屏的主要因素的图片的加载。通过统计首屏内图片的加载时间便可以获取首屏渲染完成的时间。统计流程如下:
首屏位置调用 API 开始统计 -> 绑定首屏内所有图片的 load 事件 -> 页面加载完后判断图片是否在首屏内,找出加载最慢的一张 -> 首屏时间
这是同步加载情况下的简单统计逻辑,另外需要注意的几点:
- 页面存在 iframe 的情况下也需要判断加载时间
- gif 图片在 IE 上可能重复触发 load 事件需排除
- 异步渲染的情况下应在异步获取数据插入之后再计算首屏
- css 重要背景图片可以通过 JS 请求图片 url 来统计(浏览器不会重复加载)
- 没有图片则以统计 JS 执行时间为首屏,即认为文字出现时间
统计用户可操作和总下载
用户可操作默认可以统计domready时间,因为通常会在这时候绑定事件操作。对于使用了模块化异步加载的 JS 可以在代码中去主动标记重要 JS 的加载时间,这也是产品指标的统计方式。
总下载时间默认可以统计onload时间,这样可以统计同步加载的资源全部加载完的耗时。如果页面中存在很多异步渲染,可以将异步渲染全部完成的时间作为总下载时间。
网络指标
网络类型判断
对于移动端来说,网络是页面加载速度最大的影响因素,需要根据不同的网络来采取相应的优化措施,例如对于 2G 用户采用简版等。但 web 上没有接口获取用户的网络类型。为了获取用户网络类型,可以通过测速的方式来判断不同 IP 段对应的网络。测速例如比较经典的有 facebook 的方案。经过测速后的分析,用户的加载速率有明显的分布区间,如下图所示:
各个分布区间正好对应不同的网络类型,经过与客户端的辅助测试,成功率可以在 95%以上。有了这个 IP 库对应的速率数据,就可以在分析用户数据时根据 IP 来判断用户网络类型。
网络耗时统计
网络耗时数据可以借助前面提到 Navigation Timing 接口获取,与之类似的还有Resource Timing,可以获取页面所有静态资源的加载耗时。通过此接口可以轻松获取 DNS、TCP、首字节、html 传输等耗时,Navigation Timing 的接口示意图如下所示:
以上重点介绍了数据采集部分,这也是系统中最关键的一部分,只有保证数据能真实反映用户感知,才能对症下药提升用户体验。数据采集完之后我们可以在页面加载完之后统一上报,如示例:
http://xxx.baidu.com/tj.gif?dns=100&ct=210&st=300&tt=703&c_dnslookup=0&c_connecting=0&c_waiting=296&c_receiving=403&c_fetch_dns=0&c_nav_dns=75&c_nav_fetch=75&drt=1423&drt_end=1436<=3410&c_nfpt=619&nav_type=0&redirect_count=0&_screen=1366*768|1366*728&product_id=10&page_id=200&_t=1399822334414
\### Day 5 如何分析性能数据?
让数据会说话
而数据分析过程,如前一篇文章所述,可以从多个维度去分析数据。大数据处理需要借助 hadoop、Hive 等方式,而对于普通站点则任意一种后端语言处理即可。
均值与分布
均值与分布是数据处理中最常见的两种方式。因为它能直观的表示指标的趋势与分布状况,方便进行评估、瓶颈发现与告警。处理过程中应去除异常值,例如明显超过阈值的脏数据等。
耗时的评估中,有很多这方面的研究数据。例如有人提出三个基本的时间范围:
- 0.1秒 : 0.1 秒是用户感知的最小粒度,在这个时间范围内完成的操作被认为是流畅没有延迟的
- 1.0秒 : 1.0 秒内完成的响应认为不会干扰用户的思维流。尽管用户能感觉到延迟,但 0.1 秒 -1.0 秒内完成的操作并不需要给出明显 loading 提示
- 10秒 : 达到 10 秒用户将无法保持注意力,很可能选择离开做其他事情
我们根据业界的一些调研,结合不同指标的特点,制定了指标的分布评估区间。如下图所示:
评估区间的制定方便我们了解当前性能状况,同时对性能趋势波动做出反应。
多维分析
为了方便挖掘性能可能的瓶颈,需要从多维的角度对数据进行分析。例如移动端最重要的维度就是网络,数据处理上除了总体数据,还需要根据网络类型对数据进行分析。常见的维度还有系统、浏览器、地域运营商等。我们还可以根据自身产品的特点来确定一些维度,例如页面长度分布、简版炫版等。
需要注意的是维度并不是越多越好,需要根据产品的特点及终端来确定。维度是为了方便查找性能瓶颈。
小插曲 :之前从微博中看到有人评价说想做监控但是公司没有日志服务器。并不需要单独的日志服务器,只要能把统计的这个请求访问日志保存下来即可。如果网站自己的独立服务器都没有还有解决办法,在百度开发者中心新建一个应用,写一个简单的 Web 服务将接收到的统计数据解析存到百度云免费的数据库中,然后每天再用 Mysql 处理下当天的数据即可,对于普通站点的抽样性能数据应该没问题。请叫我雷锋。
5、如何利用监控数据解决问题?
发现瓶颈,对症下药
对于图表制作,比较出名的有Highcharts,百度开发的Echarts也很不错。不管使用什么工具,最关键的一点就是让报表能突出重点,直观明了。
制作报表前多问几个如何让人直观看到目前状况和可能存在的问题,哪些地方可以加强,哪些可以去掉,使用是否习惯等。
有了能反映用户感知的真实世界、并且细分到各个业务功能,有详细的网络等辅助数据,我们在解决前端性能上便更加得心应手。监控系统已经对线上访问状况有了连续的反馈,根据现有评估与瓶颈选择对应方案进行优化,最后根据反馈进行调整,相信性能优化不再是个难题。
如何选择优化方案呢?这又是一个比较大的话题了,好在已经有很多经验可以借鉴。附录中就整理了部分性能的学习资料,可以根据需要阅读学习。
23、vue项目常见优化点
1.keep-alive缓存页面
2.使用v-show复用DOM
3.v-for 遍历避免同时使用 v-if
4.事件的销毁
例如组件销毁时清除定时器
5.图片懒加载
对于图片过多的页面,为了加速页面加载速度,所以很多时候我们需要将页面内未出现
在可视区域内的图片先不做加载, 等到滚动到可视区域后再去加载。
6.第三方插件按需引入
像element-ui这样的第三方组件库可以按需引入避免体积太大
7.无状态的组件标记为函数式组件
8.路由懒加载
- 基于webpack打包优化:屏蔽sourceMap、treeshaking...
- 静态资源压缩传输
- 资源懒加载、预加载(组件、css、图片、。。)
- v-if 和 v-show选择调用
- 减少watch的数据,慎用deep watch
- SSR(服务端渲染)(根据业务需求)
- 骨架屏加载 (通过占位线框元素,渐进式加载数据)
- keep-alive 缓存
24、react 项目常见优化点
UI 更新需要昂贵的 DOM 操作,而 React 内部使用几种巧妙的技术以便最小化 DOM 操作次数。对于大部分应用而言,使用 React 时无需专门优化就已拥有高性能的用户界面。尽管如此,你仍然有办法来加速你的 React 应用。
1、使用生产版本
当你需要对你的 React 应用进行 benchmark,或者遇到了性能问题,请确保你正在使用压缩后的生产版本。
React 默认包含了许多有用的警告信息。这些警告信息在开发过程中非常有帮助。然而这使得 React 变得更大且更慢,所以你需要确保部署时使用了生产版本。
如果你不能确定你的编译过程是否设置正确,你可以通过安装 Chrome 的 React 开发者工具 来检查。如果你浏览一个基于 React 生产版本的网站,图标背景会变成深色:
如果你浏览一个基于 React 开发模式的网站,图标背景会变成红色:
推荐你在开发应用时使用开发模式,而在为用户部署应用时使用生产模式。
你可以在下面看到几种为应用构建生产版本的操作说明。
Create React App
如果你的项目是通过 Create React App 构建的,运行:
npm run build
这段命令将在你的项目下的 build/
目录中生成对应的生产版本。
注意只有在生产部署前才需要执行这个命令。正常开发使用 npm start
即可。
单文件构建
我们提供了可以在生产环境使用的单文件版 React 和 React DOM:
<script src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
注意只有以 .production.min.js
为结尾的 React 文件适用于生产。
Brunch
通过安装 terser-brunch
插件,来获得最高效的 Brunch 生产构建:
# 如果你使用 npm
npm install --save-dev terser-brunch
# 如果你使用 Yarn
yarn add --dev terser-brunch
接着,在 build
命令后添加 -p
参数,以创建生产构建:
brunch build -p
请注意,你只需要在生产构建时这么做。你不需要在开发环境中使用 -p
参数或者应用这个插件,因为这会隐藏有用的 React 警告信息并使得构建速度变慢。
Browserify
为了最高效的生产构建,需要安装一些插件:
# 如果你使用 npm
npm install --save-dev envify terser uglifyify
# 如果你使用 Yarn
yarn add --dev envify terser uglifyify
为了创建生产构建,确保你添加了以下转换器 (顺序很重要):
envify
转换器用于设置正确的环境变量。设置为全局 (-g
)。uglifyify
转换器移除开发相关的引用代码。同样设置为全局 (-g
)。- 最后,将产物传给
terser
并进行压缩(为什么要这么做?)。
举个例子:
browserify ./index.js \
-g [ envify --NODE_ENV production ] \
-g uglifyify \
| terser --compress --mangle > ./bundle.js
请注意,你只需要在生产构建时用到它。你不需要在开发环境应用这些插件,因为这会隐藏有用的 React 警告信息并使得构建速度变慢。
Rollup
为了最高效的 Rollup 生产构建,需要安装一些插件:
# 如果你使用 npm
npm install --save-dev rollup-plugin-commonjs rollup-plugin-replace rollup-plugin-terser
# 如果你使用 Yarn
yarn add --dev rollup-plugin-commonjs rollup-plugin-replace rollup-plugin-terser
为了创建生产构建,确保你添加了以下插件 (顺序很重要):
plugins: [
// ...
require('rollup-plugin-replace')({
'process.env.NODE_ENV': JSON.stringify('production')
}),
require('rollup-plugin-commonjs')(),
require('rollup-plugin-terser')(),
// ...
]
点击查看完整的安装示例。
请注意,你只需要在生产构建时用到它。你不需要在开发中使用 terser
插件或者 replace
插件替换 'production'
变量,因为这会隐藏有用的 React 警告信息并使得构建速度变慢。
webpack
注意:
如果你使用了 Create React App,请跟随上面的说明进行操作。
只有当你直接配置了 webpack 才需要参考以下内容。
在生产模式下,Webpack v4+ 将默认对代码进行压缩:
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
mode: 'production',
optimization: {
minimizer: [new TerserPlugin({ /* additional options here */ })],
},
};
你可以在 webpack 文档中了解更多内容。
请注意,你只需要在生产构建时用到它。你不需要在开发中使用 TerserPlugin
插件,因为这会隐藏有用的 React 警告信息并使得构建速度变慢。
2、使用 Chrome Performance 标签分析组件
在开发模式下,你可以通过支持的浏览器可视化地了解组件是如何 挂载、更新以及卸载的。例如:
在 Chrome 中进行如下操作:
- 临时禁用所有的 Chrome 扩展,尤其是 React 开发者工具。他们会严重干扰度量结果!
- 确保你是在 React 的开发模式下运行应用。
- 打开 Chrome 开发者工具的 Performance 标签并按下 Record。
- 对你想分析的行为进行复现。尽量在 20 秒内完成以避免 Chrome 卡住。
- 停止记录。
- 在 User Timing 标签下会显示 React 归类好的事件。
你可以查阅 Ben Schwarz 的文章以获取更详尽的指导。
需要注意的是在生产环境中组件会相对渲染得更快些。当然了,这能帮助你查看是否有不相关的组件被错误地更新,以及 UI 更新的深度和频率。
目前只有 Chrome、Edge 和 IE 支持该功能,但是我们使用的是标准的用户计时 API。我们期待有更多浏览器能支持它。
3、使用开发者工具中的分析器对组件进行分析
react-dom
16.5+ 和 react-native
0.57+ 加强了分析能力。在开发模式下,React 开发者工具会出现分析器标签。 你可以在《介绍 React 分析器》这篇博客中了解概述。 你也可以在 YouTube 上观看分析器的视频指导。
如果你还未安装 React 开发者工具,你可以在这里找到它们:
注意
react-dom
的生产分析包也可以在react-dom/profiling
中找到。 通过查阅 fb.me/react-profiling 来了解更多关于使用这个包的内容。
4、虚拟化长列表
如果你的应用渲染了长列表(上百甚至上千的数据),我们推荐使用“虚拟滚动”技术。这项技术会在有限的时间内仅渲染有限的内容,并奇迹般地降低重新渲染组件消耗的时间,以及创建 DOM 节点的数量。
react-window 和 react-virtualized 是热门的虚拟滚动库。 它们提供了多种可复用的组件,用于展示列表、网格和表格数据。 如果你想要一些针对你的应用做定制优化,你也可以创建你自己的虚拟滚动组件,就像 Twitter 所做的。
5、避免调停
React 构建并维护了一套内部的 UI 渲染描述。它包含了来自你的组件返回的 React 元素。该描述使得 React 避免创建 DOM 节点以及没有必要的节点访问,因为 DOM 操作相对于 JavaScript 对象操作更慢。虽然有时候它被称为“虚拟 DOM”,但是它在 React Native 中拥有相同的工作原理。
当一个组件的 props 或 state 变更,React 会将最新返回的元素与之前渲染的元素进行对比,以此决定是否有必要更新真实的 DOM。当它们不相同时,React 会更新该 DOM。
即使 React 只更新改变了的 DOM 节点,重新渲染仍然花费了一些时间。在大部分情况下它并不是问题,不过如果它已经慢到让人注意了,你可以通过覆盖生命周期方法 shouldComponentUpdate
来进行提速。该方法会在重新渲染前被触发。其默认实现总是返回 true
,让 React 执行更新:
shouldComponentUpdate(nextProps, nextState) {
return true;
}
如果你知道在什么情况下你的组件不需要更新,你可以在 shouldComponentUpdate
中返回 false
来跳过整个渲染过程。其包括该组件的 render
调用以及之后的操作。
在大部分情况下,你可以继承 React.PureComponent
以代替手写 shouldComponentUpdate()
。它用当前与之前 props 和 state 的浅比较覆写了 shouldComponentUpdate()
的实现。
6、shouldComponentUpdate 的作用
这是一个组件的子树。每个节点中,SCU
代表 shouldComponentUpdate
返回的值,而 vDOMEq
代表返回的 React 元素是否相同。最后,圆圈的颜色代表了该组件是否需要被调停。
节点 C2 的 shouldComponentUpdate
返回了 false
,React 因而不会去渲染 C2,也因此 C4 和 C5 的 shouldComponentUpdate
不会被调用到。
对于 C1 和 C3,shouldComponentUpdate
返回了 true
,所以 React 需要继续向下查询子节点。这里 C6 的 shouldComponentUpdate
返回了 true
,同时由于渲染的元素与之前的不同使得 React 更新了该 DOM。
最后一个有趣的例子是 C8。React 需要渲染这个组件,但是由于其返回的 React 元素和之前渲染的相同,所以不需要更新 DOM。
显而易见,你看到 React 只改变了 C6 的 DOM。对于 C8,通过对比了渲染的 React 元素跳过了渲染。而对于 C2 的子节点和 C7,由于 shouldComponentUpdate
使得 render
并没有被调用。因此它们也不需要对比元素了。
示例
如果你的组件只有当 props.color
或者 state.count
的值改变才需要更新时,你可以使用 shouldComponentUpdate
来进行检查:
class CounterButton extends React.Component {
constructor(props) {
super(props);
this.state = {count: 1};
}
shouldComponentUpdate(nextProps, nextState) {
if (this.props.color !== nextProps.color) {
return true;
}
if (this.state.count !== nextState.count) {
return true;
}
return false;
}
render() {
return (
<button
color={this.props.color}
onClick={() => this.setState(state => ({count: state.count + 1}))}>
Count: {this.state.count}
</button>
);
}
}
在这段代码中,shouldComponentUpdate
仅检查了 props.color
或 state.count
是否改变。如果这些值没有改变,那么这个组件不会更新。如果你的组件更复杂一些,你可以使用类似“浅比较”的模式来检查 props
和 state
中所有的字段,以此来决定是否组件需要更新。React 已经提供了一位好帮手来帮你实现这种常见的模式 - 你只要继承 React.PureComponent
就行了。所以这段代码可以改成以下这种更简洁的形式:
class CounterButton extends React.PureComponent {
constructor(props) {
super(props);
this.state = {count: 1};
}
render() {
return (
<button
color={this.props.color}
onClick={() => this.setState(state => ({count: state.count + 1}))}>
Count: {this.state.count}
</button>
);
}
}
大部分情况下,你可以使用 React.PureComponent
来代替手写 shouldComponentUpdate
。但它只进行浅比较,所以当 props 或者 state 某种程度是可变的话,浅比较会有遗漏,那你就不能使用它了。当数据结构很复杂时,情况会变得麻烦。例如,你想要一个 ListOfWords
组件来渲染一组用逗号分开的单词。它有一个叫做 WordAdder
的父组件,该组件允许你点击一个按钮来添加一个单词到列表中。以下代码并不正确:
class ListOfWords extends React.PureComponent {
render() {
return <div>{this.props.words.join(',')}</div>;
}
}
class WordAdder extends React.Component {
constructor(props) {
super(props);
this.state = {
words: ['marklar']
};
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
// 这部分代码很糟,而且还有 bug
const words = this.state.words;
words.push('marklar');
this.setState({words: words});
}
render() {
return (
<div>
<button onClick={this.handleClick} />
<ListOfWords words={this.state.words} />
</div>
);
}
}
问题在于 PureComponent
仅仅会对新老 this.props.words
的值进行简单的对比。由于代码中 WordAdder
的 handleClick
方法改变了同一个 words
数组,使得新老 this.props.words
比较的其实还是同一个数组。即便实际上数组中的单词已经变了,但是比较结果是相同的。可以看到,即便多了新的单词需要被渲染, ListOfWords
却并没有被更新。
7、不可变数据的力量
避免该问题最简单的方式是避免更改你正用于 props 或 state 的值。例如,上面 handleClick
方法可以用 concat
重写:
handleClick() {
this.setState(state => ({
words: state.words.concat(['marklar'])
}));
}
ES6 数组支持扩展运算符,这让代码写起来更方便了。如果你在使用 Create React App,该语法已经默认支持了。
handleClick() {
this.setState(state => ({
words: [...state.words, 'marklar'],
}));
};
你可以用类似的方式改写代码来避免可变对象的产生。例如,我们有一个叫做 colormap
的对象。我们希望写一个方法来将 colormap.right
设置为 'blue'
。我们可以这么写:
function updateColorMap(colormap) {
colormap.right = 'blue';
}
为了不改变原本的对象,我们可以使用 Object.assign 方法:
function updateColorMap(colormap) {
return Object.assign({}, colormap, {right: 'blue'});
}
现在 updateColorMap
返回了一个新的对象,而不是修改老对象。Object.assign
是 ES6 的方法,需要 polyfill。
这里有一个 JavaScript 的提案,旨在添加对象扩展属性以使得更新不可变对象变得更方便:
function updateColorMap(colormap) {
return {...colormap, right: 'blue'};
}
如果你在使用 Create React App,Object.assign
以及对象扩展运算符已经默认支持了。
当处理深层嵌套对象时,以 immutable (不可变)的方式更新它们令人费解。如遇到此类问题,请参阅 Immer 或 immutability-helper。这些库会帮助你编写高可读性的代码,且不会失去 immutability (不可变性)带来的好处。
8、shouldComponentUpdate & PureComponent 避免重复渲染
9、使用不可突变数据结构
10、组件尽可能的进行拆分、解耦
11、bind函数优化
12、懒加载组件 强烈推荐
13、不错的文章,强烈推荐
14、react性能优化,推荐(新版本不支持了)
25、优化逻辑
50万 10万
1. 知识体系
1.1从输入 URL 到页面加载完成,发生了什么?
首先我们需要通过 DNS(域名解析系统)将 URL 解析为对应的 IP 地址,然后与这个 IP 地址确定的那台服务器建立起 TCP 网络连接,随后我们向服务端抛出我们的 HTTP 请求,服务端处理完我们的请求之后,把目标数据放在 HTTP 响应里返回给客户端,拿到响应数据的浏览器就可以开始走一个渲染的流程。渲染完毕,页面便呈现给了用户
将这个过程切分为如下的过程片段
DNS 解析
TCP 连接
HTTP 请求抛出
服务端处理请求,HTTP 响应返回
浏览器拿到响应数据,解析响应内容,把解析的结果展示给用户
1.2性能优化思维导图
2.网络篇(http)
2.1 前端能做的网络优化
输入 URL 到显示页面这个过程中,涉及到网络层面的,有三个主要过程:
DNS 解析
TCP 连接
HTTP 请求/响应
对于 DNS 解析和 TCP 连接两个步骤,我们前端可以做的努力非常有限。相比之下,HTTP 连接这一层面的优化才是我们网络优化的核心
HTTP 优化有两个大的方向
减少请求次数
减少单次请求所花费的时间
2.1 减少请求次数
2.1.1 图片:雪碧图,图标字体文件
雪碧图
多张小图片合并为一张图,利用CSS -background-position调整图片显示位置
图标字体文件
阿里图标
2.1.2 合并JS和CSS文件
webpack,需要斟酌而定
2.1.3 浏览器缓存
如果图片或者脚本,样式文件内容比较固定,不经常被修改,那么,尽可能利用缓存技术,减少HTTP请求次数或文件下载次数
2.2 减少单次请求所花费的时间
主要是减少请求中数据的大小,从而达到减少单次请求所花费的时间
2.2.1 图片
图片在线批量压缩
gzip
如果是vue项目,还有nginx,哪么vue,nginx,服务器都要开启gzip
3.网络篇(图片优化)
3.1不同业务场景下的图片方案选型
3.1.1前置知识:二进制位数与色彩的关系
在计算机中,像素用二进制数来表示。不同的图片格式中像素与二进制位数之间的对应关系是不同的。一个像素对应的二进制位数越多,它可以表示的颜色种类就越多,成像效果也就越细腻,文件体积相应也会越大。
3.2 JPEG/JPG
关键字:有损压缩、体积小、加载快、不支持透明
3.2.1 JPG 的优点
JPG 最大的特点是有损压缩。这种高效的压缩算法使它成为了一种非常轻巧的图片格式。另一方面,即使被称为“有损”压缩,JPG的压缩方式仍然是一种高质量的压缩方式:当我们把图片体积压缩至原有体积的 50% 以下时,JPG 仍然可以保持住 60% 的品质。此外,JPG 格式以 24 位存储单个图,可以呈现多达 1600 万种颜色,足以应对大多数场景下对色彩的要求,这一点决定了它压缩前后的质量损耗并不容易被我们人类的肉眼所察觉——前提是你用对了业务场景。
3.2.2 使用场景
JPG 适用于呈现色彩丰富的图片,在我们日常开发中,JPG 图片经常作为大的背景图、轮播图或 Banner 图出现。
两大电商网站对大图的处理,是 JPG 图片应用场景的最佳写照:
打开淘宝首页,我们可以发现页面中最醒目、最庞大的图片,一定是以 .jpg 为后缀的:
使用 JPG 呈现大图,既可以保住图片的质量,又不会带来令人头疼的图片体积,是当下比较推崇的一种方案。
3.2.3 JPG 的缺陷
有损压缩在上文所展示的轮播图上确实很难露出马脚,但当它处理矢量图形和 Logo 等线条感较强、颜色对比强烈的图像时,人为压缩导致的图片模糊会相当明显。
此外,JPEG 图像不支持透明度处理,透明图片需要召唤 PNG 来呈现。
3.3 png
关键字:无损压缩、质量高、体积大、支持透明
3.3.1 PNG 的优点
PNG(可移植网络图形格式)是一种无损压缩的高保真的图片格式。8 和 24,这里都是二进制数的位数。按照我们前置知识里提到的对应关系,8 位的 PNG 最多支持 256 种颜色,而 24 位的可以呈现约 1600 万种颜色。
PNG 图片具有比 JPG 更强的色彩表现力,对线条的处理更加细腻,对透明度有良好的支持。它弥补了上文我们提到的 JPG 的局限性,唯一的 BUG 就是体积太大。
3.3.2PNG-8 与 PNG-24 的选择题
什么时候用 PNG-8,什么时候用 PNG-24,这是一个问题
理论上来说,当你追求最佳的显示效果、并且不在意文件体积大小时,是推荐使用 PNG-24 的。
但实践当中,为了规避体积的问题,我们一般不用PNG去处理较复杂的图像。当我们遇到适合 PNG 的场景时,也会优先选择更为小巧的 PNG-8。
如何确定一张图片是该用 PNG-8 还是 PNG-24 去呈现呢?好的做法是把图片先按照这两种格式分别输出,看 PNG-8 输出的结果是否会带来肉眼可见的质量损耗,并且确认这种损耗是否在我们(尤其是你的 UI 设计师)可接受的范围内,基于对比的结果去做判断。
3.3.3 应用场景
前面我们提到,复杂的、色彩层次丰富的图片,用 PNG 来处理的话,成本会比较高,我们一般会交给 JPG 去存储。
考虑到 PNG 在处理线条和颜色对比度方面的优势,我们主要用它来呈现小的 Logo、颜色简单且对比强烈的图片或背景等。
此时我们再次把目光转向性能方面堪称业界楷模的淘宝首页,我们会发现它页面上的 Logo,无论大小,还真的都是 PNG 格式:
3.4 SVG
关键字:文本文件、体积小、不失真、兼容性好
3.4.1 SVG 的使用方式与应用场景
将 SVG 写入 HTML
将 SVG 写入独立文件后引入 HTML:
3.5 Base64
关键字:文本文件、依赖编码、小图标解决方案
3.5.1 Base64 的应用场景
图片的实际尺寸很小(大家可以观察一下掘金页面的 Base64 图,几乎没有超过 2kb 的)
图片无法以雪碧图的形式与其它小图结合(合成雪碧图仍是主要的减少 HTTP 请求的途径,Base64 是雪碧图的补充)
图片的更新频率非常低(不需我们重复编码和修改文件内容,维护成本较低)
3.6 WebP
关键字:年轻的全能型选手
是 Google 专为 Web 开发的一种旨在加快图片加载速度的图片格式,它支持有损压缩和无损压缩。
3.6.1 WebP 的优点
WebP 像 JPEG 一样对细节丰富的图片信手拈来,像 PNG 一样支持透明,像 GIF 一样可以显示动态图片——它集多种图片文件格式的优点于一身。
3.6.2WebP 的局限性
兼容性
3.7 总结
不同业务场景下的图片方案选型
4.存储篇(浏览器缓存)
4.1 什么是缓存
对于一个数据请求来说,可以分为发起网络请求、后端处理、浏览器响应三个步骤
浏览器缓存可以帮助我们在第一和第三步骤中优化性能。比如说直接使用缓存而不发起请求,或者发起了请求但后端存储的数据和前端一致,那么就没有必要再将数据回传回来,这样就减少了响应数据。
缓存思维导图
4.2 缓存位置
4.2.1 缓存优先级
从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络。
Service Worker
Memory Cache
Disk Cache
Push Cache
4.2.2 Service Worker
不了解
MDN
4.2.3 MemoryCache
MemoryCache,是指存在内存中的缓存。从优先级上来说,它是浏览器最先尝试去命中的一种缓存。从效率上来说,它是响应速度最快的一种缓存。
内存缓存是快的,也是“短命”的。它和渲染进程“生死相依”,当进程结束后,也就是 tab 关闭以后,内存里的数据也将不复存在。
那么哪些文件会被放入内存呢?
事实上,这个划分规则,一直以来是没有定论的。不过想想也可以理解,内存是有限的,很多时候需要先考虑即时呈现的内存余量,再根据具体的情况决定分配给内存和磁盘的资源量的比重——资源存放的位置具有一定的随机性
虽然划分规则没有定论,但根据日常开发中观察的结果,包括我们开篇给大家展示的 Network 截图,我们至少可以总结出这样的规律:资源存不存内存,浏览器秉承的是“节约原则”。我们发现,Base64 格式的图片,几乎永远可以被塞进 memory cache,这可以视作浏览器为节省渲染开销的“自保行为”;此外,体积不大的 JS、CSS 文件,也有较大地被写入内存的几率——相比之下,较大的 JS、CSS 文件就没有这个待遇了,内存资源是有限的,它们往往被直接甩进磁盘。
4.2.4 Disk Cache
Disk Cache 也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上。
在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。绝大部分的缓存都来自 Disk Cache,关于 HTTP 的协议头中的缓存字段,我们会在下文进行详细介绍
浏览器会把哪些文件丢进内存中?哪些丢进硬盘中
对于大文件来说,大概率是不存储在内存中的,反之优先
当前系统内存使用率高的话,文件优先存储进硬盘
4.2.5 Push Cache
不了解
push Cache(推送缓存)是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用
4.3 缓存过程分析
浏览器与服务器通信的方式为应答模式,即是:浏览器发起HTTP请求 – 服务器响应该请求,那么浏览器怎么确定一个资源该不该缓存,如何去缓存呢?浏览器第一次向服务器发起该请求后拿到请求结果后,将请求结果和缓存标识存入浏览器缓存,浏览器对于缓存的处理是根据第一次请求资源时返回的响应头来确定的。具体过程如下图:
上图我们可以知道
浏览器每次发起请求,都会先在浏览器缓存中查找该请求的结果以及缓存标识
浏览器每次拿到返回的请求结果都会将该结果和缓存标识存入浏览器缓存中
4.4 http缓存
HTTP 缓存是我们日常开发中最为熟悉的一种缓存机制。它又分为强缓存和协商缓存。优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存。
4.5 强缓存
强缓存:不会向服务器发送请求,直接从缓存中读取资源,在chrome控制台的Network选项中可以看到该请求返回200的状态码,并且Size显示from disk cache或from memory cache。强缓存可以通过设置两种 HTTP Header 实现:Expires 和 Cache-Control。
4.5.1 Expires
缓存过期时间,用来指定资源到期的时间,是服务器端的具体的时间点。也就是说,Expires=max-age + 请求时间,需要和Last-modified结合使用。Expires是Web服务器响应消息头字段,在响应http请求时告诉浏览器在过期时间前浏览器可以直接从浏览器缓存取数据,而无需再次请求。
Expires 是 HTTP/1 的产物,受限于本地时间,如果修改了本地时间,可能会造成缓存失效。Expires: Wed, 22 Oct 2018 08:41:00 GMT表示资源会在 Wed, 22 Oct 2018 08:41:00 GMT 后过期,需要再次请求。
缓存过期时间,用来指定资源的到期时间,是服务器端的具体的时间点
告诉浏览器在过期时间前浏览器可以直接从浏览器缓存取数据,而不用再次请求
max-age的优化级高于expires,当有max-age的时候,会无视expires
当在有效时间内,如果服务器端的文件已经发生改变,但是浏览器端无法感知
4.5.2 Cache-Control
在HTTP/1.1中,Cache-Control是最重要的规则,主要用于控制网页缓存Cache-Control 可以在请求头或者响应头中设置,并且可以组合使用多种指令
max-age
s-maxage
private
public
no-cache
no-store
max-age
max-age=xxx (xxx is numeric)表示缓存内容将在xxx秒后失效
设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与Expires相反,时间是相对于请求的时间,
优先级高于Expires
s-maxage
覆盖max-age 或者 Expires 头,但是仅适用于共享缓存(比如各个代理),并且私有缓存中它被忽略
能用于public,如CDN
优先级高于max-age
private
所有内容只有客户端可以缓存
表示中间节点不允许缓存,对于Browser <-- proxy1 <-- proxy2 <-- Server,proxy 会老老实实把Server 返回的数据发送给proxy1,自己不缓存任何数据。当下次Browser再次请求时proxy会做好请求转发而不是自作主张给自己缓存的数据
表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它),可以缓存响应内容
自己的服务器
public
所有内容都将被缓存(客户端和代理服务器都可缓存)
具体来说响应可被任何中间节点缓存,如 Browser <-- proxy1 <-- proxy2 <-- Server,中间的proxy可以缓存资源,比如下次再请求同一资源proxy1直接把自己缓存的东西给 Browser 而不再向proxy2要。
no-store
所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存
缓存不应存储有关客户端请求或服务器响应的任何内容。
不会使用任何缓存策略
no-cache
客户端缓存内容,是否使用缓存则需要经过协商缓存来验证决定。表示不使用 Cache-Control的缓存控制方式做前置验证,而是使用 Etag 或者Last-Modified字段来控制缓存。需要注意的是,no-cache这个名字有一点误导。设置了no-cache之后,并不是说浏览器就不再缓存数据,只是浏览器在使用缓存数据时,需要先确认一下数据是否还跟服务器保持一致
释放缓存副本之前,强制高速缓存将请求提交给原始服务器进行验证
这个文件不管怎么样,都会向服务器发起请求,去服务器哪边询问,这个文件有没有在缓存策略里
强缓存思维导图
4.6 协商缓存
4.6.1 什么是协商缓存
协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程,主要有以下两种情况:
协商缓存生效,返回304和Not Modified
协商缓存失效,返回200和请求结果
4.6.2 Last-Modified和If-Modified-Since
ast-Modified 是一个响应首部,其中包含源头服务器认定的资源做出修改的日期及时间。 它通常被用作一个验证器来判断接收到的或者存储的资源是否彼此一致。由于精确度比 ETag 要低,所以这是一个备用机制。包含有 If-Modified-Since 或 If-Unmodified-Since 首部的条件请求会使用这个字段。
基于客户端和服务端协商的缓存机制
Last-Modified ----response header
If-Modified-Since----request header
需要与cache-control共同使用
max-age的优先级高于Last-Modified
缺点
某些服务端不能获取精确的修改时间
文件修改时间改了,但文件内容却没有变
4.6.3 Etag/If-None-Match
ETagHTTP响应头是资源的特定版本的标识符。这可以让缓存更高效,并节省带宽,因为如果内容没有改变,Web服务器不需要发送完整的响应。而如果内容发生了变化,使用ETag有助于防止资源的同时更新相互覆盖(“空中碰撞”)
文件内容的hash值
etag--response header
if-none-match -- request header
要与cache-control共同使用
4.6.4两者对比
首先在精确度上,Etag要优于Last-Modified。
第二在性能上,Etag要逊于Last-Modified,毕竟Last-Modified只需要记录时间,而Etag需要服务器通过算法来计算出一个hash值。
第三在优先级上,服务器校验优先考虑Etag
4.6.5 思维导图
4.7 参考
浏览器缓存机制介绍与缓存策略剖析
一文读懂前端缓存
深入理解浏览器的缓存机制
5. 存储篇(本地存储) 5.1 Cookie
Cookie 的本职工作并非本地存储,而是“维持状态”。
Cookie 不够大
同一个域名下的所有请求,都会携带 Cookie
5.2 Local Storage,Session Storage 5.2.1 概述
存储容量大: Web Storage 根据浏览器的不同,存储容量可以达到 5-10M 之间
仅位于浏览器端,不与服务端发生通信。
5.2.2 Local Storage 与 Session Storage 的区别
生命周期:Local Storage 是持久化的本地存储,存储在其中的数据是永远不会过期的,使其消失的唯一办法是手动删除;而 Session Storage 是临时性的本地存储,它是会话级别的存储,当会话结束(页面被关闭)时,存储内容也随之被释放。
作用域:Local Storage、Session Storage 和 Cookie 都遵循同源策略。但 Session Storage 特别的一点在于,即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 Session Storage 内容便无法共享。
5.2.3 应用场景
Local Storage
理论上 Cookie 无法胜任的、可以用简单的键值对来存取的数据存储任务,都可以交给 Local Storage 来做。
存储一些内容稳定的资源。比如图片内容丰富的电商网站会用它来存储 Base64 格式的图片字符串
存储一些不经常更新的 CSS、JS 等静态资源
Session Storage
微博的 Session Storage 就主要是存储你本次会话的浏览足迹
5.3 IndexedDB
IndexedDB 是没有存储上限的(一般来说不会小于 250M)
IndexedDB 可以看做是 LocalStorage 的一个升级,当数据的复杂度和规模上升到了 LocalStorage 无法解决的程度,我们毫无疑问可以请出 IndexedDB 来帮忙。
6. CDN 6.1 什么是CDN
CDN (Content Delivery Network,即内容分发网络)指的是一组分布在各个地区的服务器。这些服务器存储着数据的副本,因此服务器可以根据哪些服务器与用户距离最近,来满足数据的请求。 CDN 提供快速服务,较少受高流量影响。
6.2 为什么要用 CDN
缓存、本地存储带来的性能提升,是不是只能在“获取到资源并把它们存起来”这件事情发生之后?也就是说,首次请求资源的时候,这些招数都是救不了我们的。要提升首次请求的响应能力,我们还需要借助 CDN 的能力
6.3 CDN 如何工作
假设我的根服务器在杭州,同时在图示的五个城市里都有自己可用的机房
此时有一位北京的用户向我请求资源。在网络带宽小、用户访问量大的情况下,杭州的这一台服务器或许不那么给力,不能给用户非常快的响应速度。于是我灵机一动,把这批资源 copy 了一批放在北京的机房里。当用户请求资源时,就近请求北京的服务器,北京这台服务器低头一看,这个资源我存了,离得这么近,响应速度肯定噌噌的!那如果北京这台服务器没有 copy 这批资源呢?它会再向杭州的根服务器去要这个资源。在这个过程中,北京这台服务器就扮演着 CDN 的角色。
6.4 CDN的核心功能
CDN 的核心点有两个,一个是缓存,一个是回源。
缓存
“缓存”就是说我们把资源 copy 一份到 CDN 服务器上这个过程
回源
就是说 CDN 发现自己没有这个资源(一般是缓存的数据过期了),转头向根服务器(或者它的上层服务器)去要这个资源的过程。
6.5 CDN 与前端性能优化
CDN 往往被用来存放静态资源
“根服务器”本质上是业务服务器,它的核心任务在于生成动态页面或返回非纯静态页面,这两种过程都是需要计算的。业务服务器仿佛一个车间,车间里运转的机器轰鸣着为我们产出所需的资源;相比之下,CDN 服务器则像一个仓库,它只充当资源的“栖息地”和“搬运工”。
所谓“静态资源”,就是像 JS、CSS、图片等不需要业务服务器进行计算即得的资源。而“动态资源”,顾名思义是需要后端实时动态生成的资源,较为常见的就是 JSP、ASP 或者依赖服务端渲染得到的 HTML 页面。
什么是“非纯静态资源”呢?它是指需要服务器在页面之外作额外计算的 HTML 页面。具体来说,当我打开某一网站之前,该网站需要通过权限认证等一系列手段确认我的身份、进而决定是否要把 HTML 页面呈现给我。这种情况下 HTML 确实是静态的,但它和业务服务器的操作耦合,我们把它丢到CDN 上显然是不合适的。
6.6 CDN 的实际应用
静态资源本身具有访问频率高、承接流量大的特点,因此静态资源加载速度始终是前端性能的一个非常关键的指标。CDN 是静态资源提速的重要手段,在许多一线的互联网公司,“静态资源走 CDN”并不是一个建议,而是一个规定
https://www.taobao.com/
可以看到业务服务器确实是返回给了我们一个尚未被静态资源加持过的简单 HTML 页面
随便点开一个静态资源,可以看到它都是从 CDN 服务器上请求来的
6.7 cdn与cookie
Cookie 是紧跟域名的。同一个域名下的所有请求,都会携带 Cookie。大家试想,如果我们此刻仅仅是请求一张图片或者一个 CSS 文件,我们也要携带一个 Cookie 跑来跑去(关键是 Cookie 里存储的信息我现在并不需要),这是一件多么劳民伤财的事情。Cookie 虽然小,请求却可以有很多,随着请求的叠加,这样的不必要的 Cookie 带来的开销将是无法想象的
同一个域名下的请求会不分青红皂白地携带 Cookie,而静态资源往往并不需要 Cookie 携带什么认证信息。把静态资源和主页面置于不同的域名下,完美地避免了不必要的 Cookie 的出现!
看起来是一个不起眼的小细节,但带来的效用却是惊人的。以电商网站静态资源的流量之庞大,如果没把这个多余的 Cookie 拿下来,不仅用户体验会大打折扣,每年因性能浪费带来的经济开销也将是一个非常恐怖的数字。
7.渲染篇(服务端渲染) 7.1 客户端渲染
客户端渲染模式下,服务端会把渲染需要的静态文件发送给客户端,客户端加载过来之后,自己在浏览器里跑一遍 JS,根据 JS 的运行结果,生成相应的 DOM
<!doctype html>
<html>
<head>
<title>我是客户端渲染的页面</title>
</head>
<body>
<div id='root'></div>
<script src='index.js'></script>
</body>
</html>
根节点下到底是什么内容呢?你不知道,我不知道,只有浏览器把 index.js 跑过一遍后才知道,这就是典型的客户端渲染。
页面上呈现的内容,你在 html 源文件里里找不到——这正是它的特点。
7.2 服务端渲染
服务端渲染的模式下,当用户第一次请求页面时,由服务器把需要的组件或页面渲染成 HTML 字符串,然后把它返回给客户端。客户端拿到手的,是可以直接渲染然后呈现给用户的 HTML 内容,不需要为了生成 DOM 内容自己再去跑一遍 JS 代码。
使用服务端渲染的网站,可以说是“所见即所得”,页面上呈现的内容,我们在 html 源文件里也能找到。
7.3 服务端渲染解决了什么性能问题
事实上,很多网站是出于效益(seo)的考虑才启用服务端渲染,性能倒是在其次。
假设 A 网站页面中有一个关键字叫“前端性能优化”,这个关键字是 JS 代码跑过一遍后添加到 HTML 页面中的。那么客户端渲染模式下,我们在搜索引擎搜索这个关键字,是找不到 A 网站的——搜索引擎只会查找现成的内容,不会帮你跑 JS 代码。A 网站的运营方见此情形,感到很头大:搜索引擎搜不出来,用户找不到我们,谁还会用我的网站呢?为了把“现成的内容”拿给搜索引擎看,A 网站不得不启用服务端渲染。
但性能在其次,不代表性能不重要。服务端渲染解决了一个非常关键的性能问题——首屏加载速度过慢。在客户端渲染模式下,我们除了加载 HTML,还要等渲染所需的这部分 JS 加载完,之后还得把这部分 JS 在浏览器上再跑一遍。这一切都是发生在用户点击了我们的链接之后的事情,在这个过程结束之前,用户始终见不到我们网页的庐山真面目,也就是说用户一直在等!相比之下,服务端渲染模式下,服务器给到客户端的已经是一个直接可以拿来呈现给用户的网页,中间环节早在服务端就帮我们做掉了,用户岂不“美滋滋”?
7.4 服务端渲染的应用场景
服务端渲染本质上是本该浏览器做的事情,分担给服务器去做。这样当资源抵达浏览器时,它呈现的速度就快了
但仔细想想,在这个网民遍地的时代,几乎有多少个用户就有多少台浏览器。用户拥有的浏览器总量多到数不清,那么一个公司的服务器又有多少台呢?我们把这么多台浏览器的渲染压力集中起来,分散给相比之下数量并不多的服务器,服务器肯定是承受不住的
除非网页对性能要求太高了,以至于所有的招式都用完了,性能表现还是不尽人意,这时候我们就可以考虑向老板多申请几台服务器,把服务端渲染搞起来了~
8.渲染篇(浏览器渲染) 8.1 浏览器内核
浏览器内核可以分成两部分:渲染引擎(Layout Engine 或者 Rendering Engine)和 JS 引擎
渲染引擎又包括了 HTML 解释器、CSS 解释器、布局、网络、存储、图形、音视频、图片解码器等等零部件。
8.2 浏览器渲染过程解析
8.3 基于渲染流程的 CSS 优化建议 8.3.1 CSS 选择符是从右到左进行匹配的
#myList li {}
浏览器必须遍历页面上每个 li 元素,并且每次都要去确认这个 li 元素的父元素 id 是不是 myList
8.3.2 具体优化
避免使用通配符,只对需要用到的元素进行选择
关注可以通过继承实现的属性,避免重复匹配重复定义。
少用标签选择器。如果可以,用类选择器替代
不要画蛇添足,id 和 class 选择器不应该被多余的标签选择器拖后腿
减少嵌套。后代选择器的开销是最高的,因此我们应该尽量将选择器的深度降到最低(最高不要超过三层),尽可能使用类来关联每一个标签元素
8.4 告别阻塞:CSS 与 JS 的加载顺序优化
HTML、CSS 和 JS,都具有阻塞渲染的特性。
HTML 阻塞,天经地义——没有 HTML,何来 DOM?没有 DOM,渲染和优化,都是空谈。
8.4.1 CSS 的阻塞
在刚刚的过程中,我们提到 DOM 和 CSSOM 合力才能构建渲染树。这一点会给性能造成严重影响:默认情况下,CSS 是阻塞的资源。浏览器在构建 CSSOM 的过程中,不会渲染任何已处理的内容。即便 DOM 已经解析完毕了,只要 CSSOM 不 OK,那么渲染这个事情就不 OK(这主要是为了避免没有 CSS 的 HTML 页面丑陋地“裸奔”在用户眼前)。
只有当我们开始解析 HTML 后、解析到 link 标签或者 style 标签时,CSS 才登场,CSSOM 的构建才开始。很多时候,DOM 不得不等待 CSSOM。因此我们可以这样总结:
CSS 是阻塞渲染的资源。需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。
尽早(将 CSS 放在 head 标签里)和尽快(启用 CDN 实现静态资源加载速度的优化)
8.4.2 JS 的阻塞
JS 的作用在于修改,它帮助我们修改网页的方方面面:内容、样式以及它如何响应用户交互。这“方方面面”的修改,本质上都是对 DOM 和 CSSDOM 进行修改。因此 JS 的执行会阻止 CSSOM,在我们不作显式声明的情况下,它也会阻塞 DOM。
JS 引擎是独立于渲染引擎存在的。我们的 JS 代码在文档的何处插入,就在何处执行。当 HTML 解析器遇到一个 script 标签时,它会暂停渲染过程,将控制权交给 JS 引擎。JS 引擎对内联的 JS 代码会直接执行,对外部 JS 文件还要先获取到脚本、再进行执行。等 JS 引擎运行完毕,浏览器又会把控制权还给渲染引擎,继续 CSSOM 和 DOM 的构建。 因此与其说是 JS 把 CSS 和 HTML 阻塞了,不如说是 JS 引擎抢走了渲染引擎的控制权。
8.4.3 JS的三种加载方式
正常模式
这种情况下 JS 会阻塞浏览器,浏览器必须等待 index.js 加载和执行完毕才能去做其它事情。
<script src="index.js"></script>
async 模式
async 模式下,JS 不会阻塞浏览器做任何其它的事情。它的加载是异步的,当它加载结束,JS 脚本会立即执行。
<script async src="index.js"></script>
defer 模式
efer 模式下,JS 的加载是异步的,执行是被推迟的。等整个文档解析完成、DOMContentLoaded 事件即将被触发时,被标记了 defer 的 JS 文件才会开始依次执行。
<script defer src="index.js"></script>
从应用的角度来说,一般当我们的脚本与 DOM 元素和其它脚本之间的依赖关系不强时,我们会选用 async;当脚本依赖于 DOM 元素和其它脚本的执行结果时,我们会选用 defer。
9. 渲染篇(dom优化) 10. 渲染篇(Event Loop与异步更新策略(vue)) 10.1 什么是异步更新?
当我们使用 Vue 或 React 提供的接口去更新数据时,这个更新并不会立即生效,而是会被推入到一个队列里。待到适当的时机,队列中的更新任务会被批量触发。这就是异步更新。
异步更新可以帮助我们避免过度渲染,是我们上节提到的“让 JS 为 DOM 分压”的典范之一。
10.2 异步更新的优越性
异步更新的特性在于它只看结果,因此渲染引擎不需要为过程买单
有时我们会遇到这样的情况
// 任务一
this.content = '第一次测试'
// 任务二
this.content = '第二次测试'
// 任务三
this.content = '第三次测试'
我们在三个更新任务中对同一个状态修改了三次,如果我们采取传统的同步更新策略,那么就要操作三次 DOM。但本质上需要呈现给用户的目标内容其实只是第三次的结果,也就是说只有第三次的操作是有意义的——我们白白浪费了两次计算。
但如果我们把这三个任务塞进异步更新队列里,它们会先在 JS 的层面上被批量执行完毕。当流程走到渲染这一步时,它仅仅需要针对有意义的计算结果操作一次 DOM——这就是异步更新的妙处。
10.3 Vue状态更新手法:nextTick
Vue在观察到数据变化时并不是直接更新DOM,而是开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。在缓冲时会去除重复数据,从而避免不必要的计算和DOM操作。然后,在下一个事件循环tick中,Vue刷新队列并执行实际工作
$nextTick就是用来知道什么时候DOM更新完成的
11. 渲染篇(回流与重绘)
26、HTTP 及 TLS
这一章节我们将来学习 HTTP 及 TLS 协议中的内容。
1. 讲讲网络OSI七层模型,TCP/IP和HTTP分别位于哪一层
模型 | 概述 | 单位 |
---|---|---|
物理层 | 网络连接介质,如网线、光缆,数据在其中以比特为单位传输 | bit |
数据链路层 | 数据链路层将比特封装成数据帧并传递 | 帧 |
网络层 | 定义IP地址,定义路由功能,建立主机到主机的通信 | 数据包 |
传输层 | 负责将数据进行可靠或者不可靠传递,建立端口到端口的通信 | 数据段 |
会话层 | 控制应用程序之间会话能力,区分不同的进程 | |
表示层 | 数据格式标识,基本压缩加密功能 | |
应用层 | 各种应用软件 |
2. HTTP/1.0和HTTP/1.1有什么区别
•长连接: HTTP/1.1支持长连接和请求的流水线,在一个TCP连接上可以传送多个HTTP请求,避免了因为多次建立TCP连接的时间消耗和延时
•缓存处理: HTTP/1.1引入Entity tag,If-Unmodified-Since, If-Match, If-None-Match
等新的请求头来控制缓存,详见浏览器缓存小节
•带宽优化及网络连接的使用: HTTP1.1则在请求头引入了range头域,支持断点续传功能
•Host头处理: 在HTTP/1.0中认为每台服务器都有唯一的IP地址,但随着虚拟主机技术的发展,多个主机共享一个IP地址愈发普遍,HTTP1.1的请求消息和响应消息都应支持Host头域,且请求消息中如果没有Host头域会400错误
3. 介绍一下HTTP/2.0新特性
•多路复用: 即多个请求都通过一个TCP连接并发地完成
•服务端推送: 服务端能够主动把资源推送给客户端
•新的二进制格式: HTTP/2采用二进制格式传输数据,相比于HTTP/1.1的文本格式,二进制格式具有更好的解析性和拓展性
•header压缩: HTTP/2压缩消息头,减少了传输数据的大小
4. 说说HTTP/2.0多路复用基本原理以及解决的问题
HTTP/2解决的问题,就是HTTP/1.1存在的问题:
•TCP慢启动: TCP连接建立后,会经历一个先慢后快的发送过程,就像汽车启动一般,如果我们的网页文件(HTML/JS/CSS/icon)都经过一次慢启动,对性能是不小的损耗。另外慢启动是TCP为了减少网络拥塞的一种策略,我们是没有办法改变的。
•多条TCP连接竞争带宽: 如果同时建立多条TCP连接,当带宽不足时就会竞争带宽,影响关键资源的下载。•HTTP/1.1队头阻塞: 尽管HTTP/1.1长链接可以通过一个TCP连接传输多个请求,但同一时刻只能处理一个请求,当前请求未结束前,其他请求只能处于阻塞状态。
为了解决以上几个问题,HTTP/2一个域名只使用一个TCP⻓连接来传输数据,而且请求直接是并行的、非阻塞的,这就是多路复用
实现原理: HTTP/2引入了一个二进制分帧层,客户端和服务端进行传输时,数据会先经过二进制分帧层处理,转化为一个个带有请求ID的帧,这些帧在传输完成后根据ID组合成对应的数据。
5. 说说HTTP/3.0
尽管HTTP/2解决了很多1.1的问题,但HTTP/2仍然存在一些缺陷,这些缺陷并不是来自于HTTP/2协议本身,而是来源于底层的TCP协议,我们知道TCP链接是可靠的连接,如果出现了丢包,那么整个连接都要等待重传,HTTP/1.1可以同时使用6个TCP连接,一个阻塞另外五个还能工作,但HTTP/2只有一个TCP连接,阻塞的问题便被放大了。
由于TCP协议已经被广泛使用,我们很难直接修改TCP协议,基于此,HTTP/3选择了一个折衷的方法——UDP协议,HTTP/2在UDP的基础上实现多路复用、0-RTT、TLS加密、流量控制、丢包重传等功能。
参考资料:http发展史(http0.9、http1.0、http1.1、http2、http3)梳理笔记[14] (推荐阅读)
6. HTTP和HTTPS有何区别
•HTTPS使用443端口,而HTTP使用80•HTTPS需要申请证书•HTTP是超文本传输协议,是明文传输;HTTPS是经过SSL加密的协议,传输更安全•HTTPS比HTTP慢,因为HTTPS除了TCP握手的三个包,还要加上SSL握手的九个包
7. HTTPS是如何进行加密的
我们通过分析几种加密方式,层层递进,理解HTTPS的加密方式以及为什么使用这种加密方式:
对称加密
客户端和服务器公用一个密匙用来对消息加解密,这种方式称为对称加密。客户端和服务器约定好一个加密的密匙。客户端在发消息前用该密匙对消息加密,发送给服务器后,服务器再用该密匙进行解密拿到消息。这种方式一定程度上保证了数据的安全性,但密钥一旦泄露(密钥在传输过程中被截获),传输内容就会暴露,因此我们要寻找一种安全传递密钥的方法。
非对称加密
采用非对称加密时,客户端和服务端均拥有一个公钥和私钥,公钥加密的内容只有对应的私钥能解密。私钥自己留着,公钥发给对方。这样在发送消息前,先用对方的公钥对消息进行加密,收到后再用自己的私钥进行解密。这样攻击者只拿到传输过程中的公钥也无法破解传输的内容尽管非对称加密解决了由于密钥被获取而导致传输内容泄露的问题,但中间人仍然可以用
篡改公钥
的方式来获取或篡改传输内容,而且非对称加密的性能比对称加密的性能差了不少
第三方认证
上面这种方法的弱点在于,客户端不知道公钥是由服务端返回,还是中间人返回的,因此我们再引入一个第三方认证的环节:即第三方使用私钥加密我们自己的公钥
,浏览器已经内置一些权威第三方认证机构的公钥,浏览器会使用第三方的公钥
来解开第三方私钥加密过的我们自己的公钥
,从而获取公钥,如果能成功解密,就说明获取到的自己的公钥
是正确的
但第三方认证也未能完全解决问题,第三方认证是面向所有人的,中间人也能申请证书,如果中间人使用自己的证书掉包原证书,客户端还是无法确认公钥的真伪
数字签名
为了让客户端能够验证公钥的来源,我们给公钥加上一个数字签名,这个数字签名是由企业、网站等各种信息和公钥经过单向hash而来,一旦构成数字签名的信息发生变化,hash值就会改变,这就构成了公钥来源的唯一标识。
具体来说,服务端本地生成一对密钥,然后拿着公钥以及企业、网站等各种信息到CA(第三方认证中心)去申请数字证书,CA会通过一种单向hash算法(比如MD5),生成一串摘要,这串摘要就是这堆信息的唯一标识,然后CA还会使用自己的私钥对摘要进行加密,连同我们自己服务器的公钥一同发送给我我们。
浏览器拿到数字签名后,会使用浏览器本地内置的CA公钥解开数字证书并验证,从而拿到正确的公钥。由于非对称加密性能低下,拿到公钥以后,客户端会随机生成一个对称密钥,使用这个公钥加密并发送给服务端,服务端用自己的私钥解开对称密钥,此后的加密连接就通过这个对称密钥进行对称加密。
综上所述,HTTPS在验证阶段使用非对称加密+第三方认证+数字签名获取正确的公钥,获取到正确的公钥后以对称加密的方式通信
参考资料:看图学HTTPS[15]
HTTP 请求中的内容
HTTP 请求由三部分构成,分别为:
- 请求行
- 首部
- 实体
请求行大概长这样 GET /images/logo.gif HTTP/1.1
,基本由请求方法、URL、协议版本组成,这其中值得一说的就是请求方法了。
请求方法分为很多种,最常用的也就是 Get
和 Post
了。虽然请求方法有很多,但是更多的是传达一个语义,而不是说 Post
能做的事情 Get
就不能做了。如果你愿意,都使用 Get
请求或者 Post
请求都是可以的。更多请求方法的语义描述可以阅读 文档。
常考面试题:Post 和 Get 的区别?
首先先引入副作用和幂等的概念。
副作用指对服务器上的资源做改变,搜索是无副作用的,注册是副作用的。
幂等指发送 M 和 N 次请求(两者不相同且都大于 1),服务器上资源的状态一致,比如注册 10 个和 11 个帐号是不幂等的,对文章进行更改 10 次和 11 次是幂等的。因为前者是多了一个账号(资源),后者只是更新同一个资源。
在规范的应用场景上说,Get 多用于无副作用,幂等的场景,例如搜索关键字。Post 多用于副作用,不幂等的场景,例如注册。
在技术上说:
- Get 请求能缓存,Post 不能
- Post 相对 Get 安全一点点,因为Get 请求都包含在 URL 里(当然你想写到
body
里也是可以的),且会被浏览器保存历史纪录。Post 不会,但是在抓包的情况下都是一样的。 - URL有长度限制,会影响 Get 请求,但是这个长度限制是浏览器规定的,不是 RFC 规定的
- Post 支持更多的编码类型且不对数据类型限制
首部
首部分为请求首部和响应首部,并且部分首部两种通用,接下来我们就来学习一部分的常用首部。
通用首部
通用字段 | 作用 |
---|---|
Cache-Control | 控制缓存的行为 |
Connection | 浏览器想要优先使用的连接类型,比如 keep-alive |
Date | 创建报文时间 |
Pragma | 报文指令 |
Via | 代理服务器相关信息 |
Transfer-Encoding | 传输编码方式 |
Upgrade | 要求客户端升级协议 |
Warning | 在内容中可能存在错误 |
请求首部
请求首部 | 作用 |
---|---|
Accept | 能正确接收的媒体类型 |
Accept-Charset | 能正确接收的字符集 |
Accept-Encoding | 能正确接收的编码格式列表 |
Accept-Language | 能正确接收的语言列表 |
Expect | 期待服务端的指定行为 |
From | 请求方邮箱地址 |
Host | 服务器的域名 |
If-Match | 两端资源标记比较 |
If-Modified-Since | 本地资源未修改返回 304(比较时间) |
If-None-Match | 本地资源未修改返回 304(比较标记) |
User-Agent | 客户端信息 |
Max-Forwards | 限制可被代理及网关转发的次数 |
Proxy-Authorization | 向代理服务器发送验证信息 |
Range | 请求某个内容的一部分 |
Referer | 表示浏览器所访问的前一个页面 |
TE | 传输编码方式 |
响应首部
响应首部 | 作用 |
---|---|
Accept-Ranges | 是否支持某些种类的范围 |
Age | 资源在代理缓存中存在的时间 |
ETag | 资源标识 |
Location | 客户端重定向到某个 URL |
Proxy-Authenticate | 向代理服务器发送验证信息 |
Server | 服务器名字 |
WWW-Authenticate | 获取资源需要的验证信息 |
实体首部
实体首部 | 作用 |
---|---|
Allow | 资源的正确请求方式 |
Content-Encoding | 内容的编码格式 |
Content-Language | 内容使用的语言 |
Content-Length | request body 长度 |
Content-Location | 返回数据的备用地址 |
Content-MD5 | Base64加密格式的内容 MD5检验值 |
Content-Range | 内容的位置范围 |
Content-Type | 内容的媒体类型 |
Expires | 内容的过期时间 |
Last_modified | 内容的最后修改时间 |
常见状态码
状态码表示了响应的一个状态,可以让我们清晰的了解到这一次请求是成功还是失败,如果失败的话,是什么原因导致的,当然状态码也是用于传达语义的。如果胡乱使用状态码,那么它存在的意义就没有了。
状态码通常也是一道常考题。
2XX 成功
- 200 OK,表示从客户端发来的请求在服务器端被正确处理
- 204 No content,表示请求成功,但响应报文不含实体的主体部分
- 205 Reset Content,表示请求成功,但响应报文不含实体的主体部分,但是与 204 响应不同在于要求请求方重置内容
- 206 Partial Content,进行范围请求
3XX 重定向
- 301 moved permanently,永久性重定向,表示资源已被分配了新的 URL
- 302 found,临时性重定向,表示资源临时被分配了新的 URL
- 303 see other,表示资源存在着另一个 URL,应使用 GET 方法获取资源
- 304 not modified,表示服务器允许访问资源,但因发生请求未满足条件的情况
- 307 temporary redirect,临时重定向,和302含义类似,但是期望客户端保持请求方法不变向新的地址发出请求
4XX 客户端错误
- 400 bad request,请求报文存在语法错误
- 401 unauthorized,表示发送的请求需要有通过 HTTP 认证的认证信息
- 403 forbidden,表示对请求资源的访问被服务器拒绝
- 404 not found,表示在服务器上没有找到请求的资源
5XX 服务器错误
- 500 internal sever error,表示服务器端在执行请求时发生了错误
- 501 Not Implemented,表示服务器不支持当前请求所需要的某个功能
- 503 service unavailable,表明服务器暂时处于超负载或正在停机维护,无法处理请求
TLS
HTTPS 还是通过了 HTTP 来传输信息,但是信息通过 TLS 协议进行了加密。
TLS 协议位于传输层之上,应用层之下。首次进行 TLS 协议传输需要两个 RTT ,接下来可以通过 Session Resumption 减少到一个 RTT。
在 TLS 中使用了两种加密技术,分别为:对称加密和非对称加密。
对称加密:
对称加密就是两边拥有相同的秘钥,两边都知道如何将密文加密解密。
这种加密方式固然很好,但是问题就在于如何让双方知道秘钥。因为传输数据都是走的网络,如果将秘钥通过网络的方式传递的话,一旦秘钥被截获就没有加密的意义的。
非对称加密:
有公钥私钥之分,公钥所有人都可以知道,可以将数据用公钥加密,但是将数据解密必须使用私钥解密,私钥只有分发公钥的一方才知道。
这种加密方式就可以完美解决对称加密存在的问题。假设现在两端需要使用对称加密,那么在这之前,可以先使用非对称加密交换秘钥。
简单流程如下:首先服务端将公钥公布出去,那么客户端也就知道公钥了。接下来客户端创建一个秘钥,然后通过公钥加密并发送给服务端,服务端接收到密文以后通过私钥解密出正确的秘钥,这时候两端就都知道秘钥是什么了。
TLS 握手过程如下图:
客户端发送一个随机值以及需要的协议和加密方式。
服务端收到客户端的随机值,自己也产生一个随机值,并根据客户端需求的协议和加密方式来使用对应的方式,并且发送自己的证书(如果需要验证客户端证书需要说明)
客户端收到服务端的证书并验证是否有效,验证通过会再生成一个随机值,通过服务端证书的公钥去加密这个随机值并发送给服务端,如果服务端需要验证客户端证书的话会附带证书
服务端收到加密过的随机值并使用私钥解密获得第三个随机值,这时候两端都拥有了三个随机值,可以通过这三个随机值按照之前约定的加密方式生成密钥,接下来的通信就可以通过该密钥来加密解密
通过以上步骤可知,在 TLS 握手阶段,两端使用非对称加密的方式来通信,但是因为非对称加密损耗的性能比对称加密大,所以在正式传输数据时,两端使用对称加密的方式通信。
PS:以上说明的都是 TLS 1.2 协议的握手情况,在 1.3 协议中,首次建立连接只需要一个 RTT,后面恢复连接不需要 RTT 了。
小结
总结一下内容:
- HTTP 经常考到的内容包括:请求方法、首部的作用以及状态码的含义
- TLS 中经常考到的内容包括:两种加密方式以及握手的流程
27、HTTP/2 及 HTTP/3
这一章节我们将来学习 HTTP/2 及 HTTP/3 的内容。
HTTP/2 很好的解决了当下最常用的 HTTP/1 所存在的一些性能问题,只需要升级到该协议就可以减少很多之前需要做的性能优化工作,当然兼容问题以及如何优雅降级应该是国内还不普遍使用的原因之一。
虽然 HTTP/2 已经解决了很多问题,但是并不代表它已经是完美的了,HTTP/3 就是为了解决 HTTP/2 所存在的一些问题而被推出来的。
HTTP/2
HTTP/2 相比于 HTTP/1,可以说是大幅度提高了网页的性能。
在 HTTP/1 中,为了性能考虑,我们会引入雪碧图、将小图内联、使用多个域名等等的方式。这一切都是因为浏览器限制了同一个域名下的请求数量(Chrome 下一般是限制六个连接),当页面中需要请求很多资源的时候,队头阻塞(Head of line blocking)会导致在达到最大请求数量时,剩余的资源需要等待其他资源请求完成后才能发起请求。
在 HTTP/2 中引入了多路复用的技术,这个技术可以只通过一个 TCP 连接就可以传输所有的请求数据。多路复用很好的解决了浏览器限制同一个域名下的请求数量的问题,同时也接更容易实现全速传输,毕竟新开一个 TCP 连接都需要慢慢提升传输速度。
大家可以通过 该链接 感受下 HTTP/2 比 HTTP/1 到底快了多少。
在 HTTP/1 中,因为队头阻塞的原因,你会发现发送请求是长这样的
在 HTTP/2 中,因为可以复用同一个 TCP 连接,你会发现发送请求是长这样的
二进制传输
HTTP/2 中所有加强性能的核心点在于此。在之前的 HTTP 版本中,我们是通过文本的方式传输数据。在 HTTP/2 中引入了新的编码机制,所有传输的数据都会被分割,并采用二进制格式编码。
多路复用
在 HTTP/2 中,有两个非常重要的概念,分别是帧(frame)和流(stream)。
帧代表着最小的数据单位,每个帧会标识出该帧属于哪个流,流也就是多个帧组成的数据流。
多路复用,就是在一个 TCP 连接中可以存在多条流。换句话说,也就是可以发送多个请求,对端可以通过帧中的标识知道属于哪个请求。通过这个技术,可以避免 HTTP 旧版本中的队头阻塞问题,极大的提高传输性能。
Header 压缩
在 HTTP/1 中,我们使用文本的形式传输 header,在 header 携带 cookie 的情况下,可能每次都需要重复传输几百到几千的字节。
在 HTTP /2 中,使用了 hpack压缩格式对传输的 header 进行编码,减少了 header 的大小。并在两端维护了索引表,用于记录出现过的 header ,后面在传输过程中就可以传输已经记录过的 header 的键名,对端收到数据后就可以通过键名找到对应的值。
服务端 Push
在 HTTP/2 中,服务端可以在客户端某个请求后,主动推送其他资源。
可以想象以下情况,某些资源客户端是一定会请求的,这时就可以采取服务端 push 的技术,提前给客户端推送必要的资源,这样就可以相对减少一点延迟时间。当然在浏览器兼容的情况下你也可以使用 prefetch 。
HTTP/3
虽然 HTTP/2 解决了很多之前旧版本的问题,但是它还是存在一个巨大的问题,虽然这个问题并不是它本身造成的,而是底层支撑的 TCP 协议的问题。
因为 HTTP/2 使用了多路复用,一般来说同一域名下只需要使用一个 TCP 连接。当这个连接中出现了丢包的情况,那就会导致 HTTP/2 的表现情况反倒不如 HTTP/1 了。
因为在出现丢包的情况下,整个 TCP 都要开始等待重传,也就导致了后面的所有数据都被阻塞了。但是对于 HTTP/1 来说,可以开启多个 TCP 连接,出现这种情况反到只会影响其中一个连接,剩余的 TCP 连接还可以正常传输数据。
那么可能就会有人考虑到去修改 TCP 协议,其实这已经是一件不可能完成的任务了。因为 TCP 存在的时间实在太长,已经充斥在各种设备中,并且这个协议是由操作系统实现的,更新起来不大现实。
基于这个原因,Google 就更起炉灶搞了一个基于 UDP 协议的 QUIC 协议,并且使用在了 HTTP/3 上,当然 HTTP/3 之前名为 HTTP-over-QUIC,从这个名字中我们也可以发现,HTTP/3 最大的改造就是使用了 QUIC,接下来我们就来学习关于这个协议的内容。
QUIC
之前我们学习过 UDP 协议的内容,知道这个协议虽然效率很高,但是并不是那么的可靠。QUIC 虽然基于 UDP,但是在原本的基础上新增了很多功能,比如多路复用、0-RTT、使用 TLS1.3 加密、流量控制、有序交付、重传等等功能。这里我们就挑选几个重要的功能学习下这个协议的内容。
多路复用
虽然 HTTP/2 支持了多路复用,但是 TCP 协议终究是没有这个功能的。QUIC 原生就实现了这个功能,并且传输的单个数据流可以保证有序交付且不会影响其他的数据流,这样的技术就解决了之前 TCP 存在的问题。
并且 QUIC 在移动端的表现也会比 TCP 好。因为 TCP 是基于 IP 和端口去识别连接的,这种方式在多变的移动端网络环境下是很脆弱的。但是 QUIC 是通过 ID 的方式去识别一个连接,不管你网络环境如何变化,只要 ID 不变,就能迅速重连上。
0-RTT
通过使用类似 TCP 快速打开的技术,缓存当前会话的上下文,在下次恢复会话的时候,只需要将之前的缓存传递给服务端验证通过就可以进行传输了。
纠错机制
假如说这次我要发送三个包,那么协议会算出这三个包的异或值并单独发出一个校验包,也就是总共发出了四个包。
当出现其中的非校验包丢包的情况时,可以通过另外三个包计算出丢失的数据包的内容。
当然这种技术只能使用在丢失一个包的情况下,如果出现丢失多个包就不能使用纠错机制了,只能使用重传的方式了。
小结
总结一下内容:
- HTTP/2 通过多路复用、二进制流、Header 压缩等等技术,极大地提高了性能,但是还是存在着问题的
- QUIC 基于 UDP 实现,是 HTTP/3 中的底层支撑协议,该协议基于 UDP,又取了 TCP 中的精华,实现了即快又可靠的协议
socket-io与http请求的区别?
Socket实现服务器与客户端之间的物理连接,并进行数据传输。主要有TCP/UDP两个协议。Socket处于网络协议的传输层。
TCP:传输控制协议,面向连接的的协议,稳定可靠。当客户和服务器彼此交换数据前,必须先在双方之间建立一个TCP连接,之后才能传输数据。
UDP:广播式数据传输,UDP不提供可靠性,它只是把应用程序传给IP层的数据报发送出去,但是并不能保证它们能到达目的地。由于UDP在传输数据报前不用在客户和服务器之间建立一个连接,且没有超时重发等机制,故而传输速度很快。
优点:
1. 传输数据为字节级,传输数据可自定义,数据量小。相应的移动端开发,手机费用低
2. 传输数据时间短,性能高
3. 适合C/S之间信息实时交互
4. 可以加密,数据安全性高
缺点:
1. 需要对传输的数据进行解析,转化为应用级的数据
2. 对开发人员的开发水平要求高
3. 相对于Http协议传输,增加了开发量
Http请求主要有http协议,基于http协议的soap协议,常见的http数据请求方式有get和post,web服务
优点:
1. 基于应用级的接口使用方便
2. 要求的开发水平不高,容错性强
缺点:
1. 传输速度慢,数据包大。
2. 如实现实时交互,服务器性能压力大
3. 数据传输安全性差
HTTP协议:简单对象访问协议,对应于应用层 ,HTTP协议是基于TCP连接的
tcp协议: 对应于传输层
ip协议: 对应于网络层
TCP/IP是传输层协议,主要解决数据如何在网络中传输;而HTTP是应用层协议,主要解决如何包装数据。
Socket是对TCP/IP协议的封装,Socket本身并不是协议,而是一个调用接口(API),通过Socket,我们才能使用TCP/IP协议。
http连接:http连接就是所谓的短连接,即客户端向服务器端发送一次请求,服务器端响应后连接即会断掉;
socket连接:socket连接就是所谓的长连接,理论上客户端和服务器端一旦建立起连接将不会主动断掉;但是由于各种环境因素可能会是连接断开,比如说:服务器端或客户端主机down了,网络故障,或者两者之间长时间没有数据传输,网络防火墙可能会断开该连接以释放网络资源。所以当一个socket连接中没有数据的传输,那么为了维持连接需要发送心跳消息~~具体心跳消息格式是开发者自己定义的
Socket适用场景:网络游戏,银行交互,支付。
http适用场景:公司OA服务,互联网服务。
28、输入 URL 到页面渲染的整个流程
之前我们学了那么多章节的内容,是时候找个时间将它们再次复习消化了。就借用这道经典面试题,将之前学习到的浏览器以及网络几章节的知识联系起来。
首先是 DNS 查询,如果这一步做了智能 DNS 解析的话,会提供访问速度最快的 IP 地址回来,这部分的内容之前没有写过,所以就在这里讲解下。
DNS
DNS 的作用就是通过域名查询到具体的 IP。
因为 IP 存在数字和英文的组合(IPv6),很不利于人类记忆,所以就出现了域名。你可以把域名看成是某个 IP 的别名,DNS 就是去查询这个别名的真正名称是什么。
在 TCP 握手之前就已经进行了 DNS 查询,这个查询是操作系统自己做的。当你在浏览器中想访问 www.google.com
时,会进行一下操作:
- 操作系统会首先在本地缓存中查询 IP
- 没有的话会去系统配置的 DNS 服务器中查询
- 如果这时候还没得话,会直接去 DNS 根服务器查询,这一步查询会找出负责
com
这个一级域名的服务器 - 然后去该服务器查询
google
这个二级域名 - 接下来三级域名的查询其实是我们配置的,你可以给
www
这个域名配置一个 IP,然后还可以给别的三级域名配置一个 IP
以上介绍的是 DNS 迭代查询,还有种是递归查询,区别就是前者是由客户端去做请求,后者是由系统配置的 DNS 服务器做请求,得到结果后将数据返回给客户端。
PS:DNS 是基于 UDP 做的查询,大家也可以考虑下为什么之前不考虑使用 TCP 去实现。
接下来是 TCP 握手,应用层会下发数据给传输层,这里 TCP 协议会指明两端的端口号,然后下发给网络层。网络层中的 IP 协议会确定 IP 地址,并且指示了数据传输中如何跳转路由器。然后包会再被封装到数据链路层的数据帧结构中,最后就是物理层面的传输了。
在这一部分中,可以详细说下 TCP 的握手情况以及 TCP 的一些特性。
当 TCP 握手结束后就会进行 TLS 握手,然后就开始正式的传输数据。
在这一部分中,可以详细说下 TLS 的握手情况以及两种加密方式的内容。
数据在进入服务端之前,可能还会先经过负责负载均衡的服务器,它的作用就是将请求合理的分发到多台服务器上,这时假设服务端会响应一个 HTML 文件。
首先浏览器会判断状态码是什么,如果是 200 那就继续解析,如果 400 或 500 的话就会报错,如果 300 的话会进行重定向,这里会有个重定向计数器,避免过多次的重定向,超过次数也会报错。
浏览器开始解析文件,如果是 gzip 格式的话会先解压一下,然后通过文件的编码格式知道该如何去解码文件。
文件解码成功后会正式开始渲染流程,先会根据 HTML 构建 DOM 树,有 CSS 的话会去构建 CSSOM 树。如果遇到 script 标签的话,会判断是否存在 async 或者 defer ,前者会并行进行下载并执行 JS,后者会先下载文件,然后等待 HTML 解析完成后顺序执行。
如果以上都没有,就会阻塞住渲染流程直到 JS 执行完毕。遇到文件下载的会去下载文件,这里如果使用 HTTP/2 协议的话会极大的提高多图的下载效率。
CSSOM 树和 DOM 树构建完成后会开始生成 Render 树,这一步就是确定页面元素的布局、样式等等诸多方面的东西
在生成 Render 树的过程中,浏览器就开始调用 GPU 绘制,合成图层,将内容显示在屏幕上了。
这一部分就是渲染原理中讲解到的内容,可以详细的说明下这一过程。并且在下载文件时,也可以说下通过 HTTP/2 协议可以解决队头阻塞的问题。
总的来说这一章节就是带着大家从 DNS 查询开始到渲染出画面完整的了解一遍过程,将之前学习到的内容连接起来。
当来这一过程远远不止这些内容,但是对于大部分人能答出这些内容已经很不错了,你如果想了解更加详细的过程,可以阅读这篇文章。
设计模式
设计模式总的来说是一个抽象的概念,前人通过无数次的实践总结出的一套写代码的方式,通过这种方式写的代码可以让别人更加容易阅读、维护以及复用。
这一章节我们将来学习几种最常用的设计模式。
设计模式 | 描述 | 例子 |
---|---|---|
单例模式 | 一个类只能构造出唯一实例 | Redux/Vuex的store |
工厂模式 | 对创建对象逻辑的封装 | jQuery的$(selector) |
观察者模式 | 当一个对象被修改时,会自动通知它的依赖对象 | Redux的subscribe、Vue的双向绑定 |
装饰器模式 | 对类的包装,动态地拓展类的功能 | React高阶组件、ES7 装饰器 |
适配器模式 | 兼容新旧接口,对类的包装 | 封装旧API |
代理模式 | 控制对象的访问 | 事件代理、ES6的Proxy |
工厂模式
工厂模式分为好几种,这里就不一一讲解了,以下是一个简单工厂模式的例子
class Man {
constructor(name) {
this.name = name
}
alertName() {
alert(this.name)
}
}
class Factory {
static create(name) {
return new Man(name)
}
}
Factory.create('yck').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 = 'yck'
}
let t = new Test()
t.yck = '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.querySelector('#ul')
ul.addEventListener('click', (event) => {
console.log(event.target);
})
</script>
在 Vue 中,如何实现响应式也是使用了该模式。对于需要实现响应式的对象来说,在 get
的时候会进行依赖收集,当改变了对象的属性时,就会触发派发更新。
外观模式
外观模式提供了一个接口,隐藏了内部的逻辑,更加方便外部调用。
举个例子来说,我们现在需要实现一个兼容多种浏览器的添加事件方法
function addEvent(elm, evType, fn, useCapture) {
if (elm.addEventListener) {
elm.addEventListener(evType, fn, useCapture)
return true
} else if (elm.attachEvent) {
var r = elm.attachEvent("on" + evType, fn)
return r
} else {
elm["on" + evType] = fn
}
}
对于不同的浏览器,添加事件的方式可能会存在兼容问题。如果每次都需要去这样写一遍的话肯定是不能接受的,所以我们将这些判断逻辑统一封装在一个接口中,外部需要添加事件只需要调用 addEvent
即可。
小结
这一章节我们学习了几种常用的设计模式。其实设计模式还有很多,有一些内容很简单,我就没有写在章节中了,比如迭代器模式、原型模式,有一些内容也是不经常使用,所以也就不一一列举了。
如果你还想了解更多关于设计模式的内容,可以阅读这本书。
29、常见数据结构
这一章节我们将来学习数据结构的内容。经常会有人提问说:学习数据结构或者算法对于前端工程师有用么?
总的来说,这些基础学科在短期内收效确实甚微,但是我们首先不要将自己局限在前端工程师这点上。笔者之前是做 iOS 开发的,转做前端以后,只有两个技能还对我有用:
- 基础学科内容,比如:网络知识、数据结构算法
- 编程思想
其他 iOS 上积累的经验,转行以后基本就没多大用处了。所以说,当我们把视野放到编程这个角度去说,数据结构算法一定是有用的,并且也是你未来的一个天花板。可以不花费集中的时间去学习这些内容,但是一定需要时常去学习一点,因为这些技能可以实实在在提升你写代码的能力。
这一章节的内容信息量会很大,不适合在非电脑环境下阅读,请各位打开代码编辑器,一行行的敲代码,单纯阅读是学习不了数据结构的。
Set和Map
数据类型:
*数组*:按顺序排列,不考虑元素,不考虑类型,下标与值对应,紧密型结构,
删除和插入都会影响整个长度,和改变数组各个元素位置
数组可以根据某个值找到相邻数据
查找数据,需要遍历每个元素,找到符合条件
字符串数组:长度限定
*对象:*
对象是松散型集合,不需要考虑相邻关系,获取某个值时,只需要知道键就可以获得值,可以快速查到需要的数据,而不是遍历
添加删除元素,它的速度也非常快,不可以通过当前获取周边的管理数据
1)Set
Set*:*是一个不能有重复元素的集合(列表),重复添加无效
删除添加查找的速度高于数组,但无法找不着关系数组
*新建set:* let a=new Set( )
*add(value):*添加元素
//不允许重复的列表,删除添加查找的速度高于数组,但是无法寻找到关系数据
let a=new Set( );
a.add(2);
a.add(3);
a.add(5);
a.add(3);
console.log(a);//Set(3) {2, 3, 5}
*delete(value):*删除元素:
let a=new Set( );
a.add(2);
a.add(5);
a.add(3);
a.delete(2);//2是上边的值value
console.log(a);//Set(2) {5, 3}
*has(value):*判断列表中是否是有该值
let a=new Set( );
a.add(2);
a.add(3);
a.add(5);
console.log(a.has(3));//true判断列表中是否含有该值
*clear( ):*清除所有数据
*数组去重:*
let arr=[1,2,3,1,2,3,1,2,3];
let sets=new Set(arr);
arr=Array.from(sets);
console.log(arr);
Array.from( )方法从一个类似数组或可迭代对象中创建
2)map
*set(key,value):*添加元素
let map=new Map( );
map.set("name","xietian");
map.set("age",30);
map.set("sex","男");
*get(key):*获取元素
*Size:*获取map的成员数
*a.size* //长度
let a=new Set([2,3,5,6,7,3,4,2,1,2,3,4,5]);
for(var i=0;i<a.size;i++){ }
console.log(a);//Set(7) {2, 3, 5, 6, 7,4,1}
For of, // 只能遍历Set和Map
let a=new Set([2,3,5,6,7,3,2,4,2,1,3,4,5]);
for(let value of a){
console.log(value);}//2,3,5,6,7,4,1
*has(value):*判断是否是成员
// Map hashMap
let map=new Map();
map.set("name","xietian");
map.set("age",30);
map.set("sex","男");
console.log(map.has("age"));//true判断是否有当前属性
*delete( )*
map.delete("age");
console.log(map)//Map(2) {"name" => "xietian", "sex" => "男"}
*clear( ):*清除所有数据;对象无法一次清空
map.clear( );
console.log(map);//Map(0) { }
const obj=new Map( );
obj.set( )
obj.clear( );
和对象类似;但对象没有长度,map有长度
*遍历:*
let map=new Map();
map.set("name","xietian");
map.set("age",30);
map.set("sex","男");
遍历对象
for(let value of map){
console.log(value);}
//(2)"name", "xietian""age", 30["sex", "男"]
遍历属性名
for(let value of map.keys( )){
console.log(value);//name age sex
}
遍历值
for(let value of map.values( )){
console.log(value);//xietian 30 男
}
返回所有成员的遍历器
For of*遍历map*
for(let value of map.entries( )){
console.log(value); }
//(2)"name", "xietian""age", 30["sex", "男"]
forEach*遍历map*
map.forEach(function(value,key,map){
console.log(value,key)});
//xietian name 30 "age" 男 sex
*箭头函数**遍历map*
map.forEach((value,key,list)=>{
console.log(value,key,list})
//xietian name 30 "age" 男 sex
//Map(3) {"name" => "xietian", "age" => 30, "sex" => "男"}
栈的特点是只能在某一端添加或删除数据,遵循先进后出
概念
栈是一个线性结构,在计算机中是一个相当常见的数据结构。
栈的特点是只能在某一端添加或删除数据,遵循先进后出的原则
实现
每种数据结构都可以用很多种方式来实现,其实可以把栈看成是数组的一个子集,所以这里使用数组来实现
class Stack {
constructor() {
this.stack = []
}
push(item) {
this.stack.push(item)
}
pop() {
this.stack.pop()
}
peek() {
return this.stack[this.getCount() - 1]
}
getCount() {
return this.stack.length
}
isEmpty() {
return this.getCount() === 0
}
}
应用
题意是匹配括号,可以通过栈的特性来完成这道题目
var isValid = function (s) {
let map = {
'(': -1,
')': 1,
'[': -2,
']': 2,
'{': -3,
'}': 3
}
let stack = []
for (let i = 0; i < s.length; i++) {
if (map[s[i]] < 0) {
stack.push(s[i])
} else {
let last = stack.pop()
if (map[last] + map[s[i]] != 0) return false
}
}
if (stack.length > 0) return false
return true
};
其实在 Vue 中关于模板解析的代码,就有应用到匹配尖括号的内容。
队列在某一端添加数据,在另一端删除数据,遵循先进先出
概念
队列是一个线性结构,特点是在某一端添加数据,在另一端删除数据,遵循先进先出的原则。
实现
这里会讲解两种实现队列的方式,分别是单链队列和循环队列。
单链队列
class Queue {
constructor() {
this.queue = []
}
enQueue(item) {
this.queue.push(item)
}
deQueue() {
return this.queue.shift()
}
getHeader() {
return this.queue[0]
}
getLength() {
return this.queue.length
}
isEmpty() {
return this.getLength() === 0
}
}
因为单链队列在出队操作的时候需要 O(n) 的时间复杂度,所以引入了循环队列。循环队列的出队操作平均是 O(1) 的时间复杂度。
循环队列
class SqQueue {
constructor(length) {
this.queue = new Array(length + 1)
// 队头
this.first = 0
// 队尾
this.last = 0
// 当前队列大小
this.size = 0
}
enQueue(item) {
// 判断队尾 + 1 是否为队头
// 如果是就代表需要扩容数组
// % this.queue.length 是为了防止数组越界
if (this.first === (this.last + 1) % this.queue.length) {
this.resize(this.getLength() * 2 + 1)
}
this.queue[this.last] = item
this.size++
this.last = (this.last + 1) % this.queue.length
}
deQueue() {
if (this.isEmpty()) {
throw Error('Queue is empty')
}
let r = this.queue[this.first]
this.queue[this.first] = null
this.first = (this.first + 1) % this.queue.length
this.size--
// 判断当前队列大小是否过小
// 为了保证不浪费空间,在队列空间等于总长度四分之一时
// 且不为 2 时缩小总长度为当前的一半
if (this.size === this.getLength() / 4 && this.getLength() / 2 !== 0) {
this.resize(this.getLength() / 2)
}
return r
}
getHeader() {
if (this.isEmpty()) {
throw Error('Queue is empty')
}
return this.queue[this.first]
}
getLength() {
return this.queue.length - 1
}
isEmpty() {
return this.first === this.last
}
resize(length) {
let q = new Array(length)
for (let i = 0; i < length; i++) {
q[i] = this.queue[(i + this.first) % this.queue.length]
}
this.queue = q
this.first = 0
this.last = this.size
}
}
链表链表是一个线性结构,同时也是一个天然的递归结构
概念
链表是一个线性结构,同时也是一个天然的递归结构。链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。
实现
单向链表
class Node {
constructor(v, next) {
this.value = v
this.next = next
}
}
class LinkList {
constructor() {
// 链表长度
this.size = 0
// 虚拟头部
this.dummyNode = new Node(null, null)
}
find(header, index, currentIndex) {
if (index === currentIndex) return header
return this.find(header.next, index, currentIndex + 1)
}
addNode(v, index) {
this.checkIndex(index)
// 当往链表末尾插入时,prev.next 为空
// 其他情况时,因为要插入节点,所以插入的节点
// 的 next 应该是 prev.next
// 然后设置 prev.next 为插入的节点
let prev = this.find(this.dummyNode, index, 0)
prev.next = new Node(v, prev.next)
this.size++
return prev.next
}
insertNode(v, index) {
return this.addNode(v, index)
}
addToFirst(v) {
return this.addNode(v, 0)
}
addToLast(v) {
return this.addNode(v, this.size)
}
removeNode(index, isLast) {
this.checkIndex(index)
index = isLast ? index - 1 : index
let prev = this.find(this.dummyNode, index, 0)
let node = prev.next
prev.next = node.next
node.next = null
this.size--
return node
}
removeFirstNode() {
return this.removeNode(0)
}
removeLastNode() {
return this.removeNode(this.size, true)
}
checkIndex(index) {
if (index < 0 || index > this.size) throw Error('Index error')
}
getNode(index) {
this.checkIndex(index)
if (this.isEmpty()) return
return this.find(this.dummyNode, index, 0).next
}
isEmpty() {
return this.size === 0
}
getSize() {
return this.size
}
}
树二叉树拥有一个根节点,每个节点至多拥有两个子节点
二叉树
树拥有很多种结构,二叉树是树中最常用的结构,同时也是一个天然的递归结构。
二叉树拥有一个根节点,每个节点至多拥有两个子节点,分别为:左节点和右节点。树的最底部节点称之为叶节点,当一颗树的叶数量数量为满时,该树可以称之为满二叉树。
二分搜索树
二分搜索树也是二叉树,拥有二叉树的特性。但是区别在于二分搜索树每个节点的值都比他的左子树的值大,比右子树的值小。
这种存储方式很适合于数据搜索。如下图所示,当需要查找 6 的时候,因为需要查找的值比根节点的值大,所以只需要在根节点的右子树上寻找,大大提高了搜索效率。
实现
class Node {
constructor(value) {
this.value = value
this.left = null
this.right = null
}
}
class BST {
constructor() {
this.root = null
this.size = 0
}
getSize() {
return this.size
}
isEmpty() {
return this.size === 0
}
addNode(v) {
this.root = this._addChild(this.root, v)
}
// 添加节点时,需要比较添加的节点值和当前
// 节点值的大小
_addChild(node, v) {
if (!node) {
this.size++
return new Node(v)
}
if (node.value > v) {
node.left = this._addChild(node.left, v)
} else if (node.value < v) {
node.right = this._addChild(node.right, v)
}
return node
}
}
以上是最基本的二分搜索树实现,接下来实现树的遍历。
对于树的遍历来说,有三种遍历方法,分别是先序遍历、中序遍历、后序遍历。三种遍历的区别在于何时访问节点。在遍历树的过程中,每个节点都会遍历三次,分别是遍历到自己,遍历左子树和遍历右子树。如果需要实现先序遍历,那么只需要第一次遍历到节点时进行操作即可。
// 先序遍历可用于打印树的结构
// 先序遍历先访问根节点,然后访问左节点,最后访问右节点。
preTraversal() {
this._pre(this.root)
}
_pre(node) {
if (node) {
console.log(node.value)
this._pre(node.left)
this._pre(node.right)
}
}
// 中序遍历可用于排序
// 对于 BST 来说,中序遍历可以实现一次遍历就
// 得到有序的值
// 中序遍历表示先访问左节点,然后访问根节点,最后访问右节点。
midTraversal() {
this._mid(this.root)
}
_mid(node) {
if (node) {
this._mid(node.left)
console.log(node.value)
this._mid(node.right)
}
}
// 后序遍历可用于先操作子节点
// 再操作父节点的场景
// 后序遍历表示先访问左节点,然后访问右节点,最后访问根节点。
backTraversal() {
this._back(this.root)
}
_back(node) {
if (node) {
this._back(node.left)
this._back(node.right)
console.log(node.value)
}
}
以上的这几种遍历都可以称之为深度遍历,对应的还有种遍历叫做广度遍历,也就是一层层地遍历树。对于广度遍历来说,我们需要利用之前讲过的队列结构来完成。
breadthTraversal() {
if (!this.root) return null
let q = new Queue()
// 将根节点入队
q.enQueue(this.root)
// 循环判断队列是否为空,为空
// 代表树遍历完毕
while (!q.isEmpty()) {
// 将队首出队,判断是否有左右子树
// 有的话,就先左后右入队
let n = q.deQueue()
console.log(n.value)
if (n.left) q.enQueue(n.left)
if (n.right) q.enQueue(n.right)
}
}
接下来先介绍如何在树中寻找最小值或最大数。因为二分搜索树的特性,所以最小值一定在根节点的最左边,最大值相反
getMin() {
return this._getMin(this.root).value
}
_getMin(node) {
if (!node.left) return node
return this._getMin(node.left)
}
getMax() {
return this._getMax(this.root).value
}
_getMax(node) {
if (!node.right) return node
return this._getMin(node.right)
}
向上取整和向下取整,这两个操作是相反的,所以代码也是类似的,这里只介绍如何向下取整。既然是向下取整,那么根据二分搜索树的特性,值一定在根节点的左侧。只需要一直遍历左子树直到当前节点的值不再大于等于需要的值,然后判断节点是否还拥有右子树。如果有的话,继续上面的递归判断。
floor(v) {
let node = this._floor(this.root, v)
return node ? node.value : null
}
_floor(node, v) {
if (!node) return null
if (node.value === v) return v
// 如果当前节点值还比需要的值大,就继续递归
if (node.value > v) {
return this._floor(node.left, v)
}
// 判断当前节点是否拥有右子树
let right = this._floor(node.right, v)
if (right) return right
return node
}
排名,这是用于获取给定值的排名或者排名第几的节点的值,这两个操作也是相反的,所以这个只介绍如何获取排名第几的节点的值。对于这个操作而言,我们需要略微的改造点代码,让每个节点拥有一个 size
属性。该属性表示该节点下有多少子节点(包含自身)。
class Node {
constructor(value) {
this.value = value
this.left = null
this.right = null
// 修改代码
this.size = 1
}
}
// 新增代码
_getSize(node) {
return node ? node.size : 0
}
_addChild(node, v) {
if (!node) {
return new Node(v)
}
if (node.value > v) {
// 修改代码
node.size++
node.left = this._addChild(node.left, v)
} else if (node.value < v) {
// 修改代码
node.size++
node.right = this._addChild(node.right, v)
}
return node
}
select(k) {
let node = this._select(this.root, k)
return node ? node.value : null
}
_select(node, k) {
if (!node) return null
// 先获取左子树下有几个节点
let size = node.left ? node.left.size : 0
// 判断 size 是否大于 k
// 如果大于 k,代表所需要的节点在左节点
if (size > k) return this._select(node.left, k)
// 如果小于 k,代表所需要的节点在右节点
// 注意这里需要重新计算 k,减去根节点除了右子树的节点数量
if (size < k) return this._select(node.right, k - size - 1)
return node
}
接下来讲解的是二分搜索树中最难实现的部分:删除节点。因为对于删除节点来说,会存在以下几种情况
- 需要删除的节点没有子树
- 需要删除的节点只有一条子树
- 需要删除的节点有左右两条树
对于前两种情况很好解决,但是第三种情况就有难度了,所以先来实现相对简单的操作:删除最小节点,对于删除最小节点来说,是不存在第三种情况的,删除最大节点操作是和删除最小节点相反的,所以这里也就不再赘述。
delectMin() {
this.root = this._delectMin(this.root)
console.log(this.root)
}
_delectMin(node) {
// 一直递归左子树
// 如果左子树为空,就判断节点是否拥有右子树
// 有右子树的话就把需要删除的节点替换为右子树
if ((node != null) & !node.left) return node.right
node.left = this._delectMin(node.left)
// 最后需要重新维护下节点的 `size`
node.size = this._getSize(node.left) + this._getSize(node.right) + 1
return node
}
最后讲解的就是如何删除任意节点了。对于这个操作,T.Hibbard 在 1962 年提出了解决这个难题的办法,也就是如何解决第三种情况。
当遇到这种情况时,需要取出当前节点的后继节点(也就是当前节点右子树的最小节点)来替换需要删除的节点。然后将需要删除节点的左子树赋值给后继结点,右子树删除后继结点后赋值给他。
你如果对于这个解决办法有疑问的话,可以这样考虑。因为二分搜索树的特性,父节点一定比所有左子节点大,比所有右子节点小。那么当需要删除父节点时,势必需要拿出一个比父节点大的节点来替换父节点。这个节点肯定不存在于左子树,必然存在于右子树。然后又需要保持父节点都是比右子节点小的,那么就可以取出右子树中最小的那个节点来替换父节点。
delect(v) {
this.root = this._delect(this.root, v)
}
_delect(node, v) {
if (!node) return null
// 寻找的节点比当前节点小,去左子树找
if (node.value < v) {
node.right = this._delect(node.right, v)
} else if (node.value > v) {
// 寻找的节点比当前节点大,去右子树找
node.left = this._delect(node.left, v)
} else {
// 进入这个条件说明已经找到节点
// 先判断节点是否拥有拥有左右子树中的一个
// 是的话,将子树返回出去,这里和 `_delectMin` 的操作一样
if (!node.left) return node.right
if (!node.right) return node.left
// 进入这里,代表节点拥有左右子树
// 先取出当前节点的后继结点,也就是取当前节点右子树的最小值
let min = this._getMin(node.right)
// 取出最小值后,删除最小值
// 然后把删除节点后的子树赋值给最小值节点
min.right = this._delectMin(node.right)
// 左子树不动
min.left = node.left
node = min
}
// 维护 size
node.size = this._getSize(node.left) + this._getSize(node.right) + 1
return node
}
AVL 树
概念
二分搜索树实际在业务中是受到限制的,因为并不是严格的 O(logN),在极端情况下会退化成链表,比如加入一组升序的数字就会造成这种情况。
AVL 树改进了二分搜索树,在 AVL 树中任意节点的左右子树的高度差都不大于 1,这样保证了时间复杂度是严格的 O(logN)。基于此,对 AVL 树增加或删除节点时可能需要旋转树来达到高度的平衡。
实现
因为 AVL 树是改进了二分搜索树,所以部分代码是于二分搜索树重复的,对于重复内容不作再次解析。
对于 AVL 树来说,添加节点会有四种情况
对于左左情况来说,新增加的节点位于节点 2 的左侧,这时树已经不平衡,需要旋转。因为搜索树的特性,节点比左节点大,比右节点小,所以旋转以后也要实现这个特性。
旋转之前:new < 2 < C < 3 < B < 5 < A,右旋之后节点 3 为根节点,这时候需要将节点 3 的右节点加到节点 5 的左边,最后还需要更新节点的高度。
对于右右情况来说,相反于左左情况,所以不再赘述。
对于左右情况来说,新增加的节点位于节点 4 的右侧。对于这种情况,需要通过两次旋转来达到目的。
首先对节点的左节点左旋,这时树满足左左的情况,再对节点进行一次右旋就可以达到目的。
class Node {
constructor(value) {
this.value = value
this.left = null
this.right = null
this.height = 1
}
}
class AVL {
constructor() {
this.root = null
}
addNode(v) {
this.root = this._addChild(this.root, v)
}
_addChild(node, v) {
if (!node) {
return new Node(v)
}
if (node.value > v) {
node.left = this._addChild(node.left, v)
} else if (node.value < v) {
node.right = this._addChild(node.right, v)
} else {
node.value = v
}
node.height =
1 + Math.max(this._getHeight(node.left), this._getHeight(node.right))
let factor = this._getBalanceFactor(node)
// 当需要右旋时,根节点的左树一定比右树高度高
if (factor > 1 && this._getBalanceFactor(node.left) >= 0) {
return this._rightRotate(node)
}
// 当需要左旋时,根节点的左树一定比右树高度矮
if (factor < -1 && this._getBalanceFactor(node.right) <= 0) {
return this._leftRotate(node)
}
// 左右情况
// 节点的左树比右树高,且节点的左树的右树比节点的左树的左树高
if (factor > 1 && this._getBalanceFactor(node.left) < 0) {
node.left = this._leftRotate(node.left)
return this._rightRotate(node)
}
// 右左情况
// 节点的左树比右树矮,且节点的右树的右树比节点的右树的左树矮
if (factor < -1 && this._getBalanceFactor(node.right) > 0) {
node.right = this._rightRotate(node.right)
return this._leftRotate(node)
}
return node
}
_getHeight(node) {
if (!node) return 0
return node.height
}
_getBalanceFactor(node) {
return this._getHeight(node.left) - this._getHeight(node.right)
}
// 节点右旋
// 5 2
// / \ / \
// 2 6 ==> 1 5
// / \ / / \
// 1 3 new 3 6
// /
// new
_rightRotate(node) {
// 旋转后新根节点
let newRoot = node.left
// 需要移动的节点
let moveNode = newRoot.right
// 节点 2 的右节点改为节点 5
newRoot.right = node
// 节点 5 左节点改为节点 3
node.left = moveNode
// 更新树的高度
node.height =
1 + Math.max(this._getHeight(node.left), this._getHeight(node.right))
newRoot.height =
1 +
Math.max(this._getHeight(newRoot.left), this._getHeight(newRoot.right))
return newRoot
}
// 节点左旋
// 4 6
// / \ / \
// 2 6 ==> 4 7
// / \ / \ \
// 5 7 2 5 new
// \
// new
_leftRotate(node) {
// 旋转后新根节点
let newRoot = node.right
// 需要移动的节点
let moveNode = newRoot.left
// 节点 6 的左节点改为节点 4
newRoot.left = node
// 节点 4 右节点改为节点 5
node.right = moveNode
// 更新树的高度
node.height =
1 + Math.max(this._getHeight(node.left), this._getHeight(node.right))
newRoot.height =
1 +
Math.max(this._getHeight(newRoot.left), this._getHeight(newRoot.right))
return newRoot
}
}
Trie
概念
在计算机科学,trie,又称前缀树或字典树,是一种有序树,用于保存关联数组,其中的键通常是字符串。
简单点来说,这个结构的作用大多是为了方便搜索字符串,该树有以下几个特点
- 根节点代表空字符串,每个节点都有 N(假如搜索英文字符,就有 26 条) 条链接,每条链接代表一个字符
- 节点不存储字符,只有路径才存储,这点和其他的树结构不同
- 从根节点开始到任意一个节点,将沿途经过的字符连接起来就是该节点对应的字符串
、
实现
总得来说 Trie 的实现相比别的树结构来说简单的很多,实现就以搜索英文字符为例。
class TrieNode {
constructor() {
// 代表每个字符经过节点的次数
this.path = 0
// 代表到该节点的字符串有几个
this.end = 0
// 链接
this.next = new Array(26).fill(null)
}
}
class Trie {
constructor() {
// 根节点,代表空字符
this.root = new TrieNode()
}
// 插入字符串
insert(str) {
if (!str) return
let node = this.root
for (let i = 0; i < str.length; i++) {
// 获得字符先对应的索引
let index = str[i].charCodeAt() - 'a'.charCodeAt()
// 如果索引对应没有值,就创建
if (!node.next[index]) {
node.next[index] = new TrieNode()
}
node.path += 1
node = node.next[index]
}
node.end += 1
}
// 搜索字符串出现的次数
search(str) {
if (!str) return
let node = this.root
for (let i = 0; i < str.length; i++) {
let index = str[i].charCodeAt() - 'a'.charCodeAt()
// 如果索引对应没有值,代表没有需要搜素的字符串
if (!node.next[index]) {
return 0
}
node = node.next[index]
}
return node.end
}
// 删除字符串
delete(str) {
if (!this.search(str)) return
let node = this.root
for (let i = 0; i < str.length; i++) {
let index = str[i].charCodeAt() - 'a'.charCodeAt()
// 如果索引对应的节点的 Path 为 0,代表经过该节点的字符串
// 已经一个,直接删除即可
if (--node.next[index].path == 0) {
node.next[index] = null
return
}
node = node.next[index]
}
node.end -= 1
}
}
并查集
概念
并查集是一种特殊的树结构,用于处理一些不交集的合并及查询问题。该结构中每个节点都有一个父节点,如果只有当前一个节点,那么该节点的父节点指向自己。
这个结构中有两个重要的操作,分别是:
- Find:确定元素属于哪一个子集。它可以被用来确定两个元素是否属于同一子集。
- Union:将两个子集合并成同一个集合。
实现
class DisjointSet {
// 初始化样本
constructor(count) {
// 初始化时,每个节点的父节点都是自己
this.parent = new Array(count)
// 用于记录树的深度,优化搜索复杂度
this.rank = new Array(count)
for (let i = 0; i < count; i++) {
this.parent[i] = i
this.rank[i] = 1
}
}
find(p) {
// 寻找当前节点的父节点是否为自己,不是的话表示还没找到
// 开始进行路径压缩优化
// 假设当前节点父节点为 A
// 将当前节点挂载到 A 节点的父节点上,达到压缩深度的目的
while (p != this.parent[p]) {
this.parent[p] = this.parent[this.parent[p]]
p = this.parent[p]
}
return p
}
isConnected(p, q) {
return this.find(p) === this.find(q)
}
// 合并
union(p, q) {
// 找到两个数字的父节点
let i = this.find(p)
let j = this.find(q)
if (i === j) return
// 判断两棵树的深度,深度小的加到深度大的树下面
// 如果两棵树深度相等,那就无所谓怎么加
if (this.rank[i] < this.rank[j]) {
this.parent[i] = j
} else if (this.rank[i] > this.rank[j]) {
this.parent[j] = i
} else {
this.parent[i] = j
this.rank[j] += 1
}
}
}
堆堆通常是一个可以被看做一棵树的数组对象。
概念
堆通常是一个可以被看做一棵树的数组对象。
堆的实现通过构造二叉堆,实为二叉树的一种。这种数据结构具有以下性质。
- 任意节点小于(或大于)它的所有子节点
- 堆总是一棵完全树。即除了最底层,其他层的节点都被元素填满,且最底层从左到右填入。
将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
优先队列也完全可以用堆来实现,操作是一模一样的。
实现大根堆
堆的每个节点的左边子节点索引是 i * 2 + 1
,右边是 i * 2 + 2
,父节点是 (i - 1) /2
。
堆有两个核心的操作,分别是 shiftUp
和 shiftDown
。前者用于添加元素,后者用于删除根节点。
shiftUp
的核心思路是一路将节点与父节点对比大小,如果比父节点大,就和父节点交换位置。
shiftDown
的核心思路是先将根节点和末尾交换位置,然后移除末尾元素。接下来循环判断父节点和两个子节点的大小,如果子节点大,就把最大的子节点和父节点交换。
class MaxHeap {
constructor() {
this.heap = []
}
size() {
return this.heap.length
}
empty() {
return this.size() == 0
}
add(item) {
this.heap.push(item)
this._shiftUp(this.size() - 1)
}
removeMax() {
this._shiftDown(0)
}
getParentIndex(k) {
return parseInt((k - 1) / 2)
}
getLeftIndex(k) {
return k * 2 + 1
}
_shiftUp(k) {
// 如果当前节点比父节点大,就交换
while (this.heap[k] > this.heap[this.getParentIndex(k)]) {
this._swap(k, this.getParentIndex(k))
// 将索引变成父节点
k = this.getParentIndex(k)
}
}
_shiftDown(k) {
// 交换首位并删除末尾
this._swap(k, this.size() - 1)
this.heap.splice(this.size() - 1, 1)
// 判断节点是否有左孩子,因为二叉堆的特性,有右必有左
while (this.getLeftIndex(k) < this.size()) {
let j = this.getLeftIndex(k)
// 判断是否有右孩子,并且右孩子是否大于左孩子
if (j + 1 < this.size() && this.heap[j + 1] > this.heap[j]) j++
// 判断父节点是否已经比子节点都大
if (this.heap[k] >= this.heap[j]) break
this._swap(k, j)
k = j
}
}
_swap(left, right) {
let rightValue = this.heap[right]
this.heap[right] = this.heap[left]
this.heap[left] = rightValue
}
}
小结
这一章节我们学习了一些常见的数据结构,当然我没有将其他更难的数据结构也放进来,能够掌握这些常见的内容已经足够解决大部分的问题了。当然你如果还想继续深入学习数据结构,可以阅读 算法第四版 以及在 leetcode 中实践。
30、常考算法题解析
这一章节依托于上一章节的内容,毕竟了解了数据结构我们才能写出更好的算法。
对于大部分公司的面试来说,排序的内容已经足以应付了,由此为了更好的符合大众需求,排序的内容是最多的。当然如果你还想冲击更好的公司,那么整一个章节的内容都是需要掌握的。对于字节跳动这类十分看重算法的公司来说,这一章节是远远不够的,剑指Offer应该是你更好的选择。
这一章节的内容信息量会很大,不适合在非电脑环境下阅读,请各位打开代码编辑器,一行行的敲代码,单纯阅读是学习不了算法的。
另外学习算法的时候,有一个可视化界面会相对减少点学习的难度,具体可以阅读 algorithm-visualizer 这个仓库。
时间复杂度
在进入正题之前,我们先来了解下什么是时间复杂度。
通常使用最差的时间复杂度来衡量一个算法的好坏。
常数时间 O(1) 代表这个操作和数据量没关系,是一个固定时间的操作,比如说四则运算。
对于一个算法来说,可能会计算出操作次数为 aN + 1,N 代表数据量。那么该算法的时间复杂度就是 O(N)。因为我们在计算时间复杂度的时候,数据量通常是非常大的,这时候低阶项和常数项可以忽略不计。
当然可能会出现两个算法都是 O(N) 的时间复杂度,那么对比两个算法的好坏就要通过对比低阶项和常数项了。
位运算
在进入正题之前,我们先来学习一下位运算的内容。因为位运算在算法中很有用,速度可以比四则运算快很多。
在学习位运算之前应该知道十进制如何转二进制,二进制如何转十进制。这里说明下简单的计算方式
- 十进制
33
可以看成是32 + 1
,并且33
应该是六位二进制的(因为33
近似32
,而32
是 2 的五次方,所以是六位),那么 十进制33
就是100001
,只要是 2 的次方,那么就是 1否则都为 0 - 那么二进制
100001
同理,首位是2^5
,末位是2^0
,相加得出 33
左移 <<
10 << 1 // -> 20
左移就是将二进制全部往左移动,10
在二进制中表示为 1010
,左移一位后变成 10100
,转换为十进制也就是 20,所以基本可以把左移看成以下公式 a * (2 ^ b)
算数右移 >>
10 >> 1 // -> 5
算数右移就是将二进制全部往右移动并去除多余的右边,10
在二进制中表示为 1010
,右移一位后变成 101
,转换为十进制也就是 5,所以基本可以把右移看成以下公式 int v = a / (2 ^ b)
右移很好用,比如可以用在二分算法中取中间值
13 >> 1 // -> 6
按位操作
按位与
每一位都为 1,结果才为 1
8 & 7 // -> 0
// 1000 & 0111 -> 0000 -> 0
按位或
其中一位为 1,结果就是 1
8 | 7 // -> 15
// 1000 | 0111 -> 1111 -> 15
按位异或
每一位都不同,结果才为 1
8 ^ 7 // -> 15
8 ^ 8 // -> 0
// 1000 ^ 0111 -> 1111 -> 15
// 1000 ^ 1000 -> 0000 -> 0
从以上代码中可以发现按位异或就是不进位加法
面试题:两个数不使用四则运算得出和
这道题中可以按位异或,因为按位异或就是不进位加法,8 ^ 8 = 0
如果进位了,就是 16 了,所以我们只需要将两个数进行异或操作,然后进位。那么也就是说两个二进制都是 1 的位置,左边应该有一个进位 1,所以可以得出以下公式 a + b = (a ^ b) + ((a & b) << 1)
,然后通过迭代的方式模拟加法
function sum(a, b) {
if (a == 0) return b
if (b == 0) return a
let newA = a ^ b
let newB = (a & b) << 1
return sum(newA, newB)
}
归并、快排、堆排有何区别
排序 | 时间复杂度(最好情况) | 时间复杂度(最坏情况) | 空间复杂度 | 稳定性 |
---|---|---|---|---|
快速排序 | O(nlogn) | O(n^2) | O(logn)~O(n) | 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(n) | 稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
其实从表格中我们可以看到,就时间复杂度而言,快排并没有很大优势,然而为什么快排会成为最常用的排序手段,这是因为时间复杂度只能说明随着数据量的增加,算法时间代价增长的趋势
,并不直接代表实际执行时间,实际运行时间还包括了很多常数参数的差别,此外在面对不同类型数据(比如有序数据、大量重复数据)时,表现也不同,综合来说,快排的时间效率是最高的
在实际运用中, 并不只使用一种排序手段, 例如V8的Array.sort()
就采取了当 n<=10 时, 采用插入排序, 当 n>10 时,采用三路快排的排序策略
排序
以下两个函数是排序中会用到的通用函数,就不一一写了
function checkArray(array) {
if (!array) return
}
function swap(array, left, right) {
let rightValue = array[right]
array[right] = array[left]
array[left] = rightValue
}
冒泡排序
冒泡排序的原理如下,从第一个元素开始,把当前元素和下一个索引元素进行比较。如果当前元素大,那么就交换位置,重复操作直到比较到最后一个元素,那么此时最后一个元素就是该数组中最大的数。下一轮重复以上操作,但是此时最后一个元素已经是最大数了,所以不需要再比较最后一个元素,只需要比较到 length - 2
的位置。
以下是实现该算法的代码
function bubble(array) {
checkArray(array);
for (let i = array.length - 1; i > 0; i--) {
// 从 0 到 `length - 1` 遍历
for (let j = 0; j < i; j++) {
if (array[j] > array[j + 1]) swap(array, j, j + 1)
}
}
return array;
}
该算法的操作次数是一个等差数列 n + (n - 1) + (n - 2) + 1
,去掉常数项以后得出时间复杂度是 O(n * n)
插入排序
插入排序的原理如下。第一个元素默认是已排序元素,取出下一个元素和当前元素比较,如果当前元素大就交换位置。那么此时第一个元素就是当前的最小数,所以下次取出操作从第三个元素开始,向前对比,重复之前的操作。
以下是实现该算法的代码
function insertion(array) {
checkArray(array);
for (let i = 1; i < array.length; i++) {
for (let j = i - 1; j >= 0 && array[j] > array[j + 1]; j--)
swap(array, j, j + 1);
}
return array;
}
该算法的操作次数是一个等差数列 n + (n - 1) + (n - 2) + 1
,去掉常数项以后得出时间复杂度是 O(n * n)
选择排序
选择排序的原理如下。遍历数组,设置最小值的索引为 0,如果取出的值比当前最小值小,就替换最小值索引,遍历完成后,将第一个元素和最小值索引上的值交换。如上操作后,第一个元素就是数组中的最小值,下次遍历就可以从索引 1 开始重复上述操作。
以下是实现该算法的代码
function selection(array) {
checkArray(array);
for (let i = 0; i < array.length - 1; i++) {
let minIndex = i;
for (let j = i + 1; j < array.length; j++) {
minIndex = array[j] < array[minIndex] ? j : minIndex;
}
swap(array, i, minIndex);
}
return array;
}
该算法的操作次数是一个等差数列 n + (n - 1) + (n - 2) + 1
,去掉常数项以后得出时间复杂度是 O(n * n)
归并排序
归并排序的原理如下。递归的将数组两两分开直到最多包含两个元素,然后将数组排序合并,最终合并为排序好的数组。假设我有一组数组 [3, 1, 2, 8, 9, 7, 6]
,中间数索引是 3,先排序数组 [3, 1, 2, 8]
。在这个左边数组上,继续拆分直到变成数组包含两个元素(如果数组长度是奇数的话,会有一个拆分数组只包含一个元素)。然后排序数组 [3, 1]
和 [2, 8]
,然后再排序数组 [1, 3, 2, 8]
,这样左边数组就排序完成,然后按照以上思路排序右边数组,最后将数组 [1, 2, 3, 8]
和 [6, 7, 9]
排序。
以下是实现该算法的代码
function sort(array) {
checkArray(array);
mergeSort(array, 0, array.length - 1);
return array;
}
function mergeSort(array, left, right) {
// 左右索引相同说明已经只有一个数
if (left === right) return;
// 等同于 `left + (right - left) / 2`
// 相比 `(left + right) / 2` 来说更加安全,不会溢出
// 使用位运算是因为位运算比四则运算快
let mid = parseInt(left + ((right - left) >> 1));
mergeSort(array, left, mid);
mergeSort(array, mid + 1, right);
let help = [];
let i = 0;
let p1 = left;
let p2 = mid + 1;
while (p1 <= mid && p2 <= right) {
help[i++] = array[p1] < array[p2] ? array[p1++] : array[p2++];
}
while (p1 <= mid) {
help[i++] = array[p1++];
}
while (p2 <= right) {
help[i++] = array[p2++];
}
for (let i = 0; i < help.length; i++) {
array[left + i] = help[i];
}
return array;
}
以上算法使用了递归的思想。递归的本质就是压栈,每递归执行一次函数,就将该函数的信息(比如参数,内部的变量,执行到的行数)压栈,直到遇到终止条件,然后出栈并继续执行函数。对于以上递归函数的调用轨迹如下
mergeSort(data, 0, 6) // mid = 3
mergeSort(data, 0, 3) // mid = 1
mergeSort(data, 0, 1) // mid = 0
mergeSort(data, 0, 0) // 遇到终止,回退到上一步
mergeSort(data, 1, 1) // 遇到终止,回退到上一步
// 排序 p1 = 0, p2 = mid + 1 = 1
// 回退到 `mergeSort(data, 0, 3)` 执行下一个递归
mergeSort(2, 3) // mid = 2
mergeSort(3, 3) // 遇到终止,回退到上一步
// 排序 p1 = 2, p2 = mid + 1 = 3
// 回退到 `mergeSort(data, 0, 3)` 执行合并逻辑
// 排序 p1 = 0, p2 = mid + 1 = 2
// 执行完毕回退
// 左边数组排序完毕,右边也是如上轨迹
该算法的操作次数是可以这样计算:递归了两次,每次数据量是数组的一半,并且最后把整个数组迭代了一次,所以得出表达式 2T(N / 2) + T(N)
(T 代表时间,N 代表数据量)。根据该表达式可以套用 该公式 得出时间复杂度为 O(N * logN)
快排
快排的原理如下。随机选取一个数组中的值作为基准值,从左至右取值与基准值对比大小。比基准值小的放数组左边,大的放右边,对比完成后将基准值和第一个比基准值大的值交换位置。然后将数组以基准值的位置分为两部分,继续递归以上操作。
以下是实现该算法的代码
function sort(array) {
checkArray(array);
quickSort(array, 0, array.length - 1);
return array;
}
function quickSort(array, left, right) {
if (left < right) {
swap(array, , right)
// 随机取值,然后和末尾交换,这样做比固定取一个位置的复杂度略低
let indexs = part(array, parseInt(Math.random() * (right - left + 1)) + left, right);
quickSort(array, left, indexs[0]);
quickSort(array, indexs[1] + 1, right);
}
}
function part(array, left, right) {
let less = left - 1;
let more = right;
while (left < more) {
if (array[left] < array[right]) {
// 当前值比基准值小,`less` 和 `left` 都加一
++less;
++left;
} else if (array[left] > array[right]) {
// 当前值比基准值大,将当前值和右边的值交换
// 并且不改变 `left`,因为当前换过来的值还没有判断过大小
swap(array, --more, left);
} else {
// 和基准值相同,只移动下标
left++;
}
}
// 将基准值和比基准值大的第一个值交换位置
// 这样数组就变成 `[比基准值小, 基准值, 比基准值大]`
swap(array, right, more);
return [less, more];
}
该算法的复杂度和归并排序是相同的,但是额外空间复杂度比归并排序少,只需 O(logN),并且相比归并排序来说,所需的常数时间也更少。
面试题
Sort Colors:该题目来自 LeetCode,题目需要我们将 [2,0,2,1,1,0]
排序成 [0,0,1,1,2,2]
,这个问题就可以使用三路快排的思想。
以下是代码实现
var sortColors = function(nums) {
let left = -1;
let right = nums.length;
let i = 0;
// 下标如果遇到 right,说明已经排序完成
while (i < right) {
if (nums[i] == 0) {
swap(nums, i++, ++left);
} else if (nums[i] == 1) {
i++;
} else {
swap(nums, i, --right);
}
}
};
Kth Largest Element in an Array:该题目来自 LeetCode,题目需要找出数组中第 K 大的元素,这问题也可以使用快排的思路。并且因为是找出第 K 大元素,所以在分离数组的过程中,可以找出需要的元素在哪边,然后只需要排序相应的一边数组就好。
以下是代码实现
var findKthLargest = function(nums, k) {
let l = 0
let r = nums.length - 1
// 得出第 K 大元素的索引位置
k = nums.length - k
while (l < r) {
// 分离数组后获得比基准树大的第一个元素索引
let index = part(nums, l, r)
// 判断该索引和 k 的大小
if (index < k) {
l = index + 1
} else if (index > k) {
r = index - 1
} else {
break
}
}
return nums[k]
};
function part(array, left, right) {
let less = left - 1;
let more = right;
while (left < more) {
if (array[left] < array[right]) {
++less;
++left;
} else if (array[left] > array[right]) {
swap(array, --more, left);
} else {
left++;
}
}
swap(array, right, more);
return more;
}
堆排序
堆排序利用了二叉堆的特性来做,二叉堆通常用数组表示,并且二叉堆是一颗完全二叉树(所有叶节点(最底层的节点)都是从左往右顺序排序,并且其他层的节点都是满的)。二叉堆又分为大根堆与小根堆。
- 大根堆是某个节点的所有子节点的值都比他小
- 小根堆是某个节点的所有子节点的值都比他大
堆排序的原理就是组成一个大根堆或者小根堆。以小根堆为例,某个节点的左边子节点索引是 i * 2 + 1
,右边是 i * 2 + 2
,父节点是 (i - 1) /2
。
- 首先遍历数组,判断该节点的父节点是否比他小,如果小就交换位置并继续判断,直到他的父节点比他大
- 重新以上操作 1,直到数组首位是最大值
- 然后将首位和末尾交换位置并将数组长度减一,表示数组末尾已是最大值,不需要再比较大小
- 对比左右节点哪个大,然后记住大的节点的索引并且和父节点对比大小,如果子节点大就交换位置
- 重复以上操作 3 - 4 直到整个数组都是大根堆。
以下是实现该算法的代码
function heap(array) {
checkArray(array);
// 将最大值交换到首位
for (let i = 0; i < array.length; i++) {
heapInsert(array, i);
}
let size = array.length;
// 交换首位和末尾
swap(array, 0, --size);
while (size > 0) {
heapify(array, 0, size);
swap(array, 0, --size);
}
return array;
}
function heapInsert(array, index) {
// 如果当前节点比父节点大,就交换
while (array[index] > array[parseInt((index - 1) / 2)]) {
swap(array, index, parseInt((index - 1) / 2));
// 将索引变成父节点
index = parseInt((index - 1) / 2);
}
}
function heapify(array, index, size) {
let left = index * 2 + 1;
while (left < size) {
// 判断左右节点大小
let largest =
left + 1 < size && array[left] < array[left + 1] ? left + 1 : left;
// 判断子节点和父节点大小
largest = array[index] < array[largest] ? largest : index;
if (largest === index) break;
swap(array, index, largest);
index = largest;
left = index * 2 + 1;
}
}
以上代码实现了小根堆,如果需要实现大根堆,只需要把节点对比反一下就好。
该算法的复杂度是 O(logN)
系统自带排序实现
每个语言的排序内部实现都是不同的。
对于 JS 来说,数组长度大于 10 会采用快排,否则使用插入排序 源码实现 。选择插入排序是因为虽然时间复杂度很差,但是在数据量很小的情况下和 O(N * logN)
相差无几,然而插入排序需要的常数时间很小,所以相对别的排序来说更快。
对于 Java 来说,还会考虑内部的元素的类型。对于存储对象的数组来说,会采用稳定性好的算法。稳定性的意思就是对于相同值来说,相对顺序不能改变。
链表
反转单向链表
该题目来自 LeetCode,题目需要将一个单向链表反转。思路很简单,使用三个变量分别表示当前节点和当前节点的前后节点,虽然这题很简单,但是却是一道面试常考题
以下是实现该算法的代码
var reverseList = function(head) {
// 判断下变量边界问题
if (!head || !head.next) return head
// 初始设置为空,因为第一个节点反转后就是尾部,尾部节点指向 null
let pre = null
let current = head
let next
// 判断当前节点是否为空
// 不为空就先获取当前节点的下一节点
// 然后把当前节点的 next 设为上一个节点
// 然后把 current 设为下一个节点,pre 设为当前节点
while(current) {
next = current.next
current.next = pre
pre = current
current = next
}
return pre
};
树
二叉树的先序,中序,后序遍历
先序遍历表示先访问根节点,然后访问左节点,最后访问右节点。
中序遍历表示先访问左节点,然后访问根节点,最后访问右节点。
后序遍历表示先访问左节点,然后访问右节点,最后访问根节点。
递归实现
递归实现相当简单,代码如下
function TreeNode(val) {
this.val = val;
this.left = this.right = null;
}
var traversal = function(root) {
if (root) {
// 先序
console.log(root);
traversal(root.left);
// 中序
// console.log(root);
traversal(root.right);
// 后序
// console.log(root);
}
};
对于递归的实现来说,只需要理解每个节点都会被访问三次就明白为什么这样实现了。
非递归实现
非递归实现使用了栈的结构,通过栈的先进后出模拟递归实现。
以下是先序遍历代码实现
function pre(root) {
if (root) {
let stack = [];
// 先将根节点 push
stack.push(root);
// 判断栈中是否为空
while (stack.length > 0) {
// 弹出栈顶元素
root = stack.pop();
console.log(root);
// 因为先序遍历是先左后右,栈是先进后出结构
// 所以先 push 右边再 push 左边
if (root.right) {
stack.push(root.right);
}
if (root.left) {
stack.push(root.left);
}
}
}
}
以下是中序遍历代码实现
function mid(root) {
if (root) {
let stack = [];
// 中序遍历是先左再根最后右
// 所以首先应该先把最左边节点遍历到底依次 push 进栈
// 当左边没有节点时,就打印栈顶元素,然后寻找右节点
// 对于最左边的叶节点来说,可以把它看成是两个 null 节点的父节点
// 左边打印不出东西就把父节点拿出来打印,然后再看右节点
while (stack.length > 0 || root) {
if (root) {
stack.push(root);
root = root.left;
} else {
root = stack.pop();
console.log(root);
root = root.right;
}
}
}
}
以下是后序遍历代码实现,该代码使用了两个栈来实现遍历,相比一个栈的遍历来说要容易理解很多
function pos(root) {
if (root) {
let stack1 = [];
let stack2 = [];
// 后序遍历是先左再右最后根
// 所以对于一个栈来说,应该先 push 根节点
// 然后 push 右节点,最后 push 左节点
stack1.push(root);
while (stack1.length > 0) {
root = stack1.pop();
stack2.push(root);
if (root.left) {
stack1.push(root.left);
}
if (root.right) {
stack1.push(root.right);
}
}
while (stack2.length > 0) {
console.log(s2.pop());
}
}
}
中序遍历的前驱后继节点
实现这个算法的前提是节点有一个 parent
的指针指向父节点,根节点指向 null
。
如图所示,该树的中序遍历结果是 4, 2, 5, 1, 6, 3, 7
前驱节点
对于节点 2
来说,他的前驱节点就是 4
,按照中序遍历原则,可以得出以下结论
- 如果选取的节点的左节点不为空,就找该左节点最右的节点。对于节点
1
来说,他有左节点2
,那么节点2
的最右节点就是5
- 如果左节点为空,且目标节点是父节点的右节点,那么前驱节点为父节点。对于节点
5
来说,没有左节点,且是节点2
的右节点,所以节点2
是前驱节点 - 如果左节点为空,且目标节点是父节点的左节点,向上寻找到第一个是父节点的右节点的节点。对于节点
6
来说,没有左节点,且是节点3
的左节点,所以向上寻找到节点1
,发现节点3
是节点1
的右节点,所以节点1
是节点6
的前驱节点
以下是算法实现
function predecessor(node) {
if (!node) return
// 结论 1
if (node.left) {
return getRight(node.left)
} else {
let parent = node.parent
// 结论 2 3 的判断
while(parent && parent.right === node) {
node = parent
parent = node.parent
}
return parent
}
}
function getRight(node) {
if (!node) return
node = node.right
while(node) node = node.right
return node
}
后继节点
对于节点 2
来说,他的后继节点就是 5
,按照中序遍历原则,可以得出以下结论
- 如果有右节点,就找到该右节点的最左节点。对于节点
1
来说,他有右节点3
,那么节点3
的最左节点就是6
- 如果没有右节点,就向上遍历直到找到一个节点是父节点的左节点。对于节点
5
来说,没有右节点,就向上寻找到节点2
,该节点是父节点1
的左节点,所以节点1
是后继节点
以下是算法实现
function successor(node) {
if (!node) return
// 结论 1
if (node.right) {
return getLeft(node.right)
} else {
// 结论 2
let parent = node.parent
// 判断 parent 为空
while(parent && parent.left === node) {
node = parent
parent = node.parent
}
return parent
}
}
function getLeft(node) {
if (!node) return
node = node.left
while(node) node = node.left
return node
}
树的深度
树的最大深度:该题目来自 Leetcode,题目需要求出一颗二叉树的最大深度
以下是算法实现
var maxDepth = function(root) {
if (!root) return 0
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1
};
对于该递归函数可以这样理解:一旦没有找到节点就会返回 0,每弹出一次递归函数就会加一,树有三层就会得到3。
动态规划
动态规划背后的基本思想非常简单。就是将一个问题拆分为子问题,一般来说这些子问题都是非常相似的,那么我们可以通过只解决一次每个子问题来达到减少计算量的目的。
一旦得出每个子问题的解,就存储该结果以便下次使用。
斐波那契数列
斐波那契数列就是从 0 和 1 开始,后面的数都是前两个数之和
0,1,1,2,3,5,8,13,21,34,55,89....
那么显然易见,我们可以通过递归的方式来完成求解斐波那契数列
function fib(n) {
if (n < 2 && n >= 0) return n
return fib(n - 1) + fib(n - 2)
}
fib(10)
以上代码已经可以完美的解决问题。但是以上解法却存在很严重的性能问题,当 n 越大的时候,需要的时间是指数增长的,这时候就可以通过动态规划来解决这个问题。
动态规划的本质其实就是两点
- 自底向上分解子问题
- 通过变量存储已经计算过的解
根据上面两点,我们的斐波那契数列的动态规划思路也就出来了
- 斐波那契数列从 0 和 1 开始,那么这就是这个子问题的最底层
- 通过数组来存储每一位所对应的斐波那契数列的值
function fib(n) {
let array = new Array(n + 1).fill(null)
array[0] = 0
array[1] = 1
for (let i = 2; i <= n; i++) {
array[i] = array[i - 1] + array[i - 2]
}
return array[n]
}
fib(10)
0 - 1背包问题
该问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。每个问题只能放入至多一次。
假设我们有以下物品
物品 ID / 重量 | 价值 |
---|---|
1 | 3 |
2 | 7 |
3 | 12 |
对于一个总容量为 5 的背包来说,我们可以放入重量 2 和 3 的物品来达到背包内的物品总价值最高。
对于这个问题来说,子问题就两个,分别是放物品和不放物品,可以通过以下表格来理解子问题
物品 ID / 剩余容量 | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
1 | 0 | 3 | 3 | 3 | 3 | 3 |
2 | 0 | 3 | 7 | 10 | 10 | 10 |
3 | 0 | 3 | 7 | 12 | 15 | 19 |
直接来分析能放三种物品的情况,也就是最后一行
- 当容量少于 3 时,只取上一行对应的数据,因为当前容量不能容纳物品 3
当容量 为 3 时,考虑两种情况,分别为放入物品 3 和不放物品 3
- 不放物品 3 的情况下,总价值为 10
- 放入物品 3 的情况下,总价值为 12,所以应该放入物品 3
当容量 为 4 时,考虑两种情况,分别为放入物品 3 和不放物品 3
- 不放物品 3 的情况下,总价值为 10
- 放入物品 3 的情况下,和放入物品 1 的价值相加,得出总价值为 15,所以应该放入物品 3
当容量 为 5 时,考虑两种情况,分别为放入物品 3 和不放物品 3
- 不放物品 3 的情况下,总价值为 10
- 放入物品 3 的情况下,和放入物品 2 的价值相加,得出总价值为 19,所以应该放入物品 3
以下代码对照上表更容易理解
/**
* @param {*} w 物品重量
* @param {*} v 物品价值
* @param {*} C 总容量
* @returns
*/
function knapsack(w, v, C) {
let length = w.length
if (length === 0) return 0
// 对照表格,生成的二维数组,第一维代表物品,第二维代表背包剩余容量
// 第二维中的元素代表背包物品总价值
let array = new Array(length).fill(new Array(C + 1).fill(null))
// 完成底部子问题的解
for (let i = 0; i <= C; i++) {
// 对照表格第一行, array[0] 代表物品 1
// i 代表剩余总容量
// 当剩余总容量大于物品 1 的重量时,记录下背包物品总价值,否则价值为 0
array[0][i] = i >= w[0] ? v[0] : 0
}
// 自底向上开始解决子问题,从物品 2 开始
for (let i = 1; i < length; i++) {
for (let j = 0; j <= C; j++) {
// 这里求解子问题,分别为不放当前物品和放当前物品
// 先求不放当前物品的背包总价值,这里的值也就是对应表格中上一行对应的值
array[i][j] = array[i - 1][j]
// 判断当前剩余容量是否可以放入当前物品
if (j >= w[i]) {
// 可以放入的话,就比大小
// 放入当前物品和不放入当前物品,哪个背包总价值大
array[i][j] = Math.max(array[i][j], v[i] + array[i - 1][j - w[i]])
}
}
}
return array[length - 1][C]
}
最长递增子序列
最长递增子序列意思是在一组数字中,找出最长一串递增的数字,比如
0, 3, 4, 17, 2, 8, 6, 10
对于以上这串数字来说,最长递增子序列就是 0, 3, 4, 8, 10,可以通过以下表格更清晰的理解
数字 | 0 | 3 | 4 | 17 | 2 | 8 | 6 | 10 |
---|---|---|---|---|---|---|---|---|
长度 | 1 | 2 | 3 | 4 | 2 | 4 | 4 | 5 |
通过以上表格可以很清晰的发现一个规律,找出刚好比当前数字小的数,并且在小的数组成的长度基础上加一。
这个问题的动态思路解法很简单,直接上代码
function lis(n) {
if (n.length === 0) return 0
// 创建一个和参数相同大小的数组,并填充值为 1
let array = new Array(n.length).fill(1)
// 从索引 1 开始遍历,因为数组已经所有都填充为 1 了
for (let i = 1; i < n.length; i++) {
// 从索引 0 遍历到 i
// 判断索引 i 上的值是否大于之前的值
for (let j = 0; j < i; j++) {
if (n[i] > n[j]) {
array[i] = Math.max(array[i], 1 + array[j])
}
}
}
let res = 1
for (let i = 0; i < array.length; i++) {
res = Math.max(res, array[i])
}
return res
}
32、小程序性能背景
京喜小程序自去年双十一上线微信购物一级入口后,时刻迎接着亿级用户量的挑战,细微的体验细节都有可能被无限放大,为此,“极致的页面性能”、“友好的产品体验” 和 “稳定的系统服务” 成为了我们开发团队的最基本执行原则。
首页作为小程序的门户,其性能表现和用户留存率息息相关。因此,我们对京喜首页进行了一次全方位的升级改造,从加载、渲染和感知体验几大维度深挖小程序的性能可塑性。
除此之外,京喜首页在微信小程序、H5、APP 三端都有落地场景,为了提高研发效率,我们使用了 Taro[1] 框架实现多端统一,因此下文中有部分内容是和 Taro 框架息息相关的。
怎么定义高性能?
提起互联网应用性能这个词,很多人在脑海中的词法解析就是,“是否足够快?”,似乎加载速度成为衡量系统性能的唯一指标。但这其实是不够准确的,试想一下,如果一个小程序加载速度非常快,用户花费很短时间就能看到页面的主体内容,但此时搜索框却无法输入内容,功能无法被流畅使用,用户可能就不会关心页面渲染有多快了。所以,我们不应该单纯考虑速度指标而忽略用户的感知体验,而应该全方位衡量用户在使用过程中能感知到的与应用加载相关的每个节点。
谷歌为 Web 应用定义了以用户为中心的性能指标体系,每个指标都与用户体验节点息息相关:
体验 | 指标 |
---|---|
页面能否正常访问? | 首次内容绘制 (First Contentful Paint, FCP) |
页面内容是否有用? | 首次有效绘制 (First Meaningful Paint, FMP) |
页面功能是否可用? | 可交互时间 (Time to Interactive, TTI) |
其中,“是否有用?” 这个问题是非常主观的,对于不同场景的系统可能会有完全不一样的回答,所以 FMP 是一个比较模糊的概念指标,不存在规范化的数值衡量。
小程序作为一个新的内容载体,衡量指标跟 Web 应用是非常类似的。对于大多数小程序而言,上述指标对应的含义为:
- FCP:白屏加载结束;
- FMP:首屏渲染完成;
- TTI:所有内容加载完成;
综上,我们已基本确定了高性能的概念指标,接下来就是如何利用数值指标来描绘性能表现。
小程序官方性能指标
小程序官方针对小程序性能表现制订了权威的数值指标,主要围绕 渲染表现、setData
数据量、元素节点数 和 网络请求延时 这几个维度来给予定义(下面只列出部分关键指标):
- 首屏时间不超过 5 秒;
- 渲染时间不超过 500ms;
- 每秒调用
setData
的次数不超过 20 次; setData
的数据在JSON.stringify
后不超过 256kb;- 页面 WXML 节点少于 1000 个,节点树深度少于 30 层,子节点数不大于 60 个;
- 所有网络请求都在 1 秒内返回结果;
详见 小程序性能评分规则[2]
我们应该把这一系列的官方指标作为小程序的性能及格线,不断地打磨和提升小程序的整体体验,降低用户流失率。另外,这些指标会直接作为小程序体验评分工具的性能评分规则(体验评分工具会根据这些规则的权重和求和公式计算出体验得分)。
我们团队内部在官方性能指标的基础上,进一步浓缩优化指标系数,旨在对产品体验更高要求:
- 首屏时间不超过 2.5 秒;
setData
的数据量不超过 100kb;- 所有网络请求都在 1 秒内返回结果;
- 组件滑动、长列表滚动无卡顿感;
体验评分工具
小程序提供了 体验评分工具(Audits
面板)[3] 来测量上述的指标数据,其集成在开发者工具中,在小程序运行时实时检查相关问题点,并为开发者给出优化建议。
体验评分面板
以上截图均来自小程序官方文档
体验评分工具是目前检测小程序性能问题最直接有效的途径,我们团队已经把体验评分作为页面/组件是否能达到精品门槛的重要考量手段之一。
小程序后台性能分析
我们知道,体验评分工具是在本地运行小程序代码时进行分析,但性能数据往往需要在真实环境和大数据量下才更有说服力。恰巧,小程序管理平台 和 小程序助手 为开发者提供了大量的真实数据统计。其中,性能分析面板从 启动性能、运行性能 和 网络性能 这三个维度分析数据,开发者可以根据客户端系统、机型、网络环境和访问来源等条件做精细化分析,非常具有考量价值。
小程序助手性能分析
其中,启动总耗时 = 小程序环境初始化 + 代码包加载 + 代码执行 + 渲染耗时
第三方测速系统
很多时候,宏观的耗时统计对于性能瓶颈点分析往往是杯水车薪,作用甚少,我们需要更细致地针对某个页面某些关键节点作测速统计,排查出暴露性能问题的代码区块,才能更有效地针对性优化。京喜小程序使用的是内部自研的测速系统,支持对地区、运营商、网络、客户端系统等多条件筛选,同时也支持数据可视化、同比分析数据等能力。京喜首页主要围绕 页面 onLoad
、onReady
、数据加载完成、首屏渲染完成、各业务组件首次渲染完成 等几个关键节点统计测速上报,旨在全链路监控性能表现。
内部测速系统
另外,微信为开发者提供了 测速系统[4],也支持针对客户端系统、网络类型、用户地区等维度统计数据,有兴趣的可以尝试。
了解小程序底层架构
为了更好地为小程序制订性能优化措施,我们有必要先了解小程序的底层架构,以及与 web 浏览器的差异性。
微信小程序是大前端跨平台技术的其中一种产物,与当下其他热门的技术 React Native、Weex、Flutter 等不同,小程序的最终渲染载体依然是浏览器内核,而不是原生客户端。
而对于传统的网页来说,UI 渲染和 JS 脚本是在同一个线程中执行,所以经常会出现 “阻塞” 行为。微信小程序基于性能的考虑,启用了双线程模型:
- 视图层:也就是 webview 线程,负责启用不同的 webview 来渲染不同的小程序页面;
- 逻辑层:一个单独的线程执行 JS 代码,可以控制视图层的逻辑;
双线程模型图
上图来自小程序官方开发指南
然而,任何线程间的数据传输都是有延时的,这意味着逻辑层和视图层间通信是异步行为。除此之外,微信为小程序提供了很多客户端原生能力,在调用客户端原生能力的过程中,微信主线程和小程序双线程之间也会发生通信,这也是一种异步行为。这种异步延时的特性会使运行环境复杂化,稍不注意,就会产出效率低下的编码。
作为小程序开发者,我们常常会被下面几个问题所困扰:
- 小程序启动慢;
- 白屏时间长;
- 页面渲染慢;
- 运行内存不足;
接下来,我们会结合小程序的底层架构分析出这些问题的根本原因,并针对性地给出解决方案。
小程序启动太慢?
小程序启动阶段,也就是如下图所示的展示加载界面的阶段。
小程序加载界面
在这个阶段中(包括启动前后的时机),微信会默默完成下面几项工作:
1. 准备运行环境:
在小程序启动前,微信会先启动双线程环境,并在线程中完成小程序基础库的初始化和预执行。
小程序基础库包括 WebView 基础库和 AppService 基础库,前者注入到视图层中,后者注入到逻辑层中,分别为所在层级提供其运行所需的基础框架能力。
2. 下载小程序代码包:
在小程序初次启动时,需要下载编译后的代码包到本地。如果启动了小程序分包,则只有主包的内容会被下载。另外,代码包会保留在缓存中,后续启动会优先读取缓存。
3. 加载小程序代码包:
小程序代码包下载好之后,会被加载到适当的线程中执行,基础库会完成所有页面的注册。
在此阶段,主包内的所有页面 JS 文件及其依赖文件都会被自动执行。
在页面注册过程中,基础库会调用页面 JS 文件的 Page 构造器方法,来记录页面的基础信息(包括初始数据、方法等)。
4. 初始化小程序首页:
在小程序代码包加载完之后,基础库会根据启动路径找到首页,根据首页的基础信息初始化一个页面实例,并把信息传递给视图层,视图层会结合 WXML 结构、WXSS 样式和初始数据来渲染界面。
综合考虑,为了节省小程序的“点点点”时间(小程序的启动动画是三个圆点循环跑马灯),除了给每位用户发一台高配 5G 手机并顺带提供千兆宽带网络之外,还可以尽量 控制代码包大小,缩小代码包的下载时间。
无用文件、函数、样式剔除
经过多次业务迭代,无可避免的会存在一些弃用的组件/页面,以及不被调用的函数、样式规则,这些冗余代码会白白占据宝贵的代码包空间。而且,目前小程序的打包会将工程下所有文件都打入代码包内,并没有做依赖分析。
因此,我们需要及时地剔除不再使用的模块,以保证代码包空间利用率保持在较高水平。通过一些工具化手段可以有效地辅助完成这一工作。
- 文件依赖分析
在小程序中,所有页面的路径都需要在小程序代码根目录 app.json
中被声明,类似地,自定义组件也需要在页面配置文件 page.json
中被声明。另外,WXML、WXSS 和 JS 的模块化都需要特定的关键字来声明依赖引用关系。
WXML 中的 import
和 include
:
<!-- A.wxml -->
<template name='A'>
<text>{{text}}</text>
</template>
<!-- B.wxml -->
<import src="A.wxml"/>
<template is="A" data="{{text: 'B'}}"/>
<!-- A.wxml -->
<text> A </text>
<!-- B.wxml -->
<include src="A.wxml"/>
<text> B </text>
WXSS 中的 @import
:
@import './A.wxss'
JS 中的 require
/import
:
const A = require('./A')
所以,可以说小程序里的所有依赖模块都是有迹可循的,我们只需要利用这些关键字信息递归查找,遍历出文件依赖树,然后把没用的模块剔除掉。
- JS、CSS Tree-Shaking
JS Tree-Shaking[5] 的原理就是借助 Babel
把代码编译成抽象语法树(AST),通过 AST 获取到函数的调用关系,从而把未被调用的函数方法剔除掉。不过这需要依赖 ES module,而小程序最开始是遵循 CommonJS 规范的,这意味着是时候来一波“痛并快乐着”的改造了。
而 CSS 的 Tree-Shaking 可以利用 PurifyCSS[6] 插件来完成。关于这两项技术,有兴趣的可以“谷歌一下”,这里就不铺开细讲了。
题外,京东的小程序团队已经把这一系列工程化能力集成在一套 CLI 工具中,有兴趣的可以看看这篇分享:小程序工程化探索。
减少代码包中的静态资源文件
小程序代码包最终会经过 GZIP 压缩放在 CDN 上,但 GZIP 压缩对于图片资源来说效果非常低。如 JPG
、PNG
等格式文件,本身已经被压缩过了,再使用 GZIP 压缩有可能体积更大,得不偿失。所以,除了部分用于容错的图片必须放在代码包(譬如网络异常提示)之外,建议开发者把图片、视频等静态资源都放在 CDN 上。
需要注意,Base64
格式本质上是长字符串,和 CDN 地址比起来也会更占空间。
逻辑后移,精简业务逻辑
这是一个 “痛并快乐着” 的优化措施。“痛” 是因为需要给后台同学提改造需求,分分钟被打;“快乐” 则是因为享受删代码的过程,而且万一出 Bug 也不用背锅了...(开个玩笑)
通过让后台承担更多的业务逻辑,可以节省小程序前端代码量,同时线上问题还支持紧急修复,不需要经历小程序的提审、发布上线等繁琐过程。
总结得出,一般不涉及前端计算的展示类逻辑,都可以适当做后移。譬如京喜首页中的幕帘弹窗(如下图)逻辑,这里共有 10+ 种弹窗类型,以前的做法是前端从接口拉取 10+ 个不同字段,根据优先级和 “是否已展示”(该状态存储在本地缓存) 来决定展示哪一种,最后代码大概是这样的:
// 检查每种弹窗类型是否已展示
Promise.all([
check(popup_1),
check(popup_2),
// ...
check(popup_n)
]).then(result => {
// 优先级排序
const queue = [{
show: result.popup_1
data: data.popup_1
}, {
show: result.popup_2
data: data.popup_2
},
// ...
{
show: result.popup_n
data: data.popup_n
}]
})
逻辑后移之后,前端只需负责拿幕帘字段做展示就可以了,代码变成这样:
this.setData({
popup: data.popup
})
首页幕帘弹窗
复用模板插件
京喜首页作为电商系统的门户,需要应对各类频繁的营销活动、升级改版等,同时也要满足不同用户属性的界面个性化需求(俗称 “千人千面”)。如何既能减少为应对多样化场景而产生的代码量,又可以提升研发效率,成为燃眉之急。
类似于组件复用的理念,我们需要提供更丰富的可配置能力,实现更高的代码复用度。参考小时候很喜欢玩的 “乐高” 积木玩具,我们把首页模块的模板元素作颗粒度更细的划分,根据样式和功能抽象出一块块“积木”原料(称为插件元素)。当首页模块在处理接口数据时,会启动插件引擎逐个装载插件,最终输出个性化的模板样式,整个流程就好比堆积木。当后续产品/运营需要新增模板时,只要在插件库中挑选插件排列组合即可,不需要额外新增/修改组件内容,也更不会产生难以维护的 if
/ else
逻辑,so easy ~
当然,要完成这样的插件化改造免不了几个先决条件:
- 用户体验设计的统一。如果设计风格总是天差地别的,强行插件化只会成为累赘。
- 服务端接口的统一。同上,如果得浪费大量的精力来兼容不同模块间的接口字段差异,将会非常蛋疼。
下面为大家提供部分例程来辅助理解。其中,use
方法会接受各类处理钩子最终拼接出一个Function
,在对应模块处理数据时会被调用。
// bi.helper.js
/**
* 插件引擎
* @param {function} options.formatName 标题处理钩子
* @param {function} options.validList 数据校验器钩子
*/
const use = options => data => format(data)
/**
* 预置插件库
*/
nameHelpers = {
text: data => data.text,
icon: data => data.icon
}
listHelpers = {
single: list => list.slice(0, 1),
double: list => list.slice(0, 2)
}
/**
* “堆积木”
*/
export default {
1000: use({
formatName: nameHelpers.text,
validList: listHelpers.single
}),
1001: use({
formatName: nameHelpers.icon,
validList: listHelpers.double
})
}
<!-- bi.wxml -->
<!-- 各模板节点实现 -->
<template name="renderName">
<view wx:if="{{type === 'text'}}"> text </view>
<view wx:elif="{{type === 'icon'}}"> icon </view>
</template>
<view class="bi__name">
<template is="renderName" data="{{...data.name}"/>
</view>//bi.jsComponent({
ready() {
// 根据 tpl 值选择解析函数
const formatData = helper[data.tpl]
this.setData({
data: formatData(data)
})
}
})
分包加载
小程序启动时只会下载主包/独立分包,启用分包可以有效减少下载时间。(独立)分包需要遵循一些原则,详细的可以看官方文档:
- 使用分包[7]
- 独立分包[8]
部分页面 h5 化
小程序提供了 web-view[9] 组件,支持在小程序环境内访问网页。当实在无法在小程序代码包中腾出多余空间时,可以考虑降级方案 —— 把部分页面 h5 化。
小程序和 h5 的通信可以通过 JSSDK 或 postMessage 通道来实现,详见 小程序开发文档[10]。
白屏时间过长?
白屏阶段,是指小程序代码包下载完(也就是启动界面结束)之后,页面完成首屏渲染的这一阶段,也就是 FMP (首次有效绘制)。
FMP 没法用标准化的指标定义,但对于大部分小程序来说,页面首屏展示的内容都需要依赖服务端的接口数据,那么影响白屏加载时间的主要由这两个元素构成:
- 网络资源加载时间;
- 渲染时间;
启用本地缓存
小程序提供了读写本地缓存的接口,数据存储在设备硬盘上。由于本地 I/O 读写(毫秒级)会比网络请求(秒级)要快很多,所以在用户访问页面时,可以优先从缓存中取上一次接口调用成功的数据来渲染视图,待网络请求成功后再覆盖最新数据重新渲染。除此之外,缓存数据还可以作为兜底数据,避免出现接口请求失败时页面空窗,一石二鸟。
但并非所有场景都适合缓存策略,譬如对数据即时性要求非常高的场景(如抢购入口)来说,展示老数据可能会引发一些问题。
小程序默认会按照 不同小程序、不同微信用户 这两个维度对缓存空间进行隔离。诸如京喜小程序首页也采用了缓存策略,会进一步按照 数据版本号、用户属性 来对缓存进行再隔离,避免信息误展示。
数据预拉取
小程序官方为开发者提供了一个在小程序冷启动时提前拉取第三方接口的能力:数据预拉取[11]。
关于冷启动和热启动的定义可以看 这里[12]
数据预拉取的原理其实很简单,就是在小程序启动时,微信服务器代理小程序客户端发起一个 HTTP 请求到第三方服务器来获取数据,并且把响应数据存储在本地客户端供小程序前端调取。当小程序加载完成后,只需调用微信提供的 API wx.getBackgroundFetchData
从本地缓存获取数据即可。这种做法可以充分利用小程序启动和初始化阶段的等待时间,使更快地完成页面渲染。
京喜小程序首页已经在生产环境实践过这个能力,从每日千万级的数据分析得出,预拉取使冷启动时获取到接口数据的时间节点从 2.5s 加速到 1s(提速了 60%)。虽然提升效果非常明显,但这个能力依然存在一些不成熟的地方:
预拉取的数据会被强缓存;
由于预拉取的请求最终是由微信的服务器发起的,也许是出于服务器资源限制的考虑,预拉取的数据会缓存在微信本地一段时间,缓存失效后才会重新发起请求。经过真机实测,在微信购物入口冷启动京喜小程序的场景下,预拉取缓存存活了 30 分钟以上,这对于数据实时性要求比较高的系统来说是非常致命的。
请求体和响应体都无法被拦截;
由于请求第三方服务器是从微信的服务器发起的,而不是从小程序客户端发起的,所以本地代理无法拦截到这一次真实请求,这会导致开发者无法通过拦截请求的方式来区分获取线上环境和开发环境的数据,给开发调试带来麻烦。
小程序内部接口的响应体类型都是
application/octet-stream
,即数据格式未知,使本地代理无法正确解析。- 微信服务器发起的请求没有提供区分线上版和开发版的参数,且没有提供用户 IP 等信息;
如果这几个问题点都不会影响到你的场景,那么可以尝试开启预拉取能力,这对于小程序首屏渲染速度是质的提升。
跳转时预拉取
为了尽快获取到服务端数据,比较常见的做法是在页面 onLoad
钩子被触发时发起网络请求,但其实这并不是最快的方式。从发起页面跳转,到下一个页面 onLoad
的过程中,小程序需要完成一些环境初始化及页面实例化的工作,耗时大概为 300 ~ 400 毫秒。
实际上,我们可以在发起跳转前(如 wx.navigateTo
调用前),提前请求下一个页面的主接口并存储在全局 Promise
对象中,待下个页面加载完成后从 Promise
对象中读取数据即可。
这也是双线程模型所带来的优势之一,不同于多页面 web 应用在页面跳转/刷新时就销毁掉 window 对象。
分包预下载
如果开启了分包加载能力,在用户访问到分包内某个页面时,小程序才会开始下载对应的分包。当处于分包下载阶段时,页面会维持在 “白屏” 的启动态,这用户体验是比较糟糕的。
幸好,小程序提供了 分包预下载[13] 能力,开发者可以配置进入某个页面时预下载可能会用到的分包,避免在页面切换时僵持在 “白屏” 态。
非关键渲染数据延迟请求
这是关键渲染路径优化的其中一个思路,从缩短网络请求时延的角度加快首屏渲染完成时间。
关键渲染路径(Critical Rendering Path)[14] 是指在完成首屏渲染的过程中必须发生的事件。
以京喜小程序如此庞大的小程序项目为例,每个模块背后都可能有着海量的后台服务作支撑,而这些后台服务间的通信和数据交互都会存在一定的时延。我们根据京喜首页的页面结构,把所有模块划分成两类:主体模块(导航、商品轮播、商品豆腐块等)和 非主体模块(幕帘弹窗、右侧挂件等)。
在初始化首页时,小程序会发起一个聚合接口请求来获取主体模块的数据,而非主体模块的数据则从另一个接口获取,通过拆分的手段来降低主接口的调用时延,同时减少响应体的数据量,缩减网络传输时间。
京喜首页浮层模块
分屏渲染
这也是关键渲染路径优化思路之一,通过延迟非关键元素的渲染时机,为关键渲染路径腾出资源。
类似上一条措施,继续以京喜小程序首页为例,我们在 主体模块 的基础上再度划分出 首屏模块(商品豆腐块以上部分) 和 非首屏模块(商品豆腐块及以下部分)。当小程序获取到主体模块的数据后,会优先渲染首屏模块,在所有首屏模块都渲染完成后才会渲染非首屏模块和非主体模块,以此确保首屏内容以最快速度呈现。
京喜首页分屏渲染
为了更好地呈现效果,上面 gif 做了降速处理
接口聚合,请求合并
在小程序中,发起网络请求是通过 wx.request[15] 这个 API。我们知道,在 web 浏览器中,针对同一域名的 HTTP 并发请求数是有限制的;在小程序中也有类似的限制,但区别在于不是针对域名限制,而是针对 API 调用:
wx.request
(HTTP 连接)的最大并发限制是 10 个;wx.connectSocket
(WebSocket 连接)的最大并发限制是 5 个;
超出并发限制数目的 HTTP 请求将会被阻塞,需要在队列中等待前面的请求完成,从而一定程度上增加了请求时延。因此,对于职责类似的网络请求,最好采用节流的方式,先在一定时间间隔内收集数据,再合并到一个请求体中发送给服务端。
图片资源优化
图片资源一直是移动端系统中抢占大流量的部分,尤其是对于电商系统。优化图片资源的加载可以有效地加快页面响应时间,提升首屏渲染速度。
- 使用 WebP 格式
WebP[16] 是 Google 推出的一种支持有损/无损压缩的图片文件格式,得益于更优的图像数据压缩算法,其与 JPG、PNG 等格式相比,在肉眼无差别的图片质量前提下具有更小的图片体积(据官方说明,WebP 无损压缩体积比 PNG 小 26%,有损压缩体积比 JPEG 小 25-34%)。
小程序的 image 组件[17] 支持 JPG、PNG、SVG、WEBP、GIF 等格式。
- 图片裁剪&降质
鉴于移动端设备的分辨率是有上限的,很多图片的尺寸常常远大于页面元素尺寸,这非常浪费网络资源(一般图片尺寸 2 倍于页面元素真实尺寸比较合适)。得益于京东内部强大的图片处理服务,我们可以通过资源的命名规则和请求参数来获取服务端优化后的图片:
裁剪成 100x100 的图片:https://{host}/s100x100_jfs/{file_path}
;
降质 70%:https://{href}!q70
;
- 图片懒加载、雪碧图(CSS Sprite)优化
这两者都是比较老生常谈的图片优化技术,这里就不打算细讲了。
小程序的 image 组件[18] 自带 lazy-load
懒加载支持。雪碧图技术(CSS Sprite)可以参考 w3schools[19] 的教程。
- 降级加载大图资源
在不得不使用大图资源的场景下,我们可以适当使用 “体验换速度” 的措施来提升渲染性能。
小程序会把已加载的静态资源缓存在本地,当短时间内再次发起请求时会直接从缓存中取资源(与浏览器行为一致)。因此,对于大图资源,我们可以先呈现高度压缩的模糊图片,同时利用一个隐藏的 `` 节点来加载原图,待原图加载完成后再转移到真实节点上渲染。整个流程,从视觉上会感知到图片从模糊到高清的过程,但与对首屏渲染的提升效果相比,这点体验落差是可以接受的。
下面为大家提供部分例程
<!-- banner.wxml -->
<image src="{{url}}" />
<!-- 图片加载器 -->
<image
style="width:0;height:0;display:none"
src="{{preloadUrl}}"
bindload="onImgLoad"
binderror="onErrorLoad"
/>// banner.js
Component({
ready() {
this.originUrl = 'https://path/to/picture' // 图片源地址
this.setData({
url: compress(this.originUrl) // 加载压缩降质的图片
preloadUrl: this.originUrl // 预加载原图
})
},
methods: {
onImgLoad() {
this.setData({
url: this.originUrl // 加载原图
})
}
}
})
注意,具有 display: none
样式的 `` 标签只会加载图片资源,但不渲染。
京喜首页的商品轮播模块也采用了这种降级加载方案,在首屏渲染时只会加载第一帧降质图片。以每帧原图 20~50kb 的大小计算,这一措施可以在初始化阶段节省掉几百 kb 的网络资源请求。
Banner 大图降级加载
为了更好地呈现效果,上面 gif 做了降速处理
骨架屏
一方面,我们可以从降低网络请求时延、减少关键渲染的节点数这两个角度出发,缩短完成 FMP(首次有效绘制)的时间。另一方面,我们也需要从用户感知的角度优化加载体验。
“白屏” 的加载体验对于首次访问的用户来说是难以接受的,我们可以使用尺寸稳定的骨架屏,来辅助实现真实模块占位和瞬间加载。
骨架屏目前在业界被广泛应用,京喜首页选择使用灰色豆腐块作为骨架屏的主元素,大致勾勒出各模块主体内容的样式布局。由于微信小程序不支持 SSR(服务端渲染),使动态渲染骨架屏的方案难以实现,因此京喜首页的骨架屏是通过 WXSS 样式静态渲染的。
有趣的是,京喜首页的骨架屏方案经历了 “统一管理” 和 “(组件)独立管理” 两个阶段。出于避免对组件的侵入性考虑,最初的骨架屏是由一个完整的骨架屏组件统一管理的:
<!-- index.wxml -->
<skeleton wx:if="{{isLoading}}"></skeleton>
<block wx:else>
页面主体
</block>
但这种做法的维护成本比较高,每次页面主体模块更新迭代,都需要在骨架屏组件中的对应节点同步更新(譬如某个模块的尺寸被调整)。除此之外,感官上从骨架屏到真实模块的切换是跳跃式的,这是因为骨架屏组件和页面主体节点之间的关系是整体条件互斥的,只有当页面主体数据 Ready(或渲染完毕)时才会把骨架屏组件销毁,渲染(或展示)主体内容。
为了使用户感知体验更加丝滑,我们把骨架屏元素拆分放到各个业务组件中,骨架屏元素的显示/隐藏逻辑由业务组件内部独立管理,这就可以轻松实现 “谁跑得快,谁先出来” 的并行加载效果。除此之外,骨架屏元素与业务组件共用一套 WXML 节点,且相关样式由公共的 sass
模块集中管理,业务组件只需要在适当的节点挂上 skeleton
和 skeleton__block
样式块即可,极大地降低了维护成本。
<!-- banner.wxml -->
<view class="{{isLoading ? 'banner--skeleton' : ''}}">
<view class="banner_wrapper"></view>
</view>
// banner.scss
.banner--skeleton {
@include skeleton;
.banner_wrapper {
@include skeleton__block;
}
}
京喜首页骨架屏
上面的 gif 在压缩过程有些小问题,大家可以直接访问【京喜】小程序体验骨架屏效果。
如何提升渲染性能?—
当调用 wx.navigateTo
打开一个新的小程序页面时,小程序框架会完成这几步工作:
1. 准备新的 webview 线程环境,包括基础库的初始化;
2. 从逻辑层到视图层的初始数据通信;
3. 视图层根据逻辑层的数据,结合 WXML 片段构建出节点树(包括节点属性、事件绑定等信息),最终与 WXSS 结合完成页面渲染;
由于微信会提前开始准备 webview 线程环境,所以小程序的渲染损耗主要在后两者 数据通信和 节点树创建/更新 的流程中。相对应的,比较有效的渲染性能优化方向就是:
- 降低线程间通信频次;
- 减少线程间通信的数据量;
- 减少 WXML 节点数量;
合并 setData
调用
尽可能地把多次 setData
调用合并成一次。
我们除了要从编码规范上践行这个原则,还可以通过一些技术手段降低 setData
的调用频次。譬如,把同一个时间片(事件循环[20])内的 setData
调用合并在一起,Taro 框架就使用了这个优化手段。
在 Taro 框架下,调用 setState
时提供的对象会被加入到一个数组中,当下一次事件循环执行的时候再把这些对象合并一起,通过 setData
传递给原生小程序。
// 小程序里的时间片 API
const nextTick = wx.nextTick ? wx.nextTick : setTimeout;
只把与界面渲染相关的数据放在 data
中
不难得出,setData
传输的数据量越多,线程间通信的耗时越长,渲染速度就越慢。根据微信官方测得的数据,传输时间和数据量大体上呈正相关关系:
数据传输时间与数据量关系图
上图来自小程序官方开发指南
所以,与视图层渲染无关的数据尽量不要放在 data
中,可以放在页面(组件)类的其他字段下。
应用层的数据 diff
每当调用 setData
更新数据时,会引起视图层的重新渲染,小程序会结合新的 data
数据和 WXML 片段构建出新的节点树,并与当前节点树进行比较得出最终需要更新的节点(属性)。
即使小程序在底层框架层面已经对节点树更新进行了 diff,但我们依旧可以优化这次 diff 的性能。譬如,在调用 setData
时,提前确保传递的所有新数据都是有变化的,也就是针对 data 提前做一次 diff。
Taro 框架内部做了这一层优化。在每次调用原生小程序的 setData
之前,Taro 会把最新的 state 和当前页面实例的 data 做一次 diff,筛选出有必要更新的数据再执行 setData
。
附 Taro 框架的 数据 diff 规则[21]
去掉不必要的事件绑定
当用户事件(如 Click
、Touch
事件等)被触发时,视图层会把事件信息反馈给逻辑层,这也是一个线程间通信的过程。但,如果没有在逻辑层中绑定事件的回调函数,通信将不会被触发。
所以,尽量减少不必要的事件绑定,尤其是像 onPageScroll
这种会被频繁触发的用户事件,会使通信过程频繁发生。
去掉不必要的节点属性
组件节点支持附加自定义数据 dataset
(见下面例子),当用户事件被触发时,视图层会把事件 target
和 dataset
数据传输给逻辑层。那么,当自定义数据量越大,事件通信的耗时就会越长,所以应该避免在自定义数据中设置太多数据。
<!-- wxml -->
<view
data-a='A'
data-b='B'
bindtap='bindViewTap'
>
Click Me!
</view>
// js
Page({
bindViewTap(e) {
console.log(e.currentTarget.dataset)
}
})
适当的组件颗粒度
小程序的组件模型与 Web Components[22] 标准中的 ShadowDOM 非常类似,每个组件都有独立的节点树,拥有各自独立的逻辑空间(包括独立的数据、setData
调用、createSelectorQuery
执行域等)。
不难得出,如果自定义组件的颗粒度太粗,组件逻辑过重,会影响节点树构建和新/旧节点树 diff 的效率,从而影响到组件内 setData
的性能。另外,如果组件内使用了 createSelectorQuery
来查找节点,过于庞大的节点树结构也会影响查找效率。
我们来看一个场景,京喜首页的 “京东秒杀” 模块涉及到一个倒计时特性,是通过setInterval
每秒调用 setData
来更新表盘时间。我们通过把倒计时抽离出一个基础组件,可以有效降低频繁 setData
时的性能影响。
京东秒杀
适当的组件化,既可以减小数据更新时的影响范围,又能支持复用,何乐而不为?诚然,并非组件颗粒度越细越好,组件数量和小程序代码包大小是正相关的。尤其是对于使用编译型框架(如 Taro)的项目,每个组件编译后都会产生额外的运行时代码和环境 polyfill,so,为了代码包空间,请保持理智...
事件总线,替代组件间数据绑定的通信方式
WXML 数据绑定是小程序中父组件向子组件传递动态数据的较为常见的方式,如下面例程所示:Component A
组件中的变量 a
、b
通过组件属性传递给 Component B
组件。在此过程中,不可避免地需要经历一次 Component A
组件的 setData
调用方可完成任务,这就会产生线程间的通信。“合情合理”,但,如果传递给子组件的数据只有一部分是与视图渲染有关呢?
<!-- Component A -->
<component-b prop-a="{{a}}" prop-b="{{b}}" />
// Component B
Component({
properties: {
propA: String,
propB: String,
},
methods: {
onLoad: function() {
this.data.propA
this.data.propB
}
}
})
推荐一种特定场景下非常便捷的做法:通过事件总线(EventBus),也就是发布/订阅模式,来完成由父向子的数据传递。其构成非常简单(例程只提供关键代码...):
一个全局的事件调度中心
class EventBus { constructor() { this.events = {} } on(key, cb) { this.events[key].push(cb) } trigger(key, args) { this.events[key].forEach(function (cb) { cb.call(this, ...args) }) } remove() {} } const event = new EventBus()
事件订阅者
// 子组件 Component({ created() { event.on('data-ready', (data) => { this.setData({ data }) }) } })
事件发布者
// Parent Component({ ready() { event.trigger('data-ready', data) } })
子组件被创建时事先监听数据下发事件,当父组件获取到数据后触发事件把数据传递给子组件,这整个过程都是在小程序的逻辑层里同步执行,比数据绑定的方式速度更快。
但并非所有场景都适合这种做法。像京喜首页这种具有 “数据单向传递”、“展示型交互” 特性、且 一级子组件数量庞大 的场景,使用事件总线的效益将会非常高;但若是频繁 “双向数据流“ 的场景,用这种方式会导致事件交错难以维护。
题外话,Taro 框架在处理父子组件间数据传递时使用的是观察者模式,通过 Object.defineProperty
绑定父子组件关系,当父组件数据发生变化时,会递归通知所有后代组件检查并更新数据。这个通知的过程会同步触发数据 diff 和一些校验逻辑,每个组件跑一遍大概需要 5 ~ 10 ms 的时间。所以,如果组件量级比较大,整个流程下来时间损耗还是不小的,我们依旧可以尝试事件总线的方案。
组件层面的 diff
我们可能会遇到这样的需求,多个组件之间位置不固定,支持随时随地灵活配置,京喜首页也存在类似的诉求。
京喜首页主体可被划分为若干个业务组件(如搜索框、导航栏、商品轮播等),这些业务组件的顺序是不固定的,今天是搜索框在最顶部,明天有可能变成导航栏在顶部了(夸张了...)。我们不可能针对多种顺序可能性提供多套实现,这就需要用到小程序的自定义模板`。
实现一个支持调度所有业务组件的模板,根据后台下发的模块数组按序循环渲染模板,如下面例程所示。
<!-- index.wxml -->
<template name="render-component">
<search-bar wx:if="{{compId === 'SearchBar'}}" floor-id="{{index}}" />
<nav-bar wx:if="{{compId === 'NavBar'}}" floor-id="{{index}}" />
<banner wx:if="{{compId === 'Banner'}}" floor-id="{{index}}" />
<icon-nav wx:if="{{compId === 'IconNav'}}" floor-id="{{index}}" />
</template>
<view
class="component-wrapper"
wx:for="{{comps}}"
wx:for-item="comp"
>
<template is="render-component" data="{{...comp}}"/>
</view>
// search-bar.js
Component({
properties: {
floorId: Number,
},
created() {
event.on('data-ready', (comps) => {
const data = comps[this.data.floorId] // 根据楼层位置取数据
})
}
})
貌似非常轻松地完成需求,但值得思考的是:如果组件顺序调整了,所有组件的生命周期会发生什么变化?
假设,上一次渲染的组件顺序是 ['search-bar','nav-bar','banner', 'icon-nav']
,现在需要把 nav-bar
组件去掉,调整为 ['search-bar','banner', 'icon-nav']
。经实验得出,当某个组件节点发生变化时,其前面的组件不受影响,其后面的组件都会被销毁重新挂载。
原理很简单,每个组件都有各自隔离的节点树(ShadowTree
),页面 body 也是一个节点树。在调整组件顺序时,小程序框架会遍历比较新/旧节点树的差异,于是发现新节点树的 nav-bar
组件节点不见了,就认为该(树)分支下从 nav-bar
节点起发生了变化,往后节点都需要重渲染。
但实际上,这里的组件顺序是没有变化的,丢失的组件按道理不应该影响到其他组件的正常渲染。所以,我们在 setData
前先进行了新旧组件列表 diff:如果 newList
里面的组件是 oldList
的子集,且相对顺序没有发生变化,则所有组件不重新挂载。除此之外,我们还要在接口数据的相应位置填充上空数据,把该组件隐藏掉,done。
通过组件 diff 的手段,可以有效降低视图层的渲染压力,如果有类似场景的朋友,也可以参考这种方案。
内存占用过高?—
想必没有什么会比小程序 Crash 更影响用户体验了。
当小程序占用系统资源过高,就有可能会被系统销毁或被微信客户端主动回收。应对这种尴尬场景,除了提示用户提升硬件性能之外(譬如来京东商城买新手机),还可以通过一系列的优化手段降低小程序的内存损耗。
内存不足弹窗提示
内存预警
小程序提供了监听内存不足告警事件的 API:wx.onMemoryWarning[23],旨在让开发者收到告警时及时释放内存资源避免小程序 Crash。然而对于小程序开发者来说,内存资源目前是无法直接触碰的,最多就是调用 wx.reLaunch
清理所有页面栈,重载当前页面,来降低内存负荷(此方案过于粗暴,别冲动,想想就好...)。
不过内存告警的信息收集倒是有意义的,我们可以把内存告警信息(包括页面路径、客户端版本、终端手机型号等)上报到日志系统,分析出哪些页面 Crash 率比较高,从而针对性地做优化,降低页面复杂度等等。
回收后台页面计时器
根据双线程模型,小程序每一个页面都会独立一个 webview 线程,但逻辑层是单线程的,也就是所有的 webview 线程共享一个 JS 线程。以至于当页面切换到后台态时,仍然有可能抢占到逻辑层的资源,譬如没有销毁的 setInterval
、setTimeout
定时器:
// Page A
Page({
onLoad() {
let i = 0
setInterval(() => { i++ }, 100)
}
})
即使如小程序的 `` 组件,在页面进入后台态时依然是会持续轮播的。
正确的做法是,在页面 onHide
的时候手动把定时器清理掉,有必要时再在 onShow
阶段恢复定时器。坦白讲,区区一个定时器回调函数的执行,对于系统的影响应该是微不足道的,但不容忽视的是回调函数里的代码逻辑,譬如在定时器回调里持续 setData
大量数据,这就非常难受了...
避免频发事件中的重度内存操作
我们经常会遇到这样的需求:广告曝光、图片懒加载、导航栏吸顶等等,这些都需要我们在页面滚动事件触发时实时监听元素位置或更新视图。在了解小程序的双线程模型之后不难发现,页面滚动时 onPageScroll
被频发触发,会使逻辑层和视图层发生持续通信,若这时候再 “火上浇油” 调用 setData
传输大量数据,会导致内存使用率快速上升,使页面卡顿甚至 “假死”。所以,针对频发事件的监听,我们最好遵循以下原则:
onPageScroll
事件回调使用节流;- 避免 CPU 密集型操作,譬如复杂的计算;
- 避免调用
setData
,或减小setData
的数据量; - 尽量使用 *IntersectionObserver*[24] 来替代 *SelectorQuery*[25],前者对性能影响更小;
大图、长列表优化
据 小程序官方文档[26] 描述,大图片和长列表图片在 iOS 中会引起 WKWebView 的回收,导致小程序 Crash。
对于大图片资源(譬如满屏的 gif 图)来说,我们只能尽可能对图片进行降质或裁剪,当然不使用是最好的。
对于长列表,譬如瀑布流,这里提供一种思路:我们可以利用 IntersectionObserver[27] 监听长列表内组件与视窗之间的相交状态,当组件距离视窗大于某个临界点时,销毁该组件释放内存空间,并用等尺寸的骨架图占坑;当距离小于临界点时,再取缓存数据重新加载该组件。
然而无可避免地,当用户快速滚动长列表时,被销毁的组件可能来不及加载完,视觉上就会出现短暂的白屏。我们可以适当地调整销毁阈值,或者优化骨架图的样式来尽可能提升体验感。
小程序官方提供了一个 长列表组件[28],可以通过 npm
包的方式引入,有兴趣的可以尝试。
总结—
结合上述的种种方法论,京喜小程序首页进行全方位升级改造之后给出了答卷:
1. Audits 审计工具的性能得分 86
;
2. 优化后的首屏渲染完成时间(FMP):
优化后的首屏渲染时间
3. 优化前后的测速数据对比:
优化前后的测速数据对比
然而,业务迭代在持续推进,多样化的用户场景徒增不减,性能优化将成为我们日常开发中挥之不去的原则和主题。本文以微信小程序开发中与性能相关的问题为出发点,基于小程序的底层框架原理,探究小程序性能体验提升的各种可能性,希望能为各位小程序开发者带来参考价值。
判断一个字符串中出现次数最多的字符,统计这个次数。
B:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<script>
var str = "abcdefgaddda";
var obj = {};
// 每个字符出现次数
for (let i = 0; i < str.length; i++) {
var key = str[i];
typeof obj[key] === 'undefined' ? obj[key] = 1 : obj[key]++
}
var max = -1;
var max_key = key;
// 排序
for (let key in obj) {
if (max < obj[key]) {
max = obj[key];
max_key = key;
}
}
document.write("字符:" + max_key + ",出现次数最多为:" + max + "次")
</script>
</body>
</html>
复制代码
33、UDP
网络协议是每个前端工程师都必须要掌握的知识,我们将先来学习传输层中的两个协议:UDP 以及 TCP。对于大部分工程师来说最常用的协议也就是这两个了,并且面试中经常会提问的也是关于这两个协议的区别。
我们先来解答这个常考面试题关于 UDP 部分的内容,然后再详细去学习这个协议。
常考面试题:UDP 与 TCP 的区别是什么?
首先 UDP 协议是面向无连接的,也就是说不需要在正式传递数据之前先连接起双方。然后 UDP 协议只是数据报文的搬运工,不保证有序且不丢失的传递到对端,并且UDP 协议也没有任何控制流量的算法,总的来说 UDP 相较于 TCP 更加的轻便。
面向无连接
首先 UDP 是不需要和 TCP 一样在发送数据前进行三次握手建立连接的,想发数据就可以开始发送了。
并且也只是数据报文的搬运工,不会对数据报文进行任何拆分和拼接操作。
具体来说就是:
- 在发送端,应用层将数据传递给传输层的 UDP 协议,UDP 只会给数据增加一个 UDP 头标识下是 UDP 协议,然后就传递给网络层了
- 在接收端,网络层将数据传递给传输层,UDP 只去除 IP 报文头就传递给应用层,不会任何拼接操作
不可靠性
首先不可靠性体现在无连接上,通信都不需要建立连接,想发就发,这样的情况肯定不可靠。
并且收到什么数据就传递什么数据,并且也不会备份数据,发送数据也不会关心对方是否已经正确接收到数据了。
再者网络环境时好时坏,但是 UDP 因为没有拥塞控制,一直会以恒定的速度发送数据。即使网络条件不好,也不会对发送速率进行调整。这样实现的弊端就是在网络条件不好的情况下可能会导致丢包,但是优点也很明显,在某些实时性要求高的场景(比如电话会议)就需要使用 UDP 而不是 TCP。
高效
虽然 UDP 协议不是那么的可靠,但是正是因为它不是那么的可靠,所以也就没有 TCP 那么复杂了,需要保证数据不丢失且有序到达。
因此 UDP 的头部开销小,只有八字节,相比 TCP 的至少二十字节要少得多,在传输数据报文时是很高效的。
UDP 头部包含了以下几个数据
- 两个十六位的端口号,分别为源端口(可选字段)和目标端口
- 整个数据报文的长度
- 整个数据报文的检验和(IPv4 可选 字段),该字段用于发现头部信息和数据中的错误
传输方式
UDP 不止支持一对一的传输方式,同样支持一对多,多对多,多对一的方式,也就是说 UDP 提供了单播,多播,广播的功能。
适合使用的场景
UDP 虽然对比 TCP 有很多缺点,但是正是因为这些缺点造就了它高效的特性,在很多实时性要求高的地方都可以看到 UDP 的身影。
直播
想必大家都看过直播吧,大家可以考虑下如果直播使用了基于 TCP 的协议会发生什么事情?
TCP 会严格控制传输的正确性,一旦有某一个数据对端没有收到,就会停止下来直到对端收到这个数据。这种问题在网络条件不错的情况下可能并不会发生什么事情,但是在网络情况差的时候就会变成画面卡住,然后再继续播放下一帧的情况。
但是对于直播来说,用户肯定关注的是最新的画面,而不是因为网络条件差而丢失的老旧画面,所以 TCP 在这种情况下无用武之地,只会降低用户体验。
小结
这一章节的内容就到这里,因为 UDP 协议相对简单,所以内容并不是很多,但是下一章节会呈现很多关于 TCP 相关的内容,请大家做好准备。
最后总结一下这一章节的内容:
- UDP 相比 TCP 简单的多,不需要建立连接,不需要验证数据报文,不需要流量控制,只会把想发的数据报文一股脑的丢给对端
- 虽然 UDP 并没有 TCP 传输来的准确,但是也能在很多实时性要求高的地方有所作为
34、TCP
首先还是先来解答这个常考面试题关于 TCP 部分的内容,然后再详细去学习这个协议。
常考面试题:UDP 与 TCP 的区别是什么?
TCP 基本是和 UDP 反着来,建立连接断开连接都需要先需要进行握手。在传输数据的过程中,通过各种算法保证数据的可靠性,当然带来的问题就是相比 UDP 来说不那么的高效。
头部
从这个图上我们就可以发现 TCP 头部比 UDP 头部复杂的多。
对于 TCP 头部来说,以下几个字段是很重要的
- Sequence number,这个序号保证了 TCP 传输的报文都是有序的,对端可以通过序号顺序的拼接报文
- Acknowledgement Number,这个序号表示数据接收端期望接收的下一个字节的编号是多少,同时也表示上一个序号的数据已经收到
- Window Size,窗口大小,表示还能接收多少字节的数据,用于流量控制
标识符
- URG=1:该字段为一表示本数据报的数据部分包含紧急信息,是一个高优先级数据报文,此时紧急指针有效。紧急数据一定位于当前数据包数据部分的最前面,紧急指针标明了紧急数据的尾部。
- ACK=1:该字段为一表示确认号字段有效。此外,TCP 还规定在连接建立后传送的所有报文段都必须把 ACK 置为一。
- PSH=1:该字段为一表示接收端应该立即将数据 push 给应用层,而不是等到缓冲区满后再提交。
- RST=1:该字段为一表示当前 TCP 连接出现严重问题,可能需要重新建立 TCP 连接,也可以用于拒绝非法的报文段和拒绝连接请求。
- SYN=1:当SYN=1,ACK=0时,表示当前报文段是一个连接请求报文。当SYN=1,ACK=1时,表示当前报文段是一个同意建立连接的应答报文。
- FIN=1:该字段为一表示此报文段是一个释放连接的请求报文。
状态机
TCP 的状态机是很复杂的,并且与建立断开连接时的握手息息相关,接下来就来详细描述下两种握手。
在这之前需要了解一个重要的性能指标 RTT。该指标表示发送端发送数据到接收到对端数据所需的往返时间。
三次握手四次挥手
第一次握手
客户端向服务端发送连接请求报文段。该报文段中包含自身的数据通讯初始序号。请求发送后,客户端便进入 SYN-SENT 状态。
第二次握手
服务端收到连接请求报文段后,如果同意连接,则会发送一个应答,该应答中也会包含自身的数据通讯初始序号,发送完成后便进入 SYN-RECEIVED 状态。
第三次握手
当客户端收到连接同意的应答后,还要向服务端发送一个确认报文。客户端发完这个报文段后便进入 ESTABLISHED 状态,服务端收到这个应答后也进入 ESTABLISHED 状态,此时连接建立成功。
PS:第三次握手中可以包含数据,通过快速打开(TFO)技术就可以实现这一功能。其实只要涉及到握手的协议,都可以使用类似 TFO 的方式,客户端和服务端存储相同的 cookie,下次握手时发出 cookie 达到减少 RTT 的目的。
第一次握手
若客户端 A 认为数据发送完成,则它需要向服务端 B 发送连接释放请求。
第二次握手
B 收到连接释放请求后,会告诉应用层要释放 TCP 链接。然后会发送 ACK 包,并进入 CLOSE_WAIT 状态,此时表明 A 到 B 的连接已经释放,不再接收 A 发的数据了。但是因为 TCP 连接是双向的,所以 B 仍旧可以发送数据给 A。
第三次握手
B 如果此时还有没发完的数据会继续发送,完毕后会向 A 发送连接释放请求,然后 B 便进入 LAST-ACK 状态。
PS:通过延迟确认的技术(通常有时间限制,否则对方会误认为需要重传),可以将第二次和第三次握手合并,延迟 ACK 包的发送。
第四次握手
A 收到释放请求后,向 B 发送确认应答,此时 A 进入 TIME-WAIT 状态。该状态会持续 2MSL(最大段生存期,指报文段在网络中生存的时间,超时会被抛弃) 时间,若该时间段内没有 B 的重发请求的话,就进入 CLOSED 状态。当 B 收到确认应答后,也便进入 CLOSED 状态。
ARQ 协议
ARQ 协议也就是超时重传机制。通过确认和超时机制保证了数据的正确送达,ARQ 协议包含停止等待 ARQ 和连续 ARQ 两种协议。
停止等待 ARQ
正常传输过程
只要 A 向 B 发送一段报文,都要停止发送并启动一个定时器,等待对端回应,在定时器时间内接收到对端应答就取消定时器并发送下一段报文。
报文丢失或出错
在报文传输的过程中可能会出现丢包。这时候超过定时器设定的时间就会再次发送丢失的数据直到对端响应,所以需要每次都备份发送的数据。
即使报文正常的传输到对端,也可能出现在传输过程中报文出错的问题。这时候对端会抛弃该报文并等待 A 端重传。
PS:一般定时器设定的时间都会大于一个 RTT 的平均时间。
ACK 超时或丢失
对端传输的应答也可能出现丢失或超时的情况。那么超过定时器时间 A 端照样会重传报文。这时候 B 端收到相同序号的报文会丢弃该报文并重传应答,直到 A 端发送下一个序号的报文。
在超时的情况下也可能出现应答很迟到达,这时 A 端会判断该序号是否已经接收过,如果接收过只需要丢弃应答即可。
从上面的描述中大家肯定可以发现这肯定不是一个高效的方式。假设在良好的网络环境中,每次发送数据都需要等待片刻肯定是不能接受的。那么既然我们不能接受这个不那么高效的协议,就来继续学习相对高效的协议吧。
连续 ARQ
在连续 ARQ 中,发送端拥有一个发送窗口,可以在没有收到应答的情况下持续发送窗口内的数据,这样相比停止等待 ARQ 协议来说减少了等待时间,提高了效率。
累计确认
连续 ARQ 中,接收端会持续不断收到报文。如果和停止等待 ARQ 中接收一个报文就发送一个应答一样,就太浪费资源了。通过累计确认,可以在收到多个报文以后统一回复一个应答报文。报文中的 ACK 标志位可以用来告诉发送端这个序号之前的数据已经全部接收到了,下次请发送这个序号后的数据。
但是累计确认也有一个弊端。在连续接收报文时,可能会遇到接收到序号 5 的报文后,并未接收到序号 6 的报文,然而序号 7 以后的报文已经接收。遇到这种情况时,ACK 只能回复 6,这样就会造成发送端重复发送数据的情况。
滑动窗口
在上面小节中讲到了发送窗口。在 TCP 中,两端其实都维护着窗口:分别为发送端窗口和接收端窗口。
发送端窗口包含已发送但未收到应答的数据和可以发送但是未发送的数据。
发送端窗口是由接收窗口剩余大小决定的。接收方会把当前接收窗口的剩余大小写入应答报文,发送端收到应答后根据该值和当前网络拥塞情况设置发送窗口的大小,所以发送窗口的大小是不断变化的。
当发送端接收到应答报文后,会随之将窗口进行滑动
滑动窗口是一个很重要的概念,它帮助 TCP 实现了流量控制的功能。接收方通过报文告知发送方还可以发送多少数据,从而保证接收方能够来得及接收数据,防止出现接收方带宽已满,但是发送方还一直发送数据的情况。
Zero 窗口
在发送报文的过程中,可能会遇到对端出现零窗口的情况。在该情况下,发送端会停止发送数据,并启动 persistent timer 。该定时器会定时发送请求给对端,让对端告知窗口大小。在重试次数超过一定次数后,可能会中断 TCP 链接。
拥塞处理
拥塞处理和流量控制不同,后者是作用于接收方,保证接收方来得及接受数据。而前者是作用于网络,防止过多的数据拥塞网络,避免出现网络负载过大的情况。
拥塞处理包括了四个算法,分别为:慢开始,拥塞避免,快速重传,快速恢复。
慢开始算法
慢开始算法,顾名思义,就是在传输开始时将发送窗口慢慢指数级扩大,从而避免一开始就传输大量数据导致网络拥塞。想必大家都下载过资源,每当我们开始下载的时候都会发现下载速度是慢慢提升的,而不是一蹴而就直接拉满带宽。
慢开始算法步骤具体如下
- 连接初始设置拥塞窗口(Congestion Window) 为 1 MSS(一个分段的最大数据量)
- 每过一个 RTT 就将窗口大小乘二
- 指数级增长肯定不能没有限制的,所以有一个阈值限制,当窗口大小大于阈值时就会启动拥塞避免算法。
拥塞避免算法
拥塞避免算法相比简单点,每过一个 RTT 窗口大小只加一,这样能够避免指数级增长导致网络拥塞,慢慢将大小调整到最佳值。
在传输过程中可能定时器超时的情况,这时候 TCP 会认为网络拥塞了,会马上进行以下步骤:
- 将阈值设为当前拥塞窗口的一半
- 将拥塞窗口设为 1 MSS
- 启动拥塞避免算法
快速重传
快速重传一般和快恢复一起出现。一旦接收端收到的报文出现失序的情况,接收端只会回复最后一个顺序正确的报文序号。如果发送端收到三个重复的 ACK,无需等待定时器超时而是直接启动快速重传算法。具体算法分为两种:
TCP Taho 实现如下
- 将阈值设为当前拥塞窗口的一半
- 将拥塞窗口设为 1 MSS
- 重新开始慢开始算法
TCP Reno 实现如下
- 拥塞窗口减半
- 将阈值设为当前拥塞窗口
- 进入快恢复阶段(重发对端需要的包,一旦收到一个新的 ACK 答复就退出该阶段),这种方式在丢失多个包的情况下就不那么好了
- 使用拥塞避免算法
TCP New Ren 改进后的快恢复
TCP New Reno 算法改进了之前 TCP Reno 算法的缺陷。在之前,快恢复中只要收到一个新的 ACK 包,就会退出快恢复。
在 TCP New Reno 中,TCP 发送方先记下三个重复 ACK 的分段的最大序号。
假如我有一个分段数据是 1 ~ 10 这十个序号的报文,其中丢失了序号为 3 和 7 的报文,那么该分段的最大序号就是 10。发送端只会收到 ACK 序号为 3 的应答。这时候重发序号为 3 的报文,接收方顺利接收的话就会发送 ACK 序号为 7 的应答。这时候 TCP 知道对端是有多个包未收到,会继续发送序号为 7 的报文,接收方顺利接收并会发送 ACK 序号为 11 的应答,这时发送端认为这个分段接收端已经顺利接收,接下来会退出快恢复阶段。
小结
这一章节内容很多,充斥了大量的术语,适合大家反复研读,已经把 TCP 中最核心最需要掌握的内容全盘托出了,如有哪里不明白的欢迎提问。
总结一下这一章节的内容:
- 建立连接需要三次握手,断开连接需要四次握手
- 滑动窗口解决了数据的丢包、顺序不对和流量控制问题
- 拥塞窗口实现了对流量的控制,保证在全天候环境下最优的传递数据
35、封装公共组件如何和他人共用
全局组件封装保持单一职责,开放封闭的原则
对于封装完的组件,要对具体参数、使用规则进行注释说明
在早会或者工作群告知组员,封装个什么组件
36、git
拉取远程仓库代码并进入当前仓库的master分支 git clone "远程仓库的地址"
新建自己的分支 git branch "新建自己的分支名"
切换到 git checkout "自己的分支名"
拉取最新远程分支代码: git pull
新增文件的命令:git add file或者git add .
提交文件的命令:git commit –m或者git commit –a
将自己开发分支代码推到远程仓库 git push origin "自己的分支名"
切换到test分支 new merge request 选择自己的原分支 target branch
解决冲突:
git checkout test
git merge "自己的仓库" 将自己代码本地提交test分支,手动解决冲突
查看工作区状况:git status –s
拉取合并远程分支的操作:git fetch/git merge或者git pull
查看提交记录命令:git reflog
删除分支 git branch -D “本地分支名” 不能自杀
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。