JS 知识体系

发表于:2023-03-15
字数统计:42.8k 字
阅读时长:1.8 小时
阅读量:1442

本章会整理前端面试中大部分的面试题,题目来源于网络,对于一些回答的比较好的解释会直接使用,对于不太好的或者不太好理解的回答会稍微的修改,另外现在的问题处于填空的状态,我会从上至下不间断更新。

数据类型

JS 数据类型分为两大类:

原始类型:String、Number、Boolean、Undefined、Null、Symbol(es6 新增,表示独一无二的值)、Bigint(es10 新增)

引用类型:Object

其中 Object 中又包含了很多子类型,比如 Array、Date、Function、Math、Map、Set 等等,也就不一一列出了。

存储方式

原始数据类型:直接存储在栈(stack)中,占据空间小、大小固定,属于被频繁使用数据

引用数据类型:同时存储在栈(stack)和堆(heap)中,占据空间大、大小不固定。引用数据类型在栈中存储指针,指向堆中该实体的地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。

类型判断

类型判断有好几种方式

  • typeOf
  • instanceof
  • constructor
  • Object.prototype.toString.call()

typeOf

原始类型中除了 null,其它类型都可以通过 typeof 来判断

引用类型除了 函数 都会显示 object

console.log(typeof 1); // number
console.log(typeof true); // boolean
console.log(typeof "hello"); // string
console.log(typeof undefined); // undefined
console.log(typeof null); // object null 被 typeof 解释为 object
console.log(typeof {}); // object
console.log(typeof function () {}); // function
console.log(typeof []); // object 数组被解释为 object

instanceof

能正确判断引用类型,不能精准判断原始数据类型

原理:通过原型链的方式来判断是否为构建函数的实例

console.log({} instanceof Object); // true
console.log(function () {} instanceof Function); // true
console.log(1 instanceof Number); // false
console.log(true instanceof Boolean); // false
console.log("hello" instanceof String); // false
console.log(Array.isArray([])); // true

constructor

通过判断 constructor 确定数据类型,不可靠在于,创建对象更改了原型。

console.log((1).constructor === Number); // true
console.log(true.constructor === Boolean); // true
console.log("str".constructor === String); // true
console.log([].constructor === Array); // true
console.log(function () {}.constructor === Function); // true
console.log({}.constructor === Object); // true

function Fn() {}

Fn.prototype = [];

const f = new Fn();

console.log(f.constructor === Fn); // false
console.log(f.constructor === Array); // true

Object.prototype.toString.call()

前几种方式或多或少都存在一些缺陷,Object.prototype.toString.call() 综合来看是最佳选择,能判断的类型最完整。

const toStr = Object.prototype.toString;

console.log(toStr.call(1)); // [object Number]
console.log(toStr.call(true)); // [object Boolean]
console.log(toStr.call("hello")); // [object String]
console.log(toStr.call([])); // [object Array]
console.log(toStr.call({})); // [object Object]
console.log(toStr.call(() => {})); // [object Function]
console.log(toStr.call(null)); // [object Null]
console.log(toStr.call(undefined)); // [object Undefined]

类型转换

类型转换分为两种情况,分别为强制转换隐式转换

强制转换

指的是将一个数据类型强制转换为其他的数据类型

Number(false) 	// -> 0
Number('1') 		// -> 1
Number('zb') 		// -> NaN
 (1).toString() 	// '1'
Boolean('0') 		// true
Boolean(0)			// false

这部分是日常常用的内容,就不具体展开说了,主要记住强制转数字和布尔值的规则就行。

转布尔值规则:

  • undefinednullfalseNaN''0-0 都转为 false
  • 其他所有值都转为 true,包括所有对象。

转数字规则:

  • true 为 1,false 为 0
  • null 为 0,undefinedNaNsymbol 报错
  • 字符串看内容,如果是数字或者进制值就正常转,否则就 NaN
  • 对象的规则隐式转换再讲

除了String()和Number()以外,还有其他方法可以实现字符串和数字之间的显示转换:

转为数字:

var a = 1+'3.14' // 13.14

转为字符串:

var b = 10 + ''  // '10'

隐式转换

何为隐式转换?

其实就是不需我们手动转换,而由编译器自动转换的方式就称为隐式转换。

先从一道面试题说起

定义一个变量a,使得下面的表达式结果为true

a == 1 && a == 2 && a == 3

好像触碰到知识盲区了。。。没关系,先放下吧,来看看几个更坑的

[] == ![] // true

[] == 0 // true

[2] == 2 // true

['0'] == false // true

'0' == false // true

[] == false // true

[null] == 0 // true

null == 0 // false

[null] == false // true

null == false // false

[undefined] == false // true

undefined == false // false

一脸懵逼? 不要紧!接下来带你完完全全的认识 js 的隐式转换

js 隐式转换规则

ToString

ToNumber

ToBoolean

ToPrimitive

我们需要先了解一下js数据类型之间转换的基本规则,比如数字、字符串、布尔型、数组、对象之间的相互转换。

ToString

这里所说的 ToString 可不是对象的 toString 方法,而是指其他类型的值转换为字符串类型的操作。

这里我们讨论 nullundefined布尔型数字数组普通对象转换为字符串的规则

  • null:转为 "null"
  • undefined:转为 "undefined"
  • 布尔类型:truefalse 分别被转为 "true""false"
  • 数字类型:转为数字的字符串形式,如 10 转为 "10"1e21 转为 "1e+21"
  • 数组:转为字符串是将所有元素按照","连接起来,相当于调用数组的 Array.prototype.join() 方法,如 [1, 2, 3]转为 "1,2,3",空数组 [] 转为空字符串,数组中的 nullundefined,会被当做空字符串处理
  • 普通对象:转为字符串相当于直接使用 Object.prototype.toString(),返回 "[object Object]"
String(null) // 'null'
String(undefined) // 'undefined'
String(true) // 'true'
String(10) // '10'
String(1e21) // '1e+21'
String([1,2,3]) // '1,2,3'
String([]) // ''
String([null]) // ''
String([1, undefined, 3]) // '1,,3'
String({}) // '[object Objecr]'

对象的 toString 方法,满足 ToString 操作的规则。

注意:上面所说的规则是在默认的情况下,如果修改默认的 toString() 方法,会导致不同的结果

ToNumber

ToNumber 指其他类型转换为数字类型的操作。

  • null: 转为 0
  • undefined:转为 NaN
  • 字符串:如果是纯数字形式,则转为对应的数字,空字符转为 0, 否则一律按转换失败处理,转为 NaN
  • 布尔型:true false 被转为 1 0
  • 数组:数组首先会被转为原始类型,也就是 ToPrimitive,然后在根据转换后的原始类型按照上面的规则处理,关于 ToPrimitive,会在下文中讲到
  • 对象:同数组的处理
Number(null) // 0
Number(undefined) // NaN
Number('10') // 10
Number('10a') // NaN
Number('') // 0 
Number(true) // 1
Number(false) // 0
Number([]) // 0
Number(['1']) // 1
Number({}) // NaN

ToBoolean

ToBoolean 指其他类型转换为布尔类型的操作。

js中的假值只有 falsenullundefined''0-0 NaN,其它值转为布尔型都为 true

Boolean(null) // false
Boolean(undefined) // false
Boolean('') // flase
Boolean(NaN) // flase
Boolean(0) // flase
Boolean([]) // true
Boolean({}) // true
Boolean(Infinity) // true

ToPrimitive

ToPrimitive 指对象类型类型(如:对象、数组)转换为原始类型的操作。

  • 当对象类型需要被转为原始类型时,它会先查找对象的valueOf方法,如果valueOf方法返回原始类型的值,则ToPrimitive的结果就是这个值
  • 如果valueOf不存在或者valueOf方法返回的不是原始类型的值,就会尝试调用对象的toString方法,也就是会遵循对象的ToString规则,然后使用toString的返回值作为ToPrimitive的结果。

注意:对于不同类型的对象来说,ToPrimitive 的规则有所不同,比如 Date对象 会先调用 toString,具体可以参考 ECMA标准

如果 valueOf toString 都没有返回原始类型的值,则会抛出异常。

Number([]) // 0
Number(['10']) //10

前面说过,对象类型在转换为原始类型时会先 ToPrimitive,再根据转换后的原始类型 ToNumber

Number([]), 空数组会先调用 valueOf,但返回的是数组本身,不是原始类型,所以会继续调用 toString,得到空字符串,相当于 Number(''),所以转换后的结果为 "0"

Number(['10']) 相当于 Number('10'),得到结果 10

const obj1 = {
  valueOf () {
    return 100
  },
  toString () {
    return 101
  }
}
Number(obj1) // 100

obj1 valueOf 方法返回原始类型 100,所以 ToPrimitive 的结果为 100

const obj2 = {
  toString () {
    return 102
  }
}
Number(obj2) // 102

obj2 没有 valueOf,但存在 toString,并且返回一个原始类型,所以 Number(obj2) 结果为 102

const obj3 = {
  toString () {
    return {}
  }
}
Number(obj3) // TypeError

obj3 toString方法返回的不是一个原始类型,无法 ToPrimitive,所以会抛出错误

看到这里,以为自己完全掌握了?别忘了,那道面试题和那一堆让人懵逼的判断还没解决呢,本着对知识渴望的精神,继续往下看吧。

宽松相等(==)比较时的隐式转换规则

宽松相等(==)严格相等(===)的区别在于宽松相等会在比较中进行 隐式转换。现在我们来看看不同情况下的转换规则。

布尔类型和其他类型的相等比较

  • 只要 布尔类型 参与比较,该 布尔类型 的值首先会被转换为 数字类型
  • 根据 布尔类型 ToNumber 规则,true 转为 1false 转为 0
false == 0 // true
true == 1  // true
true == 2  // false

之前有的人可能觉得数字 2 是一个真值,所以 true == 2 应该为真,现在明白了,布尔类型 true 参与相等比较会先转为数字 1,相当于 1 == 2,结果当然是 false

我们平时在使用 if 判断时,一般都是这样写

const x = 10
if (x) {
  console.log(x)
}

这里 if(x) x 会在这里被转换为布尔类型,所以代码可以正常执行。但是如果写成这样:

const x = 10
if (x == true) {
  console.log(x)
}

代码不会按照预期执行,因为 x == true 相当于 10 == 1

数字类型和字符串类型的相等比较

  • 数字类型 字符串类型 做相等比较时,字符串类型 会被转换为 数字类型
  • 根据字符串的 ToNumber 规则,如果是纯数字形式的字符串,则转为对应的数字,空字符转为 0, 否则一律按转换失败处理,转为 NaN
0 == '' // true
1 == '1' // true
1e21 == '1e21' // true
Infinity == 'Infinity' // true
true == '1' // true
false == '0' // true
false == '' // true

对象类型和原始类型的相等比较

  • 对象类型 原始类型做相等比较时,对象类型会依照 ToPrimitive 规则转换为 原始类型
'[object Object]' == {} // true
'1,2,3' == [1, 2, 3] // true

看一下文章开始时给出的例子

[2] == 2 // true

数组 [2] 是对象类型,所以会进行 ToPrimitive 操作,也就是先调用 valueOf 再调用 toString,根据数组 ToString 操作规则,会得到结果 "2", 而字符串 "2" 再和数字 2 比较时,会先转为数字类型,所以最后得到的结果为 true

[null] == 0 // true
[undefined] == 0 // true
[] == 0 // true

根据上文中提到的数组 ToString 操作规则,数组元素为 null undefined 时,该元素被当做 空字符串 处理,而空数组 [] 也被转为 空字符串,所以上述代码相当于

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

空字符串 会转换为数字 0,所以结果为 true

试试 valueOf 方法

const a = {
  valueOf () {
    return 10
  }
  toString () {
    return 20
  }
}
a == 10 // true

对象的 ToPrimitive 操作会先调用 valueOf 方法,并且 avalueOf 方法返回一个原始类型的值,所以ToPrimitive 的操作结果就是 valueOf 方法的返回值 10


讲到这里,你是不是想到了最开始的面试题? 对象每次和原始类型做==比较时,都会进行一次 ToPrimitive 操作,那我们是不是可以定义一个包含 valueOf 方法的对象,然后通过某个值的累加来实现?

试一试

const a = {
  // 定义一个属性来做累加
  count: 1,
  valueOf () {
    return this.count++
  }
}
a == 1 && a == 2 && a == 3 // true

结果正如你所想的,是正确的。当然,当没有定义 valueOf 方法时,用 toString 方法也是可以的。

const a = {
  count: 1,
  toString () {
    return this.count++
  }
}
a == 1 && a == 2 && a == 3 // true

null、undefined和其他类型的比较

  • null undefined 宽松相等的结果为 true,这一点大家都知道
  • 其次,null undefined 都是假值,那么
null == false // false
undefined == false // false

居然跟我想的不一样?为什么呢? 首先,false 转为 0,然后呢? 没有然后了,ECMAScript规范 中规定 nullundefined 之间互相 宽松相等(==),并且也与其自身相等,但和其他所有的值都不 宽松相等(==)


最后

现在再看前面的这一段代码就明了了许多

[] == ![] // true

[] == 0 // true

[2] == 2 // true

['0'] == false // true

'0' == false // true

[] == false // true

[null] == 0 // true

null == 0 // false

[null] == false // true

null == false // false

[undefined] == false // true

undefined == false // false

[] == ![]

首先右边优先级高看右边

! 是取反操作,[] 是对象,经过强制类型转换变为 true,取反之后就为 false 了

右边转换过程:Boolean([]) -> true -> !true -> false

[] == false(对象类型 原始类型做相等比较时,对象类型会依照 ToPrimitive 规则转换为 原始类型

左边转换过程:[].valueOf() -> [].toString() -> '' -> Boolean('') -> false

false == false


[] == false

转换类型:[].toString() == Number(false),就变成了 '' == 0

转换类型:Number('') = 0,就变成了 0 == 0,结果就是 true


转换规则:

只要 布尔类型 参与比较,该 布尔类型 的值首先会被转换为 数字类型

数字类型 字符串类型 做相等比较时,字符串类型 会被转换为 数字类型

对象类型 原始类型做相等比较时,对象类型会依照 ToPrimitive 规则转换为 原始类型

null undefined 之间 宽松相等(==),并且也与其自身相等,但和其他所有的值都不 宽松相等(==)

boolean -> number

string -> number

object -> 原始类型


最后想告诉大家,不要一味的排斥 js 的隐式转换,应该学会如何去利用它,你的代码中可能存在着很多的隐式转换,只是你忽略了它,要做到知其然,并知其所以然,这样才能有助于我们深入的理解 js。

精度问题 0.1 + 0.2 !== 0.3

这是因为 JS 中浮点数的表示方式导致的。

在 JS 中,所有数字都按照 IEEE 754 标准表示为 64 位浮点数。这意味着数字以二进制格式存储,其中一定数量的位数被保留为尾数(有效数字)和指数。

然而,某些十进制数的二进制表示是无限的,因此它们无法用有限的位数精确地表示。这是 0.1 和 0.2 的情况,它们在 JavaScript 中的表示会存在精度问题,因此 0.1 + 0.2 不等于 0.3。

null 和 undefined 的区别?

在 JS 中,null 和 undefined 都表示没有值的状态,但是它们的具体含义和用法略有不同。

null 表示一个空对象指针,即该变量被明确地赋值为空对象的引用。可以将 null 赋值给任何类型的变量,表示该变量当前没有指向任何对象。例如:

let myVar = null; // myVar 被明确地赋值为空对象的引用

undefined 表示变量未被定义或者被赋值为 undefined。当声明一个变量但未对其进行赋值时,该变量的值为 undefined。也可以将 undefined 显式地赋值给变量,表示该变量没有任何值。例如:

let myVar; // 没有进行赋值,myVar 的值为 undefined 
let myOtherVar = undefined; // myOtherVar 显式地被赋值为 undefined

在条件判断中,null 和 undefined 的布尔值都为 false,但是它们在运算中的行为可能不同。例如,尝试对 null 或 undefined 进行属性或方法访问会导致运行时错误。

总之,null 表示有意地将变量赋值为空对象,而 undefined 表示变量未被定义或未被赋值。在实际应用中,通常会根据具体情况选择使用哪种类型的值。

JS 执行机制/执行上下文

变量提升

所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined。

至于为什么要变量提升,在于 JavaScript 代码在执行前需要先编译,编译时变量和函数会被放到变量环境中。

演示:

showName();
console.log(myName);

var myName = "Ricky";

function showName() {
  console.log("showName");
}

模拟变量提升后的效果:

const name = undefined;

function showName() {
  console.log("showName");
}

showName();
console.log(myName);
myName = "Ricky";

函数和变量在执行前都提升到代码开头。而对于出现了同名的变量或者函数,最终生效的是最后一个(覆盖)。

作用域

作用域可以理解为变量的可访问性,总共分为三种类型,分别为:

  • 全局作用域(Global Scope)
  • 块级作用域(Block Scope),ES6 中的 let、const 就可以产生该作用域
  • 函数作用域(Function Scope/Local Scope)

全局作用域:在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。

块级作用域:就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。

函数作用域:就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

ES6 引入了 let 和 const 关键字,从而使 JS 拥有了块级作用域,能够解决一些变量提升带来的问题。

作用:

防止命名冲突:你写了一万行的代码文件,如果没有作用域,你要给每个变量取独一无二的名字,屁股想想也知道是种折磨。

安全性: 变量不会被外部访问,保证了变量值不会被随意修改。你定义在函数内的变量,如果能在几千行之后不小心被修改,脚趾头想想也知道是种折磨。

更高级的语法:封装、面向对象等的实现离不开对变量的隔离,这是依靠作用域所达到的。

以下是块级作用域的理解考察题:

function foo() {
  const a = 1;
  const b = 2;
  {
    const b = 3;
    var c = 4;
    const d = 5;
    console.log(a);
    console.log(b);
  }
  console.log(b);
  console.log(c);
  console.log(d);
}
foo();

分析一下:

  1. 变量环境:a = undefined; c = undefined;词法环境:b = undefined;
  2. 变量环境:a = 1; c = undefined;词法环境:作用域块 1(或者叫 foo 函数执行上下文):b = 2;作用域块 2:b = undefined;d = undefined;
  3. 变量环境:a = 1; c = 4;词法环境:作用域块 1:b = 2;作用域块 2:b = 3;d = 5:
  4. 先在词法环境找,再在变量环境找(作用域链),于是依次输出:1、3、2、4、err

作用域链

程序在执行的过程中,先从当前作用域去找变量,如果没有找到,再向上一级作用域去找,以此往上,这种如同链条一样的寻找方式被称为作用域链。

作用:

保证变量和函数的访问是有序的,通过作用域链,我们可以访问到外层环境的变量和函数。

作用域链的变量只能向上访问,不允许向下访问,访问到 window 对象即被终止。

闭包

1、内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

2、能够访问另一个函数作用域中变量的函数,闭包的实质是因为函数嵌套而形成的作用域链。

闭包的定义很简单:函数 A 返回了一个函数 B,并且函数 B 中使用了函数 A 的变量,函数 B 就被称为闭包。

作用

  • 可以间接调用函数内部的局部变量。
  • 可以让这些变量的值始终保持在内存中。(因此要注意不能滥用闭包)
  • 可以暂存数据,给变量开辟私密空间,避免数据污染。

缺点

闭包会导致原有作用域链不被释放,造成内存泄漏。内存消耗大,所以不能滥用,否再会导致性能

问题。

生命周期

产生:嵌套内部函数声明完成时就会产生闭包对象

死亡:嵌套内部函数成为垃圾对象(null)

使用场景

  • 在函数外部能够访问到函数内部的变量。
  • 使已经运行结束的函数上下文中的变量对象继续留在内存中,因为闭包函数保留了这个变量对象的引用,所以这个变量对象不会被回收。

例如:

function foo() {
  let myName = "Ricky";
  const test1 = 1;
  const test2 = 2;
  const innerBar = {
    getName() {
      console.log(test1);
      return myName;
    },
    setName(newName) {
      myName = newName;
    },
  };
  return innerBar;
}
const bar = foo();
bar.setName("hl");
bar.getName();
console.log(bar.getName());

根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。

应用场景

应用场景一: 典型应用是模块封装,在各模块规范出现之前,都是用这样的方式防止变量污染全局。

var Yideng = (function () {
    // 这样声明为模块私有变量,外界无法直接访问var foo = 0;

    function Yideng() {}
    Yideng.prototype.bar = function bar() {
        return foo;
    };
    return Yideng;
}());

应用场景二: 在循环中创建闭包,防止取到意外的值。

如下代码,无论哪个元素触发事件,都会弹出 3。因为函数执行后引用的 i 是同一个,而 i 在循环结束后就是 3

for (var i = 0; i < 3; i++) {
    document.getElementById('id' + i).onfocus = function() {
      alert(i);
    };
}
//可用闭包解决
function makeCallback(num) {
  return function() {
    alert(num);
  };
}
for (var i = 0; i < 3; i++) {
    document.getElementById('id' + i).onfocus = makeCallback(i);
}

经典面试题,循环中使用闭包解决 var 定义函数的问题

for ( var i=1; i<=5; i++) {
  setTimeout( function timer() {
    console.log( i );
  }, i*1000 );
}

首先因为 setTimeout 是个异步函数,所有会先把循环全部执行完毕,这时候 i 就是 6 了,所以会输出一堆 6。

解决办法三种,第一种使用闭包

for (var i = 1; i <= 5; i++) {
  (function(j) {
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })(i);
}

第二种就是使用 setTimeout 的第三个参数

for ( var i=1; i<=5; i++) {
  setTimeout( function timer(j) {
    console.log( j );
  }, i*1000, i);
}

第三种就是使用 let 定义 i

for ( let i=1; i<=5; i++) {
  setTimeout( function timer() {
    console.log( i );
  }, i*1000 );
}

因为对于 let 来说,他会创建一个块级作用域,相当于

{ // 形成块级作用域
  let i = 0
  {
    let ii = i
    setTimeout( function timer() {
        console.log( ii );
    }, i*1000 );
  }
  i++
  {
    let ii = i
  }
  i++
  {
    let ii = i
  }
  ...
}

this 指向问题

首先必须要说的是,this 的指向在函数定义的时候是确定不了的,只有函数执行的时候才能确定 this 到底指向谁,实际上 this 的最终指向的是那个调用它的对象。

下面会列出一些例子,认真看完后你就会弄明白 this 的指向问题。

普通函数

例子1:

function a() {
  var name = "张三"
  console.log(this.name) //undefined
  console.log(this) //Window
}

a()

按照我们上面说的 this 最终指向的是调用它的对象,这里的函数 a 实际是属于 Window 对象,而 Window 对象下并没有 name 属性,所以会打印 undefined。

例子2:

var o = {
  name: '张三',
  fn() {
    console.log(this.name)  // 张三
  }
}

o.fn()

这里的 this 指向的是对象 o,因为你调用这个 fn 是通过 o.fn() 执行的,那自然指向就是对象 o。

这里再次强调,this 的指向在函数创建的时候是决定不了的,在调用的时候才能决定,谁调用的就指向谁,一定要搞清楚这个。

例子3:

var o = {
  user: '张三',
  fn() {
    console.log(this.user) //张三
  }
}
window.o.fn()

这段代码和上面的那段代码几乎是一样的,但是这里的this为什么不是指向window,如果按照上面的理论,最终this指向的是调用它的对象,这里先说个而外话,window是js中的全局对象,我们创建的变量实际上是给window添加属性,所以这里可以用window点o对象。

这里先不解释为什么上面的那段代码this为什么没有指向window,我们再来看一段代码。

var o = {
  a: 10,
  b: {
    a: 12,
    fn() {
      console.log(this.a) //12
    }
  }
}
o.b.fn()

这里同样也是对象o点出来的,但是同样this并没有执行它,那你肯定会说我一开始说的那些不就都是错误的吗?其实也不是,只是一开始说的不准确,接下来我将补充一句话,我相信你就可以彻底的理解this的指向的问题。

情况1:

如果一个函数中有this,但是它没有被上一级的对象所调用,那么this指向的就是window,这里需要说明的是在js的严格版中this指向的不是window,但是我们这里不探讨严格版的问题,你想了解可以自行上网查找。

情况2:

如果一个函数中有this,这个函数有被上一级的对象所调用,那么this指向的就是上一级的对象。

情况3:

如果一个函数中有this,这个函数中包含多个对象,尽管这个函数是被最外层的对象所调用,this指向的也只是它上一级的对象,例子3可以证明,如果不相信,那么接下来我们继续看几个例子。

var o = {
  a: 10,
  b: {
    // a: 12,
    fn() {
      console.log(this.a) //12
    }
  }
}
o.b.fn()

尽管对象b中没有属性a,这个this指向的也是对象b,因为this只会指向它的上一级对象,不管这个对象中有没有this要的东西。

例子4(特殊情况):

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

这里this指向的是window,是不是有些蒙了?其实是因为你没有理解一句话,这句话同样至关重要。

this永远指向的是最后调用它的对象,也就是看它执行的时候是谁调用的,例子4中虽然函数fn是被对象b所引用,但是在将fn赋值给变量j的时候并没有执行所以最终指向的是window,这和例子3是不一样的,例子3是直接执行了fn。

箭头函数

箭头函数里 this 的指向,就是定义该函数时所在的作用域指向的对象。

例子1:

比如如下代码中的 fn 箭头函数是在 windows 环境下定义的,无论如何调用,this 都指向 window。

var a = 1
const fn = () => {
  console.log(this.a)
}
const obj = {
  fn,
  a: 2
}
obj.fn()  // 1

例子2:

比如如下代码中的 waitAgain 中箭头函数是在 zhangsan 环境下定义的,无论如何调用,this 都指向 zhangsan。

const zhangsan = {
  name: '张三',
  sayHi() {
    console.log(this)
  },
  wait() {
    setTimeout(function () {
      console.log(this)
    })
  },
  waitAgain() {
    setTimeout(() => {
      console.log(this)
    })
  }
}

zhangsan.sayHi()      // 当前对象
zhangsan.wait()       // window
zhangsan.waitAgain()  // 当前对象

示例3:

下面的代码,箭头函数在 fn() 函数下定义的,如果直接调用 fn(),这个时候箭头函数指向 window

当调用 obj.fn() 时,fn() 被定义在 obj 对象里面,所以这个时候的 this 指向 obj

function fn() {
  setTimeout(() => {
    console.log(this)
  })
}

let obj = {
  a: 1,
  fn
}

fn()
obj.fn()

考点

下面的代码输出什么?

const a = {
  b: 2,
  foo: function () {
    console.log(this.b)
  }
}

function b(foo) {
  foo()
}

b(a.foo)

在执行 b(a.foo) 的时候 a.foo 并没有函数执行符 b(a.foo()) ,所以 this 的指向不是 a , foo 最后的调用在函数 b 里面,而这里的 this 指向 window ,所以到最后 this.b 会打印全局作用域下的函数 b。

this 指向问题

全局 this 定为指向 window(浏览器环境)

在下面的 默认绑定、隐式绑定、显示绑定等,我们来看看 this 的指向是什么样的

默认绑定

函数调用时,没有前缀直接调用的情况,指向全局对象 window

隐式绑定

前面存在调用它的对象,这个 this 就会隐式绑定到这个对象 obj.fn() this 指向 obj

隐式丢失

var o = obj.fn() ; 
o();

this 指向全局进行了一个赋值了,那么这个 o 其实是走的默认绑定 this 指向全局 window

显示绑定(call、bind、apply)

call、bind、apply 都是用来显示改变 this 指向

区别:

call 和 apply 传参不同,除了第一个参数,call 接收参数列表,apply 接收参数数组

bind 返回函数,不会立即调用函数,bind只能绑定一次,多次绑定只无效

例如(call/apply):

function greet(name) {
  console.log(`Hello, ${name}! My name is ${this.name}.`);
}

const person = {
  name: 'John'
};

greet.call(person, 'Alice'); // 输出:Hello, Alice! My name is John.
greet.apply(person, ['Alice']); // 输出:Hello, Alice! My name is John.

bind 方法与 call 和 apply 不同,它并不会立即调用函数,而是返回一个新的函数,新函数的 this 指向绑定的对象,如果有传入参数,则会绑定这些参数。这样做可以将一个函数绑定到特定的上下文对象,以便稍后调用。

例如(bind):

function greet(name) {
  console.log(`Hello, ${name}! My name is ${this.name}.`);
}

const person = {
  name: 'John'
};

const greetPerson = greet.bind(person);
greetPerson('Alice'); // 输出:Hello, Alice! My name is John.

还有一个区别就是:bind只能绑定一次,多次绑定只无效

function greet(name) {
  console.log(`Hello, ${name}! My name is ${this.name}.`);
}

const person1 = { name: 'John' };

const person2 = { name: 'Ricky' };

const greetPerson1 = greet.bind(person1);
greetPerson1('Alice'); // 输出:Hello, Alice! My name is John.

const greetPerson2 = greet.bind(person2);
greetPerson('Emilia'); // 输出:Hello, Emilia! My name is John.

箭头函数的绑定

箭头函数中没有 this 绑定,必须通过查找作用域链来决定其值。 如果箭头函数被非箭头函数包含,则 this 绑定的是最近一层非箭头函数的 this,否则 this 的值则被设置为全局对象。

重点:

  • 创建箭头函数时,就已经确定了它的 this 指向。
  • 箭头函数内的 this 指向外层的 this。

new 的绑定

当使用 new 关键字调用函数时,函数中的 this 一定是 JS 创建的新对象。

this 的优先级

显示 > 隐式 > 默认

new > 隐式 > 默认

不好记住的话,你可以便利的记成:箭头函数、new、bind、apply 和 call、obj.、直接调用、不在函数里。

例题:

const name = "window";

const person1 = {
  name: "person1",
  show1() {
    console.log(this.name);
  },
  show2: () => console.log(this.name),
  show3() {
    return function () {
      console.log(this.name);
    };
  },
  show4() {
    return () => console.log(this.name);
  },
};
const person2 = { name: "person2" };

person1.show1();
person1.show1.call(person2);

person1.show2();
person1.show2.call(person2);

person1.show3()();
person1.show3().call(person2);
person1.show3.call(person2)();

person1.show4()();
person1.show4().call(person2);
person1.show4.call(person2)();

// 正确答案如下:

person1.show1(); // person1,隐式绑定,this指向调用者 person1
person1.show1.call(person2); // person2,显式绑定,this指向 person2

person1.show2(); // window,箭头函数绑定,this指向外层作用域,即全局作用域
person1.show2.call(person2); // window,箭头函数绑定,this指向外层作用域,即全局作用域

person1.show3()(); // window,默认绑定,这是一个高阶函数,调用者是window
// 类似于`var func = person1.show3()` 执行`func()`
person1.show3().call(person2); // person2,显式绑定,this指向 person2
person1.show3.call(person2)(); // window,默认绑定,调用者是window

person1.show4()(); // person1,箭头函数绑定,this指向外层作用域,即person1函数作用域
person1.show4().call(person2); // person1,箭头函数绑定,
// this指向外层作用域,即person1函数作用域
person1.show4.call(person2)(); // person2

原型和原型链

原型:

js 中(除 null 和 undefined)所有的对象都有一个叫做 prototype 的属性,这个就是原型

每个对象还有一个叫做 __proto__ 的属性,我们把这个属性所指向的对象叫做该对象的原型

作用:

实现继承

存放一些公共的属性和方法

原型链:

__proto__ 为连接的这条链条,一直到顶端(Object.prototype)为止的这个链叫做原型链

对象在查找属性和方法的时候,如果该对象自身不存在该属性, 通过它的 __proto__ 属性一层层往上查找直到最顶层 Object 对象,再往上就是 null

prototype

在我们 js 中,Array 可以访问一些属性和方法,其实这些属性和方法就是挂载在我们的原型上面,所以才能被访问到。

let arr = new Array(1, 2, 3)
arr.reverse()
arr.sort()
console.log(Array.prototype)

proto

__proto__ 属性指向该对象的原型,通过 __proto__ 实现原型链

let arr = [1, 2, 3]
console.log(arr.__proto__ === Array.prototype) // true

let str = 'hello'
console.log(str.__proto__ === String.prototype) // true

let bool = true
console.log(bool.__proto__ === Boolean.prototype) // true

function Person() {}
var person = new Person()
console.log(person.__proto__ === Person.prototype); // true

constructor

每个原型都有一个 constructor 属性指向关联的构造函数

function Person() {}
console.log(Person === Person.prototype.constructor); // true

重点讲解一下原型 prototype 的用法,最主要的方法就是将属性暴露成公用的,上代码

function Person(name,age){
  this.name = name
  this.age = age
}
Person.prototype.sayHello = function(){
  console.log(this.name + "say hello")
}
var girl = new Person("bella",23)
var boy = new Person("alex",23)
console.log(girl.name);  //bella
console.log(boy.name);   //alex
console.log(girl.sayHello === boy.sayHello)  //true

我们给函数 Person的 原型中声明了sayHello方法,当我们的构造实例对象去访问的时候访问的方法是同一个,这就是 prototype 原型最大的作用,共享属性和方法

构造函数

在js中,构造函数是一种特殊的函数,用于创建对象。构造函数具有以下特点:

  1. 构造函数使用new关键字调用,用于创建一个新的对象,并将其绑定到函数的this上下文中。
  2. 构造函数通常以大写字母开头,以便与普通函数区分开来。
  3. 构造函数可以具有参数,这些参数用于初始化新对象的属性。
  4. 构造函数可以定义该对象的属性和方法,这些属性和方法可以在新对象中使用。
  5. 构造函数可以使用prototype属性来定义该对象的原型,从而继承其他对象的属性和方法。

使用构造函数创建对象的优点是可以轻松地创建多个具有相同属性和方法的对象,并将它们分配给不同的变量。同时,构造函数还允许我们创建具有动态特性的对象,例如在构造函数中定义的属性和方法可以使用参数进行初始化,从而实现更加灵活的对象创建。

需要注意的是,使用构造函数创建的每个对象都是独立的实例,它们之间不共享状态。因此,在使用构造函数创建对象时,必须小心不要意外共享对象状态,以避免意外的错误。

以下是一个简单的构造函数的例子:

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.greet = function() {
    console.log("Hello, my name is " + this.name);
  };
}

let john = new Person("John", 30);
john.greet(); // 输出 "Hello, my name is John"

在上面的例子中,Person是一个构造函数,它接受两个参数name和age,并使用this关键字将它们分配给name和age属性。它还定义了一个greet方法,该方法使用this.name输出问候语。

使用new关键字创建了一个新的Person对象,它被分配给变量john。然后我们调用greet方法,输出问候语。

通过使用构造函数,我们可以轻松地创建多个具有相同属性和方法的对象,并将它们分配给不同的变量。同时,构造函数还允许我们创建具有动态特性的对象,例如在上面的例子中,每个Person对象都具有不同的name和age属性。

实现继承的几种方式

原型继承、组合式继承、寄生组合继承

原型继承:

优点:

  • 方法复用
  • 由于方法定义在父类的原型上,复用了父类构造函数原型上的方法。

缺点:

  • 创建的子类实例不能传参。
  • 子类实例共享了父类构造函数的引用属性(如:arr)。
const person = {
  stu: ["x", "y", "z"],
};

const p1 = Object.create(person);
p1.stu.push("A");

console.log(person.stu); // ['x','y','z','A']

组合式继承:

优点:

  • 可传参: 子类实例创建可以传递参数。
  • 方法复用: 同时所有子类可以复用父类的方法。
  • 不共享父类引用属性: 子类的改变不会引起父类引用类型的共享。

缺点:

  • 组合继承调用了两次父类的构造函数,造成了不必要的消耗。
function Father(name) {
  this.name = name;
  this.type = ["x", "y", "z"];
}

Father.prototype.sayName = function () {
  console.log(this.name);
};

function Son(name, age) {
  Father.call(this, name);
  this.age = age;
}

Son.prototype = new Father();
Son.prototype.constructor = Son;

// 优点一:可传参
const son1 = new Son("aaa", 11);
const son2 = new Son("bbb", 12);

// 优点二:共享父类方法
son1.sayName();
son2.sayName();

// 优点三:不共享父类引用类型
son1.type.push("Q");

console.log(son1.type);
console.log(son2.type);

寄生组合继承:

核心思想: 组合继承 + 原型继承结合两者的优点。 优点: 完美! 缺点:无!

function Father(name) {
  this.name = name;
  this.type = ["x", "y", "z"];
}
Father.prototype.sayName = function () {
  console.log(this.name);
};

function Son(name, age) {
  Father.call(this, name);
  this.age = age;
}

Son.prototype = Object.create(Father.prototype);
Son.prototype.constructor = Son;

const son1 = new Son("kk", 18);

son1.sayName();
son1.type.push("Q");
console.log(son1.type);

JS 用几种方式实现继承(构造函数继承、原型链继承、组合方式继承)

构造函数继承:

通过构造函数来实现的继承,只能继承父类构造函数的属性,如果原型 prototype 上面还有方法甚至原型链上的方法,不会继承。

借助原型链实现继承:

当我们修改某一个对象时,该函数所产出的所有新实例都会发生改变,这就造成了 数据污染 问题,肯定不是我们想要的。(因为它们引用的是同一个父类实例对象)

组合方式实现继承:

拿得是父类的原型对象,依旧没有自己的 constructor。(和父类的原型对象是同一个对象,导致 constructor 也指向父类)

ES6 的 extend 继承:ES6 的 extend 继承其实就是寄生组合式继承的语法糖。

Promise

Promise 是什么?

Promise 是 ES6 新增的语法,解决了回调地狱的问题。

开发中为了保存异步代码的执行顺序, 那么就会出现回调函数层层嵌套,如果回调函数嵌套的层数太多, 就会导致代码的阅读性, 可维护性大大降低,Promise 对象可以将异步操作以同步流程来表示, 避免了回调函数层层嵌套问题,避免了回调地狱问题。

关键点:

  • Promise 对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)
  • Promise 构造函数接收一个函数作为参数,该函数的两个参数分别是 resolve 和 reject
  • 一个 promise 对象只能改变一次状态,成功或者失败后都会返回结果数据。
  • then 方法可以接收两个回调函数作为参数,第一个回调函数是 Promise 对象的状态改变为 resoved 是调用,第二个回调函数是 Promise 对象的状态变为 rejected 时调用。其中第二个参数可以省略。
  • catch 方法,该方法相当于最近的 then 方法的第二个参数,指向 reject 的回调函数,另一个作用是,在执行 resolve 回调函数时,如果出错,抛出异常,不会停止陨星,而是进入 catch 方法中。

除了 then 块以外,其它两种块能否多次使用?

可以,finally 与 then 一样会按顺序执行,但是 catch 块只会执行第一个,除非 catch 块里有异常。所以最好只安排一个 catch 和 finally 块。

then 块如何中断?

then 块默认会向下顺序执行,return 是不能中断的,可以通过 throw 来跳转至 catch 实现中断。

Promise 有哪几种状态?

pending、resolved、rejected

Promise 有哪些方法?

all

Promise.all 可以将多个 Promise 实例包装成一个新的 Promise 实例。同时,成功和失败的返回值是不同的,成功的时候返回的是一个结果数组,而失败的时候则返回最先被 reject 失败状态的值。

let p1 = new Promise((resolve, reject) => {
  resolve(1)
})

let p2 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(2)
  }, 2000)
})

Promise.all([p1, p2]).then(res => {
  console.log(res) // 2秒钟后返回 [1, 2]
})

Promse.all在处理多个异步处理时非常有用,比如说一个页面上需要等两个或多个ajax的数据回来以后才正常显示,在此之前只显示loading图标。

race

顾名思义,Promse.race就 是赛跑的意思,意思就是说,Promise.race([p1, p2, p3])里面哪个结果获得的快,就返回那个结果,不管结果本身是成功状态还是失败状态。

let p1 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(1)
  }, 2000)
})

let p2 = new Promise((resolve, reject) => {
  resolve(2)
})

Promise.race([p1, p2]).then(res => {
  console.log(res) // 返回2
}).catch(res => {
  console.log(res)
})

async/await

async/await 是以更舒适的方式使用 promise 的一种特殊语法,同时它也非常易于理解和使用。

async 用来声明异步函数,await 相当于是一个 then,用来获取 Promise 中 resolve 的值。

async 定义的函数会默认的返回一个 Promise 对象,因此对 async 函数可以直接进行 then 操作,返回的值即为 then 方法的传入函数。

如果 async 关键字函数返回的不是 Promise ,会自动用 Promise.resolve() 包装。

try...catch 可捕获异常,代替了 Promise 的 catch

示例1:

async function fn1() {
  return 100 // 直接 return 相当于 return Promise.resolve(100)
}

fn1().then(data => {
  console.log(data) // 100
})

示例2:

async function fn2() {
  return new Promise(function (resolve, reject) {
    resolve(100)
  })
}

fn2().then(x => {
  console.log(x)  // 100
})

示例3:

async function fn1() {
  return 100
}

(async function() {
  const b = await fn1()
  console.log(b)
})()

100:await 获取 then 的返回值 ,调用 fn1() 等于 return Promise.resolve(100).then()

示例4:

(async function() {
  const a = await 100console.log(a)
  const  b = await Promise.resolve(200)
  console.log(b)
  const c = await Promise.reject(300)
  console.log(c)
})()

答案:100 200 报错

100:相当于 return 100

200: await 相当于是一个 then ,后面跟一个 Promise 所以 b 等于 100

报错:await 相当于是一个 then ,而 Promise.reject() 的回调是 catch 所以会报错

解决报错:

我们上面有说过 try...catch 可捕获异常,代替了 Promise 的 catch,所以我们使用它们来捕获异常

(async function () {
  try {
    await Promise.reject(300)
  } catch(data) {
    console.log(data) // 300
  }
})()

示例5:

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')
}, 0)

async1()

执行顺序:

  • script start 脚本执行
  • async1 start 微任务开始执行
  • async2 同步执行
  • async1 end 同步后的回调代码结束执行
  • setTimeout 宏任务执行

Proxy

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

代理对象通常用于以下场景:

  1. 数据劫持:可以拦截并修改对目标对象属性的访问和修改,实现数据绑定的效果;
  2. 权限控制:可以通过拦截对对象的访问,控制对象的使用权限;
  3. 缓存:可以缓存对某些对象属性的访问结果,减少重复计算;
  4. 远程调用:可以通过代理对象控制对远程对象的访问,实现远程调用的效果。

代理对象是ES6中新增的一个特性,通过使用代理对象,可以实现更加灵活和高效的操作,提升代码的可读性和可维护性。

我们来看一下它的用法:

const p = new Proxy(target, handler)

target:

需要被代理的对象,它可以是任何类型的对象,比如数组、函数等等,注意不能是基础数据类型。

示例代码:

const person = {
  name: 'Riack',
  age: 23,
}

let p = new Proxy(person, handler)

handler:

它是一个对象,该对象的属性通常都是一些函数,handler 对象中的这些函数也就是我们的处理器函数,主要定义我们在代理对象后的拦截或者自定义的行为。handler 对象的的属性大概有下面这些,比较常用的主要是 get 和 set

如果需要了解其他方法的使用,请访问 mdn https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy

  • handler.apply()
  • handler.construct()
  • handler.defineProperty()
  • handler.deleteProperty()
  • handler.get()
  • handler.getOwnPropertyDescriptor()
  • handler.getPrototypeOf()
  • handler.has()
  • handler.isExtensible()
  • handler.ownKeys()
  • handler.preventExtensions()
  • handler.set()
  • handler.setPrototypeOf()

看一个简单的例子:

const person = {
  name: 'Ricky',
  age: 20,
}

// get / set
// target(目标对象){name: 'Ricky', age: 20}
// key(属性名称)name

// set
// value(设置的属性值)25
const personProxy = new Proxy(person, {
  get: (target, key) => {
    console.log(target[key])
  },
  set: (target, key, value) => {
    target[key] = value
  }
})

personProxy.name				// Ricky
personProxy.age = '25'
personProxy.age					// 25

属性校验

使用 get 和 set 方法对属性进行校验

const person = {
  name: 'Ricky',
  age: 20,
}

const personProxy = new Proxy(person, {
  get: (target, key) => {
    if (!target[key]) {
      console.log(`属性 ${key} 不存在`)
    } else {
      console.log(`key:${key},value:${target[key]}`)
    }
  },
  set: (target, key, value) => {
    if (key === 'age' && typeof value !== 'number') {
      console.log(`age 只能是 number 类型`)
    } else {
      target[key] = value  
    }
  }
})

personProxy.address
personProxy.name
personProxy.age = '22'

Reflect

在 JS 当中,有一个内置的对象,叫做 Reflect,它能让我们更容易操作目标对象。

上文当中,我们通过 Proxy new 了一个代理对象出来,然后通过 get 和 set 方法来访问和修改数据。

同样,我们可以通过 Reflect 对象来操作,如下代码所示:


Reflect 是一个全局对象,它提供了一组静态方法,用于操作对象。这些方法与对象的方法具有相同的名称和功能,例如 Reflect.get 和 Object.get,但是 Reflect 方法具有以下优点:

  • Reflect 方法的参数更加标准化,可以接受任何类型的参数,而不仅仅是对象。
  • Reflect 方法的返回值更加统一,通常是一个布尔值或一个对象,而不是一些不同类型的返回值。

下面是 Reflect 的一些基本用法:

const person = {
  name: 'Ricky',
  age: 20,
}

// 使用 Reflect 方法获取属性值
const value1 = Reflect.get(person, 'name')
console.log(value1)

const proxy = new Proxy(person, {
  get(target, key) {
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    return Reflect.set(target, key, value)
  }
})

console.log(proxy.name) // Ricky

proxy.age = 25
console.log(proxy.age)  // 25

上文我们是通过 target[key] 来获取,通过 target[key] = value 来进行 set 操作。

现在我们使用 Reflect 来进行 get 和 set操作,我们可以看到它的参数更加标准化。

同步和异步

js是一门单线程语言,所谓"单线程",就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面

一个任务完成,再执行后面一个任务,以此类推。如果一个任务耗时过长,那么后面的任务就必须一直等待

下去,会拖延整个程序,为了解决这个问题,js的执行模式分为两种:同步和异步。

同步模式:任务有序执行,后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺

序是一致的、同步的。

异步模式:每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而

是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不

一致的、异步的。

最基础的异步是setTimeout和setInterval函数,很常见,但是很少人有人知道其实这就是异步,因为它们可以控制js的执行顺序。

Event Loop(事件循环)

js是单线程的,一次只能执行一段代码。单线程会导致很多任务需要排队,一个个去执行,如果此时某个任务执行时间太长,就会出现阻塞,为了解决这个问题,js引入了事件循环机制。

为什么要区分宏任务和微任务?

js是单线程的,但是分同步异步

微任务和宏任务皆为异步任务,它们都属于一个队列

宏任务:script(整体代码)、setTimeout、setInterval、I/O、UI、 renderingsetImmediate(Node环境)

微任务:promise.then、Object.observe、MutationObserver、process.nextTick(Node环境)

先执行同步再执行异步,异步遇到微任务,先执行微任务,执行完后如果没有微任务,就执行下一个宏任务,如果有微任务,就按顺序一个一个执行微任务

任务优先级:

先执行同步代码,遇到宏任务则将宏任务放入宏任务队列中,遇到微任务则将微任务放入微任务队列中,当所有同步代码执行完毕后,再将异步微任务从队列中调入主线程执行,微任务执行完毕后再将宏任务从队列中调入主线程执行,一直循环直至所有任务执行完毕。

这里容易产生一个错误的认识:就是微任务先于宏任务执行。实际上是先执行同步任务然后在执行异步任务,异步任务是分宏任务和微任务两种的。

看一个例子,最后结果输出啥?

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

new Promise((resolve) => {
  console.log(2)
  resolve()
  console.log(3)
}).then(() => {
  console.log(4)
})

console.log(5)

分析:

1、遇到 setTimout(宏任务),放入宏任务队列中

2、遇到 new Promise,在实例化的过程中所执行的代码都是同步进行的,所以输出 2 和 3

3、Promise.then(微任务),将其放入微任务队列中

4、遇到同步任务 console.log(5) 输出5,主线程中同步任务执行完

5、从微任务队列中取出任务到主线程中,输出 4,微任务队列为空

6、从宏任务队列中取出任务到主线程中,输出 1,宏任务队列为空,事件循环结束

最后结果为:2 3 5 4 1

例子2:

setTimeout(() => {
  new Promise(resolve => {
    resolve()
  }).then(() => {
    console.log(1)
  })
  console.log(2)
})

new Promise(resolve => {
  resolve()
  console.log(3)
}).then(() => {
  console.log(4)
  Promise.resolve().then(() => {
    console.log(5)
  }).then(() => {
    Promise.resolve().then(() => {
      console.log(6)
    })
  })
})
console.log(7)

分析:

  1. 遇到setTimeout(宏任务),将 console.log(2) 放入宏任务队列中;
  2. 遇到 new Promise,在实例化的过程中所执行的代码都是同步进行的,所以输出 3;
  3. 而 Promise.then(微任务),将其放入微任务队列中
  4. 遇到同步任务 console.log(7),输出 7 ;主线程中同步任务执行完
  5. 从微任务队列中取出任务到主线程中,输出 4 ,此微任务中又有微任务,Promise.resolve().then(微任务a).then(微任务b),将其依次放入微任务队列中;
  6. 从微任务队列中取出任务 a 到主线程中,输出 5;
  7. 从微任务队列中取出任务 b 到主线程中,任务 b 又注册了一个微任务 c,放入微任务队列中;
  8. 从微任务队列中取出任务 c 到主线程中,输出 6,微任务队列为空
  9. 从宏任务队列中取出任务到主线程,此任务中注册了一个微任务 d,将其放入微任务队列中,接下来遇到输出 2,宏任务队列为空
  10. 从微任务队列中取出任务 d 到主线程,输出 1,微任务队列为空

最后结果为:3 7 4 5 6 2 1

例子3:

console.log(1)
setTimeout(function () {
  console.log(2)
  new Promise(function (resolve) {
    console.log(3)
    resolve()
  }).then(function () {
    console.log(4)
  })
})

new Promise(function (resolve) {
  console.log(5)
  resolve()
}).then(function () {
  console.log(6)
})

setTimeout(function () {
  console.log(7)
  new Promise(function (resolve) {
    console.log(8)
    resolve()
  }).then(function () {
    console.log(9)
  })
})
console.log(10)

分析:

  1. 遇到同步任务 console.log(1) 输出1;
  2. 遇到 setTimeout(宏任务),放入宏任务队列中;
  3. 遇到 Promise,new Promise 在实例化的过程中所执行的代码都是同步进行的,所以输出 5,所以接着执行遇到 .then;
  4. 执行 .then(微任务),被分发到微任务 Event Queue 中;
  5. 遇到 setTimeout,宏任务,放入宏任务队列中;
  6. 遇到同步任务 console.log(10) 输出10,主线程中同步任务全部执行完;
  7. 从微任务队列中取出任务到主线程中,输出6;
  8. 在从宏任务队列中取出任务到主线程中,执行第一个setTimeout,输出2,3,4(在宏任务中执行同步,同步,微任务);
  9. 在执行第二个 setTimeout,输出7,8,9;

最后结果为:1,5,10,6,2,3,4,7,8,9

例子4:

new Promise((resolve) => {
  resolve(1)
  new Promise((resolve) => {
    resolve(2)
  }).then(data => {
    console.log(data)
  })
}).then(data => {
  console.log(data)
})

console.log(3)

分析:

  1. 遇到 new Promise 没有输出,往下走,然后又遇到了 new Promise 也没有输出,接着往下走,遇到 then(微任务)被分发到任务队列中,里面的走完了接着走外面的 then 同样也是分发到任务队列中;
  2. 接着遇到同步任务 console.log(3) 输出3,主线程中同步任务执行完毕;
  3. 从微任务队列中取出任务添加到主线程中,输出 2 和 1,微任务执行完毕,任务队列为空;

最后结果为:3 2 1

深拷贝和浅拷贝

在 js 中,深拷贝和浅拷贝是用于描述对象和数组的赋值方式。

浅拷贝:

修改变量的值会影响原有变量的值,默认情况下引用类型都是浅拷贝。

例子:

var obj1 = {
  a: 1,
  b: 2
}

var obj2 = obj1

obj2.a = 12

console.log(obj1) // {a: 12, b: 2}
console.log(obj2) // {a: 12, b: 2}

在上面到例子中我们修改了 obj2 中的属性 a 结果 obj1 中的 a 属性也跟着变了,这种情况就是浅拷贝。

深拷贝:

修改变量的值不会影响原有变量的值,默认情况下基本数据类型都是深拷贝。

我们也可以通过使用 Object.assign() 和 展开运算符 ... 来实现,它们都是将原始对象的属性和值复制到一个新对象中。

例子:

var obj1 = {
  a: 1,
  b: 2
}

var obj2 = Object.assign({}, obj1)
var obj3 = { ...obj2 }

obj2.a = 10
obj3.a = 20

console.log(obj1) // {a: 1, b: 2}
console.log(obj2) // {a: 10, b: 2}
console.log(obj3) // {a: 20, b: 2}

在这里我们可以看到通过使用 Object.assign 和展开运算符 ... 后修改变量的值不会影响到其他变量了,这就是深拷贝。

但是这两种方法也是有局限性的:会忽略 undefined、会忽略 symbol、不能序列化函数、不能解决循环引用的对象。


对于多层嵌套的对象,上面介绍到两种方法是不能处理的,所以要使用下面介绍的两种方式

第一种:JSON.parse(JSON.stringify())

var div1 = {
  style: {
    width: 100
  }
}

var div2 = JSON.parse(JSON.stringify(div1))
div2.style.width = 200

console.log(div1.style.width) // 100
console.log(div2.style.width) // 200

第二种:通过递归手写深拷贝

function deepClone(obj) {
  // 如果不是引用数据类型, 直接将属性拷贝即可if (typeof obj !== 'object') return obj
  // 如果是引用数据类型, 那么要新建一个存储空间保存
  let newObj = new obj.constructor
  for (let key in obj) {
    // 递归调用拷贝, 将遍历到的属性的取值拷贝给新建的对象或者数组newObj[key] = deepClone(obj[key])
  }
  return newObj
}

var div1 = {
  style: {
    width: 100
  }
}

var div2 = deepClone(div1)
div2.style.width = 200

console.log(div1.style.width) // 100
console.log(div2.style.width) // 200

ES5继承和ES6继承

ES5继承

在子类中通过call / apply方法借助父类的构造函数

将子类的原型函数设置为父类的实例对象

ES6继承

通过子类extends父类, 来告诉浏览器子类要继承父类

通过super()方法修改 this

function Person(myName, myAge) {
  this.name = myName;
  this.age = myAge;
}
Person.prototype.say = function () {
  console.log(this.name, this.age);
}

function Student(myName, myAge, myScore) {
  // 1.在子类中通过call/apply方法借助父类的构造函数
  Person.call(this, myName, myAge);
  this.score = myScore;
  this.study = function () {
    console.log("day day up");
  }
}
// 2.将子类的原型对象设置为父类的实例对象
Student.prototype = new Person();
Student.prototype.constructor = Student;

let stu = new Student("zs", 18, 98);
stu.say(); // zs 18

什么是事件代理

如果一个节点中的子节点是动态生成的,那么子节点需要注册事件的话应该注册在父节点上

<ul id="ul">
  <li>1</li>
    <li>2</li>
  <li>3</li>
  <li>4</li>
  <li>5</li>
</ul>
<script>
  let ul = document.querySelector('#ul')
  ul.addEventListener('click', (event) => {
    console.log(event.target);
  })
</script>

事件代理的方式相对于直接给目标注册事件来说,有以下优点

  • 节省内存
  • 不需要给子节点注销事件

继承

ES5继承

在子类中通过call / apply方法借助父类的构造函数

将子类的原型函数设置为父类的实例对象

ES6继承

通过子类extends父类, 来告诉浏览器子类要继承父类

通过super()方法修改 this

function Person(myName, myAge) {
  this.name = myName;
  this.age = myAge;
}
Person.prototype.say = function () {
  console.log(this.name, this.age);
}

function Student(myName, myAge, myScore) {
  // 1.在子类中通过call/apply方法借助父类的构造函数
  Person.call(this, myName, myAge);
  this.score = myScore;
  this.study = function () {
    console.log("day day up");
  }
}
// 2.将子类的原型对象设置为父类的实例对象
Student.prototype = new Person();
Student.prototype.constructor = Student;

let stu = new Student("zs", 18, 98);
stu.say(); // zs 18

箭头函数和普通函数有什么区别

箭头函数体内的 this 对象,就是定义时所在的对象,而不是使用时所在的对象。

箭头函数没有 arguments,如果要用,可以用 rest 参数代替 (注意在 node 环境下是有 arguments 的)

箭头函数不能作为构造函数,不能使用 new

箭头函数没有原型,不能继承

箭头函数不能当做 Generator 函数,不能使用 yield 关键字

cookie、sessionStorage、localStorage区别?

cookie、sessionStorage 和 localStorage 在前端开发中都是用于存储客户端会话信息或数据的方法,但它们之间存在一些显著的区别。

首先,从存储位置来看,cookie是由服务器端写入的,而sessionStorage和localStorage则是由前端写入的。

其次,关于存储大小,cookie的存储空间相对较小,大约只有4KB。而sessionStorage和localStorage的存储空间则要大得多,通常可以达到5MB或更大。

在数据有效期方面,cookie的生命周期是在服务器端在写入的时候就设置好的,它会在设置的过期时间之前一直有效,即使窗口和浏览器关闭也不会影响。sessionStorage的数据则会在浏览器窗口关闭后自动清除,其存储的数据仅在同源窗口内有效,即使在不同浏览器相同页面也是无效的。而localStorage则是始终有效的,除非手动清除,因此它常被用作持久数据的存储。

在数据作用域方面,cookie在所有同源窗口间共享数据。sessionStorage的数据则不会在不同浏览器窗口间共享,它只限制在同一个页面内。localStorage的数据也是在所有同源窗口间共享的。

最后,从数据与服务器之间的交互方式来看,cookie的数据会自动传递到服务器,服务器端也可以写cookie到客户端。而sessionStorage和localStorage则不会自动把数据发给服务器,它们仅在本地保存数据。

综上所述,cookie、sessionStorage和localStorage在存储位置、大小、有效期、作用域以及与服务器的交互方式等方面存在明显的区别。根据具体的应用场景和需求,可以选择合适的存储方式。

DOM 节点操作/BOM

DOM 节点操作

获取 DOM 节点

// 根据 ID 获取,返回元素
const div = document.getElementById('box')
// 根据标签名称获取,返回集合(HTMLCollection)
const tagList = document.getElementsByTagName('div')
// 根据类名称获取,返回集合(HTMLCollection)
const classList = document.getElementsByClassName('label')

// 根据 CSS 选择器匹配元素,可以使用它们的 id, 类, 类型, 属性, 属性值等来选取元素。
// 对于多个选择器,使用逗号隔开,返回一个匹配的元素。
// 返回匹配的第一个元素
const el1 = document.querySelector('#box')
const el2 = document.querySelector('.label')
const el3 = document.querySelector('#box, .label')

// 跟 querySelector 是一样的,返回一个 NodeList
const el4 = document.querySelectorAll('.label')

操作节点 property

const pList = document.querySelectorAll('p')
const p = pList[0]
// 获取样式(只可以获取内联样式)
p.style.color
// 修改样式
p.style.color = 'red'
// 获取 class
p.className
// 修改 class
p.className = 'desc'

// 获取 nodeName 和 nodeType
p.nodeName
p.nodeType

节点的 attribute

const p2List = document.querySelectorAll('p')
const p2 = pList[0]
p2.getAttribute('class')
p2.setAttribute('data-name', 'p2')
p2.getAttribute('style')
p2.setAttribute('style', 'color: blue; font-size: 50px')

property 和 attribute

property: 修改对象属性,不会体现到 html 结构中

attribute: 修改 html 属性, 会改变 html 结构

两者都可能引起 DOM 重新渲染,尽量使用 property

DOM 结构操作

const div1 = document.getElementById('box')
// 新建节点
const p3 = document.createElement('p')
p3.innerText = 'p3'
// 插入节点
div1.appendChild(p3)

// 移动节点(对于现有节点调用 appendChild 会移动节点)
const p1 = document.getElementById('p1')
const box2 = document.getElementById('box2')
box2.appendChild(p1)

// 获取父元素
p1.parentNode
// 获取子元素
console.log(box2.childNodes)
// 在获取子元素的时候会多出一些元素,我们可以过滤一下
const childNodes = Array.prototype.slice.call(box2.childNodes).filter(child => {
  return child.nodeType === 1 ?  true : false
})

// 删除子元素
box2.removeChild(childNodes[0])

DOM 性能优化

// 不做缓存 DOM 查询
for (let i = 0; i < document.getElementsByTagName('p').length; i++) {
  // 每次循环都会计算 length , 跑分进行 DOM 查询
}

// 缓存 DOM 查询
const p3List = document.getElementsByTagName('p')
const length = p3List.length
for (let i = 0; i < length; i++) {
  // 缓存 length , 只进行一次 DOM 查询
}

// 频繁操作
const list = document.getElementById('list')
for (let i = 0; i < 10; i++) {
  const li = document.createElement('li')
  li.innerHTML = i
  list.appendChild(li)
}

// 将频繁操作改为一次性操作
// 创建一个文档片段,此时还没有插入到 DOM 树中
const frag = document.createDocumentFragment()
const listNode = document.getElementById('list')

for (let i = 0; i < 10; i++) {
  const li = document.createElement('li')
  li.innerHTML = i
  frag.appendChild(li)
}

// 都完成后,再插入到 DOM 树中
listNode.appendChild(frag)

BOM

// navigator
const ua = navigator.userAgent
const isChrome = ua.indexOf('Chrome')

// screen
screen.width
screen.height

// location
const href = location.href
const host = location.host
const protocol = location.protocol
const pathname = location.pathname
const search = location.search
const hash = localStorage.hash

// history
history.back()  // 后退
history.forward() // 前进

事件

通用事件绑定函数

function bindEvent(el, type, fn) {
  el.addEventListener(type, fn)
}

const btn1 = document.getElementById('btn1')
bindEvent(btn1, 'click', event => {
  alert('clicked')
  // console.log(event.target) // 获取触发的元素
  // event.preventDefault() // 阻止默认行为
})

完整版(普通绑定/代理绑定)

function bindEvent(el, type, selector, fn) {
  if (fn == null) {
    fn = selector
    selector = null
  }
  
  el.addEventListener(type, event => {
    const target = event.target
    
    if (selector) {
      // 代理绑定
      if(target.matches(selector)) {
        fn.call(target, event)
      }
    } else {
      // 普通绑定
      fn.call(target, event)
    }
  })
}

// 这里要注意一点,如果使用箭头函数,this 的指向会指向定义时的作用域(window),所以要改为普通函数
const div3 = document.getElementById('div3')
bindEvent(div3, 'click', 'a', function (event) {
  console.log(this.innerHTML)
  console.log(event.target.innerHTML)
  event.preventDefault()
})

事件冒泡

下面的代码点击 p1 会触发事件冒泡,打开 event.stopPropagation() 可阻止事件冒泡。

html

<div id="div1">
  <p id="p1">激活</p>
  <p id="p2">取消</p>
  <p id="p3">取消</p>
  <p id="p4">取消</p>
</div>

<div id="div2">
  <p id="p5">取消</p>
  <p id="p6">取消</p>
</div>

js

const body = document.body
bindEvent(body, 'click', event => {
  console.log('body clicked')
})

const p1 = document.getElementById('p1')
bindEvent(p1, 'click', event => {
  console.log('p1 click')
  // event.stopPropagation() // 阻止事件冒泡
})
/**

事件代理

事件代理(Event Delegation),又称之为事件委托。是JavaScript中常用绑定事件的常用技巧。顾名思义,“事件代理”即是把原本需要绑定在子元素的响应事件(click、keydown......)委托给父元素,让父元素担当事件监听的职务。事件代理的原理是DOM元素的事件冒泡。

html

<div id="div3">
  <a href="#">a1</a>
  <a href="#">a2</a>
  <a href="#">a3</a>
  <button>加载更多</button>
</div>

js

const div3 = document.getElementById('div3')
bindEvent(div3, 'click', event => {
  event.preventDefault()
  const target = event.target
  if(target.nodeName === 'A') {
    console.log(target.innerHTML)
  }
})

class

用法:

class Student {
  constructor(name, number) {
    this.name = name;
    this.number = number;
  }

  sayHi() {
    console.log(
      `姓名:${this.name}, 学号:${this.number}`
    )
  }
}

const zhangsan = new Student('张三', 1)
zhangsan.sayHi()
console.log(zhangsan.name)
console.log(zhangsan.number)

const lisi = new Student('李四', 2)
lisi.sayHi()
console.log(lisi.name)
console.log(lisi.number)

继承示例:

// 父类
class People {
  constructor(name) {
    this.name = name;
  }

  eat() {
    console.log(`${this.name} eat something`)
  }
}

// 子类
class Student extends People {
  constructor(name, number) {
    super(name)
    this.number = number
  }

  sayHi() {
    console.log(
      `姓名:${this.name}, 学号:${this.number}`
    )
  }
}

// 子类
class Teacher extends People {
  constructor(name, major) {
    super(name)
    this.major = major
  }

  teach() {
    console.log(
      `${this.name} 教授 ${this.major}`
    )
  }
}

const zhangsan = new Student('张三', 1)
console.log(zhangsan.name)
console.log(zhangsan.number)
zhangsan.sayHi()
zhangsan.eat()

const wanglaoshi= new Teacher('王老师', '语文')
console.log(wanglaoshi.name)
wanglaoshi.teach()
wanglaoshi.eat()

1/0