浏览器
以下内容参考视频:【干货】浏览器是如何运作的?_哔哩哔哩_bilibili
组成
用户界面:包括地址栏、前进/后退按钮、书签菜单、选项卡等用户直接交互的控件。用户通过界面输入网址或执行操作指令,而主窗口之外的区域均属于用户界面。
浏览器引擎
用于在用户界面
和渲染引擎
之间传递数据,类似一个桥梁,组成包括持久层
渲染引擎:组成包括
- HTML 解析器:负责读取 HTML 文档并构建 DOM 树。
- CSS 解析器:解析 CSS 代码,并应用到 DOM 元素上。
- 布局引擎:计算每个可见元素的位置和尺寸,即
重排
(reflow)过程。 - 绘制引擎:将
渲染树
转换为屏幕上的像素,即重绘
(repaint)过程。 - 合成器:如果有多个图层(例如,带有
transform
或opacity
的元素),则会将这些图层合成为最终的画面。
常见示例:
- Trident:ie浏览器内核
- Gecko(壁虎):火狐浏览器内核
- Webkit:苹果浏览器Safari的开源内核
- Blink:基于WebKit内核优化而来,谷歌,edge,opera浏览器的内核;webkit内核开源对浏览器的发展贡献还是挺大的。
js引擎:执行嵌入在网页中的 JavaScript 代码,如 V8(用于 Blink)、SpiderMonkey(用于 Gecko)等。
通常我们谈论浏览器内核
,特指的就是浏览器的渲染引擎
,但其实还可以认为,浏览器内核还包括js引擎等组件。
虽然渲染引擎
和JavaScript引擎
在功能上是分离的,但是js代码的执行会阻塞渲染,其中的原因之一就是执行js代码会暂停html文件的解析,因为js能够操作dom。
多进程
参考文章:javascript - 从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理 - 程序生涯 - SegmentFault 思否
- 浏览器是多进程的
- 浏览器之所以能够运行,是因为操作系统给它的每个进程都分配了资源(cpu、内存)
- 简单点理解,每打开一个Tab页,就相当于创建了一个独立的浏览器进程。感兴趣的可以自行尝试下,如果再多打开一个Tab页,进程正常会+1以上
浏览器都包含哪些进程?
Browser进程:也叫做浏览器的主控进程(负责协调、主控),只有一个。如果自己打开任务管理器,然后打开一个浏览器,就可以看到:任务管理器中出现了两个进程(一个是主控进程,一个则是打开Tab页的渲染进程),
作用有
- 负责浏览器界面显示,与用户交互。如前进,后退等
- 负责各个页面的管理,创建和销毁
- 将Renderer进程得到的内存中的Bitmap,绘制到用户界面上
- 网络资源的管理,下载等
第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建
GPU进程:最多一个,用于3D绘制等
浏览器渲染进程(Renderer进程,内部是多线程的):默认每个Tab页面一个渲染进程,互不影响。主要作用为
页面渲染,脚本执行,事件处理等
重点来了,我们可以看到,上面提到了这么多的进程,那么,对于普通的前端操作来说,最重要的是什么呢?答案是渲染进程
可以这样理解,页面的渲染,JS的执行,事件的循环,都在这个进程内进行,也就是说这个渲染进程是多线程的。那么接下来看看它都包含了哪些线程(列举一些主要常驻线程):
GUI渲染线程
- 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和CSSOM树,最终得到渲染树,然后进行布局和绘制。
- 当界面需要
重绘(Repaint)
或由于某种操作引发回流(reflow)
时,该线程就会执行 - 注意,GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时,GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中,等到JS引擎空闲时(一次事件循环的末尾)立即被执行。
- 为什么是互斥的呢?因为js可以操作dom,而渲染线程的工作就是渲染dom,如果dom已经渲染好了,然后发现dom被修改了,又得重新渲染。简单的来说,js线程和渲染线程可以同时操作dom,而对dom的操作应当是互斥的。
JS引擎线程
- JS引擎线程负责解析Javascript脚本,运行代码。
- JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中,无论什么时候都只有一个JS线程在运行JS程序,也就是说js是单线程的,具体原因参考后文:
《说说你对事件循环的理解》
- 同样注意,GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
事件触发线程
定时触发器线程:注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。
异步http请求线程
然而,一个渲染进程中的实际线程,真的是这些吗?非也!就拿渲染线程和JS线程来说,它们并不是独立的线程,而是同一个线程(主线程)的不同名称或职责描述
在浏览器的渲染进程中:
主线程(Main Thread) 是唯一负责
执行JavaScript代码
和处理渲染任务
的线程。职责:
- 执行JavaScript:解析并运行所有同步JS代码。
- 处理渲染任务:解析HTML/CSS、构建DOM/CSSOM树、布局、绘制等。
- 事件循环:管理宏任务、微任务队列,处理异步事件(如定时器、网络请求回调)。
特点:
主线程是单线程,所以同一时间只能执行JS代码或渲染任务中的一项。这就是为什么我们常说,**”js线程”和”渲染线程”是互斥的**。
如果JS代码长时间运行(如复杂循环),会完全阻塞渲染任务,导致页面无响应或渲染停滞。
渲染线程 和 JS线程 是对主线程不同职责的描述,而非独立线程。
参考文章:javascript - JS阻塞渲染,这么多年我理解错啦? - 个人文章 - SegmentFault 思否
关于”js线程”和”渲染线程”是互斥的更多原因,参考:浏览器中的JS引擎和渲染线程在主线程上互斥机制 - Felix_Openmind - 博客园
既然浏览器的渲染线程和js线程并不是单独存在的线程,所以就不存在所谓的渲染线程和js线程切换,本质是主线程内任务交替执行,
浏览器通过事件循环(Event Loop) 实现任务调度:
1 | // 简化的主线程任务流程 |
简单的来说,就是在每次事件循环的末尾 ,都会检查是否有渲染任务,如果有则执行渲染任务。
1 | <body> |
执行同步任务:
document.getElementById('box').style.width = '200px';
,这是一个修改样式的操作,在渲染任务队列中添加一个任务执行阻塞3s的同步任务
同步开启定时器,1s后将对应的回调函数纳入宏任务队列
同步调用
requestAnimationFrame
无需要执行的微任务
一次事件循环结束,检查是否有待执行的渲染任务,发现确实存在,开始执行渲染任务:
requestAnimationFrame
传入的回调函数会在下次重绘前执行,具体时间点是进行样式计算
前,所以输出渲染完成时元素宽度: 200
。- 进行样式计算(Recalculate Style): 是指浏览器根据当前的 DOM 树 和 CSSOM 树,重新计算所有元素的最终样式(如颜色、边距、字体等)。算结果会生成一个 渲染树(Render Tree),其中包含所有可见元素及其最终样式
- 进行回流
- 进行重绘
查找宏任务队列,开启下一个宏任务
输出
长任务完成
可以看到上述例子中,由于同步任务持续了3s,此次事件循环也被延长了3s,所以对样式的修改被延迟到3s后才应用到页面。
为了更好的体现js执行对渲染的阻塞,可以打开开发者工具,在控制台输入:
1 | document.getElementById('box').style.width = '100px'; |
并执行,发现3s后样式才发生变化
WebWorker,JS的多线程?
前文中有提到JS引擎是单线程的,而且JS执行时间过长会阻塞页面,那么JS就真的对cpu密集型计算无能为力么?
所以,后来HTML5中支持了Web Worker
。
- 创建Worker时,JS引擎向浏览器申请开一个子线程(子线程是浏览器开的,完全受主线程控制,而且不能操作DOM)
- JS引擎线程与worker线程间通过特定的方式通信(postMessage API,需要通过序列化对象来与线程交互特定的数据)
所以,如果有非常耗时的工作,请单独开一个Worker线程,这样里面不管如何翻天覆地都不会影响JS引擎主线程,
只待计算出结果后,将结果通信给主线程即可,perfect!
而且注意下,JS引擎是单线程的,这一点的本质仍然未改变,Worker可以理解是浏览器给JS引擎开的外挂,专门用来解决那些大量计算问题。
dom树和渲染树
dom树上的结点包含不可视结点
,而渲染树上不包含;渲染树上包含伪元素结点
,但是dom树上不包括,因为伪元素不是html结构的一部分。简单来说,区别在于不可视结点
和伪元素
。
在script标签中添加async和defer有什么作用和区别
默认情况下,当HTML解析器
遇到一个<script>
标签(无论是内联脚本还是外部脚本),它会暂停HTML文档的解析,转而去加载
和执行
这个脚本,也就是说默认情况下,scrpt标签的加载和执行都会阻塞html的解析,也就是dom树的构建。
为什么要等到js执行完毕后,才继续构建dom呢?
- 我们知道dom树的构建和js执行,都在主线程上运行,主线程是单线程的,所以这2个工作不能同时执行,执行js的时候,就不能继续构建dom的工作。
- 除此之外,如果js执行和dom树的构建是并行的,可能js执行捕获,修改dom元素的操作的时候,可能出现对应的dom元素并没有被创建的情况。
好的,既然js代码执行和dom树的构建不能同时执行,那为什么默认选择先执行js代码呢?
其实怎么选择执行顺序不重要,反正都是串行执行,顺序反过来也不会快点,只不过默认情况下,选择执行先执行js,这样的目的是防止下面的情况:
1
document.write('<div>新内容</div>'); // 必须暂停DOM解析,否则后续HTML会被覆盖。
如果想要先构建DOM树,在script标签上添加defer属性即可。
我们再思考一下,js执行阻塞dom构建,是不是就是我们常说的”阻塞渲染”呢?严格来说,并不是,DOM树的构建是解析HTML的直接结果,属于浏览器处理HTML的输入阶段,它仅负责将HTML文本转换为结构化的对象模型,但此时还不涉及样式或可视化信息,渲染任务需要DOM和CSSOM都完成后才能开始。
案例
1 | <script src="script1.js"></script> |
在这个例子中,浏览器首先会尝试加载并执行 script1.js
。只有当 script1.js
被成功加载并执行完毕后,才会开始加载 script2.js
。同样地,直到 script2.js
也被加载和执行完毕,浏览器才会继续解析剩下的 HTML 并渲染 <p>
标签中的文本“Hello, world!”。
然而对于添加了async,defer属性的script标签,它们加载脚本是异步的且是并行的,也就是说此时加载js文件,不会阻塞html文档的解析,也就是不会阻塞dom树的构建;
async
async 属性用于告诉浏览器脚本是异步,并行下载的,即这个加载过程不会阻塞html的解析。
脚本下载完成后立即执行
,不保证脚本的执行顺序,如果html标签未解析完毕,可能阻塞html解析
适用于独立的脚本,如第三方分析脚本,这些脚本不需要等待其他脚本执行完毕。
defer
defer 属性用于告诉浏览器脚本应该延迟
到整个html解析完成后再执行,也是异步下载,不阻塞html解析,所有脚本在html解析完成,即DOM树构建完毕后,但DOMContentLoaded
事件触发前(没错就是在这个事件前),按引入顺序执行。适用于依赖于 DOM 的脚本,如需要操作 DOM 的脚本,这些脚本需要确保 DOM 已经完全加载。举例:vue2项目打包后,也是通过defer的方式加载js文件
1 | <head> |
如果不使用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尚未普及,单线程更符合当时的硬件条件。
拓展
我们已经知道js是单线程的,那我们再思考一下,js是一门解释型语言(比如python)
还是编译型语言(比如java,c/c++)
?
解释执行:
- 在早期,JavaScript 主要通过解释方式执行。浏览器加载网页上的 JavaScript 代码后,JavaScript 引擎会读取并解释这些代码,然后一行一行地执行。
即时编译(JIT):
现代JavaScript引擎(如V8、SpiderMonkey)采用即时编译技术,将JavaScript代码编译为机器码后再执行,以提升性能。
流程:
解析:将源代码解析为抽象语法树(AST)。
编译:将AST编译为字节码(如V8的Ignition解释器处理字节码)。
优化:对频繁执行的代码(如循环)进行即时编译,生成更高效的机器码(如V8的TurboFan编译器)。
结论:JavaScript既非纯解释型,也非纯编译型,而是两者的结合。
同步与异步任务
首先,JavaScript
是一门单线程的语言,意味着同一时间内只能做一件事,这样就存在线程阻塞的问题,
而解决阻塞的方法就是将任务划分为同步任务
和异步任务
- 同步任务:立即执行的任务,同步任务一般会直接进入到
主线程
中执行 - 异步任务:异步执行的任务,比如
ajax
网络请求,setTimeout
定时函数等,交给宿主环境
去执行,时机成熟后放入任务队列
中
微任务与宏任务
异步任务
又可以细分为微任务
和宏任务
,任务队列也被划分为微任务队列和宏任务队列。
什么是微任务,什么是宏任务?
宏任务是指时间粒度
比较大的任务(就是js操作比较多,需要更多的时间来执行,无法迅速完成的任务),微任务则反之。常见的宏任务有setTimeout()
,常见的微任务有Promise.then()
在执行下一个宏任务之前,会先查看微任务队列中
是否有需要执行的微任务
,如果有则先把微任务执行完,再开启新的宏任务。
事件循环
宏任务
是事件循环的基本单位,一个宏任务中可以同时包含同步任务
,宏任务
,微任务
;
事件循环
指的是,js引擎先执行宏任务
中包含的同步任务
,再查找并执行微任务队列中的所有微任务,再查找宏任务队列,开启新的宏任务,如此循环往复的过程。
1 | console.log("script start"); |
宏任务:执行整体代码(相当于
<script>
中的代码,整体是一个宏任务):- 输出:
script start
- 遇到 setTimeout,立即开启延时器,时机成熟后,将传入的回调函数加入宏任务队列
- 遇到第一个then,同步调用它,因为promise状态是确定的,所以立即将
console.log("promise1")
加入微任务队列 - 遇到第二个then,同步调用它,因为第一个then方法的回调未执行,所以
console.log("promise2")
不会加入微任务队列 - 输出:
script end
- 输出:
微任务:清空微任务队列(promise1)
- 输出:
promise1
,之后产生一个微任务(因为第一个then方法返回的promise的状态,要根据传入的回调函数返回值确定,所以,只有第一个then中的回调函数被执行了,第二个then方法传入的回调函数才能被执行,才能加入微任务队列),当前微任务队列(promise2) - 输出:
promise2
- 微任务队列清空
- 输出:
执行渲染操作,更新界面(敲黑板划重点)。
举个例子:
1
document.querySelector('.container').innerHTML = '你好啊'
当执行上述代码,修改了DOM元素的
innerHTML
后,DOM本身确实是被修改了,但是浏览器不会立刻进行重绘,将对DOM的修改更新到视图。相反,它会将这些更改记录下来,放入渲染队列,在下一个事件循环开始之前,检查是否有渲染任务,有则执行相应的渲染操作。执行下一个宏任务:运行
console.log("setTimeout")
参考文章:程序员 - 一次搞懂-JS事件循环之宏任务和微任务 - 个人文章 - SegmentFault 思否
async与await
async
是异步的意思,await
则可以理解为等待
放到一起可以理解async
就是用来声明一个异步方法,而 await
是用来等待异步方法执行
async
无论如何,async
函数返回的总是一个promise
对象,下面两种方法是等效的
1 | function f() { |
简单的来说async函数的返回值会被Promise.resolove
包装。
await
正常情况下,await
命令后面是一个 Promise
对象,返回该对象的结果。如果不是 Promise
对象,就直接返回
对应的值
1 | async function f(){ |
1 | async function f() { |
不管await
后面跟着的是什么,await
都会阻塞后面的代码,后面的代码成为异步任务,如果阻塞的是是同步代码就成为微任务
。
1 | async function fn1 (){ |
上述输出结果为:1
,fn2
,3
,2
综合例题
1 | setTimeout(function () { |
最终输出 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树已经构建完成,但像图片和样式表,这些外部资源可能还没有加载完毕。这时候页面已经可以交互了,因为DOM已经就绪。
1 | document.addEventListener('DOMContentLoaded', function() { |
load
则是在所有资源(包括图片、样式表等)都加载完毕后才触发(只等待资源加载,不等待资源解析
),要注意的是,load
事件并不等待所有的异步请求完成,比如通过 JavaScript 发起的 AJAX 请求或 Fetch API 请求,甚至不等待动态加载的内容(如通过 JavaScript 动态插入的图片或其他资源),为什么要等待呢?,load事件怎么知道你什么时候插入图片或者其他资源,如果你始终不这么操作,load难不成还一直等待你?
1 | window.addEventListener('load', function() { |
需要值得注意的是,首屏渲染,只需要等待html标签解析完毕,构建好dom树,等待ss文件加载并解析完,生成cssom树之后,就可以进行,换句话说,首屏渲染不等待图片资源。
unload和beforeunload
当用户导航至其他页面(新页面在本窗口打开)、关闭当前标签页或窗口、或者刷新页面时,都会触发 unload
事件,但是我们常用的其实是beforeunload
,即在页面卸载前做些什么,因为`unload事件触发的时候页面已经被卸载了,我们做任何操作都没用了。
1 | window.addEventListener('beforeunload', function (e) { |
当用户刷新页面的时候,浏览器会提示是否刷新站点;当用户跳转到其他页面的时候,提示是否进行页面跳转。
说说js的数据类型
js的数据类型可以分为两类,基本数据类型和引用数据类型
基本数据类型
基本数据类型主要有6种:Number,String,Boolean,Symbol,Null,Undefined
,后来又添加了一种叫做BigInt
,所以说基本数据类型就有7种。
Number
最常见的整数
类型格式则为十进制,还可以设置八进制(零开头)、十六进制(0x开头)
1 | let intNum = 55 // 10进制的55 |
浮点类型
则在数值中必须包含小数点
,还可通过科学计数法表示。
1 | let floatNum1 = 1.1; |
格式化
关于浮点数有一个重要的知识点就是格式化
使用
toFixed()
方法表示保留几位小数,要注意的是不是
format
方法,在js中不是使用这种方法格式化浮点数1
2let num = 123.456;
console.log(num.toFixed(2)); // 输出 "123.46" - 四舍五入到两位小数toFixed()
返回的是一个字符串
,而不是数字。如果需要进行进一步的数值计算,你可能需要将其转换回数字类型
。使用
toPrecision()
方法1
2
3let num = 123.456;
console.log(num.toPrecision(3)); // 输出 "123"
console.log(num.toPrecision(5)); // 输出 "123.46"这个方法表示保留几位有效数字,并且会根据需要,自动调整数字的表示形式(科学记数法或固定点表示法)
Number.prototype.toLocaleString()
toLocaleString()
可以用于获取特定地区的数字格式,包括货币、百分比和日期格式等。对于浮点数格式化,它可以用来设置小数位数和使用逗号作为千位分隔符等。1
2let num = 1123.456;
console.log(num.toLocaleString())//输出1,123.456
NaN
在数值类型中,存在一个特殊数值NaN
,意为“不是数值”,用于表示数值运算操作失败了,而不是抛出错误
1 | console.log(0/0); // NaN |
存储空间
在 JavaScript 中,变量的声明方式(var
、let
或 const
)不会影响其占用的内存大小。内存占用主要取决于变量存储的数据类型,而不是声明关键字本身。
Number
8 字节(64 位),因为所有数字都以双精度浮点数形式存储。
1
2let 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; // 占用少量固定内存
undefined
和null
:通常占用4 字节或更少。1
2let x; // undefined,占用少量固定内存
const y = null; // 占用少量固定内存
存储一个ip地址,如何实现存储空间最小?在c语言中,一个字符char,占用一个字节,用字符串存储一个ip地址,最多占用3*4
+3(3个分隔符)=15个字节,但是因为ip地址每位的范围是0-255
,用一个字节就能存储,所以最多大概只需要占用4字节,而如果使用int类型的存储,则需要4*4
=16字节,反而比使用字符串存储占用更多的空间。
String
字符串使用双引号(”)、单引号(’)或反引号(`)表示都可以,反引号表示的是模板字符串,模板字符串和普通字符串有什么区别呢?
在模板字符串中可以嵌入变量,这是模板字符串最常见的用法
1 | let name = 'tom' |
在 模板字符串中,会保留字符串中的所有空白字符,包括空格、制表符(\t
)和换行符(\n
)。
这种特性,使得模板字符串非常适合用于生成多行文本或格式化的字符串内容。这与普通字符串(使用单引号 '
或双引号 "
)不同,普通字符串不会自动保留换行和缩进,必须手动添加换行符(\n
)。
1 | // 普通字符串 |
在js中,字符串是不可变
的,意思是一旦创建,它们的值就不能变了。因为虽然字符串是基本数据类型,但实际存储在堆中,栈中保存的是引用。
1 | let lang = "Java";//这行代码会在内存中创建一个包含 "Java" 的字符串对象,并将引用赋值给变量 lang。 |
字符串比较
场景 | == 结果 | === 结果 | 原因 |
---|---|---|---|
2个原始字符串:let a = ‘123’, b=’123’ | true | true | a,b都是基本数据类型中的字符串,又因为引擎会将相同的字符串字面量(如 '123' )存储为同一个堆内存地址,而非创建多个实例。这样可以节省内存并提高性能。因此,a 和 b 实际上指向了同一个堆内存地址,所以a,b的值也是相同的,因此 a === b 返回 true |
原始字符串 vs String 对象:let a = ‘123’, b=new String(‘123’) | true | false | === 比较的结果为false,因为a,b不是同一数据类型;== 比较的结果是true,是因为b.valueof 的值就是字面量字符串123 的引用。 |
两个 String 对象:let a = new String(‘123’), b=new String(‘123’) | false | false | a,b的数据类型虽然相同,但是由于a,b是2个不同的对象,所以a,b存储的引用并不相同,严格比较和非严格比较的值都是false |
Boolean
Boolean(布尔值)类型有两个字面值: true
和false
通过Boolean
可以将其他类型的数据转化成布尔值
数据类型 | 转换为 true 的值 | 转换为 false 的值 |
---|---|---|
String | 非空字符串 | “” |
Number | 非零数值(包括负数) | 0 、 NaN |
Object | 任意对象 | null |
Undefined | N/A (不存在) | undefined |
Symbol
Symbol
关键字的主要用途,是用来创造一个唯一
的标识符,用作对象属性,确保不会产生属性冲突
。
1 | let genericSymbol = Symbol(); |
传入符号主要为了标识,符号相同并不代表值也相同
1 | let fooSymbol = Symbol('foo'); |
可枚举性
Symbol类型的键默认是可枚举的,通过对象字面量
或常规赋值
添加的属性,默认都是可枚举的
1 | const sym = Symbol(); |
通过Object.defineProperty
定义的属性,其可枚举性才默认为false,无论是字符串键还是 Symbol 键,均可通过 Object.defineProperty()
显式设置 enumerable: true/false
,顾名思义,这个方法就是用来定义,修改属性的,而且每次只能修改一个属性。
1 | //第一个参数指明要修改哪个对象,第二个参数指明要修改这个对象的哪个属性,第三个参数指明如何修改这个属性 |
在某些方法中不被考虑
Symbol 类型的键,并且不会出现在for...in
循环中,也不会被Object.keys()
方法返回,因为这两种方法只考虑字符串类型的键,并不是因为使用Symbol 类型做为键的属性就是不可枚举的。
1 | // 创建一个 Symbol |
Symbol 类型的键和值,都不会包含在序列化
的结果中。
1 | // 创建一个 Symbol |
Object.assign
Object.assign
会把Symbol
类型的键也拷贝进,因为Symbol类型的键默认也是可枚举的
1 | // 创建一个 Symbol |
Null
Null类型同样只有一个值,即特殊值 null
逻辑上讲, null 值表示一个空对象,这也是给typeof
传一个 null
会返回 "object"
的原因。
1 | let car = null; |
Undefined
Undefined
类型只有一个值,就是特殊值 undefined
,如果一个变量声明了但是未被赋值,那么这个变量的值就是undefined。
1 | let message; // 这个变量被声明了,只是值为 undefined |
引用数据类型
引用数据类型有多种,引用数据类型统称为Object
,所以一般不会问有几种 ,一般只问基本类型有几种。
引用数据类型主要包括以下三种:
Array
js数组是一组有序
的数据,但跟其他语言不同的是,数组中每个槽位可以存储任意类型
的数据。并且,数组也是动态大小
的,会随着数据添加而自动增长。
通常通过字面量表示法
创建数组
1 | let colors = ["red", 2, {age: 20 }] |
或者通过Array
来创建数组,给数组分配大小固定,连续的空间,内部默认没有元素;可以调用数组的fill
方法填充数组,比如arr.fill(0)
;
1 | const arr = new Array(4) |
虽然说数组大小是固定的
,但是还是可以往数组中加入元素,改变数组的大小,不过新加入的元素放在已分配空间之后
。
创建二维数组
1 | const arr = new Array(4)//创建一个长度为4的数组,虽然创建的时候指定了长度,但是长度还是可以变化的 |
上述代码可简写为:
1 | const arr = new Array(4).fill(0).map( ele => new Array(4).fill(0)) |
可以看出在js中创建二维数组还是挺麻烦的。
Function
函数实际上是对象
,每个函数都是 Function
类型的实例,而 Function
也有属性和方法,跟其他引用类型一样,其中最常见的属性比如prototype
。
其他类型
除了上述说的2种之外,还包括Date
、RegExp
、Map
、Set
等,他们都是Object
类型的子类
。
区别

基本类型的变量的值,直接存储在栈内存中,引用数据类型变量的值,存储在堆内存中,但是栈内存中存储了它们的引用。
当基本数据类型的值被作为参数,传递给函数,变量时,实际上是将该值的一个副本传给了它们。
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
14let 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
3let colors = []; // 创建一个数组
let count = colors.push("red", "green"); // 推入两项
console.log(count) // 2unshift()
:也是可以传入任意个数的元素,这些元素会被添加到数组的首部,返回新数组的长度
,会修改原数组。1
2
3
4let 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
4let 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
4let 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
4let colors = ["red", "green"]
let item = colors.pop(); // 取得最后一项
console.log(item) // green
console.log(colors.length) // 1shift()
:法用于删除数组的第一项,同时减少数组的length
值,返回被删除的项
1
2
3
4let colors = ["red", "green"]
let item = colors.shift(); // 取得第一项
console.log(item) // red
console.log(colors.length) // 1splice()
:第一个参数传入开始位置,第二个参数传入要删除元素的个数,返回包含被删除元素的数组,如果,没有任何元素被删除,则返回空数组。1
2
3
4let 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
6let 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 | let colors = ["red", "green", "blue"]; |
查
一般也是通过下标来查找数组元素。
indexOf()
:传入一个元素,返回数组中第一个
与该元素相等的元素的下标
,使用的是严格比较
,如果数组中没有该元素,则返回-1
,因为NaN
不与任何数相等,所以indexOf(NaN)
返回值必定为-1
。其实这个方法特别语义化,indexOf(元素)
意思不就是某个元素的下标吗。1
2
3let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1,NaN];
numbers.indexOf(4) // 3
console.log(numbers.indexOf(NaN)) // -1includes()
:判断某个元素是否在数组中存在,也是严格比较,存在返回true
,否则返回false
。对NaN
做了特殊处理,能判断是它否存在于数组中,就这一点而言,是比indexOf要强大的。1
2
3let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1,NaN];
numbers.includes(4) //true
numbers.includes(NaN) //返回truefind()
:传入一个返回值是布尔类型
的回调函数,用于判断满足某个条件的元素是否存在,存在则返回第一个符合条件的元素,不存在则返回undefined
,通常用于判断对象数组
中是否存在某个对象。1
2
3
4
5
6
7
8
9
10
11const 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
3let values = [1, 2, 3, 4, 5];
values.reverse();
alert(values); // 5,4,3,2,1sort()
:给数组排序,sort()
方法接受一个比较函数,用于判断哪个值应该排在前面,用的是非常多,特别在算法题里1
2
3
4
5
6
7
8function 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 | let colors = ["red", "green", "blue"]; |
迭代方法
some()
:传入一个返回值为布尔值的回调函数,作为判断条件,如果数组中存在
满足条件的元素,则该方法返回true,否则返回false。要注意千万不要把这个方法写成any
,数组并没有any
方法,这是Promise的静态方法。every()
:传入一个返回值为布尔值的回调函数,作为判断条件,如果数组中每个
元素都满足条件,则该方法返回true,否则返回false。注意千万不要把这个方法写成all
,数组中并没有all
方法,这是Promise的静态方法。forEach()
:遍历数组中的每个元素,并执行一定操作,可以修改
原数组。1
2
3
4let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];
numbers.forEach((item, index, array) => {
// 执行某些操作
});filter()
:传入一个返回值为布尔值的回调函数,作为判断条件,返回一个数组,这个数组包含所有满足这个判断条件的元素。无论原数组是否包含满足条件的元素,filter
总是会返回一个新的数组。如果没有找到任何满足条件的元素,则返回的是一个空数组[]
。1
2
3let 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,3map()
:根据传入的回调函数和数组中的每一个元素,并返回一个新的数组。要注意的是,传入的回调函数虽然也是需要有返回值的,就如同filter,some,every,但是不同的是,传入map方法的回调函数的返回值并不是一个布尔值,而是通过每个数组元素计算得到的新的值。1
2
3let 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 | let stringValue = "hello "; |
slice() substr() substring()
作用是返回字符串的切片
1 | let stringValue = "hello world"; |
数组中也有
slice()
方法可以看出
slice()
和substring()
的用法是一致的,当传入两个参数的时候,分别表示的是截取的左右区间(左闭右开,目前就没见到过左闭右闭的情况,除了正则表达式中)而
substr()
传入两个参数时,第一个表示参数起始位置,第二个参数表示的是要截取的元素的个数
。当只传入一个参数,三者的效果是相同的。
indexOf() startWith() includes()
indexOf
:从字符串开头
去搜索传入的字符串,并返回位置(如果没找到,则返回 -1 ),数组中也有这个方法,也许因为字符串本来就可以看成字符数组。
1 | let stringValue = "hello world"; |
startWith()
:判断字符串是否以某个字符串开头,返回值为布尔类型。
includes()
:判断字符串中是否包含某个字符串,返回值是布尔类型,数组中也有这个方法。
1 | let message = "foobarbaz"; |
由此可见,无论是数组还是字符串中,都有indexOf,includes,slice,concat方法
字符串拆分
把字符串按照指定的分割符,拆分成字符数组,特别是当传入''
,即空字符的时候,是真正意义上的把字符串拆分成字符数组,不会包含空字符。
1 | let str = "12+23+34" |
模板匹配
提及字符串,就不得不提到模板匹配,提起模板匹配就不得不提起正则表达式
,会在后面介绍。
match()
接收一个参数,可以是一个正则表达式字符串,也可以是一个RegExp
对象(正则表达式对象),如果你传递一个非正则表达式对象,它会被隐式转换为正则表达式;返回值是数组。
非全局匹配(传入的正则表达未加修饰符g
):只会匹配第一个符合条件的字符串片段,下面给出一个例子
1 | let text = "cat, bat, sat, fat"; |
匹配成功的返回结果,是一个数组,但是这个数组并不是传统意义上的数组,因为它的键不全是数字,包含第一个匹配的字符串片段
和更多信息
。不得不说,在js中,有的数组是真像对象,但它就是数组,有的对象也是真的像数组(伪数组),但就是对象。因为在js中,数组本质就是一个对象。

index
属性:匹配结果在字符串中的开始位置input
属性:原始字符串
如果匹配失败则返回null
全局匹配
返回所有符合条件的字符串片段,并以数组的形式给出,例子如下:
1 | let text = "cat, bat, sat, fat"; |
匹配成功的返回结果,只包含符合条件的字符串片段
。

如果匹配失败则返回null
search()
1 | str.search(regexp) |
str
是要进行搜索操作的字符串。regexp
是一个正则表达式对象。如果你传递一个非正则表达式对象(例如,一个字符串),它将被隐式转换为一个正则表达式对象。- 如果找到匹配项,
search()
方法返回第一个匹配项
的首字符
的下标。 - 如果没有找到匹配项,
search()
方法返回-1
。 - 是否给传入的正则表达式添加修饰符
g
,对结果没有影响。 - 简单的来说
search
返回的就是第一个被匹配的字符串片段的下标
下面举个例子:
1 | let text = "cas, bat, sat, fat"; |
replace()
replace()
方法用于在字符串中查找匹配的子字符串,并用新的子字符串替换它,这个方法不会改变原始字符串,因为JavaScript中的字符串是不可变的,它会返回一个新的字符串作为结果。
1 | str.replace(regexp|substr, newSubstr|function) |
regexp
(正则表达式):一个RegExp
对象或者其字面量,标识要查找的子字符串。全局搜索需要使用g
标志。substr
(字符串):将被替换的子字符串。newSubstr
(字符串):新子字符串,用于替换匹配项的字符串。function
(函数):用于创建新子字符串
的函数,所以要有返回值,该函数将被每一个匹配项调用。
1 | let str = "Hello world!"; |
1 | let str = "Hello world! Welcome to the world of programming."; |
1 | let str = "Hello World! Welcome to the world of Programming."; |
1 | let str = "20 apples, 15 bananas, and 3 cherries"; |
区别
match方法返回的是一个数组(无论是否是全局匹配),search方法返回的是下标,replace方法返回的是修改后的字符串。
说说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]
:匹配任何一个不在a
、b
、c
范围内的字符
要注意的是,无论字符组中有多少个符号,匹配的都只是一个字符。
分组/捕获组
小括号可以将多个字符或子表达式组合在一起,形成一个逻辑单元
1 | const pattern = /(ab)+/; |
(ab)
将ab
视为一个整体,而不是一个单独的字符。+
表示匹配这个整体一次或多次。- 匹配结果是整个字符串
"ababab"
,而第一个捕获组的结果是"ab"
。
小括号会创建一个捕获组,用于提取匹配的部分内容。每个捕获组的内容可以通过 match()
方法的返回值中的数组访问。
1 | const pattern = /(\d{4})-(\d{2})-(\d{2})/; |
其实上述正则表达式不使用捕获组,也能匹配到str,如果不使用捕获组,结果数组中也就不会有捕获组。
量词
*
:0 次或多次+
:1 次或多次?
:0 次或 1 次{n}
:精确匹配 n 次{n,}
:>=n
次{n,m}
:n 到 m 次,左闭右闭
总结:在js中的正则表达式中,中括号表示字符组,只匹配一个字符;大括号表示量词,表示匹配多少次,而小括号则表示一个分组或者匹配组。
常用方法
test()
1 | /hello/.test('hello world'); // true |
回布尔值,判断是否匹配成功
exec()
1 | /(\d+).(\d+)/.exec('3.14'); // ['3.14', '3', '14', index: 0, ...] |
不使用全局标志的时候
调用一个正则表达式的exec方法,并传入一个字符串,效果完全等同于:调用一个字符串的match方法并传入一个正则表达式
使用全局标志
每次调用
exec()
都会从上一次匹配结束的位置,开始寻找下一个匹配项,这一点字符串的match方法就不同了,真难记啊。
修饰符
i
:不区分大小写g
:全局匹配(查找所有匹配项)m
:多行模式(^
和$
匹配每行的开头和结尾)s
:dotAll 模式(.
匹配换行符)u
:Unicode 模式y
:粘性匹配(从lastIndex
开始匹配)
Object的常见静态方法
Object.keys()
Object.keys()
方法,用于返回一个对象自身可枚举属性组成的数组。它只返回对象自身的属性(不包括原型链上的属性),并且这些属性必须是可枚举的。
1 | const obj = { a: 1, b: 2, c: 3 }; |
1 | const obj = {}; |
我们还经常使用for in来获取一个对象所有的可枚举属性,但是它与Object.keys()
方法不同的是,还能获取原型链上的可枚举属性,所以在某些情况还需要借助hasOwnProperty
来判断是不是自身的属性。
Object.values()
Object.values()
方法,用于返回一个对象自身可枚举属性的值组成的数组。它只返回对象自身的属性(不包括原型链上的属性),并且这些属性必须是可枚举的。
1 | const obj = { a: 1, b: 2, c: 3 }; |
Object.entries()
Object.entries()
方法,用于返回一个对象自身可枚举属性的键值对数组。每个键值对是一个包含两个元素的数组:第一个元素是属性名(键),第二个元素是对应的属性值。
1 | const obj = { a: 1, b: 2, c: 3 } |
1 | const arr = ['x', 'y', 'z']; |
Object.assign()
只会拷贝对象中可枚举的自有属性,不会拷贝其继承自原型链上的属性,返回值就是传入的第一个对象,传入的第一个对象会被修改。
1 | var obj = { |
Object.defineProperty
Object.defineProperty
是 JavaScript 中用于定义或修改对象属性的底层方法
1 | Object.defineProperty(obj, prop, descriptor); |
obj
: 要定义属性的目标对象。prop
: 要定义或修改的属性名称(字符串或 Symbol)。descriptor
: 属性描述符对象,用于定义属性的行为。descriptor
是一个对象,可以包含以下键值对:
数据描述符
属性名 | 描述 |
---|---|
value | 属性的值,默认为 undefined 。 |
writable | 是否可以修改属性的值,默认为 false (即不可写)。 |
enumerable | 是否可以通过 for...in 或 Object.keys() 枚举该属性,默认为 false 。 |
configurable | 是否可以删除该属性或修改其描述符,默认为 false 。 |
存取器描述符
属性名 | 描述 |
---|---|
get | 定义获取属性值时调用的函数,默认为 undefined 。 |
set | 定义设置属性值时调用的函数,默认为 undefined 。 |
1 | const obj = {}; |
typeof和instanceof
typeof
typeof
操作符返回一个字符串,表示值的数据类型。
1 | typeof operand //这种方式用的多 |
这两种使用方法都是可以的。下面是一些例子。
1 | typeof 1 // 'number' |
值得注意的是,对所有引用数据类型(除了function
,包括数组,普通对象),使用typeof
返回的都是object
instanceof
主要用来判断某个构造函数
是否在某个实例对象
的原型链上。
1 | object instanceof constructor |
区别
typeof返回的是
字符串
,instanceof返回的是布尔值
typeof只能能准确判断
基本数据
的类型,不能准确
判断引用数据
的类型。intanceof只能准确判断
引用数据
的类型,不能判断基本数据
的类型
可以看到,上述两种方法都有弊端,并不能满足所有场景的需求。
Object.prototype.toString()
还有一种通用的判断方式Object.prototype.toString()
,简单来说就是Object
原型对象上挂载的toString
方法。这个我们常见但是用的不多的方法,真的有这么强大的功能吗?
1 | Object.prototype.toString({}) // "[object Object]" |
可以看到返回的结果是一个字符串,第一位都是
object
,这与js中万物皆对象的思想符合。所有类型的数据都能调用
toString()
方法,如果是基本数据类型,会先进行数据装箱
,转化成对象
,再调用这个方法。Object是所有对象的父类(Object在所有对象的原型链上),所以所有对象都能访问到这个方法,那为什么不直接让数据调用这个方法呢?因为对象的原型链上可能还存在
同名方法
。使用函数.call(对象)
的方式,能确保对象调用的就是指定的函数/方法。不过要这么写代码也太长了吧,要不先直接调用toString
方法试试?1
2
3
4
5
6
7
8console.log({}.toString()) //[object Object]
let a = 1
console.log(a.toString())//1
console.log('1'.toString())//1,字符串能直接调用这个方法,因为它不是纯种的基本数据类型
let b = true
console.log(b.toString())//true
let c = function () { }
console.log(c.toString())//function () { }结果发现,果然不行啊。
谈谈 JavaScript 中的类型转换机制
前面我们讲到,JS
中有六种简单数据类型:undefined
、null
、boolean
、string
、number
、symbol
,以及引用类型:object
常见的类型转换有:
- 强制转换(显示转换)
- 自动转换(隐式转换)
显式转换
显式转换,即我们很清楚可以看到这里发生了类型的转变,常见的方法有:
- Number()
- parseInt()
- String()
- Boolean()
Number()
将任意类型的值转化为数值
1 | Number(324) // 324 |
- 从上面可以看到,
Number
转换的时候是很严格的,只要有一个字符无法转成数值,整个字符串就会被转为NaN
null
转化成数字类型是0,而undefined
转化成数字类型是NaN
,这是二者最大的区别之一- 总结一下,哪些数据转化成数字类型后的值是0:
- 空字符串
- false
- null
- 空数组(空字符串是不是也能看作空字符数组呢?)
parseInt()
parseInt
相比Number
,就没那么严格了,parseInt
函数逐个解析字符,遇到不能转换的字符就停下来。这个方法是可以直接被调用的,不许要显式借助其他对象。
1 | parseInt('32a3') //32 |
要注意的是,如果传入parseInt
的值不是以数字开头的字符串,那么parseInt
的返回值将是NaN
1 | console.log(parseInt(true))//输出NaN,因为传入的不是字符串 |
和parseInt
方法类似的还有parseFloat
方法,后者和前者不同的是,是从字符串中提取出浮点数。
String()
可以将任意类型的值转化成字符串
1 | // 数值:转为相应的字符串 |
可以看到,对于基本数据类型
,强制转化成字符串,就是加个双引号
就好了,而引用数据类型就不一样了,需要调用toString
方法
Boolean()
可以将任意类型的值转为布尔值
,转换规则如下:
1 | Boolean(undefined) // false |
隐式转换
隐式转换本质就是偷偷帮我们调用了显式转换的函数,在隐式转换中,我们可能最大的疑惑是 :何时发生隐式转换
?
我们这里可以归纳为两种情况发生隐式转换的场景:
- 比较运算(
==
、!=
、>
、<
) - 算术运算(
+
、-
、*
、/
、%
) if
、while
需要布尔值地方
除了上面的场景,还要求运算符两边的操作数不是同一类型
自动转化成布尔值
在需要
布尔值
的地方,就会将非布尔值的参数自动转为布尔值,系统内部会调用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
在非严格比较中,
undefined
和null
,只与undefined
或者null
相等。这就意味着,
undefined==0
的值是false,null==0
的值也是false,undefined==false
的值也是false,null==false
的值也是false。这其实就很奇怪,为什么null
和undefined
在作为if()
的判断条件的时候,可以隐式转化成false
,但是当和false
进行非严格比较,返回值就是false。NaN == NaN 返回false
NaN
和任何数比较,包括本身,都返回false。两个都为
简单类型
,字符串和布尔值都会转换成数值,再比较。如果一个操作数是
对象
,另一个操作数不是,则调用对象的valueOf()
方法取得其原始值,再根据前面的规则进行比较。两个都为引用类型,则比较它们是否指向同一个对象,也就是比较地址是否相同。
Javascript 数字精度丢失的问题
为什么会出现精度丢失
对于某些小数,计算机无法用有限的二进制位精确的表示,比如0.1用二进制表示思路如下:
1 | 0.1*2=0.2<1 --0 |
假设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 | 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
5let 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.js
、BigDecimal.js
,通过调用相关方法
来模拟加减乘除运算。1
2
3
4
5const 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
最常用的垃圾收回机制,
垃圾回收程序运行的时候,会标记内存中存储的所有变量
。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉
在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了
随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存
1 | var m = 0,n = 19 // 把 m,n,add() 标记为进入环境。 |
引用计数
语言引擎有一张”引用表”,保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是0
,就表示这个值不再用到了,因此可以将这块内存释放。
如果一个值不再需要了,引用数却不为0
,垃圾回收机制无法释放这块内存,从而导致内存泄漏。
1 | const arr = [1, 2, 3, 4]; |
上面代码中,数组[1, 2, 3, 4]
是一个值,会占用内存。变量arr
是仅有的对这个值的引用,因此引用次数为1
。尽管后面的代码没有用到arr
,它还是会持续占用内存
如果需要这块内存被垃圾回收机制释放,只需要设置如下:
1 | arr = null |
通过设置arr
为null
,就解除了对数组[1,2,3,4]
的引用,引用次数变为 0,就被垃圾回收了。
总结
- 在js这门语言中,不需要用户手动管理内存,所以出现内存问题的情况较少
- js的内存的自动回收机制主要有两种实现方法:
- 标记清除:清除被标记的变量,被标记的变量就是不再使用的变量
- 引用计数:如果某个数据的引用次数为0,说明这个数据不再被使用,可以被清除
注意
有了垃圾自动回收机制,并不代表不用担心内存泄漏问题,对于那些占用内存很大的变量,确保它们不再被使用的时候,不存在对它们的引用。
常见内存泄漏情况
意外的全局变量
给一个未声明的标识符赋值,javaScript 引擎会认为你在引用一个已经存在的全局变量;如果找不到这个变量,则会自动在全局对象(浏览器环境中为
window
,Node.js 环境中为global
)上创建它。1
2
3function foo(arg) {
bar = "this is a hidden global variable";
}直接调用构造函数
1
2
3
4
5function foo() {
this.variable = "potential accidental global";
}
// 直接调用foo,this 指向了全局对象(window)
foo();直接调用构造函数,也会在全局对象上挂载数据。使用严格模式,可以避免意外的全局变量。
定时器
定时器开启后,除非显式的清除,否则将一直存在,如果定时器中引用了不再使用的变量,又未及时清除定时器,就会造成内存泄漏。
1 | var someResource = getData(); |
如果id
为Node的元素从DOM
中移除,该定时器仍会存在,同时,因为回调函数中包含对someResource
的引用,定时器外面的someResource
也不会被释放。
除了定时器之外,延时器(setTimeout)和事件监听器(addEventListener),如果不使用,都要记得清除或者解绑,否则会持续引用不再需要的回调函数,从而造成内存泄漏,因为函数也是一种类型的数据,也会占用内存。
闭包
1 | function bindEvent() { |
即便函数调用结束了,由于func持续引用unused函数,这个函数的空间不会被释放,又因为unused函数引用obj,obj这个变量的内存空间也不会被释放。
说说你对闭包的理解?闭包使用场景
是什么
闭包由一个内部函数
和它引用的外部函数
的作用域组成。
使用场景
- 创建私有变量
- 延长变量的生命周期
示例
1 | //立即执行函数,充当外部函数 |
1 | <script> |
说说你对防抖和节流的理解
是什么
本质上是优化高频率执行代码造成的性能损耗
的一种手段。
如:浏览器的 resize
、scroll
、keypress
、mousemove
等事件在触发时,会不断地调用绑定在事件上的回调函数,极大地浪费资源,降低前端性能。
为了优化体验,我们需要限制这类事件的调用次数,对此我们就可以采用 防抖(debounce) 或者节流(throttle) 的方式,来减少回调函数调用频率
防抖
定义
事件被触发后,且在n秒内不再触发该事件,则执行对应的回调函数,如果在n秒内再次触发该事件,则清除先前的延时器,重新开始计时;可以用操作系统中的资源被剥夺来理解,这里的资源就是延时器
。
简单的来说,就是对于频繁触发的事件,只执行最后一次触发对应的事件回调。
手写防抖函数
1 | <body> |
传入一个函数,返回一个实现了防抖的函数,返回的防抖函数,本质就是在执行传入的函数。
1 | function myDebounce(func, wait) { |
节流
定义
在n秒内无论触发多少次事件,只执行第一次触
发对应的回调函数
可以用操作系统中的资源不可被剥夺来理解
,这里的资源就是延时器
。
简单的来说就是,对于频繁触发的事件,每间隔一定时间才执行一次事件回调。
手写节流
1 | <body> |
1 | document.querySelector('button').addEventListener('click', myThrottle(func, 1000)) |
区别与联系
相同点:
- 都可以通过使用
setTimeout
实现 - 目的都是,降低回调函数的执行频率。节省计算资源
不同点:
- 函数防抖,在一段连续操作结束后,只执行最后一次触发对应的回调。函数节流,在一段连续操作中,每一段时间只执行一次,在频率较高的事件中被使用来提高性能。
- 函数防抖关注一定时间连续触发的事件,只在最后执行一次,而函数节流一段时间内只执行一次。
应用场景
防抖在连续的事件,只需触发一次回调的场景有:
- 搜索框搜索输入。只需用户最后一次输入完,再发送请求
- 手机号、邮箱验证输入检测
- 窗口大小
resize
。只需窗口调整完成后,计算窗口大小。防止重复渲染。 - 简单的来说就2种情况,输入和调整窗口大小
节流在间隔一段时间执行一次回调的场景有:
- 滚动加载,加载更多或滚到底部监听
- 搜索框,搜索联想功能
说说函数的apply,call,bind方法
这三个方法都是函数(Function)原型(prototype)上的方法,作用是用来修改函数内部this的指向。
值得注意的是,箭头函数也可以调用这个三个方法,但是不能改变其内部this的指向。
call
传入call方法的多个参数(除了obj),会被收集为一个数组args
1 | //实现自己的call函数 |
apply
apply方法和call方法不同的是,要求传入的第二个参数是一个数组
,而不是多个参数。
1 | function myApply(obj, args) { |
bind
bind方法和前2个方法不同的是,并不会立即调用目标函数,而是返回一个新的函数,在这个函数内部调用目标函数。
bind方法和call相似的是,第一参数也是this
的指向,后面传入的也是一个参数列表(但是这个参数列表可以分多次传入)
1 | function myBind(obj, ...args) { |
要注意的是,对于bind方法返回的函数,我们不关心它内部的this指向,因为我们根本不需要使用它的this,它只要确保使用了传入的目标对象,调用了指定的函数即可。
说说js中的日期对象Date
JavaScript中的Date
对象用于处理日期和时间。它提供了一系列方法来获取和设置日期的各个方面,如年、月、日、小时、分钟、秒和毫秒等
创建日期对象
我们都知道,通过new Date()
就可以创建一个代表当前日期时间的对象,但是你有没有想过,可以给Date
构造函数传入不同的参数呢?
不带参数:创建一个代表
当前日期和时间
的对象。1
2const now = new Date();
console.log(now); // 输出类似 "2025-03-07T03:48:32.123Z" 的字符串(具体时间取决于执行时刻)带日期字符串参数:根据提供的日期字符串,创建对应的日期对象,但通常情况下,要我们手动传入一个格式规范的日期字符串,是比较难的吧。
1
2const dateStr = new Date('2025-03-07T00:00:00');
console.log(dateStr); // 输出 "2025-03-07T00:00:00.000Z"带时间戳参数:根据传入的时间戳,返回对应的时间日期对象
1
2
3
4const 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
2const 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 | const date = new Date() |
深拷贝浅拷贝
当我们拷贝一个基本类型的数据,拷贝的就是它的值
,此时没有深浅拷贝一说,只有当我们拷贝一个对象的时候,才有深浅拷贝的说法。
浅拷贝
浅拷贝顾名思义,就是浅层次的拷贝,只拷贝一层
。当我们要拷贝一个对象的时候,对于这个对象的所有属性,如果属性的值是基本数据类型
,那我们直接拷贝值
,如果属性值为引用数据类型
,则拷贝地址
。示例如下:
1 | function shallowClone(obj) { |
可以看出浅拷贝的手动实现也是非常简单的。
浅拷贝常见方法
实现浅拷贝主要有2种方法,Object.assign
和扩展运算符
Object.assign
只会拷贝对象中可枚举的自有属性,不会拷贝其继承自原型链上的属性。
1
2
3
4
5
6
7
8
9
10
11
12
13let 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
9const 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
8const _ = 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
26function 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()
但是这种方式存在弊端,会忽略值为
undefined
,symbol
,函数
的属性,因为这些值都是不可被序列化
的,不会出现在序列化的字符串中。除此之外,使用Symbol作为键的属性也不会包含在序列化的结果中,因为JSON标准不支持Symbol类型的键,并不是因为Symbol类型的键是不可枚举的(实际上是可枚举的)。1
2
3
4
5
6
7
8
9
10const 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
2var 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
11var 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
3btn.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
12document.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
3var btn = document.getElementById('.btn');
btn.attachEvent(‘onclick’, showMessage);
btn.detachEvent(‘onclick’, showMessage);
讲讲事件代理
事件代理也叫事件委托
,当我们要监听某个元素某个事件的时候,我们可以选择不给这个元素添加事件监听,而是给这个元素的父元素或者祖先元素
添加对该事件的监听。然后在事件冒泡
阶段触发该事件监听对应的回调函数。我们可以说,事件委托是基于事件冒泡的
当你给DOM元素绑定事件处理器时,JavaScript引擎必须保留对该函数的引用,以便在事件触发时可以调用它。这意味着只要事件处理器存在,其对应的函数就不会被垃圾回收机制回收,从而一直占用着内存。
如果事件处理器使用了闭包(例如,在定义事件处理器的函数内部访问外部变量),那么这些外部变量也不能被垃圾回收,因为闭包会持有对外部作用域的引用。这进一步增加了内存占用。
1 | li.addEventListener('click', function() { |
事件代理的优点:
不必为每个
目标元素
绑定事件监听,减少了页面所需内存。自动绑定,解绑事件监听,减少了重复的工作。
事件代理的局限性:
focus
、blur
这些事件[输入框的事件]
没有事件冒泡机制,所以无法进行委托绑定事件。mousemove
、mouseout
这样的事件,虽然有事件冒泡,但触发频率很高,而且只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的,我们最好在目标元素上添加mousemove的监听事件,因为我们不需要计算,直接通过event.offsetX
和event.offsetY
,就能获取鼠标指针相对于触发事件的元素(即事件目标)的内部坐标。1
2
3document.querySelector('.box').addEventListener('mousemove', (e) => {
console.log(e.offsetX, e.offsetY)
})
说说你对BOM的理解
BOM
(Browser Object Model),浏览器对象模型,提供了独立于内容,与浏览器窗口进行交互的对象
其作用就是跟浏览器
做一些交互效果,比如如何进行页面的后退
,前进
,刷新
,浏览器的窗口发生变化,滚动条的滚动,以及获取客户的一些信息如:浏览器品牌版本,屏幕分辨率


window
Bom
的核心对象是window
,它表示浏览器的一个实例,location
,navigator
等后续介绍的对象都是window
的属性。
在浏览器中,window
对象有双重角色,即是浏览器窗口的一个接口,又是全局对象
因此所有在全局作用域
中声明的变量
、函数
都会变成window
对象的属性
和方法
window.scrollTo(x,y)
:如果有滚动条,将横向滚动条移动到相对于窗体宽度为x个像素的位置,将纵向滚动条移动到相对于窗体高度为y个像素的位置1
window.scrollTo(0, 500);//将页面垂直滚动到距离页面顶部500像素的位置,而水平滚动条不会发生变化。
window.scrollBy(x,y)
: 如果有滚动条,将横向滚动条
向左移动x个像素,将纵向滚动条
向下移动y个像素
window.open()
:window.open()
既可以导航到一个特定的url
,也可以打开一个新的浏览器窗口。window.open()
会返回新窗口的引用
,也就是新窗口的window
对象,当使用window.open()
方法打开新窗口时,如果返回值是null
,这通常意味着浏览器阻止了该弹窗的创建。现代浏览器为了防止恶意网站滥用弹窗,通常会限制非用户交互
触发的弹窗。如果你在页面加载时或没有明确的用户动作(如点击事件)的情况下调用window.open()
,浏览器可能会认为这是未经请求的弹窗,并阻止它。比如直接在script标签中书写:
1
window.open('sanye.blog')//被浏览器阻止
1
2
3document.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
: 在新的窗口
或标签页中加载页面。_parent
: 在父框架中加载页面。如果当前页面不在框架(iframe)内,则与_self
的行为相同,在当前标签页中加载新页面。_top
: 在整个窗口中加载页面,取消所有框架。- 或者是一个由开发者定义的名称,用来标识
特定的窗口
或<iframe>
。
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中,#后面的字符,没有则返回空串 |
host | www.wrox.com:80 | 服务器名称和端口号 |
hostname | www.wrox.com | 域名,不带端口号 |
href | http://www.wrox.com:80/WileyCDA/?q=javascript#contents | 完整url |
pathname | “/WileyCDA/“ | 服务器下面的文件路径 |
port | 80 | url的端口号,没有则为空 |
protocol | http: | 使用的协议 |
search | ?q=javascript | url的查询字符串,通常为?后面的内容 |
- 除了
hash
之外,只要修改location
的一个属性,就会导致页面重新加载新URL
,因为hash值不会发送给服务器,所以修改哈希值后刷新也面也没意义。 location.reload()
,此方法可以重新刷新当前页面。这个方法会根据最有效的方式
刷新页面,如果页面自上一次请求以来没有改变过,页面就会从浏览器缓存
中重新加载,这一点和浏览器的缓存策略
相关。如果要强制
从服务器中重新加载,传递一个参数true
即可。
history
history是window对象的一个属性,它本身也是个对象,提供了许多api,主要用来操作浏览器URL
的历史记录,允许我们编程式控制页面被在历史记录之间跳转,也允许我们修改历史记录。
检查一个页面并在控制台输入history,即可查看当前页面的state
,scrollRestoraion
等信息。

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
navigator
对象主要用来获取浏览器的属性
,区分浏览器类型。属性较多,且兼容性比较复杂。
screen
保存的纯粹是客户端能力
信息,也就是浏览器窗口外面的客户端显示器
的信息,比如像素宽度和像素高度。
DOM常见的操作有哪些
DOM是什么
浏览器
根据html
标签生成的js对象
,所有的标签属性
都可以在上面找到(所以说node中没有dom),修改这个对象属性会自动映射
到标签上。关键词:浏览器,html标签,js对象,属性映射。
DOM常见的操作
- 创建节点
- 获取结点
- 更新节点
- 添加节点
- 删除节点
创建节点
createElement
创建元素结点
1 | const divEl = document.createElement("div"); |
createTextNode
创建文本结点
1 | const textEl = document.createTextNode("content"); |
createAttribute
创建属性节点,可以是自定义属性
1 | // 创建一个元素结点 |
虽然可以直接使用elem.href = 'http://example.com';
或elem.setAttribute('href', 'http://example.com');
来达到相同的效果,但是了解如何创建和操作属性结点
可以帮助更好地理解DOM的操作机制。
获取节点
可以通过捕获
的方式获取dom结点,也可以通过一个dom结点的属性
来获取另一个dom结点
querySelector
传入任何有效的css
选择器,即获得首个
符合条件的Dom元素:
1 | document.querySelector('.element') |
如果页面上没有指定的元素时,返回 null
querySelectorAll
传入任何有效的css
选择器,返回一个伪数组
,包含全部符合匹配条件的DOM元素。
1 | const notLive = document.querySelectorAll("p"); |
其他方法
1 | document.getElementById('id属性值');返回拥有指定id的对象的引用 |
我们仅通过观察是...Element...
还是,...Elements...
就能判断出返回的结果是集合还是单独的元素
除此之外,每个DOM
元素还有parentNode
、childNodes
、firstChild
、lastChild
、nextSibling
、previousSibling
属性,关系图如下图所示。

parentNode和parentElement
parentNode
返回指定节点的父节点,这个父节点可以是任何类型的节点,包括文档类型节点、元素节点、文本节点等。但是,在实际应用中,除了元素节点外,其他类型的节点很少作为父节点存在。
parentElement
仅返回指定节点的父元素节点(即类型为HTMLElement
的节点)。如果指定节点的父节点不是一个元素节点(例如,它可能是一个文本节点),则 parentElement
返回 null
,即先捕获再判断类型
。
简单的来说,就是一个对父节点的类型有要求,一个没有。
childNodes和children
childNodes
返回一个实时的NodeList
对象,包含了指定节点的所有直接子节点(一级子节点),包括元素节点、文本节点、注释节点等所有类型的节点。
children
返回一个实时的 HTMLCollection
对象,只包含指定节点的直接子元素节点(一级元素结点,即标签)。不包括文本节点、注释节点等其他类型的节点。
获取页面上的所有结点
一个简单的方法是从文档的根节点(document
或 document.documentElement
,后者指的是 <html>
元素)开始,然后递归地访问每个节点的 childNodes
。
获取页面上的所有元素结点
使用
document.getElementsByTagName('*')
:这将返回一个包含文档中所有元素的实时HTMLCollection
使用
document.querySelectorAll()
:通过传递'*'
选择器,你可以获得一个静态的NodeList
,它包含了文档中的所有元素结点。
更新结点
innerHTML
不但可以修改一个DOM
节点的文本内容
,如果传入的是html片段,还会被解析成dom结点。
1 | // 获取<p id="p">...</p > |
innerText、textContent
自动对字符串进行HTML
编码,就是把小于号转化成<
大于号转化成>
保证无法设置任何HTML
标签
1 | // 获取<p id="p-id">...</p > |
两者的区别在于读取属性时,innerText
不返回隐藏元素
的文本(即 display: none
或者 visibility: hidden
),而textContent
返回所有文本
1 | <div id="example"> |
添加结点
appendChild
把一个节点添加到父节点的最后一个子节点之后,如果这个添加的结点已经在页面中存在,那么这个结点会先从原位置删除。
1 | <p id="js">JavaScript</p > |
添加一个p
元素
1 | const js = document.getElementById('js') |
在HTML
结构变成了下面
1 | <div id="list"> |
insertBefore
1 | parentElement.insertBefore(newElement, referenceElement) |
子节点会插入到referenceElement
之前
parentElement
: 这是要操作的目标元素,新的子节点将被添加到这个元素的子节点列表
中。newElement
: 这是你想要插入的新元素节点
。referenceElement
: 这是在新元素插入之前所依据的参考元素
。新元素会被放置在这个参考元素
之前。如果这个参数为null
,则新元素会被插入到父元素的最后,就像使用appendChild()
一样。
setAttribute
在指定元素中添加一个属性
节点,如果元素中已有该属性改变属性值。
1 | const div = document.getElementById('id') |
删除结点
removeChild
删除一个节点,首先要获得该节点本身
以及它的父节点
,然后,调用父节点的removeChild
,把自己删掉,也就是说,一个结点是不能删除自身的,而是需要借助父节点。
1 | // 拿到待删除节点: |
删除后的节点虽然不在文档树
中了,但其实它还在内存中
,可以随时再次被添加到别的位置。
nodeType
nodeType
是一个只读属性,用于标识 DOM 节点的类型。- 常见的nodeType值包括:
1
:元素节点(Element
),因为元素结点是最常见的结点,所以nodeType的值是13
:文本节点(Text
)8
:注释节点(Comment
)9
:文档节点(Document
)
如何实现触底加载,下拉刷新?
定位属性
要明白如何实现功能,我们首先要搞清楚dom元素的一些定位,宽高属性
client
clientWidth/clientHeight
:可视区域的宽/高+内边距,不包含border
scroll
scrollWidth/scrollHeight
:有滚动条元素的元素整体的宽高。一个没有滚动条的元素,它的scrollWidth/scrollHeight
属性的值等于它的
clientWidth/clientHeight
属性的值。
举个例子,我们在一个高度为600px
的盒子box
里放两个背景颜色不同,高度都是400px
的盒子box1
,box2
,并给box
添加css属性
1 | overflow:auto |
1 | 补充一下overflow属性的值 |
这样box
盒子就出现了滚条,可以实现内容的滚动,内部盒子也不会影响外部盒子的布局(开启了BFC
,可以观察添加该条属性前后,body高度的变化,从800px变为600px)。然后我们访问box盒子(有滚动条的盒子)的clientHeight
属性和scrollHeight
属性
1 | box.clientHeight //600px |
这样是不是就很容易理解client和scroll之间的区别呢。对于没有滚动条的元素,clientWidth/clientHeight与scrollWidth/scrollHeight的值是一一相等的。
当我们不断地给body
添加元素,body
的高度总有超过浏览器窗口高度的时候,此时body
标签的父元素,html
标签会自动开启滚动条,html.clientHeight
就是浏览器窗口的高度。
scroll
scroll开头的属性中,还有两个重要的属性。
scrollLeft/scrollTop
:表示具有滚动条的元素,顶部滚动出可视区域
的高度,或者左部滚动出可视区域
的宽度,对于不具有滚动条的元素,这两个属性的值都是0
。这两个属性是可读写的,将元素的scrollLeft
和scrollTop
设置为 0,可以重置元素的滚动位置,通常用来实现一键到底,或者返回顶部。
常见的属性中除了以client,scroll开头的属性,还有以offset
开头的属性
offset
offsetWidth/offsetHeight
:可视区域的宽/高+内边距+border+滚动条,这两个属性通常被拿来与clientWidth/clientHeight
属性比较,这2类属性的范畴都包含可视区域的宽高和padding,都不包含margin(明明和padding一样都是边距,为什么就这么不受待见呢),区别在于前者还包含border,后者不包含,范围更小。offsetLeft/offsetTop
:元素左部/顶部距离最近的定位元素的距离,相对的不是视口,通常是固定的,不会随滚动条改变而改变。要注意的是,没有offsetRight
和offsetBottom
属性。

offsetX/offsetY
:offsetX
和offsetY
是与鼠标事件相关的属性,通常在处理用户交互时使用。这两个属性提供了鼠标指针相对于触发事件的元素(即事件目标元素,可以通过event.target
获得)的 X 和 Y 坐标。它们是 MouseEvent 对象的一部分,我曾经尝试使用这2个属性来做放大镜的效果,发现一直实现不了,后来发现了问题所在,触发鼠标事件的目标元素始终是蒙层,而不是商品图片,可以通过给蒙层添加point-events:none
忽略鼠标事件来解决问题。
如何实现触底加载
方法1
如果html
元素顶部滚出可视区域的高度+html元素的可视区域高度,大于html标签的整体高度,则判定为触底,其实这个时候就是页面已经滑到底部了。
1 | if (html.scrollTop + html.clientHeight >= html.scrollHeight) { |
优点:实现起来非常简单。
缺点:只能判断最后一个元素是否触底,不能判断非底部元素是否触底(如果body很长,那么非底部元素也是有触底事件的)
方法2
使用IntersectionObserver API
,创建observer
对象 ,需要传递2个参数
1 | const func = (entries, observer) => { |
关于IntersectionObserver
更详细的介绍参考下文。
优点:不仅能精确控制某个元素是否触底,令threshold
的值为0,还能实现图片懒加载的效果,即图片一出现在视口,就发送请求获取图片。
缺点:需要调用api实现起来麻烦。
如何实现下拉刷新
监听window
的touchstart
,touchmove
,touchend
,通过e.touches[0].clientY
获得触碰位置。touchend
事件触发后,计算移动的距离,判断是否需要刷新数据。
要注意的是不是mousestart
,mousemove
,mouseend
事件,因为”下拉”是只在移动端才有的动作,所以没有鼠标,所以事件也不以mouse
开头,而是以touch
开头
e.touches
是一个 TouchList
对象,包含了所有当前活跃的触点(即手指接触屏幕且尚未抬起的所有触点),当用户用手指触碰设备屏幕时(比如手机或平板),会产生一个或多个触摸点。每个触摸点都由一个 Touch
对象表示,该对象包含了诸如位置、状态等信息。所有这些触点被包含在一个 TouchList
对象中。
e.touches[0]
:这表示获取 TouchList
列表中的第一个触点。因为 TouchList
是一个类数组对象
,所以你可以通过索引来访问其中的元素。索引从 0 开始,因此 e.touches[0]
就是列表中的第一个触点。
clientY
:这是 Touch
对象的一个属性,表示触点相对于可见视口(viewport)的 Y 轴坐标。换句话说,它给出了手指在屏幕上的垂直位置,不包括任何滚动条导致的偏移。
1 | let start = 0 |
如何判断一个元素是否在可视区域中?
使用场景
在日常开发中,我们经常需要判断目标元素是否在视窗之内,或者和视窗的距离小于一个值(例如 100 px),从而实现一些常用的功能,例如:
- 图片的懒加载
- 列表的无限滚动
- 计算广告元素的曝光情况
- 可点击链接的预加载
实现方式
借助dom的布局属性
当一个元素的html
标签的scrollTop
属性,加上视口的高度
,大于等于一个元素的offsetTop
属性,那么这个元素就出现在视口中。如何获取视口的高度呢?有三种方式:
window.innerHeight
html.clientHeight
:html标签
的高度就可以认为是视口的高度(document.documentElement=html
)document.body.clientHeight
:body
标签的高度通常等于html标签
的高度
,所以也可以被认为是视口的高度。
1 | el.offsetTop - document.documentElement.scrollTop <= viewPortHeight |
然而这么做有一个前提,那就是el的最近的定位元素是html
标签。
1 | function isInViewPortOfOne (el) { |
IntersectionObserver
Intersection(交叉,交集) Observer
即,重叠观察者,从这个命名就可以看出它用于判断两个元素是否重叠
,因为不用进行scroll
事件的监听,性能方面相比getBoundingClientRect
会好很多。
使用步骤主要分为两步:创建观察者
和传入被观察者
创建观察者
1 | const options = { |
通过new IntersectionObserver
创建了观察者 observer
,传入的回调函数 callback
,在重叠比例超过 threshold
时会被执行,所以我们在实现图片懒加载
的时候令这个值为0,当图片刚刚出现在视口的时候,就是重叠比例
超过0的时候,就是回到函数被执行的时候。
关于callback
回调函数常用属性如下:
1 | // 上段代码中被省略的 callback |
传入被观察者
通过 observer.observe(target)
这一行代码,即可简单的注册被观察者
1 | const target = document.querySelector('.target'); |
getBoundingClientRect
调用dom元素的getBoundingClientRect
方法,返回值是一个 DOMRect
对象,拥有left
, top
, right
, bottom
, x
, y
, width
, 和 height
属性,所有布局属性(除了宽高)都是相对视口
的。
x
: 元素左边缘相对于视口左边界
的距离,通常情况下等于lefty
: 元素上边缘相对于视口顶部
的距离,通常情况下等于toptop
: 元素上边缘相对于视口顶部
的距离。bottom
: 元素下边缘相对于视口顶部
的距离。left
: 元素左边缘相对于视口左边界
的距离。right
: 元素右边缘相对于视口左边界
的距离。width
: 元素的宽度,包括填充和边框,但不包括外边距,等于offsetWidth
height
: 元素的高度,包括填充和边框,但不包括外边距,等于offsetHeight
如果一个元素全部在视窗之内的话,那么它一定满足下面四个条件:
- top 大于等于 0
- left 大于等于 0
- bottom 小于等于视窗高度,就是元素底部距离视口顶部的距离,小于等于视口高度,这个是最常用的判断条件
- right 小于等于视窗宽度
1 | //返回一个布尔值,判断元素是否在视口中 |
如果要获得元素的即时位置,每次页面滚动都要重新调用这个方法,来获得最新的位置信息。
实现图片的懒加载
下面使用IntersectionObserver
实现了一个图片懒加载指令。
1 | //因为自定义指令只会被导入一次,所以observer对象只会被创建一次 |
本来就出现在视口中的图片,因为threshold
一开始就超过了0,所以回调函数会被正常执行。
使用getBoundingClientRect
实现
1 | export const lazy = { |
但是这样存在缺点:
- 必须滚动后才能触发,对于本来就在显示在视口的图片,不滚动就不会展示,
- 每给一张图片添加懒加载,就会给window注册一个scroll事件的监听,造成了不必要的内存占用
下面给出修改后的代码:
1 | let handler = [] |
其实还有进一步优化的空间
- 指令解绑后,卸载事件监听;
- 每次滚动,都要遍历所有添加了懒加载的img对象,调用它们的
getBoundingClientRect
方法,这比较消耗性能的,也是使用getBoundingClientRect
方法不可避免的问题 。
实现列表的无限滚动
使用getBoundingClientRect
来实现
1 | const handler = [] |
使用IntersectionObserver
来实现
1 | const observer = new IntersectionObserver( |
把这种方法应用到我实际开发的项目中,想要实现列表无限滚动的时候,缺发现不起效果,仔细思考后发现:
初始图片未加载的时候,列表高度为0,列表完全处于视口中,此时回调函数就已经被触发,观察已被解除(unobserve)
初始图片加载完毕的时候,列表的高度就大于视口高度了(列表太长了),所以列表和视口的重合面积,和列表的面积的比例,永远会小于1,所以传入的回调函数永远不会被触发。
即便把threshold设置为小于1的值,也会因为列表越来越长,导致后来重合面积的比例越来越小,后续滚动也不会加载新的数据。
所以说IntersectionObserver不适合用来做列表的无限滚动
new操作符到底做了什么
- 创建一个新的对象
- 让这个对象的
[[prototype]]
属性等于构造函数的prototype
,即让新创建的对象的原型等于构造函数的原型对象。 - 调用这个对象的
constructor
方法
如果构造函数的返回值
是基本类型,那么这个返回值不起任何效果,但是如果构造函数的返回值是引用类型,new操作返回的对象就是构造函数返回的对象。
1 | function Parent() { |
1 | function Parent() { |
手写new
1 | function Parent(name,age) { |
在js中如何实现继承
继承可以使得子类具有父类别的各种属性和方法,而不需要再次编写相同的代码
在子类别继承父类别的同时,可以重新定义
某些属性,并重写某些方法,即覆盖父类别的原有属性和方法,使其获得与父类别不同的功能。
如果大家学过java
,想必对继承的概念都非常熟悉了。
那在js这门语言中是如何实现继承呢?
原型链继承
让父类的一个实例
作为子类的原型对象
,这样子类的原型对象的原型确实是父类的原型对象
1 | function Parent() { |
这样就满足Child.prototype._proto_ = Parent.prototype
,在原型链上确实是符合继承关系,但是这也只是在原型链上实现了继承,Child.prototype.constructor
也不指向Child
的构造函数,而是指向Parent
的构造函数,而且这个构造函数还不是原型对象(Child.prototype)自己的属性

正确的情况,Child.prototype
的constructor
应该是Child.prototype
自己的属性(ownProperty)
正确的状态,就是每个原型对象,都有一个自己的构造函数,指向正确的构造函数。比如Parent.prototype
,就有自己的构造函数,指向Parent构造函数

总的来说,原型链继承的问题包括, 子类的原型对象没有自己的构造函数,还存在多余的属性。
构造函数继承
1 | function Parent(){ |
构造函数继承也只是实现了构造函数上的继承,比原型链继承还低智,就纯粹在Child
的构造函数中,借助call方法调用了Parent
的构造函数。
在这个例子中,Parent1.call(this)
完全可以被替换为 this.name = 'parent1';
这种继承方式唯一的作用,拿这个例子来讲,就是把父类Parent
的name属性终于变成子类Child实例
自己的属性了(对象本身就有的,而不是原型对象上的,可以通过hasOwnProperty
方法来判断)。

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

可以看到 Child.prototype.constructor
指向是正确的,创建的Child
实例也有自己的name
和play
属性,但是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 | function Parent() { |
extends+super
使用extends
关键字实现继承,基于es6
新引入的class
,本质上使用的也是寄生组合式继承
,不过还要配合super
关键字使用。
1 | class Person { |
Javascript本地存储的方式有哪些?区别及应用场景?
javaScript
本地缓存的方法我们主要讲述以下四种:
- cookie
- sessionStorage
- localStorage
- indexedDB
其中sessionStorage
和localStorage
都是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
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
Path
指定了一个
URL
路径,只有包含这个路径的请求,才能携带这个cookieSecure
标记为
Secure
的Cookie
,意味着这个cookie包含了重要的信息,不应该被泄漏,只能通过HTTPS
请求,安全地发送给服务器。HttpOnly
标记为HttpOnly的请求,只能通过
http/https
协议来操作。SameSite
Cookie默认不会在跨域请求中被发送,而跨站请求一定跨域,
SameSite
属性用于控制 Cookie 是否应该随跨站请求一起发送。它有三个可能的值:Strict
:Cookie 仅在同站
请求中发送,即只有当用户从同一站点发起请求时,才会包含 Cookie。Lax
:大多数情况下不发送跨站请求中的 Cookie,但在导航到目标站点(如点击链接)时例外。None
:允许跨站请求中发送 Cookie,但要求请求必须通过HTTPS
发送(即启用安全传输层)。
然而只配置SameSite属性为None还不能解决跨站请求不能携带cookie的问题,参考:前端面试—网络 | 三叶的博客中的withCredentails
部分
操作方式
通过js操作cookie
获取当前页面所有cookie
1
document.cookie
返回一个字符串,包含当前页面的所有cookie的键值对,形如:
key=val;key2=val2;......;keys=vals
如果要查看当前页面的全部cookie的详细信息,可以选择
检查页面
,前往应用程序->存储->cookie
中查看。创建一个cookie
1
2//可以继续添加其他限制属性,一次只能添加一个cookie
document.cookie = 'key=val;Max-age=3600;Domain=www.sanye.blog'我们知道,
document.cookie
,返回一个字符串,包含当前页面的所有cookie的键值对。上述创建cookie的代码的效果貌似是覆盖掉这个字符串,其实不是的,效果真的是添加一个cookie。修改cookie
关于
cookie
的修改,首先要确定domain
和path
属性都是相同的才可以,这两个属性可以理解为用来限制cookie的作用域,其中有一个不同的时候都会创建出一个新的cookie
,而不是修改原来的cookie1
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,可以通过设置其Expires
或Max-Age
为过去的时间戳来实现
浏览器行为
- 浏览器会在每次请求时,自动附加(携带)与目标URL(也就是请求url,而不是发起请求的页面的url)相匹配的所有Cookies。
- 如果某个Cookie被标记为
HttpOnly
,那么JavaScript代码不能读取或修改这个Cookie,增加了安全性。 - 当一个Cookie过期后,浏览器会自动将其从存储中移除,不再随请求一起发送。
localStorage
HTML5
新方法,IE8及以上浏览器都兼容
特点
持久化的本地存储,除非主动删除数据,否则数据永不过期
存储的信息在
同一域
中是共享的,这个同一域包括子域名
,也就是说若两个域名即便只有子域名不同,也不会被认为是同一域名。子域名指的是主域名(二级域名+顶级域名)之前的部分,比如
www.sanye.blog
中的www
就是子域名,sanye.blog
就是主域名,一般域名购买,购买的就是主域名。当本页操作(新增、修改、删除)了
localStorage
中的数据的时候,本页面不会触发storage
事件,但是别的页面会触发storage
事件,这里的其他页面指的是同源
的其他页面。大小:5M(跟浏览器厂商有关系),
localStorage
的大小限制主要指的是 整个域名下所有存储数据的总和,而不是单个键值对的大小。localStorage
本质上是对字符串的读取,如果存储内容多的话会消耗内存空间,会导致页面变卡受同源策略的限制
storage事件补充
假设你有两个标签页(Tab A 和 Tab B)打开了同一个网站 example.com
,并且这两个标签页都在使用 localStorage
来存储一些数据。
Tab A 中执行以下 JavaScript 代码来设置一个新的
localStorage
项:1
localStorage.setItem('key', 'value');
Tab A 不会触发
storage
事件,因为它就是触发这次变更的操作源
。Tab B 中监听
storage
事件,并打印出事件详情:1
2
3
4
5
6
7window.addEventListener('storage', function(event) {
console.log('Storage event received:', event);
console.log('Key:', event.key);
console.log('Old value:', event.oldValue);
console.log('New value:', event.newValue);
console.log('URL:', event.url);
});当你在 Tab A 中设置了
localStorage
后,Tab B 会立即接收到storage
事件,并输出类似如下的信息:1
2
3
4
5Storage event received: StorageEvent {…}
Key: key
Old value: null
New value: value
URL: https://example.com/如果你在 Tab B 中也设置了相同的
localStorage
项,比如:1
localStorage.setItem('key', 'newValue');
Tab B 自身不会触发
storage
事件,但 Tab A 会接收到storage
事件,并显示相应的更新信息。
常见使用语法
设置
1 | localStorage.setItem('username','cfangxu'); |
获取
1 | localStorage.getItem('username') |
获取键名
1 | localStorage.key(0) //获取第一个键名 |
删除
1 | localStorage.removeItem('username') |
一次性清除所有存储
1 | localStorage.clear() |
localStorage
也不是完美的,它有两个缺点:
- 无法像
Cookie
一样设置过期时间 - 只能存入
字符串
,无法直接存对象,如果尝试存储一个对象,它会自动调用该对象的toString()
方法,这通常会导致数据丢失或无法正确恢复原始对象;存入对象之前必须先序列化
。
和cookie的区别与联系
- 过期时间:
localStorage
无法像Cookie
一样设置过期时间
,数据在本地的存储是持久化
的,除非主动删除数据,否则数据永不过期
。 - 存取方式:
localStorage
中的数据必须手动存取
,而cookie
中的数据是自动存取
的 - 同源策略:
localStorage
严格遵循同源策略,同源页面才能共享同一份localStorage
中的数据;虽然 Cookies 也默认遵循同源策略,但可以通过特定的设置来实现跨子域的数据共享
。 - 大小限制:二者都有存储大小的限制,每个
cookie
的存储大小限制是4kB,而每个页面的localStorage
的存储大小限制一般是5MB,明显更大。 - 存储类型:二者只能存储
字符串
。这意味着如果你想要存储对象或其他复杂的数据结构,需要进行序列化
和反序列化
操作。
sessionStorage
sessionStorage
和 localStorage
使用方法基本一致,唯一不同的是生命周期
,一旦页面(会话)关闭,sessionStorage
中的数据将会被删除。
前端扩展存储方式
虽然 Web Storage
对于存储较少量的数据很有用,但对于存储更大量的结构化数据来说,这种方法不太有用。IndexedDB
提供了一个解决方案。
indexedDB
是一种低级API,用于客户端存储大量结构化数据(包括, 文件/ blobs)。该API使用索引(index)
来实现对该数据的高性能搜索。
优点
- 储存量理论上没有上限
- 所有操作都是
异步
的,相比LocalStorage
同步操作性能更高,尤其是数据量较大时 - 原生支持储存
JS
的对象 - 是个正经的数据库,意味着数据库能干的事它都能干
缺点
- 操作非常繁琐
- 本身有一定门槛
关于indexedDB
的使用基本使用步骤如下:
- 打开数据库并且开始一个事务
- 创建一个
object store
- 构建一个请求来执行一些数据库操作,像增加或提取数据等。
- 通过监听正确类型的
DOM
事件以等待操作完成。 - 在操作结果上进行一些操作(可以在
request
对象中找到)
关于使用indexdb
的使用会比较繁琐,大家可以通过使用Godb.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
:表示当前的请求方式,常见的有GET
、POST
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
19const 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()- 0(unsent):
onload 是 XMLHttpRequest 的另一个事件处理函数,它仅在请求成功完成(即 readyState === 4 且 status 为成功状态码,如 200)时触发,更简洁,适用于只关心成功响应的场景。如果请求失败(如网络错误或服务器返回非 2xx 状态码),
onload
不会被触发,而是会触发onerror
或ontimeout
。接受并处理服务端向客户端响应的数据结果
将处理结果更新到
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 | //fetch返回值是一个promise对象 |
当我们调用then方法的时候只传入成功回调的时候,借助async,await
能让代码更简洁,并使用try-catch
捕获错误。
1 | //使用2次await |
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.create创建axios实例
创建出来的实例具有与axios一样的功能,这样就相当于为每个请求都配置了相同的
基地址
,超时时间
,响应头
,这不就起到了封装的作用吗?1
2
3
4
5
6
7
8
9//request.js
import axios from 'axios'
const instance = axios.create({
baseURL: 'https://smart-shop.itheima.net/index.php?s=/api',
timeout: 10000,
headers: {
'platform': 'H5',
}
})给axios实例添加响应拦截器,请求拦截器。
响应拦截器返回(return)的数据,其实就是axios请求最终返回的数据。
无论是在成功的回调函数中,还是在失败的回调函数中,只要返回了
Promise.reject()
,那么axios
请求的Promise
将被拒绝,并触发失败回调链,导致.catch()
方法被调用;如果返回值为不是promise实例,返回值会成为
axios
请求的最终Promise
的解析值,并且.then()
方法会被调用;如果返回值是
Promise.resolve()
,那么我们可以认为axios的返回值就是这个,且.then()
方法会被调用。所以说不是成功的回调函数的返回值一定会被包装成Promise.resolve
,失败的回调函数的返回值一定会被包装成Promise.reject
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24instance.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对象
})取消请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14// axios.CancelToken是一个构造函数,用来获得取消令牌源对象。
// source是一个取消令牌源对象。这个对象包含了两个重要的属性:
// token: 这是一个实际的取消令牌。你可以将这个令牌传递给 Axios 请求配置中的 cancelToken 属性,从而使得该请求可以被取消。
// cancel: 这是一个函数,调用它可以取消所有关联了source.token 的请求。
// 你可以选择性地提供一个消息参数,这个消息会作为取消原因包含在取消事件中。
const source = axios.CancelToken.source();
axios.get('xxxx', {
cancelToken: source.token
})
// 取消请求 (请求原因是可选的)
// 调用source.cancel('取消原因') 时,它会将关联的 cancelToken 标记为已取消状态,并记录提供的取消原因(如 '取消原因')
// 这个操作不会直接发送网络请求,而是改变了令牌的状态。
source.cancel('主动取消请求');总结
想要取消请求,需要先使用
axios.CancelToken
生成一个取消令牌源对象source
,然后再请求中配置cancelToken
属性的值为source.token
,当想要取消请求的时候,就调用source.cancel()
方法,传入取消请求的原因。调用
source.cancel('取消原因') 时
,它会将关联的cancelToken
标记为已取消状态,并记录提供的取消原因(如 ‘取消原因’)
这个操作不会直接发送网络请求,而是改变了令牌的状态。1
2
3
4const CancelToken = axios.CancelToken;
//获得取消请求源对象
const source = CancelToken.source();
console.log(source.token)//输出token,结构如下1
2
3
4const 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
15
16// 给axios实例,添加响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 清除loading效果
Toast.clear();
// 判断是否是因为取消操作导致的错误
if (axios.isCancel(error)) {
console.log('Request canceled:', error.message);
} else {
// 其他错误处理
Toast('请求失败,请稍后重试');
}
return Promise.reject(error);
})简单的来说,我们想要某个请求被取消,那么这个请求必须携带
cancelToken
,当我们想要取消请求的时候,就调用source.cancel()
方法并传入取消的原因,这个操作并不会发送新的请求,而是会修改cancelToken
的状态,然后响应拦截器根据cancelToken
的状态,判断不再需要处理这个请求,所以说,请求取消,完全不需要后端配合,请求发送后无论如何都会被响应,取消请求只不过是抛弃了响应结果。导出配置好的axios实例
1
export default instance
使用axios发送请求
我们通常把同一业务功能的
api
放到一个js文件中,比如和购物车cart
相关的接口都放在cart.js
文件中,在这些文件中引入导出的axios实例来发送请求。1
import request from 'index.js'
发送请求有两种常用写法
request({})
这种写法是直接传入一个
配置对象
,请求方法(method)
等所有信息都包含在内,我们需要对大部分配置属性都熟悉1
2
3
4
5
6
7request({
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
7import request from '@/utils/request'
//修改购物车商品信息(这里url是模板字符串,因为使用了path参数)
export const updateCartAPI = ({ skuId, selected, count }) => {
return request.put(`/member/cart/${skuId}`, { selected, count })
}
//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
来判断执行哪个函数,可以注意到status
和data
是同一级别的数据

响应错误对象的response
属性则有与响应成功对象一样
的结构
案例分析
1 | //request.js |
实现一个简易版的axios
构建一个Axios
构造函数,核心代码为request
1 | class Axios { |
1 | // 导出request方法 |
上述代码就已经能够实现axios({})
这种方式的请求
下面是来实现下axios.method()
这种形式的请求
1 | const methodsArr = ['get', 'delete', 'head', 'options', 'put', 'patch', 'post']; |
get,post这些方法与request
方法一样,都挂载到Axios.prototype
上,虽然这些方法本质是在调用Axios.prototype.request
方法,但是它们还是属于同级别
的关系。这些方法能通过axios实例
调用,但是不能通过axios
函数调用,但是我们得到axios
应该是函数,我们因该要能通过axios函数
调用这些方法。
文件上传怎么做

input标签
借助input标签,点击选择文件。
1 | <input type="file" class="postImage"> |
选择文件后可以通过e.target.files
获取到文件对象File数组
为什么是files
呢,因为如果我们给input标签添加multiply属性,是允许选择多个文件的,也就是多文件上传,不过这要求用户有一定的电脑操作基础,要知道如何选择多个文件。所以开发过程中,使用的方案其实是多次单文件上传
,用一个数组存储每次循环的选择的文件对象。
File对象

常见属性
- size属性:表示文件的字节数(B),可用来限制文件的大小
- type属性:表示文件的类型
和Blob对象的关系
属于Blob类的子类,二者可以随意转换;
1 | new Blob([file]);new File([blob],filename) |
案例:将网络图片转换成File对象
1 | import axios from 'axios' |
简单来说,将网络图片转换成file对象,先要把这个图片下载下来,获得这个图片的二进制数据,然后再逐步转换成File对象。
输出的response格式如下:

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 | //on-change事件,图片选择后触发该回调函数 |
Blob对象也可以使用FileReader的语法
URL.createObjectURL(file/blob)
URL.createObjectURL
会生成一个指向 Blob 或 File 对象的临时 URL
。这个 URL 可以被用作 <img>、<video>、<audio>、<a>
等 HTML 元素的 src 或 href 属性,用来展示。
允许在不暴露文件的实际路径(网络图片)或内容(base64格式的图片)
的前提下,显示文件,增加了安全性
对象 URL 是临时的
,浏览器会自动在页面卸载
时释放这些 URL。但是,为了确保最佳性能和避免内存泄漏,你应该在不再需要时,显式调用 URL.revokeObjectURL
。
举个例子:
1 | <body> |
1 | blob:http://<origin>/<unique-identifier> |
blob:
指定了这是一个 Blob URL scheme(格式)。http://<origin>
,当前页面的源
,例如http://example.com
。对于直接打开的页面,就是null
<unique-identifier>
是一个唯一标识符,用来区分不同的 Blob 对象。这个标识符是由浏览器自动生成的,保证在同一页面中每个通过createObjectURL
创建的 URL 都是独一无二的。

FormData
1 | const fd = new FormData() |
FormData
对象允许你构造一组键/值对,这组键/值对可以被轻松地序列化为application/x-www-form-urlencoded
或multipart/form-data
格式,非常适合用来模拟 HTML 表单提交。使用
append()
方法可以向FormData
对象中添加字段或文件。当
FormData
包含文件(File)时,axios
会自动设置请求头Content-Type
为multipart/form-data
,这是文件上传的标准格式。即便是一个和FormData对象内容
完全一致
的不同对象也做不到这点。
图像展示方法
- 拿到本地图片file对象,转换成
base64格式
的图片(由图片文件数据编码而来的一个字符串) - 拿到本地图片file对象,生成一个
临时url
(只能用来展示) - 网络图片链接,会自动发送一个请求获取图片
文件可上传格式
- file/blob(二进制)
- base64(即可展示又可上传,无敌了)
前端网络安全
XSS
跨站脚本攻击(Cross-Site Scripting,简称XSS),不叫css主要是为了和防止和层叠样式表(Cascading Style Sheets, CSS)混淆。
只有动态页面才会受到xss攻击,而纯静态页面则不会
XSS攻击危害包括:
- 窃取cookie
- 劫持流量
- 插入广告
- 置入木马
- 获取用户信息
注入方式主要包括:
- url参数
- 用户输入
简单的来说,只要是用户输入的地方,就是不安全的
xss可分为以下几类:
储存过程
- 反射型:浏览器提交恶意代码到服务端 ,服务端将恶意代码传回客户端
- 储存型:浏览器提交恶意代码到服务端,服务端将恶意代码储存到数据库
- DOM型:恶意代码仅在客户端运行
其中反射型和dom类型主要通过url参数的方式注入,存储型造成的危害是最持久的,通常通过用户输入的方式注入。
那如何防止呢?
其他
src属性和href属性的区别
src属性
src
(source 的缩写)属性主要用于嵌入外部资源到当前文档中。例如,图像、脚本、框架、音频、视频等元素。
1 | <img src="image.jpg" alt="描述图片"> |
href属性
href
(hypertext reference 的缩写)属性用于定义超链接的目标 URL。它可以出现在多种元素上,如<a>
、<link>
、<area>
等,用来指向另一个网页、文件、同一页面内的不同位置、样式表、JavaScript 文件等对于
<a>
标签,它指定了用户点击链接后应导航到的位置;对于<link>
标签,它通常用于引入外部资源,如 CSS 文件,告诉浏览器获取并应用这些资源来渲染页面。
区别
src
用于嵌入外部资源到文档中,而href
则用于创建超链接或引用外部资源而不直接嵌入文档。简单来说,一个是嵌入外部资源到文档,一个链接外部资源到文档。- 使用
src
时,浏览器需要下载并处理资源,然后将其插入到文档流中,这个过程可能会暂停 HTML 解析(就是script标签);而使用href
时,特别是对于<link>
标签,浏览器可以异步加载资源,并且不会阻塞 HTML 解析器。