续接上一篇文章:前端学习之 JS 篇(一)

# 三、JavaScript 基础

  1. new 操作符的实现原理

new 操作符的执行过程:

(1)首先创建了一个新的空对象

(2)设置原型,将对象的原型设置为函数的 prototype 对象。

(3)让函数的 this 指向这个对象,执行构造函数的代码(为这个新对象添加属性)

(4)判断函数的返回值类型,如果是值类型,返回创建的对象。如果是引用类型,就返回这个引用类型的对象。

具体实现:

function objectFactory() {
  let newObject = null;
  let constructor = Array.prototype.shift.call(arguments);
  let result = null;
  // 判断参数是否是一个函数
  if (typeof constructor !== "function") {
    console.error("type error");
    return;
  }
  // 新建一个空对象,对象的原型为构造函数的 prototype 对象
  newObject = Object.create(constructor.prototype);
  // 将 this 指向新建对象,并执行函数
  result = constructor.apply(newObject, arguments);
  // 判断返回对象
  let flag = result && (typeof result === "object" || typeof result === "function");
  // 判断返回结果
  return flag ? result : newObject;
}
// 使用方法
objectFactory(构造函数, 初始化参数);
  1. map 和 Object 的区别
MapObject
意外的键Map 默认情况不包含任何键,只包含显式插入的键。Object 有一个原型,原型链上的键名有可能和自己在对象上的设置的键名产生冲突。
键的类型Map 的键可以是任意值,包括函数、对象或任意基本类型。Object 的键必须是 String 或是 Symbol。
键的顺序Map 中的 key 是有序的。因此,当迭代的时候, Map 对象以插入的顺序返回键值。Object 的键是无序的
SizeMap 的键值对个数可以轻易地通过 size 属性获取Object 的键值对个数只能手动计算
迭代Map 是 iterable 的,所以可以直接被迭代。迭代 Object 需要以某种方式获取它的键然后才能迭代。
性能在频繁增删键值对的场景下表现更好。在频繁添加和删除键值对的场景下未作出优化。
  1. map 和 weakMap 的区别

(1)Map
map 本质上就是键值对的集合,但是普通的 Object 中的键值对中的键只能是字符串。而 ES6 提供的 Map 数据结构类似于对象,但是它的键不限制范围,可以是任意类型,是一种更加完善的 Hash 结构。如果 Map 的键是一个原始数据类型,只要两个键严格相同,就视为是同一个键。

实际上 Map 是一个数组,它的每一个数据也都是一个数组,其形式如下:

const map = [
     ["name","张三"],
     ["age",18],
]

Map 数据结构有以下操作方法:

  • size: map.size 返回 Map 结构的成员总数。
  • set (key,value):设置键名 key 对应的键值 value,然后返回整个 Map 结构,如果 key 已经有值,则键值会被更新,否则就新生成该键。(因为返回的是当前 Map 对象,所以可以链式调用)
  • get (key):该方法读取 key 对应的键值,如果找不到 key,返回 undefined。
  • has (key):该方法返回一个布尔值,表示某个键是否在当前 Map 对象中。
  • delete (key):该方法删除某个键,返回 true,如果删除失败,返回 false。
  • clear ():map.clear () 清除所有成员,没有返回值。

Map 结构原生提供是三个遍历器生成函数和一个遍历方法

  • keys ():返回键名的遍历器。
  • values ():返回键值的遍历器。
  • entries ():返回所有成员的遍历器。
  • forEach ():遍历 Map 的所有成员。
t
const map = new Map([
     ["foo",1],
     ["bar",2],
])
for(let key of map.keys()){
    console.log(key);  // foo bar
}
for(let value of map.values()){
     console.log(value); // 1 2
}
for(let items of map.entries()){
    console.log(items);  // ["foo",1]  ["bar",2]
}
map.forEach( (value,key,map) => {
     console.log(key,value); // foo 1    bar 2
})

(2)WeakMap WeakMap 对象也是一组键值对的集合,其中的键是弱引用的。其键必须是对象,原始数据类型不能作为 key 值,而值可以是任意的。

该对象也有以下几种方法:

  • set (key,value):设置键名 key 对应的键值 value,然后返回整个 WeakMap 结构。如果 key 已经有值,则键值会被更新,否则就新生成该键。(因为返回的是当前 Map 对象,所以可以链式调用)
  • get (key):该方法读取 key 对应的键值,如果找不到 key,返回 undefined。
  • has (key):该方法返回一个布尔值,表示某个键是否在当前 WeakMap 对象中。
  • delete (key):该方法删除某个键,返回 true,如果删除失败,返回 false。

其 clear () 方法已经被弃用,所以可以通过创建一个空的 WeakMap 并替换原对象来实现清除。

WeakMap 的设计目的在于,有时想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用。一旦不再需要这两个对象,就必须手动删除这个引用,否则垃圾回收机制就不会释放对象占用的内存。

而 WeakMap 的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。

总结:

  • Map 数据结构。它类似于对象,也是键值对的集合,但是 “键” 的范围不限于字符串,各种类型的值(包括对象)都可以当作键。
  • WeakMap 结构与 Map 结构类似,也是用于生成键值对的集合。但是 WeakMap 只接受对象作为键名( null 除外),不接受其他类型的值作为键名。而且 WeakMap 的键名所指向的对象,不计入垃圾回收机制。
  1. JavaScript 有哪些内置对象

全局的对象( global objects )或称标准内置对象,不要和 "全局对象(global object)" 混淆。这里说的全局的对象是说在 全局作用域里的对象。全局作用域中的其他对象可以由用户的脚本创建或由宿主程序提供。

标准内置对象的分类:

(1)值属性,这些全局属性返回一个简单值,这些值没有自己的属性和方法。例如 Infinity、NaN、undefined、null 字面量

(2)函数属性,全局函数可以直接调用,不需要在调用时指定所属对象,执行结束后会将结果直接返回给调用者。例如 eval ()、parseFloat ()、parseInt () 等

(3)基本对象,基本对象是定义或使用其他对象的基础。基本对象包括一般对象、函数对象和错误对象。例如 Object、Function、Boolean、Symbol、Error 等

(4)数字和日期对象,用来表示数字、日期和执行数学计算的对象。例如 Number、Math、Date

(5)字符串,用来表示和操作字符串的对象。例如 String、RegExp

(6)可索引的集合对象,这些对象表示按照索引值来排序的数据集合,包括数组和类型数组,以及类数组结构的对象。例如 Array

(7)使用键的集合对象,这些集合对象在存储数据时会使用到键,支持按照插入顺序来迭代元素。 例如 Map、Set、WeakMap、WeakSet

(8)矢量集合,SIMD 矢量集合中的数据会被组织为一个数据序列。 例如 SIMD 等

(9)结构化数据,这些对象用来表示和操作结构化的缓冲区数据,或使用 JSON 编码的数据。例如 JSON 等

(10)控制抽象对象 例如 Promise、Generator 等

(11)反射。例如 Reflect、Proxy

(12)国际化,为了支持多语言处理而加入 ECMAScript 的对象。例如 Intl、Intl.Collator 等

(13)WebAssembly

(14)其他。例如 arguments

总结:
js 中的内置对象主要指的是在程序执行前存在全局作用域里的由 js 定义的一些全局值属性、函数和用来实例化其他对象的构造函数对象。一般经常用到的如全局变量值 NaN、undefined,全局函数如 parseInt ()、parseFloat () 用来实例化对象的构造函数如 Date、Object 等,还有提供数学计算的单体内置对象如 Math 对象。

  1. 常用的正则表达式有哪些?
t
// (1)匹配 16 进制颜色值
var regex = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})/g;
// (2)匹配日期,如 yyyy-mm-dd 格式
var regex = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/;
// (3)匹配 qq 号
var regex = /^[1-9][0-9]{4,10}$/g;
// (4)手机号码正则
var regex = /^1[34578]\d{9}$/g;
// (5)用户名正则
var regex = /^[a-zA-Z\$][a-zA-Z0-9_\$]{4,16}$/;
  1. 对 JSON 的理解

JSON 是一种基于文本的轻量级的数据交换格式。它可以被任何的编程语言读取和作为数据格式来传递。

在项目开发中,使用 JSON 作为前后端数据交换的方式。在前端通过将一个符合 JSON 格式的数据结构序列化为 JSON 字符串,然后将它传递到后端,后端通过 JSON 格式的字符串解析后生成对应的数据结构,以此来实现前后端数据的一个传递。

因为 JSON 的语法是基于 js 的,因此很容易将 JSON 和 js 中的对象弄混,但是应该注意的是 JSON 和 js 中的对象不是一回事,JSON 中对象格式更加严格,比如说在 JSON 中属性值不能为函数,不能出现 NaN 这样的属性值等,因此大多数的 js 对象是不符合 JSON 对象的格式的。

在 js 中提供了两个函数来实现 js 数据结构和 JSON 格式的转换处理,

  • JSON.stringify 函数,通过传入一个符合 JSON 格式的数据结构,将其转换为一个 JSON 字符串。如果传入的数据结构不符合 JSON 格式,那么在序列化的时候会对这些值进行对应的特殊处理,使其符合规范。在前端向后端发送数据时,可以调用这个函数将数据对象转化为 JSON 格式的字符串。

  • JSON.parse () 函数,这个函数用来将 JSON 格式的字符串转换为一个 js 数据结构,如果传入的字符串不是标准的 JSON 格式的字符串的话,将会抛出错误。当从后端接收到 JSON 格式的字符串时,可以通过这个方法来将其解析为一个 js 数据结构,以此来进行数据的访问。

  1. JavaScript 脚本延迟加载的方式有哪些?

延迟加载就是等页面加载完成之后再加载 JavaScript 文件。 js 延迟加载有助于提高页面加载速度。

一般有以下几种方式:

  • defer 属性: 给 js 脚本添加 defer 属性,这个属性会让脚本的加载与文档的解析同步解析,然后在文档解析完成后再执行这个脚本文件,这样的话就能使页面的渲染不被阻塞。多个设置了 defer 属性的脚本按规范来说最后是顺序执行的,但是在一些浏览器中可能不是这样。

  • async 属性: 给 js 脚本添加 async 属性,这个属性会使脚本异步加载,不会阻塞页面的解析过程,但是当脚本加载完成后立即执行 js 脚本,这个时候如果文档没有解析完成的话同样会阻塞。多个 async 属性的脚本的执行顺序是不可预测的,一般不会按照代码的顺序依次执行。

  • 动态创建 DOM 方式: 动态创建 DOM 标签的方式,可以对文档的加载事件进行监听,当文档加载完成后再动态的创建 script 标签来引入 js 脚本。

  • 使用 setTimeout 延迟方法: 设置一个定时器来延迟加载 js 脚本文件

  • 让 JS 最后加载: 将 js 脚本放在文档的底部,来使 js 脚本尽可能的在最后来加载执行。

  1. JavaScript 类数组对象的定义?

一个拥有 length 属性和若干索引属性的对象就可以被称为类数组对象,类数组对象和数组类似,但是不能调用数组的方法。常见的类数组对象有 arguments 和 DOM 方法的返回结果,还有一个函数也可以被看作是类数组对象,因为它含有 length 属性值,代表可接收的参数个数。

常见的类数组转换为数组的方法有这样几种:

(1)通过 call 调用数组的 slice 方法来实现转换

t
Array.prototype.slice.call(arrayLike)

(2)通过 call 调用数组的 splice 方法来实现转换

t
Array.prototype.splice.call(arrayLike,0)

(3)通过 apply 调用数组的 concat 方法来实现转换

t
Array.prototype.concat.apply([],arrayLike)

(P.S.call 和 apply 是 JavaScript 中函数对象的两个方法,它们的主要功能非常相似,都是用于调用函数并显式指定函数内部的 this 值。然而,它们在传递参数的方式上有所不同:call 接受参数列表,apply 接受参数数组。)

(4)通过 Array.from 方法来实现转换

t
Array.from(arrayLike);
  1. 数组有哪些原生方法?
  • 数组和字符串的转换方法:toString ()、toLocalString ()、join () 其中 join () 方法可以指定转换为字符串时的分隔符。
  • 数组尾部操作的方法 pop () 和 push (),push 方法可以传入多个参数。
  • 数组首部操作的方法 shift () 和 unshift () 重排序的方法 reverse () 和 sort (),sort () 方法可以传入一个函数来进行比较,传入前后两个值,如果返回值为正数,则交换两个参数的位置。
  • 数组连接的方法 concat () ,返回的是拼接好的数组,不影响原数组。
  • 数组截取办法 slice (),用于截取数组中的一部分返回,不影响原数组。
  • 数组插入方法 splice (),影响原数组查找特定项的索引的方法,indexOf () 和 lastIndexOf () 迭代方法 every ()、some ()、filter ()、map () 和 forEach () 方法
  • 数组归并方法 reduce () 和 reduceRight ()
  1. Unicode、UTF-8、UTF-16、UTF-32 的区别?

(1)Unicode

在说 Unicode 之前需要先了解一下 ASCII 码:ASCII 码(American Standard Code for Information Interchange)称为美国标准信息交换码。

  • 它是基于拉丁字母的一套电脑编码系统。
  • 它定义了一个用于代表常见字符的字典。
  • 它包含了 "A-Z"(包含大小写),数据 "0-9" 以及一些常见的符号。
  • 它是专门为英语而设计的,有 128 个编码,对其他语言无能为力

ASCII 码可以表示的编码有限,要想表示其他语言的编码,还是要使用 Unicode 来表示,可以说 Unicode 是 ASCII 的超集。

Unicode 全称 Unicode Translation Format,又叫做统一码、万国码、单一码。
Unicode 是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。

Unicode 的实现方式(也就是编码方式)有很多种,常见的是 UTF-8、UTF-16、UTF-32 和 USC-2。

(2)UTF-8

UTF-8 是使用最广泛的 Unicode 编码方式,它是一种可变长的编码方式,可以是 1—4 个字节不等,它可以完全兼容 ASCII 码的 128 个字符。

注意: UTF-8 是一种编码方式,Unicode 是一个字符集合。

UTF-8 的编码规则:

  • 对于单字节的符号,字节的第一位为 0,后面的 7 位为这个字符的 Unicode 编码,因此对于英文字母,它的 Unicode 编码和 ACSII 编码一样。
  • 对于 n 字节的符号,第一个字节的前 n 位都是 1,第 n+1 位设为 0,后面字节的前两位一律设为 10,剩下的没有提及的二进制位,全部为这个符号的 Unicode 码 。

来看一下具体的 Unicode 编号范围与对应的 UTF-8 二进制格式 :

编码范围(编号对应的十进制数)二进制格式
0x00—0x7F (0-127)0xxxxxxx
0x80—0x7FF (128-2047)110xxxxx 10xxxxxx
0x800—0xFFFF (2048-65535)1110xxxx 10xxxxxx 10xxxxxx
0x10000—0x10FFFF (65536 以上)11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

那该如何通过具体的 Unicode 编码,进行具体的 UTF-8 编码呢?步骤如下:

  • 找到该 Unicode 编码的所在的编号范围,进而找到与之对应的二进制格式
  • 将 Unicode 编码转换为二进制数(去掉最高位的 0)
  • 将二进制数从右往左一次填入二进制格式的 X 中,如果有 X 未填,就设为 0

来看一个实际的例子:
“马” 字的 Unicode 编码是:0x9A6C,整数编号是 39532
(1)首选确定了该字符在第三个范围内,它的格式是 1110xxxx 10xxxxxx 10xxxxxx
(2)39532 对应的二进制数为 1001 1010 0110 1100
(3)将二进制数填入 X 中,结果是:11101001 10101001 10101100

(3)UTF-16

平面的概念
在了解 UTF-16 之前,先看一下平面的概念:
Unicode 编码中有很多很多的字符,它并不是一次性定义的,而是分区进行定义的,每个区存放 65536(216)个字符,这称为一个平面,目前总共有 17 个平面。
最前面的一个平面称为基本平面,它的码点从 0 — 216-1,写成 16 进制就是 U+0000 — U+FFFF,那剩下的 16 个平面就是辅助平面,码点范围是 U+10000—U+10FFFF。

UTF-16 概念:
UTF-16 也是 Unicode 编码集的一种编码形式,把 Unicode 字符集的抽象码位映射为 16 位长的整数(即码元)的序列,用于数据存储或传递。Unicode 字符的码位需要 1 个或者 2 个 16 位长的码元来表示,因此 UTF-16 也是用变长字节表示的。

UTF-16 编码规则:
编号在 U+0000—U+FFFF 的字符(常用字符集),直接用两个字节表示。
编号在 U+10000—U+10FFFF 之间的字符,需要用四个字节表示。

编码识别

那么问题来了,当遇到两个字节时,怎么知道是把它当做一个字符还是和后面的两个字节一起当做一个字符呢?

UTF-16 编码肯定也考虑到了这个问题,在基本平面内,从 U+D800 — U+DFFF 是一个空段,也就是说这个区间的码点不对应任何的字符,因此这些空段就可以用来映射辅助平面的字符。

辅助平面共有 220 个字符位,因此表示这些字符至少需要 20 个二进制位。UTF-16 将这 20 个二进制位分成两半,前 10 位映射在 U+D800 — U+DBFF,称为高位(H),后 10 位映射在 U+DC00 — U+DFFF,称为低位(L)。这就相当于,将一个辅助平面的字符拆成了两个基本平面的字符来表示。

因此,当遇到两个字节时,发现它的码点在 U+D800 —U+DBFF 之间,就可以知道,它后面的两个字节的码点应该在 U+DC00 — U+DFFF 之间,这四个字节必须放在一起进行解读。

举例说明
以 "𡠀" 字为例,它的 Unicode 码点为 0x21800,该码点超出了基本平面的范围,因此需要用四个字节来表示,步骤如下:

首先计算超出部分的结果:0x21800 - 0x10000
将上面的计算结果转为 20 位的二进制数,不足 20 位就在前面补 0,结果为:0001000110 0000000000
将得到的两个 10 位二进制数分别对应到两个区间中
U+D800 对应的二进制数为 1101100000000000, 将 0001000110 填充在它的后 10 个二进制位,得到 1101100001000110,转成 16 进制数为 0xD846。同理,低位为 0xDC00,所以这个字的 UTF-16 编码为 0xD846 0xDC00

(4) UTF-32
UTF-32 就是字符所对应编号的整数二进制形式,每个字符占四个字节,这个是直接进行转换的。该编码方式占用的储存空间较多,所以使用较少。
比如 “马” 字的 Unicode 编号是:U+9A6C,整数编号是 39532,直接转化为二进制:1001 1010 0110 1100,这就是它的 UTF-32 编码。

(5)总结
Unicode、UTF-8、UTF-16、UTF-32 有什么区别?

  • Unicode 是编码字符集(字符集),而 UTF-8、UTF-16、UTF-32 是字符集编码(编码规则);
  • UTF-16 使用变长码元序列的编码方式,相较于定长码元序列的 UTF-32 算法更复杂,甚至比同样是变长码元序列的 UTF-8 也更为复杂,因为其引入了独特的代理对这样的代理机制;
  • UTF-8 需要判断每个字节中的开头标志信息,所以如果某个字节在传送过程中出错了,就会导致后面的字节也会解析出错;而 UTF-16 不会判断开头标志,即使错也只会错一个字符,所以容错能力教强;
  • 如果字符内容全部英文或英文与其他文字混合,但英文占绝大部分,那么用 UTF-8 就比 UTF-16 节省了很多空间;而如果字符内容全部是中文这样类似的字符或者混合字符中中文占绝大多数,那么 UTF-16 就占优势了,可以节省很多空间;
  1. 常见的位运算符有哪些?其计算规则是什么?

现代计算机中数据都是以二进制的形式存储的,即 0、1 两种状态,计算机对二进制数据进行的运算加减乘除等都是叫位运算,即将符号位共同参与运算的运算。

常见的位运算符有:

运算符描述运算规则
&两个位都为 1 时,结果才为 1
两个位都为 0 时,结果才为 0
^异或两个位相同为 0,不同为 1
~取反0 变 1,1 变 0
<<左移各二进位全部左移若干位,高位丢弃,低位补 0
>>右移各二进制位全部右移若干位,正数左补 0,负数左补 1,右边丢弃
  1. 原码、补码、反码
    上面提到了补码、反码等知识,这里就补充一下。 计算机中的有符号数有三种表示方法,即原码、反码和补码。三种表示方法均有符号位和数值位两部分,符号位都是用 0 表示 “正”,用 1 表示 “负”,而数值位,三种表示方法各不相同。

(1)原码

原码就是一个数的二进制数。例如:10 的原码为 0000 1010

(2)反码

  • 正数的反码与原码相同,如:10 反码为 0000 1010
  • 负数的反码为除符号位,按位取反,即 0 变 1,1 变 0。

(3)补码
正数的补码与原码相同,如:10 补码为 0000 1010
负数的补码是原码除符号位外的所有位取反即 0 变 1,1 变 0,然后加 1,也就是反码加 1。

  1. 为什么函数的 arguments 参数是类数组而不是数组?如何遍历类数组?

arguments 是一个对象,它的属性是从 0 开始依次递增的数字,还有 callee 和 length 等属性,与数组相似;但是它却没有数组常见的方法属性,如 forEach, reduce 等,所以叫它们类数组。

要遍历类数组,有三个方法:

(1)将数组的方法应用到类数组上,这时候就可以使用 call 和 apply 方法,如:

t
function foo() {
    Array.prototype.forEach.call(arguments, a => console.log(a))
}

(2)使用 Array.from 方法将类数组转化成数组:‌

t
function foo(){ 
  const arrArgs = Array.from(arguments) 
  arrArgs.forEach(a => console.log(a))
}

(3)使用展开运算符将类数组转化成数组

t
function foo(){ 
    const arrArgs = [...arguments] 
    arrArgs.forEach(a => console.log(a)) 
}
  1. 什么是 DOM 和 BOM?
  • DOM 指的是文档对象原型,它指的是把文档当作一个对象,这个对象主要定义了处理网页内容的方法和接口。
  • BOM 指的是浏览器对象模型,它指的是把浏览器当做一个对象来对待,这个对象主要定义了与浏览器进行交互的方法和接口。BOM 的核心是 window,而 window 对象具有双重角色,它既是通过 js 访问浏览器窗口的一个接口,又是一个 Global(全局)对象。这意味着在网页中定义的任何对象,变量和函数,都作为全局对象的一个属性或者方法存在。window 对象含有 location 对象、navigator 对象、screen 对象等子对象,并且 DOM 的最根本的对象 document 对象也是 BOM 的 window 对象的子对象。
  1. 对类数组对象的理解,如何转化为数组

一个拥有 length 属性和若干索引属性的对象就可以被称为类数组对象,
类数组对象和数组类似,但是不能调用数组的方法。
见的类数组对象有 arguments 和 DOM 方法的返回结果,函数参数也可以被看作是类数组对象,因为它含有 length 属性值,代表可接收的参数个数。

常见的类数组转换为数组的方法有这样几种:

  • 通过 call 调用数组的 slice 方法来实现转换
t
Array.prototype.slice.call(arrayLike)
  • 通过 call 调用数组的 splice 方法来实现转换
t
Array.prototype.splice.call(arrayLike, 0);
  • 通过 apply 调用数组的 concat 方法来实现转换
t
Array.prototype.concat.apply([], arrayLike);
  • 通过 Array.from 方法来实现转换
t
Array.from(arrayLike);
  1. escape、encodeURI、encodeURIComponent 的区别
  • encodeURI 是对整个 URI 进行转义,将 URI 中的非法字符转换为合法字符,所以对于一些在 URI 中有特殊意义的字符不会进行转义。
  • encodeURIComponent 是对 URI 的组成部分进行转义,所以一些特殊字符也会得到转义。
  • escape 和 encodeURI 的作用相同,不过它们对于 unicode 编码为 0xff 之外字符的时候会有区别,escape 是直接在字符的 unicode 编码前加上 % u,而 encodeURI 首先会将字符转换为 UTF-8 的格式,再在每个字节前加上 %。
  1. 对 AJAX 的理解,实现一个 AJAX 请求

AJAX 是 Asynchronous JavaScript and XML 的缩写,指的是通过 JavaScript 的 异步通信,从服务器获取 XML 文档从中提取数据,再更新当前网页的对应部分,而不用刷新整个网页。

创建 AJAX 请求的步骤:

  • 创建一个 XMLHttpRequest 对象。
  • 在这个对象上使用 open 方法创建一个 HTTP 请求,open 方法所需要的参数是请求的方法、请求的地址、是否异步和用户的认证信息。
  • 在发起请求前,可以为这个对象添加一些信息和监听函数。比如说可以通过 setRequestHeader 方法来为请求添加头信息。还可以为这个对象添加一个状态监听函数。一个 XMLHttpRequest 对象一共有 5 个状态,当它的状态变化时会触发 onreadystatechange 事件,可以通过设置监听函数,来处理请求成功后的结果。当对象的 readyState 变为 4 的时候,代表服务器返回的数据接收完成,这个时候可以通过判断请求的状态,如果状态是 2xx 或者 304 的话则代表返回正常。这个时候就可以通过 response 中的数据来对页面进行更新了。
  • 当对象的属性和监听函数设置完成后,最后调用 sent 方法来向服务器发起请求,可以传入参数作为发送的数据体。
t
const SERVER_URL = "/server";
let xhr = new XMLHttpRequest();
// 创建 Http 请求
xhr.open("GET", SERVER_URL, true);
// 设置状态监听函数
xhr.onreadystatechange = function() {
  if (this.readyState !== 4) return;
  // 当请求成功时
  if (this.status === 200) {
    handle(this.response);
  } else {
    console.error(this.statusText);
  }
};
// 设置请求失败时的监听函数
xhr.onerror = function() {
  console.error(this.statusText);
};
// 设置请求头信息
xhr.responseType = "json";
xhr.setRequestHeader("Accept", "application/json");
// 发送 Http 请求
xhr.send(null);

使用 Promise 封装 AJAX:

t
//promise 封装实现:
function getJSON(url) {
  // 创建一个 promise 对象
  let promise = new Promise(function(resolve, reject) {
    let xhr = new XMLHttpRequest();
    // 新建一个 http 请求
    xhr.open("GET", url, true);
    // 设置状态的监听函数
    xhr.onreadystatechange = function() {
      if (this.readyState !== 4) return;
      // 当请求成功或失败时,改变 promise 的状态
      if (this.status === 200) {
        resolve(this.response);
      } else {
        reject(new Error(this.statusText));
      }
    };
    // 设置错误监听函数
    xhr.onerror = function() {
      reject(new Error(this.statusText));
    };
    // 设置响应的数据类型
    xhr.responseType = "json";
    // 设置请求头信息
    xhr.setRequestHeader("Accept", "application/json");
    // 发送 http 请求
    xhr.send(null);
  });
  return promise;
}

传统的 AJAX 请求通常使用 XMLHttpRequest 对象来实现。这种方式需要手动处理异步操作的回调函数,代码的可读性和维护性相对较低。
使用 Promise 封装 AJAX 请求可以将异步操作以更简洁和可读的方式表达。Promise 提供了链式调用的能力,使得处理多个异步操作时更加方便。Promise 是 ES6 标准的一部分,提供了统一的异步编程模型,更容易被其他开发者理解和使用。

  1. JavaScript 为什么要进行变量提升,它导致了什么问题?

变量提升的表现是,无论在函数中何处位置声明的变量,好像都被提升到了函数的首部,可以在变量声明前访问到而不会报错。

造成变量声明提升的本质原因是 js 引擎在代码执行前有一个解析的过程,创建了执行上下文,初始化了一些代码执行时需要用到的对象。当访问一个变量时,会到当前执行上下文中的作用域链中去查找,而作用域链的首端指向的是当前执行上下文的变量对象,这个变量对象是执行上下文的一个属性,它包含了函数的形参、所有的函数和变量声明,这个对象的是在代码解析的时候创建的。

首先要知道,JS 在拿到一个变量或者一个函数的时候,会有两步操作,即解析和执行。

  • 在解析阶段,JS 会检查语法,并对函数进行预编译。解析的时候会先创建一个全局执行上下文环境,先把代码中即将执行的变量、函数声明都拿出来,变量先赋值为 undefined,函数先声明好可使用。在一个函数执行之前,也会创建一个函数执行上下文环境,跟全局执行上下文类似,不过函数执行上下文会多出 this、arguments 和函数的参数。

  • 全局上下文:变量定义,函数声明

  • 函数上下文:变量定义,函数声明,this,arguments

  • 在执行阶段,就是按照代码的顺序依次执行。

那为什么会进行变量提升呢?主要有以下两个原因:

  • 提高性能
  • 容错性更好

(1)提高性能 在 JS 代码执行之前,会进行语法检查和预编译,并且这一操作只进行一次。这么做就是为了提高性能,如果没有这一步,那么每次执行代码前都必须重新解析一遍该变量(函数),而这是没有必要的,因为变量(函数)的代码并不会改变,解析一遍就够了。
在解析的过程中,还会为函数生成预编译代码。在预编译时,会统计声明了哪些变量、创建了哪些函数,并对函数的代码进行压缩,去除注释、不必要的空白等。这样做的好处就是每次执行函数时都可以直接为该函数分配栈空间(不需要再解析一遍去获取代码中声明了哪些变量,创建了哪些函数),并且因为代码压缩的原因,代码执行也更快了。

(2)容错性更好
变量提升可以在一定程度上提高 JS 的容错性,看下面的代码:

t
a = 1;var a;console.log(a);

如果没有变量提升,这两行代码就会报错,但是因为有了变量提升,这段代码就可以正常执行。

虽然,在可以开发过程中,可以完全避免这样写,但是有时代码很复杂的时候。可能因为疏忽而先使用后定义了,这样也不会影响正常使用。由于变量提升的存在,而会正常运行。

总结:

  • 解析和预编译过程中的声明提升可以提高性能,让函数可以在执行时预先为变量分配栈空间
  • 声明提升还可以提高 JS 代码的容错性,使一些不规范的代码也可以正常执行

变量提升虽然有一些优点,但是他也会造成一定的问题,在 ES6 中提出了 let、const 来定义变量,它们就没有变量提升的机制。下面看一下变量提升可能会导致的问题:

t
var tmp = new Date();
function fn(){
	console.log(tmp);
	if(false){
		var tmp = 'hello world';
	}
}
fn();  // undefined

在这个函数中,原本是要打印出外层的 tmp 变量,但是因为变量提升的问题,内层定义的 tmp 被提到函数内部的最顶部,相当于覆盖了外层的 tmp,所以打印结果为 undefined。

t
var tmp = 'hello world';
for (var i = 0; i < tmp.length; i++) {
	console.log(tmp[i]);
}
console.log(i); // 11

由于遍历时定义的 i 会变量提升成为一个全局变量,在函数结束之后不会被销毁,所以打印出来 11。

  1. 什么是尾调用,使用尾调用有什么好处?

尾调用指的是函数的最后一步调用另一个函数。
代码执行是基于执行栈的,所以当在一个函数里调用另一个函数时,会保留当前的执行上下文,然后再新建另外一个执行上下文加入栈中。使用尾调用的话,因为已经是函数的最后一步,所以这时可以不必再保留当前的执行上下文,从而节省了内存,这就是尾调用优化。

但是 ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。

  1. ES6 模块与 CommonJS 模块有什么异同?
    ES6 Module 和 CommonJS 是两种常见的 JavaScript 模块系统,它们用于在不同的 JavaScript 运行环境中管理代码的组织和复用。

ES6 Module 和 CommonJS 模块的区别:

  • CommonJS 是对模块的浅拷⻉,ES6 Module 是对模块的引⽤,即 ES6 Module 只存只读,不能改变其值,也就是指针指向不能变,类似 const;
  • import 的接⼝是 read-only(只读状态),不能修改其变量值。即不能修改其变量的指针指向,但可以改变变量内部指针指向,可以对 commonJS 对重新赋值(改变指针指向),但是对 ES6 Module 赋值会编译报错。

ES6 Module 和 CommonJS 模块的共同点:

  • CommonJS 和 ES6 Module 都可以对引⼊的对象进⾏赋值,即对对象内部属性的值进⾏改变。
  1. 常见的 DOM 操作有哪些

1)DOM 节点的获取

DOM 节点的获取的 API 及使用:

t
getElementById // 按照 id 查询
getElementsByTagName // 按照标签名查询
getElementsByClassName // 按照类名查询
querySelectorAll // 按照 css 选择器查询
// 按照 id 查询
var imooc = document.getElementById('imooc') // 查询到 id 为 imooc 的元素
// 按照标签名查询
var pList = document.getElementsByTagName('p')  // 查询到标签为 p 的集合
console.log(divList.length)
console.log(divList[0])
// 按照类名查询
var moocList = document.getElementsByClassName('mooc') // 查询到类名为 mooc 的集合
// 按照 css 选择器查询
var pList = document.querySelectorAll('.mooc') // 查询到类名为 mooc 的集合

2)DOM 节点的创建

创建一个新节点,并把它添加到指定节点的后面。 已知的 HTML 结构如下:

l
<html>
  <head>
    <title>DEMO</title>
  </head>
  <body>
    <div id="container"> 
      <h1 id="title">我是标题</h1>
    </div>   
  </body>
</html>

要求添加一个有内容的 span 节点到 id 为 title 的节点后面,做法就是:

t
// 首先获取父节点
var container = document.getElementById('container')
// 创建新节点
var targetSpan = document.createElement('span')
// 设置 span 节点的内容
targetSpan.innerHTML = 'hello world'
// 把新创建的元素塞进父节点里去
container.appendChild(targetSpan)

3)DOM 节点的删除

删除指定的 DOM 节点, 已知的 HTML 结构如下:

t
<html>
  <head>
    <title>DEMO</title>
  </head>
  <body>
    <div id="container"> 
      <h1 id="title">我是标题</h1>
    </div>   
  </body>
</html>

需要删除 id 为 title 的元素,做法是:

t
// 获取目标元素的父元素
var container = document.getElementById('container')
// 获取目标元素
var targetNode = document.getElementById('title')
// 删除目标元素
container.removeChild(targetNode)

或者通过子节点数组来完成删除:

t
// 获取目标元素的父元素 var container = document.getElementById ('container')// 获取目标元素 var targetNode = container.childNodes [1]// 删除目标元素 container.removeChild (targetNode)

4)修改 DOM 元素

修改 DOM 元素这个动作可以分很多维度,比如说移动 DOM 元素的位置,修改 DOM 元素的属性等。

将指定的两个 DOM 元素交换位置, 已知的 HTML 结构如下:

t
<html>
  <head>
    <title>DEMO</title>
  </head>
  <body>
    <div id="container"> 
      <h1 id="title">我是标题</h1>
      <p id="content">我是内容</p>
    </div>   
  </body>
</html>

现在需要调换 title 和 content 的位置,可以考虑 insertBefore 或者 appendChild:

t
// 获取父元素
var container = document.getElementById('container')   
 
// 获取两个需要被交换的元素
var title = document.getElementById('title')
var content = document.getElementById('content')
// 交换两个元素,把 content 置于 title 前面
container.insertBefore(content, title)
  1. use strict 是什么意思?使用它区别是什么?

use strict 是一种 ECMAscript5 添加的(严格模式)运行模式,这种模式使得 Javascript 在更严格的条件下运行。设立严格模式的目的如下:

  • 消除 Javascript 语法的一些不合理、不严谨之处,减少一些怪异行为;
  • 消除代码运行的一些不安全之处,保证代码运行的安全;
  • 提高编译器效率,增加运行速度;
  • 为未来新版本的 Javascript 做好铺垫。

区别:

  • 禁止使用 with 语句。
  • 禁止 this 关键字指向全局对象。
  • 对象不能有重名的属性。
  1. 如何判断一个对象是否属于某个类?
  • 第一种方式,使用 instanceof 运算符来判断构造函数的 prototype 属性是否出现在对象的原型链中的任何位置。
  • 第二种方式,通过对象的 constructor 属性来判断,对象的 constructor 属性指向该对象的构造函数,但是这种方式不是很安全,因为 constructor 属性可以被改写。
  • 第三种方式,如果需要判断的是某个内置的引用类型的话,可以使用 Object.prototype.toString () 方法来打印对象的 [[Class]] 属性来进行判断。
  1. 强类型语言和弱类型语言的区别
  • 强类型语言:强类型语言也称为强类型定义语言,是一种总是强制类型定义的语言,要求变量的使用要严格符合定义,所有变量都必须先定义后使用。
  • Java 和 C++ 等语言都是强制类型定义的,也就是说,一旦一个变量被指定了某个数据类型,如果不经过强制转换,那么它就永远是这个数据类型了。例如你有一个整数,如果不显式地进行转换,你不能将其视为一个字符串。
  • 弱类型语言:弱类型语言也称为弱类型定义语言,与强类型定义相反。
  • JavaScript 语言就属于弱类型语言。简单理解就是一种变量类型可以被忽略的语言。比如 JavaScript 是弱类型定义的,在 JavaScript 中就可以将字符串 '12' 和整数 3 进行连接得到字符串 '123',在相加的时候会进行强制类型转换。

两者对比:强类型语言在速度上可能略逊色于弱类型语言,但是强类型语言带来的严谨性可以有效地帮助避免许多错误。

  1. 解释性语言和编译型语言的区别

(1)解释型语言 使用专门的解释器对源程序逐行解释成特定平台的机器码并立即执行。是代码在执行时才被解释器一行行动态翻译和执行,而不是在执行之前就完成翻译。解释型语言不需要事先编译,其直接将源代码解释成机器码并立即执行,所以只要某一平台提供了相应的解释器即可运行该程序。其特点总结如下

  • 解释型语言每次运行都需要将源代码解释成机器码并执行,效率较低;
  • 只要平台提供相应的解释器,就可以运行源代码,所以可以方便源程序移植;
  • JavaScript、Python 等属于解释型语言。

(2)编译型语言 使用专门的编译器,针对特定的平台,将高级语言源代码一次性的编译成可被该平台硬件执行的机器码,并包装成该平台所能识别的可执行性程序的格式。在编译型语言写的程序执行之前,需要一个专门的编译过程,把源代码编译成机器语言的文件,如 exe 格式的文件,以后要再运行时,直接使用编译结果即可,如直接运行 exe 文件。因为只需编译一次,以后运行时不需要编译,所以编译型语言执行效率高。其特点总结如下:

  • 一次性的编译成平台相关的机器语言文件,运行时脱离开发环境,运行效率高;
  • 与特定平台相关,一般无法移植到其他平台;
  • C、C++、Java、Go、Rust 等属于编译型语言。

两者主要区别在于: 前者(编译型)源程序编译后即可在该平台运行,(解释型)后者是在运行期间才编译。所以前者运行速度快,后者跨平台性好。

  1. for...in 和 for...of 的区别

for…of 是 ES6 新增的遍历方式,允许遍历一个含有 iterator 接口的数据结构(数组、对象等)并且返回各项的值,和 ES3 中的 for…in 的区别如下:

  • for…of 遍历获取的是对象的键值,for…in 获取的是对象的键名;
  • for… in 会遍历对象的整个原型链,性能非常差不推荐使用,而 for … of 只遍历当前对象不会遍历原型链;
  • 对于数组的遍历,for…in 会返回数组中所有可枚举的属性 (包括原型链上可枚举的属性),for…of 只返回数组的下标对应的属性值;

总结: for...in 循环主要是为了遍历对象而生,不适用于遍历数组;for...of 循环可以用来遍历数组、类数组对象,字符串、Set、Map 以及 Generator 对象。

  1. 如何使用 for...of 遍历对象

for…of 是作为 ES6 新增的遍历方式,允许遍历一个含有 iterator 接口的数据结构(数组、对象等)并且返回各项的值,普通的对象用 for..of 遍历是会报错的。

p.s. 含有迭代器接口(Iterator Interface)的数据结构是一种可以被遍历的数据结构,它提供了一种标准的方法来访问集合中的元素,而无需暴露集合的内部结构。在 JavaScript 中,迭代器接口通过 Symbol.iterator 方法实现。许多内置的数据结构(如数组、Set、Map)都实现了这一接口。

如果需要遍历的对象是类数组对象,用 Array.from 转成数组即可。

t
var obj = {
    0:'one',
    1:'two',
    length: 2
};
obj = Array.from(obj);
for(var k of obj){
    console.log(k)
}

如果不是类数组对象,就给对象添加一个 [Symbol.iterator] 属性,并指向一个迭代器即可。

t
// 方法一:
var obj = {
    a:1,
    b:2,
    c:3
};
obj[Symbol.iterator] = function(){
	var keys = Object.keys(this);
	var count = 0;
	return {
		next(){
			if(count<keys.length){
				return {value: obj[keys[count++]],done:false};
			}else{
				return {value:undefined,done:true};
			}
		}
	}
};
for(var k of obj){
	console.log(k);
}
// 方法二
var obj = {
    a:1,
    b:2,
    c:3
};
obj[Symbol.iterator] = function*(){
    var keys = Object.keys(obj);
    for(var k of keys){
        yield [k,obj[k]]
    }
};
for(var [k,v] of obj){
    console.log(k,v);
}
  1. ajax、axios、fetch 的区别

(1)AJAX Ajax 即 “AsynchronousJavascriptAndXML”(异步 JavaScript 和 XML),是指一种创建交互式网页应用的网页开发技术。它是一种在无需重新加载整个网页的情况下,能够更新部分网页的技术。通过在后台与服务器进行少量数据交换,Ajax 可以使网页实现异步更新。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。传统的网页(不使用 Ajax)如果需要更新内容,必须重载整个网页页面。其缺点如下:

  • 本身是针对 MVC(Model-View-Controller)编程,不符合前端 MVVM ((Model-View-ViewModel)) 的浪潮
  • 基于原生 XHR (XMLHttpRequest) 开发,XHR 本身的架构不清晰
  • 不符合关注分离(Separation of Concerns)的原则
  • 配置和调用方式非常混乱,而且基于事件的异步模型不友好。

(2)Fetch fetch 号称是 AJAX 的替代品,是在 ES6 出现的,使用了 ES6 中的 promise 对象。Fetch 是基于 promise 设计的。Fetch 的代码结构比起 ajax 简单多。fetch 不是 ajax 的进一步封装,而是原生 js,没有使用 XMLHttpRequest 对象。
fetch 的优点:

  • 语法简洁,更加语义化
  • 基于标准 Promise 实现,支持 async/await
  • 更加底层,提供的 API 丰富(request, response)
  • 脱离了 XHR,是 ES 规范里新的实现方式

P.S. async/await 是 JavaScript 中用于简化异步代码的语法糖,基于 Promise 实现。它使得异步代码的书写和阅读更加直观,避免了传统的回调函数嵌套(即 “回调地狱”)和复杂的 Promise 链式调用。

fetch 的缺点:

  • fetch 只对网络请求报错,对 400,500 都当做成功的请求,服务器返回 400,500 错误码时并不会 reject,只有网络错误这些导致请求不能完成时,fetch 才会被 reject。
  • fetch 默认不会带 cookie,需要添加配置项: fetch (url, {credentials: 'include'})
  • fetch 不支持 abort,不支持超时控制,使用 setTimeout 及 Promise.reject 的实现的超时控制并不能阻止请求过程继续在后台运行,造成了流量的浪费
  • fetch 没有办法原生监测请求的进度,而 XHR 可以

(3)Axios Axios 是一种基于 Promise 封装的 HTTP 客户端,其特点如下:

  • 浏览器端发起 XMLHttpRequests 请求
  • node 端发起 http 请求
  • 支持 Promise API
  • 监听请求和返回
  • 对请求和返回进行转化
  • 取消请求
  • 自动转换 json 数据
  • 客户端支持抵御 XSRF 攻击
  1. 数组的遍历方法有哪些
    | 方法 | 是否改变原数组 | 特点 |
    | forEach () | 否 | 数组方法,不改变原数组,没有返回值 |
    | map () | 否 | 数组方法,不改变原数组,有返回值,可链式调用 |
    | filter () | 否 | 数组方法,过滤数组,返回包含符合条件的元素的数组,可链式调用 |
    | for...of | 否 | for...of 遍历具有 Iterator 迭代器的对象的属性,返回的是数组的元素、对象的属性值,不能遍历普通的 obj 对象,将异步循环变成同步循环 |
    | every () 和 some () | 否 | 数组方法,some () 只要有一个是 true,便返回 true;而 every () 只要有一个是 false,便返回 false. |
    | find () 和 findIndex () | 否 | 数组方法,find () 返回的是第一个符合条件的值;findIndex () 返回的是第一个返回条件的值的索引值 |
    | reduce () 和 reduceRight () | 否 | 数组方法,reduce () 对数组正序操作;reduceRight () 对数组逆序操作 |

  2. forEach 和 map 方法有什么区别

这方法都是用来遍历数组的,两者区别如下:

  • forEach () 方法会针对每一个元素执行提供的函数,对数据的操作会改变原数组,该方法没有返回值;
  • map () 方法不会改变原数组的值,返回一个新数组,新数组中的值为原数组调用函数处理之后的值;

# 四、原型与原型链

  1. 对原型、原型链的理解

在 JavaScript 中是使用构造函数来新建一个对象的,每一个构造函数的内部都有一个 prototype 属性,它的属性值是一个对象,这个对象包含了可以由该构造函数的所有实例共享的属性和方法。
当使用构造函数新建一个对象后,在这个对象的内部将包含一个指针,这个指针指向构造函数的 prototype 属性对应的值,在 ES5 中这个指针被称为对象的原型。一般来说不应该能够获取到这个值的,但是现在浏览器中都实现了 proto 属性来访问这个属性,但是最好不要使用这个属性,因为它不是规范中规定的。
ES5 中新增了一个 Object.getPrototypeOf () 方法,可以通过这个方法来获取对象的原型。

当访问一个对象的属性时,如果这个对象内部不存在这个属性,那么它就会去它的原型对象里找这个属性,这个原型对象又会有自己的原型,于是就这样一直找下去,也就是原型链的概念。原型链的尽头一般来说都是 Object.prototype 所以这就是新建的对象为什么能够使用 toString () 等方法的原因。

特点: JavaScript 对象是通过引用来传递的,创建的每个新对象实体中并没有一份属于自己的原型副本。当修改原型时,与之相关的对象也会继承这一改变。

  1. 原型修改、重写
t
function Person(name) {
    this.name = name
}
// 修改原型
Person.prototype.getName = function() {}
var p = new Person('hello')
console.log(p.__proto__ === Person.prototype) // true
console.log(p.__proto__ === p.constructor.prototype) // true
// 重写原型
Person.prototype = {
    getName: function() {}
}
var p = new Person('hello')
console.log(p.__proto__ === Person.prototype)        // true
console.log(p.__proto__ === p.constructor.prototype) // false

可以看到修改原型的时候 p 的构造函数不是指向 Person 了,因为直接给 Person 的原型对象直接用对象赋值时,它的构造函数指向的了根构造函数 Object,所以这时候 p.constructor === Object ,而不是 p.constructor === Person。要想成立,就要用 constructor 指回来:

t
Person.prototype = {
    getName: function() {}
}
var p = new Person('hello')
p.constructor = Person
console.log(p.__proto__ === Person.prototype)        // true
console.log(p.__proto__ === p.constructor.prototype) // true
  1. 原型链指向
t
p.__proto__  // Person.prototype
Person.prototype.__proto__  // Object.prototype
p.__proto__.__proto__ //Object.prototype
p.__proto__.constructor.prototype.__proto__ // Object.prototype
Person.prototype.constructor.prototype.__proto__ // Object.prototype
p1.__proto__.constructor // Person
Person.prototype.constructor  // Person

P.S. constructor 是一个特殊的属性,它指向用于创建该对象的函数。
Person.prototype.constructor 这是 Person 函数本身。
假设 p1 是 new Person () 创建的对象,那么 p1.proto 是 Person.prototype。

  1. 原型链的终点是什么?如何打印出原型链的终点?

由于 Object 是构造函数,原型链终点是 Object.prototype.proto,而 Object.prototype.proto=== null //true,所以,原型链的终点是 null。

原型链上的所有原型都是对象,所有的对象最终都是由 Object 构造的,而 Object.prototype 的下一级是 Object.prototype.proto。(null)

  1. 如何获得对象非原型链上的属性?

使用后 hasOwnProperty () 方法来判断属性是否属于原型链的属性:

t
function iterate(obj){
   var res=[];
   for(var key in obj){
        if(obj.hasOwnProperty(key))
           res.push(key+': '+obj[key]);
   }
   return res;
}

# 五、执行上下文 / 作用域链 / 闭包

  1. 对闭包的理解

闭包是指有权访问另一个函数作用域中变量的函数,
创建闭包的最常见的方式就是在一个函数内创建另一个函数,创建的函数可以访问到当前函数的局部变量。

闭包有两个常用的用途;

  • 闭包的第一个用途是使我们在函数外部能够访问到函数内部的变量。通过使用闭包,可以通过在外部调用闭包函数,从而在外部访问到函数内部的变量,可以使用这种方法来创建私有变量。

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

P.S. 引用是一个变量或数据结构,它存储了另一个对象或数据的内存地址,而不是数据本身。通过引用,程序可以直接访问和操作该内存地址中的数据。

比如,函数 A 内部有一个函数 B,函数 B 可以访问到函数 A 中的变量,那么函数 B 就是闭包。

t
function A() {
  let a = 1
  window.B = function () {
      console.log(a)
  }
}
A()
B() // 1

在 JS 中,闭包存在的意义就是让我们可以间接访问函数内部的变量。经典面试题:循环中使用闭包解决 var 定义函数的问题

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

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

  • 第一种是使用闭包的方式
t
for (var i = 1; i <= 5; i++) {
    (function(j) {
        setTimeout(function timer() {
            console.log(j);
        }, j * 1000);
    })(i);
}

P.S. 立即执行函数表达式(IIFE):

(function (j) { ... })(i):这是一个立即执行的函数表达式,它创建了一个新的作用域,并将当前的 i 值传递给参数 j。这样可以确保每个 setTimeout 回调函数中的 j 都是当前循环迭代的值,而不是最终的 i 值。

在上述代码中,首先使用了立即执行函数将 i 传入函数内部,这个时候值就被固定在了参数 j 上面不会改变,当下次执行 timer 这个闭包的时候,就可以使用外部函数的变量 j,从而达到目的。

  • 第二种就是使用 setTimeout 的第三个参数,这个参数会被当成 timer 函数的参数传入。
t
for (var i = 1; i <= 5; i++) {
  setTimeout(
    function timer(j) {
      console.log(j)
    },
    i * 1000,
    i
  )
}
  • 第三种就是使用 let 定义 i 了来解决问题了,这个也是最为推荐的方式
t
for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

P.S.

  • var 的问题
    (1)函数作用域:var 声明的变量具有函数作用域,而不是块作用域。这意味着在函数内部定义的 var 变量在整个函数内部都是可见的,而不受块(如 if 语句或 for 循环)的限制。

(2)变量提升:var 声明的变量会被提升到其作用域的顶部,导致变量在声明之前可以被访问,但其值为 undefined。

(3)循环中的问题:在 for 循环中使用 var 声明的变量会在每次循环迭代中被重新赋值,但变量本身仍然是函数作用域的。这导致在异步操作(如 setTimeout)中,所有回调函数引用的是同一个变量,而不是各自循环迭代时的值。

  • let 的优点
    (1)块作用域:let 声明的变量具有块作用域,这意味着它们只在定义它们的块内可见。

(2)不提升:let 声明的变量不会被提升到其作用域的顶部,因此在声明之前访问会报错(暂时性死区)。

(3)循环中的行为:在 for 循环中使用 let 声明的变量,每次循环迭代都会创建一个新的变量绑定,因此在异步操作中,每个回调函数引用的是各自循环迭代时的值。

var:函数作用域,变量提升,不推荐在现代 JavaScript 中使用,特别是在循环中。
let:块作用域,不提升,推荐在需要块作用域变量时使用。
使用 let 可以避免很多 var 带来的问题,特别是与作用域和变量提升相关的问题。因此,在现代 JavaScript 开发中,推荐使用 let 和 const 来声明变量,而不是 var。

  1. 对作用域、作用域链的理解

1)全局作用域和函数作用域

(1)全局作用域

  • 最外层函数和最外层函数外面定义的变量拥有全局作用域
  • 所有未定义直接赋值的变量自动声明为全局作用域
  • 所有 window 对象的属性拥有全局作用域
  • 全局作用域有很大的弊端,过多的全局作用域变量会污染全局命名空间,容易引起命名冲突。

(2)函数作用域

  • 函数作用域声明在函数内部的变量,一般只有固定的代码片段可以访问到
  • 作用域是分层的,内层作用域可以访问外层作用域,反之不行

2)块级作用域

  • 使用 ES6 中新增的 let 和 const 指令可以声明块级作用域,块级作用域可以在函数中创建也可以在一个代码块中的创建(由 { } 包裹的代码片段)
  • let 和 const 声明的变量不会有变量提升,也不可以重复声明
  • 在循环中比较适合绑定块级作用域,这样就可以把声明的计数器变量限制在循环内部。

作用域链: 在当前作用域中查找所需变量,但是该作用域没有这个变量,那这个变量就是自由变量。如果在自己作用域找不到该变量就去父级作用域查找,依次向上级作用域查找,直到访问到 window 对象就被终止,这一层层的关系就是作用域链。

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

作用域链的本质上是一个指向变量对象的指针列表。变量对象是一个包含了执行环境中所有变量和函数的对象。作用域链的前端始终都是当前执行上下文的变量对象。全局执行上下文的变量对象(也就是全局对象)始终是作用域链的最后一个对象。

当查找一个变量时,如果当前执行环境中没有找到,可以沿着作用域链向后查找。

  1. 对执行上下文的理解
    1. 执行上下文类型

(1)全局执行上下文

任何不在函数内部的都是全局执行上下文,它首先会创建一个全局的 window 对象,并且设置 this 的值等于这个全局对象,一个程序中只有一个全局执行上下文。

(2)函数执行上下文

当一个函数被调用时,就会为该函数创建一个新的执行上下文,函数的上下文可以有任意多个。

(3)Eval 函数执行上下文

执行在 eval 函数中的代码会有属于他自己的执行上下文,不过 eval 函数不常使用,不做介绍。

    1. 执行上下文栈
  • JavaScript 引擎使用执行上下文栈来管理执行上下文

  • 当 JavaScript 执行代码时,首先遇到全局代码,会创建一个全局执行上下文并且压入执行栈中,每当遇到一个函数调用,就会为该函数创建一个新的执行上下文并压入栈顶,引擎会执行位于执行上下文栈顶的函数,当函数执行完成之后,执行上下文从栈中弹出,继续执行下一个上下文。当所有的代码都执行完毕之后,从栈中弹出全局执行上下文。

t
let a = 'Hello World!';
function first() {
  console.log('Inside first function');
  second();
  console.log('Again inside first function');
}
function second() {
  console.log('Inside second function');
}
first();
// 执行顺序
// 先执行 second (), 在执行 first ()
    1. 创建执行上下文

创建执行上下文有两个阶段:创建阶段和执行阶段

1)创建阶段

(1)this 绑定

  • 在全局执行上下文中,this 指向全局对象(window 对象)
  • 在函数执行上下文中,this 指向取决于函数如何调用。如果它被一个引用对象调用,那么 this 会被设置成那个对象,否则 this 的值被设置为全局对象或者 undefined

(2)创建词法环境组件

  • 词法环境是一种有标识符 —— 变量映射的数据结构,标识符是指变量 / 函数名,变量是对实际对象或原始数据的引用。
  • 词法环境的内部有两个组件:加粗样式:环境记录器:用来储存变量个函数声明的实际位置外部环境的引用:可以访问父级作用域

(3)创建变量环境组件

  • 变量环境也是一个词法环境,其环境记录器持有变量声明语句在执行上下文中创建的绑定关系。

2)执行阶段 此阶段会完成对变量的分配,最后执行完代码。

简单来说执行上下文就是指:

在执行一点 JS 代码之前,需要先解析代码。解析的时候会先创建一个全局执行上下文环境,先把代码中即将执行的变量、函数声明都拿出来,变量先赋值为 undefined,函数先声明好可使用。这一步执行完了,才开始正式的执行程序。

在一个函数执行之前,也会创建一个函数执行上下文环境,跟全局执行上下文类似,不过函数执行上下文会多出 this、arguments 和函数的参数。

  • 全局上下文:变量定义,函数声明
  • 函数上下文:变量定义,函数声明,this,arguments