这篇文章记录了一些前端知识点的补充,其中包括ES6,ES5一些函数的实现原理,便于更好的理解JS,一些基础排序算法的JS实现,以及其他比较常见的知识点
数据响应式实现
在Vue3.0
中将会通过Proxy
来替换原本的Object.defineProperty
来实现数据双向绑定
Proxy
可以用来自定义对象中的操作。
通过Object.defineProperty实现数据响应式
ES6
中新加了Array.property.includes
用于判断一个特定的值是否存在于数组中。ES5
中的indexOf
不够语义化,而且对于
NaN
存在误判
let data = {
price: 10,
count: 5
}
let target = null
class Dep {
constructor() {
this.subscribers = []
}
depend() {
if (target && !this.subscribers.includes(target)) {
this.subscribers.push(target)
}
}
notify() {
this.subscribers.forEach(sub => sub())
}
}
Object.keys(data).forEach(key => {
let internalValue = data[key]
const dep = new Dep()
Object.defineProperty(data, key, {
get() {
dep.depend()
return internalValue
},
set(newValue) {
internalValue = newValue
dep.notify()
}
})
})
target = () => {
data.total = data.price * data.count
}
target()
console.log('data.total', data.total);
data.count = 10
console.log('data.total', data.total);
基于Proxy实现的数据响应式
let onWatch = (obj,setBind,getLogger)=>{
let handler = {
set(target,property,value,receiver){
setBind(value,property)
return Reflect.set(target,property,value)
//如果property部署了set函数,则set函数的this绑定receiver
},
get(target,property,receiver){
getLogger(target,property)
return Reflect.get(target,property,receiver)
//如果property部署了get函数,则set函数的this绑定receiver
}
}
return new Proxy(obj,handler)
}
let obj = {a:1}
let watchObj = onWatch(
obj,
(value,property)=>{
console.log(`监听到属性${property}改变为${value}`);
},
(target,property)=>{
console.log(`'${property}' = ${target[property]}`);
}
)
Promise的ES5实现
function MyPromise(executor) {
let self = this
self.status = 'pending'
self.onResolvedCallback = [] //Promise resolve时的回调函数集
self.onRejectedCallback = [] //Promise reject时的回调函数集
function resolve(value) {
if (value instanceof MyPromise) {
return value.then(resolve, reject)
}
setTimeout(function() {
if (self.status === 'pending') {
self.status = 'resolved'
self.data = value
self.onResolvedCallback.forEach(cb => cb(self.data))
}
})
}
function reject(reason) {
setTimeout(function() {
if (self.status === 'pending') {
self.status = 'rejected'
self.data = reason
self.onRejectedCallback.forEach(cb => cb(self.data))
}
})
}
try {
executor(resolve, reject)
} catch (reason) {
reject(reason)
}
}
MyPromise.prototype.then = function(onResolved, onRejected) {
let self = this
let promise2 //then必须返回一个新的promise
//如果参数不是函数,那么则将其忽略。同时这里实现了值穿
onResolved = typeof onResolved === 'function' ? onResolved : (v) => v
onRejected = typeof onRejected === 'function' ? onRejected : (r) => { throw r }
if (self.status === 'resolved') {
//如果promise1(此处即为this)的状态已经确定并且是resolved,调用onResolved
//考虑到可能会抛出错误,所以将其包在try/catch块中
return promise2 = new MyPromise((resolve, reject) => {
try {
let x = onResolved(self.data)
// 如果onResolved的返回值是一个Promise对象,直接取它的结果做为promise2的结果
if (x instanceof MyPromise) {
x.then(resolve, reject)
}
resolve(x)
} catch (e) {
reject(e)
}
})
}
if (self.status === 'rejected') {
return promise2 = new MyPromise((resolve, reject) => {
try {
let x = onRejected(self.data)
if (x instanceof MyPromise) {
x.then(resolve, reject)
}
} catch (e) {
reject(e)
}
})
}
// 如果当前的Promise还处于pending状态,我们并不能确定调用onResolved还是onRejected,
// 只能等到Promise的状态确定后,才能确实如何处理。
// 所以我们需要把我们的**两种情况**的处理逻辑做为callback放入promise1(此处即this/self)的回调数组里
if (self.status === 'pending') {
return promise2 = new MyPromise((resolve, reject) => {
self.onResolvedCallback.push((value) => {
try {
let x = onResolved(self.data)
if (x instanceof MyPromise) {
x.then(resolve, reject)
}
} catch (e) {
reject(e)
}
})
self.onRejectedCallback.push((reason) => {
try {
let x = onRejected(self.data)
if (x instanceof MyPromise) {
x.then(resolve, reject)
}
} catch (e) {
reject(e)
}
})
})
}
}
MyPromise.prototype.catch = function(onRejected) { this.then(null, onRejected) }
//promise.then(onResolved, onRejected)里的这两相函数需要异步调用,关于这一点,标准里也有说明:
//In practice, this requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which then is called, and with a fresh stack.
call,apply,bind的原生实现
call
Function.prototype.call = function(context,...arg){
context = context || window //不传入参数,默认this指向window
const symbol = Symbol() //避免覆盖context的原始属性
context[symbol] = this //把要调用此方法的那个函数对象绑定在context上
const val = context[symbol](...arg) //在context上调用函数,那函数的this值就是context
delete context[symbol] //删除context的fn属性,去除影响
return val
}
apply
Function.prototype.myapply = function(context, arr) {
context = context || window
const symbol = Symbol()
context[symbol] = this
const result = context[symbol](...arr)
delete context[symbol]
return result
}
Function.prototype.myapply = function(context, arr) {
return this.mycall(context, ...arr)
}
bind
Function.prototype.mybind = function(obj, ...arg) { //收集剩余参数
return (...arg2) => this.myapply(obj, arg1.concat(arg2))
}
Function.prototype.mybind = function(obj, ...arg) {
return (...arg2) => { //使用箭头函数绑定this在当前环境,否则this指向window
let args = arg.concat(arg2)
const symbol = Symbol()
obj[symbol] = this;
const val = obj[symbol](...args)
delete obj[symbol]
return val
}
}
不使用箭头函数的话,只需要在return函数之前将this对象保存即可
let self = this
用于实现柯里化
function a(a,b,c){
console.log(a+b+c)
}
let default = a.bind(undefined,1)
default(2,3) //6
New
function New(func, ...arg) {
let MiddleObj = {}
if (func.prototype !== null) {
MiddleObj.__proto__ = func.prototype
}
const value = func.call(MiddleObj, ...arg) // this 绑定
if ((typeof value === 'object' || typeof value === 'function') && value !== null) {
return value
}
return MiddleObj
}
- 声明一个中间对象
- 将该对象的原型指向构造函数的原型
- 将构造函数的this,指向该中间对象
- 运行构造函数,如果构造函数返回一个对象,那么直接将这个对象作为结果返回
- 否则返回中间对象
Object.create
Object.prototype.create = function(obj){
if(Object.prototype.create){
return Object.prototype.create
}else{
function F(){}
F.prototype = obj
return new F()
}
}
Instanceof
function myInstanceof(left, right) {
let prototype = right.prototype
left = left.__proto__
while (true) { //循环查询原型链 myInstanceof(实例, Object)
if (left === null || left === undefined) { //原型链的终点是null
return false
}
if (prototype === left) {
return true
}
left = left.__proto__
}
}
- 首先获取类型的原型
- 然后获得对象的原型
- 然后一直循环判断对象的原型是否等于类型的原型,直到对象原型为
null
,因为原型链最终为null
类型检测
typeof
对于原始类型来说,除了 null
都可以显示正确的类型
null
会返回object
因为在计算机中以32字节存储,对象的前三位为0,null
的所有位都是0,就被错误判定为对象,这个bug不可修复。因为会对现有代码造成破坏
typeof
对于对象,除了函数都会返回object
instanceof
判断对象类型可以返回正确的结果,但无法判断原始类型
instanceof
用于查看对象是否是特定构造函数的实例
如果想要instanceof
可以判断原始类型
class PlusIns{
static [Symbol.hasInstance](x){
return typeof x === 'string'
}
}
console.log('hey' instanceof PlusIns) //true
这是ES6
的语法,当对象调用instanceof时
会调用[Symbol.hasInstance]
这个方法,通过改写这个方法达到判断原始类型的目的
Object.prototype.toString.call('') ; // [object String]
Object.prototype.toString.call(1) ; // [object Number]
Object.prototype.toString.call(true) ; // [object Boolean]
Object.prototype.toString.call(undefined) ; // [object Undefined]
Object.prototype.toString.call(null) ; // [object Null]
Object.prototype.toString.call(new Function()) ; // [object Function]
Object.prototype.toString.call(new Date()) ; // [object Date]
Object.prototype.toString.call([]) ; // [object Array]
Object.prototype.toString.call(new RegExp()) ; // [object RegExp]
Object.prototype.toString.call(new Error()) ; // [object Error]
Object.prototype.toString.call(document) ; // [object HTMLDocument]
Object.prototype.toString.call(window) ; //[object global] window是全局对象global的引用
类型转换
加法会进行隐式类型转换,如果一方为字符串,那么会把另外一方也转为字符串
如果一方不是字符串也不是数字,那么会将他转换为数字或者字符串
'a'++'b' //aNaN
因为+'b'
等于NaN,
也可以通过+'1'
的形式来快速获取number
类型
对于除了加法的运算符来说,只要其中一方是数字,那么另一方就会被转为数字
THIS
- 对于直接调用
foo
来说,不管foo
函数被放在了什么地方,this
一定是window
- 对于
obj.foo()
来说,我们只需要记住,谁调用了函数,谁就是this
,所以在这个场景下foo
函数中的this
就是obj
对象 - 对于
new
的方式来说,this
被永远绑定在了c
上面,不会被任何方式改变this
不管函数调用 bind
几次,fn
中的 this
永远由第一次 bind
决定也就是fn.bind().bind(a)() // window
bind
没有传参,所以指向window
对于 ==
来说,如果对比双方的类型不一样的话,就会进行隐式类型转换
[] == [] //false 因为是引用类型,两者的堆内存地址不同
[] == ![] //true 因为!运算符优先级较高,右边会变为false 其中一个为Boolean所以将两边转为数字,都为0,所以结果返回true
由于this在箭头函数中已经按照词法作用域绑定了,所以,用call()或者apply()调用箭头函数时,无法对this进行绑定,即传入的第一个参数被忽略:
闭包
闭包存在的意义就是可以间接访问函数内部的变量。
闭包的作用
1.可以读取函数内部的变量。
2.可以使变量的值长期保存在内存中,生命周期比较长。因此不能滥用闭包,否则会造成网页的性能问题
3.可以用来实现JS
模块。
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i)
}, i * 1000)
}
setTimeout
属于异步任务,在同步任务执行完毕后执行
setTimeout
还有第三个参数,用于传参给函数
深度拷贝
Call By Sharing
javascript
中不存在按引用传递,传递方式都为值传递或者Call By Sharing 大意就是如果更改拷贝对象内部的值,会影响到原始对象,但如果直接更改了拷贝对象的引用地址,那么两者就相互独立,互不影响
浅拷贝只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存。但深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象。
-
Object.assign
let a = { age: 1 } let b = Object.assign({}, a) a.age = 2 console.log(b.age) // 1
-
...
let a = { age: 1 } let b = { ...a } a.age = 2 console.log(b.age) // 1
浅拷贝在于如果对象内部得值还有对象的话,那么两个子对象之间还是会互相影响
let a = {
age: 1,
jobs: {
first: 'FE'
}
}
let b = { ...a }
a.jobs.first = 'native'
console.log(b.jobs.first) // native
通常可以通过 JSON.parse(JSON.stringify(object))
来解决。
这种方法会忽略掉对象内部的Symbol
,undefined
,function
实现深拷贝函数是很复杂的,推荐使用lodash
提供的深拷贝函数
原型链
函数提升优于变量提升
JS
中不存在类,CLASS
只是语法糖,本质还是函数
Object与Function
Function instanceof Object;//true Object instanceof Function;//true
首先Object
与Function
都是构造函数,而所有的构造函数都是Function
的实例对象,因此Object
是Function
的实例对象
Function.prototype
是Object
的实例对象
实例对象的原型(__proto__
)指向构造函数的prototype
属性,因此Object.__proto__ === Function.prototype,Function.prototype.__proto__===Object.prototype
当访问一个属性的时候,它会沿着原型链向上查找,直到找到或者到Object.prototype.__proto__
(null
)为止
匿名函数是浏览器中的native code
,由c或c++编写
寄生组合继承
function Parent(value) {
this.val = value
}
Parent.prototype.getValue = function() {
console.log(this.val)
}
function Child(value) {
Parent.call(this, value)
}
Child.prototype = Object.create(Parent.prototype, {
constructor: {
value: Child,
enumerable: false,
writable: true,
configurable: true
}
})
const child = new Child(1)
child.getValue() // 1
child instanceof Parent // true
function create(proto,options){
vat tmp = {};
tmp.__proto__ = proto
Object.defineProperties(tmp,options)
return tmp
}
ES6
实现继承必须调用super
,否则子类无法正常使用,因为子类没有自己的this
,是通过继承父类的this
对象
class Parent {
constructor(value) {
this.val = value
}
getValue() {
console.log(this.val)
}
}
class Child extends Parent {
constructor(value) {
super(value)
this.val = value
}
}
let child = new Child(1)
child.getValue() // 1
child instanceof Parent // true
模块化
使用模块化有以下功能
- 解决命名冲突
- 提供复用性
- 提高代码可维护性
-
立即执行函数
立即执行函数是早期实现模块化的手段
(function(globalVariable){ globalVariable.test = function() {} // ... 声明各种变量、函数都不会污染全局作用域 })(globalVariable)
-
AMD
和CMD
目前较少使用
-
CommonJS
-
ESModule
原生实现的模块化方案,异步导入模块,采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化
// 引入模块 API
import XXX from './a.js'
import { XXX } from './a.js'
// 导出模块 API
export function a() {}
export default function() {}
map,filter,reduce
["1", "2", "3"].map(parseInt) // [1,NaN,NaN]
因为map的回调函数的参数index被当做了parseInt的基数radix,导致出现超范围的radix和不合法的进制解析
parseInt('1',0) = 1,
parseInt('2',1) = NaN,
parseInt('3',2) = NaN,
Polyfill
是一个js
库,主要抚平不同浏览器之间对js
实现的差异。
map
作用是生成一个新数组,遍历原数组,将每个元素拿出来做一些变换然后放入到新的数组中。
[1, 2, 3].map(v => v + 1) // -> [2, 3, 4]
map
的回调函数接受三个参数,分别是当前索引元素,索引,原数组
map
与forEach
的不同之处在于,前者不会改变原数组,并返回经过操作后的新数组。而后者会返回undefined
,并更改原数组
filter
的作用也是生成一个新数组,在遍历数组的时候将返回值为 true
的元素放入新数组,我们可以利用这个函数删除一些不需要的元素
let array = [1, 2, 4, 6]
let newArray = array.filter(item => item !== 6)
console.log(newArray) // [1, 2, 4]
和 map
一样,filter
的回调函数也接受三个参数,用处也相同。
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
来实现 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]
异步
并发与并行是不同的,并发是指在一段时间内通过任务间的切换完成所有任务。并行则是
cpu
存在多个核心,可以同时去执行并完成多个任务。
Callback
ajax(url, () => {
// 处理逻辑
})
Callback Hell
ajax(url, () => {
// 处理逻辑
ajax(url1, () => {
// 处理逻辑
ajax(url2, () => {
// 处理逻辑
})
})
})
- 嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身
- 嵌套函数一多,就很难处理错误
Promise
构造 Promise
的时候,构造函数内部的代码是立即执行的
Promise
实现了链式调用,也就是说每次调用 then
之后返回的都是一个 Promise
,并且是一个全新的 Promise
,原因也是因为状态不可变。如果你在 then
中 使用了 return
,那么 return
的值会被 Promise.resolve()
包装
Promise.resolve(1)
.then(res => {
console.log(res) // => 1
return 2 // 包装成 Promise.resolve(2)
})
.then(res => {
console.log(res) // => 2
})
anync,await
一个函数如果加上 async
,那么该函数就会返回一个 Promise
async function test() {
return "1"
}
console.log(test()) // -> Promise {<resolved>: "1"}
await
将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await
会导致性能上的降低。
async function test() {
// 以下代码没有依赖性的话,完全可以使用 Promise.all 的方式
// 如果有依赖性的话,其实就是解决回调地狱的例子了
await fetch(url)
await fetch(url1)
await fetch(url2)
}
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
Event Loop
当在浏览器中打开了一个标签页,就代表开启了一个进程,一个进程中可以有多个线程,比如渲染线程
JS
引擎线程,
HTTP
请求线程等。当发起一个请求的时候,就是创建了一个线程,当请求结束后,该线程可能就会被销毁
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
// script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout
创建promise对象里面的代码属于同步代码,setTimeout
的任务队列优先级低于promise
队列
EventLoop
javascript 为什么是单线程?
假设javascript有两个线程,一个在某个DOM节点添加内容,另一个线程在这个节点上删除内容
使用web worker技术开的多线程有着诸多限制. 例如:所有新线程都受主线程的完全控制,不能独立执行。这意味着这些“线程” 实际上应属于主线程的子线程。另外,这些子线程并没有执行I/O操作的权限,只能为主线程分担一些诸如计算等任务。
- 首先执行同步代码,这属于宏任务
- 当执行完11所有同步代码后,执行栈为空,查询是否有异步代码需要执行
- 执行所有微任务
- 当执行完所有微任务后,如有必要会渲染页面
- 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是
setTimeout
中的回调函数
把对应的回调加入当前执行栈…如此反复,进入循环。
- macro-task(宏任务)
- setTimeout
- setInterval
- setImmediate
- micro-task(微任务)
- Promise
- process.nextTick
0.1+0.2
JS
采用 IEEE 754 双精度版本(64位)
在计算机中是以二进制存储的,而0.1与0.2在二进制下都是无限循环,导致精度丢失。解决方案将他们变为整数就好
垃圾回收机制
V8
实现了准确式GC
,GC算法
采用了分代式垃圾回收机制。V8
将内存分为新生代以及老生代
新生代算法
新生代中的对象一般存活时间较短,Scavenge GC
算法,将内存空间分为两部分,分别为From
和To
空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入From
空间中,当From
空间被占满时,新生代GC
就会启动。算法会检查From
空间中存活的对象并复制到To
空间中,如果有失活的对象就会销毁。当复制完成后将From
空间和To
空间互换。
老生代算法
老生代中的对象一般存活时间较长且数量较多,使用了两个算法,分别是标记清除算法和标记压缩算法
- 新生代的对象如果已经经历过一次
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
};
在老生代对象中有如下情况会启动标记清除算法
- 某一个空间没有分块
- 空间中对象超过一定限制
- 空间不能保证新生代中的对象移动到老生代中
在这个阶段,会遍历堆中的所有对象,然后标记活动对象,在标记完成后,销毁所有没有被标记的对象。
在2018年,
GC
技术新增了并发标记。该技术可以让GC
扫描和标记对象时,同时允许JS
运行
当清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活动对象向一端移动,直到所有对象都移动完成然后清理掉不需要的内存
基础知识
事件注册
如果给一个 body 中的子节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行。
// 以下会先打印冒泡然后是捕获
node.addEventListener(
'click',
event => {
console.log('冒泡')
},
false
)
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
addEventListener
可以接收三个参数,第三个参数可以使布尔值,也可以是对象。
对于布尔值useCapture
参数来说,默认为false
,代表事件是否捕获,对于对象参数来说,有以下几个属性
capture
:布尔值,和useCapture
作用一样once
:布尔值,值为true
表示该回调只会调用一次,调用后会移除监听passive
:布尔值,表示永远不会调用preventDefault
事件代理
如果一个节点中的子节点是动态生成的,那么子节点需要注册事件的话应该注册在父节点上
事件代理的方式相较于直接给目标注册事件来说,有以下优点:
- 节省内存
- 不需要给子节点注销事件
跨域
浏览器出于安全考虑,有同源策略。同源策略主要是用来防止CSRF
攻击。防止利用用户的登录态发起恶意请求
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)
})
CORS
CORS
需要浏览器和后端同时支持。
服务端设置 Access-Control-Allow-Origin
就可以开启 CORS
。 该属性表示哪些域名可以访问资源,如果设置通配符则表示所有网站都可以访问资源。
CORS
会在发送请求时出现两种情况,分别为简单请求和复杂请求。
简单请求
以 Ajax 为例,当满足以下条件时,会触发简单请求
- 使用下列方法之一:
GET
HEAD
POST
Content-Type
的值仅限于下列三者之一:text/plain
multipart/form-data
application/x-www-form-urlencoded
复杂请求
不符合以上条件的请求就是复杂请求。
对于复杂请求来说,首先会发起一个预检请求,该请求是 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()
存储
特性 | cookie | localStorage | sessionStorage | indexDB |
---|---|---|---|---|
数据生命周期 | 一般由服务器生成,可以设置过期时间 | 除非被清理,否则一直存在 | 页面关闭就清理 | 除非被清理,否则一直存在 |
数据存储大小 | 4K | 5M | 5M | 无限 |
与服务端通信 | 每次都会携带在 header 中,对于请求性能影响 | 不参与 | 不参与 | 不参与 |
cookie
不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStorage
和 sessionStorage
。对于不怎么改变的数据尽量使用 localStorage
存储,否则可以用 sessionStorage
存储。
属性 | 作用 |
---|---|
value |
如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识 |
http-only |
不能通过 JS 访问 Cookie,减少 XSS 攻击 |
secure |
只能在协议为 HTTPS 的请求中携带 |
same-site |
规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击 |
强缓存(200) 协商缓存(304)
有时候缓存是 200 OK (from disk cache)有时候会是 304 ? 看运维是否移除了 Entity Tag。移除了,就总是 200 OK (from cache)。没有移除,就两者交替出现。
他们两个的区别是 200 OK (from disk cache) 是浏览器没有跟服务器确认, 就是它直接用浏览器缓存。
304 是浏览器和服务器确认了一次缓存有效性,再用的缓存。
那么禁止200 OK (from disk cache) 这个缓存的方法是,
ajax
请求是带上参数 cache: false
强缓存
强缓存可以通过设置两种 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 秒后过期,需要再次请求。
协商缓存
如果缓存过期了,就需要发起请求验证资源是否有更新。协商缓存可以通过设置两种 HTTP Header 实现:Last-Modified
和 ETag
。
当浏览器发起请求验证资源时,如果资源没有做改变,那么服务端就会返回 304 状态码,并且更新浏览器缓存有效期。
实际场景应用缓存策略
频繁变动的资源
对于频繁变动的资源,首先需要使用 Cache-Control: no-cache
使浏览器每次都请求服务器,然后配合 ETag
或者 Last-Modified
来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。
代码文件
这里特指除了 HTML 外的代码文件,因为 HTML 文件一般不缓存或者缓存时间很短。
一般来说,现在都会使用工具来打包代码,那么我们就可以对文件名进行哈希处理,只有当代码修改后才会生成新的文件名。基于此,我们就可以给代码文件设置缓存有效期一年 Cache-Control: max-age=31536000
,这样只有当 HTML 文件中引入的文件名发生了改变才会去下载最新的代码文件,否则就一直使用缓存。
渲染
执行
JS
有一个JS
引擎,那么执行渲染也有一个渲染引擎。
浏览器接收到 HTML 文件并转换为 DOM 树
- 当浏览器接收到这些字节数据以后,它会将这些字节数据转换为字符串,也就是我们写的代码。
- 当数据转换为字符串以后,浏览器会先将这些字符串通过词法分析转换为标记(token),这一过程在词法分析中叫做标记化(
tokenization
)。 - 当结束标记化后,这些标记会紧接着转换为 Node,最后这些 Node 会根据不同 Node 之前的联系构建为一颗 DOM 树。
将 CSS 文件转换为 CSSOM 树
生成渲染树
当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做重排–Reflow)
为什么操作 DOM 慢
因为 DOM 是属于渲染引擎中的东西,而 JS
又是 JS
引擎中的东西。当我们通过 JS 操作 DOM 的时候,其实这个操作涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。操作 DOM 次数一多,也就等同于一直在进行线程之间的通信,并且操作 DOM 可能还会带来重绘回流的情况,所以也就导致了性能上的问题。
插入几万个 DOM,如何实现页面不卡顿?
可以通过虚拟滚动(virtualized scroller
)去解决这个问题
这种技术的原理就是只渲染可视区域内的内容,非可见区域的那就完全不渲染了,当用户在滚动的时候就实时去替换渲染的内容。
重绘(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
标签
安全
XSS
XSS
可以分为多种类型,但是总体上分为两类:持久型和非持久型。
持久型也就是攻击的代码被服务端写入进数据库中,这种攻击危害性很大,因为如果网站访问量很大的话,就会导致大量正常访问页面的用户都受到攻击。
非持久型相比于前者危害就小的多了,一般通过修改 URL 参数的方式加入攻击代码,诱导用户访问链接从而进行攻击。
转义字符
首先,对于用户的输入应该是永远不信任的。最普遍的做法就是转义输入输出的内容,对于引号、尖括号、斜杠进行转义
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
}
js-xss
CSRF
CSRF 跨站请求伪造。原理就是攻击者构造出一个后端请求地址,诱导用户点击或者通过某些途径自动发起请求。如果用户是在登录状态下的话,后端就以为是用户在操作,从而进行相应的逻辑。
防范 CSRF 攻击可以遵循以下几种规则:
- Get 请求不对数据进行修改
- 不让第三方网站访问到用户 Cookie
- 阻止第三方网站请求接口
- 请求时附带验证信息,比如验证码或者 Token
验证 Referer
对于需要防范 CSRF 的请求,我们可以通过验证 Referer 来判断该请求是否为第三方网站发起的。
Token
服务器下发一个随机 Token,每次发起请求时将 Token 携带上,服务器验证 Token 是否有效。
性能优化
图片加载优化
- 不用图片。很多时候会使用到很多修饰类图片,其实这类修饰图片完全可以用 CSS 去代替。
- 对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片。
- 小图使用 base64 格式
- 将多个图标文件整合到一张图片中(雪碧图)
- 选择正确的图片格式:
- 对于能够显示 WebP 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点就是兼容性并不好
- 小图使用 PNG,其实对于大部分图标这类图片,完全可以使用 SVG 代替
- 照片使用 JPEG
DNS 预解析
DNS
解析也是需要时间的,可以通过预解析的方式来预先获得域名所对应的 IP。
<link rel="dns-prefetch" href="//yuchengkai.cn">
节流以及防抖
懒加载
懒加载就是将不关键的资源延后加载。
懒加载的原理就是只加载自定义区域(通常是可视区域,但也可以是即将进入可视区域)内需要加载的东西。对于图片来说,先设置图片标签的 src
属性为一张占位图,将真实的图片资源放入一个自定义属性中,当进入自定义区域时,就将自定义属性替换为 src
属性,这样图片就会去下载资源,实现了图片懒加载。
懒加载不仅可以用于图片,也可以使用在别的资源上。比如进入可视区域才开始播放视频等等。
CDN
CDN 的原理是尽可能的在各个地方分布机房缓存数据,这样即使我们的根服务器远在国外,在国内的用户也可以通过国内的机房迅速加载资源。
因此,我们可以将静态资源尽量使用 CDN 加载,由于浏览器对于单个域名有并发请求上限,可以考虑使用多个 CDN 域名。并且对于 CDN 加载静态资源需要注意 CDN 域名要与主站不同,否则每次请求都会带上主站的 Cookie,平白消耗流量。
性能监控
对于性能监控来说,我们可以直接使用浏览器自带的 Performance API 来实现这个功能。
对于性能监控来说,其实我们只需要调用 performance.getEntriesByType('navigation')
这行代码就行了。
异常监控
对于代码运行错误,通常的办法是使用 window.onerror
拦截报错。该方法能拦截到大部分的详细报错信息,但是也有例外
- 对于跨域的代码运行错误会显示
Script error.
对于这种情况我们需要给script
标签添加crossorigin
属性 - 对于某些浏览器可能不会显示调用栈信息,这种情况可以通过
arguments.callee.caller
来做栈递归
对于异步代码来说,可以使用 catch
的方式捕获错误。比如 Promise
可以直接使用 catch
函数,async await
可以使用 try catch
。
但是要注意线上运行的代码都是压缩过的,需要在打包时生成 sourceMap
文件便于 debug。
对于捕获的错误需要上传给服务器,通常可以通过 img
标签的 src
发起一个请求。
另外接口异常就相对来说简单了,可以列举出出错的状态码。一旦出现此类的状态码就可以立即上报出错。接口异常上报可以让开发人员迅速知道有哪些接口出现了大面积的报错,以便迅速修复问题
TCP
HTTP TLS
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 | 内容的最后修改时间 |
TLS
HTTPS 还是通过了 HTTP 来传输信息,但是信息通过 TLS 协议进行了加密。
TLS 协议位于传输层之上,应用层之下。首次进行 TLS 协议传输需要两个 RTT ,接下来可以通过 Session Resumption 减少到一个 RTT。
在 TLS 中使用了两种加密技术,分别为:对称加密和非对称加密。
对称加密:
对称加密就是两边拥有相同的秘钥,两边都知道如何将密文加密解密。
这种加密方式固然很好,但是问题就在于如何让双方知道秘钥。因为传输数据都是走的网络,如果将秘钥通过网络的方式传递的话,一旦秘钥被截获就没有加密的意义的。
非对称加密:
有公钥私钥之分,公钥所有人都可以知道,可以将数据用公钥加密,但是将数据解密必须使用私钥解密,私钥只有分发公钥的一方才知道。
这种加密方式就可以完美解决对称加密存在的问题。假设现在两端需要使用对称加密,那么在这之前,可以先使用非对称加密交换秘钥。
简单流程如下:首先服务端将公钥公布出去,那么客户端也就知道公钥了。接下来客户端创建一个秘钥,然后通过公钥加密并发送给服务端,服务端接收到密文以后通过私钥解密出正确的秘钥,这时候两端就都知道秘钥是什么了。
TLS 握手过程如下图:
客户端发送一个随机值以及需要的协议和加密方式。
服务端收到客户端的随机值,自己也产生一个随机值,并根据客户端需求的协议和加密方式来使用对应的方式,并且发送自己的证书(如果需要验证客户端证书需要说明)
客户端收到服务端的证书并验证是否有效,验证通过会再生成一个随机值,通过服务端证书的公钥去加密这个随机值并发送给服务端,如果服务端需要验证客户端证书的话会附带证书
服务端收到加密过的随机值并使用私钥解密获得第三个随机值,这时候两端都拥有了三个随机值,可以通过这三个随机值按照之前约定的加密方式生成密钥,接下来的通信就可以通过该密钥来加密解密
通过以上步骤可知,在 TLS 握手阶段,两端使用非对称加密的方式来通信,但是因为非对称加密损耗的性能比对称加密大,所以在正式传输数据时,两端使用对称加密的方式通信。
PS:以上说明的都是 TLS 1.2 协议的握手情况,在 1.3 协议中,首次建立连接只需要一个 RTT,后面恢复连接不需要 RTT 了。
设计模式
单例模式
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', s1 === s2);
适配器模式
适配器用来解决两个接口不兼容的情况,不需要改变已有的接口,通过包装一层的方式实现两个接口的正常协作。
以下是如何实现适配器模式的例子
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' // 不可修改
算法
冒泡排序
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;
}
选择排序
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;
}
快排
快排的原理如下。随机选取一个数组中的值作为基准值,从左至右取值与基准值对比大小。比基准值小的放数组左边,大的放右边,对比完成后将基准值和第一个比基准值大的值交换位置。然后将数组以基准值的位置分为两部分,继续递归以上操作。
function quickSort(arr){
//如果数组<=1,则直接返回
if(arr.length<=1){return arr;}
var pivotIndex=Math.floor(arr.length/2);
//找基准,并把基准从原数组删除
var pivot=arr.splice(pivotIndex,1)[0];
//定义左右数组
var left=[];
var right=[];
//比基准小的放在left,比基准大的放在right
for(var i=0;i<arr.length;i++){
if(arr[i]<=pivot){
left.push(arr[i]);
}
else{
right.push(arr[i]);
}
}
//递归
return quickSort(left).concat([pivot],quickSort(right));
}
插入排序
插入排序的原理如下。第一个元素默认是已排序元素,取出下一个元素和当前元素比较,如果当前元素大就交换位置。那么此时第一个元素就是当前的最小数,所以下次取出操作从第三个元素开始,向前对比,重复之前的操作。
function Insertion(arr){
for(let i = 1;i<arr.length;i++){
for(let j = i-1;j>=0&&arr[j]>arr[j+1];j--){
[arr[j],arr[j+1]] = [arr[j+1],arr[j]]
}
}
}
css解析选择器是从右向左解析的,因为从左向右解析,如果发现不匹配,需要进行回溯。影响性能
sort()方法在收到一个数组的时候,会判断数组的长度,如果数组小于10则选择插入排序,否则选择快速排序