赏口饭吃┭┮﹏┭┮
JavaScript 系列
JavaScript 中的数据类型 存储差别
前言
在 JavaScript 中,我们可以分成两种类型:
两种类型的区别是:存储位置不同
1.基本类型
基本类型主要为以下六种:
Number
数值最常见的整数类型格式则为十进制,还可以设置八进制(零开头)、十六进制(0x 开头)
1 | let intNum = 55 // 10进制的55 |
浮点类型则在数值汇总必须包含小数点,还可通过科学计数法表示
1 | let floatNum1 = 1.1 |
在数值类型中,存在一个特殊数值 NaN,意为“不是数值”,用于表示本来要返回数值的操作失败了(而不是抛出错误)
1 | console.log(0 / 0) // NaN |
Undefined
Undefined
类型只有一个值,就是特殊值 undefined
。当使用 var 或 let 声明了变量但没有初始化时,就相当于给变量赋予了 undefined
值
1 | let message |
包含undefined
值的变量跟未定义变量是有区别的
1 | let message // 这个变量被声明了,只是值为 undefined |
String
字符串可以使用双引号(”)、单引号(’)或反引号(`)表示
1 | let firstName = 'John' |
字符串是不可变的,意思是一旦创建,它们的值就不能变了
1 | let lang = 'Java' |
Null
Null
类型同样只有一个值,即特殊值 null
逻辑上讲, null
值表示一个空对象指针,这也是给typeof
传一个 null
会返回 "object"
的原因
1 | let car = null |
undefined
值是由null
值派生而来
1 | console.log(null == undefined) // true |
只要变量要保存对象,而当时又没有那个对象可保存,就可用 null 来填充该变量
Boolean
Boolean
(布尔值)类型有两个字面值: true
和false
通过Boolean
可以将其他类型的数据转化成布尔值
规则如下:
1 | 如下: |
Symbol
Symbol (符号)是原始值,且符号实例是唯一、不可变的。符号的用途是确保对象属性使用唯一标识符,不会发生属性冲突的危险
1 | let genericSymbol = Symbol() |
2.引用类型
复杂类型统称为Object
,我们这里主要讲述下面三种:
Object
创建object
常用方式为对象字面量表示法,属性名可以是字符串或数值
1 | let person = { |
Array
JavaScript
数组是一组有序的数据,但跟其他语言不同的是,数组中每个槽位可以存储任意类型的数据。并且,数组也是动态大小的,会随着数据添加而自动增长
1 | let colors = ['red', 2, { age: 20 }] |
Function
函数实际上是对象,每个函数都是 Function 类型的实例,而 Function 也有属性和方法,跟其他引用类型一样
函数存在三种常见的表达方式:
1 | // 函数声明 |
1 | // 函数声明 |
函数声明和函数表达式两种方式
1 | // 函数声明 |
除了上述说的三种之外,还包括 Date、RegExp、Map、Set 。
3.存储区别
基本数据类型和引用数据类型存储在内存中的位置不同:
当我们把变量赋值给一个变量时,解析器首先要确认的就是这个值是基本类型值还是引用类型值
基本类型
1 | let a = 10 |
a 的值为一个基本类型,是存储在栈中,将 a 的值赋给 b,虽然两个变量的值相等,但是两个变量保存了两个不同的内存地址
下图演示了基本类型赋值的过程:
引用类型
1 | var obj1 = {} |
引用类型数据存放在堆中,每个堆内存对象都有对应的引用地址指向它,引用地址存放在栈中。obj1
是一个引用类型,在赋值操作过程汇总,实际是将堆内存对象在栈内存的引用地址复制了一份给了 obj2
,实际上他们共同指向了同一个堆内存对象,所以更改 obj2
会对 obj1
产生影响
下图演示这个引用类型赋值过程
小结
声明变量时不同的内存地址分配:
不同的数据类型导致赋值变量时的不同
数组的常用方法有哪些
1.操作方法
数组基本操作可以归纳为 增、删、改、查,需要留意的是哪些方法会对原数组产生影响,哪些方法不会
增
下面前三种是对原数组产生影响的增添方法,第四种则不会对原数组产生影响
push()
push()
方法接收任意数量的参数,并将它们添加到数组末尾,返回数组的最新长度
1 | let colors = [] // 创建一个数组 |
unshift()
unshift()
在数组开头添加任意多个值,然后返回新的数组长度
1 | let colors = new Array() // 创建一个数组 |
splice()
传入三个参数,分别是开始位置、0(要删除的元素数量)、插入的元素,返回空数组
1 | let colors = ['red', 'green', 'blue'] |
concat()
首先会创建一个当前数组的副本,然后再把它的参数添加到副本末尾,最后返回这个新构建的数组,不会影响原始数组
1 | let colors = ['red', 'green', 'blue'] |
删
下面三种都会影响原数组,最后一项不影响原数组:
pop()
pop()
方法用于删除数组的最后一项,同时减少数组的 length 值,返回被删除的项
1 | let colors = ['red', 'green'] |
shift()
shift()
方法用于删除数组的第一项,同时减少数组的 length 值,返回被删除的项
1 | let colors = ['red', 'green'] |
splice()
传入两个参数,分别是开始位置,删除元素的数量,返回包含删除元素的数组
1 | let colors = ['red', 'green', 'blue'] |
slice()
slice()
用于创建一个包含原有数组中一个或多个元素的新数组,不会影响原始数组
1 | let colors = ['red', 'green', 'blue', 'yellow', 'purple'] |
改
即修改原来数组的内容,常用splice
splice()
传入三个参数,分别是开始位置,要删除元素的数量,要插入的任意多个元素,返回删除元素的数组,对原数组产生影响
1 | let colors = ['red', 'green', 'blue'] |
查
即查找元素,返回元素坐标或者元素值
indexOf()
返回要查找的元素在数组中的位置,如果没找到则返回 -1
1 | let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1] |
includes()
返回要查找的元素在数组中的位置,找到返回 true,否则 false
1 | let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1] |
find()
返回第一个匹配的元素
1 | const people = [ |
2.排序方法
数组有两个方法可以用来对元素重新排序:
reverse()
顾名思义,将数组元素方向反转
1 | let values = [1, 2, 3, 4, 5] |
sort()
sort()
方法接受一个比较函数,用于判断哪个值应该排在前面
1 | function compare(value1, value2) { |
3.转换方法
常见的转换方法有:
join()
join() 方法接收一个参数,即字符串分隔符,返回包含所有项的字符串
1 | let colors = ['red', 'green', 'blue'] |
4.迭代方法
常用来迭代数组的方法(都不改变原数组)有如下:
some()
对数组每一项都运行传入的测试函数,如果至少有 1 个元素返回 true ,则这个方法返回 true
1 | let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1] |
every()
对数组每一项都运行传入的测试函数,如果所有元素都返回 true ,则这个方法返回 true
1 | let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1] |
forEach()
对数组每一项都运行传入的函数,没有返回值
1 | let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1] |
filter()
对数组每一项都运行传入的函数,函数返回 true 的项会组成数组之后返回
1 | let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1] |
map()
对数组每一项都运行传入的函数,返回由每次函数调用的结果构成的数组
1 | let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1] |
JavaScript 中的类型转换机制
1.概述
前面我们讲到,JS 中有六种简单数据类型:undefined
、null
、boolean
、string
、number
、symbol
,以及引用类型:object
但是我们在声明的时候只有一种数据类型,只有到运行期间才会确定当前类型
1 | let x = y ? 1 : a |
上面代码中,x
的值在编译阶段是无法获取的,只有等到程序运行时才能知道
虽然变量的数据类型是不确定的,但是各种运算符对数据类型是有要求的,如果运算子的类型与预期不符合,就会触发类型转换机制
常见的类型转换有:
2.显示转换
显示转换,即我们很清楚可以看到这里发生了类型的转变,常见的方法有:
Number()
将任意类型的值转化为数值
先给出类型转换规则:
实践一下:
1 | Number(324) // 324 |
从上面可以看到,Number 转换的时候是很严格的,只要有一个字符无法转成数值,整个字符串就会被转为 NaN
parseInt()
parseInt
相比Number
,就没那么严格了,parseInt
函数逐个解析字符,遇到不能转换的字符就停下来
1 | parseInt('32a3') //32 |
String()
可以将任意类型的值转化成字符串,如图所示
实践一下:
1 | // 数值:转为相应的字符串 |
Boolean()
可以将任意类型的值转为布尔值,转换规则如下:
实践一下:
1 | Boolean(undefined) // false |
3.隐式转换
在隐式转换中,我们可能最大的疑惑是 :何时发生隐式转换?
我们这里可以归纳为两种情况发生隐式转换的场景:
除了上面的场景,还要求运算符两边的操作数不是同一类型
自动转换为布尔值
在需要布尔值的地方,就会将非布尔值的参数自动转为布尔值,系统内部会调用Boolean
函数
可以得出小结:
除了上面几种会被转化成false
,其他都换被转化成true
自动转换为字符串
遇到预期为字符串的地方,就会将非字符串的值自动转为字符串
具体规则是:先将复合类型的值转为原始类型的值,再将原始类型的值转为字符串
常发生在+运算中,一旦存在字符串,则会进行字符串拼接操作
1 | '5' + 1 // '51' |
自动转换为数值
除了+
有可能把运算子转为字符串,其他运算符都会把运算子自动转成数值
1 | '5' - '2' // 3 |
null
转为数值时,值为0
。undefined
转为数值时,值为NaN
深拷贝和浅拷贝的区别?如何实现?
1.数据类型存储
前面文章我们讲到,JavaScript
中存在两大数据类型:1. 基本类型
2. 引用类型
基本类型数据保存在在栈内存中
引用类型数据保存在堆内存中,引用数据类型的变量是一个指向堆内存中实际对象的引用,存在栈中
2.浅拷贝
浅拷贝,指的是创建新的数据,这个数据有着原始数据属性值的一份精确拷贝
如果属性是基本类型,拷贝的就是基本类型的值。如果属性是引用类型,拷贝的就是内存地址
即浅拷贝是拷贝一层,深层次的引用类型则共享内存地址
下面简单实现一个浅拷贝
1 | function shallowClone(obj) { |
在JavaScript
中,存在浅拷贝的现象有:
Object.assign
1 | var obj = { |
slice()
1 | const fxArr = ['One', 'Two', 'Three'] |
concat()
1 | const fxArr = ['One', 'Two', 'Three'] |
拓展运算符
1 | const fxArr = ['One', 'Two', 'Three'] |
3.深拷贝
深拷贝开辟一个新的栈,两个对象属完成相同,但是对应两个不同的地址,修改一个对象的属性,不会改变另一个对象的属性
常见的深拷贝方式有:
_.cloneDeep()
1 | const _ = require('lodash') |
jQuery.extend()
1 | const $ = require('jquery') |
JSON.stringify()
1 | const obj2 = JSON.parse(JSON.stringify(obj1)) |
但是这种方式会存在弊端,会忽略undefined
,symbol
,函数
1 | const obj = { |
循环递归
1 | function deepClone(obj, hash = new WeakMap()) { |
4.区别
下面首先借助两张图,可以更加清晰看到浅拷贝与深拷贝的区别
从上图发现,浅拷贝和深拷贝都创建出一个新的对象,但在复制对象属性的时候,行为就不一样
浅拷贝只复制属性指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存,修改对象属性会影响原对象
1 | // 浅拷贝 |
但深拷贝会另外创造一个一模一样的对象,新对象和原对象不共享内存,修改新对象不会改变原对象
1 | // 深拷贝 |
小结
前提为拷贝类型为引用类型的情况下:
对闭包的理解,使用场景
1.是什么
一个函数对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)
也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域
在JavaScript
中,每当创建一个函数,闭包就会在函数创建的同时被创建出来,作为函数内部与外部连接起来的一座桥梁
下面给出一个简单的例子
1 | function init() { |
displayName()
没有自己的局部变量。然而,由于闭包的特性,它可以访问到外部函数的变量
2.使用场景
任何闭包的使用场景都离不开这两点:
一般函数的词法环境在函数返回后就被销毁,但是闭包会保存对创建时所在词法环境的引用,即便创建时所在的执行上下文被销毁,但创建时所在词法环境依然存在,以达到延长变量的生命周期的目的
举个栗子:
在页面上添加一些可以调整字号的按钮
1 | function makeSizer(size) { |
柯里化函数
柯里化的目的在于避免频繁调用具有相同参数函数的同时,又能够轻松的重用
1 | // 假设我们有一个求长方形面积的函数 |
使用闭包模拟私有方法
在JavaScript
中,没有支持声明私有变量,但我们可以使用闭包来模拟私有方法
下面举个例子:
1 | var Counter = (function () { |
上述通过使用闭包来定义公共函数,并令其可以访问私有函数和变量,这种方式也叫模块方式
两个计数器 Counter1
和 Counter2
是维护它们各自的独立性的,每次调用其中一个计数器时,通过改变这个变量的值,会改变这个闭包的词法环境,不会影响另一个闭包中的变量
其他
例如计数器、延迟调用、回调等闭包的应用,其核心思想还是创建私有变量和延长变量的生命周期
3.注意事项
如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响
例如,在创建新的对象或者类时,方法通常应该关联于对象的原型,而不是定义到对象的构造器中。
原因在于每个对象的创建,方法都会被重新赋值
1 | function MyObject(name, message) { |
上面的代码中,我们并没有利用到闭包的好处,因此可以避免使用闭包。修改成如下:
1 | function MyObject(name, message) { |
对作用域链的理解
1.作用域
作用域,即变量(变量作用域又称上下文)和函数生效(能被访问)的区域或集合
换句话说,作用域决定了代码区块中变量和其他资源的可见性
举个例子:
1 | function myFunction() { |
上述例子中,函数 myFunction 内部创建一个inVariable
变量,当我们在全局访问这个变量的时候,系统会报错
这就说明我们在全局是无法获取到(闭包除外)函数内部的变量
我们一般将作用域分成:
全局作用域
任何不在函数中或是大括号中声明的变量,都是在全局作用域下,全局作用域下声明的变量可以在程序的任意位置访问
1 | // 全局变量 |
函数作用域
函数作用域也叫局部作用域,如果一个变量是在函数内部声明的它就在一个函数作用域下面。这些变量只能在函数内部访问,不能在函数以外去访问
1 | function greet() { |
可见上述代码中在函数内部声明的变量或函数,在函数外部是无法访问的,这说明在函数内部定义的变量或者方法只是函数作用域
块级作用域
ES6 引入了 let 和 const 关键字,和 var 关键字不同,在大括号中使用 let 和 const 声明的变量存在于块级作用域中。在大括号之外不能访问这些变量
1 | { |
2.词法作用域
词法作用域,又叫静态作用域,变量被创建时就确定好了,而非执行阶段确定的。也就是说我们写好代码时它的作用域就确定了,JavaScript 遵循的就是词法作用域
1 | var a = 2 |
上述代码变成一张图
由于 JavaScript
遵循词法作用域,相同层级的 foo 和 bar 就没有办法访问到彼此块作用域中的变量,所以输出 2
3.作用域链
当在 Javascript 中使用一个变量的时候,首先 Javascript 引擎会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域
如果在全局作用域里仍然找不到该变量,它就会在全局范围内隐式声明该变量(非严格模式下)或是直接报错
这里拿《你不知道的 Javascript(上)》中的一张图解释:
把作用域比喻成一个建筑,这份建筑代表程序中的嵌套作用域链,第一层代表当前的执行作用域,顶层代表全局作用域
变量的引用会顺着当前楼层进行查找,如果找不到,则会往上一层找,一旦到达顶层,查找的过程都会停止
下面代码演示下:
1 | var sex = '男' |
上述代码主要主要做了以下工作:
JavaScript 原型,原型链,有什么特点
1.原型
JavaScript
常被描述为一种基于原型的语言——每个对象拥有一个原型对象
当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象的原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾
准确地说,这些属性和方法定义在 Object 的构造器函数(constructor functions)之上的 prototype 属性上,而非实例对象本身
下面举个例子: 函数可以有属性。 每个函数都有一个特殊的属性叫作原型 prototype
1 | function doSomething() {} |
控制台输出:
1 | { |
上面这个对象,就是大家常说的原型对象
可以看到,原型对象有一个自有属性constructor
,这个属性指向该函数,如下图关系展示
2.原型链
原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链 (prototype chain),它解释了为何一个对象会拥有定义在其他对象中的属性和方法
在对象实例和它的构造器之间建立一个链接(它是proto属性,是从构造函数的 prototype 属性派生的),之后通过上溯原型链,在构造器中找到这些属性和方法
下面举个例子:
1 | function Person(name) { |
根据代码,我们可以得到下图
下面分析一下:
3.总结
下面首先要看几个概念:
__proto__
作为不同对象之间的桥梁,用来指向创建它的构造函数的原型对象的
每个对象的__proto__
都是指向它的构造函数的原型对象prototype
的
1 | person1.__proto__ === Person.prototype |
构造函数是一个函数对象,是通过 Function
构造器产生的
1 | Person.__proto__ === Function.prototype |
原型对象本身是一个普通对象,而普通对象的构造函数都是Object
1 | Person.prototype.__proto__ === Object.prototype |
刚刚上面说了,所有的构造器都是函数对象,函数对象都是 Function
构造产生的
1 | Object.__proto__ === Function.prototype |
Object
的原型对象也有__proto__
属性指向null
,null
是原型链的顶端
1 | Object.prototype.__proto__ === null |
下面做出总结:
JavaScript 怎么实现继承
1.是什么
继承(inheritance)是面向对象软件技术当中的一个概念。
如果一个类别 B“继承自”另一个类别 A,就把这个 B 称为“A 的子类”,而把 A 称为“B 的父类别”也可以称“A 是 B 的超类”
继承的优点:
虽然JavaScript
并不是真正的面向对象语言,但它天生的灵活性,使应用场景更加丰富
关于继承,我们举个形象的例子:
定义一个类(Class)叫汽车,汽车的属性包括颜色、轮胎、品牌、速度、排气量等
1 | class Car { |
由汽车这个类可以派生出“轿车”和“货车”两个类,在汽车的基础属性上,为轿车添加一个后备厢、给货车添加一个大货箱
1 | // 货车 |
这样轿车和货车就是不一样的,但是二者都属于汽车这个类,汽车、轿车继承了汽车的属性,而不需要再次在“轿车”中定义汽车已经有的属性
在“轿车”继承“汽车”的同时,也可以重新定义汽车的某些属性,并重写或覆盖某些属性和方法,使其获得与“汽车”这个父类不同的属性和方法
1 | class Truck extends Car { |
从这个例子中就能详细说明汽车、轿车以及卡车之间的继承关系
2.实现方式
下面给出JavaScripy
常见的继承方式:
原型链继承
原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针
1 | function Parent() { |
上面代码看似没问题,实际存在潜在问题
1 | var s1 = new Child2() |
改变 s1 的 play 属性,会发现 s2 也跟着发生变化了,这是因为两个实例使用的是同一个原型对象,内存空间是共享的
构造函数继承
借助 call
调用Parent
函数
1 | function Parent() { |
可以看到,父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法
相比第一种原型链继承方式,父类的引用属性不会被共享,优化了第一种继承方式的弊端,但是只能继承父类的实例属性和方法,不能继承原型属性或者方法
组合继承
前面我们讲到两种继承方式,各有优缺点。组合继承则将前两种方式继承起来
1 | function Parent3() { |
这种方式看起来就没什么问题,方式一和方式二的问题都解决了,但是从上面代码我们也可以看到Parent3
执行了两次,造成了多构造一次的性能开销
原型式继承
这里主要借助Object.create
方法实现普通对象的继承
1 | let parent4 = { |
这种继承方式的缺点也很明显,因为Object.create
方法实现的是浅拷贝,多个实例的引用类型属性指向相同的内存,存在篡改的可能
寄生式继承
寄生式继承在上面继承基础上进行优化,利用这个浅拷贝的能力再进行增强,添加一些方法
1 | let parent5 = { |
其优缺点也很明显,跟上面讲的原型式继承一样
寄生组合式继承
寄生组合式继承,借助解决普通对象的继承问题的Object.create
方法,在前面几种继承方式的优缺点基础上进行改造,这也是所有继承方式里面相对最优的继承方式
1 | function clone(parent, child) { |
可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题
文章一开头,我们是使用 ES6 中的extends
关键字直接实现 JavaScript
的继承
1 | class Person { |
利用babel
工具进行转换,我们会发现 extends`实际采用的也是寄生组合继承方式,因此也证明了这种方式是较优的解决继承的方式
3.总结
下面一张图
通过
Object.create
来划分不同的继承方式,最后的寄生式组合继承方式是通过组合继承改造之后的最优继承方式,而 extends 的语法糖和寄生组合继承的方式基本类似
谈谈 this 对象的理解
1.定义
函数的 this
关键字在 JavaScript
中的表现略有不同,此外,在严格模式和非严格模式之间也会有一些差别
在绝大多数情况下,函数的调用方式决定了 this
的值(运行时绑定)
this
关键字是函数运行时自动生成的一个内部对象,只能在函数内部使用,总指向调用它的对象
1 | function baz() { |
同时,this
在函数执行过程中,this
一旦被确定了,就不可以再更改
1 | var a = 10; |
2.绑定规则
根据不同的使用场合,this 有不同的值,主要分为下面几种情况:
3.箭头函数
在 ES6 的语法中还提供了箭头函语法,让我们在代码书写时就能确定 this 的指向(编译时绑定)
举个例子:
1 | const obj = { |
虽然箭头函数的 this 能够在编译的时候就确定了 this 的指向,但也需要注意一些潜在的坑
下面举个例子:
绑定事件监听
1 | const button = document.getElementById('mngb') |
上述可以看到,我们其实是想要this
为点击的button
,但此时this
指向了window
包括在原型上添加方法时候,此时this
指向window
1 | Cat.prototype.sayName = () => { |
同样的,箭头函数不能作为构建函数
4.优先级
隐式绑定 VS 显示绑定
1 | function foo() { |
显然,显示绑定的优先级更高
new 绑定 VS 隐式绑定
1 | function foo(something) { |
可以看到,new 绑定的优先级>隐式绑定
new 绑定 VS 显式绑定
因为new
和apply
、call
无法一起使用,但硬绑定也是显式绑定的一种,可以替换测试
1 | function foo(something) { |
bar
被绑定到 obj1 上,但是new bar(3)
并没有像我们预计的那样把obj1.a
修改为 3。但是,new
修改了绑定调用bar()
中的this
我们可认为new
绑定优先级>显式绑定
综上,new 绑定优先级 > 显示绑定优先级 > 隐式绑定优先级 > 默认绑定优先级
JavaScript 中执行上下文和执行栈
JavaScript 事件模型
1.事件与事件流
javascript
中的事件,可以理解就是在 HTML 文档或者浏览器中发生的一种交互操作,使得网页具备互动性, 常见的有加载事件、鼠标事件、自定义事件等
由于 DOM 是一个树结构,如果在父子节点绑定事件时候,当触发子节点的时候,就存在一个顺序问题,这就涉及到了事件流的概念
事件流都会经历三个阶段:
事件冒泡是一种从下往上的传播方式,由最具体的元素(触发节点)然后逐渐向上传播到最不具体的那个节点,也就是 DOM 中最高层的父节点
1 |
|
然后,我们给button
和它的父元素,加入点击事件
1 | var button = document.getElementById('clickMe') |
点击按钮输出如下:
1 | 1.button |
点击事件首先在button
元素上发生,然后逐级向上传播
事件捕获与事件冒泡相反,事件最开始由不太具体的节点最早接受事件, 而最具体的节点(触发节点)最后接受事件
2.事件模型
事件模型可以分为三种:
原始事件模型
事件绑定监听函数比较简单,有两种方式:
HTML 代码直接绑定
1 | <input type="button" onclick="fun()"> |
通过 JS 代码绑定
1 | var btn = document.getElementById('.btn') |
特性
1 | <input type="button" id="btn" onclick="fun1()"> |
如上,当希望为同一个元素绑定多个同类型事件的时候(上面的这个 btn 元素绑定 2 个点击事件),是不被允许的,后绑定的事件会覆盖之前的事件
删除 DOM0 级事件处理程序只要将对应事件属性置为 null 即可
1 | btn.onclick = null |
标准事件模型
在该事件模型中,一次事件共有三个过程:
事件监听函数的方式如下:
1 | addEventListener(eventType, handler, useCapture) |
事件移除监听函数的方式如下:
1 | removeEventListener(eventType, handler, useCapture) |
参数如下:
举个例子
1 | var btn = document.getElementById('.btn'); |
特性
可以在一个 DOM 元素上绑定多个事件处理器,各自并不会冲突
1 | btn.addEventListener(‘click’, showMessage1, false); |
执行时机
当第三个参数(useCapture
)设置为 true 就在捕获过程中执行,反之在冒泡过程中执行处理函数
下面举个例子:
1 | <div id="div"> |
设置点击事件
1 | var div = document.getElementById('div') |
上述使用了eventPhase
,返回一个代表当前执行阶段的整数值。1 为捕获阶段、2 为事件对象触发阶段、3 为冒泡阶段
点击Click Me!
,输出如下
1 | P 3 |
可以看到,p
和div
都是在冒泡阶段响应了事件,由于冒泡的特性,裹在里层的 p 率先做出响应
如果把第三个参数都改为true
1 | div.addEventListener('click', onClickFn, true) |
输出如下
1 | DIV 1 |
两者都是在捕获阶段响应事件,所以 div 比 p 标签先做出响应
typeof 与 instanceof 的区别
1.typeof
typeof
操作符返回一个字符串,表示未经计算的操作数的类型
使用方法如下:
1 | typeof operand |
operand
表示对象或原始值的表达式,其类型将被返回
1 | typeof 1 // 'number' |
从上面例子,前 6 个都是基础数据类型。虽然typeof null
为object
,但这只是JavaScript
存在的一个悠久 Bug,不代表 null 就是引用数据类型,并且 null 本身也不是对象
所以,null
在 typeof
之后返回的是有问题的结果,不能作为判断 null 的方法。如果你需要在 if 语句中判断是否为 null
,直接通过===null
来判断就好
同时,可以发现引用类型数据,用typeof
来判断的话,除了function
会被识别出来之外,其余的都输出object
如果我们想要判断一个变量是否存在,可以使用typeof
:(不能使用 if(a), 若 a 未声明,则报错)
1 | if (typeof a != 'undefined') { |
2.instanceof
instanceof
运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上,使用如下:
1 | object instanceof constructor |
object
为实例对象,constructor
为构造函数
构造函数通过new
可以实例对象,instanceof
能判断这个对象是否是之前那个构造函数生成的对象
1 | // 定义构建函数 |
关于instanceof
的实现原理,可以参考下面:
1 | function myInstanceof(left, right) { |
3.区别
typeof
与instanceof
都是判断数据类型的方法,区别如下:
可以看到,上述两种方法都有弊端,并不能满足所有场景的需求
如果需要通用检测数据类型,可以采用Object.prototype.toString
,调用该方法,统一返回格式“[object Xxx]
”的字符串
如下
1 | Object.prototype.toString({}) // "[object Object]" |
了解了 toString 的基本用法,下面就实现一个全局通用的数据类型判断方法
1 | function getType(obj) { |
使用如下
1 | getType([]) // "Array" typeof []是object,因此toString返回 |
什么是事件代理?应用场景
1.是什么
事件代理,俗地来讲,就是把一个元素响应事件(click
、keydown
……)的函数委托到另一个元素
前面讲到,事件流的都会经过三个阶段: 捕获阶段 -> 目标阶段 -> 冒泡阶段,而事件委托就是在冒泡阶段完成
事件委托,会把一个或者一组元素的事件委托到它的父层或者更外层元素上,真正绑定事件的是外层元素,而不是目标元素
当事件响应到目标元素上时,会通过事件冒泡机制从而触发它的外层元素的绑定事件上,然后在外层元素上去执行函数
下面举个例子:
比如一个宿舍的同学同时快递到了,一种笨方法就是他们一个个去领取
较优方法就是把这件事情委托给宿舍长,让一个人出去拿好所有快递,然后再根据收件人一一分发给每个同学
在这里,取快递就是一个事件,每个同学指的是需要响应事件的 DOM
元素,而出去统一领取快递的宿舍长就是代理的元素
所以真正绑定事件的是这个元素,按照收件人分发快递的过程就是在事件执行中,需要判断当前响应的事件应该匹配到被代理元素中的哪一个或者哪几个
2.应用场景
如果我们有一个列表,列表之中有大量的列表项,我们需要在点击列表项的时候响应一个事件
1 | <ul id="list"> |
如果给每个列表项一一都绑定一个函数,那对于内存消耗是非常大的
1 | // 获取目标元素 |
这时候就可以事件委托,把点击事件绑定在父级元素 ul 上面,然后执行事件的时候再去匹配目标元素
1 | // 给父层元素绑定事件 |
还有一种场景是上述列表项并不多,我们给每个列表项都绑定了事件
但是如果用户能够随时动态的增加或者去除列表项元素,那么在每一次改变的时候都需要重新给新增的元素绑定事件,给即将删去的元素解绑事件
如果用了事件委托就没有这种麻烦了,因为事件是绑定在父层的,和目标元素的增减是没有关系的,执行到目标元素是在真正响应执行事件函数的过程中去匹配的
举个例子:
下面html
结构中,点击input
可以动态添加元素
1 | <input type="button" name="" id="btn" value="添加" /> |
使用事件委托
1 | const oBtn = document.getElementById('btn') |
可以看到,使用事件委托,在动态绑定事件的情况下是可以减少很多重复工作的
3.总结
适合事件委托的事件有:click
,mousedown
,mouseup
,keydown
,keyup
,keypress
从上面应用场景中,我们就可以看到使用事件委托存在两大优点:
但是使用事件委托也是存在局限性:
如果把所有事件都用事件代理,可能会出现事件误判,即本不该被触发的事件被绑定上了事件
new 操作符具体干了什么
1.是什么
在JavaScript
中,new 操作符用于创建一个给定构造函数的实例对象
例子
1 | function Person(name, age) { |
从上面可以看到:
现在在构建函数中显式加上返回值,并且这个返回值是一个原始类型
1 | function Test(name) { |
可以发现,构造函数中返回一个原始值,然而这个返回值并没有作用
下面在构造函数中返回一个对象
1 | function Test(name) { |
从上面可以发现,构造函数如果返回值为一个对象,那么这个返回值会被正常使用
2.流程
从上面介绍中,我们可以看到 new 关键字主要做了以下的工作:
举个例子:
1 | function Person(name, age) { |
流程图如下:
3.手写 new 操作符
现在我们已经清楚地掌握了new
的执行过程
那么我们就动手来实现一下new
1 | function mynew(Func, ...args) { |
测试
1 | function mynew(func, ...args) { |
可以发现 代码虽然短 但是能够模拟实现 new
ajax 原理是什么?如何实现
1.是什么
AJAX
全称(Async Javascript and XML)
即异步的JavaScript
和XML
,是一种创建交互式网页应用的网页开发技术,可以在不重新加载整个网页的情况下,与服务器交换数据,并且更新部分网页
Ajax
的原理简单来说通过XmlHttpRequest
对象来向服务器发异步请求,从服务器获得数据,然后用JavaScript
来操作DOM
而更新页面
流程图如下:
下面举个例子:
领导想找小李汇报一下工作,就委托秘书去叫小李,自己就接着做其他事情,直到秘书告诉他小李已经到了,最后小李跟领导汇报工作
Ajax 请求数据流程与“领导想找小李汇报一下工作”类似,上述秘书就相当于 XMLHttpRequest 对象,领导相当于浏览器,响应数据相当于小李
浏览器可以发送 HTTP 请求后,接着做其他事情,等收到 XHR 返回来的数据再进行操作
2.实现过程
实现 Ajax
异步交互需要服务器逻辑进行配合,需要完成以下步骤:
3.封装
通过上面对XMLHttpRequest
对象的了解,下面来封装一个简单的 ajax 请求
1 | //封装一个ajax请求 |
使用方式如下:
1 | ajax({ |
bind,call,apply 区别
1.作用
call
、apply
、bind
作用是改变函数执行时的上下文,简而言之就是改变函数运行时的 this 指向
那么什么情况下需要改变 this 的指向呢?下面举个例子
1 | var name = 'lucy' |
从上面可以看到,正常情况 say 方法输出 martin
但是我们把 say 放在 setTimeout 方法中,在定时器中是作为回调函数来执行的,因此回到主栈执行时是在全局执行上下文的环境中执行的,这时候 this 指向 window,所以输出 lucy
我们实际需要的是 this 指向 obj 对象,这时候就需要该改变 this 指向了
1 | setTimeout(obj.say.bind(obj), 0) //martin,this指向obj对象 |
区别
下面再来看看apply
、call
、bind
的使用
apply
apply
接受两个参数,第一个参数是this
的指向,第二个参数是函数接受的参数,以数组的形式传入
改变this
指向后原函数会立即执行,且此方法只是临时改变this
指向一次
1 | function fn(...args) { |
当第一个参数为null
、undefined
的时候,默认指向window
(在浏览器中)
1 | fn.apply(null, [1, 2]) // this指向window |
call
call
方法的第一个参数也是this
的指向,后面传入的是一个参数列表
跟 apply 一样,改变this
指向后原函数会立即执行,且此方法只是临时改变this
指向一次
1 | function fn(...args) { |
同样的,当第一个参数为null
、undefined
的时候,默认指向window
(在浏览器中)
1 | fn.call(null, [1, 2]) // this指向window |
bind
bind 方法和 call 很相似,第一参数也是this
的指向,后面传入的也是一个参数列表(但是这个参数列表可以分多次传入)
改变this
指向后不会立即执行,而是返回一个永久改变this
指向的函数
1 | function fn(...args) { |
小结
从上面可以看到,apply
、call
、bind
三者的区别在于:
3.实现
实现bind
的步骤,我们可以分解成为三部分:
1 | // 方式一:只在bind中传递函数参数 |
整体实现代码如下:
1 | Function.prototype.myBind = function (context) { |
正则表达式的理解 应用场景
1.是什么
正则表达式是一种用来匹配字符串的强有力的武器
它的设计思想是用一种描述性的语言定义一个规则,凡是符合规则的字符串,我们就认为它“匹配”了,否则,该字符串就是不合法的
在 JavaScript
中,正则表达式也是对象,构建正则表达式有两种方式:
- 字面量创建,其由包含在斜杠之间的模式组成
1 | const re = /\d+/g |
- 调用 RegExp 对象的构造函数
1 | const re = new RegExp('\\d+', 'g') |
使用构建函数创建,第一个参数可以是一个变量,遇到特殊字符\需要使用\进行转义
2.匹配规则
常见的匹配规则如下:
规则 | 描述 |
---|---|
\ | 转义 |
^ | 匹配输入的开始 |
$ | 匹配输入的结束 |
* | 匹配前一个表达式 0 次或多次 |
+ | 匹配前面一个表达式 1 次或者多次。等价于 {1,} |
? | 匹配前面一个表达式 0 次或者 1 次。等价于{0,1} |
. | 默认匹配除换行符之外的任何单个字符 |
… | … |
对事件循环的理解
1.是什么
首先,JavaScript
是一门单线程的语言,意味着同一时间内只能做一件事,但是这并不意味着单线程就是阻塞,而实现单线程非阻塞的方法就是事件循环
在JavaScript
中,所有的任务都可以分为
同步任务与异步任务的运行流程图如下:
从上面我们可以看到,同步任务进入主线程,即主执行栈,异步任务进入任务队列,主线程内的任务执行完毕为空,会去任务队列读取对应的任务,推入主线程执行。上述过程的不断重复就事件循环
2.宏任务与微任务
如果将任务划分为同步任务和异步任务并不是那么的准确,举个例子:
1 | console.log(1) |
如果按照上面流程图来分析代码,我们会得到下面的执行步骤:
所以按照分析,它的结果应该是 1 => 'new Promise' => 3 => 2 => 'then'
但是实际结果是:1=>'new Promise'=> 3 => 'then' => 2
出现分歧的原因在于异步任务执行顺序,事件队列其实是一个“先进先出”
的数据结构,排在前面的事件会优先被主线程读取
例子中 setTimeout
回调事件是先进入队列中的,按理说应该先于 .then 中的执行,但是结果却偏偏相反
原因在于异步任务还可以细分为宏任务
与微任务
宏任务
宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合
常见的宏任务有:
微任务
一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前
常见的微任务有:
这时候,事件循环,那个任务,微任务的关系如图所示:
按照这个流程,它的执行机制是:
回到上面题目
1 | console.log(1) |
流程如下:
1 | // 遇到 console.log(1) ,直接打印 1 |
async 与 await
async
是异步的意思,await 则可以理解为 async wait
。所以可以理解async
就是用来声明一个异步方法,而 await
是用来等待异步方法执行
async
async 函数返回一个 promise 对象,下面两种方法是等效的
1 | function f() { |
await
正常情况下,await
命令后面是一个 Promise
对象,返回该对象的结果。如果不是 Promise
对象,就直接返回对应的值
1 | async function f() { |
不管await
后面跟着的是什么,await
都会阻塞后面的代码
1 | async function fn1() { |
上面的例子中,await
会阻塞下面的代码(即加入微任务队列),先执行 async
外面的同步代码,同步代码执行完,再回到 async 函数中,再执行之前阻塞的代码
所以上述输出结果为:1
,fn2
,3
,2
4.流程分析
通过对上面的了解,我们对 JavaScript 对各种场景的执行顺序有了大致的了解
这里直接上代码:
1 | async function async1() { |
分析过程:
- 执行整段代码,遇到
console.log('script start')
直接打印结果,输出script start
- 遇到定时器了,它是宏任务,先放着不执行
- 遇到
async1()
,执行async1
函数,先打印async1 start
,下面遇到 await 怎么办?先执行async2
,打印async2
,然后阻塞下面代码(即加入微任务列表),跳出去执行同步代码 - 跳到
new Promise
这里,直接执行,打印promise1
,下面遇到.then()
,它是微任务
,放到微任务列表等待执行 - 最后一行直接打印
script end
,现在同步代码执行完了,开始执行微任务,即await
下面的代码,打印async1 end
- 继续执行下一个微任务,即执行
then
的回调,打印promise2
- 上一个宏任务所有事都做完了,开始下一个宏任务,就是定时器,打印
settimeout
所以最后的结果是:script start
、async1 start
、async2
、promise1
、script end
、async1 end
、promise2
、settimeout
JavaScript 内存泄漏
1.是什么
内存泄漏(Memory leak)是在计算机科学中,由于疏忽或错误造成程序未能释放已经不再使用的内存
并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费
程序的运行需要内存。只要程序提出要求,操作系统或者运行时就必须供给内存
对于持续运行的服务进程,必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃
2.垃圾回收机制
Javascript 具有自动垃圾回收机制(GC:Garbage Collecation),也就是说,执行环境会负责管理代码执行过程中使用的内存
原理:垃圾收集器会定期(周期性)找出那些不在继续使用的变量,然后释放其内存
通常情况下有两种实现方式:
标记清除
JavaScript
最常用的垃圾收回机制
当变量进入执行环境是,就标记这个变量为“进入环境“。进入环境的变量所占用的内存就不能释放,当变量离开环境时,则将其标记为“离开环境“
垃圾回收程序运行的时候,会标记内存中存储的所有变量。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉
在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了
随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存
举个例子
1 | var m = 0, |
引用计数
语言引擎有一张”引用表”,保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是 0,就表示这个值不再用到了,因此可以将这块内存释放
如果一个值不再需要了,引用数却不为 0,垃圾回收机制无法释放这块内存,从而导致内存泄漏
1 | const arr = [1, 2, 3, 4] |
上面代码中,数组[1, 2, 3, 4]是一个值,会占用内存。变量 arr 是仅有的对这个值的引用,因此引用次数为 1。尽管后面的代码没有用到 arr,它还是会持续占用内存
如果需要这块内存被垃圾回收机制释放,只需要设置如下:
1 | arr = null |
通过设置 arr 为 null,就解除了对数组[1,2,3,4]的引用,引用次数变为 0,就被垃圾回收了
小结
有了垃圾回收机制,不代表不用关注内存泄露。那些很占空间的值,一旦不再用到,需要检查是否还存在对它们的引用。如果是的话,就必须手动解除引用
JavaScript 本地存储方式 区别及应用场景
1.方式
javaScript
本地缓存的方法我们主要讲述以下四种:
cookie
Cookie
,类型为「小型文本文件」,指某些网站为了辨别用户身份而储存在用户本地终端上的数据。是为了解决 HTTP 无状态导致的问题
作为一段一般不超过 4KB 的小型文本数据,它由一个名称(Name)、一个值(Value)和其它几个用于控制 cookie``有效期
、安全性
、使用范围
的可选属性组成
但是cookie
在每次请求中都会被发送,如果不使用 HTTPS
并对其加密,其保存的信息很容易被窃取,导致安全风险。举个例子,在一些使用 cookie
保持登录态的网站上,如果 cookie
被窃取,他人很容易利用你的 cookie
来假扮成你登录网站
关于 cookie 常用的属性如下:
Expires 用于设置 Cookie 的过期时间
1 | Expires=Wed, 21 Oct 2015 07:28:00 GMT |
Max-Age 用于设置在 Cookie 失效之前需要经过的秒数(优先级比 Expires 高)
1 | Max-Age=604800 |
Domain
指定了 Cookie
可以送达的主机名Path
指定了一个 URL
路径,这个路径必须出现在要请求的资源的路径中才可以发送 Cookie
首部
1 | Path=/docs # /docs/Web/ 下的资源会带 Cookie 首部 |
标记为 Secure
的 Cookie
只应通过被 HTTPS 协议加密过的请求发送给服务端
通过上述,我们可以看到cookie
又开始的作用并不是为了缓存而设计出来,只是借用了cookie
的特性实现缓存
关于cookie
的使用如下:
1 | document.cookie = '名字=值' |
关于 cookie
的修改,首先要确定 domain
和 path
属性都是相同的才可以,其中有一个不同得时候都会创建出一个新的 cookie
1 | Set-Cookie:name=aa; domain=aa.net; path=/ # 服务端设置 |
最后cookie
的删除,最常用的方法就是给 cookie 设置一个过期的事件,这样 cookie 过期后会被浏览器删除
localStorage
HTML5 新方法,IE8 及以上浏览器都兼容
特点
下面再看看关于localStorage
的使用
设置
1 | localStorage.setItem('username', 'cfangxu') |
获取
1 | localStorage.getItem('username') |
获取键名
1 | localStorage.key(0) //获取第一个键名 |
删除
1 | localStorage.removeItem('username') |
一次性清除所有存储
1 | localStorage.clear() |
localStorage
也不是完美的,它有两个缺点:
1 | localStorage.setItem('key', { name: 'value' }) |
sessionStorage
sessionStorage 和 localStorage 使用方法基本一致,唯一不同的是生命周期,一旦页面(会话)关闭,sessionStorage 将会删除数据
扩展的前端存储方式
indexedDB 是一种低级 API,用于客户端存储大量结构化数据(包括, 文件/ blobs)。该 API 使用索引来实现对该数据的高性能搜索
虽然 Web Storage 对于存储较少量的数据很有用,但对于存储更大量的结构化数据来说,这种方法不太有用。IndexedDB 提供了一个解决方案
优点:
缺点:
2.区别
关于cookie
、sessionStorage
、localStorage
三者的区别主要如下:
3.应用场景
在了解了上述的前端的缓存方式后,我们可以看看针对不对场景的使用选择:
JavaScript 数字精度丢失的问题,如何解决
1.场景复现
一个经典的面试题
1 | 0.1 + 0.2 === 0.3 // false |
为什么是false
呢?
先看下面这个比喻
比如一个数 1÷3=0.33333333……
3 会一直无限循环,数学可以表示,但是计算机要存储,方便下次取出来再使用,但 0.333333…… 这个数无限循环,再大的内存它也存不下,所以不能存储一个相对于数学来说的值,只能存储一个近似值,当计算机存储后再取出时就会出现精度丢失问题
2.浮点数
“浮点数”是一种表示数字的标准,整数也可以用浮点数的格式来存储
我们也可以理解成,浮点数就是小数
在JavaScript
中,现在主流的数值类型是Number
,而Number
采用的是 IEEE754 规范中 64 位双精度浮点数编码
这样的存储结构优点是可以归一化处理整数和小数,节省存储空间
对于一个整数,可以很轻易转化成十进制或者二进制。但是对于一个浮点数来说,因为小数点的存在,小数点的位置不是固定的。解决思路就是使用科学计数法,这样小数点位置就固定了
3.问题分析
1 | 0.1 + 0.2 === 0.3 // false |
通过上面的学习,我们知道,在 javascript 语言中,0.1 和 0.2 都转化成二进制后再进行运算
1 | // 0.1 和 0.2 都转化成二进制后再进行运算 |
所以输出 false
再来一个问题,那么为什么x=0.1
得到0.1
?
主要是存储二进制时小数点的偏移量最大为 52 位,最多可以表达的位数是2^53=9007199254740992
,对应科学计数尾数是 9.007199254740992
,这也是 JS 最多能表示的精度
它的长度是 16,所以可以使用 toPrecision(16)
来做精度运算,超过的精度会自动做凑整处理
1 | ;(0.10000000000000000555).toPrecision(16) |
但看到的 0.1 实际上并不是 0.1。不信你可用更高的精度试试:
1 | 0.1.toPrecision(21) = 0.100000000000000005551 |
小结
计算机存储双精度浮点数需要先把十进制数转换为二进制的科学记数法的形式,然后计算机以自己的规则{符号位+(指数位+指数偏移量的二进制)+小数部分}存储二进制的科学记数法
因为存储时有位数限制(64 位),并且某些十进制的浮点数在转换为二进制数时会出现无限循环,会造成二进制的舍入操作(0 舍 1 入),当再转换为十进制时就造成了计算误差
4.解决方案
理论上用有限的空间来存储无限的小数是不可能保证精确的,但我们可以处理一下得到我们期望的结果
当你拿到 1.4000000000000001 这样的数据要展示时,建议使用 toPrecision 凑整并 parseFloat 转成数字后再显示,如下:
1 | parseFloat((1.4000000000000001).toPrecision(12)) === 1.4 // True |
封装成方法就是:
1 | function strip(num, precision = 12) { |
对于运算类操作,如 +-*/,就不能使用 toPrecision 了。正确的做法是把小数转成整数后再运算。以加法为例:
1 | /** |
最后还可以使用第三方库,如Math.js
、BigDecimal.js
什么是防抖和节流
1.是什么
本质上是优化高频率执行代码的一种手段
如:浏览器的resize
、scroll
、keypress
、mousemove
等事件在触发时,会不断地调用绑定在事件上的回调函数,极大地浪费资源,降低前端性能
为了优化体验,需要对这类事件进行调用次数的限制,对此我们就可以采用 防抖(debounce) 和 节流(throttle) 的方式来减少调用频率
定义
一个经典的比喻:
想象每天上班大厦底下的电梯。把电梯完成一次运送,类比为一次函数的执行和响应
假设电梯有两种运行策略 debounce 和 throttle,超时设定为 15 秒,不考虑容量限制
电梯第一个人进来后,15 秒后准时运送一次,这是节流
电梯第一个人进来后,等待 15 秒。如果过程中又有人进来,15 秒等待重新计时,直到 15 秒后开始运送,这是防抖
2.代码实现
节流
完成节流可以使用时间戳与定时器的写法
使用时间戳写法,事件会立即执行,停止触发后没有办法再次执行
1 | function throttled1(fn, delay = 500) { |
使用定时器写法,delay
毫秒后第一次执行,第二次事件停止触发后依然会再一次执行
1 | function throttled2(fn, delay = 500) { |
可以将时间戳写法的特性与定时器写法的特性相结合,实现一个更加精确的节流。实现如下
1 | function throttled(fn, delay) { |
防抖
简单版本的实现
1 | function debounce(func, wait) { |
防抖如果需要立即执行,可加入第三个参数用于判断,实现如下:
1 | function debounce(func, wait, immediate) { |
3.区别
相同点:
不同点:
例如,都设置时间频率为 500ms,在 2 秒时间内,频繁触发函数,节流,每隔 500ms 就执行一次。防抖,则不管调动多少次方法,在 2s 后,只会执行一次
如下图所示
4.应用场景
防抖在连续的事件,只需触发一次回调的场景有:
节流在间隔一段时间执行一次回调的场景有:
如何判断一个元素是否在可视区域内
1.用途
可视区域即我们浏览网页的设备肉眼可见的区域,如下图
在日常开发中,我们经常需要判断目标元素是否在视窗之内或者和视窗的距离小于一个值(例如 100 px),从而实现一些常用的功能,例如
2.实现方式
判断一个元素是否在可视区域,我们常用的有三种办法:
offsetTop、scrollTop
offsetTop
,元素的上外边框至包含元素的上内边框之间的像素距离,其他 offset 属性如下图所示:
下面再来了解下clientWidth
、clientHeight
:
这里可以看到 client 元素都不包括外边距
最后,关于 scroll 系列的属性如下:
注意
下面再看看如何实现判断:
公式如下:
1 | el.offsetTop - document.documentElement.scrollTop <= viewPortHeight |
代码实现:
1 | function isInViewPortOfOne(el) { |
getBoundingClientRect
返回值是一个 DOMRect 对象,拥有 left, top, right, bottom, x, y, width, 和 height 属性
1 | const target = document.querySelector('.target') |
属性对应的关系图如下所示 :
当页面发生滚动的时候,top
与left
属性值都会随之改变
如果一个元素在视窗之内的话,那么它一定满足下面四个条件:
实现代码如下:
1 | function isInViewPort(element) { |
Intersection Observer
Intersection Observer
即重叠观察者,从这个命名就可以看出它用于判断两个元素是否重叠,因为不用进行事件的监听,性能方面相比 getBoundingClientRect 会好很多
使用步骤主要分为两步:创建观察者和传入被观察者
创建观察者
1 | const options = { |
通过new IntersectionObserver
创建了观察者 observer
,传入的参数 callback
在重叠比例超过 threshold
时会被执行`
关于callback
回调函数常用属性如下:
1 | // 上段代码中被省略的 callback |
传入被观察者
通过 observer.observe(target)
这一行代码即可简单的注册被观察者
1 | const target = document.querySelector('.target') |
3.案例分析
实现:创建了一个十万个节点的长列表,当节点滚入到视窗中时,背景就会从红色变为黄色
Html
结构如下:
1 | <div class="container"></div> |
css
样式如下:
1 | .container { |
往container
插入 1000 个元素
1 | const $container = $('.container') |
这里,首先使用getBoundingClientRect
方法进行判断元素是否在可视区域
1 | function isInViewPort(element) { |
然后开始监听scroll
事件,判断页面上哪些元素在可视区域中,如果在可视区域中则将背景颜色设置为yellow
1 | $(window).on('scroll', () => { |
通过上述方式,可以看到可视区域颜色会变成黄色了,但是可以明显看到有卡顿的现象,原因在于我们绑定了scroll
事件,scroll
事件伴随了大量的计算,会造成资源方面的浪费
下面通过Intersection Observer
的形式同样实现相同的功能
首先创建一个观察者
1 | const observer = new IntersectionObserver(getYellow, { threshold: 1.0 }) |
getYellow
回调函数实现对背景颜色改变,如下:
1 | function getYellow(entries, observer) { |
最后传入观察者,即.target
元素
1 | $targets.each((index, element) => { |
大文件如何做断点续传
1.是什么
不管怎样简单的需求,在量级达到一定层次时,都会变得异常复杂
文件上传简单,文件变大就复杂
上传大文件时,以下几个变量会影响我们的用户体验
上传时间会变长,高频次文件上传失败,失败后又需要重新上传等等
为了解决上述问题,我们需要对大文件上传单独处理
这里涉及到分片上传及断点续传两个概念
分片上传
分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(Part)来进行分片上传
如下图
上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件
大致流程如下:
- 将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;
- 初始化一个分片上传任务,返回本次分片上传唯一标识;
- 按照一定的策略(串行或并行)发送各个分片数据块;
- 发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件
断点续传
断点续传指的是在下载或上传时,将下载或上传任务人为的划分为几个部分
每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载。用户可以节省时间,提高速度
一般实现方式有两种
上传过程中将文件在服务器写为临时文件,等全部写完了(文件上传完),将此临时文件重命名为正式文件即可
如果中途上传中断过,下次上传的时候根据当前临时文件大小,作为在客户端读取文件的偏移量,从此位置继续读取文件数据块,上传到服务器从此偏移量继续写入文件即可
3.使用场景
如何实现上拉加载,下拉刷新
1.前言
下拉刷新和上拉加载这两种交互方式通常出现在移动端中
本质上等同于 PC 网页中的分页,只是交互形式不同
开源社区也有很多优秀的解决方案,如 iscroll、better-scroll、pulltorefresh.js 库等等
这些第三方库使用起来非常便捷
我们通过原生的方式实现一次上拉加载,下拉刷新,有助于对第三方库有更好的理解与使用
2.实现原理
上拉加载及下拉刷新都依赖于用户交互
最重要的是要理解在什么场景,什么时机下触发交互动作
上拉加载
首先可以看一张图
上拉加载的本质是页面触底,或者快要触底时的动作
判断页面触底我们需要先了解一下下面几个属性
综上我们得出一个触底公式:
1 | scrollTop + clientHeight >= scrollHeight |
简单实现
1 | let clientHeight = document.documentElement.clientHeight //浏览器高度 |
下拉刷新
下拉刷新的本质是页面本身置于顶部时,用户下拉时需要触发的动作
关于下拉刷新的原生实现,主要分成三步:
举个例子:html
结构如下
1 | <main> |
监听touchstart
事件,记录初始的值
1 | var _element = document.getElementById('refreshContainer'), |
监听touchmove
移动事件,记录滑动差值
1 | _element.addEventListener( |
最后,就是监听touchend
离开的事件
1 | _element.addEventListener( |
从上面可以看到,在下拉到松手的过程中,经历了三个阶段:
3.案例
在实际开发中,我们更多的是使用第三方库,下面以better-scroll
进行举例:
HTML 结构
1 | <div id="position-wrapper"> |
实例化上拉下拉插件,通过use
来注册插件
1 | import BScroll from '@better-scroll/core' |
实例化BetterScroll
,并传入相关的参数
1 | let pageNo = 1,pageSize = 10,dataList = [],isMore = true; |
注意点:
使用better-scroll
实现下拉刷新、上拉加载时要注意以下几点:
什么是单点登录
1.是什么?
单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一
SSO 的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统
SSO 一般都需要一个独立的认证中心(passport),子系统的登录均得通过 passport,子系统本身将不参与登录操作
当一个系统成功登录以后,passport
将会颁发一个令牌给各个子系统,子系统可以拿着令牌会获取各自的受保护资源,为了减少频繁认证,各个子系统在被passport
授权以后,会建立一个局部会话,在一定时间内可以无需再次向passport
发起认证
上图有四个系统,分别是Application1
、Application2
、Application3
、和SSO
,当Application1
、Application2
、Application3
需要登录时,将跳到SSO
系统,SSO
系统完成登录,其他的应用系统也就随之登录了
淘宝、天猫都属于阿里旗下,当用户登录淘宝后,再打开天猫,系统便自动帮用户登录了天猫,这种现象就属于单点登录
2.如何实现
同域名下的单点登录
cookie
的domain
属性设置为当前域的父域,并且父域的cookie
会被子域所共享。path
属性默认为web
应用的上下文路径
利用 Cookie
的这个特点,没错,我们只需要将Cookie
的domain
属性设置为父域的域名(主域名),同时将Cookie
的path
属性设置为根路径,将 Session ID(或 Token)
保存到父域中。这样所有的子域应用就都可以访问到这个Cookie
不过这要求应用系统的域名需建立在一个共同的主域名之下,如 tieba.baidu.com
和 map.baidu.com
,它们都建立在 baidu.com
这个主域名之下,那么它们就可以通过这种方式来实现单点登录
不同域名下的单点登录(一)
如果是不同域的情况下,Cookie
是不共享的,这里我们可以部署一个认证中心,用于专门处理登录请求的独立的Web
服务
用户统一在认证中心进行登录,登录成功后,认证中心记录用户的登录状态,并将 token
写入 Cookie
(注意这个 Cookie
是认证中心的,应用系统是访问不到的)
应用系统检查当前请求有没有 Token,如果没有,说明用户在当前系统中尚未登录,那么就将页面跳转至认证中心
由于这个操作会将认证中心的 Cookie
自动带过去,因此,认证中心能够根据 Cookie
知道用户是否已经登录过了
如果认证中心发现用户尚未登录,则返回登录页面,等待用户登录
如果发现用户已经登录过了,就不会让用户再次登录了,而是会跳转回目标 URL
,并在跳转前生成一个 Token
,拼接在目标 URL
的后面,回传给目标应用系统
应用系统拿到 Token
之后,还需要向认证中心确认下 Token
的合法性,防止用户伪造。确认无误后,应用系统记录用户的登录状态,并将Token
写入 Cookie
,然后给本次访问放行。(注意这个 Cookie
是当前应用系统的)当用户再次访问当前应用系统时,就会自动带上这个 Token
,应用系统验证 Token
发现用户已登录,于是就不会有认证中心什么事了
此种实现方式相对复杂,支持跨域,扩展性好,是单点登录的标准做法
不同域名下的单点登录(二)
可以选择将 Session ID (或 Token )
保存到浏览器的 LocalStorage
中,让前端在每次向后端发送请求时,主动将LocalStorage
的数据传递给服务端
这些都是由前端来控制的,后端需要做的仅仅是在用户登录成功后,将 Session ID(或 Token)
放在响应体中传递给前端
单点登录完全可以在前端实现。前端拿到 Session ID(或 Token )
后,除了将它写入自己的 LocalStorage
中之外,还可以通过特殊手段将它写入多个其他域下的 LocalStorage
中
1 | // 获取 token |
前端通过 iframe+postMessage()
方式,将同一份Token
写入到了多个域下的 LocalStorage
中,前端每次在向后端发送请求之前,都会主动从 LocalStorage
中读取Token
并在请求中携带,这样就实现了同一份Token
被多个域所共享
此种实现方式完全由前端控制,几乎不需要后端参与,同样支持跨域
3.流程
单点登录的流程图如下所示:
用户访问系统 1 的受保护资源,系统 1 发现用户未登录,跳转至 sso 认证中心,并将自己的地址作为参数
sso 认证中心发现用户未登录,将用户引导至登录页面
用户输入用户名密码提交登录申请
sso 认证中心校验用户信息,创建用户与 sso 认证中心之间的会话,称为全局会话,同时创建授权令牌
sso 认证中心带着令牌跳转会最初的请求地址(系统 1)
系统 1 拿到令牌,去 sso 认证中心校验令牌是否有效
sso 认证中心校验令牌,返回有效,注册系统 1
系统 1 使用该令牌创建与用户的会话,称为局部会话,返回受保护资源
用户访问系统 2 的受保护资源
系统 2 发现用户未登录,跳转至 sso 认证中心,并将自己的地址作为参数
sso 认证中心发现用户已登录,跳转回系统 2 的地址,并附上令牌
系统 2 拿到令牌,去 sso 认证中心校验令牌是否有效
sso 认证中心校验令牌,返回有效,注册系统 2
系统 2 使用该令牌创建与用户的局部会话,返回受保护资源
用户登录成功之后,会与sso
认证中心及各个子系统建立会话,用户与sso
认证中心建立的会话称为全局会话
用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过sso
认证中心
全局会话与局部会话有如下约束关系:
123456789