JavaScript 系列

JavaScript 中的数据类型 存储差别

前言

在 JavaScript 中,我们可以分成两种类型:

  • 基本类型
  • 复杂类型
  • 两种类型的区别是:存储位置不同

    1.基本类型

    基本类型主要为以下六种:

  • Number
  • String
  • Boolean
  • Undefined
  • null
  • symbol
  • Number

    数值最常见的整数类型格式则为十进制,还可以设置八进制(零开头)、十六进制(0x 开头)

    1
    2
    3
    let intNum = 55 // 10进制的55
    let num1 = 070 // 8进制的56
    let hexNum1 = 0xa //16进制的10

    浮点类型则在数值汇总必须包含小数点,还可通过科学计数法表示

    1
    2
    3
    4
    let floatNum1 = 1.1
    let floatNum2 = 0.1
    let floatNum3 = 0.1 // 有效,但不推荐
    let floatNum = 3.125e7 // 等于 31250000

    在数值类型中,存在一个特殊数值 NaN,意为“不是数值”,用于表示本来要返回数值的操作失败了(而不是抛出错误)

    1
    2
    console.log(0 / 0) // NaN
    console.log(-0 / +0) // NaN

    Undefined

    Undefined类型只有一个值,就是特殊值 undefined。当使用 var 或 let 声明了变量但没有初始化时,就相当于给变量赋予了 undefined

    1
    2
    let message
    console.log(message == undefined) // true

    包含undefined 值的变量跟未定义变量是有区别的

    1
    2
    3
    4
    let message // 这个变量被声明了,只是值为 undefined

    console.log(message) // "undefined"
    console.log(age) // 没有声明过这个变量,报错

    String

    字符串可以使用双引号(”)、单引号(’)或反引号(`)表示

    1
    2
    3
    let firstName = 'John'
    let lastName = 'Jacob'
    let lastName = `Jingleheimerschmidt`

    字符串是不可变的,意思是一旦创建,它们的值就不能变了

    1
    2
    let lang = 'Java'
    lang = lang + 'Script' // 先销毁再创

    Null

    Null类型同样只有一个值,即特殊值 null
    逻辑上讲, null 值表示一个空对象指针,这也是给typeof传一个 null 会返回 "object" 的原因

    1
    2
    let car = null
    console.log(typeof car) // "object"

    undefined 值是由null值派生而来

    1
    console.log(null == undefined) // true

    只要变量要保存对象,而当时又没有那个对象可保存,就可用 null 来填充该变量

    Boolean

    Boolean(布尔值)类型有两个字面值: truefalse
    通过Boolean可以将其他类型的数据转化成布尔值
    规则如下:

    1
    2
    3
    4
    5
    6
    如下:
    数据类型 转换为 true 的值 转换为 false 的值
    String 非空字符串 ""
    Number 非零数值(包括无穷值) 0NaN
    Object 任意对象 null
    Undefined N/A (不存在) undefined

    Symbol
    Symbol (符号)是原始值,且符号实例是唯一、不可变的。符号的用途是确保对象属性使用唯一标识符,不会发生属性冲突的危险

    1
    2
    3
    4
    5
    6
    7
    let genericSymbol = Symbol()
    let otherGenericSymbol = Symbol()
    console.log(genericSymbol == otherGenericSymbol) // false

    let fooSymbol = Symbol('foo')
    let otherFooSymbol = Symbol('foo')
    console.log(fooSymbol == otherFooSymbol) // false

    2.引用类型

    复杂类型统称为Object,我们这里主要讲述下面三种:

  • Object
  • Array
  • Function
  • Object

    创建object常用方式为对象字面量表示法,属性名可以是字符串或数值

    1
    2
    3
    4
    5
    let person = {
    name: 'Nicholas',
    age: 29,
    5: true,
    }

    Array

    JavaScript数组是一组有序的数据,但跟其他语言不同的是,数组中每个槽位可以存储任意类型的数据。并且,数组也是动态大小的,会随着数据添加而自动增长

    1
    2
    let colors = ['red', 2, { age: 20 }]
    colors.push(2)

    Function

    函数实际上是对象,每个函数都是 Function 类型的实例,而 Function 也有属性和方法,跟其他引用类型一样
    函数存在三种常见的表达方式:

  • 函数声明
  • 1
    2
    3
    4
    // 函数声明
    function sum(num1, num2) {
    return num1 + num2
    }
  • 函数表达式
  • 1
    2
    3
    4
    // 函数声明
    let sum = function (num1, num2) {
    return num1 + num2
    }
  • 箭头函数
  • 函数声明和函数表达式两种方式

    1
    2
    3
    4
    // 函数声明
    let sum = function (num1, num2) {
    return num1 + num2
    }

    除了上述说的三种之外,还包括 Date、RegExp、Map、Set 。

    3.存储区别

    基本数据类型和引用数据类型存储在内存中的位置不同:

  • 基本数据类型存储在栈中
  • 引用类型的对象存储于堆中
  • 当我们把变量赋值给一个变量时,解析器首先要确认的就是这个值是基本类型值还是引用类型值

    基本类型

    1
    2
    3
    4
    let a = 10
    let b = a // 赋值操作
    b = 20
    console.log(a) // 10值

    a 的值为一个基本类型,是存储在栈中,将 a 的值赋给 b,虽然两个变量的值相等,但是两个变量保存了两个不同的内存地址
    下图演示了基本类型赋值的过程:

    引用类型

    1
    2
    3
    4
    var obj1 = {}
    var obj2 = obj1
    obj2.name = 'Xxx'
    console.log(obj1.name) // xxx

    引用类型数据存放在堆中,每个堆内存对象都有对应的引用地址指向它,引用地址存放在栈中。
    obj1 是一个引用类型,在赋值操作过程汇总,实际是将堆内存对象在栈内存的引用地址复制了一份给了 obj2,实际上他们共同指向了同一个堆内存对象,所以更改 obj2 会对 obj1 产生影响

    下图演示这个引用类型赋值过程

    小结

    声明变量时不同的内存地址分配:

  • 简单类型的值存放在栈中,在栈中存放的是对应的值
  • 引用类型对应的值存储在堆中,在栈中存放的是指向堆内存的地址
  • 不同的数据类型导致赋值变量时的不同

  • 简单类型赋值,是生成相同的值,两个对象对应不同的地址
  • 复杂类型赋值,是将保存对象的内存地址赋值给另一个变量。也就是两个变量指向堆内存中同一个对象
  • 数组的常用方法有哪些

    1.操作方法

    数组基本操作可以归纳为 增、删、改、查,需要留意的是哪些方法会对原数组产生影响,哪些方法不会

    下面前三种是对原数组产生影响的增添方法,第四种则不会对原数组产生影响

  • push()
  • unshift()
  • splice()
  • concat()
  • push()

    push()方法接收任意数量的参数,并将它们添加到数组末尾,返回数组的最新长度

    1
    2
    3
    let colors = [] // 创建一个数组
    let count = colors.push('red', 'green') // 推入两项
    console.log(count) // 2

    unshift()

    unshift()在数组开头添加任意多个值,然后返回新的数组长度

    1
    2
    3
    let colors = new Array() // 创建一个数组
    let count = colors.unshift('red', 'green') // 从数组开头推入两项
    alert(count) // 2

    splice()

    传入三个参数,分别是开始位置、0(要删除的元素数量)、插入的元素,返回空数组

    1
    2
    3
    4
    let colors = ['red', 'green', 'blue']
    let removed = colors.splice(1, 0, 'yellow', 'orange')
    console.log(colors) // red,yellow,orange,green,blue
    console.log(removed) // []

    concat()

    首先会创建一个当前数组的副本,然后再把它的参数添加到副本末尾,最后返回这个新构建的数组,不会影响原始数组

    1
    2
    3
    4
    let colors = ['red', 'green', 'blue']
    let colors2 = colors.concat('yellow', ['black', 'brown'])
    console.log(colors) // ["red", "green","blue"]
    console.log(colors2) // ["red", "green", "blue", "yellow", "black", "brown"]

    下面三种都会影响原数组,最后一项不影响原数组:

  • pop()
  • shift()
  • splice()
  • slice()
  • pop()

    pop() 方法用于删除数组的最后一项,同时减少数组的 length 值,返回被删除的项

    1
    2
    3
    4
    let colors = ['red', 'green']
    let item = colors.pop() // 取得最后一项
    console.log(item) // green
    console.log(colors.length) // 1

    shift()

    shift()方法用于删除数组的第一项,同时减少数组的 length 值,返回被删除的项

    1
    2
    3
    4
    let colors = ['red', 'green']
    let item = colors.shift() // 取得第一项
    console.log(item) // red
    console.log(colors.length) // 1

    splice()

    传入两个参数,分别是开始位置,删除元素的数量,返回包含删除元素的数组

    1
    2
    3
    4
    let colors = ['red', 'green', 'blue']
    let removed = colors.splice(0, 1) // 删除第一项
    console.log(colors) // green,blue
    console.log(removed) // red,只有一个元素的数组

    slice()

    slice() 用于创建一个包含原有数组中一个或多个元素的新数组,不会影响原始数组

    1
    2
    3
    4
    5
    6
    let colors = ['red', 'green', 'blue', 'yellow', 'purple']
    let colors2 = colors.slice(1)
    let colors3 = colors.slice(1, 4)
    console.log(colors) // red,green,blue,yellow,purple
    concole.log(colors2) // green,blue,yellow,purple
    concole.log(colors3) // green,blue,yellow

    即修改原来数组的内容,常用splice

    splice()

    传入三个参数,分别是开始位置,要删除元素的数量,要插入的任意多个元素,返回删除元素的数组,对原数组产生影响

    1
    2
    3
    4
    let colors = ['red', 'green', 'blue']
    let removed = colors.splice(1, 1, 'red', 'purple') // 插入两个值,删除一个元素
    console.log(colors) // red,red,purple,blue
    console.log(removed) // green,只有一个元素的数组

    即查找元素,返回元素坐标或者元素值

  • indexOf()
  • includes()
  • find()
  • indexOf()

    返回要查找的元素在数组中的位置,如果没找到则返回 -1

    1
    2
    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1]
    numbers.indexOf(4) // 3

    includes()

    返回要查找的元素在数组中的位置,找到返回 true,否则 false

    1
    2
    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1]
    numbers.includes(4) // true

    find()

    返回第一个匹配的元素

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const people = [
    {
    name: 'Matt',
    age: 27,
    },
    {
    name: 'Nicholas',
    age: 29,
    },
    ]
    people.find((element, index, array) => element.age < 28) // // {name: "Matt", age: 27}

    2.排序方法

    数组有两个方法可以用来对元素重新排序:

  • reverse()
  • sort()
  • reverse()

    顾名思义,将数组元素方向反转

    1
    2
    3
    let values = [1, 2, 3, 4, 5]
    values.reverse()
    alert(values) // 5,4,3,2,1

    sort()

    sort()方法接受一个比较函数,用于判断哪个值应该排在前面

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function compare(value1, value2) {
    if (value1 < value2) {
    return -1
    } else if (value1 > value2) {
    return 1
    } else {
    return 0
    }
    }
    let values = [0, 1, 5, 10, 15]
    values.sort(compare)
    alert(values) // 0,1,5,10,15

    3.转换方法

    常见的转换方法有:

    join()

    join() 方法接收一个参数,即字符串分隔符,返回包含所有项的字符串

    1
    2
    3
    let colors = ['red', 'green', 'blue']
    alert(colors.join(',')) // red,green,blue
    alert(colors.join('||')) // red||green||blue

    4.迭代方法

    常用来迭代数组的方法(都不改变原数组)有如下:

  • some()
  • every()
  • forEach()
  • filter()
  • map()
  • some()

    对数组每一项都运行传入的测试函数,如果至少有 1 个元素返回 true ,则这个方法返回 true

    1
    2
    3
    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1]
    let someResult = numbers.some((item, index, array) => item > 2)
    console.log(someResult) // true

    every()

    对数组每一项都运行传入的测试函数,如果所有元素都返回 true ,则这个方法返回 true

    1
    2
    3
    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1]
    let everyResult = numbers.every((item, index, array) => item > 2)
    console.log(everyResult) // false

    forEach()

    对数组每一项都运行传入的函数,没有返回值

    1
    2
    3
    4
    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1]
    numbers.forEach((item, index, array) => {
    // 执行某些操作
    })

    filter()

    对数组每一项都运行传入的函数,函数返回 true 的项会组成数组之后返回

    1
    2
    3
    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1]
    let filterResult = numbers.filter((item, index, array) => item > 2)
    console.log(filterResult) // 3,4,5,4,3

    map()

    对数组每一项都运行传入的函数,返回由每次函数调用的结果构成的数组

    1
    2
    3
    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1]
    let mapResult = numbers.map((item, index, array) => item * 2)
    console.log(mapResult) // 2,4,6,8,10,8,6,4,2

    JavaScript 中的类型转换机制

    1.概述

    前面我们讲到,JS 中有六种简单数据类型:undefinednullbooleanstringnumbersymbol,以及引用类型:object
    但是我们在声明的时候只有一种数据类型,只有到运行期间才会确定当前类型

    1
    let x = y ? 1 : a

    上面代码中,x的值在编译阶段是无法获取的,只有等到程序运行时才能知道
    虽然变量的数据类型是不确定的,但是各种运算符对数据类型是有要求的,如果运算子的类型与预期不符合,就会触发类型转换机制

    常见的类型转换有:

  • 强制转换(显示转换)
  • 自动转换(隐式转换)
  • 2.显示转换

    显示转换,即我们很清楚可以看到这里发生了类型的转变,常见的方法有:

  • Number()
  • parseInt()
  • String()
  • Boolean()
  • Number()
    将任意类型的值转化为数值
    先给出类型转换规则:

    实践一下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    Number(324) // 324

    // 字符串:如果可以被解析为数值,则转换为相应的数值
    Number('324') // 324

    // 字符串:如果不可以被解析为数值,返回 NaN
    Number('324abc') // NaN

    // 空字符串转为0
    Number('') // 0

    // 布尔值:true 转成 1,false 转成 0
    Number(true) // 1
    Number(false) // 0

    // undefined:转成 NaN
    Number(undefined) // NaN

    // null:转成0
    Number(null) // 0

    // 对象:通常转换成NaN(除了只包含单个数值的数组)
    Number({ a: 1 }) // NaN
    Number([1, 2, 3]) // NaN
    Number([5]) // 5

    从上面可以看到,Number 转换的时候是很严格的,只要有一个字符无法转成数值,整个字符串就会被转为 NaN

    parseInt()

    parseInt相比Number,就没那么严格了,parseInt函数逐个解析字符,遇到不能转换的字符就停下来

    1
    parseInt('32a3') //32

    String()

    可以将任意类型的值转化成字符串,如图所示

    实践一下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 数值:转为相应的字符串
    String(1) // "1"

    //字符串:转换后还是原来的值
    String('a') // "a"

    //布尔值:true转为字符串"true",false转为字符串"false"
    String(true) // "true"

    //undefined:转为字符串"undefined"
    String(undefined) // "undefined"

    //null:转为字符串"null"
    String(null) // "null"

    //对象
    String({ a: 1 }) // "[object Object]"
    String([1, 2, 3]) // "1,2,3"

    Boolean()

    可以将任意类型的值转为布尔值,转换规则如下:

    实践一下:

    1
    2
    3
    4
    5
    6
    7
    8
    Boolean(undefined) // false
    Boolean(null) // false
    Boolean(0) // false
    Boolean(NaN) // false
    Boolean('') // false
    Boolean({}) // true
    Boolean([]) // true
    Boolean(new Boolean(false)) // true

    3.隐式转换

    在隐式转换中,我们可能最大的疑惑是 :何时发生隐式转换?

    我们这里可以归纳为两种情况发生隐式转换的场景:

  • 比较运算(==、!=、>、<)、if、while需要布尔值地方
  • 算术运算(+、-、*、/、%)
  • 除了上面的场景,还要求运算符两边的操作数不是同一类型

    自动转换为布尔值

    在需要布尔值的地方,就会将非布尔值的参数自动转为布尔值,系统内部会调用Boolean函数

    可以得出小结:

  • undefined
  • null
  • false
  • +0
  • -0
  • NaN
  • ""
  • 除了上面几种会被转化成false,其他都换被转化成true

    自动转换为字符串

    遇到预期为字符串的地方,就会将非字符串的值自动转为字符串

    具体规则是:先将复合类型的值转为原始类型的值,再将原始类型的值转为字符串

    常发生在+运算中,一旦存在字符串,则会进行字符串拼接操作

    1
    2
    3
    4
    5
    6
    7
    8
    '5' + 1 // '51'
    '5' + true // "5true"
    '5' + false // "5false"
    '5' + {} // "5[object Object]"
    '5' + [] // "5"
    '5' + function () {} // "5function (){}"
    '5' + undefined // "5undefined"
    '5' + null // "5null"

    自动转换为数值

    除了+有可能把运算子转为字符串,其他运算符都会把运算子自动转成数值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    '5' - '2' // 3
    '5' * '2' // 10
    true - 1 // 0
    false - 1 // -1
    '1' - 1 // 0
    '5' * [] // 0
    false / '5' // 0
    'abc' - 1 // NaN
    null + 1 // 1
    undefined + 1 // NaN

    null转为数值时,值为0undefined转为数值时,值为NaN

    深拷贝和浅拷贝的区别?如何实现?

    1.数据类型存储

    前面文章我们讲到,JavaScript中存在两大数据类型:1. 基本类型 2. 引用类型

    基本类型数据保存在在栈内存中

    引用类型数据保存在堆内存中,引用数据类型的变量是一个指向堆内存中实际对象的引用,存在栈中

    2.浅拷贝

    浅拷贝,指的是创建新的数据,这个数据有着原始数据属性值的一份精确拷贝

    如果属性是基本类型,拷贝的就是基本类型的值。如果属性是引用类型,拷贝的就是内存地址

    即浅拷贝是拷贝一层,深层次的引用类型则共享内存地址

    下面简单实现一个浅拷贝

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function shallowClone(obj) {
    const newObj = {}
    for (let prop in obj) {
    if (obj.hasOwnProperty(prop)) {
    newObj[prop] = obj[prop]
    }
    }
    return newObj
    }

    JavaScript中,存在浅拷贝的现象有:

  • Object.assign
  • Array.prototype.slice(), Array.prototype.concat()
  • 使用拓展运算符实现的复制
  • Object.assign

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    var obj = {
    age: 18,
    nature: ['smart', 'good'],
    names: {
    name1: 'fx',
    name2: 'xka',
    },
    love: function () {
    console.log('fx is a great girl')
    },
    }
    var newObj = Object.assign({}, fxObj)

    slice()

    1
    2
    3
    4
    5
    const fxArr = ['One', 'Two', 'Three']
    const fxArrs = fxArr.slice(0)
    fxArrs[1] = 'love'
    console.log(fxArr) // ["One", "Two", "Three"]
    console.log(fxArrs) // ["One", "love", "Three"]

    concat()

    1
    2
    3
    4
    5
    const fxArr = ['One', 'Two', 'Three']
    const fxArrs = fxArr.concat()
    fxArrs[1] = 'love'
    console.log(fxArr) // ["One", "Two", "Three"]
    console.log(fxArrs) // ["One", "love", "Three"]

    拓展运算符

    1
    2
    3
    4
    5
    const fxArr = ['One', 'Two', 'Three']
    const fxArrs = [...fxArr]
    fxArrs[1] = 'love'
    console.log(fxArr) // ["One", "Two", "Three"]
    console.log(fxArrs) // ["One", "love", "Three"]

    3.深拷贝

    深拷贝开辟一个新的栈,两个对象属完成相同,但是对应两个不同的地址,修改一个对象的属性,不会改变另一个对象的属性

    常见的深拷贝方式有:

  • _.cloneDeep()
  • jQuery.extend()
  • JSON.stringify()
  • 手写循环递归
  • _.cloneDeep()

    1
    2
    3
    4
    5
    6
    7
    8
    const _ = require('lodash')
    const obj1 = {
    a: 1,
    b: { f: { g: 1 } },
    c: [1, 2, 3],
    }
    const obj2 = _.cloneDeep(obj1)
    console.log(obj1.b.f === obj2.b.f) // false

    jQuery.extend()

    1
    2
    3
    4
    5
    6
    7
    8
    const $ = require('jquery')
    const obj1 = {
    a: 1,
    b: { f: { g: 1 } },
    c: [1, 2, 3],
    }
    const obj2 = $.extend(true, {}, obj1)
    console.log(obj1.b.f === obj2.b.f) // false

    JSON.stringify()

    1
    const obj2 = JSON.parse(JSON.stringify(obj1))

    但是这种方式会存在弊端,会忽略undefinedsymbol函数

    1
    2
    3
    4
    5
    6
    7
    8
    const obj = {
    name: 'A',
    name1: undefined,
    name3: function () {},
    name4: Symbol('A'),
    }
    const obj2 = JSON.parse(JSON.stringify(obj))
    console.log(obj2) // {name: "A"}

    循环递归

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    function deepClone(obj, hash = new WeakMap()) {
    if (obj === null) return obj // 如果是null或者undefined我就不进行拷贝操作
    if (obj instanceof Date) return new Date(obj)
    if (obj instanceof RegExp) return new RegExp(obj)
    // 可能是对象或者普通的值 如果是函数的话是不需要深拷贝
    if (typeof obj !== 'object') return obj
    // 是对象的话就要进行深拷贝
    if (hash.get(obj)) return hash.get(obj)
    let cloneObj = new obj.constructor()
    // 找到的是所属类原型上的constructor,而原型上的 constructor指向的是当前类本身
    hash.set(obj, cloneObj)
    for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
    // 实现一个递归拷贝
    cloneObj[key] = deepClone(obj[key], hash)
    }
    }
    return cloneObj
    }

    4.区别

    下面首先借助两张图,可以更加清晰看到浅拷贝与深拷贝的区别

    从上图发现,浅拷贝和深拷贝都创建出一个新的对象,但在复制对象属性的时候,行为就不一样

    浅拷贝只复制属性指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存,修改对象属性会影响原对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 浅拷贝
    const obj1 = {
    name: 'init',
    arr: [1, [2, 3], 4],
    }
    const obj3 = shallowClone(obj1) // 一个浅拷贝方法
    obj3.name = 'update'
    obj3.arr[1] = [5, 6, 7] // 新旧对象还是共享同一块内存

    console.log('obj1', obj1) // obj1 { name: 'init', arr: [ 1, [ 5, 6, 7 ], 4 ] }
    console.log('obj3', obj3) // obj3 { name: 'update', arr: [ 1, [ 5, 6, 7 ], 4 ] }

    但深拷贝会另外创造一个一模一样的对象,新对象和原对象不共享内存,修改新对象不会改变原对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 深拷贝
    const obj1 = {
    name: 'init',
    arr: [1, [2, 3], 4],
    }
    const obj4 = deepClone(obj1) // 一个深拷贝方法
    obj4.name = 'update'
    obj4.arr[1] = [5, 6, 7] // 新对象跟原对象不共享内存

    console.log('obj1', obj1) // obj1 { name: 'init', arr: [ 1, [ 2, 3 ], 4 ] }
    console.log('obj4', obj4) // obj4 { name: 'update', arr: [ 1, [ 5, 6, 7 ], 4 ] }

    小结

    前提为拷贝类型为引用类型的情况下:

  • 浅拷贝是拷贝一层,属性为对象时,浅拷贝是复制,两个对象指向同一个地址
  • 深拷贝是递归拷贝深层次,属性为对象时,深拷贝是新开栈,两个对象指向不同的地址
  • 对闭包的理解,使用场景

    1.是什么

    一个函数对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)

    也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域

    JavaScript中,每当创建一个函数,闭包就会在函数创建的同时被创建出来,作为函数内部与外部连接起来的一座桥梁
    下面给出一个简单的例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function init() {
    var name = 'Mozilla' // name 是一个被 init 创建的局部变量
    function displayName() {
    // displayName() 是内部函数,一个闭包
    alert(name) // 使用了父函数中声明的变量
    }
    displayName()
    }
    init()

    displayName() 没有自己的局部变量。然而,由于闭包的特性,它可以访问到外部函数的变量

    2.使用场景

    任何闭包的使用场景都离不开这两点:

  • 创建私有变量
  • 延长变量的生命周期
  • 一般函数的词法环境在函数返回后就被销毁,但是闭包会保存对创建时所在词法环境的引用,即便创建时所在的执行上下文被销毁,但创建时所在词法环境依然存在,以达到延长变量的生命周期的目的

    举个栗子:
    在页面上添加一些可以调整字号的按钮

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function makeSizer(size) {
    return function () {
    document.body.style.fontSize = size + 'px'
    }
    }

    var size12 = makeSizer(12)
    var size14 = makeSizer(14)
    var size16 = makeSizer(16)

    document.getElementById('size-12').onclick = size12
    document.getElementById('size-14').onclick = size14
    document.getElementById('size-16').onclick = size16

    柯里化函数

    柯里化的目的在于避免频繁调用具有相同参数函数的同时,又能够轻松的重用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 假设我们有一个求长方形面积的函数
    function getArea(width, height) {
    return width * height
    }
    // 如果我们碰到的长方形的宽老是10
    const area1 = getArea(10, 20)
    const area2 = getArea(10, 30)
    const area3 = getArea(10, 40)

    // 我们可以使用闭包柯里化这个计算面积的函数
    function getArea(width) {
    return (height) => {
    return width * height
    }
    }

    const getTenWidthArea = getArea(10)
    // 之后碰到宽度为10的长方形就可以这样计算面积
    const area1 = getTenWidthArea(20)

    // 而且如果遇到宽度偶尔变化也可以轻松复用
    const getTwentyWidthArea = getArea(20)

    使用闭包模拟私有方法

    JavaScript中,没有支持声明私有变量,但我们可以使用闭包来模拟私有方法

    下面举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    var Counter = (function () {
    var privateCounter = 0
    function changeBy(val) {
    privateCounter += val
    }
    return {
    increment: function () {
    changeBy(1)
    },
    decrement: function () {
    changeBy(-1)
    },
    value: function () {
    return privateCounter
    },
    }
    })()

    var Counter1 = makeCounter()
    var Counter2 = makeCounter()
    console.log(Counter1.value()) /* logs 0 */
    Counter1.increment()
    Counter1.increment()
    console.log(Counter1.value()) /* logs 2 */
    Counter1.decrement()
    console.log(Counter1.value()) /* logs 1 */
    console.log(Counter2.value()) /* logs 0 */

    上述通过使用闭包来定义公共函数,并令其可以访问私有函数和变量,这种方式也叫模块方式

    两个计数器 Counter1Counter2 是维护它们各自的独立性的,每次调用其中一个计数器时,通过改变这个变量的值,会改变这个闭包的词法环境,不会影响另一个闭包中的变量

    其他

    例如计数器、延迟调用、回调等闭包的应用,其核心思想还是创建私有变量和延长变量的生命周期

    3.注意事项

    如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响

    例如,在创建新的对象或者类时,方法通常应该关联于对象的原型,而不是定义到对象的构造器中。

    原因在于每个对象的创建,方法都会被重新赋值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function MyObject(name, message) {
    this.name = name.toString()
    this.message = message.toString()
    this.getName = function () {
    return this.name
    }

    this.getMessage = function () {
    return this.message
    }
    }

    上面的代码中,我们并没有利用到闭包的好处,因此可以避免使用闭包。修改成如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function MyObject(name, message) {
    this.name = name.toString()
    this.message = message.toString()
    }
    MyObject.prototype.getName = function () {
    return this.name
    }
    MyObject.prototype.getMessage = function () {
    return this.message
    }

    对作用域链的理解

    1.作用域

    作用域,即变量(变量作用域又称上下文)和函数生效(能被访问)的区域或集合

    换句话说,作用域决定了代码区块中变量和其他资源的可见性

    举个例子:

    1
    2
    3
    4
    5
    function myFunction() {
    let inVariable = '函数内部变量'
    }
    myFunction() //要先执行这个函数,否则根本不知道里面是啥
    console.log(inVariable) // Uncaught ReferenceError: inVariable is not defined

    上述例子中,函数 myFunction 内部创建一个inVariable变量,当我们在全局访问这个变量的时候,系统会报错

    这就说明我们在全局是无法获取到(闭包除外)函数内部的变量

    我们一般将作用域分成:

  • 全局作用域
  • 函数作用域
  • 块级作用域
  • 全局作用域

    任何不在函数中或是大括号中声明的变量,都是在全局作用域下,全局作用域下声明的变量可以在程序的任意位置访问

    1
    2
    3
    4
    5
    6
    7
    // 全局变量
    var greeting = 'Hello World!'
    function greet() {
    console.log(greeting)
    }
    // 打印 'Hello World!'
    greet()

    函数作用域

    函数作用域也叫局部作用域,如果一个变量是在函数内部声明的它就在一个函数作用域下面。这些变量只能在函数内部访问,不能在函数以外去访问

    1
    2
    3
    4
    5
    6
    7
    8
    function greet() {
    var greeting = 'Hello World!'
    console.log(greeting)
    }
    // 打印 'Hello World!'
    greet()
    // 报错: Uncaught ReferenceError: greeting is not defined
    console.log(greeting)

    可见上述代码中在函数内部声明的变量或函数,在函数外部是无法访问的,这说明在函数内部定义的变量或者方法只是函数作用域

    块级作用域

    ES6 引入了 let 和 const 关键字,和 var 关键字不同,在大括号中使用 let 和 const 声明的变量存在于块级作用域中。在大括号之外不能访问这些变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {
    // 块级作用域中的变量
    let greeting = 'Hello World!'
    var lang = 'English'
    console.log(greeting) // Prints 'Hello World!'
    }
    // 变量 'English'
    console.log(lang)
    // 报错:Uncaught ReferenceError: greeting is not defined
    console.log(greeting)

    2.词法作用域

    词法作用域,又叫静态作用域,变量被创建时就确定好了,而非执行阶段确定的。也就是说我们写好代码时它的作用域就确定了,JavaScript 遵循的就是词法作用域

    1
    2
    3
    4
    5
    6
    7
    8
    9
    var a = 2
    function foo() {
    console.log(a)
    }
    function bar() {
    var a = 3
    foo()
    }
    bar()

    上述代码变成一张图

    由于 JavaScript 遵循词法作用域,相同层级的 foo 和 bar 就没有办法访问到彼此块作用域中的变量,所以输出 2

    3.作用域链

    当在 Javascript 中使用一个变量的时候,首先 Javascript 引擎会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域

    如果在全局作用域里仍然找不到该变量,它就会在全局范围内隐式声明该变量(非严格模式下)或是直接报错

    这里拿《你不知道的 Javascript(上)》中的一张图解释:

    把作用域比喻成一个建筑,这份建筑代表程序中的嵌套作用域链,第一层代表当前的执行作用域,顶层代表全局作用域

    变量的引用会顺着当前楼层进行查找,如果找不到,则会往上一层找,一旦到达顶层,查找的过程都会停止
    下面代码演示下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    var sex = '男'
    function person() {
    var name = '张三'
    function student() {
    var age = 18
    console.log(name) // 张三
    console.log(sex) // 男
    }
    student()
    console.log(age) // Uncaught ReferenceError: age is not defined
    }
    person()

    上述代码主要主要做了以下工作:

  • student函数内部属于最内层作用域,找不到name,向上一层作用域person函数内部找,找到了输出“张三”
  • student内部输出sex时找不到,向上一层作用域person函数找,还找不到继续向上一层找,即全局作用域,找到了输出“男”
  • 在person函数内部输出age时找不到,向上一层作用域找,即全局作用域,还是找不到则报错
  • JavaScript 原型,原型链,有什么特点

    1.原型

    JavaScript常被描述为一种基于原型的语言——每个对象拥有一个原型对象

    当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象的原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾

    准确地说,这些属性和方法定义在 Object 的构造器函数(constructor functions)之上的 prototype 属性上,而非实例对象本身

    下面举个例子: 函数可以有属性。 每个函数都有一个特殊的属性叫作原型 prototype

    1
    2
    function doSomething() {}
    console.log(doSomething.prototype)

    控制台输出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    constructor: ƒ doSomething(),
    __proto__: {
    constructor: ƒ Object(),
    hasOwnProperty: ƒ hasOwnProperty(),
    isPrototypeOf: ƒ isPrototypeOf(),
    propertyIsEnumerable: ƒ propertyIsEnumerable(),
    toLocaleString: ƒ toLocaleString(),
    toString: ƒ toString(),
    valueOf: ƒ valueOf()
    }
    }

    上面这个对象,就是大家常说的原型对象
    可以看到,原型对象有一个自有属性constructor,这个属性指向该函数,如下图关系展示

    2.原型链

    原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链 (prototype chain),它解释了为何一个对象会拥有定义在其他对象中的属性和方法

    在对象实例和它的构造器之间建立一个链接(它是proto属性,是从构造函数的 prototype 属性派生的),之后通过上溯原型链,在构造器中找到这些属性和方法

    下面举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function Person(name) {
    this.name = name
    this.age = 18
    this.sayName = function () {
    console.log(this.name)
    }
    }
    // 第二步 创建实例
    var person = new Person('person')

    根据代码,我们可以得到下图

    下面分析一下:

  • 构造函数Person存在原型对象Person.prototype
  • 构造函数生成实例对象person,person的__proto__指向构造函数Person原型对象
  • Person.prototype.__proto__ 指向内置对象,因为 Person.prototype 是个对象,默认是由 Object函数作为类创建的,而 Object.prototype 为内置对象
  • Person.__proto__ 指向内置匿名函数 anonymous,因为 Person 是个函数对象,默认由 Function 作为类创建
  • Function.prototype 和 Function.__proto__同时指向内置匿名函数 anonymous,这样原型链的终点就是 null
  • 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__属性指向nullnull是原型链的顶端

    1
    Object.prototype.__proto__ === null

    下面做出总结:

  • 一切对象都是继承自Object对象,Object 对象直接继承根源对象null
  • 一切的函数对象(包括 Object 对象),都是继承自 Function 对象
  • Object 对象直接继承自 Function 对象
  • Function对象的__proto__会指向自己的原型对象,最终还是继承自Object对象
  • JavaScript 怎么实现继承

    1.是什么

    继承(inheritance)是面向对象软件技术当中的一个概念。
    如果一个类别 B“继承自”另一个类别 A,就把这个 B 称为“A 的子类”,而把 A 称为“B 的父类别”也可以称“A 是 B 的超类”

    继承的优点:

  • 继承可以使得子类具有父类别的各种属性和方法,而不需要再次编写相同的代码
  • 在子类别继承父类别的同时,可以重新定义某些属性,并重写某些方法,即覆盖父类别的原有属性和方法,使其获得与父类别不同的功能
  • 虽然JavaScript并不是真正的面向对象语言,但它天生的灵活性,使应用场景更加丰富

    关于继承,我们举个形象的例子:

    定义一个类(Class)叫汽车,汽车的属性包括颜色、轮胎、品牌、速度、排气量等

    1
    2
    3
    4
    5
    6
    7
    class Car {
    constructor(color, speed) {
    this.color = color
    this.speed = speed
    // ...
    }
    }

    由汽车这个类可以派生出“轿车”和“货车”两个类,在汽车的基础属性上,为轿车添加一个后备厢、给货车添加一个大货箱

    1
    2
    3
    4
    5
    6
    7
    // 货车
    class Truck extends Car {
    constructor(color, speed) {
    super(color, speed)
    this.Container = true // 货箱
    }
    }

    这样轿车和货车就是不一样的,但是二者都属于汽车这个类,汽车、轿车继承了汽车的属性,而不需要再次在“轿车”中定义汽车已经有的属性

    在“轿车”继承“汽车”的同时,也可以重新定义汽车的某些属性,并重写或覆盖某些属性和方法,使其获得与“汽车”这个父类不同的属性和方法

    1
    2
    3
    4
    5
    6
    7
    class Truck extends Car {
    constructor(color, speed) {
    super(color, speed)
    this.color = 'black' //覆盖
    this.Container = true // 货箱
    }
    }

    从这个例子中就能详细说明汽车、轿车以及卡车之间的继承关系

    2.实现方式

    下面给出JavaScripy常见的继承方式:

  • 原型链继承
  • 构造函数继承(借助 call)
  • 组合继承
  • 原型式继承
  • 寄生式继承
  • 寄生组合式继承
  • 原型链继承

    原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function Parent() {
    this.name = 'parent1'
    this.play = [1, 2, 3]
    }
    function Child() {
    this.type = 'child2'
    }
    Child1.prototype = new Parent()
    console.log(new Child())

    上面代码看似没问题,实际存在潜在问题

    1
    2
    3
    4
    var s1 = new Child2()
    var s2 = new Child2()
    s1.play.push(4)
    console.log(s1.play, s2.play) // [1,2,3,4]

    改变 s1 的 play 属性,会发现 s2 也跟着发生变化了,这是因为两个实例使用的是同一个原型对象,内存空间是共享的

    构造函数继承

    借助 call调用Parent函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function Parent() {
    this.name = 'parent1'
    }

    Parent.prototype.getName = function () {
    return this.name
    }

    function Child() {
    Parent1.call(this)
    this.type = 'child'
    }

    let child = new Child()
    console.log(child) // 没问题
    console.log(child.getName()) // 会报错

    可以看到,父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法

    相比第一种原型链继承方式,父类的引用属性不会被共享,优化了第一种继承方式的弊端,但是只能继承父类的实例属性和方法,不能继承原型属性或者方法

    组合继承
    前面我们讲到两种继承方式,各有优缺点。组合继承则将前两种方式继承起来

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    function Parent3() {
    this.name = 'parent3'
    this.play = [1, 2, 3]
    }

    Parent3.prototype.getName = function () {
    return this.name
    }
    function Child3() {
    // 第二次调用 Parent3()
    Parent3.call(this)
    this.type = 'child3'
    }

    // 第一次调用 Parent3()
    Child3.prototype = new Parent3()
    // 手动挂上构造器,指向自己的构造函数
    Child3.prototype.constructor = Child3
    var s3 = new Child3()
    var s4 = new Child3()
    s3.play.push(4)
    console.log(s3.play, s4.play) // 不互相影响
    console.log(s3.getName()) // 正常输出'parent3'
    console.log(s4.getName()) // 正常输出'parent3'

    这种方式看起来就没什么问题,方式一和方式二的问题都解决了,但是从上面代码我们也可以看到Parent3 执行了两次,造成了多构造一次的性能开销

    原型式继承

    这里主要借助Object.create方法实现普通对象的继承

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    let parent4 = {
    name: 'parent4',
    friends: ['p1', 'p2', 'p3'],
    getName: function () {
    return this.name
    },
    }

    let person4 = Object.create(parent4)
    person4.name = 'tom'
    person4.friends.push('jerry')

    let person5 = Object.create(parent4)
    person5.friends.push('lucy')

    console.log(person4.name) // tom
    console.log(person4.name === person4.getName()) // true
    console.log(person5.name) // parent4
    console.log(person4.friends) // ["p1", "p2", "p3","jerry","lucy"]
    console.log(person5.friends) // ["p1", "p2", "p3","jerry","lucy"]

    这种继承方式的缺点也很明显,因为Object.create方法实现的是浅拷贝,多个实例的引用类型属性指向相同的内存,存在篡改的可能

    寄生式继承

    寄生式继承在上面继承基础上进行优化,利用这个浅拷贝的能力再进行增强,添加一些方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    let parent5 = {
    name: 'parent5',
    friends: ['p1', 'p2', 'p3'],
    getName: function () {
    return this.name
    },
    }

    function clone(original) {
    let clone = Object.create(original)
    clone.getFriends = function () {
    return this.friends
    }
    return clone
    }

    let person5 = clone(parent5)

    console.log(person5.getName()) // parent5
    console.log(person5.getFriends()) // ["p1", "p2", "p3"]

    其优缺点也很明显,跟上面讲的原型式继承一样

    寄生组合式继承

    寄生组合式继承,借助解决普通对象的继承问题的Object.create方法,在前面几种继承方式的优缺点基础上进行改造,这也是所有继承方式里面相对最优的继承方式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    function clone(parent, child) {
    // 这里改用 Object.create 就可以减少组合继承中多进行一次构造的过程
    child.prototype = Object.create(parent.prototype)
    child.prototype.constructor = child
    }

    function Parent6() {
    this.name = 'parent6'
    this.play = [1, 2, 3]
    }
    Parent6.prototype.getName = function () {
    return this.name
    }
    function Child6() {
    Parent6.call(this)
    this.friends = 'child5'
    }

    clone(Parent6, Child6)

    Child6.prototype.getFriends = function () {
    return this.friends
    }

    let person6 = new Child6()
    console.log(person6) //{friends:"child5",name:"child5",play:[1,2,3],__proto__:Parent6}
    console.log(person6.getName()) // parent6
    console.log(person6.getFriends()) // child5

    可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题
    文章一开头,我们是使用 ES6 中的extends关键字直接实现 JavaScript的继承

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    class Person {
    constructor(name) {
    this.name = name
    }
    // 原型方法
    // 即 Person.prototype.getName = function() { }
    // 下面可以简写为 getName() {...}
    getName = function () {
    console.log('Person:', this.name)
    }
    }
    class Gamer extends Person {
    constructor(name, age) {
    // 子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
    super(name)
    this.age = age
    }
    }
    const asuna = new Gamer('Asuna', 20)
    asuna.getName() // 成功访问到父类的方法

    利用babel工具进行转换,我们会发现 extends`实际采用的也是寄生组合继承方式,因此也证明了这种方式是较优的解决继承的方式

    3.总结

    下面一张图


    通过Object.create 来划分不同的继承方式,最后的寄生式组合继承方式是通过组合继承改造之后的最优继承方式,而 extends 的语法糖和寄生组合继承的方式基本类似

    谈谈 this 对象的理解

    1.定义

    函数的 this 关键字在 JavaScript中的表现略有不同,此外,在严格模式和非严格模式之间也会有一些差别
    在绝大多数情况下,函数的调用方式决定了 this 的值(运行时绑定)

    this关键字是函数运行时自动生成的一个内部对象,只能在函数内部使用,总指向调用它的对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    function baz() {
    // 当前调用栈是:baz
    // 因此,当前调用位置是全局作用域

    console.log('baz')
    bar() // <-- bar的调用位置
    }

    function bar() {
    // 当前调用栈是:baz --> bar
    // 因此,当前调用位置在baz中

    console.log('bar')
    foo() // <-- foo的调用位置
    }

    function foo() {
    // 当前调用栈是:baz --> bar --> foo
    // 因此,当前调用位置在bar中

    console.log('foo')
    }

    baz() // <-- baz的调用位置

    同时,this在函数执行过程中,this一旦被确定了,就不可以再更改

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var a = 10;
    var obj = {
    a: 20
    }

    function fn() {
    this = obj; // 修改this,运行后会报错
    console.log(this.a);
    }

    fn();

    2.绑定规则

    根据不同的使用场合,this 有不同的值,主要分为下面几种情况:

  • 默认绑定
  • 隐式绑定
  • new绑定
  • 显示绑定
  • 3.箭头函数

    在 ES6 的语法中还提供了箭头函语法,让我们在代码书写时就能确定 this 的指向(编译时绑定)
    举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const obj = {
    sayThis: () => {
    console.log(this)
    },
    }

    obj.sayThis() // window 因为 JavaScript 没有块作用域,所以在定义 sayThis 的时候,里面的 this 就绑到 window 上去了
    const globalSay = obj.sayThis
    globalSay() // window 浏览器中的 global 对象

    虽然箭头函数的 this 能够在编译的时候就确定了 this 的指向,但也需要注意一些潜在的坑

    下面举个例子:

    绑定事件监听

    1
    2
    3
    4
    5
    const button = document.getElementById('mngb')
    button.addEventListener('click', () => {
    console.log(this === window) // true
    this.innerHTML = 'clicked button'
    })

    上述可以看到,我们其实是想要this为点击的button,但此时this指向了window

    包括在原型上添加方法时候,此时this指向window

    1
    2
    3
    4
    5
    6
    Cat.prototype.sayName = () => {
    console.log(this === window) //true
    return this.name
    }
    const cat = new Cat('mm')
    cat.sayName()

    同样的,箭头函数不能作为构建函数

    4.优先级

    隐式绑定 VS 显示绑定

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    function foo() {
    console.log(this.a)
    }

    var obj1 = {
    a: 2,
    foo: foo,
    }

    var obj2 = {
    a: 3,
    foo: foo,
    }

    obj1.foo() // 2
    obj2.foo() // 3

    obj1.foo.call(obj2) // 3
    obj2.foo.call(obj1) // 2

    显然,显示绑定的优先级更高

    new 绑定 VS 隐式绑定

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    function foo(something) {
    this.a = something
    }

    var obj1 = {
    foo: foo,
    }

    var obj2 = {}

    obj1.foo(2)
    console.log(obj1.a) // 2

    obj1.foo.call(obj2, 3)
    console.log(obj2.a) // 3

    var bar = new obj1.foo(4)
    console.log(obj1.a) // 2
    console.log(bar.a) // 4

    可以看到,new 绑定的优先级>隐式绑定

    new 绑定 VS 显式绑定

    因为newapplycall无法一起使用,但硬绑定也是显式绑定的一种,可以替换测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function foo(something) {
    this.a = something
    }

    var obj1 = {}

    var bar = foo.bind(obj1)
    bar(2)
    console.log(obj1.a) // 2

    var baz = new bar(3)
    console.log(obj1.a) // 2
    console.log(baz.a) // 3

    bar被绑定到 obj1 上,但是new bar(3) 并没有像我们预计的那样把obj1.a修改为 3。但是,new修改了绑定调用bar()中的this

    我们可认为new绑定优先级>显式绑定

    综上,new 绑定优先级 > 显示绑定优先级 > 隐式绑定优先级 > 默认绑定优先级

    JavaScript 中执行上下文和执行栈

    JavaScript 事件模型

    1.事件与事件流

    javascript中的事件,可以理解就是在 HTML 文档或者浏览器中发生的一种交互操作,使得网页具备互动性, 常见的有加载事件、鼠标事件、自定义事件等

    由于 DOM 是一个树结构,如果在父子节点绑定事件时候,当触发子节点的时候,就存在一个顺序问题,这就涉及到了事件流的概念

    事件流都会经历三个阶段:

  • 事件捕获阶段(capture phase)
  • 处于目标阶段(target phase)
  • 事件冒泡阶段(bubbling phase)
  • 事件冒泡是一种从下往上的传播方式,由最具体的元素(触发节点)然后逐渐向上传播到最不具体的那个节点,也就是 DOM 中最高层的父节点

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8" />
    <title>Event Bubbling</title>
    </head>
    <body>
    <button id="clickMe">Click Me</button>
    </body>
    </html>

    然后,我们给button和它的父元素,加入点击事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    var button = document.getElementById('clickMe')

    button.onclick = function () {
    console.log('1.Button')
    }
    document.body.onclick = function () {
    console.log('2.body')
    }
    document.onclick = function () {
    console.log('3.document')
    }
    window.onclick = function () {
    console.log('4.window')
    }

    点击按钮输出如下:

    1
    2
    3
    4
    1.button
    2.body
    3.document
    4.window

    点击事件首先在button元素上发生,然后逐级向上传播

    事件捕获与事件冒泡相反,事件最开始由不太具体的节点最早接受事件, 而最具体的节点(触发节点)最后接受事件

    2.事件模型

    事件模型可以分为三种:

  • 原始事件模型(DOM0级)
  • 标准事件模型(DOM2级)
  • IE事件模型(基本不用)
  • 原始事件模型

    事件绑定监听函数比较简单,有两种方式:

    HTML 代码直接绑定

    1
    <input type="button" onclick="fun()">

    通过 JS 代码绑定

    1
    2
    var btn = document.getElementById('.btn')
    btn.onclick = fun

    特性

  • 绑定速度快
  • DOM0级事件具有很好的跨浏览器优势,会以最快的速度绑定,但由于绑定速度太快,可能页面还未完全加载出来,以至于事件可能无法正常运行
  • 只支持冒泡,不支持捕获
  • 同一个类型的事件只能绑定一次
  • 1
    2
    3
    4
    <input type="button" id="btn" onclick="fun1()">

    var btn = document.getElementById('.btn');
    btn.onclick = fun2;

    如上,当希望为同一个元素绑定多个同类型事件的时候(上面的这个 btn 元素绑定 2 个点击事件),是不被允许的,后绑定的事件会覆盖之前的事件

    删除 DOM0 级事件处理程序只要将对应事件属性置为 null 即可

    1
    btn.onclick = null

    标准事件模型
    在该事件模型中,一次事件共有三个过程:

  • 事件捕获阶段:事件从document一直向下传播到目标元素, 依次检查经过的节点是否绑定了事件监听函数,如果有则执行
  • 事件处理阶段:事件到达目标元素, 触发目标元素的监听函数
  • 事件冒泡阶段:事件从目标元素冒泡到document, 依次检查经过的节点是否绑定了事件监听函数,如果有则执行
  • 事件监听函数的方式如下:

    1
    addEventListener(eventType, handler, useCapture)

    事件移除监听函数的方式如下:

    1
    removeEventListener(eventType, handler, useCapture)

    参数如下:

  • eventType指定事件类型(不要加on)
  • handler是事件处理函数
  • useCapture是一个boolean用于指定是否在捕获阶段进行处理,一般设置为false与IE浏览器保持一致
  • 举个例子

    1
    2
    3
    var btn = document.getElementById('.btn');
    btn.addEventListener(‘click’, showMessage, false);
    btn.removeEventListener(‘click’, showMessage, false);

    特性
    可以在一个 DOM 元素上绑定多个事件处理器,各自并不会冲突

    1
    2
    3
    btn.addEventListener(‘click’, showMessage1, false);
    btn.addEventListener(‘click’, showMessage2, false);
    btn.addEventListener(‘click’, showMessage3, false);

    执行时机

    当第三个参数(useCapture)设置为 true 就在捕获过程中执行,反之在冒泡过程中执行处理函数

    下面举个例子:

    1
    2
    3
    4
    5
    <div id="div">
    <p id="p">
    <span id="span">Click Me!</span>
    </p>
    </div>

    设置点击事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var div = document.getElementById('div')
    var p = document.getElementById('p')

    function onClickFn(event) {
    var tagName = event.currentTarget.tagName
    var phase = event.eventPhase
    console.log(tagName, phase)
    }

    div.addEventListener('click', onClickFn, false)
    p.addEventListener('click', onClickFn, false)

    上述使用了eventPhase,返回一个代表当前执行阶段的整数值。1 为捕获阶段、2 为事件对象触发阶段、3 为冒泡阶段

    点击Click Me!,输出如下

    1
    2
    P 3
    DIV 3

    可以看到,pdiv都是在冒泡阶段响应了事件,由于冒泡的特性,裹在里层的 p 率先做出响应

    如果把第三个参数都改为true

    1
    2
    div.addEventListener('click', onClickFn, true)
    p.addEventListener('click', onClickFn, true)

    输出如下

    1
    2
    DIV 1
    P 1

    两者都是在捕获阶段响应事件,所以 div 比 p 标签先做出响应

    typeof 与 instanceof 的区别

    1.typeof

    typeof操作符返回一个字符串,表示未经计算的操作数的类型
    使用方法如下:

    1
    2
    typeof operand
    typeof operand

    operand表示对象或原始值的表达式,其类型将被返回

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    typeof 1 // 'number'
    typeof '1' // 'string'
    typeof undefined // 'undefined'
    typeof true // 'boolean'
    typeof Symbol() // 'symbol'
    typeof null // 'object'
    typeof [] // 'object'
    typeof {} // 'object'
    typeof console // 'object'
    typeof console.log // 'function'

    从上面例子,前 6 个都是基础数据类型。虽然typeof nullobject,但这只是JavaScript 存在的一个悠久 Bug,不代表 null 就是引用数据类型,并且 null 本身也不是对象

    所以,nulltypeof之后返回的是有问题的结果,不能作为判断 null 的方法。如果你需要在 if 语句中判断是否为 null,直接通过===null来判断就好

    同时,可以发现引用类型数据,用typeof来判断的话,除了function会被识别出来之外,其余的都输出object

    如果我们想要判断一个变量是否存在,可以使用typeof:(不能使用 if(a), 若 a 未声明,则报错)

    1
    2
    3
    if (typeof a != 'undefined') {
    //变量存在
    }

    2.instanceof

    instanceof运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上,使用如下:

    1
    object instanceof constructor

    object为实例对象,constructor为构造函数

    构造函数通过new可以实例对象,instanceof能判断这个对象是否是之前那个构造函数生成的对象

    1
    2
    3
    4
    5
    6
    7
    8
    // 定义构建函数
    let Car = function () {}
    let benz = new Car()
    benz instanceof Car // true
    let car = new String('xxx')
    car instanceof String // true
    let str = 'xxx'
    str instanceof String // false

    关于instanceof的实现原理,可以参考下面:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function myInstanceof(left, right) {
    // 这里先用typeof来判断基础数据类型,如果是,直接返回false
    if (typeof left !== 'object' || left === null) return false
    // getProtypeOf是Object对象自带的API,能够拿到参数的原型对象
    let proto = Object.getPrototypeOf(left)
    while (true) {
    if (proto === null) return false
    if (proto === right.prototype) return true //找到相同原型对象,返回true
    proto = Object.getPrototypeof(proto)
    }
    }
    ;``

    3.区别

    typeofinstanceof都是判断数据类型的方法,区别如下:

  • typeof会返回一个变量的基本类型,instanceof返回的是一个布尔值
  • instanceof 可以准确地判断复杂引用数据类型,但是不能正确判断基础数据类型
  • 而typeof 也存在弊端,它虽然可以判断基础数据类型(null 除外),但是引用数据类型中,除了function 类型以外,其他的也无法判断
  • 可以看到,上述两种方法都有弊端,并不能满足所有场景的需求

    如果需要通用检测数据类型,可以采用Object.prototype.toString,调用该方法,统一返回格式“[object Xxx]”的字符串
    如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    Object.prototype.toString({}) // "[object Object]"
    Object.prototype.toString.call({}) // 同上结果,加上call也ok
    Object.prototype.toString.call(1) // "[object Number]"
    Object.prototype.toString.call('1') // "[object String]"
    Object.prototype.toString.call(true) // "[object Boolean]"
    Object.prototype.toString.call(function () {}) // "[object Function]"
    Object.prototype.toString.call(null) //"[object Null]"
    Object.prototype.toString.call(undefined) //"[object Undefined]"
    Object.prototype.toString.call(/123/g) //"[object RegExp]"
    Object.prototype.toString.call(new Date()) //"[object Date]"
    Object.prototype.toString.call([]) //"[object Array]"
    Object.prototype.toString.call(document) //"[object HTMLDocument]"
    Object.prototype.toString.call(window) //"[object Window]"

    了解了 toString 的基本用法,下面就实现一个全局通用的数据类型判断方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function getType(obj) {
    let type = typeof obj
    if (type !== 'object') {
    // 先进行typeof判断,如果是基础数据类型,直接返回
    return type
    }
    // 对于typeof返回结果是object的,再进行如下的判断,正则返回结果
    return Object.prototype.toString.call(obj).replace(/^\[object (\S+)\]$/, '$1')
    }

    使用如下

    1
    2
    3
    4
    5
    6
    7
    8
    getType([]) // "Array" typeof []是object,因此toString返回
    getType('123') // "string" typeof 直接返回
    getType(window) // "Window" toString返回
    getType(null) // "Null"首字母大写,typeof null是object,需toString来判断
    getType(undefined) // "undefined" typeof 直接返回
    getType() // "undefined" typeof 直接返回
    getType(function () {}) // "function" typeof能判断,因此首字母小写
    getType(/123/g) //"RegExp" toString返回

    什么是事件代理?应用场景

    1.是什么

    事件代理,俗地来讲,就是把一个元素响应事件(clickkeydown……)的函数委托到另一个元素

    前面讲到,事件流的都会经过三个阶段: 捕获阶段 -> 目标阶段 -> 冒泡阶段,而事件委托就是在冒泡阶段完成

    事件委托,会把一个或者一组元素的事件委托到它的父层或者更外层元素上,真正绑定事件的是外层元素,而不是目标元素

    当事件响应到目标元素上时,会通过事件冒泡机制从而触发它的外层元素的绑定事件上,然后在外层元素上去执行函数

    下面举个例子:

    比如一个宿舍的同学同时快递到了,一种笨方法就是他们一个个去领取

    较优方法就是把这件事情委托给宿舍长,让一个人出去拿好所有快递,然后再根据收件人一一分发给每个同学

    在这里,取快递就是一个事件,每个同学指的是需要响应事件的 DOM 元素,而出去统一领取快递的宿舍长就是代理的元素

    所以真正绑定事件的是这个元素,按照收件人分发快递的过程就是在事件执行中,需要判断当前响应的事件应该匹配到被代理元素中的哪一个或者哪几个

    2.应用场景

    如果我们有一个列表,列表之中有大量的列表项,我们需要在点击列表项的时候响应一个事件

    1
    2
    3
    4
    5
    6
    7
    <ul id="list">
    <li>item 1</li>
    <li>item 2</li>
    <li>item 3</li>
    ......
    <li>item n</li>
    </ul>

    如果给每个列表项一一都绑定一个函数,那对于内存消耗是非常大的

    1
    2
    3
    4
    5
    6
    7
    8
    // 获取目标元素
    const lis = document.getElementsByTagName('li')
    // 循环遍历绑定事件
    for (let i = 0; i < lis.length; i++) {
    lis[i].onclick = function (e) {
    console.log(e.target.innerHTML)
    }
    }

    这时候就可以事件委托,把点击事件绑定在父级元素 ul 上面,然后执行事件的时候再去匹配目标元素

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 给父层元素绑定事件
    document.getElementById('list').addEventListener('click', function (e) {
    // 兼容性处理
    var event = e || window.event
    var target = event.target || event.srcElement
    // 判断是否匹配目标元素
    if (target.nodeName.toLocaleLowerCase === 'li') {
    console.log('the content is: ', target.innerHTML)
    }
    })

    还有一种场景是上述列表项并不多,我们给每个列表项都绑定了事件

    但是如果用户能够随时动态的增加或者去除列表项元素,那么在每一次改变的时候都需要重新给新增的元素绑定事件,给即将删去的元素解绑事件

    如果用了事件委托就没有这种麻烦了,因为事件是绑定在父层的,和目标元素的增减是没有关系的,执行到目标元素是在真正响应执行事件函数的过程中去匹配的

    举个例子:
    下面html结构中,点击input可以动态添加元素

    1
    2
    3
    4
    5
    6
    7
    <input type="button" name="" id="btn" value="添加" />
    <ul id="ul1">
    <li>item 1</li>
    <li>item 2</li>
    <li>item 3</li>
    <li>item 4</li>
    </ul>

    使用事件委托

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    const oBtn = document.getElementById('btn')
    const oUl = document.getElementById('ul1')
    const num = 4

    //事件委托,添加的子元素也有事件
    oUl.onclick = function (ev) {
    ev = ev || window.event
    const target = ev.target || ev.srcElement
    if (target.nodeName.toLowerCase() == 'li') {
    console.log('the content is: ', target.innerHTML)
    }
    }

    //添加新节点
    oBtn.onclick = function () {
    num++
    const oLi = document.createElement('li')
    oLi.innerHTML = `item ${num}`
    oUl.appendChild(oLi)
    }

    可以看到,使用事件委托,在动态绑定事件的情况下是可以减少很多重复工作的

    3.总结

    适合事件委托的事件有:clickmousedownmouseupkeydownkeyupkeypress
    从上面应用场景中,我们就可以看到使用事件委托存在两大优点:

  • 减少整个页面所需的内存,提升整体性能
  • 动态绑定,减少重复工作
  • 但是使用事件委托也是存在局限性:

  • focus、blur这些事件没有事件冒泡机制,所以无法进行委托绑定事件
  • mousemove、mouseout这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的
  • 如果把所有事件都用事件代理,可能会出现事件误判,即本不该被触发的事件被绑定上了事件

    new 操作符具体干了什么

    1.是什么

    JavaScript中,new 操作符用于创建一个给定构造函数的实例对象

    例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function Person(name, age) {
    this.name = name
    this.age = age
    }
    Person.prototype.sayName = function () {
    console.log(this.name)
    }
    const person1 = new Person('Tom', 20)
    console.log(person1) // Person {name: "Tom", age: 20}
    t.sayName() // 'Tom'

    从上面可以看到:

  • new 通过构造函数 Person 创建出来的实例可以访问到构造函数中的属性
  • new 通过构造函数 Person 创建出来的实例可以访问到构造函数原型链中的属性(即实例与构造函数通过原型链连接了起来)
  • 现在在构建函数中显式加上返回值,并且这个返回值是一个原始类型

    1
    2
    3
    4
    5
    6
    function Test(name) {
    this.name = name
    return 1
    }
    const t = new Test('xxx')
    console.log(t.name) // 'xxx'

    可以发现,构造函数中返回一个原始值,然而这个返回值并没有作用

    下面在构造函数中返回一个对象

    1
    2
    3
    4
    5
    6
    7
    8
    function Test(name) {
    this.name = name
    console.log(this) // Test { name: 'xxx' }
    return { age: 26 }
    }
    const t = new Test('xxx')
    console.log(t) // { age: 26 }
    console.log(t.name) // 'undefined'

    从上面可以发现,构造函数如果返回值为一个对象,那么这个返回值会被正常使用

    2.流程

    从上面介绍中,我们可以看到 new 关键字主要做了以下的工作:

  • 创建一个新的对象 obj
  • 将对象与构建函数通过原型链连接起来
  • 将构建函数中的 this 绑定到新建的对象 obj 上
  • 根据构建函数返回类型做判断,如果返回类型原始值则被忽略,如果返回的是对象,则正常处理
  • 举个例子:

    1
    2
    3
    4
    5
    6
    7
    function Person(name, age) {
    this.name = name
    this.age = age
    }
    const person1 = new Person('Tom', 20)
    console.log(person1) // Person {name: "Tom", age: 20}
    t.sayName() // 'Tom'

    流程图如下:

    3.手写 new 操作符

    现在我们已经清楚地掌握了new的执行过程

    那么我们就动手来实现一下new

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function mynew(Func, ...args) {
    // 1.创建一个新对象
    const obj = {}
    // 2.新对象原型指向构造函数原型对象
    obj.__proto__ = Func.prototype
    // 3.将构建函数的this指向新对象
    let result = Func.apply(obj, args)
    // 4.根据返回值判断
    return result instanceof Object ? result : obj
    }

    测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function mynew(func, ...args) {
    const obj = {}
    obj.__proto__ = func.prototype
    let result = func.apply(obj, args)
    return result instanceof Object ? result : obj
    }
    function Person(name, age) {
    this.name = name
    this.age = age
    }
    Person.prototype.say = function () {
    console.log(this.name)
    }

    let p = mynew(Person, 'huihui', 123)
    console.log(p) // Person {name: "huihui", age: 123}
    p.say() // huihui

    可以发现 代码虽然短 但是能够模拟实现 new

    ajax 原理是什么?如何实现

    1.是什么

    AJAX全称(Async Javascript and XML)

    即异步的JavaScriptXML,是一种创建交互式网页应用的网页开发技术,可以在不重新加载整个网页的情况下,与服务器交换数据,并且更新部分网页

    Ajax的原理简单来说通过XmlHttpRequest对象来向服务器发异步请求,从服务器获得数据,然后用JavaScript来操作DOM而更新页面

    流程图如下:

    下面举个例子:
    领导想找小李汇报一下工作,就委托秘书去叫小李,自己就接着做其他事情,直到秘书告诉他小李已经到了,最后小李跟领导汇报工作

    Ajax 请求数据流程与“领导想找小李汇报一下工作”类似,上述秘书就相当于 XMLHttpRequest 对象,领导相当于浏览器,响应数据相当于小李

    浏览器可以发送 HTTP 请求后,接着做其他事情,等收到 XHR 返回来的数据再进行操作

    2.实现过程

    实现 Ajax异步交互需要服务器逻辑进行配合,需要完成以下步骤:

  • 创建 Ajax的核心对象 XMLHttpRequest对象
  • 通过 XMLHttpRequest 对象的 open() 方法与服务端建立连接
  • 构建请求所需的数据内容,并通过XMLHttpRequest 对象的 send() 方法发送给服务器端
  • 通过 XMLHttpRequest 对象提供的 onreadystatechange 事件监听服务器端你的通信状态
  • 接受并处理服务端向客户端响应的数据结果
  • 将处理结果更新到 HTML页面中
  • 3.封装

    通过上面对XMLHttpRequest对象的了解,下面来封装一个简单的 ajax 请求

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    //封装一个ajax请求
    function ajax(options) {
    //创建XMLHttpRequest对象
    const xhr = new XMLHttpRequest()


    //初始化参数的内容
    options = options || {}
    options.type = (options.type || 'GET').toUpperCase()
    options.dataType = options.dataType || 'json'
    const params = options.data

    //发送请求
    if (options.type === 'GET') {
    xhr.open('GET', options.url + '?' + params, true)
    xhr.send(null)
    } else if (options.type === 'POST') {
    xhr.open('POST', options.url, true)
    xhr.send(params)

    //接收请求
    xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) {
    let status = xhr.status
    if (status >= 200 && status < 300) {
    options.success && options.success(xhr.responseText, xhr.responseXML)
    } else {
    options.fail && options.fail(status)
    }
    }
    }
    }

    使用方式如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    ajax({
    type: 'post',
    dataType: 'json',
    data: {},
    url: 'https://xxxx',
    success: function (text, xml) {
    //请求成功后的回调函数
    console.log(text)
    },
    fail: function (status) {
    ////请求失败后的回调函数
    console.log(status)
    },
    })

    bind,call,apply 区别

    1.作用

    callapplybind作用是改变函数执行时的上下文,简而言之就是改变函数运行时的 this 指向

    那么什么情况下需要改变 this 的指向呢?下面举个例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    var name = 'lucy'
    var obj = {
    name: 'martin',
    say: function () {
    console.log(this.name)
    },
    }
    obj.say() // martin,this 指向 obj 对象
    setTimeout(obj.say, 0) // lucy,this 指向 window 对象

    从上面可以看到,正常情况 say 方法输出 martin

    但是我们把 say 放在 setTimeout 方法中,在定时器中是作为回调函数来执行的,因此回到主栈执行时是在全局执行上下文的环境中执行的,这时候 this 指向 window,所以输出 lucy

    我们实际需要的是 this 指向 obj 对象,这时候就需要该改变 this 指向了

    1
    setTimeout(obj.say.bind(obj), 0) //martin,this指向obj对象

    区别

    下面再来看看applycallbind的使用

    apply

    apply接受两个参数,第一个参数是this的指向,第二个参数是函数接受的参数,以数组的形式传入

    改变this指向后原函数会立即执行,且此方法只是临时改变this指向一次

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function fn(...args) {
    console.log(this, args)
    }
    let obj = {
    myname: '张三',
    }

    fn.apply(obj, [1, 2]) // this会变成传入的obj,传入的参数必须是一个数组;
    fn(1, 2) // this指向window

    当第一个参数为nullundefined的时候,默认指向window(在浏览器中)

    1
    2
    fn.apply(null, [1, 2]) // this指向window
    fn.apply(undefined, [1, 2]) // this指向window

    call

    call方法的第一个参数也是this的指向,后面传入的是一个参数列表

    跟 apply 一样,改变this指向后原函数会立即执行,且此方法只是临时改变this指向一次

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function fn(...args) {
    console.log(this, args)
    }
    let obj = {
    myname: '张三',
    }

    fn.call(obj, 1, 2) // this会变成传入的obj,传入的参数必须是一个数组;
    fn(1, 2) // this指向window

    同样的,当第一个参数为nullundefined的时候,默认指向window(在浏览器中)

    1
    2
    fn.call(null, [1, 2]) // this指向window
    fn.call(undefined, [1, 2]) // this指向window

    bind

    bind 方法和 call 很相似,第一参数也是this的指向,后面传入的也是一个参数列表(但是这个参数列表可以分多次传入)

    改变this指向后不会立即执行,而是返回一个永久改变this指向的函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function fn(...args) {
    console.log(this, args)
    }
    let obj = {
    myname: '张三',
    }

    const bindFn = fn.bind(obj) // this 也会变成传入的obj ,bind不是立即执行需要执行一次
    bindFn(1, 2) // this指向obj
    fn(1, 2) // this指向window

    小结

    从上面可以看到,applycallbind三者的区别在于:

  • 三者都可以改变函数的this对象指向
  • 三者第一个参数都是this要指向的对象,如果如果没有这个参数或参数为undefined或null,则默认指向全局window
  • 三者都可以传参,但是apply是数组,而call是参数列表,且apply和call是一次性传入参数,而bind可以分为多次传入
  • bind是返回绑定this之后的函数,apply、call 则是立即执行
  • 3.实现

    实现bind的步骤,我们可以分解成为三部分:

  • 修改this指向
  • 动态传递参数
  • 1
    2
    3
    4
    5
    // 方式一:只在bind中传递函数参数
    fn.bind(obj, 1, 2)()

    // 方式二:在bind中传递函数参数,也在返回函数中传递参数
    fn.bind(obj, 1)(2)
  • 兼容new关键字
  • 整体实现代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    Function.prototype.myBind = function (context) {
    // 判断调用对象是否为函数
    if (typeof this !== 'function') {
    throw new TypeError('Error')
    }

    // 获取参数
    const args = [...arguments].slice(1),
    fn = this

    return function Fn() {
    // 根据调用方式,传入不同绑定值
    return fn.apply(
    this instanceof Fn ? new fn(...arguments) : context,
    args.concat(...arguments)
    )
    }
    }

    正则表达式的理解 应用场景

    1.是什么

    正则表达式是一种用来匹配字符串的强有力的武器

    它的设计思想是用一种描述性的语言定义一个规则,凡是符合规则的字符串,我们就认为它“匹配”了,否则,该字符串就是不合法的

    JavaScript中,正则表达式也是对象,构建正则表达式有两种方式:

    1. 字面量创建,其由包含在斜杠之间的模式组成
    1
    const re = /\d+/g
    1. 调用 RegExp 对象的构造函数
    1
    2
    3
    4
    const re = new RegExp('\\d+', 'g')

    const rul = '\\d+'
    const re1 = new RegExp(rul, 'g')

    使用构建函数创建,第一个参数可以是一个变量,遇到特殊字符\需要使用\进行转义

    2.匹配规则

    常见的匹配规则如下:

    规则 描述
    \ 转义
    ^ 匹配输入的开始
    $ 匹配输入的结束
    * 匹配前一个表达式 0 次或多次
    + 匹配前面一个表达式 1 次或者多次。等价于 {1,}
    ? 匹配前面一个表达式 0 次或者 1 次。等价于{0,1}
    . 默认匹配除换行符之外的任何单个字符

    对事件循环的理解

    1.是什么

    首先,JavaScript是一门单线程的语言,意味着同一时间内只能做一件事,但是这并不意味着单线程就是阻塞,而实现单线程非阻塞的方法就是事件循环

    JavaScript中,所有的任务都可以分为

  • 同步任务:立即执行的任务,同步任务一般会直接进入到主线程中执行
  • 异步任务:异步执行的任务,比如ajax网络请求,setTimeout定时函数等
  • 同步任务与异步任务的运行流程图如下:

    从上面我们可以看到,同步任务进入主线程,即主执行栈,异步任务进入任务队列,主线程内的任务执行完毕为空,会去任务队列读取对应的任务,推入主线程执行。上述过程的不断重复就事件循环

    2.宏任务与微任务

    如果将任务划分为同步任务和异步任务并不是那么的准确,举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    console.log(1)

    setTimeout(() => {
    console.log(2)
    }, 0)

    new Promise((resolve, reject) => {
    console.log('new Promise')
    resolve()
    }).then(() => {
    console.log('then')
    })

    console.log(3)

    如果按照上面流程图来分析代码,我们会得到下面的执行步骤:

  • console.log(1),同步任务,主线程中执行
  • setTimeout() ,异步任务,放到 Event Table,0 毫秒后console.log(2)回调推入 Event Queue 中
  • new Promise ,同步任务,主线程直接执行
  • .then ,异步任务,放到 Event Table
  • console.log(3),同步任务,主线程执行
  • 所以按照分析,它的结果应该是 1 => 'new Promise' => 3 => 2 => 'then'

    但是实际结果是:1=>'new Promise'=> 3 => 'then' => 2

    出现分歧的原因在于异步任务执行顺序,事件队列其实是一个“先进先出”的数据结构,排在前面的事件会优先被主线程读取

    例子中 setTimeout回调事件是先进入队列中的,按理说应该先于 .then 中的执行,但是结果却偏偏相反

    原因在于异步任务还可以细分为宏任务微任务

    宏任务
    宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合
    常见的宏任务有:

  • script (可以理解为外层同步代码)
  • setTimeout/setInterval
  • UI rendering/UI事件
  • postMessage、MessageChannel
  • setImmediate、I/O(Node.js)
  • 微任务
    一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

    常见的微任务有:

  • Promise.then
  • MutaionObserver
  • Object.observe(已废弃;Proxy 对象替代)
  • process.nextTick(Node.js)
  • 这时候,事件循环,那个任务,微任务的关系如图所示:

    按照这个流程,它的执行机制是:

  • 执行一个宏任务,如果遇到微任务就将它放到微任务的事件队列中
  • 当前宏任务执行完成后,会查看微任务的事件队列,然后将里面的所有微任务依次执行完
  • 回到上面题目

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    console.log(1)
    setTimeout(() => {
    console.log(2)
    }, 0)
    new Promise((resolve, reject) => {
    console.log('new Promise')
    resolve()
    }).then(() => {
    console.log('then')
    })
    console.log(3)

    流程如下:

    1
    2
    3
    4
    5
    6
    7
    // 遇到 console.log(1) ,直接打印 1
    // 遇到定时器,属于新的宏任务,留着后面执行
    // 遇到 new Promise,这个是直接执行的,打印 'new Promise'
    // .then 属于微任务,放入微任务队列,后面再执行
    // 遇到 console.log(3) 直接打印 3
    // 好了本轮宏任务执行完毕,现在去微任务列表查看是否有微任务,发现 .then 的回调,执行它,打印 'then'
    // 当一次宏任务执行完,再去执行新的宏任务,这里就剩一个定时器的宏任务了,执行它,打印 2

    async 与 await

    async 是异步的意思,await 则可以理解为 async wait。所以可以理解async就是用来声明一个异步方法,而 await是用来等待异步方法执行

    async

    async 函数返回一个 promise 对象,下面两种方法是等效的

    1
    2
    3
    4
    5
    6
    7
    8
    function f() {
    return Promise.resolve('TEST')
    }

    // asyncF is equivalent to f!
    async function asyncF() {
    return 'TEST'
    }

    await

    正常情况下,await命令后面是一个 Promise对象,返回该对象的结果。如果不是 Promise对象,就直接返回对应的值

    1
    2
    3
    4
    5
    6
    async function f() {
    // 等同于
    // return 123
    return await 123
    }
    f().then((v) => console.log(v)) // 123

    不管await后面跟着的是什么,await都会阻塞后面的代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    async function fn1() {
    console.log(1)
    await fn2()
    console.log(2) // 阻塞
    }

    async function fn2() {
    console.log('fn2')
    }

    fn1()
    console.log(3)

    上面的例子中,await 会阻塞下面的代码(即加入微任务队列),先执行 async外面的同步代码,同步代码执行完,再回到 async 函数中,再执行之前阻塞的代码

    所以上述输出结果为:1fn232

    4.流程分析

    通过对上面的了解,我们对 JavaScript 对各种场景的执行顺序有了大致的了解

    这里直接上代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
    }
    async function async2() {
    console.log('async2')
    }
    console.log('script start')
    setTimeout(function () {
    console.log('settimeout')
    })
    async1()
    new Promise(function (resolve) {
    console.log('promise1')
    resolve()
    }).then(function () {
    console.log('promise2')
    })
    console.log('script end')

    分析过程:

    1. 执行整段代码,遇到 console.log('script start') 直接打印结果,输出 script start
    2. 遇到定时器了,它是宏任务,先放着不执行
    3. 遇到 async1(),执行 async1 函数,先打印 async1 start,下面遇到 await 怎么办?先执行async2,打印 async2,然后阻塞下面代码(即加入微任务列表),跳出去执行同步代码
    4. 跳到 new Promise 这里,直接执行,打印 promise1,下面遇到 .then(),它是微任务,放到微任务列表等待执行
    5. 最后一行直接打印 script end,现在同步代码执行完了,开始执行微任务,即 await 下面的代码,打印 async1 end
    6. 继续执行下一个微任务,即执行 then的回调,打印 promise2
    7. 上一个宏任务所有事都做完了,开始下一个宏任务,就是定时器,打印 settimeout

    所以最后的结果是:script startasync1 startasync2promise1script endasync1 endpromise2settimeout

    JavaScript 内存泄漏

    1.是什么

    内存泄漏(Memory leak)是在计算机科学中,由于疏忽或错误造成程序未能释放已经不再使用的内存

    并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费

    程序的运行需要内存。只要程序提出要求,操作系统或者运行时就必须供给内存

    对于持续运行的服务进程,必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃

    2.垃圾回收机制

    Javascript 具有自动垃圾回收机制(GC:Garbage Collecation),也就是说,执行环境会负责管理代码执行过程中使用的内存

    原理:垃圾收集器会定期(周期性)找出那些不在继续使用的变量,然后释放其内存

    通常情况下有两种实现方式:

  • 标记清除
  • 引用计数
  • 标记清除

    JavaScript最常用的垃圾收回机制

    当变量进入执行环境是,就标记这个变量为“进入环境“。进入环境的变量所占用的内存就不能释放,当变量离开环境时,则将其标记为“离开环境“

    垃圾回收程序运行的时候,会标记内存中存储的所有变量。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉

    在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了

    随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存

    举个例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    var m = 0,
    n = 19 // 把 m,n,add() 标记为进入环境。
    add(m, n) // 把 a, b, c标记为进入环境。
    console.log(n) // a,b,c标记为离开环境,等待垃圾回收。
    function add(a, b) {
    a++
    var c = a + b
    return c
    }

    引用计数
    语言引擎有一张”引用表”,保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是 0,就表示这个值不再用到了,因此可以将这块内存释放

    如果一个值不再需要了,引用数却不为 0,垃圾回收机制无法释放这块内存,从而导致内存泄漏

    1
    2
    const arr = [1, 2, 3, 4]
    console.log('hello world')

    上面代码中,数组[1, 2, 3, 4]是一个值,会占用内存。变量 arr 是仅有的对这个值的引用,因此引用次数为 1。尽管后面的代码没有用到 arr,它还是会持续占用内存

    如果需要这块内存被垃圾回收机制释放,只需要设置如下:

    1
    arr = null

    通过设置 arr 为 null,就解除了对数组[1,2,3,4]的引用,引用次数变为 0,就被垃圾回收了

    小结
    有了垃圾回收机制,不代表不用关注内存泄露。那些很占空间的值,一旦不再用到,需要检查是否还存在对它们的引用。如果是的话,就必须手动解除引用

    JavaScript 本地存储方式 区别及应用场景

    1.方式

    javaScript本地缓存的方法我们主要讲述以下四种:

  • cookie
  • sessionStorage
  • localStorage
  • indexedDB
  • 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 首部

    标记为 SecureCookie只应通过被 HTTPS 协议加密过的请求发送给服务端

    通过上述,我们可以看到cookie又开始的作用并不是为了缓存而设计出来,只是借用了cookie的特性实现缓存

    关于cookie的使用如下:

    1
    document.cookie = '名字=值'

    关于 cookie 的修改,首先要确定 domainpath 属性都是相同的才可以,其中有一个不同得时候都会创建出一个新的 cookie

    1
    2
    Set-Cookie:name=aa; domain=aa.net; path=/  # 服务端设置
    document.cookie =name=bb; domain=aa.net; path=/ # 客户端设置

    最后cookie的删除,最常用的方法就是给 cookie 设置一个过期的事件,这样 cookie 过期后会被浏览器删除

    localStorage
    HTML5 新方法,IE8 及以上浏览器都兼容

    特点

  • 生命周期:持久化的本地存储,除非主动删除数据,否则数据是永远不会过期的
  • 存储的信息在同一域中是共享的
  • 当本页操作(新增、修改、删除)了localStorage的时候,本页面不会触发storage事件,但是别的页面会触发storage事件。
  • 大小:5M(跟浏览器厂商有关系)
  • localStorage本质上是对字符串的读取,如果存储内容多的话会消耗内存空间,会导致页面变卡
  • 受同源策略的限制
  • 下面再看看关于localStorage的使用

    设置

    1
    localStorage.setItem('username', 'cfangxu')

    获取

    1
    localStorage.getItem('username')

    获取键名

    1
    localStorage.key(0) //获取第一个键名

    删除

    1
    localStorage.removeItem('username')

    一次性清除所有存储

    1
    localStorage.clear()

    localStorage 也不是完美的,它有两个缺点:

  • 无法像Cookie一样设置过期时间
  • 只能存入字符串,无法直接存对象
  • 1
    2
    localStorage.setItem('key', { name: 'value' })
    console.log(localStorage.getItem('key')) // '[object, Object]'

    sessionStorage
    sessionStorage 和 localStorage 使用方法基本一致,唯一不同的是生命周期,一旦页面(会话)关闭,sessionStorage 将会删除数据

    扩展的前端存储方式
    indexedDB 是一种低级 API,用于客户端存储大量结构化数据(包括, 文件/ blobs)。该 API 使用索引来实现对该数据的高性能搜索

    虽然 Web Storage 对于存储较少量的数据很有用,但对于存储更大量的结构化数据来说,这种方法不太有用。IndexedDB 提供了一个解决方案

    优点:

  • 储存量理论上没有上限
  • 所有操作都是异步的,相比 LocalStorage 同步操作性能更高,尤其是数据量较大时
  • 原生支持储存JS的对象
  • 是个正经的数据库,意味着数据库能干的事它都能干
  • 缺点:

  • 操作非常繁琐
  • 本身有一定门槛
  • 2.区别

    关于cookiesessionStoragelocalStorage三者的区别主要如下:

  • 存储大小:cookie数据大小不能超过4k,sessionStorage和localStorage虽然也有存储大小的限制,但比cookie大得多,可以达到5M或更大
  • 有效时间:localStorage存储持久数据,浏览器关闭后数据不丢失除非主动删除数据; sessionStorage数据在当前浏览器窗口关闭后自动删除;cookie设置的cookie过期时间之前一直有效,即使窗口或浏览器关闭
  • 数据与服务器之间的交互方式,cookie的数据会自动的传递到服务器,服务器端也可以写cookie到客户端; sessionStorage和localStorage不会自动把数据发给服务器,仅在本地保存
  • 3.应用场景

    在了解了上述的前端的缓存方式后,我们可以看看针对不对场景的使用选择:

  • 标记用户与跟踪用户行为的情况,推荐使用cookie
  • 适合长期保存在本地的数据(令牌),推荐使用localStorage
  • 敏感账号一次性登录,推荐使用sessionStorage
  • 存储大量数据的情况、在线文档(富文本编辑器)保存编辑历史的情况,推荐使用indexedDB
  • 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
    2
    3
    4
    5
    6
    // 0.1 和 0.2 都转化成二进制后再进行运算
    0.00011001100110011001100110011001100110011001100110011010 +
    0.0011001100110011001100110011001100110011001100110011010 =
    0.0100110011001100110011001100110011001100110011001100111

    // 转成十进制正好是 0.30000000000000004

    所以输出 false

    再来一个问题,那么为什么x=0.1得到0.1

    主要是存储二进制时小数点的偏移量最大为 52 位,最多可以表达的位数是2^53=9007199254740992,对应科学计数尾数是 9.007199254740992,这也是 JS 最多能表示的精度

    它的长度是 16,所以可以使用 toPrecision(16)来做精度运算,超过的精度会自动做凑整处理

    1
    2
    ;(0.10000000000000000555).toPrecision(16)
    // 返回 0.1000000000000000,去掉末尾的零后正好为 0.1

    但看到的 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
    2
    3
    function strip(num, precision = 12) {
    return +parseFloat(num.toPrecision(precision))
    }

    对于运算类操作,如 +-*/,就不能使用 toPrecision 了。正确的做法是把小数转成整数后再运算。以加法为例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    /**
    * 精确加法
    */
    function add(num1, num2) {
    const num1Digits = (num1.toString().split('.')[1] || '').length
    const num2Digits = (num2.toString().split('.')[1] || '').length
    const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits))
    return (num1 * baseNum + num2 * baseNum) / baseNum
    }

    最后还可以使用第三方库,如Math.jsBigDecimal.js

    什么是防抖和节流

    1.是什么

    本质上是优化高频率执行代码的一种手段

    如:浏览器的resizescrollkeypressmousemove 等事件在触发时,会不断地调用绑定在事件上的回调函数,极大地浪费资源,降低前端性能

    为了优化体验,需要对这类事件进行调用次数的限制,对此我们就可以采用 防抖(debounce)节流(throttle) 的方式来减少调用频率

    定义

  • 节流: n 秒内只运行一次,若在 n 秒内重复触发,只有一次生效
  • 防抖: n 秒后在执行该事件,若在 n 秒内被重复触发,则重新计时
  • 一个经典的比喻:

    想象每天上班大厦底下的电梯。把电梯完成一次运送,类比为一次函数的执行和响应

    假设电梯有两种运行策略 debounce 和 throttle,超时设定为 15 秒,不考虑容量限制

    电梯第一个人进来后,15 秒后准时运送一次,这是节流

    电梯第一个人进来后,等待 15 秒。如果过程中又有人进来,15 秒等待重新计时,直到 15 秒后开始运送,这是防抖

    2.代码实现

    节流

    完成节流可以使用时间戳与定时器的写法

    使用时间戳写法,事件会立即执行,停止触发后没有办法再次执行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function throttled1(fn, delay = 500) {
    let oldtime = Date.now()
    return function (...args) {
    let newtime = Date.now()
    if (newtime - oldtime >= delay) {
    fn.apply(null, args)
    oldtime = Date.now()
    }
    }
    }

    使用定时器写法,delay毫秒后第一次执行,第二次事件停止触发后依然会再一次执行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function throttled2(fn, delay = 500) {
    let timer = null
    return function (...args) {
    if (!timer) {
    timer = setTimeout(() => {
    fn.apply(this, args)
    timer = null
    }, delay)
    }
    }
    }

    可以将时间戳写法的特性与定时器写法的特性相结合,实现一个更加精确的节流。实现如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function throttled(fn, delay) {
    let timer = null
    let starttime = Date.now()
    return function () {
    let curTime = Date.now() // 当前时间
    let remaining = delay - (curTime - starttime) // 从上一次到现在,还剩下多少多余时间
    let context = this
    let args = arguments
    clearTimeout(timer)
    if (remaining <= 0) {
    fn.apply(context, args)
    starttime = Date.now()
    } else {
    timer = setTimeout(fn, remaining)
    }
    }
    }

    防抖

    简单版本的实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function debounce(func, wait) {
    let timeout

    return function () {
    let context = this // 保存this指向
    let args = arguments // 拿到event对象

    clearTimeout(timeout)
    timeout = setTimeout(function () {
    func.apply(context, args)
    }, wait)
    }
    }

    防抖如果需要立即执行,可加入第三个参数用于判断,实现如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    function debounce(func, wait, immediate) {
    let timeout

    return function () {
    let context = this
    let args = arguments

    if (timeout) clearTimeout(timeout) // timeout 不为null
    if (immediate) {
    let callNow = !timeout // 第一次会立即执行,以后只有事件执行后才会再次触发
    timeout = setTimeout(function () {
    timeout = null
    }, wait)
    if (callNow) {
    func.apply(context, args)
    }
    } else {
    timeout = setTimeout(function () {
    func.apply(context, args)
    }, wait)
    }
    }
    }

    3.区别

    相同点:

  • 都可以通过使用 setTimeout 实现
  • 目的都是,降低回调执行频率。节省计算资源
  • 不同点:

  • 函数防抖,在一段连续操作结束后,处理回调,利用clearTimeout和 setTimeout实现。函数节流,在一段连续操作中,每一段时间只执行一次,频率较高的事件中使用来提高性能
  • 函数防抖关注一定时间连续触发的事件,只在最后执行一次,而函数节流一段时间内只执行一次
  • 例如,都设置时间频率为 500ms,在 2 秒时间内,频繁触发函数,节流,每隔 500ms 就执行一次。防抖,则不管调动多少次方法,在 2s 后,只会执行一次

    如下图所示

    4.应用场景

    防抖在连续的事件,只需触发一次回调的场景有:

  • 搜索框搜索输入。只需用户最后一次输入完,再发送请求
  • 手机号、邮箱验证输入检测
  • 窗口大小resize。只需窗口调整完成后,计算窗口大小。防止重复渲染。
  • 节流在间隔一段时间执行一次回调的场景有:

  • 滚动加载,加载更多或滚到底部监听
  • 搜索框,搜索联想功能
  • 如何判断一个元素是否在可视区域内

    1.用途

    可视区域即我们浏览网页的设备肉眼可见的区域,如下图

    在日常开发中,我们经常需要判断目标元素是否在视窗之内或者和视窗的距离小于一个值(例如 100 px),从而实现一些常用的功能,例如

  • 图片的懒加载
  • 列表的无限滚动
  • 计算广告元素的曝光情况
  • 可点击链接的预加载
  • 2.实现方式

    判断一个元素是否在可视区域,我们常用的有三种办法:

  • offsetTop、scrollTop
  • getBoundingClientRect
  • Intersection Observer
  • offsetTop、scrollTop

    offsetTop,元素的上外边框至包含元素的上内边框之间的像素距离,其他 offset 属性如下图所示:

    下面再来了解下clientWidthclientHeight

  • clientWidth:元素内容区宽度加上左右内边距宽度,即clientWidth = content + padding
  • clientHeight:元素内容区高度加上上下内边距高度,即clientHeight = content + padding
  • 这里可以看到 client 元素都不包括外边距

    最后,关于 scroll 系列的属性如下:

  • scrollWidth 和 scrollHeight 主要用于确定元素内容的实际大小
  • scrollLeft 和 scrollTop 属性既可以确定元素当前滚动的状态,也可以设置元素的滚动位置
  • 垂直滚动 scrollTop > 0 ,水平滚动 scrollLeft > 0
  • 将元素的 scrollLeft 和 scrollTop 设置为 0,可以重置元素的滚动位置
  • 注意

  • 上述属性都是只读的,每次访问都要重新开始
  • 下面再看看如何实现判断:

    公式如下:

    1
    el.offsetTop - document.documentElement.scrollTop <= viewPortHeight

    代码实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function isInViewPortOfOne(el) {
    // viewPortHeight 兼容所有浏览器写法
    const viewPortHeight =
    window.innerHeight ||
    document.documentElement.clientHeight ||
    document.body.clientHeight
    const offsetTop = el.offsetTop
    const scrollTop = document.documentElement.scrollTop
    const top = offsetTop - scrollTop
    return top <= viewPortHeight
    }

    getBoundingClientRect

    返回值是一个 DOMRect 对象,拥有 left, top, right, bottom, x, y, width, 和 height 属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const target = document.querySelector('.target')
    const clientRect = target.getBoundingClientRect()
    console.log(clientRect)

    // {
    // bottom: 556.21875,
    // height: 393.59375,
    // left: 333,
    // right: 1017,
    // top: 162.625,
    // width: 684
    // }

    属性对应的关系图如下所示 :

    当页面发生滚动的时候,topleft属性值都会随之改变

    如果一个元素在视窗之内的话,那么它一定满足下面四个条件:

  • top 大于等于 0
  • left 大于等于 0
  • bottom 小于等于视窗高度
  • right 小于等于视窗宽度
  • 实现代码如下:

    1
    2
    3
    4
    5
    6
    7
    function isInViewPort(element) {
    const viewWidth = window.innerWidth || document.documentElement.clientWidth
    const viewHeight = window.innerHeight || document.documentElement.clientHeight
    const { top, right, bottom, left } = element.getBoundingClientRect()

    return top >= 0 && left >= 0 && right <= viewWidth && bottom <= viewHeight
    }

    Intersection Observer

    Intersection Observer 即重叠观察者,从这个命名就可以看出它用于判断两个元素是否重叠,因为不用进行事件的监听,性能方面相比 getBoundingClientRect 会好很多

    使用步骤主要分为两步:创建观察者和传入被观察者

    创建观察者

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const options = {
    // 表示重叠面积占被观察者的比例,从 0 - 1 取值,
    // 1 表示完全被包含
    threshold: 1.0,
    root:document.querySelector('#scrollArea') // 必须是目标元素的父级元素
    };

    const callback = (entries, observer) => { ....}

    const observer = new IntersectionObserver(callback, options);

    通过new IntersectionObserver创建了观察者 observer,传入的参数 callback 在重叠比例超过 threshold 时会被执行`

    关于callback回调函数常用属性如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 上段代码中被省略的 callback
    const callback = function (entries, observer) {
    entries.forEach((entry) => {
    entry.time // 触发的时间
    entry.rootBounds // 根元素的位置矩形,这种情况下为视窗位置
    entry.boundingClientRect // 被观察者的位置举行
    entry.intersectionRect // 重叠区域的位置矩形
    entry.intersectionRatio // 重叠区域占被观察者面积的比例(被观察者不是矩形时也按照矩形计算)
    entry.target // 被观察者
    })
    }

    传入被观察者

    通过 observer.observe(target) 这一行代码即可简单的注册被观察者

    1
    2
    const target = document.querySelector('.target')
    observer.observe(target)

    3.案例分析

    实现:创建了一个十万个节点的长列表,当节点滚入到视窗中时,背景就会从红色变为黄色

    Html结构如下:

    1
    <div class="container"></div>

    css样式如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    .container {
    display: flex;
    flex-wrap: wrap;
    }
    .target {
    margin: 5px;
    width: 20px;
    height: 20px;
    background: red;
    }

    container插入 1000 个元素

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const $container = $('.container')

    // 插入 100000 个 <div class="target"></div>
    function createTargets() {
    const htmlString = new Array(100000)
    .fill('<div class="target"></div>')
    .join('')
    $container.html(htmlString)
    }

    这里,首先使用getBoundingClientRect方法进行判断元素是否在可视区域

    1
    2
    3
    4
    5
    6
    7
    function isInViewPort(element) {
    const viewWidth = window.innerWidth || document.documentElement.clientWidth
    const viewHeight = window.innerHeight || document.documentElement.clientHeight
    const { top, right, bottom, left } = element.getBoundingClientRect()

    return top >= 0 && left >= 0 && right <= viewWidth && bottom <= viewHeight
    }

    然后开始监听scroll事件,判断页面上哪些元素在可视区域中,如果在可视区域中则将背景颜色设置为yellow

    1
    2
    3
    4
    5
    6
    7
    8
    $(window).on('scroll', () => {
    console.log('scroll !')
    $targets.each((index, element) => {
    if (isInViewPort(element)) {
    $(element).css('background-color', 'yellow')
    }
    })
    })

    通过上述方式,可以看到可视区域颜色会变成黄色了,但是可以明显看到有卡顿的现象,原因在于我们绑定了scroll事件,scroll事件伴随了大量的计算,会造成资源方面的浪费

    下面通过Intersection Observer的形式同样实现相同的功能

    首先创建一个观察者

    1
    const observer = new IntersectionObserver(getYellow, { threshold: 1.0 })

    getYellow回调函数实现对背景颜色改变,如下:

    1
    2
    3
    4
    5
    function getYellow(entries, observer) {
    entries.forEach((entry) => {
    $(entry.target).css('background-color', 'yellow')
    })
    }

    最后传入观察者,即.target元素

    1
    2
    3
    4
    5
    $targets.each((index, element) => {
    observer.observe(element)
    })
    ``
    可以看到功能同样完成,并且页面不会出现卡顿的情况

    大文件如何做断点续传

    1.是什么

    不管怎样简单的需求,在量级达到一定层次时,都会变得异常复杂

    文件上传简单,文件变大就复杂

    上传大文件时,以下几个变量会影响我们的用户体验

  • 服务器处理数据的能力
  • 请求超时
  • 网络波动
  • 上传时间会变长,高频次文件上传失败,失败后又需要重新上传等等

    为了解决上述问题,我们需要对大文件上传单独处理

    这里涉及到分片上传及断点续传两个概念

    分片上传
    分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(Part)来进行分片上传
    如下图

    上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件

    大致流程如下:

    1. 将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;
    2. 初始化一个分片上传任务,返回本次分片上传唯一标识;
    3. 按照一定的策略(串行或并行)发送各个分片数据块;
    4. 发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件

    断点续传

    断点续传指的是在下载或上传时,将下载或上传任务人为的划分为几个部分

    每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载。用户可以节省时间,提高速度

    一般实现方式有两种

  • 服务器端返回,告知从哪开始
  • 浏览器端自行处理
  • 上传过程中将文件在服务器写为临时文件,等全部写完了(文件上传完),将此临时文件重命名为正式文件即可

    如果中途上传中断过,下次上传的时候根据当前临时文件大小,作为在客户端读取文件的偏移量,从此位置继续读取文件数据块,上传到服务器从此偏移量继续写入文件即可

    3.使用场景

  • 大文件加速上传:当文件大小超过预期大小时,使用分片上传可实现并行上传多个 Part, 以加快上传速度
  • 网络环境较差:建议使用分片上传。当出现上传失败的时候,仅需重传失败的Part
  • 流式上传:可以在需要上传的文件大小还不确定的情况下开始上传。这种场景在视频监控等行业应用中比较常见
  • 如何实现上拉加载,下拉刷新

    1.前言

    下拉刷新和上拉加载这两种交互方式通常出现在移动端中

    本质上等同于 PC 网页中的分页,只是交互形式不同

    开源社区也有很多优秀的解决方案,如 iscroll、better-scroll、pulltorefresh.js 库等等

    这些第三方库使用起来非常便捷

    我们通过原生的方式实现一次上拉加载,下拉刷新,有助于对第三方库有更好的理解与使用

    2.实现原理

    上拉加载及下拉刷新都依赖于用户交互

    最重要的是要理解在什么场景,什么时机下触发交互动作

    上拉加载
    首先可以看一张图

    上拉加载的本质是页面触底,或者快要触底时的动作

    判断页面触底我们需要先了解一下下面几个属性

  • scrollTop:滚动视窗的高度距离window顶部的距离,它会随着往上滚动而不断增加,初始值是0,它是一个变化的值
  • clientHeight:它是一个定值,表示屏幕可视区域的高度;
  • scrollHeight:页面不能滚动时也是存在的,此时scrollHeight等于clientHeight。scrollHeight表示body所有元素的总长度(包括body元素自身的padding)
  • 综上我们得出一个触底公式:

    1
    scrollTop + clientHeight >= scrollHeight

    简单实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    let clientHeight = document.documentElement.clientHeight //浏览器高度
    let scrollHeight = document.body.scrollHeight
    let scrollTop = document.documentElement.scrollTop

    let distance = 50 //距离视窗还用50的时候,开始触发;

    if (scrollTop + clientHeight >= scrollHeight - distance) {
    console.log('开始加载数据')
    }

    下拉刷新
    下拉刷新的本质是页面本身置于顶部时,用户下拉时需要触发的动作

    关于下拉刷新的原生实现,主要分成三步:

  • 监听原生touchstart事件,记录其初始位置的值,e.touches[0].pageY;
  • 监听原生touchmove事件,记录并计算当前滑动的位置值与初始位置值的差值,大于0表示向下拉动,并借助CSS3的translateY属性使元素跟随手势向下滑动对应的差值,同时也应设置一个允许滑动的最大值;
  • 监听原生touchend事件,若此时元素滑动达到最大值,则触发callback,同时将translateY重设为0,元素回到初始位置
  • 举个例子:
    html 结构如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <main>
    <p class="refreshText"></p>
    <ul id="refreshContainer">
    <li>111</li>
    <li>222</li>
    <li>333</li>
    <li>444</li>
    <li>555</li>
    ...
    </ul>
    </main>

    监听touchstart事件,记录初始的值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    var _element = document.getElementById('refreshContainer'),
    _refreshText = document.querySelector('.refreshText'),
    _startPos = 0, // 初始的值
    _transitionHeight = 0 // 移动的距离

    _element.addEventListener(
    'touchstart',
    function (e) {
    _startPos = e.touches[0].pageY // 记录初始位置
    _element.style.position = 'relative'
    _element.style.transition = 'transform 0s'
    },
    false
    )

    监听touchmove移动事件,记录滑动差值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    _element.addEventListener(
    'touchmove',
    function (e) {
    // e.touches[0].pageY 当前位置
    _transitionHeight = e.touches[0].pageY - _startPos // 记录差值

    if (_transitionHeight > 0 && _transitionHeight < 60) {
    _refreshText.innerText = '下拉刷新'
    _element.style.transform = 'translateY(' + _transitionHeight + 'px)'

    if (_transitionHeight > 55) {
    _refreshText.innerText = '释放更新'
    }
    }
    },
    false
    )

    最后,就是监听touchend离开的事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    _element.addEventListener(
    'touchend',
    function (e) {
    _element.style.transition = 'transform 0.5s ease 1s'
    _element.style.transform = 'translateY(0px)'
    _refreshText.innerText = '更新中...'
    // todo...
    },
    false
    )

    从上面可以看到,在下拉到松手的过程中,经历了三个阶段:

  • 当前手势滑动位置与初始位置差值大于零时,提示正在进行下拉刷新操作
  • 下拉到一定值时,显示松手释放后的操作提示
  • 下拉到达设定最大值松手时,执行回调,提示正在进行更新操作
  • 3.案例

    在实际开发中,我们更多的是使用第三方库,下面以better-scroll进行举例:

    HTML 结构

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <div id="position-wrapper">
    <div>
    <p class="refresh">下拉刷新</p>
    <div class="position-list">
    <!--列表内容-->
    </div>
    <p class="more">查看更多</p>
    </div>
    </div>

    实例化上拉下拉插件,通过use来注册插件

    1
    2
    3
    4
    5
    import BScroll from '@better-scroll/core'
    import PullDown from '@better-scroll/pull-down'
    import PullUp from '@better-scroll/pull-up'
    BScroll.use(PullDown)
    BScroll.use(PullUp)

    实例化BetterScroll,并传入相关的参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    let pageNo = 1,pageSize = 10,dataList = [],isMore = true;
    var scroll= new BScroll("#position-wrapper",{
    scrollY:true,//垂直方向滚动
    click:true,//默认会阻止浏览器的原生click事件,如果需要点击,这里要设为true
    pullUpLoad:true,//上拉加载更多
    pullDownRefresh:{
    threshold:50,//触发pullingDown事件的位置
    stop:0//下拉回弹后停留的位置
    }
    });
    //监听下拉刷新
    scroll.on("pullingDown",pullingDownHandler);
    //监测实时滚动
    scroll.on("scroll",scrollHandler);
    //上拉加载更多
    scroll.on("pullingUp",pullingUpHandler);

    async function pullingDownHandler(){
    dataList=[];
    pageNo=1;
    isMore=true;
    $(".more").text("查看更多");
    await getlist();//请求数据
    scroll.finishPullDown();//每次下拉结束后,需要执行这个操作
    scroll.refresh();//当滚动区域的dom结构有变化时,需要执行这个操作
    }
    async function pullingUpHandler(){
    if(!isMore){
    $(".more").text("没有更多数据了");
    scroll.finishPullUp();//每次上拉结束后,需要执行这个操作
    return;
    }
    pageNo++;
    await this.getlist();//请求数据
    scroll.finishPullUp();//每次上拉结束后,需要执行这个操作
    scroll.refresh();//当滚动区域的dom结构有变化时,需要执行这个操作
    }
    function scrollHandler(){
    if(this.y>50) $('.refresh').text("松手开始加载");
    else $('.refresh').text("下拉刷新");
    }
    function getlist(){
    //返回的数据
    let result=....;
    dataList=dataList.concat(result);
    //判断是否已加载完
    if(result.length<pageSize) isMore=false;
    //将dataList渲染到html内容中
    }

    注意点:
    使用better-scroll实现下拉刷新、上拉加载时要注意以下几点:

  • wrapper里必须只有一个子元素
  • 子元素的高度要比wrapper要高
  • 使用的时候,要确定DOM元素是否已经生成,必须要等到DOM渲染完成后,再new BScroll()
  • 滚动区域的DOM元素结构有变化后,需要执行刷新 refresh()
  • 上拉或者下拉,结束后,需要执行finishPullUp()或者finishPullDown(),否则将不会执行下次操作
  • better-scroll,默认会阻止浏览器的原生click事件,如果滚动内容区要添加点击事件,需要在实例化属性里设置click:true
  • 什么是单点登录

    1.是什么?

    单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一

    SSO 的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统

    SSO 一般都需要一个独立的认证中心(passport),子系统的登录均得通过 passport,子系统本身将不参与登录操作

    当一个系统成功登录以后,passport将会颁发一个令牌给各个子系统,子系统可以拿着令牌会获取各自的受保护资源,为了减少频繁认证,各个子系统在被passport授权以后,会建立一个局部会话,在一定时间内可以无需再次向passport发起认证

    上图有四个系统,分别是Application1Application2Application3、和SSO,当Application1Application2Application3需要登录时,将跳到SSO系统,SSO系统完成登录,其他的应用系统也就随之登录了

    淘宝、天猫都属于阿里旗下,当用户登录淘宝后,再打开天猫,系统便自动帮用户登录了天猫,这种现象就属于单点登录

    2.如何实现

    同域名下的单点登录

    cookiedomain属性设置为当前域的父域,并且父域的cookie会被子域所共享。path属性默认为web应用的上下文路径

    利用 Cookie的这个特点,没错,我们只需要将Cookiedomain属性设置为父域的域名(主域名),同时将Cookiepath属性设置为根路径,将 Session ID(或 Token)保存到父域中。这样所有的子域应用就都可以访问到这个Cookie

    不过这要求应用系统的域名需建立在一个共同的主域名之下,如 tieba.baidu.commap.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
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 获取 token
    var token = result.data.token

    // 动态创建一个不可见的iframe,在iframe中加载一个跨域HTML
    var iframe = document.createElement('iframe')
    iframe.src = 'http://app1.com/localstorage.html'
    document.body.append(iframe)
    // 使用postMessage()方法将token传递给iframe
    setTimeout(function () {
    iframe.contentWindow.postMessage(token, 'http://app1.com')
    }, 4000)
    setTimeout(function () {
    iframe.remove()
    }, 6000)

    // 在这个iframe所加载的HTML中绑定一个事件监听器,当事件被触发时,把接收到的token数据写入localStorage
    window.addEventListener(
    'message',
    function (event) {
    localStorage.setItem('token', event.data)
    },
    false
    )

    前端通过 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