浏览器

多进程

浏览器是多进程的,每打开一个Tab页,就相当于创建了一个独立的浏览器进程。感兴趣的可以自行尝试下,如果再多打开一个Tab页,进程正常会+1以上(一个Tab页面就是一个进程)

参考文章:javascript - 从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理 - 程序生涯 - SegmentFault 思否

浏览器都包含哪些进程?

Browser进程

也叫做浏览器的主控进程,只有一个。如果自己打开任务管理器,然后打开一个浏览器,就可以看到:任务管理器中出现了两个进程(一个是主控进程,一个则是打开Tab页的渲染进程)

作用包括

  • 负责浏览器界面显示,与用户交互。如前进,后退等
  • 负责各个页面的管理,创建和销毁
  • 将Renderer进程得到的内存中的Bitmap,绘制到用户界面上,网络资源的管理,下载等

其他进程

  • 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建
  • GPU进程:最多一个,用于3D绘制等

渲染进程

Renderer进程,是我们最关心的进程,内部是多线程的:默认每个Tab页面就是一个渲染进程,互不影响。可以这样理解,页面的渲染,JS的执行,事件的循环,都在这个进程内进行,也就是说这个渲染进程是多线程的。那么接下来看看它都包含了哪些线程(列举一些主要常驻线程):

  1. GUI渲染线程:负责渲染浏览器界面,解析HTML,CSS,构建DOM树和CSSOM树,最终得到渲染树,然后进行布局和绘制

  2. 事件触发线程

  3. 定时触发器线程:注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。

  4. 异步http请求线程

然而,一个渲染进程中的实际线程,真的是这些吗?非也!就拿渲染线程JS线程来说,它们并不是独立的线程,而是同一个线程(主线程)的不同名称或职责描述

主线程

参考文章:javascript - JS阻塞渲染,这么多年我理解错啦? - 个人文章 - SegmentFault 思否

在浏览器的渲染进程中:主线程(Main Thread) 是唯一负责执行JavaScript代码处理渲染任务的线程。

职责:

  • 执行JavaScript:解析并运行所有同步JS代码。
  • 处理渲染任务:解析HTML/CSS、构建DOM/CSSOM树、布局、绘制等。
  • 事件循环:管理宏任务、微任务队列,处理异步事件(如定时器、网络请求回调)。

特点:

  • 主线程是单线程,所以同一时间只能执行JS代码或渲染任务中的一项。这就是为什么我们常说,**”js线程”和”渲染线程”是互斥的**。

  • 如果JS代码长时间运行(如复杂循环),会完全阻塞渲染任务,导致页面无响应或渲染停滞。

  • 渲染线程JS线程 是对主线程不同职责的描述,而非独立线程。

关于”js线程”和”渲染线程”是互斥的更多原因,参考:浏览器中的JS引擎和渲染线程在主线程上互斥机制 - Felix_Openmind - 博客园

事件循环与渲染机制

既然浏览器的渲染线程js线程并不是单独存在的线程,所以就不存在所谓的渲染线程和js线程切换,本质是主线程内任务交替执行,

浏览器通过事件循环(Event Loop) 实现任务调度:

在每次事件循环的末尾 ,都会检查距离上次渲染,是否超过了一帧的时间(如果是60hz刷新率,就是16.6ms),如果没有,则继续执行下一个宏任务;

比如:

1
2
3
element.addEventListener('mousemove', (e) => {
banner.style.transform = `translateX(${e.clientX}px)`; // 同步修改
});//事件监听触发的回调属于宏任务
  • 当鼠标快速移动,在一帧内触发多次mousemove事件,然后在一帧内产生多个宏任务(其内部有同步修改样式的任务)

  • 当其中一个宏任务执行完毕,修改了样式后,由于没有微任务,到达一次事件循环的末尾,于是检查是否需要渲染

  • 由于这次宏任务所花的时间远小于一帧的时间(如果是60hz刷新率,就是16.6ms),且开始执行的较早,此时距离上次渲染,还没有一帧的时间,即便有样式修改操作,且处于一次事件循环的末尾,也并不会进行渲染,而是开始执行下一个宏任务(都是在修改banner样式),所以其实后面的宏任务的样式修改会覆盖前面的样式修改。

  • 如果主线程被宏任务阻塞超过 16.7ms,则跳过当前帧的渲染:假如宏任务执行时间是1.5个帧的时间(假设没有微任务),则会跳过第一帧,在第二帧开始渲染,也就是说宏任务在 25ms 结束,但下一帧边界是 33.4ms,浏览器会等待到 33.4ms 才渲染,而非立即渲染

再比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
<body>
<div id="box" style="width: 100px; height:100px; background: red;"></div>
</body>

<script defer>
// 触发渲染的修改
setTimeout(() => {
document.getElementById('box').style.width = '200px';
console.log('样式被修改')
const start = Date.now();
while (Date.now() - start < 3000) { } // 阻塞3s的同步任务
}, 1000)
</script>

如果不把script标签中的代码使用setTimeout包裹,首次打开页面,会有至少3s时间的白屏。因为即便使用了defer,也只能保证代码会在DOM树构建之后才执行,不能确保页面渲染之后才被执行,而且如果不使用setTimeout包裹script标签中的代码,在修改样式后,还会执行长达3s的同步任务,阻塞页面的首次渲染。

但在上述例子中,由于使用了setTimeout包裹,首次打开页面页面能立马被渲染出来,然后一秒后,执行回调,开启了新的宏任务。宏任务中同步修改了样式,同时开启了长达3s的同步任务,导致整个宏任务时间被延长,阻塞了页面的渲染,所以页面4秒才会发生样式变更。

dom树和渲染树

dom树上的结点包含不可视结点,而渲染树上不包含;渲染树上包含伪元素结点,但是dom树上不包括,因为伪元素不是html结构的一部分。简单来说,区别在于不可视结点伪元素

说说你对js引擎的理解,什么是V8引擎?

参考文章:解 Chrome 「V8 」引擎,让你更懂JavaScript - 知乎

js引擎

JavaScript 引擎可以将 JS 代码编译为不同 CPU(Intel, ARM 以及 MIPS 等)对应的汇编代码,这样我们就不需要去翻阅每个 CPU 的指令集手册来编写汇编代码了。当然,JavaScript 引擎的工作也不只是编译代码,它还要负责执行代码、分配内存以及垃圾回收

(总结:js引擎的工作就是编译,执行代码,分配内存,回收内存

热门的 JavaScript 的引擎有哪些?

  • V8 (Google):用 C++编写,开放源代码,由 Google 丹麦开发,是 Google Chrome 的一部分,也用于 Node.js。
  • JavaScriptCore (Apple),开放源代码,用于 webkit 型浏览器,如 Safari ,2008 年实现了编译器和字节码解释器,升级为了 SquirrelFish。

我们这里主要介绍V8引擎

V8引擎

  • V8 是由 Google 开发的开源JavaScript 引擎,是 JavaScript 虚拟机的一种,模拟实际计算机各种功能来实现代码的编译和执行

  • 我们可以简单地把 JavaScript 虚拟机理解成是一个翻译程序,将人类能够理解的 编程语言 JavaScript,翻译成机器能够理解的机器语言。目前主要用在 Chrome 浏览器和 Node.js 中。

V8 引擎的内部结构

Parse

负责将 JavaScript 源码(字符串),转换为 Abstract Syntax Tree (AST),即抽象语法树,抽象语法树本质上是一个嵌套的对象,用来表示源代码的语法结构。

Ignition

解释器,负责将 AST 转换为 Bytecode(字节码),然后解释执行 Bytecode

同时收集 TurboFan 优化编译所需的信息,比如函数参数的类型。

TurboFan

compiler,即编译器,利用 Ignition 所收集的类型信息,将 Bytecode 转换为优化的汇编代码

V8 是怎么执行一段 JavaScript 代码的?

大致流程

简单地说,Parser 将 JS 源码转换为 AST,然后 Ignition 将 AST 转换为 Bytecode,并解释执行,最后 TurboFan 将 Bytecode 转换为经过优化的 Machine Code

  • 如果函数没有被调用,则 V8 不会去编译它(不会编译成字节码)。
  • 如果函数只被调用 一 次,则 Ignition 将其编译为Bytecode 后,就直接解释执行了。TurboFan 不会进行优化编译,因为它需要 Ignition 收集函数执行时的类型信息。这就要求函数至少需要执行 1 次,TurboFan 才有可能进行优化编译。
  • 如果函数被调用多次,则它有可能会被识别为热点函数,且 Ignition 收集的类型信息证明可以进行优化编译的话,这时 TurboFan 则会将 Bytecode 编译为 Optimized Machine Code(已优化的机器码),以提高代码的执行性能
  • TurboFan 使用多种高级优化技术,如内联、死代码消除、寄存器分配等,来提高执行效率。
  • 图片中的红色虚线是逆向的,这个过程叫做Deoptimization(去优化)。因为Ignition 收集的信息可能是错误的,比如 add 函数的参数之前是整数,后来又变成了字符串,V8 可以“撤销”优化版本的代码,回退到原始的字节码执行状态
1
2
3
4
5
6
7
function add(x, y) {
return x + y;
}

add(3, 5);
//生成的 Optimized Machine Code 已经假定 add 函数的参数是整数,那当然是错误的,于是需要进行 Deoptimization。
add('3', '5');

从上述分析中可以看出,在v8中执行js代码时,即包含解释执行,又包含编译执行

总结

解释执行和编译执行都有各自的优缺点。解释执行启动速度快,但是执行时速度慢,而编译执行启动速度慢,但是执行速度快

为了充分地利用解释执行和编译执行的优点,规避其缺点,V8 采用了一种权衡策略,在启动过程中采用了解释执行的策略,但是如果某段代码的执行频率超过一个值,那么 V8 就会采用优化编译器TurboFan将其编译成执行效率更加高效的机器代码。

在 V8 出现之前,所有的 JavaScript 虚拟机所采用的都是解释执行的方式,因为每次执行时都需要实时解析和执行代码,导致效率低于预编译的代码。而 V8 率先引入了即时编译(JIT)的双轮驱动的设计,这是一种权衡策略,混合编译执行和解释执行这两种手段,给 JavaScript 的执行速度带来了极大的提升。

v8引擎在程序运行时,动态地将字节码(或中间表示)编译为机器码的过程,就是即时编译

对比其他编程语言

C语言

C语言的编译过程:源代码 → (编译)→汇编代码→ (汇编)→机器码:目标文件→ (链接)→完整的可执行程序(01代码)

C 语言编写的代码,需要将其编译为二进制代码的文件,然后再直接执行二进制代码。生成的二进制文件,是针对特定操作系统和硬件架构的。例如,在 Windows 上编译的程序不能直接在 Linux 上运行,所以使用编译执行的语言,编写的程序,可移植性较差

为什么C语言编译器,编译的程序不能在不同的操作系统上运行,js中也有编译器,比如TurboFan编译器,就没有这个问题?

因为C 编译器是静态编译的,在写完代码后,通过 gcc 或 clang 把 .c 文件一次性编译成可执行文件(.exe, .out, .elf 等)。

原因说明
编译时不知道目标平台开发者必须提前指定目标架构和操作系统(比如用 -m32-target arm-linux-gnueabi),否则默认就是本机平台。
输出的是原生机器码C 编译器输出的是 CPU 直接能执行的机器码,绑定死了 CPU 架构和操作系统。
无法在运行时改变策略可执行文件一旦生成,就不能再适配不同平台了。

那为什么不把c语言编译器分发到目标平台呢,就像谷歌v8引擎做的那样(谷歌v8引擎中包含了TurboFan编译器)

原因有如下几点:

编译器太大gcc/clang 编译器本身就有几十 MB 到几百 MB,不是一个轻量级组件。
依赖太多它们依赖操作系统提供的头文件、标准库、链接器、调试工具等。
安装配置复杂在不同系统上安装 C 编译器需要管理员权限、包管理器支持等。
性能开销大即使你成功运行了编译器,编译过程也会带来显著延迟。

Java

1
2
3
4
5
6
7
.java 源代码
│ javac 编译

.class 字节码(平台无关)
│ JVM 执行

程序运行
  • javac开发阶段使用的工具,用于将 .java 文件编译成 .class 字节码
  • .class 文件是二进制格式的字节码,不是机器码,也不是源代码,这些字节码是平台无关的,这样它们就可以在任何安装了 Java 虚拟机(JVM) 的设备上运行。JVM 只需要 .class 文件就可以运行程序,它不需要原始的 .java 文件和 javac
  • Java 程序可以在任何安装了相应版本 JVM 的平台上运行,其中Java 程序是指已经编译好的.class文件,而不是.java源文件。因此,其他平台只需安装 JVM(或 JRE),而无需安装 javac 编译器。
  • .class 文件在JVM中也是解释执行和即时编译执行

在 Google V8 引擎中,也有类似的“源代码 → 字节码”转换过程(由 Ignition 解释器完成),但这个过程发生在运行时,并且是在目标平台上已经安装好 V8 引擎的前提下进行的。因此,JavaScript 的字节码不需要像 Java 那样强调“平台无关性”,因为 V8 本身就已经适配了当前平台,并负责后续的执行与优化(如通过 TurboFan 编译为本地机器码)。

在script标签中添加async和defer有什么作用和区别

默认情况下,当HTML解析器遇到一个<script>标签(无论是内联脚本还是外部脚本),它会暂停HTML文档的解析,转而去加载执行这个脚本,也就是说默认情况下,scrpt标签的加载和执行都会阻塞html的解析,也就是dom树的构建,因为二者都在主线程上工作,主线程是单线程的,所以这两个工作不能同时执行。

我们再思考一下,js执行阻塞dom构建,是不是就是我们常说的”阻塞渲染”呢?严格来说,并不是,DOM树的构建是解析HTML的直接结果,属于浏览器处理HTML的输入阶段,它仅负责将HTML文本转换为结构化的对象模型,但此时还不涉及样式或可视化信息,渲染任务需要DOM和CSSOM都完成后才能开始。

案例

1
2
3
<script src="script1.js"></script>
<script src="script2.js"></script>
<p>Hello, world!</p>

在这个例子中,浏览器首先会尝试加载并执行 script1.js。只有当 script1.js 被成功加载并执行完毕后,才会开始加载 script2.js。同样地,直到 script2.js 也被加载和执行完毕,浏览器才会继续解析剩下的 HTML 并渲染 <p> 标签中的文本“Hello, world!”。

async

async 属性用于告诉浏览器脚本是异步,并行下载的,即这个加载过程不会阻塞html的解析。

脚本下载完成后立即执行,不保证脚本的执行顺序,如果html标签未解析完毕,可能阻塞html解析

适用于独立的脚本,如第三方分析脚本,这些脚本不需要等待其他脚本执行完毕。

defer

defer 属性用于告诉浏览器,脚本应该延迟到整个html解析完成后再执行,也是异步,并行下载,不阻塞html解析,所有脚本在html解析完成,即DOM树构建完毕后(DOM树中包含所有script标签),但DOMContentLoaded事件触发前(没错就是在这个事件前),按引入顺序执行。适用于依赖于 DOM 的脚本,如需要操作 DOM 的脚本,这些脚本需要确保 DOM 已经完全加载。举例:vue2项目打包后,也是通过defer的方式加载js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="favicon.ico">
<title>heima-shopping</title>
<script defer="defer" src="js/chunk-vendors.f3f3a489.js"></script>
<script defer="defer" src="js/app.04cc1747.js"></script>
</head>

<body>
<div id="app"></div>
</body>

上述例子中,如果不使用defer属性会出现什么问题?

如果不使用defer属性,浏览器首先会尝试加载并执行第一个脚本。只有当第一个脚本被成功加载并执行完毕后,才会开始加载第二个脚本。同样地,直到第二个脚本也被加载和执行完毕,浏览器才会继续解析剩下的 HTML 并渲染 <div id="app"></div>

Vue的挂载逻辑需要等待dom解析完,否则捕获不到根元素,document.getElementById('app')返回null,导致错误。

拓展

  • 图片等资源的加载和解析不会阻塞主线程
  • css文件的加载是异步的,不会阻塞主线程
  • css 文件的解析(构建 CSSOM)和 HTML 的解析(构建 DOM)二者在主线程上互斥执行。
  • 由于得到渲染树后才能进行渲染,而得到渲染树需要先构建好dom树和cssom树,所以页面会等待首屏关键css加载并解析完,才会开始渲染。

说说你对事件循环的理解

为什么js是一门单线程语言

  • 设计为多线程存在问题:如果被设计为多线程,当2个线程对同一个dom进行不同的操作,浏览器不知道该以谁为主。
  • 为了解决多线程存在的问题,会让代码变得复杂:js语言设计的初衷就是“轻量级”,如果被设计为多线程,就必须引入线程的同步与互斥机制,这就意味着会增大代码的复杂度,违背了js设计的初衷。
  • 硬件条件:js诞生的时候(1995年),多核cpu尚未普及,单线程更符合当时的硬件条件。

简单的来说,设计为多线程会让代码变得复杂,因为需要解决不同线程访问冲突的问题。

同步与异步任务

首先,JavaScript是一门单线程的语言,意味着同一时间内只能做一件事,这样就存在线程阻塞的问题,

而解决阻塞的方法就是将任务划分为同步任务异步任务,并通过事件循环机制协调它们的执行顺序。

  • 同步任务:立即执行的任务,同步任务一般会直接进入到主线程中执行
  • 异步任务:异步执行的任务,比如ajax网络请求,setTimeout定时函数等,交给宿主环境(就是除了主线程外的其他线程)去执行,时机成熟后放入任务队列

微任务与宏任务

异步任务又可以细分微任务宏任务,任务队列也被划分为微任务队列和宏任务队列。

什么是微任务,什么是宏任务?宏任务和微任务的区别在于执行的时机不同,微任务执行的优先级更高。

在执行下一个宏任务之前,会先查看微任务队列中是否有需要执行的微任务,如果有则先把微任务执行完,再开启新的宏任务。

属于微任务的事件包括但不限于:Promise.then(),MutationObserver,object.observe,process.nextTick

属于宏任务的事件包括但是不限于:setTimeout,setInterval,setImmediate,requestAnimationFrame

事件循环

事件循环机制,被用来缓解js单线程容易阻塞的问题。

宏任务是事件循环的基本单位,一个宏任务中可以同时包含同步任务宏任务微任务

事件循环指的是:js引擎先执行宏任务中包含的同步任务,再查找微任务队列中是否有微任务,如果有,则先清空微任务队列,再查找宏任务队列,开启新的宏任务,执行新的事件循环,如此循环往复的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log("script start");

setTimeout(function () {
console.log("setTimeout");
}, 0);//虽然是立即触发回调,但是并不是立即输出setTimeout,而是加入宏任务队列

Promise.resolve()
.then(function () {
console.log("promise1");//虽然第一个then方法中的回调函数是立即触发,但是不是立即执行,而是加入微任务队列
})
.then(function () {
console.log("promise2");//因为then方法会立即返回一个promise对象,所以第二个then方法中的回调函数也会加入微任务队列
});

console.log("script end");
  1. 宏任务:执行整体代码(相当于<script>中的代码,整体是一个宏任务):

    1. 输出: script start
    2. 遇到 setTimeout,立即开启延时器,时机成熟后,将传入的回调函数加入宏任务队列
    3. 遇到第一个then,同步调用它,将console.log("promise1")加入微任务队列
    4. 遇到第二个then,因为then方法会立即返回一个promise对象,所以第二个then方法也会同步调用,对应的回调函数,也会加入微任务队列。
    5. 输出:script end
  2. 微任务:清空微任务队列

    1. 输出:promise1(该微任务调用时机是”立即调用”)
    2. 输出:promise2(该微任务调用时机是等到第一个回调函数,也就是第一个微任务,调用后,也就是产生返回值后,也就是第一个then方法返回的promise实例状态确定后,再调用)
    3. 微任务队列清空
  3. 执行渲染操作,更新界面(敲黑板划重点)。

    举个例子:

    1
    document.querySelector('.container').innerHTML = '你好啊'

    当执行上述代码,修改了DOM元素的innerHTML后,DOM本身确实是被修改了,但是浏览器不会立刻进行重绘,将对DOM的修改更新到视图。相反,它会将这些更改记录下来,放入渲染队列,在下一个事件循环开始之前,检查是否有渲染任务,有则执行相应的渲染操作。

  4. 执行下一个宏任务:运行console.log("setTimeout")

参考文章:程序员 - 一次搞懂-JS事件循环之宏任务和微任务 - 个人文章 - SegmentFault 思否

async与await

async 是异步的意思,await 则可以理解为等待

放到一起可以理解async就是用来声明一个异步方法,而 await 是用来等待异步方法执行

async

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

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

//会自动包装成resolved类型的promise对象
async function asyncF() {
return 'TEST';
}

简单的来说async函数的返回值会被Promise.resolve包装。

await

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

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

不管await后面跟着的是什么,await都会阻塞后面的代码,后面的代码成为异步任务,如果阻塞的是是同步代码就成为微任务

1
2
3
4
5
6
7
8
9
10
async function fn1 (){
console.log(1)
await fn2() // fn2函数的执行不会被阻塞
console.log(2) // 被阻塞,成为微任务
}
async function fn2 (){
console.log('fn2')
}
fn1()
console.log(3)

上述输出结果为:1fn232

综合例题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
setTimeout(function () {
console.log(1);
}, 0);

async function s1() {
console.log(7);
await s2();
console.log(8);
}

async function s2() {
console.log(9);
}

s1();

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

最终输出 7 9 3 6 5 8 4 1

  • 先同步开启定时器(执行setTimeout),并立即将console.log(1)这个任务放入宏任务队列
  • 调用async函数,会立即执行其中的同步代码(我还以为调用async函数,会被视为一个宏任务呢,就像定时器回调函数一样),所以调用s1立即输出7,然后调用s2,同样的立即输出9
  • console.log(8)会等待s2函数返回值的状态改变后再执行,属于微任务,放入微任务队列
  • 然后创建promise实例,传入其中的回调函数会被立即执行,所以立即输出3和6
  • console.log(4)需要等待promise实例的状态改变,属于微任务,放入微任务队列
  • 同步执行console.log(5)输出5
  • 清空微任务队列,依次输出8和4
  • 然后再执行下一个宏任务,输出1

说说js资源加载事件

DOMContentLoaded

DOMContentLoaded事件是在HTML文档被完全加载和解析之后触发的,也就是说,当浏览器已经解析完整个HTML文档,DOM树构建完毕,这时候才会触发这个事件。不过,可能需要注意的是,虽然DOM树已经构建完成,但像图片和样式表,这些外部资源可能还没有加载完毕

1
2
3
document.addEventListener('DOMContentLoaded', function() {
console.log('HTML 文档已加载并解析完成');
});

load

则是在所有资源(包括图片、样式表等)都加载完毕后才触发,只等待资源加载,不等待资源解析,也不等待所有的异步请求完成,比如通过 JavaScript 发起的 AJAX 请求或 Fetch API 请求,甚至不等待动态加载的内容(如通过 JavaScript 动态插入的图片或其他资源),为什么要等待呢?,load事件怎么知道你请求什么时候响应,怎么知道你什么时候插入图片或者其他资源,如果你始终不这么操作,load难不成还一直等待你?

1
2
3
4
window.addEventListener('load', function() {
console.log('页面及所有资源已完全加载');
//此时可以确认所有资源,包括图片,样式表等,都已经加载完毕
});

需要值得注意的是,首屏渲染,只需要等待html标签解析完毕,构建好dom树,等待css文件加载并解析完,生成cssom树之后,就可以进行,换句话说,首屏渲染不等待图片资源,所以有时候我们能看到页面渲染出来了,但是图片没渲染出来的情况。

unload和beforeunload

当用户导航至其他页面,且新页面在本窗口打开、或者关闭当前标签页或窗口、或者刷新页面时,都会触发 unload 事件,但是我们常用的其实是beforeunload,即在页面卸载前做些什么,因为unload事件触发的时候页面已经被卸载了,我们做任何操作都没用了。

1
2
3
4
5
window.addEventListener('beforeunload', function (e) {
//阻止事件默认行为,一定要添加,光注册beforeunload事件监听没用
//这样就能阻止unload事件触发,页面就不会被卸载
e.preventDefault();
});

当用户刷新页面的时候,浏览器会提示是否刷新站点;当用户跳转到其他页面的时候,提示是否进行页面跳转。

说说js的数据类型

js的数据类型可以分为两类,基本数据类型引用数据类型

基本数据类型

基本数据类型主要有6种:Number,String,Boolean,Symbol,Null,Undefined,后来又添加了一种叫做BigInt所以说基本数据类型就有7种。

Number

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

1
2
3
4
5
6
let intNum = 55 // 10进制的55
let num1 = 070 // 8进制的56
let hexNum1 = 0xA //16进制的10
// 十六进制的 1A 转换为十进制是26
// A代表10,再乘以权重16^0,等于10,1乘以权重16^1等于16,相加得到26
let hexNumber = 0x1A;

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

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

格式化

关于浮点数有一个重要的知识点就是格式化

  • 使用 toFixed() 方法

    表示保留几位小数,要注意的是不是format方法,在js中不是使用这种方法格式化浮点数

    1
    2
    let num = 123.456;
    console.log(num.toFixed(2)); // 输出 "123.46" - 四舍五入到两位小数

    toFixed() 返回的是一个字符串,而不是数字。如果需要进行进一步的数值计算,你可能需要将其转换回数字类型

  • 使用 toPrecision() 方法

    这个方法表示保留几位有效数字,并且会根据需要,自动调整数字的表示形式(科学记数法或固定点表示法)

    1
    2
    3
    let num = 123.456;
    console.log(num.toPrecision(3)); // 输出 "123"
    console.log(num.toPrecision(5)); // 输出 "123.46"
  • Number.prototype.toLocaleString()

    toLocaleString() 可以用于获取特定地区的数字格式,包括货币、百分比和日期格式等。对于浮点数格式化,它可以用来设置小数位数和使用逗号作为千位分隔符等。

    1
    2
    let num = 1123.456;
    console.log(num.toLocaleString())//输出1,123.456

NaN

在数值类型中,存在一个特殊数值NaN,意为“不是数值”,用于表示数值运算操作失败了,而不是抛出错误

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

存储空间

在 JavaScript 中,变量的声明方式(varletconst不会影响其占用的内存大小。内存占用主要取决于变量存储的数据类型,而不是声明关键字本身。

  • Number

    8 字节(64 位),因为所有数字都以双精度浮点数形式存储。

    1
    2
    let num = 123; // 占用 8 字节
    const pi = 3.14; // 占用 8 字节
  • BigInt:内存占用随整数大小动态变化。

    1
    const bigIntValue = 1234567890123456789012345678901234567890n; // 内存占用随值增大而增加
  • String:内存占用与字符串长度成正比,每个字符通常占用2 字节

    1
    const str = "Hello"; // 长度为 5 的字符串,占用约 10 字节
  • Boolean通常占用4 字节或更少(具体实现因引擎而异)。

    1
    const flag = true; // 占用少量固定内存
  • undefinednull:通常占用4 字节或更少。

    1
    2
    let x; // undefined,占用少量固定内存
    const y = null; // 占用少量固定内存

存储一个ip地址,如何实现存储空间最小?在c语言中,一个字符char,占用一个字节,用字符串存储一个ip地址,最多占用3*4+3(3个分隔符)=15个字节,但是因为ip地址每位的范围是0-255,用一个字节就能存储,所以用一个数组存储ip数组的各个部分,最多大概只需要占用4字节。

String

字符串使用双引号(”)、单引号(’)或反引号(`)表示都可以,反引号表示的是模板字符串,模板字符串和普通字符串有什么区别呢?

在模板字符串中可以嵌入变量,这是模板字符串最常见的用法

1
2
let name = 'tom'
console.log(`my name is ${name}`)//输出'my name is tom'

在模板字符串中,会保留字符串中的所有空白字符,包括空格、制表符(\t)和换行符(\n)。

这种特性,使得模板字符串非常适合用于生成多行文本或格式化的字符串内容。这与普通字符串(使用单引号 ' 或双引号 ")不同,普通字符串不会自动保留换行和缩进,必须手动添加换行符(\n)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 普通字符串
let str1 = 'Hello\nWorld';
console.log(str1);
// 输出:
// Hello
// World

// 模板字符串
let str2 = `
Hello
World
`;
console.log(str2);
// 输出:
// Hello
// World
let str3 = 'Hello
World'; //报错,不能在普通字符串中直接使用换行,必须使用换行符

在js中,字符串是不可变的,意思是一旦创建,它们的值就不能变了。因为虽然字符串是基本数据类型,但实际存储在堆中,栈中保存的是引用。

1
2
3
4
5
6
7
let lang = "Java";//这行代码,会在内存中创建一个包含 "Java" 的字符串对象,并将引用赋值给变量 lang。

//从内存中读取 lang 当前所指向的字符串 "Java"。
//将 "Java" 和 "Script" 拼接成新的字符串 "JavaScript",并在内存中创建一个新的字符串对象。
//将 lang 变量存储的引用,更新为新创建的字符串 "JavaScript"的引用
lang = lang + "Script";
console.log(lang)

字符串比较

场景== 结果=== 结果原因
2个原始字符串:let a = ‘123’, b=’123’truetruea,b都是基本数据类型中的字符串,又因为引擎会将相同的字符串字面量(如 '123')存储为同一个堆内存地址,而非创建多个实例。这样可以节省内存并提高性能。因此,ab 实际上指向了同一个堆内存地址,所以a,b的值也是相同的,因此 a === b 返回 true
原始字符串 vs String 对象:let a = ‘123’, b=new String(‘123’)truefalse===比较的结果为false,因为a,b不是同一数据类型,a的类型是string,b的类型是object;==比较的结果是true,是因为b.valueof的值就是,字面量字符串123的引用。
两个 String 对象:let a = new String(‘123’), b=new String(‘123’)falsefalsea,b的数据类型虽然相同,值也相同,但是由于对象之间的比较,比较的是引用,无论是严格比较还是非严格比较,a,b是2个不同的对象,所以a,b存储的引用并不相同,严格比较和非严格比较的值都是false

Boolean

Boolean(布尔值)类型有两个字面值: truefalse

通过Boolean可以将其他类型的数据,显式转化成布尔值

数据类型转换为 true 的值转换为 false 的值
String非空字符串“”
Number非零数值(包括负数)0 、 NaN
Object任意对象null
UndefinedN/A (不存在)undefined

Symbol

Symbol关键字的主要用途,是用来创造一个唯一的标识符,用作对象属性,确保不会产生属性冲突

1
2
3
let genericSymbol = Symbol();
let otherGenericSymbol = Symbol();
console.log(genericSymbol == otherGenericSymbol); // false

传入符号主要为了标识,符号相同并不代表值也相同

1
2
3
let fooSymbol = Symbol('foo');
let otherFooSymbol = Symbol('foo');
console.log(fooSymbol == otherFooSymbol); // false

可枚举性

Symbol类型的键默认是可枚举的,通过对象字面量或常规赋值添加的属性,默认都是可枚举的

1
2
3
const sym = Symbol();
const obj = { [sym]: 'value' };
console.log(Object.getOwnPropertyDescriptor(obj, sym).enumerable); // true

通过Object.defineProperty定义的属性,其可枚举性才默认为false,无论是字符串键还是 Symbol 键,均可通过 Object.defineProperty() 显式设置 enumerable: true/false,顾名思义,这个方法就是用来定义,修改属性的,而且每次只能修改一个属性。

1
2
3
4
5
6
7
//第一个参数指明要修改哪个对象,第二个参数指明要修改这个对象的哪个属性,第三个参数指明如何修改这个属性
Object.defineProperty(obj, 'hidden', {
value: '秘密',
enumerable: true
writable: false //是否可以被修改
configurable: false //是否可以被删除
});

在某些方法中不被考虑

Symbol 类型的键,并且不会出现在for...in 循环中,也不会被Object.keys()方法返回,因为这两种方法只考虑字符串类型的键,并不是Symbo类型的属性就是不可枚举的。

1
2
3
4
5
6
7
8
9
10
11
12
// 创建一个 Symbol
const sym = Symbol('description');
// 使用 Symbol 作为对象的键
const obj = {
normalKey: 'value for normal key',
[sym]: '123'
};
// 遍历对象属性,Symbol 键不会出现
for (let key in obj) {
console.log(key); // 输出: normalKey
}
console.log(Object.keys(obj));//输出['normalKey']

Symbol 类型的键和值,都不会包含在序列化的结果中,因为无法转化成字符串,而且JSON规范明确要求了键必须是字符串。

1
2
3
4
5
6
7
8
9
// 创建一个 Symbol
const sym = Symbol('description');
// 使用 Symbol 作为对象的键
const obj = {
name: 'tom',
normalKey: sym
[sym]: '123'
};
console.log(JSON.stringify(obj))//{"name":"tom"}

Object.assign

Object.assign会把Symbol 类型的键也拷贝进,因为Symbol类型的键默认也是可枚举的

1
2
3
4
5
6
7
8
9
10
// 创建一个 Symbol
const sym = Symbol('description');
// 使用 Symbol 作为对象的键
const obj = {
normalKey: 'value for normal key',
[sym]: '123'
};
const obj2 = {}
Object.assign(obj2, obj)
console.log(obj2)//{normalKey: 'value for normal key', Symbol(description): '123'}

Null

Null类型同样只有一个值,即特殊值 null

null明明是基本数据类型,typeof null返回的结果却是"object",逻辑上讲, null 值表示一个空对象,这也是给typeof传一个 null 会返回 "object" 的原因,这也是js的历史遗留问题。

1
2
3
let car = null;
console.log(typeof car); // "object"
null instanceof Object;// 输出false,因为null是一个特殊的基本数据类型,不代表任何对象。

Undefined

Undefined 类型只有一个值,就是特殊值 undefined,如果一个变量声明了但是未被赋值,那么这个变量的值就是undefined。

1
2
3
let message; // 这个变量被声明了,只是值为 undefined
console.log(message); // "undefined"
console.log(age); // 没有声明过这个变量,报错

引用数据类型

引用数据类型有多种,引用数据类型统称为Object,所以一般不会问有几种 ,一般只问基本类型有几种。

引用数据类型主要包括以下三种:

Array

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

通常通过字面量表示法创建数组

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

或者通过Array来创建数组,给数组分配大小固定,连续的空间,内部默认没有元素;可以调用数组的fill方法填充数组,比如arr.fill(0)

1
2
3
4
const arr = new Array(4)
arr.forEach(i => { console.log(i) })//不会输出任何内容,因为数组中一个元素都没有,全都是空槽,空槽不参与遍历
arr.fill(0)
arr.forEach(i => { console.log(i) })//输出四个0

虽然说数组大小好像是固定的,比如这里初始化长度为4,但是还是可以往数组中加入元素,改变数组的大小,不过新加入的元素放在已分配空间之后

创建二维数组

1
2
3
const arr = new Array(4)//创建一个长度为4的数组,虽然创建的时候指定了长度,但是长度还是可以变化的
arr.fill(0) // 初始化/填充 数组
arr.map(i=>new Array(4).fill(0))//每次迭代都会创建一个新的数组并返回,确保二维数组中的每个数组存储空间互不干扰

上述代码可简写为:

1
const arr = new Array(4).fill(0).map( ele => new Array(4).fill(0))

可以看出在js中创建二维数组还是挺麻烦的。

Function

函数实际上是对象,每个函数都是 Function类型的实例,而 Function也有属性和方法,跟其他引用类型一样,其中最常见的属性比如prototype。但是函数和其他对象不同的是,对一个函数使用typeof返回的是function,对一个数组使用typeof返回的都是object

其他类型

除了上述说的2种之外,还包括DateRegExpMapSet等,他们都是Object类型的子类

区别

  • 基本类型变量的值,直接存储在栈内存中,引用类型变量的值,存储在堆内存中,但是栈内存中存储了它们的引用。

  • 在上图中,貌似取b的值拿到的是地址,其实拿到的还是对象数据,因为直接使用的时候(非赋值情况)会默认根据地址取值

  • 当基本数据类型的值,被作为参数,传递给函数或者变量时,实际上是将该值的一个副本传给了它们。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    //函数内部对参数所做的任何修改都不会影响到原始变量。
    function changeValue(x) {
    x = 10;
    }
    let a = 5;
    changeValue(a);
    console.log(a); //输出5

    let a = 10;
    let b = a; // 复制 a 的值给 b,b 是独立的新值
    b = 20;

    console.log(a); // 10(a 未受影响)
  • 当引用类型的值,被作为参数传递给函数或者变量时,实际上是将该值的一个引用传给了它们。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    let obj1 = { value: 10 };
    let obj2 = obj1; // 复制引用地址,obj2 与 obj1 指向同一个对象
    obj2.value = 20;

    console.log(obj1.value); // 20(原始对象被修改)
    console.log(obj2.value); // 20

    function modifyObject(obj) {
    obj.value = 100; // 修改共享对象的属性
    }

    let myObj = { value: 10 };
    modifyObject(myObj);
    console.log(myObj.value); // 100(原始对象被修改)

数组的常用方法

我们可以从增删查改,是否会修改原数组这几个角度,来给数组的常用方法归类

  • push():可以传入任意个数的元素,这些元素会被添加到数组的末尾,返回新数组的长度,会修改原数组。

    1
    2
    3
    let colors = []; // 创建一个数组
    let count = colors.push("red", "green"); // 推入两项
    console.log(count) // 2
  • unshift():也是可以传入任意个数的元素,这些元素会被添加到数组的首部,返回新数组的长度,会修改原数组。

    1
    2
    3
    4
    let colors = new Array(); // 创建一个数组
    let count = colors.unshift("red", "green"); // 从数组开头推入两项
    console.log(count); // 2
    console.log(colors)//['red', 'green'],说明不是先推入red,后推入green,而是视为一个整体,放到了数组的头部

    这个方法很容易和数组另一个方法shift混用,后者用来删除数组首部元素。

  • splice():第一个参数传入开始位置,第二个参数(表示删除元素的个数)传入0,表示不删除元素,后续参数传入插入的元素。

    1
    2
    3
    4
    let colors = ["red", "green", "blue"];
    let removed = colors.splice(1, 0, "yellow", "orange")
    console.log(colors) // red,yellow,orange,green,blue(插入的元素从开始下标开始排序)
    console.log(removed) // [],返回包含被删除元素的数组,因为没有元素被删除所以是空数组
  • concat():首先会创建一个当前数组的副本,然后再把它的参数添加到副本末尾,最后返回这个新构建的数组,不会影响原始数组

    1
    2
    3
    4
    let colors = ["red", "green", "blue"];
    let colors2 = colors.concat("yellow", ["black", "brown"]);
    console.log(colors); // ["red", "green","blue"],可以看到原数组并没有改变
    console.log(colors2); // ["red", "green", "blue", "yellow", "black", "brown"]

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

    1
    2
    3
    4
    let colors = ["red", "green"]
    let item = colors.pop(); // 取得最后一项
    console.log(item) // green
    console.log(colors.length) // 1
  • shift():法用于删除数组的第一项,同时减少数组的length 值,返回被删除的项

    1
    2
    3
    4
    let colors = ["red", "green"]
    let item = colors.shift(); // 取得第一项
    console.log(item) // red
    console.log(colors.length) // 1
  • splice():第一个参数传入开始位置,第二个参数传入要删除元素的个数,返回包含被删除元素的数组,如果,没有任何元素被删除,则返回空数组。

    1
    2
    3
    4
    let colors = ["red", "green", "blue"];
    let removed = colors.splice(0,1); // 删除第一项
    console.log(colors); // green,blue
    console.log(removed); // ["red"],只有一个元素的数组
  • slice():本质是返回一个数组切片,并不会修改原数组,截取区间遵循左闭右开原则。

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

一般通过下标修改数组元素的值,也可以使用splice先删除元素再添加元素。

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

一般也是通过下标来查找数组元素。

  • indexOf():传入一个元素,返回数组中第一个与该元素相等的元素的下标,使用的是严格比较,如果数组中没有该元素,则返回-1,因为NaN不与任何数相等,所以indexOf(NaN)返回值必定为-1。其实这个方法特别语义化,indexOf(元素)意思不就是某个元素的下标吗。

    1
    2
    3
    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1,NaN];
    numbers.indexOf(4) // 3
    console.log(numbers.indexOf(NaN)) // -1
  • includes():判断某个元素是否在数组中存在,也是严格比较,存在返回true,否则返回false。对NaN做了特殊处理,能判断是它否存在于数组中,就这一点而言,是比indexOf要强大的。

    1
    2
    3
    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1,NaN];
    numbers.includes(4) //true
    numbers.includes(NaN) //返回true
  • find():传入一个返回值是 布尔类型的回调函数,用于判断满足某个条件的元素是否存在,存在则返回第一个符合条件的元素,不存在则返回undefined,通常用于判断对象数组中是否存在某个对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const people = [
    {
    name: "Matt",
    age: 27
    },
    {
    name: "Nicholas",
    age: 29
    }
    ];
    people.find((element, index, array) => element.age < 28)// {name: "Matt", age: 27}
  • findIndex():语法和用途和find相同,不过返回的是元素的下标,未找到返回-1

排序方法

  • reverse():反转数组,会修改原数组

    1
    2
    3
    let values = [1, 2, 3, 4, 5];
    values.reverse();
    alert(values); // 5,4,3,2,1
  • sort():给数组排序,sort()方法接受一个比较函数,用于判断哪个值应该排在前面,用的是非常多,特别在算法题里

    1
    2
    3
    4
    5
    6
    7
    8
    function compare(value1, value2) {
    //return value1-value2 升序排序
    //return value2-value1 降序排序
    //value1[key]-value2[key] 根据某个属性升序排序,反之降序排序
    }
    let values = [0, 1, 5, 10, 15];
    values.sort(compare);
    alert(values);

转换方法

join():把数组中的元素拼接成一个字符串,用传入的符号连接,如果传入的符号是'',那么就是一个类似将字符数组转化成字符串的过程。显然这个方法也不会修改原数组。

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

迭代方法

  • some():传入一个返回值为布尔值的回调函数,作为判断条件,如果数组中存在满足条件的元素,则该方法返回true,否则返回false。要注意千万不要把这个方法写成any,数组并没有any方法,这是Promise的静态方法。

  • every():传入一个返回值为布尔值的回调函数,作为判断条件,如果数组中每个元素都满足条件,则该方法返回true,否则返回false。注意千万不要把这个方法写成all,数组中并没有all方法,这是Promise的静态方法。

  • forEach():遍历数组中的每个元素,并执行一定操作,可以修改原数组。

    1
    2
    3
    4
    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];
    numbers.forEach((item, index, array) => {
    // 执行某些操作
    });
  • filter():传入一个返回值为布尔值的回调函数,作为判断条件,返回一个数组,这个数组包含所有满足这个判断条件的元素。无论原数组是否包含满足条件的元素,filter 总是会返回一个新的数组。如果没有找到任何满足条件的元素,则返回的是一个空数组 []

    1
    2
    3
    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];
    let filterResult = numbers.filter((item, index, array) => item > 2);
    console.log(filterResult); // 3,4,5,4,3
  • map():根据传入的回调函数和数组中的每一个元素,并返回一个新的数组。要注意的是,传入的回调函数虽然也是需要有返回值的,就如同filter,some,every,但是不同的是,传入map方法的回调函数的返回值并不是一个布尔值,而是通过每个数组元素计算得到的新的值。

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

字符串常用方法

操作方法

concat

用于将一个或多个字符串拼接成一个新字符串,返回一个新的字符串,不会修改原来的字符串,js中的字符串是不可变的

在数组中也有这个方法哦,效果也非常相似,其实数组和字符串有很多同名的方法。

1
2
3
4
let stringValue = "hello ";
let result = stringValue.concat("world");//创建一个新的字符串
console.log(result); // "hello world"
console.log(stringValue); // "hello"

slice() substr() substring()

作用是返回字符串的切片

1
2
3
4
5
6
7
8
let stringValue = "hello world";
console.log(stringValue.slice(3)); // "lo world"
console.log(stringValue.substring(3)); // "lo world"
console.log(stringValue.slice(3, 7)); // "lo w"
console.log(stringValue.substring(3,7)); // "lo w"

console.log(stringValue.substr(3)); // "lo world"
console.log(stringValue.substr(3, 7)); // "lo worl"
  • 数组中也有slice()方法

  • 可以看出slice()substring()的用法是一致的,当传入两个参数的时候,分别表示的是截取的左右区间(左闭右开,目前就没见到过左闭右闭的情况,除了正则表达式中)

  • substr()传入两个参数时,第一个表示参数起始位置,第二个参数表示的是要截取的元素的个数

  • 当只传入一个参数,三者的效果是相同的。

indexOf() startWith() includes()

indexOf:从字符串开头去搜索传入的字符串,并返回位置(如果没找到,则返回 -1 ),数组中也有这个方法,也许因为字符串本来就可以看成字符数组。

1
2
let stringValue = "hello world";
console.log(stringValue.indexOf("o")); // 4

startWith():判断字符串是否以某个字符串开头,返回值为布尔类型。

includes():判断字符串中是否包含某个字符串,返回值是布尔类型,数组中也有这个方法。

1
2
3
4
5
let message = "foobarbaz";
console.log(message.startsWith("foo")); // true
console.log(message.startsWith("bar")); // false
console.log(message.includes("foo")); // true
console.log(message.includes("bar")); // true

由此可见,无论是数组还是字符串中,都有indexOf,includes,slice,concat方法

字符串拆分

把字符串按照指定的分割符,拆分成字符数组,特别是当传入'',即空字符的时候,是真正意义上的把字符串拆分成字符数组,不会包含空字符。

1
2
3
let str = "12+23+34"
let arr = str.split("+") // [12,23,34]
let arr2 = str.split("") //['1', '2', '+', '2', '3', '+', '3', '4']

模板匹配

提及字符串,就不得不提到模板匹配,提起模板匹配就不得不提起正则表达式,会在后面介绍。

match()

接收一个参数,可以是一个正则表达式字符串,也可以是一个RegExp对象(正则表达式对象),如果你传递一个非正则表达式对象,它会被隐式转换为正则表达式;返回值是数组。

非全局匹配(传入的正则表达未加修饰符g):只会匹配第一个符合条件的字符串片段,下面给出一个例子

1
2
3
4
let text = "cat, bat, sat, fat";
let pattern = /.at/;
let matches = text.match(pattern);
console.log(matches);

匹配成功的返回结果,是一个数组,但是这个数组并不是传统意义上的数组,因为它的键不全是数字,包含第一个匹配的字符串片段更多信息。不得不说,在js中,有的数组是真像对象,但它就是数组,有的对象也是真的像数组(伪数组),但就是对象。因为在js中,数组本质就是一个对象。

  • index 属性:匹配结果在字符串中的开始位置
  • input 属性:原始字符串

如果匹配失败则返回null

全局匹配

返回所有符合条件的字符串片段,并以数组的形式给出,例子如下:

1
2
3
4
let text = "cat, bat, sat, fat";
let pattern = /.at/;
let matches = text.match(pattern);
console.log(matches);

匹配成功的返回结果,只包含符合条件的字符串片段

如果匹配失败则返回null

1
str.search(regexp)
  • str 是要进行搜索操作的字符串。
  • regexp 是一个正则表达式对象。如果你传递一个非正则表达式对象(例如,一个字符串),它将被隐式转换为一个正则表达式对象。
  • 如果找到匹配项,search() 方法返回第一个匹配项首字符的下标。
  • 如果没有找到匹配项,search() 方法返回 -1
  • 是否给传入的正则表达式添加修饰符g,对结果没有影响。
  • 简单的来说search返回的就是第一个被匹配的字符串片段的下标

下面举个例子:

1
2
3
4
5
6
let text = "cas, bat, sat, fat";
let pattern = /.at/;
let pattern2 = /.at/g;
let index = text.search(pattern);
let index2 = text.search(pattern);
console.log(index,index2); //输出5,5

replace()

replace() 方法用于在字符串中查找匹配的子字符串,并用新的子字符串替换它,这个方法不会改变原始字符串,因为JavaScript中的字符串是不可变的,它会返回一个新的字符串作为结果。

1
str.replace(regexp|substr, newSubstr|function)
  • regexp (正则表达式):一个RegExp对象或者其字面量,标识要查找的子字符串。全局搜索需要使用g标志。
  • substr (字符串):将被替换的子字符串。
  • newSubstr (字符串):新子字符串,用于替换匹配项的字符串。
  • function (函数):用于创建新子字符串的函数,所以要有返回值,该函数将被每一个匹配项调用。
1
2
3
4
let str = "Hello world!";
let newStr = str.replace("world", "JavaScript");

console.log(newStr); // 输出: "Hello JavaScript!"
1
2
3
4
let str = "Hello world! Welcome to the world of programming.";
let newStr = str.replace(/world/g, "JavaScript");

console.log(newStr); // 输出: "Hello JavaScript! Welcome to the JavaScript of programming."
1
2
3
4
let str = "Hello World! Welcome to the world of Programming.";
let newStr = str.replace(/world/gi, "JavaScript");

console.log(newStr); // 输出: "Hello JavaScript! Welcome to the JavaScript of Programming."
1
2
3
4
5
let str = "20 apples, 15 bananas, and 3 cherries";
let newStr = str.replace(/\d+/g, function(number) {// \d+ 是匹配一个或多个数字的正则表达式
return number * 2; // number是被匹配的匹配项,在此处分别是20,15,3
});
console.log(newStr); // 输出: "40 apples, 30 bananas, and 6 cherries"

区别

match方法返回的是一个数组(无论是否是全局匹配),search方法返回的是下标,replace方法返回的是修改后的字符串。

说说js中的日期对象Date

JavaScript中的Date对象用于处理日期和时间。它提供了一系列方法来获取和设置日期的各个方面,如年、月、日、小时、分钟、秒和毫秒等

创建日期对象

我们都知道,通过new Date()就可以创建一个代表当前日期时间的对象,但是你有没有想过,可以给Date构造函数传入不同的参数呢?

  • 不带参数:创建一个代表当前日期和时间的对象。

    1
    2
    const now = new Date();
    console.log(now); // 输出类似 "2025-03-07T03:48:32.123Z" 的字符串(具体时间取决于执行时刻)
  • 日期字符串参数:根据提供的日期字符串,创建对应的日期对象,但通常情况下,要我们手动传入一个格式规范的日期字符串,是比较难的吧。

    1
    2
    const dateStr = new Date('2025-03-07T00:00:00');
    console.log(dateStr); // 输出 "2025-03-07T00:00:00.000Z"
  • 带时间戳参数:根据传入的时间戳,返回对应的时间日期对象

    1
    2
    3
    4
    const timestamp = new Date(1709756400000);
    console.log(timestamp); // 输出 "2025-03-07T00:00:00.000Z"
    //我们只要再调用toLocaleString方法,时间格式就变得熟悉了
    console.log(timestamp.toLocaleString()) // 2024/3/7 04:20:00
  • 通过多个数值参数创建:指定年、月(从0开始计数)、日、时、分、秒和毫秒,感觉是比传入一个日期字符串好用?

    1
    2
    const customDate = new Date(2025, 2, 7, 0, 0, 0, 0); // 注意月份是从0开始计数的,所以2表示3月
    console.log(customDate); // 输出 "2025-03-07T00:00:00.000Z"

获取日期信息

Date对象提供了多种方法来获取日期的不同部分:

  • getFullYear():获取四位数的年份。
  • getMonth():获取月份(0-11)。
  • getDate():获取一个月中的某一天(1-31)。
  • getDay():获取星期几(0-6,0表示星期天)。
  • getHours():获取小时(0-23)。
  • getMinutes():获取分钟(0-59)。
  • getSeconds():获取秒(0-59)。
  • getMilliseconds():获取毫秒(0-999)。
  • getTime():获取自1970年1月1日以来的毫秒数,也就是时间戳,获取时间戳的方法还有Date.now()

其他常用方法

  • toDateString():返回日期部分的字符串表示形式,不常用
  • toTimeString():返回时间部分的字符串表示形式,不常用
  • toISOString():返回ISO格式的日期字符串(UTC时间),不常用
  • toLocaleString():基于本地时间格式化日期和时间,常用,要注意的是不要把locale(现场)写成local(本地)
  • toLocaleDateString():仅格式化日期部分为本地格式。
  • toLocaleTimeString():仅格式化时间部分为本地格式。
1
2
3
4
5
6
7
const date = new Date()
console.log(date.toDateString())//Fri Mar 07 2025
console.log(date.toTimeString())//2:38:26 GMT+0800 (中国标准时间)
console.log(date.toISOString())//2025-03-07T04:38:26.053Z
console.log(date.toLocaleString())//2025/3/7 12:41:03
console.log(date.toLocalDateString())//2025/3/7
console.log(date.toLocaleTimeString())//12:41:03

说说js中的正则表达式

创建正则表达式

字面量语法

1
const regex = /pattern/;

例如:/ab+c/i 匹配 “abc”, “ABBC” 等。

构造函数语法

1
const regex = new RegExp('pattern', '修饰符');

例如:new RegExp('ab+c', 'i'),等价于/ab+c/i,感觉还是字面量语法方便啊

正则表达式语法

元字符

  • \d:数字(0-9),因为digit的意思就是数字的意思;
  • \D:非数字
  • \w:单词字符(字母、数字、下划线,在js中的标识符,就是由这三者构成的),word就是单词的意思;
  • \W:非单词字符
  • \s:空白符(空格、制表符、换行),space就是空格的意思,然而\s匹配的是所有类型的空白字符,而不仅仅是空格。
  • \S:非空白符
  • .:匹配除换行外任意字符(若需包含换行,使用修饰符 s
  • ^:字符串开头;$:字符串结尾

字符组

  • [abc]:匹配 a、b、c 中的任意一个
  • [a-z]:匹配 a 到 z 的任意小写字母
  • [^abc]:匹配任何一个不在 abc 范围内的字符

要注意的是,无论字符组中有多少个符号,匹配的都只是一个字符

分组/捕获组

小括号可以将多个字符或子表达式组合在一起,形成一个逻辑单元

1
2
3
const pattern = /(ab)+/;
const str = "ababab";
console.log(str.match(pattern)); // ["ababab", "ab"]
  • (ab)ab 视为一个整体,而不是一个单独的字符。
  • + 表示匹配这个整体一次或多次。
  • 匹配结果是整个字符串 "ababab",而第一个捕获组的结果是 "ab"

小括号会创建一个捕获组,用于提取匹配的部分内容。每个捕获组的内容可以通过 match() 方法的返回值中的数组访问。

1
2
3
4
5
6
7
8
const pattern = /(\d{4})-(\d{2})-(\d{2})/;
const str = "2023-10-05";
const match = str.match(pattern);

console.log(match[0]); // "2023-10-05"(完整匹配)
console.log(match[1]); // "2023"(第一个捕获组)
console.log(match[2]); // "10"(第二个捕获组)
console.log(match[3]); // "05"(第三个捕获组)、

其实上述正则表达式不使用捕获组,也能匹配到str,但如果不使用捕获组,结果数组中也就不会有捕获组。

量词

  • *:0 次或多次
  • +:1 次或多次
  • ?:0 次或 1 次
  • {n}:精确匹配 n 次
  • {n,}>=n
  • {n,m}:n 到 m 次,左闭右闭

总结:在js中的正则表达式中,中括号表示字符组,只匹配一个字符;大括号表示量词,表示匹配多少次,而小括号则表示一个分组或者匹配组。

修饰符

  • i:不区分大小写
  • g:全局匹配(查找所有匹配项)
  • m:多行模式(^$ 匹配每行的开头和结尾)
  • s:dotAll 模式(. 匹配换行符)
  • u:Unicode 模式
  • y:粘性匹配(从 lastIndex 开始匹配)

常用方法

test()

1
/hello/.test('hello world'); // true

回布尔值,判断是否匹配成功

exec()

1
/(\d+).(\d+)/.exec('3.14'); // ['3.14', '3', '14', index: 0, ...]
  • 不使用全局标志的时候

    调用一个正则表达式的exec方法,并传入一个字符串,效果完全等同于:调用一个字符串的match方法并传入一个正则表达式

  • 使用全局标志

    每次调用 exec() 都会从上一次匹配结束的位置,开始寻找下一个匹配项,这一点字符串的match方法就不同了,真难记啊。

Object的常见静态方法

Object.keys()

Object.keys() 方法,用于返回一个对象自身可枚举属性(且不包括Symbol类型的属性)组成的数组。

1
2
const obj = { a: 1, b: 2, c: 3, [Symbol()]: 4 };
console.log(Object.keys(obj));// 输出: ['a', 'b', 'c']
1
2
3
4
5
6
const obj = {};
Object.defineProperty(obj, 'invisible', {
value: 'hidden',
enumerable: false // 设置为不可枚举
});
console.log(Object.keys(obj)); // 输出: []

我们还经常使用for in来获取一个对象所有的可枚举属性,但是它与Object.keys() 方法不同的是,还能获取原型链上的可枚举属性,所以在某些情况还需要借助hasOwnProperty来判断是不是自身的属性。

Object.values()

Object.values() 方法,用于返回一个对象自身可枚举属性(且不包括Symbol类型的属性)的值组成的数组。它只返回对象自身的属性(不包括原型链上的属性),并且这些属性必须是可枚举的。

1
2
const obj = { a: 1, b: 2, c: 3, [Symbol()]: 4 };
console.log(Object.values(obj));// 输出: [1, 2, 3]

Object.entries()

Object.entries() 方法,用于返回一个对象自身可枚举属性(且不包括Symbol类型的属性)键值对数组。每个键值对是一个包含两个元素的数组:第一个元素是属性名(键),第二个元素是对应的属性值。

1
2
const obj = { a: 1, b: 2, c: 3, [Symbol()]: 4 };
console.log(Object.entries(obj))// 输出: [['a', 1], ['b', 2], ['c', 3]]

Object.assign()

传入2个对象作为参数,会将第二个对象中可枚举的自有属性(不包括其继承自原型链上的属性),拷贝到第一个对象。返回值就是传入的第一个对象,传入的第一个对象会被修改

1
2
3
4
5
6
7
8
9
10
11
12
var obj = {
age: 18,
nature: ['smart', 'good'],
names: {
name1: 'tom',
},
[Symbol()]:'cindy'
}
let obj2 = {}
var newObj = Object.assign(obj2, obj);
console.log(newObj)//{age: 18, nature: Array(2), names: {…}, Symbol(): 'cindy'}
console.log(obj2 == newObj) //返回true,说明返回的就是原对象(传入的第一个对象)

Object.defineProperty

Object.defineProperty 是 JavaScript 中用于定义或修改对象属性的底层方法

1
Object.defineProperty(obj, prop, descriptor);
  • obj: 要定义属性的目标对象。
  • prop: 要定义或修改的属性名称(字符串或 Symbol)。
  • descriptor: 属性描述符对象,用于定义属性的行为。descriptor 是一个对象,可以包含以下键值对:

数据描述符

属性名描述
value属性的值,默认为 undefined
writable是否可以修改属性的值,默认为 false(即不可写)。
enumerable是否可以通过 for...inObject.keys() 枚举该属性,默认为 false
configurable是否可以删除该属性或修改其描述符,默认为 false

存取器描述符

属性名描述
get定义获取属性值时调用的函数,默认为 undefined
set定义设置属性值时调用的函数,默认为 undefined
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const obj = {};
let _age = 25; // 私有变量

Object.defineProperty(obj, 'age', {
get() {
return _age;
},
set(newValue) {
if (newValue < 0) {
console.error('Age cannot be negative');
} else {
_age = newValue;
}
},
enumerable: true,
configurable: true
});

console.log(obj.age); // 输出 25
obj.age = 30; // 设置新值
console.log(obj.age); // 输出 30
obj.age = -5; // 触发错误提示,但不会修改值
console.log(obj.age); // 输出 30

typeof和instanceof

typeof

typeof 操作符返回一个字符串,表示值的数据类型。

1
2
typeof operand //这种方式用的多
typeof(operand)

这两种使用方法都是可以的。下面是一些例子。

1
2
3
4
5
6
7
8
9
10
typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined',返回对应的基本类型
typeof true // 'boolean',返回对应的基本类型
typeof Symbol() // 'symbol',返回对应的基本类型
typeof null // 'object',这个比较特别
typeof [] // 'object',返回的不是array
typeof {} // 'object'
typeof console // 'object'
typeof console.log // 'function'

值得注意的是,对所有引用数据类型(除了function,包括数组,普通对象),使用typeof返回的都是object

instanceof

主要用来判断某个构造函数,是否在某个实例对象的原型链上。

1
object instanceof constructor

区别

  • typeof返回的是字符串,instanceof返回的是布尔值

  • typeof只能能准确判断基本数据的类型,不能准确判断引用数据的类型。

  • intanceof只能准确判断引用数据的类型,不能判断基本数据的类型

可以看到,上述两种方法都有弊端,并不能满足所有场景的需求。

谈谈 JavaScript 中的类型转换机制

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

常见的类型转换有:强制转换(显示转换),自动转换(隐式转换)

显式转换

显式转换,即我们很清楚可以看到这里发生了类型的转变,常见的方法有:Number(),parseInt(),String(),Boolean()

Number()

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Number(324) // 324
// 字符串:如果可以被解析为数值,则转换为相应的数值
Number('324') // 324
// 字符串:如果不可以被解析为数值,返回 NaN
Number('324abc') // NaN
// 空字符串转为0,空数组也转换成0
Number('') // 0
// 布尔值:true 转成 1,false 转成 0
Number(true) // 1
Number(false) // 0
// undefined:转成 NaN
Number(undefined) // NaN
// null:转成0,这就是undefined和null的区别
Number(null) // 0
//对象:通常转换成NaN,除了只包含单个数值的数组和空数组
Number({}) // NaN
Number({a: 1}) // NaN
Number([1, 2, 3]) // NaN
Number([5]) // 5
Number([]) // 0
  • 从上面可以看到,Number转换的时候是很严格的,只要有一个字符无法转成数值,整个字符串就会被转为NaN
  • null转化成数字类型是0,而undefined转化成数字类型是NaN,这是二者最大的区别之一
  • 总结一下,哪些数据转化成数字类型后的值是0:
    • 空字符串
    • false
    • null
    • 空数组

parseInt()

parseInt相比Number,就没那么严格了,parseInt函数逐个解析字符,遇到不能转换的字符就停下来。这个方法是可以直接被调用的,不许要显式借助其他对象

1
parseInt('32a3') //32

要注意的是,如果传入parseInt的值不是以数字开头的字符串,那么parseInt的返回值将是NaN ,这一点其实是和Number()相同的。

1
2
console.log(parseInt(true))//输出NaN,因为传入的不是字符串
console.log(parseInt('a123'))//输出NaN

parseInt方法类似的还有parseFloat方法,后者和前者不同的是,是从字符串中提取出浮点数。

String()

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

1
2
3
4
5
6
7
8
9
10
11
12
13
// 数值:转为相应的字符串
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" 调用数组的toString方法就是去除左右括号

可以看到,对于基本数据类型,强制转化成字符串,就是加个双引号就好了,而引用数据类型就不一样了,需要调用toString方法

Boolean()

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

1
2
3
4
5
6
7
8
Boolean(undefined) // false
Boolean(null) // false
Boolean(0) // false
Boolean(NaN) // false
Boolean('') // false
Boolean({}) // true
Boolean([]) // true
Boolean(new Boolean(false)) // 因为返回的是一个对象Boolean {false},所以转化成布尔值是true

隐式转换

隐式转换本质就是偷偷帮我们调用了显式转换的函数,在隐式转换中,我们可能最大的疑惑是 :何时发生隐式转换

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

  • 比较运算(==!=><
  • 算术运算(+-*/%
  • ifwhile需要布尔值地方

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

  • 自动转化成布尔值

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

  • 自动转换成字符串

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    '5' + 1 // '51'
    '5' + true // "5true"
    '5' + false // "5false"
    '5' + {} // "5[object Object]" ,因为{}转化成字符串是[object Object]
    '5' + [] // "5" 因为[]转换成字符串是空串
    '5' + function (){} // "5function (){}",因为函数调用toString方法返回值是function (){}
    '5' + undefined // "5undefined"
    '5' + null // "5null"

    对于基本数据类型和函数,字符串拼接的时候直接参与拼接,对于其他引用数据类型,需要先调用toString方法。哈哈,原来字符串凭借不是所有情况都是直接拼接啊。

  • 自动转换成数值

    除了左右两边包含字符串的+号,其他运算符都会把参与运算的数据自动转成数值

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

==和===的区别

一个是宽松比较,一个是严格比较==比较是否相等,不比较类型是否相同,允许隐式转换。===比较的是类型是否相同,都相同才会返回true。

难点在于==非严格比较,比较规则如下

  • undefined == null 返回true

    在非严格比较中,undefinednull,只与undefined或者null相等。

    这就意味着,undefined==0的值是false,null==0的值也是false,undefined==false的值也是false,null==false的值也是false。这其实就很奇怪,为什么nullundefined在作为if()的判断条件的时候,可以隐式转化成false,但是当和false进行非严格比较,返回值就是false。

  • NaN == NaN 返回false

    NaN和任何数比较,包括本身,都返回false。

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

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

  • 两个都为引用类型,则比较它们是否指向同一个对象,也就是比较地址是否相同。

Javascript 数字精度丢失的问题

为什么会出现精度丢失

对于某些小数,计算机无法用有限的二进制位精确的表示,比如0.1用二进制表示思路如下:

1
2
3
4
0.1*2=0.2<1  --0
0.2*2=0*4<1 --0
0.4*2=0.8<1 --0
0.8*2=1.6>1 --1

假设0.1的二进制表示是0.xxxx,每次对0.1×2,都会让二进制表示中的小数点右移一位(就像我们给十进制小数×10会让小数点右移一样,每个数的权值都变大了),即x.xxxx,如果0.1×2<1,说明第一个x为0,依此类推,0.1的二进制表示为0.0001xxxx,然后我们继续计算后续的x的值。0.1×16 = 1.6,对应的二进制表示为1.xxxx,显然0.xxxx应该表示的是0.6,所以我们就把问题转化为求0.6的二进制表示了。

**简单的来说,如果要求我们求某个小数对应的二进制表示,我们只需要对小数不断的×2**,如果结果小于1,填入0,反之填入1,然后对乘法的结果-1,然后继续计算,填入的位置是从小数点的高位到低位。

1
2
3
4
5
0.6*2=1.2>1  --1
0.2*2=0.4<1 --0
0.4*2=0.8<1 --0
0.8*2=1.6>1 --1
0.6*2=1.2>1 --1

因此我们可以得出0.1的二进制表示是0.000110011....很明显这是一个无限循环小数,我们无法用有限的二进制位来精确的存储这个小数,因为存储的时候,数据就没有被准确的存储,所以下次再取出使用的时候就会有精度损失

后面的扩展内容涉及的主要是计组知识

JavaScript中,现在主流的数值类型是Number,而Number采用的是IEEE754规范中64位双精度浮点数编码

如何理解这个字呢,这个表示使用2个机器字(word)来表示浮点数,通常现代计算机的一个机器字是 32 位,双精度意思就是用64位来表示浮点数。这样的存储结构优点是可以统一处理整数和小数,节省存储空间。

我们先来看看如何将一个浮点数用IEEE754单精度浮点数编码表示,单精度浮点数编码用32位来表示浮点数,第一位是符号位,0表示正数,1表示负数;后8位表示指数位,后23位表示尾数。

0.75这个浮点数举例子,将它转换成二进制是0.11,然后将二进制转换成1.xxx*2^n的形式,就是1.1*2^-1,由于这个数是正数,所以第一位是0,指数位-1,我们将它与127(二进制表示是8个111111111)相加得到126(11111110),我们把这个操作叫做偏移,然后尾数是1,这样我们就可以得到单精度浮点数编码表示:0111111101,然后要凑齐32位,后面补零就好。

对于双精度浮点数编码,道理其实也是一样的,不同的是,使用1位表示符号位,11位表示指数位,52位表示尾数

具体如何处理可以自行搜索或者参考:面试官:说说 Javascript 数字精度丢失的问题,如何解决? | web前端面试 - 面试官系列

如何解决精度缺失问题

先把小数转换成整数再参与运算。

1
2
3
4
5
let a = 0.1
let b = 0.2
let c = a + b
let d = (a * 10 + b * 10) / 10
console.log(c == 0.3, d == 0.3)//输出false true

借助第三方工具库,比如Math.jsBigDecimal.js,通过调用相关方法来模拟加减乘除运算。

1
2
3
4
5
const math = require('mathjs');
const a = math.bignumber('0.1');
const b = math.bignumber('0.2');
const result = math.add(a, b);
console.log(math.format(result, {notation: 'fixed'})); // 输出 "0.3"
1
2
3
4
5
6
7
<script src="https://cdnjs.cloudflare.com/ajax/libs/bigdecimal.js/0.6.2/bigdecimal.min.js"></script>
<script>
var a = new bigDecimal('0.1');
var b = new bigDecimal('0.2');
var result = a.add(b);
console.log(result.toString()); // 输出 "0.3"
</script>

说说 JavaScript 中内存泄漏的几种情况

内存泄漏是什么

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

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

可以拿电脑游戏来举例,3a大作的体积动辄几十上百GB,但是我们的电脑并没有这么多的内存(外存一般都有几百GB,但是内存一般只有几十GB),我们玩游戏的时候,并不会将游戏的全部资源都放入内存中,而是只将需要用到的资源放入,并释放不再需要的内存。

垃圾自动回收机制

C语言中,因为是手动管理内存,内存泄露是经常出现的事情。这很麻烦,所以大多数语言提供自动内存管理,减轻程序员的负担,这被称为垃圾自动回收机制,js也有垃圾自动回收机制。

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

通常情况下有两种实现方式,用来判断哪些变量不再使用:标记清除(主流)和引用计数,如果一个数据无法被访问到,或者没有被引用,则会被垃圾自动回收器回收。

常见内存泄漏情况

有了垃圾自动回收机制,并不代表不用担心内存泄漏问题,有些不再被用到的内存,垃圾自动回收器也不会回收。

意外的全局变量

给一个未声明的标识符赋值,javaScript 引擎会认为你在引用一个已经存在的全局变量;如果找不到这个变量,则会自动在全局对象(浏览器环境中为 window,Node.js 环境中为 global)上创建它。

1
2
3
function foo(arg) {
bar = "this is a hidden global variable";
}

直接调用构造函数

1
2
3
4
5
function foo() {
this.variable = "potential accidental global";
}
// 直接调用foo,this 指向了全局对象(window)
foo();

直接调用构造函数,也会在全局对象上挂载数据。使用严格模式,可以避免意外的全局变量。

定时器

定时器开启后,除非显式的清除,否则将一直存在,回调函数会被一直引用;如果定时器的回调中引用了不再使用的变量,又未及时清除定时器,就会造成内存泄漏。除了定时器之外,延时器(setTimeout)和事件监听器(addEventListener),如果不使用,都要记得清除或者解绑,否则会持续引用不再需要的回调函数。

闭包

1
2
3
4
5
6
7
8
9
10
function bindEvent() {
var obj = document.createElement('XXX');
var unused = function () {
console.log(obj, '闭包内引用obj obj不会被释放');
};
return unused
}
const func = bindEvent()
//解决方法,清除引用
func = null

即便函数调用结束了,由于func持续引用unused函数,这个函数的空间不会被释放,又因为unused函数引用obj,obj这个变量的内存空间也不会被释放。

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

是什么

闭包由一个内部函数和外部函数的作用域组成。

使用场景

创建私有变量,延长变量的生命周期

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//立即执行函数,充当外部函数
var Counter = (function() {
var privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
}
})();
1
2
3
4
5
6
<script>
let a = 1;
function b(){
console.log(a);
}//因为没有外部函数,所以没有构成闭包
</script>

说说你对防抖和节流的理解

是什么

本质上是优化高频率执行代码造成的性能损耗的一种手段。

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

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

防抖

定义

事件被触发后,且在n秒内不再触发该事件,则执行对应的回调函数,如果在n秒内再次触发该事件,则清除先前的延时器,重新开始计时;可以用操作系统中的资源被剥夺来理解,这里的资源就是延时器

简单的来说,就是对于频繁触发的事件,只执行最后一次触发对应的事件回调

手写防抖函数

1
2
3
<body>
<input type="text">
</body>

传入一个函数,返回一个实现了防抖的函数,返回的防抖函数,本质就是在执行传入的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function myDebounce(func, wait) {
let timer = null
//形成了一个闭包,内部函数引用了外部函数的变量timer,func,wait
return function (...args) {
clearTimeout(timer)
//开启新的定时器
timer = setTimeout(() => {
//修改this指向,传入e
func.call(this, ...args)
}, wait)
}
}
function func(e) {
console.log(e.target.value)
}
document.querySelector('[type=text]').addEventListener('input', myDebounce(func, 500))

节流

定义

在n秒内无论触发多少次事件,只执行第一次触发对应的回调函数

可以用操作系统中的资源不可被剥夺来理解,这里的资源就是延时器

简单的来说就是,对于频繁触发的事件,每间隔一定时间才执行一次事件回调。

手写节流

1
2
3
<body>
<button>click</button>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
document.querySelector('button').addEventListener('click', myThrottle(func, 1000))
function func() {
console.log(1)
console.log(this)
}
//传入一个函数,返回一个实现了节流的函数
function myThrottle(func, wait) {
//声明一个定时器,写在外部函数是因为防止每次调用返回的节流函数都重新初始化定时器
//myThrottle只会被调用一次,而返回的节流函数会被调用多次,因为充当的是回调函数
let timer = null
//返回一个新的函数,这个函数引用了func,wait和timer,构成闭包
return function (...args) {
if (timer) {
//如果定时器已经开启直接返回
return
}
timer = setTimeout(() => {
//虽然实际调用的是返回的新函数,但是在函数内部还是调用了传入的func函数,而且我们使用apply模拟了直接调用
func.apply(this, args)

//释放资源,因为延时器结束也不会修改timer(延时器id)的值
//如果我们不这么做,节流只能触发一次
timer = null
}, wait)
//返回的节流函数没有返回值,如果有,因该是func函数的返回值,但是我们不关心返回值
}
}

区别与联系

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

不同点:函数防抖,在一段连续操作结束后,只执行最后一次触发对应的回调。函数节流,在一段连续操作中,每一段时间只执行一次,在频率较高的事件中被使用来提高性能。函数防抖关注一定时间连续触发的事件,只在最后执行一次,而函数节流一段时间内只执行一次

应用场景

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

  • 推荐搜索词条:用户最后移除输出操作结束后,再发送请求
  • 手机号、邮箱验证输入检测
  • 窗口大小resize。只需窗口调整完成后,计算窗口大小。防止重复渲染。
  • 简单的来说就2种情况,输入和调整窗口大小

节流在间隔一段时间执行一次回调的场景有:

  • 滚动加载,加载更多或滚到底部监听
  • 搜索框,搜索联想功能

说说函数的apply,call,bind方法

这三个方法都是函数(Function)原型(prototype)上的方法,作用是用来修改函数内部this的指向。

值得注意的是,箭头函数也可以调用这个三个方法,但是不能改变其内部this的指向。

call

传入call方法的多个参数(除了obj),会被收集为一个数组args

1
2
3
4
5
6
7
8
9
10
11
//实现自己的call函数
function myCall(obj, ...args) {
//总假设传入的obj是一个对象
const f = Symbol()
obj[f] = this
const res = obj[f](...args)
delete obj[f]
//返回被调用函数的返回值
return res
}
Function.prototype.myCall = myCall

apply

apply方法和call方法不同的是,要求传入的第二个参数是一个数组,而不是多个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function myApply(obj, args) {
//总假设传入的obj是一个对象
const f = Symbol()
obj[f] = this
const res = obj[f](...args)
delete obj[f]

//返回被调用函数的返回值
//我们关心的不仅仅是传入的函数,被调用时内部this指向是否满足要求
//还要确保还能拿到传入的函数的返回值
//也就是说,使用call或者apply调用函数后的返回值,就是原来那个函数的返回值
return res
}
Function.prototype.myApply = myApply
let func = functiom(){ console.log(1) }

bind

bind方法和前2个方法不同的是,并不会立即调用目标函数,而是返回一个新的函数,在这个函数内部使用指定的对象调用目标函数,且这个函数的返回值就是目标函数的返回值。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function myBind(obj, ...args) {
const context = this
//返回的函数也能接收多个参数
return function (...args2) {
//合并参数
const args3 = [...args, ...args2]
let f = Symbol()
obj[f] = context
//调用目标函数并拿到返回值
const res = obj[f](...args3)
delete obj[f]
//返回返回值
return res
}
}

要注意的是,对于bind方法返回的函数,我们不关心它内部的this指向,因为我们根本不需要使用它的this,它只要确保使用了传入的目标对象,调用了指定的函数即可。

深拷贝浅拷贝

当我们拷贝一个基本类型的数据,拷贝的就是它的值,此时没有深浅拷贝一说,只有当我们拷贝一个对象的时候,才有深浅拷贝的说法。

浅拷贝

浅拷贝顾名思义,就是浅层次的拷贝,只拷贝一层。当我们要拷贝一个对象的时候,对于这个对象的所有属性,如果属性的值是基本数据类型,那我们直接拷贝,如果属性值为引用数据类型,则拷贝地址。示例如下:

1
2
3
4
5
6
7
8
9
10
function shallowClone(obj) {
const newObj = {};
for(let prop in obj) {
//只拷贝obj自身的属性
if(obj.hasOwnProperty(prop)){
newObj[prop] = obj[prop];
}
}
return newObj;
}

可以看出浅拷贝的手动实现也是非常简单的。

浅拷贝常见方法

实现浅拷贝主要有2种方法,Object.assign和扩展运算符

Object.assign

只会拷贝对象中可枚举的自有属性,不会拷贝其继承自原型链上的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
let f = Symbol()
var obj = {
age: 18,
nature: ['smart', 'good'],
names: {
name1: 'fx',
},
}
obj[f] = 'cindy'
let obj2 = {}
var newObj = Object.assign(obj2, obj);
console.log(newObj)//{age: 18, nature: Array(2), names: {…}, Symbol(): 'cindy'}
console.log(obj2 == newObj) //返回true,说明返回的就是原对象(传入的第一个对象)

使用扩展运算符实现的拷贝

使用扩展运算符不仅能拷贝对象,还能拷贝数组。

1
2
3
4
5
6
7
8
9
const fxArr = ["One", "Two", "Three"]
const fxArrs = [...fxArr]
fxArrs[1] = "love";
console.log(fxArr) // ["One", "Two", "Three"]
console.log(fxArrs) // ["One", "love", "Three"]

onst obj = { a: 1, b: 2, c: { d: 3 } };
const copy = { ...obj };
console.log(copy); // 输出: { a: 1, b: 2, c: { d: 3 } }

深拷贝

对一个对象进行深拷贝,拷贝多层。当我们要深拷贝一个对象的时候,对于这个对象的所有属性,如果属性的值是基本数据类型,那我们直接拷贝值,如果属性值为引用数据类型,则我们递归拷贝这个引用类型。特点是深拷贝得到的对象与原对象没有任何公共的内存空间

深拷贝常见方法

_.cloneDeep()

借助第三方库lodash

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

手写简单深拷贝

核心在于把值为基本类型的属性,当作递归出口,实现起来也不是很难,尝试自己敲一遍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function deepClone(obj) {
// 如果是null或者不是object类型(基础数据类型),则直接返回,这是一个递归出口
// 注意判断一个是不是对象不要使用 !obj instanceof Object,这在运算优先级上有问题,!会先与obj运算
// 正确的写法是!(obj instanceof Object)或者typeof obj !== 'object'
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 处理Array
if (Array.isArray(obj)) {
//创建一个新数组数组
let cloneArr = [];
for (let i = 0; i < obj.length; i++) {
//递归进行拷贝
cloneArr[i] = deepClone(obj[i]);
}
//返回这个
return cloneArr;
}
// 处理Object
// 创建一个新的空对象
let cloneObj = {};
Object.keys(obj).forEach(key=>{
cloneObj[key] = deepClone(obj[key]);
})
return cloneObj;
}

JSON.stringify()

但是这种方式存在弊端,会忽略值为undefinedsymbol函数的属性,因为这些值都是不可被序列化的,不会出现在序列化的字符串中。除此之外,使用Symbol作为键的属性,也不会包含在序列化的结果中,因为JSON标准不支持Symbol类型的键,并不是因为Symbol类型的键是不可枚举的(实际上是可枚举的)。

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

关于JSON.stringify()还要一个问题就是,传入什么值的时候会报错?当尝试序列化一个循环引用的对象的时候,会报错。

说说js中的事件模型

事件与事件流

事件就是用户与页面或者浏览器进行的交互操作

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

  • 事件捕获阶段(capture phase)
  • 处于目标阶段(target phase)
  • 事件冒泡阶段(bubbling phase)

当我们在某个元素上触发某个事件的时候(这个与我们直接交互的元素叫做目标元素),然后事件流就会从顶级元素开始,通常是DOM元素,流向目标元素,这个向下流动的过程叫做事件捕获,再流回顶级元素,这个向上流动的过程叫做事件冒泡。事件监听通常是在冒泡阶段触发的。

事件模型分类

有三大类:原始事件模型标准事件模型,ie事件模型(很少用了)

原始事件模型(DOM0级)

绑定方式

1
<input type="button" onclick="fun()"> //里面的js代码会被执行,所以不要写成函数名,而是可执行的js代码

或者写成

1
2
var btn = document.querySelector('[type=button]');
btn.onclick = fun;//相当于给dom元素添加属性,传入一个函数,作为回调函数,而不是传入可执行的代码

link标签也可以添加onload属性,link标签加载后再执行相关逻辑,比如修改media类型。

1
2
<link rel="stylesheet" href="./txt.css" media="print" onload="setTimeout(()=>{this.media='all'},2000)">
//this指向link标签,是linnk标签对应的dom对象

link标签加载完毕后再参与渲染,这也会存在样式闪烁问题。

解绑方式:

1
btn.onclick = null;

特点:绑定速度快,只支持冒泡触发,不支持捕获触发,同一个类型的事件(比如click事件)只能绑定一次,后面绑定的会覆盖前面绑定的

标准事件模型(DOM2级)

标准事件模型,就是现在我们最常使用的事件模型

绑定方式:

1
dom.addEventListener(eventType, handler, useCapture)

参数如下:

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

示例:

1
2
3
4
5
<div id='div'>
<p id='p'>
<span id='span'>点击我</span>
</p >
</div>
1
2
3
4
5
6
7
8
9
10
11
var div = document.getElementById('div');
var p = document.getElementById('p');

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

div.addEventListener('click', onClickFn, false);
p.addEventListener('click', onClickFn, false);
1
2
3
//点击后输出
P 3
DIV 3

几个常见事件属性

  • event.currentTarget ,当前事件流所在的元素,是一个dom对象,也就是添加了该事件监听器的元素。
  • event.eventPhase,代表当前执行阶段的整数值。1为捕获阶段、2为事件对象触发阶段、3为冒泡阶段。
  • event.target,代表目标元素,即触发事件的元素。
  • event.stopPropagation,阻止事件冒泡。
  • event.stopImmediatePropagation(),这个方法不仅做了stopPropagation()所做的所有事情——即阻止事件冒泡到父元素,而且还会完全停止同一个事件的所有后续处理。这意味着,在当前元素上为该事件类型注册的所有其它监听器,都不会被调用

解绑方式

传入的回调函数必须是具名函 数,内容相同的两个匿名函数不会被认为相等,就和2个内容完全相同的对象也不相等的原理是一样的。

解绑事件监听器的作用是释放内存空间,否则事件监听器将会一直存在,持续引用对应的回调函数。

1
dom.removeEventListener(eventType, handler, useCapture)

特性:

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

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

如果在目标元素上绑定了多个对同一事件的监听,则捕获触发对应的事件回调会先于冒泡触发对应的事件回调被执行;如果是都是在同一阶段触发,比如都是冒泡触发,则按声明顺序执行回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
document.querySelector('button').addEventListener('click', (e) => {
console.log('我是第一个添加的监听')
}, false)//冒泡触发
document.querySelector('button').addEventListener('click', (e) => {
console.log('我是第二个添加的监听')
}, false)//冒泡触发
document.querySelector('button').addEventListener('click', (e) => {
console.log('我是第三个添加的监听')
}, true)//捕获触发
document.querySelector('button').addEventListener('click', (e) => {
console.log('我是第四个添加的监听')
}, true)//捕获触发

点击box,控制台输出的顺序是:

'我是第三个添加的监听'

'我是第四个添加的监听'

'我是第一个添加的监听'

'我是第二个添加的监听'

如果在捕获监听的回调函数中添加,event.stopPropagation,则冒泡监听的回调函数不会触发;

如果在第一个捕获监听的回调函数中添加event.stopImmediatePropagation(),则后续的所有事件回调,包括其他捕获触发的事件回调都不会触发。

我们可以利用这一点来实现一个指令,给点击事件添加防抖,因为默认添加的事件监听都是冒泡触发,我们只需对同名事件再添加一个捕获触发的监听,然后控制stopPropagation的频率,就能实现节流或者防抖的效果。

IE事件模型

基本不用,现在再vscode中都无法使用

IE事件模型只有2个过程,没有事件捕获阶段:

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

绑定方式

1
dom.attachEvent(eventType, handler)

解绑方式

1
dom.detachEvent(eventType, handler)

举个例子:

1
2
3
var btn = document.getElementById('.btn');
btn.attachEvent(‘onclick’, showMessage);
btn.detachEvent(‘onclick’, showMessage);

讲讲事件代理

事件代理也叫事件委托,当我们要监听某个元素某个事件的时候,我们可以选择不给这个元素添加事件监听,而是给这个元素的父元素或者祖先元素添加对该事件的监听。然后在事件冒泡阶段触发该事件监听对应的回调函数。我们可以说,事件委托是基于事件冒泡的

当你给DOM元素绑定事件处理器时,JavaScript引擎必须保留对该函数的引用,以便在事件触发时可以调用它。这意味着只要事件处理器存在,其对应的函数就不会被垃圾回收机制回收,从而一直占用着内存。

如果事件处理器使用了闭包(例如,在定义事件处理器的函数内部访问外部变量),那么这些外部变量也不能被垃圾回收,因为闭包会持有对外部作用域的引用。这进一步增加了内存占用。

1
2
3
li.addEventListener('click', function() {
alert(this.textContent);
});//比如这个事件监听器中的回调函数就会一直被引用

事件代理的优点:

  • 不必为每个目标元素绑定事件监听,减少了页面所需内存

  • 自动绑定,解绑事件监听,减少了重复的工作。

事件代理的局限性:

  • focusblur这些事件[输入框的事件]没有事件冒泡机制,所以无法进行委托绑定事件。

  • mousemovemouseout这样的事件,虽然有事件冒泡,但触发频率很高,而且只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的,我们最好在目标元素上添加mousemove的监听事件,因为我们不需要计算,直接通过event.offsetXevent.offsetY,就能获取鼠标指针相对于触发事件的元素(即事件目标)的内部坐标。

    1
    2
    3
    document.querySelector('.box').addEventListener('mousemove', (e) => {
    console.log(e.offsetX, e.offsetY)
    })

说说你对BOM的理解

BOM (Browser Object Model),浏览器对象模型,提供了独立于内容,与浏览器窗口进行交互的对象

其作用就是跟浏览器做一些交互效果,比如如何进行页面的后退前进刷新,浏览器的窗口发生变化,滚动条的滚动,以及获取客户的一些信息如:浏览器品牌版本,屏幕分辨率

window

Bom的核心对象是window,它表示浏览器的一个实例,locationnavigator等后续介绍的对象都是window的属性。

在浏览器中,window对象有双重角色,即是浏览器窗口的一个接口,又是全局对象,因此所有在全局作用域中声明的变量函数都会变成window对象的属性方法

window.scrollTo

如果有滚动条,将横向滚动条移动到相对于窗体宽度为x个像素的位置,将纵向滚动条移动到相对于窗体高度为y个像素的位置

1
window.scrollTo(0, 500);//将页面垂直滚动到距离页面顶部500像素的位置,而水平滚动条不会发生变化。

window.scrollBy

如果有滚动条,将横向滚动条向左移动x个像素,将纵向滚动条向下移动y个像素

window.open

window.open()既可以导航到一个特定的url,也可以打开一个新的浏览器窗口。

window.open() 会返回新窗口的引用,也就是新窗口的 window 对象,当使用 window.open() 方法打开新窗口时,如果返回值是 null这通常意味着浏览器阻止了该弹窗的创建。现代浏览器为了防止恶意网站滥用弹窗,通常会限制非用户交互触发的弹窗。如果你在页面加载时或没有明确的用户动作(如点击事件)的情况下调用 window.open(),浏览器可能会认为这是未经请求的弹窗,并阻止它。

比如直接在script标签中书写:

1
window.open('sanye.blog')//被浏览器阻止
1
2
3
document.querySelector('button').addEventListener('click', (e) => {
myWin = window.open('http://www.vue3js.cn', '_blank')
})//可行
1
var newWindow = window.open(url, target, features[, replace]);

参数分析:

url :类型为String,新窗口要加载的 URL 地址。如果省略或设置为 null,则会打开一个空白窗口。

target :类型为 String,指定新窗口的目标位置。它可以是以下常用的预定义值之一:

  • _self: 在当前框架中加载页面(默认行为)。
  • _blank: 在新的窗口或标签页中加载页面。

features:类型为String,是一系列用逗号分隔的字符串,用于指定新窗口的各种属性行为。每个特征可以带有或不带参数

  • width = 600: 设置窗口宽度为 600 像素。
  • height = 400: 设置窗口高度为 400 像素

replace:类型为Boolean, 如果设置为 true,则新加载的页面将替换历史记录中的当前条目;如果为 false 或未提供,则会在历史记录中添加一个新条目。这对于防止用户多次点击后退按钮返回到同一个页面非常有用。

window.close

仅用于关闭通过 window.open() 打开的窗口,如果尝试关闭一个不同域名下的窗口,可能会遇到跨域限制。在这种情况下,window.close() 可能不会工作,因为浏览器的安全模型会阻止你操作不属于同一源的窗口。

1
myWin.close()//关闭myWin窗口,它是使用 `window.open()` 打开的新窗口

新创建的 window 对象有一个 opener 属性,该属性指向打开他的原始窗口对象。

location

是一个对象,包含了许多属性,一个url地址例子如下:

1
http://www.wrox.com:80/WileyCDA/?q=javascript#contents

属性

location属性描述如下:

属性名例子说明
hash“#contents”url中,#后面的字符,没有则返回空串
hostwww.wrox.com:80服务器名称和端口号
hostnamewww.wrox.com域名,不带端口号
hrefhttp://www.wrox.com:80/WileyCDA/?q=javascript#contents完整url
pathname“/WileyCDA/“服务器下面的文件路径
port80url的端口号,没有则为空
protocolhttp:使用的协议
search?q=javascripturl的查询字符串,通常为?后面的内容

除了 hash之外,只要修改location的一个属性,就会导致页面重新加载新URL,因为hash值不会发送给服务器,所以修改哈希值后刷新也面也没意义。

location.reload

此方法可以重新刷新当前页面。这个方法会根据最有效的方式刷新页面,如果页面自上一次请求以来没有改变过,页面就会从浏览器缓存中重新加载,这一点和浏览器的缓存策略相关。如果要强制从服务器中重新加载,传递一个参数true即可。

location.replace

1
2
3
4
5
// 跳转到新页面,但不保留当前页面在历史记录中
window.location.replace("https://www.example.com");

// 简写(window 可省略)
location.replace("/new-page.html");

效果:浏览器立即跳转到指定 URL,当前页面不会出现在“后退”历史中。可以防止用户点击“后退”回到登录页(避免重复提交或自动填充密码),提升用户体验。

history

history是window对象的一个属性,它本身也是个对象,提供了许多api,主要用来操作浏览器URL的历史记录,允许我们编程式控制页面在历史记录之间跳转,也允许我们修改历史记录。检查一个页面并在控制台输入history,即可查看当前页面的statescrollRestoraion等信息。

API作用
history.back()跳转到前一个页面,如果没有前一个页面,则不做响应,不会改变history.length
history.forward()跳转到后一个页面,如果当前就最新页面,则不做响应,不会改变history.length
history.go()传入数字,正数表示前进几个页面,负数表示后退几个页面,0表示刷新页面,不会改变history.length
history.length获取当前窗口页面历史记录跳数,它是一个只读属性,无法直接修改。
history.pushState()历史记录栈顶添加一条记录,历史记录条数加1,但是不会跳转页面。在当前页面调用这个api,你能明显的看到url改变了,但是页面没有跳转。接收三个参数,历史记录对象(state),页面标题,URL路径。
history.replaceState()不会增加历史记录数目,会修改当前历史记录
history.state访问当前页面的状态对象。
history.scrollRestoraion如果值为auto,则在前进或者后退的时候,滚动条会回到原来的位置。如果值为manual(手动的),则不会恢复。默认值是auto,即后退到历史页面的时候,滚动条会回到原来的位置。可以通过在页面(html文件)内部的js代码中使用history.scrollRestoraion来修改这个页面滚动条的恢复方式。

历史记录用一个栈来维护,每添加一历史记录的操作可以叫做push(入栈),当前页面就是历史记录栈顶的页面 ;假设当前历史记录栈的大小是3,当执行history.back(),弹出(pop)一条历史记录,页面也随之发生变化,因为栈顶元素改变了,但是这条历史记录并不会丢失,当我们执行history.forward(),它又会重新成为历史记录栈的栈顶元素。

navigator 对象主要用来获取浏览器的属性,区分浏览器类型。属性较多,且兼容性比较复杂。

screen

保存的纯粹是客户端能力信息,也就是浏览器窗口外面的客户端显示器的信息,比如像素宽度和像素高度。

DOM常见的操作有哪些

DOM是什么

浏览器根据html标签生成的js对象,所有的标签属性都可以在上面找到(所以说node中没有dom),修改这个对象属性会自动映射到标签上。关键词:浏览器,html标签,js对象,属性映射。

nodeType

nodeType 是一个只读属性,用于标识 DOM 节点的类型。常见的nodeType值包括:

  • 1:元素节点(Element),因为元素结点是最常见的结点,所以nodeType的值是1
  • 3:文本节点(Text
  • 8:注释节点(Comment
  • 9:文档节点(Document

DOM常见的操作

创建节点

createElement

创建元素结点

1
const divEl = document.createElement("div");=

createTextNode

创建文本结点

1
const textEl = document.createTextNode("content");

createAttribute

创建属性节点,可以是自定义属性

1
2
3
4
5
6
7
8
9
10
// 创建一个元素结点
var elem = document.createElement('a');
// 创建一个属性结点
var attr = document.createAttribute('href');
// 设置属性结点的值
attr.value = 'http://example.com';
// 将属性结点添加到元素结点
elem.setAttributeNode(attr);
// 添加一些文本到元素
elem.textContent = '链接';

虽然可以直接使用elem.href = 'http://example.com';elem.setAttribute('href', 'http://example.com');来达到相同的效果,但是了解如何创建和操作属性结点可以帮助更好地理解DOM的操作机制。

获取节点

可以通过捕获的方式获取dom结点,也可以通过一个dom结点的属性来获取另一个dom结点

querySelector

传入任何有效的css 选择器,即获得首个符合条件的Dom元素:

1
2
3
4
5
document.querySelector('.element')
document.querySelector('#element')
document.querySelector('div')
document.querySelector('[name=username]')
document.querySelector('div + p > span')

如果页面上没有指定的元素时,返回 null

querySelectorAll

传入任何有效的css 选择器,返回一个伪数组,包含全部符合匹配条件的DOM元素。

1
const notLive = document.querySelectorAll("p");

其他方法

1
2
3
4
5
6
7
8
9
document.getElementById('id属性值');//返回拥有指定id的对象的引用
document.getElementsByClassName('class属性值');//返回拥有指定class的对象集合
document.getElementsByTagName('标签名');//返回拥有指定标签名的对象集合
document.getElementsByName('name属性值'); //返回拥有指定名称的对象结合
document/element.querySelector('CSS选择器'); //仅返回第一个匹配的元素
document/element.querySelectorAll('CSS选择器'); //返回所有匹配的元素
document.documentElement; //获取页面中的HTML标签
document.body; //获取页面中的BODY标签
document.all; //获取页面中的所有元素节点的对象集合型,不是标准DOM的一部分,它的行为可能在不同的浏览器中表现不同

我们仅通过观察是...Element...还是,...Elements...就能判断出返回的结果是集合还是单独的元素

除此之外,每个DOM元素还有parentNodechildNodesfirstChildlastChildnextSiblingpreviousSibling属性,关系图如下图所示。

parentNode和parentElement

parentNode 返回指定节点的父节点,这个父节点可以是任何类型的节点,包括文档类型节点、元素节点、文本节点等。但是,在实际应用中,除了元素节点外,其他类型的节点很少作为父节点存在。

parentElement 仅返回指定节点的父元素节点(即类型为HTMLElement的节点)。如果指定节点的父节点不是一个元素节点(例如,它可能是一个文本节点),则 parentElement 返回 null,即先捕获再判断类型

简单的来说,就是一个对父节点的类型有要求,一个没有。

childNodes和children

childNodes 返回一个实时的NodeList对象,包含了指定节点的所有直接子节点(一级子节点),包括元素节点、文本节点、注释节点等所有类型的节点。

children 返回一个实时的 HTMLCollection 对象,只包含指定节点的直接子元素节点(一级元素结点,即标签)。不包括文本节点、注释节点等其他类型的节点。

previousElementSibling和previousSibling

前者用来获取上一个元素节点(即 HTML 标签),后者用来获取上一个任意类型的结点

获取页面上的所有结点

一个简单的方法是从文档的根节点(documentdocument.documentElement,后者指的是 <html> 元素)开始,然后递归地访问每个节点的 childNodes

获取页面上的所有元素结点

  • 使用 document.getElementsByTagName('*'):这将返回一个包含文档中所有元素的实时 HTMLCollection

  • 使用 document.querySelectorAll():通过传递 '*' 选择器,你可以获得一个静态的 NodeList,它包含了文档中的所有元素结点。

更新结点

innerHTML

不但可以修改一个DOM节点的文本内容,如果传入的是html片段,还会被解析成dom结点。

1
2
3
4
5
6
// 获取<p id="p">...</p >
var p = document.getElementById('p');
// 设置文本为abc:
p.innerHTML = 'ABC'; // <p id="p">ABC</p >
// 设置HTML:
p.innerHTML = 'ABC <span style="color:red">RED</span> XYZ';

innerText、textContent

自动对字符串进行HTML编码,就是把小于号转化成&lt;大于号转化成&gt;保证无法设置任何HTML标签

1
2
3
4
5
6
// 获取<p id="p-id">...</p >
var p = document.getElementById('p-id');
// 设置文本:
p.innerText = '<script>alert("Hi")</script>';
// HTML被自动编码,无法设置一个<script>节点:
// <p id="p-id">&lt;script&gt;alert("Hi")&lt;/script&gt;</p >

两者的区别在于读取属性时,innerText不返回隐藏元素的文本(即 display: none 或者 visibility: hidden),而textContent返回所有文本

1
2
3
4
5
6
7
8
9
10
<div id="example">
<p>可见文本</p>
<p style="display: none;">隐藏文本</p>
</div>

<script>
const element = document.getElementById('example');
console.log(element.innerText); // 输出: "可见文本"
console.log(element.textContent); // 输出: "可见文本\n隐藏文本"
</script>

添加结点

appendChild

把一个节点添加到父节点的最后一个子节点之后,如果这个添加的结点已经在页面中存在,那么这个结点会先从原位置删除

1
2
3
4
5
6
<p id="js">JavaScript</p >
<div id="list">
<p id="java">Java</p >
<p id="python">Python</p >
<p id="scheme">Scheme</p >
</div>

添加一个p元素

1
2
3
4
const js = document.getElementById('js')
js.innerHTML = "JavaScript"
const list = document.getElementById('list');
list.appendChild(js);

HTML结构变成了下面

1
2
3
4
5
6
<div id="list">
<p id="java">Java</p >
<p id="python">Python</p >
<p id="scheme">Scheme</p >
<p id="js">JavaScript</p > <!-- 添加元素 -->
</div>

insertBefore

1
parentElement.insertBefore(newElement, referenceElement)

子节点会插入到referenceElement之前

  • parentElement: 这是要操作的目标元素,新的子节点将被添加到这个元素的子节点列表中。
  • newElement: 这是你想要插入的新元素节点。
  • referenceElement: 这是在新元素插入之前,所依据的参考元素。新元素会被放置在这个参考元素之前。如果这个参数为 null,则新元素会被插入到父元素的最后,就像使用 appendChild() 一样。

setAttribute

在指定元素中添加一个属性节点,如果元素中已有该属性改变属性值。

1
2
const div = document.getElementById('id')
div.setAttribute('class', 'white');//第一个参数属性名,第二个参数属性值。

删除结点

removeChild

删除一个节点,首先要获得该节点本身以及它的父节点,然后,调用父节点的removeChild,把自己删掉,也就是说,一个结点是不能删除自身的,而是需要借助父节点。

1
2
3
4
5
6
7
// 拿到待删除节点:
const self = document.getElementById('to-be-removed');
// 拿到父节点:
const parent = self.parentNode;
// 删除并返回被删除的dom元素
const removed = parent.removeChild(self);
removed === self; // true

删除后的节点虽然不在文档树中了,但其实它还在内存中,可以随时再次被添加到别的位置。

说说js中的布局属性

要明白如何实现功能,我们首先要搞清楚dom元素的一些布局属性

clientWidth,scrollWidth,offsetWidth

client系列

clientWidth/clientHeight:可视区域的宽/高+内边距,不包含border

offset系列

offsetWidth/offsetHeight:可视区域的宽/高+内边距+border+滚动条,这两个属性通常被拿来与clientWidth/clientHeight属性比较。这2类属性的范畴都包含可视区域的宽高和padding,都不包含margin(明明和padding一样都是边距,为什么就这么不受待见呢),区别在于前者还包含border,后者不包含,范围更小。

scroll系列

scrollWidth/scrollHeight:有滚动条元素的元素整体的宽高。一个没有滚动条的元素,它的scrollWidth/scrollHeight属性的值等于它的clientWidth/clientHeight属性的值。

举个例子,我们在一个高度为600px的盒子box里放两个背景颜色不同,高度都是400px的盒子box1,box2,并给box添加css属性

1
overflow:auto 
1
2
3
4
5
6
7
8
9
补充一下overflow属性的值
visible(默认值):内容不会被裁剪,而是会呈现在元素框之外。
hidden:内容会被裁剪,并且超出的部分不会显示。浏览器不会为溢出内容提供任何滚动机制。
scroll:即使内容并未溢出,也提供滚动条
auto:如果内容溢出了元素框,则浏览器会根据需要提供滚动条。如果内容没有溢出,则不显示滚动条

单个方向上的溢出控制
overflow-x:控制水平方向上的溢出。
overflow-y: 控制垂直方向上的溢出。

这样box盒子就出现了滚条,可以实现内容的滚动,内部盒子也不会影响外部盒子的布局(开启了BFC,可以观察添加该条属性前后,body高度的变化,从800px变为600px)。然后我们访问box盒子(有滚动条的盒子)的clientHeight属性和scrollHeight属性

1
2
box.clientHeight //600px
box.scrollHeight //800px

这样是不是就很容易理解client和scroll之间的区别呢。对于没有滚动条的元素,clientWidth/clientHeightscrollWidth/scrollHeight的值是一一相等的。

当我们不断地给body添加元素,**body的高度总有超过浏览器窗口高度的时候,此时body标签的父元素,html标签会自动开启滚动条html.clientHeight就是浏览器窗口**的高度。

scrollLeft和scrollTop

scroll开头的属性中,还有两个重要的属性,scrollLeft/scrollTop

表示具有滚动条的元素,顶部滚动出可视区域的高度,或者左部滚动出可视区域的宽度,对于不具有滚动条的元素,这两个属性的值都是0。这两个属性是可读写的,将元素的 scrollLeftscrollTop 设置为 0,可以重置元素的滚动位置,通常用来实现一键到底,或者返回顶部等功能。

offsetLeft和offsetTop

  • 元素左部/顶部距离最近的定位元素的距离,相对的不是视口,通常是固定的,不会随滚动条改变而改变。
  • offsetLeft/offsetTop这2个属性是只读的,不能手动修改
  • 要注意的是,没有offsetRightoffsetBottom属性。

offsetX和offsetY

  • offsetXoffsetY 是与鼠标事件相关的属性,通常在处理用户交互时使用,注意要和offsetLeft/offsetTop区分开来。
  • 这两个属性提供了鼠标指针相对于触发事件的元素(即事件目标元素,可以通过event.target获得)的 X 和 Y 坐标。它们是 MouseEvent 对象的一部分
  • 我曾经尝试使用这2个属性来做放大镜的效果,发现一直实现不了,后来发现了问题所在,触发鼠标事件的目标元素始终是蒙层,而不是商品图片,可以通过给蒙层添加point-events:none忽略鼠标事件来解决问题。

如何实现触底加载,下拉刷新?

如何实现触底加载

使用布局属性+scroll事件监听

如果html元素顶部滚出可视区域的高度 + html元素的可视区域高度,大于html标签的整体高度,则判定为触底,其实这个时候就是页面已经滑到底部了。

1
2
3
if (html.scrollTop + html.clientHeight >= html.scrollHeight) {
console.log('触底')
}//这里的html标签可以被替换为所有具有滚动条的元素

优点:实现起来非常简单。

缺点:只能判断最后一个元素是否触底,不能判断非底部元素是否触底(如果body很长,那么非底部元素也是有触底事件的),而且需要监听页面的scroll事件,性能较差。

使用IntersectionObserver

使用IntersectionObserver API,创建observer对象 ,需要传递2个参数

1
2
3
4
5
6
7
8
9
10
11
12
13
const func = (entries, observer) => {
//每观察一个元素,entries的大小就会+1
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('元素进入视口');
//entry.target:表示被观察的元素。
observer.unobserve(entry.target); // 一旦进入视口,停止观察
}
});
}
//第二个参数指明元素完全出现在视口再触发回调函数,符合触底的思想
const observer = new IntersectionObserver(func, { threshold:1 });
observer.observe(target) //观察某个元素

关于IntersectionObserver更详细的介绍参考下文。

优点:不仅能精确控制某个元素是否触底,令threshold的值为0,还能实现图片懒加载的效果,即图片一出现在视口,就发送请求获取图片。

缺点:需要调用api实现起来麻烦。

如何实现下拉刷新

监听windowtouchstarttouchmovetouchend,要注意的是不是mousestartmousemovemouseend事件,因为”下拉”是只在移动端才有的动作,所以没有鼠标,所以事件也不以mouse开头,而是以touch开头。

通过e.touches[0].clientY获得触碰位置距离视口顶部的距离。

  • e.touches是一个 TouchList 对象,包含了所有当前活跃的触点(即手指接触屏幕且尚未抬起的所有触点),当用户用手指触碰设备屏幕时(比如手机或平板),会产生一个或多个触摸点。每个触摸点都由一个 Touch 对象表示,该对象包含了诸如位置、状态等信息。所有这些触点被包含在一个 TouchList 对象中。
  • e.touches[0]:这表示获取 TouchList 列表中的第一个触点。因为 TouchList 是一个类数组对象,所以你可以通过索引来访问其中的元素。索引从 0 开始,因此 e.touches[0] 就是列表中的第一个触点。
  • clientY:这是 Touch 对象的一个属性,表示触点相对于可见视口(viewport)的 Y 轴坐标。换句话说,它给出了手指在屏幕上的垂直位置,不包括任何滚动条导致的偏移。

touchend事件触发后,计算移动的距离,判断是否需要刷新数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
let start = 0
let distance = 0
let load = false
let tip = false
//在一次下拉操作中,这个回调函数只会执行一次
window.addEventListener('touchstart', function (e) {
//存储第一次触摸屏幕距离视口顶部的距离
start = e.touches[0].clientY
})
//在一次下拉操作中,这个回调函数会被执行多次,总不能一直提示正在进行下拉操作吧
window.addEventListener('touchmove', function (e) {
//记录下拉的距离
distance = e.touches[0].clientY - start
//如果下拉的距离大于0且未提示过正在下拉刷新,则提示
if (distance > 0 && !tip) {
console.log('正在进行下拉刷新操作')
//本次下拉操作不再提示正在下拉刷新
tip = true
}
//如果下拉距离到了设置的高度,继续下拉总不能一直提示松手释放吧
//如果下拉的距离超过设定的距离且未提示过松手释放,则提示松手释放
if (distance > 50 && !load) {
console.log('松手释放')
//本次下拉操作不再提示松手释放
load = true
}
})
//在一次下拉操作中,这个回调函数只会执行一次
window.addEventListener('touchend', function (e) {
//如果下拉的距离超过了指定距离,则松手后开始更新
if (load) {
console.log('正在进行更新操作')
}
//一次下拉操作结束后,把这两个变量重新初始化,为下一次下拉刷新准备
load = false
tip = false
start = 0
distance = 0
})

如何判断一个元素是否在可视区域中?

使用场景

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

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

IntersectionObserver

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

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

创建观察者

1
2
3
4
5
6
7
8
9
const options = {
// 表示 被观察元素 和 根元素 “重叠面积”,占被观察者的比例,从 0 - 1 取值,
// 1 表示被观察者完全出现在根元素
threshold: 1.0,
root:document.querySelector('#scrollArea') // 必须是目标元素的父级元素,如果省略,默认是浏览器视口
rootMargin: '0px 0px 100px 0px', // 给根元素添加margin,提前 100px 触发
};
const callback = (entries, observer) => { ....}
const observer = new IntersectionObserver(callback, options);

通过new IntersectionObserver创建了观察者 observer,传入的回调函数 callback ,在重叠比例跨过 threshold 时会被执行一次,所以我们在实现图片懒加载的时候,令这个值为0,当图片刚刚出现在视口的时候,就是重叠比例超过0的时候,就是回到函数被执行的时候。rootMargin 就像 CSS 的 margin,但它作用在 根元素(root)的边缘,用来扩展或收缩根元素的检测区域

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

1
2
3
4
5
6
7
8
9
10
// 上段代码中被省略的 callback
const callback = function(entries, observer) {
//entries是一个数组,包含了所有被观察的对象
entries.forEach(entry => {
entry.isIntersecting // 布尔值,表示两元素是否重叠
entry.time; // 触发的时间
entry.intersectionRatio; // 重叠区域占被观察者面积的比例(被观察者不是矩形时也按照矩形计算)
entry.target; // 被观察者(DOM元素)
});
};

传入被观察者

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

1
2
const target = document.querySelector('.target');
observer.observe(target);#被观察者是dom元素

getBoundingClientRect

调用dom元素的getBoundingClientRect方法,返回值是一个 DOMRect对象,拥有left, top, right, bottom, x, y, width, 和 height属性,所有布局属性(除了宽高)都是相对视口的。

  • x: 元素左边缘相对于视口左边界的距离,通常情况下等于left
  • y: 元素上边缘相对于视口顶部的距离,通常情况下等于top
  • top: 元素上边缘相对于视口顶部的距离。
  • bottom: 元素下边缘相对于视口顶部的距离。
  • left: 元素左边缘相对于视口左边界的距离。
  • right: 元素右边缘相对于视口左边界的距离。
  • width: 元素的宽度,包括填充和边框,但不包括外边距,等于offsetWidth
  • height: 元素的高度,包括填充和边框,但不包括外边距,等于offsetHeight

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

  • top 大于等于 0
  • left 大于等于 0
  • bottom 小于等于视窗高度,就是元素底部距离视口顶部的距离,小于等于视口高度,这个是最常用的判断条件
  • right 小于等于视窗宽度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//返回一个布尔值,判断元素是否在视口中
function isInViewPort(element) {
const viewWidth = window.innerWidth || document.documentElement.clientWidth;
const viewHeight = window.innerHeight || document.documentElement.clientHeight;
const {
top,
right,
bottom,
left,
} = element.getBoundingClientRect();

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

如果要获得元素的即时位置,每次页面滚动都要重新调用这个方法,来获得最新的位置信息。

实现图片的懒加载

下面使用IntersectionObserver实现了一个图片懒加载指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 因为自定义指令只会被导入一次,所以observer对象只会被创建一次
// 写在外面的好处就是不用每次使用指令都创建一个observer,减少了内存占用
const observer = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.src = entry.target[val]//不能通过entry.target.val的方式访问,val 是一个 Symbol 类型的键
// 图片出现在视口,就移除对图片的监听
// 传入的还是dom对象,对应添加监听的时候传入的是dom对象
observer.unobserve(entry.target)
}
})
},
{ threshold: 0 }//第二个参数传入一个配置对象
)
let val = Symbol('value')
//导出一个对象
export const lazyLoad = {
mounted(el, binding) {
//记住img元素的src属性
el[val] = binding.value
observer.observe(el)//传入绑定的dom对象
}
}

本来就出现在视口中的图片,threshold一开始就超过了0,回调函数也会被正常执行,因为IntersectionObserver 会报告初始状态。

实现列表的无限滚动

getBoundingClientRect

使用getBoundingClientRect来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const handler = []
window.addEventListener('scroll', () => {
handler.forEach((obj) => {
if (
//这里给一个 80 是希望元素底部距离出现在视口还有 80px 的时候就获取新的数据,用户体验更好
obj.el.getBoundingClientRect().bottom <=
document.documentElement.clientHeight + 80
) {
obj.func()
}
})
})
export const infinite = {
mounted(el, binding) {
handler.push({ el, func: binding.value })
}
}

其中:

  • obj.el.getBoundingClientRect().bottom,获取的是一个dom元素底部距离视口顶部的距离
  • document.documentElement.clientHeight+80,其中document.documentElement就代表html元素,而html.clientHeight代表的就是视口的高度
  • 其中binding.value就是元素触底后执行的函数。

infinite指令的实现思路就是:

  • 使用了v-infinite指令的dom元素,都会连同对应的回调函数(binding.value),被push到handler数组中。
  • 当页面滚动的时候,遍历所有使用了v-infinite指令的dom元素,调用它们的getBoundingClientRect方法,判断它们的底部是否将要出现在视口,如果满足条件,则调用对应的回调函数。

IntersectionObserver

尝试使用IntersectionObserver来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.val()
}
})
},
{ threshold: 1 }
)
let val = Symbol('func')
export const infinite = {
mounted(el, binding) {
el[val] = binding.value
observer.observe(el)
}
}

把这种方法应用到我实际开发的项目中,想要实现列表无限滚动的时候,缺发现不起效果,仔细思考后发现:

初始图片加载完毕的时候,列表的高度就大于视口高度了(列表太长了),所以列表和视口的重合面积,和列表的面积的比例,永远会小于1,所以传入的回调函数永远不会被触发。即便把threshold设置为小于1的值,也会因为列表越来越长,导致后来重合面积的比例越来越小,后续滚动也不会加载新的数据。

难道说IntersectionObserver不适合用来做列表的无限滚动?其实不是,只要在列表末尾加一个哨兵元素即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const vInfinite = {
mounted(el, binding) {
const bottom = document.createElement("div");
bottom.style.height = "10px";
el.appendChild(bottom);
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
binding.value();//传入的是一个表达式,是一个函数
}
},
{
threshold: 0,
rootMargin: "0px 0px 100px 0px", //增加视口的范围
}
);
observer.observe(bottom);
},
};

new操作符到底做了什么

  • 创建一个新的对象
  • 让这个对象的[[prototype]]属性等于构造函数的prototype,即让新创建的对象的原型等于构造函数的原型对象。
  • 调用这个对象的constructor方法

如果构造函数的返回值基本类型,那么这个返回值不起任何效果,但是如果构造函数的返回值是引用类型,new操作返回的对象就是构造函数返回的对象。

1
2
3
4
5
6
function Parent() {
this.name = 'parent';
this.play = [1, 2, 3];
return 1
}
console.log(new Parent()) //Parent {name: 'parent', play: Array(3)}
1
2
3
4
5
6
function Parent() {
this.name = 'parent';
this.play = [1, 2, 3];
return [1,2,3]
}
console.log(new Parent()) //输出[1,2,3]

手写new

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Parent(name,age) {
this.name = name
this.age = age
}
function myNew(constructor, ...args) {
//方法1
// const obj = Object.create(constructor.prototype)
//方法2
const obj = {}
//新对象原型指向构造函数原型对象
obj.__proto__ = constructor.prototype
//让这个对象调用constructor方法,并传入参数
const res = obj.constructor(...args)
if (typeof res == 'object') {
return res
} else {
return obj
}
}
console.log(myNew(Parent,'tom',30)) //Parent {name: 'tom', age: 30}

在js中如何实现继承

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

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

如果大家学过java,想必对继承的概念都非常熟悉了。

那在js这门语言中是如何实现继承呢?

原型链继承

让父类的一个实例作为子类的原型对象,这样子类的原型对象的原型确实是父类的原型对象

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

这样就满足Child.prototype._proto_ = Parent.prototype,在原型链上确实是符合继承关系,但是这也只是在原型链上实现了继承,Child.prototype.constructor也不指向Child的构造函数,而是指向Parent的构造函数,而且这个构造函数还不是原型对象(Child.prototype)自己的属性

正确的情况,Child.prototypeconstructor应该是Child.prototype自己的属性(ownProperty)

正确的状态,就是每个原型对象,都有一个自己的构造函数,指向正确的构造函数。比如Parent.prototype,就有自己的构造函数,指向Parent构造函数

总的来说,原型链继承的问题包括, 子类的原型对象没有自己的构造函数,还存在多余的属性

构造函数继承

1
2
3
4
5
6
7
8
9
10
11
function Parent(){
this.name = 'parent1';
}
Parent.prototype.getName = function () {
return this.name;
}
function Child(){
Parent1.call(this);
this.type = 'child'
}
let child = new Child();

构造函数继承也只是实现了构造函数上的继承,比原型链继承还低智,就纯粹在Child的构造函数中,借助call方法调用了Parent的构造函数。

在这个例子中,Parent1.call(this)完全可以被替换为 this.name = 'parent1';这种继承方式唯一的作用,拿这个例子来讲,就是把父类Parent的name属性终于变成子类Child实例自己的属性了(对象本身就有的,而不是原型对象上的,可以通过hasOwnProperty方法来判断)。

组合式继承

组合式继承就是把前面两种方式,即原型链继承构造函数继承,这两种不完美的方法结合了起来,并更正了Child原型对象的指向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Parent() {
this.name = 'parent';
this.play = [1, 2, 3];
}

Parent.prototype.getName = function () {
return this.name;
}
function Child() {
// 第二次调用 Parent()
Parent.call(this);
this.type = 'child';
}

// 第一次调用 Parent()
Child.prototype = new Parent();
// 手动挂上构造器,指向自己的构造函数,更正指向
Child.prototype.constructor = Child;
console.log(new Child())

可以看到 Child.prototype.constructor指向是正确的,创建的Child实例也有自己的nameplay属性,但是Child构造函数的原型对象上有多余的属性比如name和play。

再解释一下为什么这个Child实例对象前面有Child表示(如图),因为这个实例是被Child构造函数创建的;再解释一下[[Prototype]]:Person代表什么,代表Child.prototype.__proto__ = Person.prototype

寄生组合式继承

是对组合式继承的优化,不再使用父类(Parent)的实例作为子类(Child)的原型对象(Prototype),而是使用**Object.create()**方法单独为子类创造一个原型对象。

Object.create()能以传入的对象为对象原型,创造一个新的对象。

示例:Object.create(Parent.prototype)

以Parent的原型对象为对象原型,创造一个新的对象,意思就是创造的对象_proto_属性=Parent.prototype就好像创建了一个Parent实例,所以创建的对象显示的类型也是Parent,不过这个实例对象没有自己的属性(多余的属性比如name,play),再给这个对象添加自己的constructor属性后用来充当原型对象再合适不过了。

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

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

function Child() {
Parent.call(this);
this.friends = 'child';
}
// 这里改用 Object.create 就可以减少组合继承中多进行一次构造的过程
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
Child.prototype.getFriends = function () {
return this.friends;
}

let person = new Child();
console.log(person); //{friends:"child",name:"parent",play:[1,2,3],__proto__:Parent}
console.log(person.getName()); // parent
console.log(person.getFriends()); // child

extends+super

使用extends关键字实现继承,基于es6新引入的class,本质上使用的也是寄生组合式继承,不过还要配合super关键字使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Person {
//构造函数
constructor(name) {
this.name = name
}
// 原型方法,会被挂载到构造函数的原型上
// 即Person.prototype.getName = function() {}
getName() {
console.log('Person:', this.name)
}
}
class Gamer extends Person {
//构造函数不是必须写的,如果不写构造函数就相当于书写了以下代码:
//constructor(name) {
// super(name)
//}
constructor(name, age) {
// 子类中存在构造函数,则需要在使用“this”之前首先调用 super(),表示调用父类的构造函数
// 不调用super就会报错
super(name)
this.age = age
}
}
const asuna = new Gamer('Asuna', 20)
asuna.getName() // 成功访问到父类的方法

Javascript本地存储的方式有哪些?区别及应用场景?

javaScript本地缓存的方法我们主要讲述以下四种:cookie,sessionStorage,localStorage,indexedDB

其中sessionStoragelocalStorage都是H5新增的,在此之前使用的都是cookie。

是什么

Cookie是存储在客户端的小型文本文件(txt),被用来解决 HTTP无状态的问题。

作为一段一般不超过 4KB 的小型文本数据(4KB 的大小限制主要针对单个 Cookie),它由一个名称(Name)、一个值(Value)和其它几个用于控制cookie有效期、安全性、使用范围的可选属性组成。

但是cookie在每次请求中都会被发送,如果不使用 HTTPS并对其加密,其保存的信息很容易被窃取,导致安全风险。举个例子,在一些使用 cookie保持登录态的网站上,如果 cookie被窃取,他人很容易利用你的 cookie来假扮成你登录网站。

关于cookie常用的属性如下:

过期时间

Expires

用于设置 Cookie 的过期时间

1
Expires=Wed, 21 Oct 2015 07:28:00 GMT

Max-Age

用于设置在 Cookie 的有效时间(优先级比Expires高,书写方式也比Expires友好)

1
Max-Age=604800 //单位是s

设置缓存的有效时间使用的也是这2个字段

作用范围限制

Domain

指定了 Cookie 在哪些域名下生效,包含了这些域名的请求,才会自动携带cookie

添加cookie的时候,不指定Domain,默认就是当前域名;指定了一个域名,则其子域名也总会被包含;

比如,在https://www.bilibili.com/页面下添加一个cookie,但是未指定Domain,则Domain就是www.bilibili.com

如果指定Domain为bilibili.com,则实际为.bilibili.com,表示在bilibili.com所有子域名下,这个cookie也生效,这一操作的效果等同于指定Domain为.bilibili.com

通过配置Domain可以实现跨子域共享cookie

Path

指定了一个 URL路径,只有包含这个路径的请求,才能自动携带这个cookie

安全限制

Secure

标记为 SecureCookie,意味着这个cookie包含了重要的信息,不应该被泄漏,只能通过HTTPS请求,安全地发送给服务器

HttpOnly

标记为HttpOnly的请求,只能通过http/https协议来操作。

SameSite

Cookie默认不会在跨域请求中被发送,而跨站请求一定跨域,SameSite 属性用于控制 Cookie 是否应该随跨站请求一起发送。它有三个可能的值:

  • Strict:Cookie 仅在同站请求中发送,即只有当用户从同一站点发起请求时,才会包含 Cookie。
  • Lax(宽松的):大多数情况下不发送跨站请求中的 Cookie,但在导航到目标站点(如点击链接)时例外。
  • None:允许跨站请求中发送 Cookie,但要求请求必须通过 HTTPS 发送(即启用安全传输层)。

然而只配置SameSite属性为None,还不能解决跨站请求不能携带cookie的问题,还需要配置withCredentails的值为true,然后后端还需允许跨域,也就是说,需要配置3部分才能允许跨站携带cookie。

参考:前端面试—网络 | 三叶的博客中的withCredentails部分

操作方式

通过js操作cookie

  • 获取当前页面所有cookie

    1
    document.cookie

    返回一个字符串,包含当前页面的所有cookie的键值对,形如:key = val;key2 = val2;......;keys = vals,不同cookie之间用分号分隔,对于document.cookie中的每个cookie,只包含最进本的key和value,其他限制信息不包含在内。

    正因如此,在前端访问某个具体的cookie是比较麻烦的,如果设置为httpOnly,前端更是访问不了。

    如果要查看当前页面的全部cookie的详细信息,可以选择检查页面,前往应用程序->存储->cookie中查看。

  • 创建一个cookie

    1
    2
    //可以继续添加其他限制属性,不同限制属性之间使用分号分隔,一次只能添加一个cookie
    document.cookie = 'key=val;Max-age=3600;Domain=www.sanye.blog'//使用分号来分割 其他配置项

    我们知道,document.cookie,返回一个字符串,包含当前页面的所有cookie的键值对。上述创建cookie的代码的效果貌似是覆盖掉这个字符串,其实不是的,效果真的是添加一个cookie

  • 修改cookie

    关于cookie的修改,首先要确定domainpath属性都是相同的才可以,这两个属性可以理解为用来限制cookie的作用域。其中有一个不同的时候,都会创建出一个新的cookie,而不是修改原来的cookie

    1
    document.cookie = 'name=bb; domain=aa.net; path=/'
  • 删除cookie

    最常用的方法就是给cookie设置一个过期的时间,这样cookie过期后会自动被浏览器删除。

    1
    document.cookie = "id=1;Max-age=0"

通过http操作cookie

添加cookie

http通过在响应头中添加Set-Cookie字段,在客户端种cookie,如果有多个 Cookie 就在响应头中设置多个 Set-Cookie 字段。

通过http操作cookie的方式,与js操作cookie的方式在形式上是不同的,但本质上还是相同的。

1
Set-Cookie: <cookie-name>=<cookie-value>; [Expires=<date>]; [Max-Age=<non-zero-digit>]; [Domain=<domain-value>]; [Path=<path-value>]; [Secure]; [HttpOnly]; 

更新或者删除cookie

要更新现有的Cookie,只需再次发送带有相同名称的新 Set-Cookie 头。这将覆盖旧的同名Cookie。要删除一个Cookie,可以通过设置其 ExpiresMax-Age 为过去的时间戳来实现

浏览器行为

  • 浏览器会在每次请求时,自动附加(携带)与目标URL(也就是请求url,而不是发起请求所在页面的url)相匹配的所有Cookies。
  • 如果某个Cookie被标记为 HttpOnly,那么JavaScript代码不能读取或修改这个Cookie,增加了安全性。
  • 当一个Cookie过期后,浏览器会自动将其从存储中移除,不再随请求一起发送。

localStorage

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

特点

  • 持久化的本地存储,除非主动删除数据,否则数据永不过期
  • localStorage中的键和值必须是字符串类型,如果指定为数字,布尔值,或者引用数据类型,则会隐式转化成字符串。
  • 遵循严格的同源策略:存储的信息在同一域中是共享的,这个同一域包括子域名,也就是说若两个域名即便只有子域名不同,也不会被认为是同一域名。子域名指的是主域名之前的部分,比如www.sanye.blog中的www就是子域名,sanye.blog就是主域名,一般域名购买,购买的就是主域名。
  • 当本页操作(新增、修改、删除)了localStorage中的数据的时候,本页面不会触发storage事件,但是别的页面会触发storage事件,这里的其他页面,指的是同源的其他页面。
  • 大小:5M(跟浏览器厂商有关系),localStorage 的大小限制主要指的是 整个域名下所有存储数据的总和,而不是单个键值对的大小。
  • localStorage本质上是对字符串的读取,如果存储内容多的话会消耗内存空间,会导致页面变卡

storage事件补充

storage有许多api,那哪些常用的api能触发storage事件呢?

方法是否触发 storage 事件说明
setItem(key, value)会触发设置新值或修改现有值
removeItem(key)会触发删除指定键值
clear()会触发清空整个存储
getItem(key)不会触发仅读取数据,无副作用
1
2
3
4
5
6
7
8
9
10
11
12
window.addEventListener('storage',(e)=>{
// 当清空token的时候,所有页面退出登录
if(e.key==='token' && e.newValue === ''){
router.navigate('/login')
console.log(e)
}
//当一个页面登录的时候,另一个页面也跳转到首页
if(e.key==='token' && e.oldValue ===''){
router.navigate('/home')
console.log(e)
}
})

假设你有两个标签页(Tab A 和 Tab B)打开了同一个网站 example.com,并且这两个标签页都在使用 localStorage 来存储一些数据。

  1. Tab A 中执行以下 JavaScript 代码来设置一个新的 localStorage 项:

    1
    localStorage.setItem('key', 'value');
  2. Tab A 不会触发 storage 事件,因为它就是触发这次变更的操作源

  3. Tab B 中监听 storage 事件,并打印出事件详情:

    1
    2
    3
    4
    5
    6
    7
    window.addEventListener('storage', function(event) {
    console.log('Storage event received:', event);
    console.log('Key:', event.key);//可以得知修改的localstorage中的哪个字段,比如token字段
    console.log('Old value:', event.oldValue);// 该字段修改后的新值
    console.log('New value:', event.newValue);// 该字段修改前的旧值
    console.log('URL:', event.url);
    });
  4. 当你在 Tab A 中设置了 localStorage 后,Tab B 会立即接收到 storage 事件,并输出类似如下的信息:

    1
    2
    3
    4
    5
    Storage event received: StorageEvent {…}
    Key: key
    Old value: null
    New value: value
    URL: https://example.com/
  5. 如果你在 Tab B 中也设置了相同的 localStorage 项,比如:

    1
    localStorage.setItem('key', 'newValue');
  6. Tab B 自身不会触发 storage 事件,但 Tab A 会接收到 storage 事件,并显示相应的更新信息。

对于 sessionStoragelocalStorage 上的任何更改都会触发 storage 事件,但 storage 事件不会区分这两者。

常见使用语法

设置

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

获取

1
localStorage.getItem('username')

获取键名

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

删除

1
localStorage.removeItem('username')

一次性清除所有存储

1
localStorage.clear()

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

  • 无法像Cookie一样设置过期时间
  • 只能存入字符串,无法直接存对象,如果尝试存储一个对象,它会自动调用该对象的 toString() 方法,这通常会导致数据丢失或无法正确恢复原始对象;存入对象之前必须先序列化

区别与联系

  • 兼容性:Web Storage是H5新推出的,只在现代浏览器中支持,兼容性不如cookie。

  • 过期时间localStorage 无法像Cookie一样设置过期时间,数据在本地的存储是持久化的,除非主动删除数据,否则数据永不过期

  • 存取方式localStorage中的数据必须手动存取,而cookie中的数据是自动存取

  • 同源策略localStorage严格遵循同源策略,同源页面才能共享同一份localStorage中的数据;虽然 Cookies 也默认遵循同源策略,但可以通过特定的设置来实现跨子域的数据共享

  • 大小限制:二者都有存储大小的限制,每个cookie的存储大小限制是4kB,而每个页面的localStorage的存储大小限制一般是5MB,明显更大,因为storage中数据本身不会自动参与和服务端的通信

  • 存储类型:二者只能存储字符串。这意味着如果你想要存储对象或其他复杂的数据结构,需要进行序列化反序列化操作。

为什么选择把token存储在localStorage中而不是cookie中

  • 前后端分离项目,发送的ajax请求通常是跨域的cookie不会自动被携带,需要额外配置withCredential,较为麻烦
  • 由于storgae中的数据遵循严格的同源策略且不会自动携带,无法跨站访问storage中数据,天然避免 CSRF(跨站请求伪造)攻击;而将jwt存储在cookie中有CSRF的风险。
  • localStorage可以轻松读取token,从而判断用户是否登录;而cookie 虽然也能存 JWT,但是读取单个cookie的值的步骤比较复杂。
  • cookie是自动存储,自动随请求携带的,但是不是所有请求都需要携带token。

sessionStorage

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

前端扩展存储方式

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

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

优点

储存量理论上没有上限;所有操作都是异步的,相比 LocalStorage 同步操作性能更高,尤其是数据量较大时;原生支持储存JS的对象

是个正经的数据库,意味着数据库能干的事它都能干。

缺点

操作非常繁琐;本身有一定门槛

应用场景

  • 标记用户与跟踪用户行为的情况,推荐使用cookie
  • 适合长期保存在本地的数据(令牌),推荐使用localStorage
  • 敏感账号一次性登录,推荐使用sessionStorage
  • 存储大量数据的情况、在线文档(富文本编辑器)保存编辑历史的情况,推荐使用indexedDB

ajax

定义

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

Ajax的原理简单来说就是通过XmlHttpRequest(xhr)对象向服务器发送异步请求,收到服务器响应的数据后,用Js操作DOM来更新页面。

实现过程

创建 Ajax的核心对象 XMLHttpRequest对象

1
const xhr = new XMLHttpRequest();

通过 XMLHttpRequest 对象的 open() 方法初始化一个 HTTP 请求

1
xhr.open(method, url, [async][, user][, password])
  • method:表示当前的请求方式,常见的有GETPOST
  • url:服务端地址
  • async:布尔值,表示是否异步执行操作,默认为true
  • user: 可选的用户名用于认证用途;默认为null
  • password: 可选的密码用于认证用途,默认为null

构建请求所需的数据内容,并通过XMLHttpRequest 对象的 send() 方法发送给服务器端

1
xhr.send([body])//如果请求体中不需要携带数据,什么都不要传入

通过 XMLHttpRequest 对象提供的 onreadystatechange 事件(即监听(on)准备状态(readystate)改变(change))监听服务器端的通信状态。

关于XMLHttpRequest.readyState属性有5个状态,用数字来区分,只要 readyState属性值一变化,就会触发一次 readystatechange 事件。

  • 0(unsent):open方法还未调用,连接还未建立。
  • 1(opened):open方法调用了,但是还未发送请求(还未调用send方法)
  • 2(headers_recieved):请求发送了,响应头响应状态已经接收到了,但是还未开始下载。
  • 3(loading):响应体下载中
  • 4(done):响应体下载完毕,请求完成。

这五个状态可以简记为,open前,send前,响应状态+响应头接受但响应体还未开始下载,响应体下载中,响应体下载完毕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const xhr = new XMLHttpRequest()
//注意这里不是ContentType
//xhr.setRequestHeader('Content-Type', 'application/json'):用于设置请求头,告诉服务器请求体的格式。
//xhr.responseType = 'json':用于设置响应类型,告诉浏览器如何解析响应体。
xhr.responseType = 'arraybuffer'; // 设置响应类型为二进制数据
xhr.onreadystatechange = function(e){
if(xhr.readyState === 4){ // 整个请求过程完毕
//状态码是xhr.status不是statusCode
if(xhr.status >= 200 && xhr.status <= 300){
console.log(xhr.responseText) // 状态文本
console.log(xhr.resopnse)//拿到响应的结果
}else if(xhr.status >=400){
console.log("错误信息:" + xhr.status)
}
}
}
//用于初始化一个 HTTP 请求。这个方法并不发送请求,而是为后续的 request.send() 调用做准备
xhr.open('POST','http://xxxx')
xhr.send()

onload 是 XMLHttpRequest 的另一个事件处理函数,它仅在请求成功完成(即 readyState === 4 且 status 为成功状态码,如 200)时触发,更简洁,适用于只关心成功响应的场景。如果请求失败(如网络错误或服务器返回非 2xx 状态码),onload 不会被触发,而是会触发 onerrorontimeout

接受并处理服务端向客户端响应的数据结果

将处理结果更新HTML页面中

fetch

  • 也能发送ajax请求,且不需要借助xhr

  • 是浏览器内置的api,不需要额外下载

  • 和axios一样,也是基于promise的

  • 特点是关注分离,不能一步就拿到数据

  • 缺点是兼容性不好,部分老版本浏览器不支持这个api,所以fetch用的并不多,了解就好

  • fetch方法的参数:第一个参数是url,第二个参数是一个配置对象,用于自定义请求的行为,常见参数如下,由此可以看出:xhr的open方法的第一个参数是请求的方法,axios的常见写法axios.method也可以认为第一个参数是请求方法,而fetch方法的第一个参数却是url,请求方法在第二个参数中,属于配置属性之一。

属性名类型描述
method字符串请求方法,默认为 'GET'。常见的值包括 'GET''POST''PUT''DELETE' 等。
headers对象或 Headers设置请求头。例如:{ 'Content-Type': 'application/json' }
body字符串、Blob、FormData 等请求体数据,仅适用于非 GET 请求(如 POST、PUT)。
mode字符串请求模式,默认为 'cors'。常见值包括 'cors''no-cors''same-origin'
credentials字符串是否携带凭据(如 cookies)。常见值包括 'omit''same-origin''include'
1
2
3
4
5
6
7
8
9
10
11
//fetch返回值是一个promise对象
fetch('https://www.sanye.blog').then((res)=>{
//不能直接拿到数据,第一步先判断是否成功联系到服务器,输出res也看不到数据的踪迹
//也就是说不能通过第一个promise对象的值拿到数据
console.log('联系服务器成功',res)
//res.json()又会返回一个promise对象,返回的数据就是这个promise对象的值
return res.json()
}).then((res2)=>{
//输出,查看数据的结构
console.log(res2)
}).catch(err=>{console.log(err)})//最后调用catch,统一处理错误,至于为什么能捕获全部错误,原理不太清楚

当我们调用then方法的时候只传入成功回调的时候,借助async,await能让代码更简洁,并使用try-catch捕获错误。

1
2
3
4
5
6
7
8
//使用2次await
try{
const res = await fetch('https://www.sanye.blog')
const data = await res.json()
console.log(data)
}catch(err){
console.log(err)
}

axios

定义

axios 是一个基于promise的网络请求库,在浏览器端借助XHR,在node.js中借助http模块

有如下特点:

  • 拦截请求和响应
  • 转换请求数据和响应数据
  • 取消请求
  • 自动转换JSON 数据
  • 客户端支持防御XSRF

开始使用

在浏览器中可以通过script标签直接引入

1
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>

在node模块化开发环境中,可以通过npm包的形式下载,需要使用的时候再导入

1
npm install axios --S //安装到生产环境
1
import axios from 'axios'

实现一个简易版的axios

axios(config)

构建一个Axios类,核心代码为request方法。从上述代码中,我们可以看出axios其实就是使用promise+xhr实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Axios {
constructor() {
// 因为axios实例并没有什么常用的属性,所以这里没有任何初始化代码
}
// 核心方法request,会自动挂载到Axios.prototype上,传入配置对象,立即返回一个promise对象(状态为pending)
request(config) {
//request方法会立即返回一个promise对象
return new Promise((resolve,reject) => {
//对象解构赋值,获取到请求url,method(默认值是Get),data,并给这些属性赋予默认值
const {url = '', method = 'get', data = {}} = config; //实际请求携带的配置参数可能不止这么点
// 发送ajax请求,可以看出axios在浏览器中是基于xhr的
const xhr = new XMLHttpRequest();

//用于初始化一个 HTTP 请求。这个方法并不发送请求
//第三个参数是一个布尔值,表示是否异步执行请求,默认为true,表示异步。
xhr.open(method, url[, true]);
xhr.onload = function() {
//当请求被响应,根据响应状态码,改变promise对象的状态
console.log(xhr.responseText)
//调用resolve函数,修改Promise实例的状态为fulfilled, 修改value为xhr.response
//这一操作,就把异步回调函数内的值传递到外部了,避免依赖这个数据的代码,写在回调函数内
resolve(xhr.response);
}
xhr.onerror = function(){
reject(new error('请求失败'))
}
//发送请求并携带数据
xhr.send(data);
})
}
}

1
2
3
4
5
6
//创建一个axios实例
const context = new Axios({});
//定义一个axios函数
function axios(config) {
return context.request(config);
}

由于调用request方法会立即返回一个Promise对象,所以调用axios函数也会立即返回一个promise对象

axios.method()

下面是来实现下axios.method()这种形式的请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const methodsArr = ['get', 'delete', 'head', 'options', 'put', 'patch', 'post'];
//在Axios的prototype上挂载这些方法。这种写法功能等同于直接在Axios类中一个个定义这些方法(类似request方法)
//不过这种写法更简洁。
methodsArr.forEach(method => {
Axios.prototype[method] = function() {
// 处理只可能传入2个参数的请求方法
// 2个参数(url[, config])
if (['get', 'delete', 'head', 'options'].includes(method)) {
//此处的this指向axios实例,所以能调用request方法,同时也说明这些方法本质也是在调用request方法
return this.request({
method,
url: arguments[0],
...(arguments[1] || {})//如果第二个参数没传入,arguments[1]的值就是undefined,然后展开一个空对象
})
} else { // 3个参数(url[,data[,config]])
return this.request({
method,
url: arguments[0],
data: arguments[1] || {},//arguments[1]是一个数据对象,不需要展开
...arguments[2] || {}//arguments[2]是剩余配置属性对象,需要展开
})
}
}
})
  • arguments 是一个类数组对象,它包含了传递给函数的所有参数;arguments 对象允许你在不知道具体有多少个参数的情况下,访问所有传递给函数的参数,即便函数没有声明形参

  • get,post这些方法与request方法一样,都挂载到Axios.prototype

  • 这些方法本质是在调用Axios.prototype.request方法,并返回request方法的返回值,就如同axios函数一样

  • 无论是调用axios(),还是axios.get(),都会立即返回一个promise对象,因为它们本质都是在调用request方法然后立即返回值。

我们还期望axios函数能直接调用get,post这些方法,而不只是axios实例,所以我们还需要做其他处理

1
2
3
4
5
6
7
8
9
10
11
const context = new Axios({});
function axios(config) {
return context.request(config);
}

// 把 Axios 原型上的方法挂载到 axios 函数对象上(或者axios函数的原型上),这些方法包括request方法
Object.keys(Axios.prototype).forEach(key => {
if (key !== 'constructor') {
axios[key] = Axios.prototype[key];
}
});

把 Axios 原型上的方法,挂载到 axios 函数上(或者axios函数的原型对象上),这些方法包括request方法。在上述配置之后,就能使用axios函数调用get,post等方法了。

常见用法

在线文档关于axios的介绍说实话没有让人看下去的动力,这里写点自己的东西。

axios.create

调用axios.create传入一个配置对象,它的返回值具有与axios函数一样的功能,具体来说axios.create的返回值也是一个函数,而不是一个axios实例,可以像axios函数那样直接调用,也可以调用get,post等方法。

1
2
3
4
5
6
7
8
9
10
//request.js
import axios from 'axios'
const request = axios.create({
baseURL: 'https://smart-shop.itheima.net/index.php?s=/api',
timeout: 10000,
headers: {
'platform': 'H5',
}
})
console.log(typeof request)//function

这样就相当于为每个请求都配置了相同的基地址超时时间请求头,起到了封装的作用

添加拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
instance.interceptors.request.use(function (config) {
// 在发送请求之前做些什么
// 只要有token,就在请求时携带,便于请求需要授权的接口
// 每次请求都会获取token,也就是说token每次都是现用现取的,如果删除了就取不到了
const token = store.getters.token
if (token) {
config.headers['Access-Token'] = token
}
return config
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error)
})

// 添加响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码(response.status)都会触发该函数。
// 对响应数据做点什么
return response.data //默认会被包装成resolved类型的promise对象
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error) //如果直接返回error,也会被包装成resolved类型的promise对象
})

前面我们介绍过,无论是调用axios(),还是axios.get(),都会立即返回一个promise对象,因为它们本质都是在调用request方法。这个立即返回的promise对象的值和状态,由响应拦截器的返回值决定

  • 响应拦截器中有2个回调函数,响应成功的回调和失败的回调,同时只能调用一个
  • 如果最终返回的是一个普通的数据(非Promise对象),无论是哪个回调函数返回的,则这个立即返回的promise对象的值,就是这个返回的普通数据,状态变为fulfilled
  • 如果返回一个状态为rejected的promise对象,比如Promise.reject(error),无论是哪个回调函数返回的,则这个立即返回的promise对象的值,就变为error,状态变为rejected
  • 如果返回一个状态为fulfilled的promise对象,比如Promise.resolve(response.data),无论是哪个回调函数返回的,其效果其实就相当于return response.data。也就是说,最终的 Promise 将获得 response.data 作为其值,并进入 fulfilled 状态。
  • 无论是在成功的回调函数中,还是在失败的回调函数中,只要最终返回了Promise.reject(),那么 axios 请求的 Promise 的状态将变为rejected,并触发失败回调链,导致 .catch() 方法被调用;

取消请求

1
2
3
4
5
6
7
const source = axios.CancelToken.source();

axios.get('xxxx', {
cancelToken: source.token
})
// 取消请求 (请求原因是可选的)
source.cancel('主动取消请求');
  • axios.CancelToken是一个构造函数,用来获得取消令牌源对象。
  • source是一个取消令牌源对象。这个对象包含了两个重要的属性:
    • token: 这是一个实际的取消令牌。你可以将这个令牌传递给 Axios 请求配置中的 cancelToken 属性,从而使得该请求可以被取消。
    • cancel: 这是一个函数,调用它可以取消所有关联了source.token 的请求。你可以选择性地提供一个消息参数,这个消息会作为取消原因包含在取消事件中。调用source.cancel('取消原因') 时,它会将关联的 cancelToken 标记为已取消状态,并记录提供的取消原因(如 ‘取消原因’),这个操作不会直接发送网络请求,而是改变了令牌的状态

深入分析

1
2
3
4
const CancelToken = axios.CancelToken;
//获得取消请求源对象
const source = CancelToken.source();
console.log(source.token)//输出token,结构如下
1
2
3
4
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
source.cancel('主动取消')
console.log(source.token)

Axios 的响应拦截器会检查每个正在处理的请求,是否关联了被标记为已取消cancelToken。如果匹配,则立即停止该请求的进一步处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 给axios实例,添加响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 判断是否是因为取消操作导致的错误
if (axios.isCancel(error)) {
console.log('Request canceled:', error.message);
} else {
// 其他错误处理
console.log('请求失败,请稍后重试');
}
return Promise.reject(error);
})
  • 简单的来说,我们想要某个请求被取消,那么这个请求必须携带cancelToken
  • 当我们想要取消请求的时候,就调用source.cancel()方法并传入取消的原因,这个操作并不会发送新的请求,而是会修改cancelToken的状态,然后响应拦截器根据cancelToken的状态,判断不再需要处理这个请求
  • 所以说,请求取消,完全不需要后端配合,请求发送后无论如何都会被响应,取消请求只不过是抛弃了响应结果

使用axios实例发送请求

我们通常把同一业务功能的api放到一个js文件中,比如和购物车cart相关的接口,都放在cart.js文件中,在这些文件中引入导出的axios函数来发送请求。

1
import request from 'index.js'

发送请求有两种常用写法

request({})

这种写法是直接传入一个配置对象请求方法(method)等所有信息都包含在内,我们需要对大部分配置属性都熟悉

1
2
3
4
5
6
7
request({
url:
method:'post',
params:
data:
headers:
})

request.method()

这种写法是把请求方法提取到外面,然后传入多个参数来实现的。

第一个参数指定请求的 URL

第二个参数:

  • 如果是get/delete等请求,就是除了请求体外的配置属性,即不包括data属性的配置对象。
  • 如果是put/post请求,则是data,即请求体数据对象,所以说第二个参数到底是data还是不包括data属性的配置对象,取决于请求的方法。

第三个参数,只有put/post请求,可能需要配置第三个参数,即不包括data属性的配置对象

要注意的是,使用了这种写法:request.method(),再直接传入一个配置对象是不符合语法的,是错误的,必须按照上述的规则填写参数。

案例

1
2
3
4
5
6
7
import request from '@/utils/request'
//修改购物车商品信息(这里url是模板字符串,因为使用了path参数)
export const updateCartAPI = ({ skuId, selected, count }) => {
return request.put(`/member/cart/${skuId}`, { selected, count })//立即返回一个promise对象
}
//delete也要传入data,属于接口不符合规范
export const delCartAPI = (ids) => request({ url: '/member/cart', method: 'delete', data: { ids } })
  • 配置对象和接口文档的对应关系

    • path:需要在url中直接配置,嵌入在url的资源路径

      1
      /users/{userId}  --->  /users/123
    • query:在配置对象的params属性中配置,会被放到url的?之后,并且多个参数之间用与号 & 分隔

      1
      {name:"tom",age:18}  --->  /users/?name=tom&age=18
    • body:即请求体,在data属性中配置

    • header:在配置对象的headers属性中配置

  • 配置对象和请求报文的对应关系

    • header对应请求报文中的请求头
    • data对应请求报文中的请求体
    • method请求方法,资源路径,查询参数等出现在请求行中。

响应结果结构分析

在响应拦截器中,常常通过response.status来判断执行哪个函数,可以注意到statusdata是同一级别的数据

响应错误对象的response属性则有与响应成功对象一样的结构

文件上传怎么做

input标签

借助input标签,点击选择文件。

1
2
3
4
5
6
7
<input type="file" class="postImage">
<script>
const postImage = document.querySelector('.postImage')
postImage.addEventListener('change', function (e) {
console.log(e.target.files[0])//输出一个File对象
})
</script>

选择文件后可以通过e.target.files获取到文件对象File数组

为什么是files呢,因为如果我们给input标签添加multiply属性,是允许选择多个文件的,也就是多文件上传,不过这要求用户有一定的电脑操作基础,要知道如何选择多个文件。所以开发过程中,使用的方案其实是多次单文件上传,用一个数组存储每次循环的选择的文件对象。

File对象

常见属性

  • lastModified:文件上次被修改的时间,值是一个时间戳,在浏览器协商缓存中非常重要

  • size属性:表示文件的字节数(B),可用来限制文件的大小

  • type属性:表示文件的类型

file对象打印出来是这样的,其中包含的文件数据存储在哪儿?在浏览器环境中,File 对象中的文件数据并不是直接存储在 JavaScript 变量中,而是存储在浏览器的内存中或临时存储区域。具体来说,当你通过 <input type="file"> 选择一个文件后,文件的内容会被读取到浏览器的内存中。JavaScript 可以通过 FileReader API 或其他方法访问这些数据。

总的来说File 对象本身并不直接包含文件数据,而是提供了一个接口,来访问文件的信息和内容

slice

如果想处理大文件,可以使用 File.slice() 方法来分片读取文件内容。从而实现文件的分片上传。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 上传文件逻辑,使用fetch+AbortController实现
let start = 0;
let chunkSize = 1024 * 1024;
function uploadFile(file) {
const end = Math.min(start + chunkSize, file.size);
const chunk = file.file.slice(start, end);
// 当 `FormData` 包含文件(File)时,
// 会自动设置请求头 `Content-Type` 为 `multipart/form-data`,这是文件上传的标准格式
const formData = new FormData();
// 当第二个参数是 Blob 或 File 类型时,
// filename 参数会作为文件名写入请求头的 Content-Disposition 部分,
// 服务器可通过此字段识别原始文件名
formData.append("video", new Blob([chunk]), file.name);
fetch("http://localhost:8081/api/upload", {
method: "post",
body: formData,
})
.then((res) => {
if (!res.ok) {
//抛出异常
throw new Error("网络响应失败");
}
//如果文件没有上传完,则继续上传
start = end;
if (end < file.size) {
uploadFile(file);
} else {
console.log("文件上传完成");
file.status = "completed";
}
})
.catch((err) => {
console.log("上传过程中出错");
})
}

和Blob对象的关系

属于Blob类的子类,二者可以随意转换;

1
new Blob([file]); new File([blob],filename)

案例:将网络图片转换成File对象

二进制数据->Blob对象->File对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import axios from 'axios'
export const imageUrlToFile = async (url, fileName) => {
try {
// 第一步:使用axios获取网络图片数据
// img标签也默认会以二进制格式加载图片数据,但不会显式指定 responseType: 'arraybuffer'。
const response = await axios.get(url, { responseType: 'arraybuffer' }) //指定接收二进制数据流
const imageData = response.data//返回的是二进制数据

// 第二步:将图片数据转换为Blob对象
const blob = new Blob([imageData], {
type: response.headers['content-type']//这个表达式的值是"image/jpeg",确保文件类型正确识别
})

// 第三步:根据创建好的Blob对象,创建一个新的File对象
// blob.type的值:'image/jpeg'
const file = new File([blob], fileName, { type: blob.type })
// 返回的是一个promise对象
return file
} catch (error) {
console.error('将图片转换为File对象时发生错误:', error)
throw error
}
}
responseType含义
'json' (默认)自动将响应内容解析为 JSON 对象
'text'将响应作为字符串返回(UTF-8)
'arraybuffer'返回原始的二进制数据(ArrayBuffer),适合处理图片、PDF、音频、视频等
'blob'返回 Blob 对象,适合下载文件、创建对象 URL
'document'返回 HTML/XML 文档对象(主要用于 AJAX 加载网页)

简单来说,将网络图片转换成file对象,先要把这个图片下载下来,获得这个图片的二进制数据,然后再逐步转换成File对象。

输出的response格式如下:

其中的ArrayBuffer指的是

FileReader

故名思义,可以转换文件对象,比如可以把文件对象异步转换成base64格式

设你有一张 PNG 格式的图片,通过 data URL 和 Base64 编码的方式内联到 HTML 文件中,它可能看起来像这样:

1
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA..." alt="Embedded Image">

data:image/png表示数据的MIME类型,base64表示数据是否经过了 Base64 编码,如果数据未进行 Base64 编码,则应省略此部分。

这里的 "iVBORw0KGgoAAAANSUhEUgAAAAUA..." 就是该图片经过 Base64 编码后的字符串

1
2
3
4
5
6
7
8
9
10
11
12
//on-change事件,图片选择后触发该回调函数
const onUploadFile = (uploadFile) => {
//创建一个reader对象
const reader = new FileReader()
//uploadFile.raw的值是图片的文件对象File
reader.readAsDataURL(uploadFile.raw)
//把file对象转化成base64格式是*异步*的,监听reader的onload事件;需要在回调函数中拿到结果result
reader.onload = () => {
//把图片从File对象转换成base64格式的图片,可以用来展示和提交
imageUrl.value = reader.result
}
}

简要步骤如下:

  • 使用new FileReader()创建一个reader对象
  • 调用reader的 readAsDataURL,传入一个File对象,
  • 监听reader的onload事件,这个事件触发后,就代表转换完成,从reader.result中就能拿到base64字符串

Blob对象也可以使用FileReader的语法

URL.createObjectURL(file/blob)

  • URL.createObjectURL会生成一个指向 Blob 或 File 对象的临时 URL。这个 URL 可以被用作 <img>、<video>、<audio>、<a> 等 HTML 元素的 src 或 href 属性,用来展示

  • 允许在不暴露文件的实际路径(网络图片)或内容(比如base64格式的图片就会暴露内容)的前提下,显示文件,增加了安全性

  • 对象 URL 是临时的,浏览器会自动在页面卸载(比如页面更新)时释放这些 URL。举个例子,用户在当前页面拿到我的某个资源的临时URL后,他确实可以使用这个URL下载我的资源,但是一旦关闭或者刷新页面,这个URL就无效了。

  • 但是,为了确保最佳性能和避免内存泄漏,应该在不再需要时,显式调用 URL.revokeObjectURL

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
<body>
<label for="postImage">更换图片</label>
<input type="file" class="postImage" id="postImage">
<div><img src="" alt=""></div>
</body>
<script>
const postImage = document.querySelector('.postImage')
const img = document.querySelector('img')
postImage.addEventListener('change', function (e) {
const url = URL.createObjectURL(e.target.files[0])
img.src= url
}
</script>
1
blob:http://<origin>/<unique-identifier>
  • blob: 指定了这是一个 Blob URL scheme(格式)。
  • http://<origin>,当前页面的,例如 http://example.com。对于直接打开的页面,就是null
  • <unique-identifier> 是一个唯一标识符,用来区分不同的 Blob 对象。这个标识符是由浏览器自动生成的,保证在同一页面中每个通过 createObjectURL 创建的 URL 都是独一无二的。

FormData

1
2
3
4
5
6
7
8
9
10
11
12
// FormData` 的构造函数 **不接受普通对象(Object)作为参数**
// 只接受传入一个 HTML `<form>` 元素(用于自动收集表单数据)或者空构造。
const fd = new FormData()
// 添加键值对
fd.append('img', e.target.files[0])
axios({
url: "http://localhost:8080/upload",
method: 'post',
data: fd//直接当作请求体对象
}).then(result => {
console.log(result)
})
  • FormData 对象允许你构造一组键/值对,这组键/值对可以被轻松地序列化为 application/x-www-form-urlencodedmultipart/form-data 格式,非常适合用来模拟 HTML 表单提交

  • 使用 append() 方法可以向 FormData 对象中添加字段或文件。append方法还接收第三个参数,当你上传一个文件时,可以通过这个参数为上传的文件指定一个名称。如果没有提供这个参数,浏览器通常会使用 File 对象本身的 name 属性值作为文件名。

  • FormData 包含文件(File)时,axios 会自动设置请求头 Content-Typemultipart/form-data,这是文件上传的标准格式。

    即便是一个和FormData对象内容完全一致的不同对象也做不到这点。

图像展示方法

  • 拿到本地图片file对象,转换成base64格式的图片(由图片文件数据编码而来的一个字符串)
  • 拿到本地图片file对象,生成一个临时url(只能用来展示)
  • 网络图片链接,会自动发送一个请求获取图片

文件可上传格式

  • file/blob(二进制)
  • base64(即可展示又可上传,无敌了)

其他

src属性和href属性的区别

src属性

  • src(source 的缩写)属性主要用于嵌入外部资源到当前文档中。例如,图像、脚本、框架、音频、视频等元素。
1
2
<img src="image.jpg" alt="描述图片">
<script src="script.js"></script>

href属性

  • href(hypertext reference 的缩写)属性用于定义超链接的目标 URL。它可以出现在多种元素上,如 <a><link><area> 等,用来指向另一个网页、文件、同一页面内的不同位置、样式表、JavaScript 文件等

  • 对于 <a> 标签,它指定了用户点击链接后应导航到的位置;对于 <link> 标签,它通常用于引入外部资源,如 CSS 文件,告诉浏览器获取并应用这些资源来渲染页面。

区别

  • src 用于嵌入外部资源到文档中,而 href 则用于创建超链接或引用外部资源而不直接嵌入文档。简单来说,一个是嵌入外部资源到文档,一个链接外部资源到文档。
  • 使用 src 时,浏览器需要下载并处理资源,然后将其插入到文档流中,这个过程可能会暂停 HTML 解析(就是script标签);而使用 href 时,特别是对于 <link> 标签,浏览器可以异步加载资源,并且不会阻塞 HTML 解析器。