Skip to content

Javascript篇

1. 谈谈 JavaScript 中的类型转换机制

1.1 概述

前面我们讲到,JS中有六种简单数据类型:undefinednullbooleanstringnumbersymbol,以及引用类型:object

但是我们在声明的时候只有一种数据类型,只有到运行期间才会确定当前类型

js
const x = y ? 1 : a

上面代码中,x的值在编译阶段是无法获取的,只有等到程序运行时才能知道

虽然变量的数据类型是不确定的,但是各种运算符对数据类型是有要求的,如果运算子的类型与预期不符合,就会触发类型转换机制

常见的类型转换有:

  • 强制转换(显示转换)

  • 自动转换(隐式转换)

1.2 显示转换

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

  • Number()

  • parseInt()

  • String()

  • Boolean()

Number()

将任意类型的值转化为数值

先给出类型转换规则:

实践一下:

js
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函数逐个解析字符,遇到不能转换的字符就停下来

js
Number.parseInt('32a3') // 32

String()

可以将任意类型的值转化成字符串

给出转换规则图:

实践一下:

js
// 数值:转为相应的字符串
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()

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

实践一下:

js
Boolean(undefined) // false
Boolean(null) // false
Boolean(0) // false
Boolean(Number.NaN) // false
Boolean('') // false
Boolean({}) // true
Boolean([]) // true
Boolean(new Boolean(false)) // true

1.3 隐式转换

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

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

  • 比较运算(==!=><)、ifwhile需要布尔值地方

  • 算术运算(+-*/%

除了上面的场景,还要求运算符两边的操作数不是同一类型

自动转换为布尔值

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

可以得出个小结:

  • undefined

  • null

  • false

  • +0

  • -0

  • NaN

  • ""

除了上面几种会被转化成false,其他都换被转化成true

自动转换成字符串

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

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

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

js
`5${1}` // '51'
`5${true}` // "5true"
`5${false}` // "5false"
`5${{}}` // "5[object Object]"
`5${[]}` // "5"
`5${function () {}}` // "5function (){}"
`5${undefined}` // "5undefined"
`5${null}` // "5null"

自动转换成数值

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

js
'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

2. == 和 ===区别,分别在什么情况使用

2.1 等于操作符

等于操作符用两个等于号( == )表示,如果操作数相等,则会返回 true前面文章,我们提到在JavaScript中存在隐式转换。等于操作符(==)在比较中会先进行类型转换,再确定操作数是否相等

遵循以下规则:

如果任一操作数是布尔值,则将其转换为数值再比较是否相等

js
const result1 = (true == 1) // true

如果一个操作数是字符串,另一个操作数是数值,则尝试将字符串转换为数值,再比较是否相等

js
const result1 = ('55' == 55) // true

如果一个操作数是对象,另一个操作数不是,则调用对象的 valueOf()方法取得其原始值,再根据前面的规则进行比较

js
const obj = { valueOf() { return 1 } }
const result1 = (obj == 1) // true

nullundefined相等

js
const result1 = (undefined == null) // true

如果有任一操作数是 NaN ,则相等操作符返回 false

js
const result1 = (Number.NaN == Number.NaN) // false

如果两个操作数都是对象,则比较它们是不是同一个对象。如果两个操作数都指向同一个对象,则相等操作符返回true

js
const obj1 = { name: 'xxx' }
const obj2 = { name: 'xxx' }
const result1 = (obj1 == obj2) // false

下面进一步做个小结:

  • 两个都为简单类型,字符串和布尔值都会转换成数值,再比较

  • 简单类型与引用类型比较,对象转化成其原始类型的值,再比较

  • 两个都为引用类型,则比较它们是否指向同一个对象

  • null 和 undefined 相等

  • 存在 NaN 则返回 false

2.2 全等操作符

全等操作符由 3 个等于号( === )表示,只有两个操作数在不转换的前提下相等才返回 true。即类型相同,值也需相同

js
const result1 = ('55' === 55) // false,不相等,因为数据类型不同
const result2 = (55 === 55) // true,相等,因为数据类型相同值也相同

undefinednull 与自身严格相等

js
const result1 = (null === null) // true
const result2 = (undefined === undefined) // true

2.3 区别

相等操作符(==)会做类型转换,再进行值的比较,全等运算符不会做类型转换

js
const result1 = ('55' === 55) // false,不相等,因为数据类型不同
const result2 = (55 === 55) // true,相等,因为数据类型相同值也相同

nullundefined 比较,相等操作符(==)为true,全等为false

js
const result1 = (undefined == null) // true
const result2 = (undefined === null) // false

小结

相等运算符隐藏的类型转换,会带来一些违反直觉的结果

js
'' == '0' // false
0 == '' // true
0 == '0' // true

false == 'false' // false
false == '0' // true

undefined == false // false
false == null // false
undefined == null // true

' \t\r\n' == 0 // true

但在比较null的情况的时候,我们一般使用相等操作符==

js
const obj = {}

if (obj.x == null)
  console.log('1') // 执行

等同于下面写法

js
if(obj.x === null || obj.x === undefined) {
    ...
}

使用相等操作符(==)的写法明显更加简洁了

所以,除了在比较对象属性为null或者undefined的情况下,我们可以使用相等操作符(==),其他情况建议一律使用全等操作符(===)

3. 深拷贝浅拷贝的区别?如何实现一个深拷贝?

3.1 数据类型存储

前面文章我们讲到,JavaScript中存在两大数据类型:

  • 基本类型

  • 引用类型

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

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

3.2 浅拷贝

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

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

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

下面简单实现一个浅拷贝

js
function shallowClone(obj) {
  const newObj = {}
  for (const prop in obj) {
    if (obj.hasOwnProperty(prop))
      newObj[prop] = obj[prop]
  }
  return newObj
}

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

  • Object.assign

  • Array.prototype.slice(), Array.prototype.concat()

  • 使用拓展运算符实现的复制

Object.assign

js
const obj = {
  age: 18,
  nature: ['smart', 'good'],
  names: {
    name1: 'fx',
    name2: 'xka'
  },
  love() {
    console.log('fx is a great girl')
  }
}
const newObj = Object.assign({}, fxObj)

slice()

js
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()

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

拓展运算符

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

3.3 深拷贝

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

常见的深拷贝方式有:

  • _.cloneDeep()

  • jQuery.extend()

  • JSON.stringify()

  • 手写循环递归

_.cloneDeep()

js
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()

js
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()

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

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

js
const obj = {
  name: 'A',
  name1: undefined,
  name3() {},
  name4: Symbol('A')
}
const obj2 = JSON.parse(JSON.stringify(obj))
console.log(obj2) // {name: "A"}

循环递归

js
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)
  const cloneObj = new obj.constructor()
  // 找到的是所属类原型上的constructor,而原型上的 constructor指向的是当前类本身
  hash.set(obj, cloneObj)
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      // 实现一个递归拷贝
      cloneObj[key] = deepClone(obj[key], hash)
    }
  }
  return cloneObj
}

4. 说说你对闭包的理解?闭包使用场景

4.1 闭包是什么

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

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

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

下面给出一个简单的例子

js
function init() {
  const name = 'Mozilla' // name 是一个被 init 创建的局部变量
  function displayName() { // displayName() 是内部函数,一个闭包
    alert(name) // 使用了父函数中声明的变量
  }
  displayName()
}
init()

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

4.2 使用场景

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

  • 创建私有变量

  • 延长变量的生命周期

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

下面举个例子:

在页面上添加一些可以调整字号的按钮

js
function makeSizer(size) {
  return function () {
    document.body.style.fontSize = `${size}px`
  }
}

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

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

柯里化函数

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

js
// 假设我们有一个求长方形面积的函数
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中,没有支持声明私有变量,但我们可以使用闭包来模拟私有方法

下面举个例子:

js
const Counter = (function () {
  let privateCounter = 0
  function changeBy(val) {
    privateCounter += val
  }
  return {
    increment() {
      changeBy(1)
    },
    decrement() {
      changeBy(-1)
    },
    value() {
      return privateCounter
    }
  }
})()

const Counter1 = makeCounter()
const 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 是维护它们各自的独立性的,每次调用其中一个计数器时,通过改变这个变量的值,会改变这个闭包的词法环境,不会影响另一个闭包中的变量

其他

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

4.3 注意事项

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

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

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

js
function MyObject(name, message) {
  this.name = name.toString()
  this.message = message.toString()
  this.getName = function () {
    return this.name
  }

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

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

js
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
}

5. Javascript如何实现继承?

5.1 什么是继承

继承(inheritance)是面向对象软件技术当中的一个概念。

如果一个类别B“继承自”另一个类别A,就把这个B称为“A的子类”,而把A称为“B的父类别”也可以称“A是B的超类”

  • 继承的优点

继承可以使得子类具有父类别的各种属性和方法,而不需要再次编写相同的代码

在子类别继承父类别的同时,可以重新定义某些属性,并重写某些方法,即覆盖父类别的原有属性和方法,使其获得与父类别不同的功能

虽然Javascript并不是真正的面向对象语言,但它天生的灵活性,使应用场景更加丰富

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

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

js
class Car {
  constructor(color, speed) {
    this.color = color
    this.speed = speed
    // ...
  }
}

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

js
// 货车
class Truck extends Car {
  constructor(color, speed) {
    super(color, speed)
    this.Container = true // 货箱
  }
}

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

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

js
class Truck extends Car {
  constructor(color, speed) {
    super(color, speed)
    this.color = 'black' // 覆盖
    this.Container = true // 货箱
  }
}

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

5.2 实现方式

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

  • 原型链继承

  • 构造函数继承(借助 call)

  • 组合继承

  • 原型式继承

  • 寄生式继承

  • 寄生组合式继承

原型链继承

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

举个例子

js
function Parent() {
  this.name = 'parent1'
  this.play = [1, 2, 3]
}
function Child() {
  this.type = 'child2'
}
Child1.prototype = new Parent()
console.log(new Child())

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

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

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

构造函数继承

借助 call调用Parent函数

js
function Parent() {
  this.name = 'parent1'
}

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

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

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

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

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

组合继承

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

js
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
const s3 = new Child3()
const 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方法实现普通对象的继承

同样举个例子

js
const parent4 = {
  name: 'parent4',
  friends: ['p1', 'p2', 'p3'],
  getName() {
    return this.name
  }
}

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

const 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方法实现的是浅拷贝,多个实例的引用类型属性指向相同的内存,存在篡改的可能

寄生式继承

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

js
const parent5 = {
  name: 'parent5',
  friends: ['p1', 'p2', 'p3'],
  getName() {
    return this.name
  }
}

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

const person5 = clone(parent5)

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

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

寄生组合式继承

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

js
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
}

const 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的继承

js
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实际采用的也是寄生组合继承方式,因此也证明了这种方式是较优的解决继承的方式

5.3 总结

下面以一张图作为总结:

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

6. 谈谈对this对象的理解

6.1 定义

函数的 this 关键字在 JavaScript 中的表现略有不同,此外,在严格模式和非严格模式之间也会有一些差别在绝大多数情况下,函数的调用方式决定了 this 的值(运行时绑定)this 关键字是函数运行时自动生成的一个内部对象,只能在函数内部使用,总指向调用它的对象

举个例子:

js
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一旦被确定了,就不可以再更改

js
const a = 10
const obj = {
  a: 20
}

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

fn()

6.2 绑定规则

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

  • 默认绑定

  • 隐式绑定

  • new绑定

  • 显示绑定

默认绑定

全局环境中定义person函数,内部使用this关键字

js
const name = 'Jenny'
function person() {
  return this.name
}
console.log(person()) // Jenny

上述代码输出Jenny,原因是调用函数的对象在游览器中位window,因此this指向window,所以输出Jenny

注意:

严格模式下,不能将全局对象用于默认绑定,this会绑定到undefined,只有函数运行在非严格模式下,默认绑定才能绑定到全局对象

隐式绑定

函数还可以作为某个对象的方法调用,这时this就指这个上级对象

js
function test() {
  console.log(this.x)
}

const obj = {}
obj.x = 1
obj.m = test

obj.m() // 1

这个函数中包含多个对象,尽管这个函数是被最外层的对象所调用,this指向的也只是它上一级的对象

js
const o = {
  a: 10,
  b: {
    fn() {
      console.log(this.a) // undefined
    }
  }
}
o.b.fn()

上述代码中,this的上一级对象为bb内部并没有a变量的定义,所以输出undefined

这里再举一种特殊情况

js
const o = {
  a: 10,
  b: {
    a: 12,
    fn() {
      console.log(this.a) // undefined
      console.log(this) // window
    }
  }
}
const j = o.b.fn
j()

此时this指向的是window,这里的大家需要记住,this永远指向的是最后调用它的对象,虽然fn是对象b的方法,但是fn赋值给j时候并没有执行,所以最终指向window

new绑定

通过构建函数new关键字生成一个实例对象,此时this指向这个实例对象

js
function test() {
  this.x = 1
}

const obj = new test()
obj.x // 1

上述代码之所以能过输出1,是因为new关键字改变了this的指向

这里再列举一些特殊情况:

new过程遇到return一个对象,此时this指向为返回的对象

js
function fn() {
  this.user = 'xxx'
  return {}
}
const a = new fn()
console.log(a.user) // undefined

如果返回一个简单类型的时候,则this指向实例对象

js
function fn() {
  this.user = 'xxx'
  return 1
}
const a = new fn()
console.log(a.user) // xxx

注意的是null虽然也是对象,但是此时new仍然指向实例对象

js
function fn() {
  this.user = 'xxx'
  return null
}
const a = new fn()
console.log(a.user) // xxx

显示修改

apply()、call()、bind()是函数的一个方法,作用是改变函数的调用对象。它的第一个参数就表示改变后的调用这个函数的对象。因此,这时this指的就是这第一个参数

js
const x = 0
function test() {
  console.log(this.x)
}

const obj = {}
obj.x = 1
obj.m = test
obj.m.apply(obj) // 1

关于apply、call、bind三者的区别,我们后面再详细说

6.3 箭头函数

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

举个例子:

js
const obj = {
  sayThis: () => {
    console.log(this)
  }
}

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

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

下面举个例子:

绑定事件监听

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

上述可以看到,我们其实是想要this为点击的button,但此时this指向了window包括在原型上添加方法时候,此时this指向window

js
Cat.prototype.sayName = () => {
  console.log(this === window) // true
  return this.name
}
const cat = new Cat('mm')
cat.sayName()

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

6.4 优先级

隐式绑定 VS 显式绑定

js
function foo() {
  console.log(this.a)
}

const obj1 = {
  a: 2,
  foo
}

const obj2 = {
  a: 3,
  foo
}

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

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

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

new绑定 VS 隐式绑定

js
function foo(something) {
  this.a = something
}

const obj1 = {
  foo
}

const obj2 = {}

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

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

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

可以看到,new绑定的优先级>隐式绑定new绑定 VS 显式绑定因为newapply、call无法一起使用,但硬绑定也是显式绑定的一种,可以替换测试

js
function foo(something) {
  this.a = something
}

const obj1 = {}

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

const 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绑定优先级 > 显示绑定优先级 > 隐式绑定优先级 > 默认绑定优先级

7. 说说JavaScript中的事件模型

7.1 事件与事件流

javascript中的事件,可以理解就是在HTML文档或者浏览器中发生的一种交互操作,使得网页具备互动性, 常见的有加载事件、鼠标事件、自定义事件等由于DOM是一个树结构,如果在父子节点绑定事件时候,当触发子节点的时候,就存在一个顺序问题,这就涉及到了事件流的概念

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

  • 事件捕获阶段(capture phase)

  • 处于目标阶段(target phase)

  • 事件冒泡阶段(bubbling phase)

事件冒泡是一种从下往上的传播方式,由最具体的元素(触发节点)然后逐渐向上传播到最不具体的那个节点,也就是DOM中最高层的父节点

js
<!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和它的父元素,加入点击事件

js
const 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')
}

点击按钮,输出如下

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

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

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

7.2 事件模型

事件模型可以分为三种:

  • 原始事件模型(DOM0级)

  • 标准事件模型(DOM2级)

  • IE事件模型(基本不用)

原始事件模型

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

  • HTML代码中直接绑定
js
<input type="button" onclick="fun()">
  • 通过JS代码绑定
js
const btn = document.getElementById('.btn')
btn.onclick = fun

特性

  • 绑定速度快

DOM0级事件具有很好的跨浏览器优势,会以最快的速度绑定,但由于绑定速度太快,可能页面还未完全加载出来,以至于事件可能无法正常运行

  • 只支持冒泡,不支持捕获

  • 同一个类型的事件只能绑定一次

jsx
<input type="button" id="btn" onclick="fun1()">

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

如上,当希望为同一个元素绑定多个同类型事件的时候(上面的这个btn元素绑定2个点击事件),是不被允许的,后绑定的事件会覆盖之前的事件删除 DOM0 级事件处理程序只要将对应事件属性置为null即可

js
btn.onclick = null

标准事件模型

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

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

  • 事件处理阶段:事件到达目标元素, 触发目标元素的监听函数

  • 事件冒泡阶段:事件从目标元素冒泡到document, 依次检查经过的节点是否绑定了事件监听函数,如果有则执行

事件绑定监听函数的方式如下:

js
addEventListener(eventType, handler, useCapture)

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

js
removeEventListener(eventType, handler, useCapture)

参数如下:

  • eventType指定事件类型(不要加on)

  • handler是事件处理函数

  • useCapture是一个boolean用于指定是否在捕获阶段进行处理,一般设置为false与IE浏览器保持一致

举个例子:

js
const btn = document.getElementById('.btn')
btn.addEventListener('click', showMessage, false)
btn.removeEventListener('click', showMessage, false)

特性

  • 可以在一个DOM元素上绑定多个事件处理器,各自并不会冲突
js
btn.addEventListener('click', showMessage1, false)
btn.addEventListener('click', showMessage2, false)
btn.addEventListener('click', showMessage3, false)
  • 执行时机

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

下面举个例子:

html
<div id="div">
  <p id="p">
    <span id="span">Click Me!</span>
  </p>
</div>

设置点击事件

js
const div = document.getElementById('div')
const p = document.getElementById('p')

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

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

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

txt
P 3
DIV 3

可以看到,pdiv都是在冒泡阶段响应了事件,由于冒泡的特性,裹在里层的p率先做出响应如果把第三个参数都改为true

js
div.addEventListener('click', onClickFn, true)
p.addEventListener('click', onClickFn, true)

输出如下

txt
DIV 1
P 1

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

IE事件模型

IE事件模型共有两个过程:

  • 事件处理阶段:事件到达目标元素, 触发目标元素的监听函数。

  • 事件冒泡阶段:事件从目标元素冒泡到document, 依次检查经过的节点是否绑定了事件监听函数,如果有则执行

事件绑定监听函数的方式如下:

js
attachEvent(eventType, handler)

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

js
detachEvent(eventType, handler)

举个例子:

js
const btn = document.getElementById('.btn')
btn.attachEvent('onclick', showMessage)
btn.detachEvent('onclick', showMessage)

8. web常见的攻击方式有哪些?如何防御?

8.1 是什么

Web攻击(WebAttack)是针对用户上网行为或网站服务器等设备进行攻击的行为

如植入恶意代码,修改网站权限,获取网站用户隐私信息等等

Web应用程序的安全性是任何基于Web业务的重要组成部分

确保Web应用程序安全十分重要,即使是代码中很小的 bug 也有可能导致隐私信息被泄露

站点安全就是为保护站点不受未授权的访问、使用、修改和破坏而采取的行为或实践

我们常见的Web攻击方式有

  • XSS (Cross Site Scripting) 跨站脚本攻击

  • CSRF(Cross-site request forgery)跨站请求伪造

  • SQL注入攻击

8.2 XSS

XSS,跨站脚本攻击,允许攻击者将恶意代码植入到提供给其它用户使用的页面中

XSS涉及到三方,即攻击者、客户端与Web应用XSS的攻击目标是为了盗取存储在客户端的cookie或者其他网站用于识别客户端身份的敏感信息。一旦获取到合法用户的信息后,攻击者甚至可以假冒合法用户与网站进行交互

举个例子:

一个搜索页面,根据url参数决定关键词的内容

html
<input type="text" value="<%= getParameter("keyword") %>">
<button>搜索</button>
<div>
  您搜索的关键词是:<%= getParameter("keyword") %>
</div>

这里看似并没有问题,但是如果不按套路出牌呢?

用户输入"><script>alert('XSS');</script>,拼接到 HTML 中返回给浏览器。形成了如下的 HTML:

html
<input type="text" value="" />
<script>
  alert('XSS')
</script>
">
<button>搜索</button>
<div>
  您搜索的关键词是:">
  <script>
    alert('XSS')
  </script>
</div>

浏览器无法分辨出 <script>alert('XSS');</script> 是恶意代码,因而将其执行,试想一下,如果是获取cookie发送对黑客服务器呢?根据攻击的来源,XSS攻击可以分成:

  • 存储型

  • 反射型

  • DOM 型

存储型

存储型 XSS 的攻击步骤:

  1. 攻击者将恶意代码提交到目标网站的数据库中

  2. 用户打开目标网站时,网站服务端将恶意代码从数据库取出,拼接在 HTML 中返回给浏览器

  3. 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行

  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作

这种攻击常见于带有用户保存数据的网站功能,如论坛发帖、商品评论、用户私信等

反射型 XSS

反射型 XSS 的攻击步骤:

  1. 攻击者构造出特殊的 URL,其中包含恶意代码

  2. 用户打开带有恶意代码的 URL 时,网站服务端将恶意代码从 URL 中取出,拼接在 HTML 中返回给浏览器

  3. 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行

  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作

反射型 XSS 跟存储型 XSS 的区别是:存储型 XSS 的恶意代码存在数据库里,反射型 XSS 的恶意代码存在 URL 里。

反射型 XSS 漏洞常见于通过 URL 传递参数的功能,如网站搜索、跳转等。

由于需要用户主动打开恶意的 URL 才能生效,攻击者往往会结合多种手段诱导用户点击。

POST 的内容也可以触发反射型 XSS,只不过其触发条件比较苛刻(需要构造表单提交页面,并引导用户点击),所以非常少见

DOM 型 XSS

DOM 型 XSS 的攻击步骤:

  1. 攻击者构造出特殊的 URL,其中包含恶意代码

  2. 用户打开带有恶意代码的 URL

  3. 用户浏览器接收到响应后解析执行,前端 JavaScript 取出 URL 中的恶意代码并执行

  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作

DOM 型 XSS 跟前两种 XSS 的区别:DOM 型 XSS 攻击中,取出和执行恶意代码由浏览器端完成,属于前端 JavaScript 自身的安全漏洞,而其他两种 XSS 都属于服务端的安全漏洞

XSS的预防

通过前面介绍,看到XSS攻击的两大要素:

  • 攻击者提交而恶意代码

  • 浏览器执行恶意代码

针对第一个要素,我们在用户输入的过程中,过滤掉用户输入的恶劣代码,然后提交给后端,但是如果攻击者绕开前端请求,直接构造请求就不能预防了

而如果在后端写入数据库前,对输入进行过滤,然后把内容给前端,但是这个内容在不同地方就会有不同显示

例如:

一个正常的用户输入了 5 < 7 这个内容,在写入数据库前,被转义,变成了 5 < 7在客户端中,一旦经过了 escapeHTML(),客户端显示的内容就变成了乱码( 5 < 7 )

在前端中,不同的位置所需的编码也不同。

  • 5 < 7 作为 HTML 拼接页面时,可以正常显示:
html
<div title="comment">5 &lt; 7</div>
  • 5 < 7 通过 Ajax 返回,然后赋值给 JavaScript 的变量时,前端得到的字符串就是转义后的字符。这个内容不能直接用于 Vue 等模板的展示,也不能直接用于内容长度计算。不能用于标题、alert 等

可以看到,过滤并非可靠的,下面就要通过防止浏览器执行恶意代码:

在使用 .innerHTML.outerHTMLdocument.write() 时要特别小心,不要把不可信的数据作为 HTML 插到页面上,而应尽量使用 .textContent.setAttribute() 等如果用 Vue/React 技术栈,并且不使用 v-html/dangerouslySetInnerHTML 功能,就在前端 render 阶段避免 innerHTMLouterHTML 的 XSS 隐患DOM 中的内联事件监听器,如 locationonclickonerroronloadonmouseover 等,<a> 标签的 href 属性,JavaScript 的 eval()setTimeout()setInterval() 等,都能把字符串作为代码运行。如果不可信的数据拼接到字符串中传递给这些 API,很容易产生安全隐患,请务必避免

jsx
<!-- 链接内包含恶意代码 -->
< a href=" ">1</ a>

<script>
// setTimeout()/setInterval() 中调用恶意代码
setTimeout("UNTRUSTED")
setInterval("UNTRUSTED")

// location 调用恶意代码
location.href = 'UNTRUSTED'

// eval() 中调用恶意代码
eval("UNTRUSTED")

8.3 CSRF

CSRF(Cross-site request forgery)跨站请求伪造:攻击者诱导受害者进入第三方网站,在第三方网站中,向被攻击网站发送跨站请求

利用受害者在被攻击网站已经获取的注册凭证,绕过后台的用户验证,达到冒充用户对被攻击的网站执行某项操作的目

一个典型的CSRF攻击有着如下的流程:

  • 受害者登录a.com,并保留了登录凭证(Cookie)

  • 攻击者引诱受害者访问了b.com

  • b.com 向 a.com 发送了一个请求:a.com/act=xx。浏览器会默认携带a.com的Cookie

  • a.com接收到请求后,对请求进行验证,并确认是受害者的凭证,误以为是受害者自己发送的请求

  • a.com以受害者的名义执行了act=xx

  • 攻击完成,攻击者在受害者不知情的情况下,冒充受害者,让a.com执行了自己定义的操作

csrf可以通过get请求,即通过访问img的页面后,浏览器自动访问目标地址,发送请求同样,也可以设置一个自动提交的表单发送post请求,如下:

html
<form action="http://bank.example/withdraw" method="POST">
  <input type="hidden" name="account" value="xiaoming" />
  <input type="hidden" name="amount" value="10000" />
  <input type="hidden" name="for" value="hacker" />
</form>
<script>
  document.forms[0].submit()
</script>

访问该页面后,表单会自动提交,相当于模拟用户完成了一次POST操作还有一种为使用a标签的,需要用户点击链接才会触发

访问该页面后,表单会自动提交,相当于模拟用户完成了一次POST操作

html
< a href="http://test.com/csrf/withdraw.php?amount=1000&for=hacker" taget="_blank"> 重磅消息!!
<a />

CSRF的特点

  • 攻击一般发起在第三方网站,而不是被攻击的网站。被攻击的网站无法防止攻击发生

  • 攻击利用受害者在被攻击网站的登录凭证,冒充受害者提交操作;而不是直接窃取数据

  • 整个过程攻击者并不能获取到受害者的登录凭证,仅仅是“冒用”

  • 跨站请求可以用各种方式:图片URL、超链接、CORS、Form提交等等。部分请求方式可以直接嵌入在第三方论坛、文章中,难以进行追踪

CSRF的预防

CSRF通常从第三方网站发起,被攻击的网站无法防止攻击发生,只能通过增强自己网站针对CSRF的防护能力来提升安全性

防止csrf常用方案如下:

  • 阻止不明外域的访问

    • 同源检测

    • Samesite Cookie

  • 提交时要求附加本域才能获取的信息

    • CSRF Token

    • 双重Cookie验证

这里主要讲讲token这种形式,流程如下:

  • 用户打开页面的时候,服务器需要给这个用户生成一个Token

  • 对于GET请求,Token将附在请求地址之后。对于 POST 请求来说,要在 form 的最后加上

html
<input type="”hidden”" name="”csrftoken”" value="”tokenvalue”" />
  • 当用户从客户端得到了Token,再次提交给服务器的时候,服务器需要判断Token的有效性

8.4 SQL注入

Sql 注入攻击,是通过将恶意的 Sql查询或添加语句插入到应用的输入参数中,再在后台 Sql服务器上解析执行进行的攻击

流程如下所示:

  • 找出SQL漏洞的注入点

  • 判断数据库的类型以及版本

  • 猜解用户名和密码

  • 利用工具查找Web后台管理入口

  • 入侵和破坏

预防方式如下:

  • 严格检查输入变量的类型和格式

  • 过滤和转义特殊字符

  • 对访问数据库的Web应用程序采用Web应用防火墙

上述只是列举了常见的web攻击方式,实际开发过程中还会遇到很多安全问题,对于这些问题, 切记不可忽视