JavaScript
JavaScript
基础
js是创始人Brendan Eich于1995.5在10天内设计的一种宽松脚本语言,目的是让网页开发者能快速上手,因此你能看到许多从其他语言得到的借鉴灵感和不少不合理的历史遗留--因为现在改变规则会使上亿的老页面发生改变。标准协会TC39负责js维护和规范js主要有两部分相关内容ECMAScript标准是js的主要基础规范,最出名的是ES6(2015),从这个版本之后改为每年迭代,比如ES2017Web API:浏览器中有很多可用的API。它们建立在核心JavaScript语言之上,为使用JavaScript代码提供额外的超强能力- 浏览器
API内置于Web浏览器中,能从浏览器和电脑周边环境中提取数据,或者通过浏览器完成复杂的操作,通过特定的抽象化的接口,调用浏览器实现的底层低级代码 - 第三方
API不会内置,通常需要从web中某个地方获取代码和信息,允许集成其他平台页面功能
- 浏览器
常见的页面
DOM操作就由Web API定义(定义了表示和修改文档的对象,描述了处理网页内容的方法和接口)。之前曾使用BOM用于描述浏览器和提供的对象接口集合,概念出现于90年代。现代浏览器中BOM的操作已经被并入Web API不同功能中,基本不再使用,Web API不仅覆盖了原有的BOM能力还提供了更丰富的功能MDN文档,尽可能阅读英文版,内容更新更全面
基本操作
变量作用域:在
js中有局部作用域和全局作用域- 局部作用域有两种情况,函数内部作用域和块级作用域
{}(自己添加{}包裹的块),局部作用域中的变量仅在当前作用域有效。局部变量会在局部作用域结束后销毁回收 script标签和.js文件就是全局作用域,在此声明的变量在任何位置都可以访问,应该尽可能少地添加全局变量,不推荐在函数中使用var或直接在全局变量比如window上挂载变量。全局变量一般不会销毁,在页面关闭后才回收- 正常情况下,局部作用域会发生嵌套。在执行时会优先在当前作用域中查找变量,找不到会依次查找父级作用域,直到全局作用域,找不到会得到
undefined
- 局部作用域有两种情况,函数内部作用域和块级作用域
定义变量
var变量将声明到全局作用域或当前函数作用域中- 同作用域内可以重复声明,每次仅保留最新的值;之前的声明不在本作用域,将会创建当前作用域的新变量
- 不会受到块级作用域限制,可以在块级作用域外访问到,因此
for(var i = 0; i < 10; i++)中每次块级作用域中得到的都是同一个变量 var声明的全局变量会绑定到全局变量上,比如window上var声明会出现变量提升的情况,在赋值之前得到的是undefined
let和const的变量是局部变量,只能在当前作用域中访问,会受到块级作用域限制- 不允许重复声明,重复声明会报错
let和const变量不会绑定到全局变量上,比如window上- 没有变量提升特性,虽然也会被提升到作用域顶部,但存在暂时性死区,在定义之前访问会报错
// 全局变量,非函数定义,会同步挂载到window对象,window.a // 函数中定义,仅在当前函数作用域有效 // 无块级作用域 var a = 1; // 局部变量,仅在当前作用域或循环轮次有效 let b = 2; // 局部常量,仅在当前作用域或循环轮次有效 const c = 3;垃圾回收
- 数据内存分配:垃圾回收机制作用在堆空间
- 堆:存放所有对象(函数也是对象)、数组,需要垃圾回收,无法手动释放
- 常量池:在堆中有一个区域存放字符串和数字常量,减少占用
在部分情况下,存在需要手动释放的外部资源,但这些资源不属于
js内存,比如webGL的部分资源,Nodejs:fs打开的文件句柄 - 栈:存放基本数据类型(包括
number,boolean,null,undefined,symbol,bigint,string)、对象引用,由操作系统分配释放
- 堆:存放所有对象(函数也是对象)、数组,需要垃圾回收,无法手动释放
- 常见的浏览器垃圾回收算法
引用计数算法:
IE使用,如果一个内存不再有指向他的引用,回收对象,算法要求跟踪内存引用(创建变量记录,如果一个内存有添加引用就累加1,减少引用就减1),引用为0则回收。如果多个对象互相存储了引用,则无法回收function fn() { let a = {}; let b = {}; // 相互引用,无法回收 a.b = b; b.a = a; }标记清除算法:现代浏览器使用基于此方法的改进算法,将不再使用对象定义为无法到达的对象,从js全局变量出发,扫描内存中的对象,凡是能到达的对象,都是还需要使用的,其他无法到达的对象则被标记为垃圾对象,稍后进行回收
基本流程语句
if语句for语句switch语句while语句do...while语句let condition = true; let expression = 0; if (condition) {} else {} for (let i = 0; i < 10; i++) {} // 获取对象或数组中的值,需要对象可迭代 for (let v of array) {} // 获取索引键字符串,数组返回数字索引 for (let k in object) {} switch (expression) { case 0: break; case 1: break; } while (condition) {} do {} while (condition);
事件循环机制
- 由于
js被设计出来是用于处理用户交互、操作DOM的脚本语言,多线程会产生很多同步问题,因此浏览器的代码执行和渲染操作被设计为是单线程的,这也奠定了js的语言基础,应该不会再改变了 - 为了更好地实现功能,
js支持异步代码,为了更好地处理异步代码,浏览器为代码执行和渲染设计了事件循环Event Loop机制- 事件循环机制中所有的代码都被规范分类为了宏任务和微任务
- 宏任务
macro task:包括setTimeout、setInterval、I/O、事件触发回调等,这些任务现在可能分门别类,划分到不同的队列中,渲染主线程会根据规定的优先级选择其中的任务执行 - 微任务
micro task:包括Promise.then、MutationObserver、queueMicrotask(等效Promise.resolve().then())等,微任务优先其他的宏任务执行,浏览器将微任务添加到一个单独的队列中
- 宏任务
- 所有的
js代码实际上是在渲染主线程的调度下执行的,渲染主线程通常是一个永远不会停止的循环,会不断从队列中取出任务交给js引擎执行- 所有的任务都需要在主执行栈上执行,在同步代码执行的过程中,所有异步代码会被浏览器保管起来,在条件满足后由其他支持线程包装分类,放入宏任务(有的地方也称消息队列,标准中的说法是
task queue)和微任务队列中。每个队列中任务是先进先出的 - 每个队列的任务不存在优先级,但有多个不同的队列,队列存在优先级,其中微任务队列的优先级最高,其他宏任务队列的优先级高低依赖浏览器实现
- 宏任务中的异步代码都是需要
js执行线程外提供支持的方法,比如事件触发线程、定时器线程、网络请求线程提供的方法,这些回调会在条件满足时被添加到任务队列中,等待被执行
- 所有的任务都需要在主执行栈上执行,在同步代码执行的过程中,所有异步代码会被浏览器保管起来,在条件满足后由其他支持线程包装分类,放入宏任务(有的地方也称消息队列,标准中的说法是
- 开始时,主程序同步代码作为宏任务队列中的第一个任务进入执行栈执行。宏任务执行完毕(执行栈清空)后,事件循环进入微任务队列,依次清空所有的微任务之后,再等待渲染主线程取出宏任务队列中的下一个宏任务,开启下一轮循环
- 事件循环机制中所有的代码都被规范分类为了宏任务和微任务
- 一轮事件循环完成之后,如果需要更新一帧画面,渲染主线程会进行一次视图渲染,渲染间隔和屏幕帧率有关,在渲染之前先执行所有的
requestAnimationFrame回调,如果代码执行耗时过长阻塞了渲染,会造成视觉卡顿
通过事件循环机制,实际上异步的浏览器本身,而
js引擎的执行依旧是同步的基本操作符
- 基本运算:
+、-、*、/、%、**(幂运算)- 其中
+可以为单目运算,表示转换为数字,+"1"得到数字1 %会保留数字符号+支持拼接字符串,其他的运算符遇到字符串会得到NaN
- 其中
- 比较:
==、===、!=、!==、>、<、>=、<= - 逻辑:
&&、||、! - 位运算:
&、|、^、~、<<、>>、>>>- 位运算支持32位整数,会向0取整自动转换为整数,更大范围的数字溢出会被截断
- 赋值:
= - 判断特定键字符串是否存在:
in('name' in window)
- 基本运算:
;的添加,js允许在语句后面添加;分隔不同语句,在除了可能导致语义不清的情况下,;可以省略,要注意:for循环中循环条件中必须通过;分隔以
[]、()的语句,为了和之前的语句形成分隔,必须在前面添加;let a = 1, b = 2; // [] 开头的数组 [a, b] = [b, a]; // () 开头的立即执行函数 (function () {});{}开头作为变量直接进行运算的情况,需要添加()修改执行顺序,当然一般这样也没有效果,无内容被修改// 被浏览器理解为代码块 {}; +[]; // + 支持是一元的,相当于 Number([]) {} + [] // 需要添加() ({} + [])return、throw关键字后会自动添加;,如果需要返回值必须在同一行写,throw换行会报SyntaxError: Illegal newline after throw,但return换行不会报错
变量类型
Number:数字,包括整数和浮点数
let a = 1, b = 2.0;- 安全整数范围:,在这个范围外的整数会发生精度丢失,结果不一定正确,因为整数是使用
IEEE754浮点数存储的,仅尾数位(还有一个隐含位)用于存储数字 - 存在特殊全局变量
Infinity(正无穷大)、NaN(无意义数字),用于表示特殊值Infinity:正无穷大,比任何数都大,可以添加-表示负无穷大,通过Number.isFinite(value)判断是否为无穷大NaN:无意义数字,NaN不等于任何值,包括NaN自身,可以通过Number.isNaN(value)判断是否为NaN- 还有
+0和-0两种,在正常情况下,二者会被==和===判定为相等,如果需要更严格的判断,可以使用Object.is(a, b),见Object
BigInt大数ES2020:可以解决大整数精度丢失问题,需要在整数数字结尾添加n,如let a = 1n;,支持加减乘除取余幂运算(向下取整),支持除了>>>外的位运算,但不能和普通数字混合运算,也无法被序列化为json字符串
序列化和反序列化
- 序列化:把数据结构或对象转换成一种可以存储或传输的线性格式(通常是字符串或字节流)的过程,以便之后可以恢复原来的数据结构(反序列化),一般通过
JSON.stringify(obj)和JSON.parse(str)实现
const obj = { name: '张三', age: 18 } const json = JSON.stringify(obj) // '{"name":"张三","age":18}' console.log(json) console.log(JSON.parse(json)) // {name: '张三', age: 18}- 不支持序列化的内容
Symbol、BigInt和函数类型- 循环引用
undefined:作为属性会被忽略、作为数组元素会转换为null- 其他非可枚举类型:被隐藏的内部实现(属性描述符中
emuerable为false)。属性描述符被用来描述属性的元信息,告诉引擎这个属性的行为规则,具体操作见变量特性
- 安全整数范围:,在这个范围外的整数会发生精度丢失,结果不一定正确,因为整数是使用
String:字符串
let a = 'hello world';,字符串可以使用'、"、`包裹,`支持模板字符串写法,模板字符串可以使用${}包裹变量,比如let a = `${name}`;Boolean:布尔值,
let a = true, b = false;Undefined:未定义值
let a;Null:有意义的空值
let b = null;Symbol:唯一标识符
const s = Symbol("id");- 可以通过
s.description获取描述符"id" - 每次创建的
Symbol值都是唯一的,永远不相等 - 同时
Symbol作为属性键是不会参与遍历的,避免干扰普通对象的遍历和序列化逻辑,可以安全地隐藏 - 如果希望遍历
Symbol属性,可以通过Object.getOwnPropertySymbols(obj)获取到包含所有Symbol属性键的数组
- 可以通过
Object:引用类型,比如数组
[]、函数function(){}、普通对象{}- 数据都保存在堆内存(
Heap)中,并在栈内存(Stack)中保存一个引用指针 - 使用
{}直接创建的对象相当于直接使用new Object({})创建,是一种语法糖 - 引用类型的属性将按写的顺序逐个初始化,因此如果有属性依赖其他属性,那么属性的初始化顺序可能会对结果有影响
除
Object外,其他类型称为基本类型或原始值类型Primitive Types,数据会按值传递栈中的变量存储方式
- 现在不同引擎采用的存储方式不太相同
NaN-Boxing:即NaN装箱,使用IEEE754双精度浮点数统一表示所有的基本类型,用于对齐处理方式。在IEEE754中,规定非数字NaN需要阶码全1,但没有规定尾数需要是多少,因此可以将此时的非0尾数进一步划分,将所有的其他非数字类型存入这52位尾数中tag pointer:在js中,引用地址一般为8B(64位),但一般不会用上全部的地址空间,这样就可以使用未使用空间存储其他信息,比如v8将最低位用于堆地址和立即数类型的判断
- 数据都保存在堆内存(
基本类型在使用方法时,
js会临时创建对应的包装对象(Number、String、Boolean等),这些对象有扩展的方法,调用完方法后对象立即销毁
类型判断
在js中主要有三种方法可以判断类型
typeof:返回类型字符串仅用于获取基本类型字符串,所有对象都是获取到
object且无法区分
null和非function类型的object,函数的判断是规范中规定的特意处理(有[[Call]]属性的可调用对象返回function)[[key]]这样的属性被称为内部槽,这些属性仅能被引擎处理,无法通过js直接定义和处理typeof 123 // "number" typeof 'abc' // "string" typeof true // "boolean" typeof undefined // "undefined" typeof Symbol() // "symbol" typeof 10n // "bigint" typeof function(){} // "function" typeof class A {} // "function" typeof {} // "object" typeof [] // "object" typeof null // "object" (历史遗留问题,null读取到类型标记和对象类型一致)
instanceof:判断对象是否是某个构造函数的实例,返回布尔值会沿着构造函数的原型链查找,直到找到
null,看能否匹配跨窗口或
iframe判断时永远是false,因为不同的全局环境有不同的构造函数无法判断基本类型,对于基本类型永远返回
false[] instanceof Array // true [] instanceof Object // true new Date() instanceof Date // true new Date() instanceof Object // true
Object.prototype.toString():返回对象类型的字符串表示,最精准的判断方法返回字符串表示,不受跨窗口和
iframe的影响准确获取对象的类型,且对基本类型也有效果
实际上获取的是
[Symbol.toStringTag]属性的值,这个值在ES6新增用于类型判断对于早于
ES6不支持的[Symbol.toStringTag]属性的类型,有特殊的标签对应:Array、Function、Error、Boolean、Number、String、Date、RegExp对于自己定义的类型,默认为
[object Object],可以通过设置[Symbol.toStringTag]属性来改变实际上因为这个方法挂载在原型链上,在未定义类的
toString函数情况下,对象可以直接使用到这个方法[Symbol.toStringTag]的中括号表明这个属性的键是Symbol类型,需要通过中括号才能访问到,这个键是全局的,可以直接使用
Object.prototype.toString.call(123) // "[object Number]" Object.prototype.toString.call('abc') // "[object String]" Object.prototype.toString.call(true) // "[object Boolean]" Object.prototype.toString.call(undefined) // "[object Undefined]" Object.prototype.toString.call(null) // "[object Null]" Object.prototype.toString.call([]) // "[object Array]" Object.prototype.toString.call({}) // "[object Object]" Object.prototype.toString.call(new Date()) // "[object Date]" Object.prototype.toString.call(/abc/) // "[object RegExp]" Object.prototype.toString.call(() => {}) // "[object Function]" // 自定义 Symbol.toStringTag class ValidatorClass { // get 函数设置值,当然也可以使用 this[Symbol.toStringTag] 设置值 get [Symbol.toStringTag]() { return "Validator"; } } Object.prototype.toString.call(new ValidatorClass()); // "[object Validator]"
类型转换
js作为一门弱类型语言,意味着你可以经常使用与预期类型不同的类型的值,由js为你把它转换为正确的类型。js为此定义了一些强制转换规则对象参与运算时会考虑进行原始类型转换,依次调用
[Symbol.toPrimitive]()、valueOf()和toString()方法- 其中
valueOf()和toString()谁先调用依赖期望返回的类型,如果是字符串,则优先调用toString();如果是数字类型,则优先调用valueOf();其余情况除了Date对象外,其他对象都先调用valueOf() [Symbol.toPrimitive](hint):方法获取到一个字符串,表示转换的提示,可以是"string"、"number"、"default",根据不同提示可以选择返回不同值valueOf():意为返回原始类型的值,默认返回自身,但在转换时不应该返回对象类型,对象类型的返回值会被忽略,接下来会继续考虑toString()toString():返回字符串类型的值
// valueOf() 被忽略的例子 // 默认返回自身 {} + [] // 被忽略后使用 toString(),得到 "[object Object]" + "" console.log({} + []) // 如果直接{}+[]在控制台会发现是 0 // 使用 {} 开头被解析为 {}; +[] // Number([]) 实际上是 Number('') 结果是 0- 其中
可以自己调用包装对象函数进行类型转换,比如
Number('123')js中有一元加法运算,比如+'123'实际上就是Number('123')。BigInt为了和asm.js兼容,不支持单目加法运算,需要使用函数形式调用Number方法会先尝试获取到一个原始类型值- 对于
null和空字符串''会返回0 - 其他的
string会尝试将字符串转为数字,如果无法转换,则返回NaN undefined会返回NaNboolean会返回1或0- 其他对象期望获取到数字
number,进行原始类型转换
- 对于
算术运算
+时,如果一方是字符串,全部使用字符串运算,否则转换为数字进行运算,- * /中有字符串会得到NaN因此只会转换为数字进行运算宽松相等
==或<、>等比大小运算时,会触发类型转换- 字符串或
Boolean类型和数字比较:字符串或Boolean类型会转为数字进行数字比较 null和undefined相等- 对象也会转换为原始值计算,需要的类型依赖另一边的原始类型,如果两个都是对象不转换,根据引用是否相同判断
如果希望同时比较类型是否相等,需要使用
===严格相等- 字符串或
逻辑运算符的返回值:逻辑运算符会将操作数转换为布尔值再判断真假
- 除了
!返回布尔值外,其他逻辑运算符返回最后一个被判断的值,比如0 || 1返回1、0 && 'hello'返回0,而且返回的是原操作数的引用值
- 除了
BigInt向number类型的自动转换:在需要传入number类型时,在数据大小允许的情况下,允许BigInt数据类型自动转换为number类型进行运算,比如array.splice(0, 1n)
变量特性
- 引用变量常见特性如下
属性可写性:
writable属性- 不可写的属性不能被修改
属性特性的设置方法
- 属性可写性、可枚举性和可配置性,也就是属性描述符都是通过
Object.defineProperty方法配置的
Object.defineProperty(person, 'age', { value: 25, // 属性值 writable: false, // 不可修改 enumerable: true, // 可枚举 configurable: false // 不可删除或重新定义 // 访问器属性,与 value 和 writable 描述符不能同时使用 // get() { return 25; }, // set 的调用者是属性所属的对象 person // set(value) { this.age = value; }, }); // 查看 Object.getOwnPropertyDescriptor(person, 'age');属性可枚举性:
enumerable属性- 不可见的属性不会被枚举到键,比如
for in和Object.keys() Object.getPropertyName方法和Object.getPropertySymbol方法不受影响- 使用反射
Reflect.OwnKeys获取也不受影响
- 不可见的属性不会被枚举到键,比如
属性可配置性:
configurable属性- 在为
true时,可以使用delete obj.prop删除属性,也可以修改属性修改它的类型(比如改为使用访问器get和set方法定义,访问器的使用见访问器属性) - 在为
false时,属性无法被删除、不能修改它的类型、也不能再使用Object.defineProperty配置
- 在为
- 对象可扩展性:
Object.proventExtensions(),阻止新增属性。可以通过Object.isExtensible()判断对象是否可扩展 - 对象密封性:
Object.seal(),禁止新增删除属性,并无法设置属性描述符,相当于configurable: false。可以通过Object.isSealed()判断对象是否密封 - 对象冻结性:
Object.freeze(),对象和属性完全只读,但深层嵌套属性仍可更改。可以通过Object.isFrozen()判断对象是否冻结`
| 方法 | 能新增属性? | 能删除属性? | 能修改属性值? | 能改属性描述符? | 属性configurable变为 | 属性writable变为 |
|---|---|---|---|---|---|---|
Object.preventExtensions | ❌ | ✅ | ✅ | ✅ | 不变 | 不变 |
Object.seal | ❌ | ❌ | ✅ | ❌ | false | 不变 |
Object.freeze | ❌ | ❌ | ❌ | ❌ | false | false |
可迭代性:要成为可迭代对象,对象需要实现
[Symbol.iterator]()方法,此方法返回一个迭代器对象- 可迭代对象可以使用
for...of循环 - 迭代器对象中必须实现
next()方法,方法返回一个对象,包含value和done属性,value为迭代器返回的值,done为迭代器是否结束,也就是迭代器结束需要返回{value: undefined, done: true} - 当然迭代器对象可以使用
function*生成器函数语法糖实现,生成器函数会在有yield时停下并- 将结果作为
value返回给使用的场景,同时设置done: false - 当没有更多迭代器
yield返回时返回{value: undefined, done: true}
- 将结果作为
生成器函数可以更好地实现状态机代码,更方便实现需要大量暂停和回复状态的迭代器模式,语法更简洁
const obj = { a: 1, b: 2, c: 3, [Symbol.iterator]() { const entries = Object.entries(this); let index = 0; return { next() { if (index < entries.length) { const [key, value] = entries[index++]; return { value: [key, value], done: false }; } else { return { done: true }; } } }; } }; // 生成器函数写法 const obj = { a: 1, b: 2, c: 3, *[Symbol.iterator]() { for (const key of Object.keys(this)) { yield [key, this[key]]; } } };- 可迭代对象可以使用
数组
在
js中,数组可以存储任意类型的数据,并且可以随意扩大长度和删除元素- 数组实际上是一种对象,只是引擎内部做了一定的优化
- 有一些非负整数数字索引和一个表示数组长度的属性
length,length的值自动为最大的索引值加1
实际上数组结构可以通过对象实现,这样的对象被称为类数组对象
- 类数组对象:结构上类似数组,但不是
Array实例的对象,因为许多数组作用的方法和...解构语法只是读取特定数据,因此可以像一般数组一样使用,这些实例:- 有整数索引属性
- 有
length属性 - 无其他数组原型的方法,需要使用
Array.prototype.toString.call(arrayLike)才能调用这些方法 - 如果希望判断是不是数组,可以使用
Array.isArray()方法
const arrayLike = { 0: 'a', 1: 'b', 2: 'c', length: 3 }; console.log(arrayLike[0]); // 'a' console.log(arrayLike.length); // 3 console.log(Array.isArray(arrayLike)); // false // 作为数组处理 console.log(Array.prototype.join.call(arrayLike, '-')); // 'a-b-c' // 转为数组的方法 const array = [...arrayLike] const array1 = Array.from(arrayLike)- 类数组对象:结构上类似数组,但不是
js的数组不同于其他编程语言的数组,数组的元素可以是稀疏的,在引擎中会切换到哈希表存储,输出时会明显看到空位(empty slot)- 数组长度不会因为稀疏而改变,保持为最大的索引值加1
- 读取空位时会获取到
undefined - 数组方法对空位的处理:因为方法可能被稀疏数组使用,比如
[1,,3].forEach(callback),因此较久的方法考虑了空位的处理(跳过),新方法不处理空位(作为undefined),具体见数组方法与空位 for in和for of也会跳过空位
const a = [1, , 3]; // [1, <empty item>, 3] console.log(a.length); // 3
函数
函数声明
function声明- 匿名函数作为变量赋值
- 立即执行函数
- 箭头函数,
ES6新加,不创建函数arguments、不能作为构造函数、不可new、不能继承(因此没有this、prototype)
// 函数声明 function func() {} // 匿名函数作为变量赋值 const func = function () {}; // 立即执行函数 (function () {})(); // 箭头函数 const func = () => {}; // 只有一个参数时,可以省略小括号 // 只有一行代码时,可以省略大括号,且无需写 return,默认返回 const func = a => 2 * a函数的
this除箭头函数外,函数创建的
this指向调用它的对象- 箭头函数不会创建
this,会随作用域不断向上一级查找,直到找到有this的作用域,因此像事件回调函数希望获取到节点信息,最好使用普通函数 - 通过
function定义的函数会挂载在window对象上,如果指明了window.fn,this指向window对象,如果未指明,则this在非严格模式会修改到全局对象 - 如果是
new执行的创建对象的构造函数,this指向新创建的对象 - 如果是通过对象绑定的函数,
this指向对象,比如obj.f()、f.bind(obj)()、f.call(obj)
改变指向
在
js中,bind()、call()、apply()都可以改变this的指向(在不使用箭头函数的情况下使用外部this的方法)bind(thisObj, ...arg):返回一个新函数,新函数的this指向绑定的对象- 新函数的前几个参数会固定为当前传入的参数列表中的参数,之后使用仅需要传入剩余的参数,也就是类似函数柯里化
- 使用
new时,指向会被修改为新创建的对象,此时this指向的修改相当于没做 - 对箭头函数无效,因为箭头函数不产生
this - 方法实际上通过原型继承
call(thisObj, ...arg):立即执行,并改变this的指向,arg是函数参数列表- 方法和下面的方法实现类似,只是参数列表传入方式不同
- 两个方法都不能对箭头函数(无效)和
new情况(一般报错,因为立即执行返回值一般不是构造函数)做处理 - 两个方法实现原理实际上是将
thisObj转换为对象,然后将函数挂载在对象上,最后调用对象上的函数,返回返回值,最后删除挂载
apply(thisObj, array):立即执行,并改变this的指向,主要用于传递数组值作为函数参数
callFunction.prototype.call = function (context) { // 基础类型转包装对象 if (context === undefined || context === null) { context = window } else if (typeof context === 'string') { context = new String(context) } else if (typeof context === 'number') { context = new Number(context) } else if (typeof context === 'boolean') { context = new Boolean(context) } // 保存原函数至指定对象的fn属性上 context.fn = this // 获取除第一个参数之后的所有参数 const args = Array.from(arguments).slice(1) // 通过指定对象的fn属性执行原函数并出入参数 const fnValue = context.fn(...args) delete context.fn // 从context中删除fn原函数 return fnValue }bindFunction.prototype.bind = function (context) { // 保存原函数 const ofn = this // 获取除第一个参数之后的所有参数 const args = Array.from(arguments).slice(1) function O() {} function fn() { // 第一个参数的判断是为了忽略使用new实例化函数时让this指向它自己,否则就指向这个context指定对象 // 第二个参数的处理做了参数合并, 就是 bind & fn 两个函数的参数合并 ofn.apply(this instanceof O ? this : context, args.concat(Array.from(arguments))) } O.prototype = this.prototype fn.prototype = new O() return fn }applyFunction.prototype.apply = function (context, arr) { // 基础类型转包装对象 if (context === undefined || context === null) { context = window } else if (typeof context === 'string') { context = new String(context) } else if (typeof context === 'number') { context = new Number(context) } else if (typeof context === 'boolean') { context = new Boolean(context) } // 非对象,非undefined,非null的值才会抛错 if (typeof arr !== 'object' && typeof arr !== 'undefined' && typeof arr !== 'null') throw new TypeError('CreateListFromArrayLike called on non-object') arr = Array.isArray(arr) && arr || [] // 非数组就赋值空数组 // 保存原函数至指定对象的fn属性上 context.fn = this // 通过指定对象的fn属性执行原函数并出入参数 const fnValue = context.fn(...arr) delete context.fn // 从context中删除fn原函数 return fnValue }:::
- 箭头函数不会创建
严格和非严格模式的区别
- 非严格模式下,如果不是对象调用,
this会指向全局对象window(浏览器)或Node的一个当前模块对象 - 严格模式下,
this指向不会修改,非对象调用且非箭头函数的this为undefined
- 非严格模式下,如果不是对象调用,
函数参数
- 非箭头函数中有
arguments类数组可迭代变量,包含了所有传入的参数- 在
arguments中有callee属性,对应函数本身,arguments.callee.toString()方法返回函数定义
- 在
- 所有函数支持
...args接受剩余参数,以...开头的传入参数是一个数组,包含剩下的所有参数
传入参数个数和函数定义的参数个数可以不一致,如果无剩余参数,其余的需要通过
arguments访问,如果无传入,参数为undefinedfunction sum(...args) { return args.reduce((a, b) => a + b); }- 函数的默认构建时的参数个数可以通过
length属性获取
- 非箭头函数中有
函数重载:
js中不支持函数函数重载,每次定义函数时,会覆盖之前的定义,但可以通过arguments参数实现类似重载的效果,判断参数个数和类型,实现不同的逻辑
解构赋值
解构的核心是表达式左边的变量都能够对应上右边的表达式,会为左边变量赋值,赋值后的变量与右边变量无关
解构的变量和外部变量一样,变量名不要冲突
数组解构,变量会通过相同位置的数组元素赋值,实际上所有有迭代器方法的对象都可以进行数组解构
- 如果变量没有对应
[a, b] = [1],则b的值为undefined - 支持直接在解构内部通过
=提供默认值[a, b=1],解构成功会覆盖默认值 - 支持
[a, , b]=[1, 2, 3]跳过第二个值,按需导入 - 支持多维解构
[[a, b], c] = [[1, 2], [3, 4]] - 支持剩余参数
[a, ...rest] = [1, 2, 3]获取剩余的所有值,rest会外包数组rest = [2, 3]
let [a, b, c, d=5] = [1, 2]; console.log(a, b, c, d); [b, a] = [a, b]; console.log(a, b); let [aa, , ...rest] = [1, 2, 3, [4], [5, 6, 7]]; console.log(aa, rest);- 如果变量没有对应
对象解构
如果变量没有对应名称的属性,则返回
undefined支持修改别名
{a: b},将对象原有的属性a赋给新的变量b可以进行进一步解构赋值
{a: {b: c}},得到变量c支持直接在解构内部通过
=提供默认值{a=0, c:b=1},解构成功会覆盖默认值支持剩余参数
{a, ...rest} = {a: 1, b: 2, c: 3}获取剩余的所有值{b: 2, c: 3}let {a, b} = {a: 1, b: 2}; console.log(a, b); const { a: { b: c } } = { a: { b: 42 } }; console.log(c); let {name, hobby: {d, e, f}} = {name: '张三', hobby: {a: 1, b: 2, c: 3}} console.log(name, d, e, f); let {aa, bb, ...rest} = {a: 1, b: 2, c: 3} console.log(aa, bb, rest); let {a: [b1, c1], d1=1} = {a: [1, 2, 3]} console.log(b1, c1, d1);
类
原型链
- 原型链:继承和方法共享的实现原理,基于原型对象的继承使得不同构造函数的原型对象关联到一起,关联是链状的结构
每个对象都有一个内部属性
[[Prototype]],这个属性被称为隐式原型(也称为原型对象),指向对象的原型对象,但这种内部槽属性无法直接访问,而需要在每个实例上通过obj.__proto__属性访问或使用Object.getPrototypeOf(obj)方法访问推荐使用函数获取对象原型,而不是直接访问
__proto__属性,__proto__属性为兼容之前的浏览器而保留的属性每个函数都包含一个显式原型
prototype,通过此函数创建的对象实例的隐式原型指向此函数的显式原型function Person() {} let obj = new Person() console.log(obj.__proto__ === Person.prototype)每个函数也都是对象,有隐式原型
__proto__(也称为对象原型)指向Function.prototype,由于Function也是函数,所以Function的__proto__属性指向Function.prototype每个变量都拥有原型,也可以成为原型,原型通过
__proto__循环形成链,直到最后Object.prototype.__proto__为null- 实际上
Person.prototype也是一个对象,如果手动让Person.prototype = new Animal(),那么Person.prototype的__proto__属性指向Animal.prototype,这样就实现了继承 - 别忘了
constructor属性需要修改正确,不然person instanceof Person为false,是Animal对象
function Person() {} function Animal() {} let obj = new Person() console.log(obj.__proto__.__proto__ === Object.prototype) console.log(Object.prototype.__proto__ === null) Person.prototype = new Animal() Person.prototype.constructor = Person // 不推荐直接修改原型,可能有兼容性问题 // Person.prototype.__proto__ = new Animal() while (obj.__proto__) { console.log(obj.__proto__.constructor.name) obj = obj.__proto__ }- 实际上
当访问对象属性时,会先查找对象本身,如果找不到,就沿着原型链
__proto__向上查找,直到null- 因为查找是基于
__proto__属性递归向上的,如果为当前的构造函数Person.prototype.a添加属性,构造函数Person.a是无法访问到这个属性的,必须Person.prototype.a或在实例上才能获取到
原型方法通过实例调用,
this会指向实例对象- 因为查找是基于
可以使用
instanceof来检查构造函数的prototype是否出现在对象的原型链上function Person() {} let p = new Person() console.log(p instanceof Person)
类和构造机制
es6增加了class关键字,在之前类声明是通过function构造函数完成的- 每一个原型对象都有一个
constructor属性,该属性指向该对象的构造函数Person.prototype.constructor == Personclass声明时可以指定constructor属性,如果不指定也会有默认的值,永远指向类本身,直接修改构造函数的prototype属性无效constructor属性只有一个值,因此每个类仅一个构造函数- 早期直接通过构造函数创建时,
constructor可以被后续修改,如果将对象原型完整地重新赋值时Person.prototype = {...},此属性会丢失,最好在完整赋值时添加constructor,指向构造函数
constructor可以用于判断对象类型是否相同
class Person { // 如果直接添加属性,不需要使用 let age = 18; // 静态属性 static type = 'Person'; // 构造函数 constructor(name) { this.name = name; // 实例属性 } // 原型方法 greet() { console.log(`Hello, I'm ${this.name}`); } // 静态方法 static greet() { console.log('Hello, I\'m a static method'); } } // 早期创建方式,构造函数 function Person(name) { this.name = name; // 方法会添加到实例上,因此每个实例都会创建一遍 this.greet = function() { console.log(`Hello, I'm ${this.name}`); } // return this; } Person.greet = function() { console.log('Hello, I\'m a static method'); } Person.type = 'Person'; // 挂载在原型上,就是所有实例共享,不需要多次创建 Person.prototype.greet = function() { console.log(`Hello, I'm ${this.name}`); }this指向
在
function声明类时最好不要直接调用Person()这样的构造函数,在非严格模式如果利用和返回this,会得到window对象,操作将影响全局作用域,比如原型链和函数this中的示例function Person() { this.name = 'Tom'; } Person(); console.log(window.name);私有属性:
ES6之前,私有属性一般都通过闭包实现ES6引入了WeakMap可以用于实现实例到变量的转换;Symbol类型可以作为唯一键,如果无法获取到键就无法访问到对应的值WeakMap和WeakSet只允许将可垃圾回收的值作为键,这些键要么是对象,要么是未注册的symbol,即使键仍在集合中,也可能被回收,详见js中的哈希结构ES2022才添加#,#name属性会被认为是私有的
// 闭包实现 function A(name) { let _name = name; return { getName() { return _name; }, setName(v) { _name = v; } }; } const _weakName = new WeakMap(); const _symName = Symbol("symName"); class Person { #hashName; // ES2022 私有字段 constructor(name) { // 分别初始化三种私有存储方式 _weakName.set(this, name + " (WeakMap)"); this[_symName] = name + " (Symbol)"; this.#hashName = name + " (#Private)"; } // Getter(访问不同私有属性) getWeakName() { return _weakName.get(this); } getSymbolName() { return this[_symName]; } getPrivateName() { return this.#hashName; } // Setter(修改不同私有属性) setWeakName(value) { _weakName.set(this, value); } setSymbolName(value) { this[_symName] = value; } setPrivateName(value) { this.#hashName = value; } }
- 每一个原型对象都有一个
new操作:- 创建一个空对象
- 将空对象的
__proto__指向函数或类的prototype - 构造函数的
this指向这个空对象 - 执行构造函数内部的代码
- 如果构造函数返回一个对象,则返回这个对象,否则返回创建的对象
虽然
new运算符支持在不添加参数时不带括号,比如new Foo等效new Foo(),但new运算符不带括号时优先级没有.高,因此new Foo.bar()会先执行Foo.bar获取静态方法,再执行new操作创建对象的三种方式
- 使用
new操作符,通过构造函数创建,比如可以使用内置构造函数(创建包装类型,如Number、String、Boolean、Array),原型对应构造函数 - 使用
{}直接得到一个对象,原型固定是Object.prototype - 使用
Object.create()创建对象,指定原型,不使用构造逻辑,还可以使用可选属性描述参数,类似Object.defineProperty
const obj = Object.create(Object.prototype, { x: { value: 10, writable: true, enumerable: true, configurable: true } }); console.log(obj.x); // 10属性覆盖
在初始化创建属性时,由于已经挂载了
prototype属性,因此看起来会存在使用原型属性的情况,实际上属性覆盖表明了属性的修改存在隔离- 我们都知道对于实例可以通过原型链使用上面的属性
- 对于普通属性来说,实例创建同名属性
name时,原型上的name不会被修改,之后根据优先级也不能通过obj.name被访问和修改 - 对于通过
Object.defineProperty中的get和set定义的属性来说,实例赋值不会创建同名的属性,这个属性是通过原型链上的get获取的,修改会调用set方法值得注意的是,在控制台输出时,看起来好像当前的实例上有一个同名且是使用get方法获取到的属性,但这是可能是方便查看而展示的,如果使用
Object.getOwnPropertyDescriptor(o, 'a')会发现实例上没有这个属性,但在原型o.__proto__上使用是有的
function A () { this.a = 1 this.b = 1 } let a = 2 Object.defineProperty(A.prototype, 'a', { get () { return a }, set (val) { a = val } }) let o = new A() let b = 2 Object.defineProperty(A.prototype, 'b', { get () { return b }, set (val) { b = val } }) console.log(a) // 被修改为 1 console.log(b) // 保持原值 2 // 发现实例上有一个通过 get 得到的属性 a // 结果:A{ b:1 } 1 1 1 2 console.log(o, o.a, o.b, o.__proto__.a, o.__proto__.b) // undefined { enumerable: false, configurable: false, get: ƒ, set: ƒ } console.log(Object.getOwnPropertyDescriptor(o, 'a'), Object.getOwnPropertyDescriptor(o.__proto__, 'a'))使用
class定义且预声明了这个属性时,get和set方法定义的属性行为就和一般属性一样了,在实例对象和原型上有两个属性。如果是ts编译的话,目标版本高于es2021时编译结果会添加a的声明class A { a; // 声明后,默认有这个属性,初始值 undefined }
继承
继承:由于变量会根据原型链向上访问,因此可以基于原型链设计继承关系
function Person() { this.name = 'Tom'; this.age = 18; } function Student() {} Student.prototype = new Person(); Student.prototype.constructor = Student;避免使用相同的对象作为父对象
- 如果两个原型对象
prototype指向相同的对象,在prototype或对象的__proto__上添加的属性,会添加到这两个原型对象构建的变量的__proto__中,被访问到,没有隔离
// 所有 prototype 都引用同一个变量 // 修改会共享 function Person() {} function Man() {} function Woman() {} Man.prototype = Person Man.prototype.constructor = Man Woman.prototype = Person Woman.prototype.constructor = Woman let man = new Man() let woman = new Woman() woman.__proto__.say = function () { console.log('hello world') } // Man 也会有say man.say()- 如果两个原型对象
get和set方法
get和set方法是js中的访问器属性语法,用于在读取和设置值时执行函数逻辑,等同于Object.definePropertyget在读取属性时执行函数set在设置属性时执行函数,必须有一个参数
const user = { firstName: "Tom", lastName: "Hanks", // getter get fullName() { return this.firstName + " " + this.lastName; }, // setter set fullName(value) { const [first, last] = value.split(" "); this.firstName = first; this.lastName = last; } };
Object实例
- 几乎所有声明的对象都是
Object的实例,所有的默认类声明的上一级prototype都是Object.prototype new Object()方法可以传入一个变量,如果是原始类型会得到一个封装类,如果是一个对象会得到它本身(不会拷贝)- 此方法也可以不加
new,直接调用
- 此方法也可以不加
Object原型链上的方法,这些方法可以直接在实例上调用,影响许多机制和运行行为
代理和反射
异常处理
简单处理
throw抛出异常:在遇到预估会发生错误的情况时,使用throw抛出一个错误对象,避免错误的发生,使程序不会继续运行- 配合
new Error()使用,提供错误信息
function add(a, b) { if (!a || !b) throw new Error('参数不能为空') }- 配合
try...catch:捕获异常,确保之后的无关部分代码继续执行finally块:无论正常执行还是报错,离开try...catch代码块,甚至在try块和catch块中return,都会执行,如果有多次return,以第一次return返回的空间为准return时,会用一个新的引用记录数据,如果是基本类型,后续修改不会影响返回值,如果是引用类型,对属性修改会影响结果
try { add(1) } catch (error) { console.log('捕获异常') // 输出错误信息 console.log(error.message) } finally { console.log('finally代码块') } console.log('后续代码继续执行')浏览器错误调试
debugger:在代码中添加debugger行,浏览器会在对应行暂停代码执行,并进入调试模式,此时可以查看变量的值,并执行代码,直到结束调试模式,可用于快速查看异常原因,似乎会因为在回调中而不起作用
Error 对象
Error对象:Error对象是JavaScript中的通用错误对象,用于创建错误对象,并返回错误信息,构造函数:new Error(message[, fileName[, lineNumber]])message:错误信息,默认为空字符串fileName:错误出现的文件名,默认为出错代码所在的文件名lineNumber:错误出现的行号,默认为出错代码所在行号
Error对象的属性和方法:- 标准属性和方法
name:错误名称,默认为Errormessage:错误信息toString():返回错误信息
- 浏览器拓展的属性和方法,但为了兼容性应谨慎
stack:错误堆栈,返回错误出现的位置,现在一般都支持fileName:错误出现的文件名lineNumber:错误出现的行号columnNumber:错误出现的列号
- 标准属性和方法
全局异常处理
window对象上有一个error事件,在出现错误并没有被捕获时,会触发- 一般用于处理资源加载错误,需要注意的是
onerror属性无法捕获到图片、脚本等资源加载错误(不冒泡),但error事件监听可以设置在捕获阶段触发 - 在跨域脚本出现错误时,为了安全,不会获取到错误信息,仅能得到为
e.message = "Script error" - 通过
e.error属性可以获取错误 Promise的错误不会触发error事件
跨域脚本
跨域脚本通过读取错误信息,可能能用于构造攻击,因此浏览器很早就隐藏了跨域脚本之间通过
error事件获取跨域脚本的错误详情,得到的都是Script error希望解决
Script error需要在script标签中添加crossorigin="anonymous"属性,并在服务器端设置响应头Access-Control-Allow-Origin当然不是所有的脚本都能进行前后端设置,可以在执行前对原生方法进行劫持,在原生方法外添加
catch,因为try...catch处理不会有这样的问题const nativeFetch = window.fetch window.fetch = function () { return nativeFetch.apply(this, arguments).catch(e => { console.log(e.message) throw e }) }因为原生方法有很多,全部劫持完不现实
- 一般用于处理资源加载错误,需要注意的是
Promise的错误处理:window上有一个unhandledrejection事件,在Promise对象已拒绝却未被catch函数错误处理时,会触发这个事件- 因为
Promise是链式的,如果在then或catch中返回了新的拒绝的对象,依旧会触发这个事件 - 事件似乎是在对应的同步代码和微任务执行之后触发的
- 因为
异步请求
Promise
Promise是ES6引入的异步编程的新解决方案。语法上Promise是一个构造函数,用来封装异步操作并可以获取其成功或失败的结果Promise构造函数:Promise((resolve, reject) => {}):resolve和reject是两个函数resolve:异步操作成功时调用,并将异步操作的结果作为参数传递给resolve方法reject:异步操作失败时调用,并将异步操作的错误信息作为参数传递给reject方法
const promise = new Promise((resolve, reject) => { setTimeout(() => { resolve('成功') }, 2000) })
命运与生命周期
Promise对象可能有两种命运,已解决resolved或未解决unresolved- 未解决表明尝试解决或拒绝会对对象产生影响
- 已解决不代表已经完成,可能是等待另一个
Promise对象的结果,在这种情况下,外层的Promise的最终状态将跟随内部Promise对象的状态,获取到的结果是内部Promise对象得到的结果(吸收)
// Promise 对象也可以作为异步操作的结果传递给 resolve 方法 // 当前对象会被锁定,直到获取到新的 Promise 对象的返回结果 // 新的 Promise 对象操作结束时,当前对象会接收到结果,作为本对象的结果 const promise2 = new Promise((resolve) => { resolve(new Promise((_, reject) => { reject('失败') console.log('内部 Promise 拒绝') // 如果在内部直接使用外部的接收函数 // 因为初始化在外部 resolve(promise) 之前会先执行 // resolve('成功') })) })一个
Promise对象有三种状态:pending挂起(初始挂起状态)、fulfilled已兑现(操作成功)、rejected已拒绝(操作失败,可能由错误或其他原因导致)fulfiled和rejected状态将在Promise对象在pending状态时第一次被resolve或reject时发生- 之后代码会继续执行,但再次使用
resolve或reject将不会改变Promise对象的状态和结果 Promise对象实际上执行回调方法和吸收内部Promise对象结果状态都是通过微任务队列完成的,在内部对象已兑现之后,微任务队列会先执行内部对象的then和catch回调,之后让外部对象吸收内部对象状态结果,接下来再执行外部对象的链式回调
在浏览器中输出一个
Promise对象,可以发现有一个内部的[[PromiseState]]内部槽属性,表明Promise的状态,但此属性无法通过编程式的方法获取到
Promise.prototype.then方法:在所有Promise实例都可以调用,来自thenable接口,写法为then((val)=>{}, (err)=>{})Promise.prototype.catch方法:相当与then(null, (err)=>{})Promise.prototype.finally方法:避免重复写在fulfilled和rejected的状态的处理逻辑,finally(()=>{})不接受任何参数,在Promise被敲定(settled,兑现resolved或拒绝rejected)时执行链式调用
then、catch方法会返回新的Promise对象,finally方法会返回原始的Promise对象,因此可以进行链式调用then方法和catch方法中的回调函数返回值将作为新的Promise对象的结果值;如果在执行期间报错,将Error对象作为新的Promise对象结果值;如果不返回,新的Promise对象结果值将未定义undefined- 只有执行回调报错时,新的
Promise对象状态为已拒绝,剩下都为已完成,因此如果希望链式调用,回调必须返回值,并且判断好如何设置Promise对象状态,推荐链式调用时then方法在catch方法之前,避免捕获后无原始值
// 链式调用 promise.then((val)=> val + val) .then((val)=> val + val) .catch((err)=>{ console.log(err) }).then((val)=> { console.log(val) }, (err)=>{ console.log(err) }).finally(()=>{ console.log('finally') })
Promise的静态方法Promise.all(...promises):当所有的promise都成功时,返回一个成功的promise,否则返回带有第一个操作失败原因的已拒绝promisePromise.allSettled(...promises):返回一个promise,该promise在所有promise都完成时完成,并带有一个数组,该数组包含所有操作的promise结果,无论成功或失败Promise.any(...promises):返回带有第一个成功结果的成功promise,如果都失败,返回包含拒绝原因数组的已拒绝promisePromise.race(...promises):返回第一个返回成功结果或第一个失败原因的promisePromise.try(fn, ...args):尝试执行fn(...args)函数,如果fn是同步的,本身也将同步地返回值或抛出错误,如果fn是异步的,获得Promise对象,包含未来返回的结果(已兑现)或抛出的错误(已拒绝)Promise.resolve(value):返回一个已兑现的promise,并带有给定结果值,也就是将一般类型转换为promise。如果传入的是Promise对象,直接返回此对象Promise.reject(reason):返回一个已拒绝的promise,并带有给定拒绝原因
取消异步请求操作
Promise本身没有取消的一级协议,通常使用AbortController实现AbortController对象是一个用于"取消正在进行的异步操作"的对象- 许多异步操作的对象配置中允许直接传递
{signal: controller.signal},signal本身是一个事件目标,在AbortController.abort时触发,对这些有配置的异步操作对象而言,会停止内部操作 - 现在也可以
abortSignal.timeout(ms)设置一个超时触发 - 对于不支持的异步操作停止配置的异步操作对象,可以使用以下方法
function abortable(promise, signal) { return Promise.race([ promise, new Promise((_, reject) => signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')) )) ]); }- 许多异步操作的对象配置中允许直接传递
ajax
ajax:Asynchronous JavaScript And XML是浏览器提供的一种异步请求技术,用于向服务器发送请求,并获取数据,并且无刷新地更新页面中相关部分XMLHttpRequest:js自带的服务器请求类型初始化请求对象并使用
open(type, url, async, user, password)设置参数,包含请求方式、请求地址、是否异步、用户名、密码参数- 请求方式:对应
HTTP支持的请求方式,表明使用什么方式请求 - 请求地址:请求的资源地址
- 是否异步:是否异步请求,默认为
true,表示异步请求,false表示同步请求,会阻塞当前线程,直到请求完成 - 用户名和密码参数几乎不使用,用于
HTTP基本认证,浏览器会自动在响应头中包含Authorization: Basic base64(username:password),base64函数将返回字符串username:password的base64编码
现在大多数认证机制都用定义的认证头实现,基本认证将在请求中包含密码原文,非常不安全,实际上基本认证的用户名密码也可以写在
URL中,https://username:password@example.comlet xhr = new XMLHttpRequest(); xhr.open('GET', 'https://api.github.com/users/mzj'); // 通过添加认证头进行认证,比如JWT认证 xhr.setRequestHeader('Authorization', 'Bearer ' + token);- 请求方式:对应
设置跨域请求的
cookie行为,xhr.withCredentials = true,不设置的话sameSite: None的cookie也将不会发送Lax和Strict的cookie因为浏览器限制,无法被任何脚本请求跨域发送,Lax仅能在顶级导航变化时实现跨域发送- 同源请求会自动携带所有的
cookie
使用
onreadystatechange事件监听请求状态改变0对应UNSENT:请求已创建,但未调用open()方法1对应OPENED:请求已初始化,但未发送2对应HEADERS_RECEIVED:请求已发送,已接收响应头3对应LOADING:正在接收响应体4对应DONE:请求已完成,响应已就绪
在响应就绪
4时,通过responseText属性获取响应数据xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300 ) { console.log(xhr.responseText) // 如果是 xml 类型返回,可使用 dom 方法解析 console.log(xhr.responseXML) } else { console.log('请求失败') // 响应头 console.log(xhr.getAllResponseHeaders()) console.log(xhr.getResponseHeader('xhr')); // 响应文字 console.log(xhr.statusText) } } }调用
send()方法发送请求,在send()方法中可以传入请求体xhr.send() xhr.send(body)
::: 请求参数和请求体
有两种在请求时传输数据的方式,分别是请求参数
param和请求体body- 请求参数
param:放在URL里,一般有路径参数和查询参数两种,在请求路径中的参数比如/users/123中的123是请求参数;在最后的?之后的key=value对(多个用&连接)是查询参数 - 请求体
body:数据可以放在请求体里,一般情况下只有POST、PUT、PATCH、DELETE请求才会使用请求体,浏览器可能忽略GET的请求体,请求体的形式多样,因此需要在请求头Content-Type中指定数据格式
xhr.setRequestHeader('Content-Type', 'application/json') // json 类型 xhr.send(JSON.stringify({ name: '张三' })) xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') // 表单类型 xhr.send("user=admin&password=123456") xhr.setRequestHeader('Content-Type', 'multipart/form-data') // 文件上传或混合文本 const fileInput = document.getElementById('fileInput'); // 来自 <input type="file"> const formData = new FormData(); formData.append('username', 'Tom'); formData.append('file', fileInput.files[0]); xhr.send(formData) xhr.setRequestHeader('Content-Type', 'text/plain') // 纯文本 xhr.send('Tom') xhr.setRequestHeader('Content-Type', 'application/octet-stream') // 二进制数据 const file = document.querySelector('#fileInput').files[0]; xhr.send(file)大文件请求也可以使用
File.slice(start, end)方法来分片发送数据,此方法截取了文件的指定部分,并返回一个新的Blob对象- 请求参数
:::
除了原始的
XMLHttpRequest对象,js还有fetch方法,使用更加方便,返回值是Promise对象- 实际上写法和
JQuery的ajax方法基本一样,但返回值是Promise对象而不是JqXHR对象,只有当网络故障或其他阻止请求完成时对象才会被拒绝 then方法接收到一个Response对象,因为请求失败不会被拒绝,需要判断Response.ok和Response.status属性,可以使用response.json()方法获取响应体,还支持text()、blob()等方法解析
fetch(url, { method: 'POST', body: { a: 1, b: 2 } headers: { 'Content-Type': 'application/json' }, // 设置跨域请求的 cookie 行为,有三种选择,`omit`(不发送,默认值)、`same-origin`(同源发送)、`include`(强制发送) credentials: 'include' }).then(res => res.json() ) .then(data => console.log(data))取消请求
fetch请求可以取消,使用AbortController实现let controller = new AbortController(); const signal = controller.signal; // 传入 signal 参数 fetch(url, { signal }) .then((response) => { console.log("下载完成", response); }) .catch((err) => { console.error(`下载错误:${err.message}`); }); // 取消请求 cocontroller.abort();XMLHttpRequest请求可以直接使用abort()取消,这时readyStatus属性会变为0
- 实际上写法和
有其他库实现了请求功能,比如
JQuery和AxiosAxios一个简单的请求,可以直接使用
axios.请求名(url, body, config)或axios(config)完成get/delete/patch方法没有请求体body参数,因此参数为(url, config)
axios({ url: 'https:example.com/api/users/123', method: 'get', params: { name: 'Tom' }, headers: { Authorization: 'Bearer 123' }, withCredentials: true // 和 xhr 配置项名称相同,允许跨域请求携带 cookie }) // get/delete/patch 无请求体参数 axios.get('/api/users/123').then(res => console.log(res.data)); axios.post('/api/users', { name: 'Tom' }); axios.put('/api/users/123', { age: 25 }); axios.delete('/api/users/123'); axios.patch('/api/users/123', { age: 25 }, { headers: { Authorization: 'Bearer 123' } });config参数支持传入一个配置对象配置基本参数,全部配置说明见axios 文档
配置项 说明 url请求地址 method请求方法(默认 GET)baseURL基础路径(可统一设置) params查询参数(会自动序列化) data请求体数据 headers自定义请求头 timeout超时时间 responseType响应类型( json,blob,text等)withCredentials是否携带跨域凭证( cookie)signal用于传入 AboutController取消请求信号validateStatus自定义响应是否成功的函数,返回 true的请求被认为成功函数结果是一个
Promise对象- 可以直接使用
then((res)=>{})获得响应内容 - 可以使用
catch(err=>{})处理错误 - 可以使用
finally(()=>{})处理完成逻辑
axios.get('/api/users').then(res => { // 响应体 console.log(res.data) // 响应状态码 console.log(res.status) // 响应头 console.log(res.headers) // 响应状态信息 console.log(res.statusText) // 请求配置信息 console.log(res.config) // 请求的实例,浏览器中为 XMLHttpRequest 对象,Node.js 中为 http.ClientRequest 对象 console.log(res.request) }).catch(function (error) { if (error.response) { // 请求成功发出且服务器也响应了状态码,但状态代码超出了 2xx 的范围 console.log(error.response.data); console.log(error.response.status); console.log(error.response.headers); } else if (error.request) { // 请求已经成功发起,但没有收到响应,输出请求的实例 console.log(error.request); } else { // 发送请求时出了点问题 console.log('Error', error.message); } console.log(error.config); })- 可以直接使用
全局配置类:可以通过
axios.defaults修改全局配置,或者创建一个全局实例来管理这些信息// 全局配置 axios.defaults.baseURL = 'http://localhost:8080'; axios.defaults.timeout = 5000; axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; // 全局实例 const api = axios.create({ baseURL: 'http://localhost:8080', timeout: 5000, headers: {Authorization: 'Bearer ' + token} }) // 自动使用配置,传入的 URL 会被拼接到 baseURL 后面 api.get('/api/users').then(res => { console.log(res) })拦截器:如果希望对请求或响应做全局处理,可以使用拦截器,拦截器可以挂载在实例或全局上
- 请求处理:请求正确设置时的处理
config=>config(返回值作为发送请求的配置) - 响应处理:响应正确处理
response=>any、响应出错处理error=>any,函数的返回值如果是普通值或非拒绝的Promise对象,都会提供给then函数,作为参数,如果报错或Promise.reject,才会作为参数,传递给catch函数
api.interceptors.request.use( config => { // 可以统一加 token const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } console.log('请求发送前', config); return config; }, error => { // 请求错误处理 return Promise.reject(error); } ); api.interceptors.response.use( response => { console.log('响应成功', response); return response.data; // 可以直接返回 data }, error => { console.error('响应错误', error); if (error.response?.status === 401) { // 可以统一处理未授权 console.log('跳转到登录页'); } return Promise.reject(error); } );- 请求处理:请求正确设置时的处理
JQuery$.ajax相比当时底层的XMLHttpRequest更灵活方便,可以完全控制请求的各个细节
$.ajax({ url: '/api/users', type: 'GET', // 响应体、状态描述字符串、JQuery 封装的 XHR 请求对象 // 状态描述字符串由 JQuery 生成 success(res, textStatus, jqXHR) { console.log(res) } // 请求对象、状态描述字符串、XHR 请求时或浏览器提供的错误信息 error(jqXHR, textStatus, errorThrown) { console.log(errorThrown) } },) // 支持固定类型的别名请求函数 // 简写时不支持传入错误处理函数和其他参数 $.get('/api/users', function(res) { console.log(res) })基本参数
参数名 含义 url请求地址 method/type请求方法( GET、POST、PUT、DELETE...)data请求参数(对象、字符串) dataType期望的响应类型( json、text、xml、html)contentType发送的数据类型(如 'application/json')success(res, )成功回调 error(xhr, status, err)失败回调 beforeSend(xhr)请求发送前触发 complete(xhr)请求完成(成功/失败)后触发 headers自定义请求头
CORS
- 浏览器同源策略:浏览器同源策略限制了页面的访问其他页面的资源,比如
iframe、script、ajax请求等- 源地址:由协议、网址域名、端口号组成
- 同源请求:请求所处页面源地址和服务器的目标源地址相同,否则是跨域请求
- 浏览器对跨域请求的限制:
DOM访问:源A的脚本不能读取源B的DOMCookie访问:源A不能访问源B的Cookieajax请求:源A能发送ajax请求,但是不能从源B获取响应结果- 跨域请求仅在浏览器端存在限制,服务器端不存在
- 即使跨域了请求也可以正常发出,但响应数据经过浏览器校验,不会交给页面,重点就在于如何符合校验规则
- 标签请求,比如
link、script、img,也可能跨域,不过浏览器对这些标签跨域没有严格的限制(可以通过CSP请求头或页面元信息添加限制),基本无影响
CORS介绍见计算机网络部分
JSONP:早期解决跨域问题的有效方法,但存在XSS威胁风险,前后端为了配合需要大幅修改,现在新项目基本不使用- 使用
script发送简单get请求,服务器直接响应一个符合js规范的代码 - 服务器,使用一个约定的函数名,将需要发送的数据
json作为一个函数的参数,比如返回callback({name: '张三', age: 18}) - 页面在需要请求的时候创建一个
script标签并挂载在body最后,执行会调用方法响应请求,函数名可以通过query参数传入
- 使用
JQuery封装了一个JSONP请求方法getJSON// callback参数对应函数名,由 JQuery 自动生成 // 服务器应该读取 callback,作为函数名 callback({}) $.getJSON('/api/users?callback=?', (res) => { console.log(res) })
服务器端解决跨域的方法
Nginx转发
location /api { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization'; if ($request_method = 'OPTIONS') { return 204; } proxy_pass http://www.example.org/; }- 服务器代理转发,比如
http-proxy-middleware,是非常流行的node.js中间件,用于express、next.js等许多服务器框架中
const express = require('express') const { createProxyMiddleware } = require('http-proxy-middleware') const app = express() // 创建代理中间件 // 将 http://127.0.0.1:3000/api/foo/bar 转发到 http://www.example.org/api/foo/bar app.use( createProxyMiddleware({ target: 'http://www.example.org', // 目标服务器地址 changeOrigin: true, // 是否改变请求源 pathRewrite: { '^/api': '' }, // 路径重写 pathFilter: '/api', }) ) app.listen(3000)
await/async
async可以作为函数修饰,表明此函数将返回一个Promise对象,对象获取到的结果由函数返回值决定await表达式可以将异步像同步一样处理- 必须出现在最外层或
async函数内部,配合async函数让异步保留,可以不阻塞主程序 - 右侧返回值必须是一个
Promise对象,等待执行完成后返回成功resolve后的结果 - 如果
Promise对象失败reject,await会抛出错误,需要使用try...catch处理
async function getUser() { // 直接等待请求完成,获取结果再继续 const res = await axios.get('/api/users') return res.data } const user = await getUser()- 必须出现在最外层或
WebSocket
WebSocket是一种用于在浏览器和Web服务器之间建立全双工的通信协议,允许浏览器和服务器之间进行双向通信,可以发送和接收数据,并且可以保持连接,直到显式关闭在
js中有WebSocket对象,用于创建一个WebSocket连接new WebSocket(url)创建一个WebSocket连接,参数为url,可以是ws://或wss://开头的urlWebSocket对象有如下属性:readyState:表示连接状态,有四种状态CONNECTING、OPEN、CLOSING、CLOSED,和XMLHttpRequest对象一样
- 方法:
send(data):发送数据ping():发送心跳包,在WebSocket连接中,如果长时间没有收到消息,可以发送心跳包,如果对方没有收到,则认为连接已断开,也可以使用send自定义心跳消息
- 事件
onopen:连接成功时触发onmessage:收到消息时触发onclose:连接关闭时触发onerror:连接错误时触发
const ws = new WebSocket('ws://localhost:8080') ws.onopen = function(e) { console.log('连接成功') } ws.onmessage = function(e) { console.log("收到消息", e.data) } ws.onclose = function(e) { console.log('连接关闭') } ws.onerror = function(e) { console.log('连接错误', e) } // 发送消息 if (ws.readyState === WebSocket.OPEN) { ws.send('hello') }也可以使用其他库,比如
Socket.IO实现const socket = io('ws://localhost:3000', { transports: ['websocket'], // 强制 WebSocket pingInterval: 25000, // 心跳间隔 25s(自定义) pingTimeout: 5000, // 超时 5s reconnection: true, // 自动重连 reconnectionAttempts: 5 // 最多重试 5 次 }); // 连接成功 socket.on('connect', () => { console.log('连接成功,心跳启动'); }); // 接收消息(包括 pong 响应) socket.on('message', (data) => { console.log('收到消息:', data); }); // 心跳断开检测(Socket.IO 自动处理) socket.on('disconnect', (reason) => { console.log('断开原因:', reason); // e.g., 'ping timeout' }); // 发送消息 function sendMessage(msg) { if (socket.connected) { socket.emit('message', msg); // emit 是 Socket.IO 的发送方法 } }
SSE
SSE(Server-Sent Events,服务器发送事件)是一种用于在浏览器和Web服务器之间建立单向的通信协议,允许服务器单向不断向浏览器发送数据,可用于实时推送消息,可以保持连接,直到显式关闭EventSource对象用于创建一个SSE连接,使用的是http请求,请求头中包含Accept: text/event-stream构造函数:
new EventSource(url, options),其中options可以包含一个参数withCredentials,表示是否允许cors,创建时默认打开连接EventSource对象有如下属性方法:readyState:表示连接状态,有四种状态CONNECTING(0)、OPEN(1)、CLOSED(2)url:SSE连接的urlwithCredentials:是否允许corsclose():关闭连接
EventSource对象有如下预定义事件:open:连接成功时触发message:收到消息时默认触发error:连接错误时触发,比如后端关闭了连接时,该事件不可取消
得益于
SSE使用text/event-stream内容协议支持自定义事件名,可以自定义事件类型并接收- 协议返回的数据支持多行文本,每次数据格式为
event: xxx\ndata: xxx\n\n,其中event表示事件名(可选),data表示数据,\n表示换行符,\n\n表示结束符 - 可以使用事件字段定义自定义事件,默认是
message - 当发现有事件字段时,
EventSource会触发一个名称和事件字段一致的事件,而不仅仅是默认的message事件
EventSourceconst source = new EventSource('http://localhost:8080/sse') es.addEventListener('update', (e) => { // 自定义 console.log('更新:', JSON.parse(e.data).time); }); es.addEventListener('message', (e) => { // 固定,默认 console.log('默认:', e.data); }); es.addEventListener('close', (e) => { console.log('关闭连接:', e) }) es.addEventListener('error', (e) => { console.log('错误:', e) }) es.addEventListener('end', (e) => { es.close() console.log('已关闭连接') })expressapp.get('/events', (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') res.setHeader('Access-Control-Max-Age', '86400') res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); // 发送自定义事件 const a = setInterval(() => { res.write('event: update\ndata: ' + JSON.stringify({ time: Date.now() }) + '\n\n'); }, 2000); // 发送默认消息 const b = setInterval(() => { res.write('data: 默认消息\n\n'); }, 3000); setTimeout(() => { clearInterval(a); clearInterval(b); res.write('event: end\ndata: end\n\n'); res.end(); }, 9000); });- 协议返回的数据支持多行文本,每次数据格式为
模块化
随着应用的复杂度越来越高,代码量和复杂度都会急速增加,会导致全局污染、依赖混乱、数据安全的问题,因此出现了模块化
模块化的核心思想就是模块之间是隔离的,通过导入和导出进行模块间的数据和功能的共享,现在流行的模块化方案有
CommonJS:Node.js服务器端在ES6之前广泛使用的方案- 导出:在文件内部有一个空对象,模块内部使用
module.exports、exports、this都指向它,最终导出的就是这个对象modeule.exports
exports是module.exports对象的初始引用,因此不应该直接赋值exports = value,应该通过exports.xxx挂载属性- 导入:使用
require函数导入模块,返回模块的导出对象,这个对象可以进行解构赋值,如果有重复声明冲突,需要{ name: alias }修改名称name为alias
index.jsconst { add, mul } = require('./js/mathUtils.js')mathUtils.jsfunction add(num1, num2) { return num1 + num2 } function mul(num1, num2) { return num1 * num2 } module.exports = { add, mul } // 也可以 // exports.add = add // exports.mul = mul实现原理
CommonJS的模块化实际上是基于外层函数包裹完成的,可以在模块文件中通过arguments.callee.toString()获取到包含模块内容的完整函数,实际上mathUtils.js文件内容对应的函数如下:function (exports, require, module, __filename, __dirname) { function add(num1, num2) { return num1 + num2 } function mul(num1, num2) { return num1 * num2 } module.exports = { add, mul } // 也可以 // exports.add = add // exports.mul = mul console.log(arguments.callee.toString()) }node会帮助处理模块函数,require函数会正确引入这些导出的内容在浏览器中不支持
CommonJS,require函数是不存在的,需要经过编译,可以使用browserify编译npm install -g browserify # 编译,将上方的index.js编译为浏览器可以导入的bundle.js文件 browserify app.js -o bundle.js # 可以在浏览器中导入 # <script type="text/javascript" src="bundle.js"></script>
- 导出:在文件内部有一个空对象,模块内部使用
ES6 Module:ES6标准提出的模块化标准,广泛用于浏览器页面导出
export:导出(暴露)方式有三种,分别暴露、统一暴露和默认暴露,三种方式可以出现在一个文件中// 分别暴露 export const add = (a, b) => a + b; export const minus = (a, b) => a - b; // 统一暴露 const mul = (a, b) => a * b; const div = (a, b) => a / b; export { mul, div }; // 默认暴露,默认暴露的属性会放入 default 属性中 const mod = (a, b) => a % b; export default mod导入
import:导入的形式也多种多样,可以静态导入,也可以动态导入import时,被导入文件全局操作(副作用)会被执行,可以什么都不导入import 'xxx',仅执行// 静态导入 // 针对非默认暴露,可以全导入或按需引入 // 实际上全导入的对象中有 default 属性对应默认暴露的属性 import * as math1 from './math.js'; // 由于 default 是标识符,所以无法这样导入 import { add, minus, mul, div } from './math.js'; // 针对默认暴露,获取到的math就是mod函数 import math2 from './math.js'; // 针对混合暴露,既有默认暴露,也有非默认暴露 // 必须先写默认暴露 import math3, { add as add1, minus as minus1 } from './math.js'; // 动态导入,result 就是一个全导入的对象,导入的内容和 math1 一致 const result = await import('./math.js'); // 包含所有暴露的属性 console.log(math1); console.log(result); // 获取默认暴露的属性,本身是 mod 函数 console.log(math2); console.log(math3); console.log(math1.default(10, 2)); // mod(10, 2) console.log(math2(10, 2)); // mod(10, 2) console.log(add(10, 2)); // add(10, 2)两种模块化导出的基本类型变量区别
在
CommonJS中,模块化导出的是原始变量的引用(赋值),也就是说基本数据类型的修改不会相互影响,引用类型内容的修改会相互影响let count = 1; function inc() { count++; } module.exports = { count, inc } // CommonJS 导入时 let { count, inc } = require('./module'); console.log(count); // 1 inc(); console.log(count); // 1在
ES6模块中,模块化导出的内容和原始数据指向的是同一块内存空间,在导出之后无法在外部直接修改(引用不可变,键不可扩展),相当于使用const声明,但在使用模块内部函数后,值可能被改变,这可能带来值的不确定性export let count = 1; export function inc() { count++; } // ES6 模块导入时 import { count, inc } from "./count.js"; console.log(count); // 1 inc(); console.log(count); // 值变化为 2
在
html中引入:ES6模块是浏览器支持的功能,可以直接引入模块文件,脚本类型为module,引入的内容声明不会污染全局作用域,不交出去不获取的变量就无法使用<script type="module" src="./math.js"></script>在
node.js中使用:需要创建package.json文件,并设置json内容{"type": "module"};也可以直接修改后缀为.mjs(module模块化特殊文件后缀, 对应CommonJS后缀为.cjs)
type属性用于决定node如何处理js文件,默认是commonjs,就是按CommonJS处理AMD:来自require.js的模块化规范环境配置requirejs.config({ baseUrl: "./js", paths: { // ./js/num.js num: "num", // ./js/add.js add: "add" } })使用// c 是 num 模块导出的变量容器对象 // 模块和变量对应,导入顺序和变量名称无关 define(["num"], function(c) { function add(a, b) { return a + b + c.num; } return { add } })引入依赖<script src="https://cdn.jsdelivr.net/npm/requirejs@2.3.7/require.min.js"></script>
:::
CMD:来自sea.js使用// c 是 num 模块导出的变量 // 模块和变量对应,导入和变量名称无关 define(function(require, exports, module) { const c = require("num.js") function add(a, b) { return a + b + c.num; } exports.add = add })引入依赖<script src="https://cdn.jsdelivr.net/npm/sea.js@2.3.0/sea.min.js"></script>
常用函数
Object
Object静态方法Object.assign(newObj, ...objs):浅拷贝对象,为newObj添加objs有的属性,如果属性有重复,则后面的属性会覆盖前面的属性
深浅拷贝
- 直接复制引用
const a = b:修改会共享 - 浅拷贝:会创建新对象,但其中的元素只是原对象的引用,不会递归复制内层对象,因此第二层及更深层的元素修改会共享
const a = {...b}Object.assign(a, b)
- 深拷贝:会创建新对象,并递归复制内层对象,使得新对象与原对象完全独立
创建递归函数手动创建新对象
// 简单的递归拷贝函数,仅对数组和对象进行递归处理 function deepCopy(obj, oldObj) { // 使用键遍历 for (let key in obj) { // 必须先判断是否为数组,因为数组也是对象 if (oldObj[key] instanceof Array) { obj[key] = []; deepCopy(obj[key], oldObj[key]); } else if (oldObj[key] instanceof Object) { obj[key] = {}; deepCopy(obj[key], oldObj[key]); } else { obj[key] = oldObj[key]; } } }借助
json格式const a = JSON.parse(JSON.stringify(b)),特殊对象难以表达使用
structuredClone方法:ECMAScript 2021添加的原生方法,通过结构化克隆算法完成拷贝,但有许多类型不支持此算法,比如:函数、许多错误类型、DOM节点、原型链跟踪、属性描述符、正则对象的lastIndex属性等,见结构化克隆算法此方法第二个参数中的选项支持转移对象属性到新对象中
const obj = { a: 1, b: { c: 2 } }; const copy = structuredClone(obj); console.log(copy); // { a: 1, b: { c: 2 } } console.log(copy === obj); // false(深拷贝) // 创建一个 ArrayBuffer const buffer = new ArrayBuffer(8); // 克隆并转移所有权 const cloned = structuredClone({ buf: buffer }, { transfer: [buffer] }); console.log(cloned.buf.byteLength); // 8 console.log(buffer.byteLength); // 0 原 buffer 被清空(转移完成)
借助其他工具库的实现,比如
lodash的cloneDeep方法,处理了许多的特殊类型,但不支持原型链跟踪
Object.keys(obj):返回对象obj的属性名组成的数组Object.values(obj):返回对象obj的属性值组成的数组Object.getOwnPropertyDescriptors(obj):返回对象obj的所有属性描述符组成的对象,在变量特性部分已经介绍过Object.getOwnPropertyNames(obj):返回对象obj的所有属性名组成的数组,包括Symbol属性键Object.getOwnPropertySymbols(obj):返回对象obj的所有Symbol属性键组成的数组Object.getPrototypeOf(obj):返回对象obj的原型对象Object.is(value1, value2):比===更严格的判断两个值是否相等的方法,只有以下情况被认为是相同的- 都是 undefined
- 都是 null
- 都是 true 或者都是 false
- 都是长度相同、字符相同、顺序相同的字符串
- 都是相同的对象(意味着两个值都引用了内存中的同一对象)
- 都是 BigInt 且具有相同的数值
- 都是 symbol 且引用相同的 symbol 值
- 都是数字且都是相同值时,
+0和-0被认为是不同值(与===的区别),两个NaN被认为是相同的
Array
- 数组实例方法
arr.push(item1, item2, ...):在数组末尾添加元素,返回新数组长度arr.pop():删除数组最后一个元素并返回该元素arr.shift():删除数组第一个元素并返回该元素arr.unshift(item1, item2, ...):在数组开头添加元素,返回新数组长度arr.splice(start, deleteCount, item1, item2, ...):删除数组的元素,并添加新的元素,start为开始索引(删除含本元素),deleteCount为删除的元素个数,item1...为要添加的元素,返回被删除的元素组成的数组- 如果希望删除元素,仅使用前两个参数
arr.splice(start, deleteCount),deleteCount默认无限制 - 如果希望添加元素,仅使用索引和最后的可变参数
arr.splice(start, 0, item1, item2, ...) - 由于函数返回值是被删除的部分,有时也由于截取数组中部分内容
- 如果希望删除元素,仅使用前两个参数
arr.forEach((val, idx, arr) => void, thisArg):遍历数组arr,对每个元素执行callback函数,元素长度将被预先记录,在修改过程中被添加的元素不会被遍历(与for循环的区别)thisArg:用于修改callback函数的this指向,因为函数作为参数传入之后会丢失对应的obj绑定,所以需要传入一个对象作为this指向,确保调用形式是callback.call(thisArg, val, idx, arr)callback的执行逻辑:forEach、map等这样的函数,都会对数组的每一个元素调用一次callback函数,这个函数的返回值将影响最终返回的新数组或值,函数本身的this有特殊设计过,如果不传入值,那么this指向window(或严格模式下的undefined)- 对空位的处理:因为这种方法可能被稀疏数组使用
Array.prototype.forEach.call([1, , 3], callback),因此较久的方法考虑了空位的处理(跳过),新方法不处理空位(作为undefined),具体见数组方法与空位
arr.map((val, idx, arr) => any, thisArg):遍历数组arr,对每个元素执行callback函数(返回值作为新值),返回一个新数组arr.filter((val, idx, arr) => boolean, thisArg):遍历数组arr,对每个元素执行callback函数,返回一个新数组,新数组的元素是满足callback函数(返回true)的元素arr.reduce((accumulator, curVal, index, arr) => any [, initialValue]):遍历数组arr,对每个元素执行callback函数,返回一个值,用于求和等操作- 初始值
initialValue是可选的,不提供且数组非空就使用数组的第一个元素作为初始值,不对第一个元素执行callback函数 accumulator是上一个回调的返回值,初始值为initialValue或数组的第一个元素
- 初始值
arr.join([separator]):将数组元素连接为一个字符串arr.find((val, idx, arr) => boolean):找到数组中满足条件回调(返回true)的第一个元素,如果没有满足条件的元素,则返回undefinedarr.findIndex((val, idx, arr) => boolean):返回数组中满足条件回调的第一个元素的索引,如果没有满足条件的元素,则返回-1arr.every((val, idx, arr) => boolean):测试数组的所有元素是否都符合指定条件回调arr.some((val, idx, arr) => boolean):测试数组中是否有元素符合指定条件回调arr.sort((a, b) => number):对数组进行排序,返回表示在前,表示在前,时稳定性依赖实现,ES2019起大多数是稳定排序arr.reverse():将数组的元素顺序颠倒arr.slice(start, end):返回数组的片段,不包含end下标的内容end可省略,默认到末尾,start也可以省略,默认为0- 索引可以使用负数,表示从末尾开始
- 返回的是浅拷贝的新数组
arr.concat(item1, item2, ...):连接数组(展开第一层)或元素并返回一个新数组arr.join(separator):将数组元素连接为字符串arr.flat(depth):将数组拍平,最多变为一维depth:拍平的深度,默认为1,如果传入infinity,则拍平所有层- 拍平只处理数组,对类数组对象不会处理
- 拍平会删除空位
- 数组静态方法
Array.isArray(value):判断是否是数组Array.from(arrayLike, mapFn, thisArg):将类数组对象或可遍历对象转换成数组arrayLike:类数组对象或可遍历对象mapFn:转换函数,可以转换映射,用于在转换时对元素加工thisArg:转换函数的this指向,目的是确保mapFn指向正确,默认指向window
String
- 字符串实例属性
length:字符串长度
- 字符串实例方法
str.split(separator, limit):将字符串按separator分割成数组,separator可以是字符串或正则,limit为返回数组的最大长度str.replace(regexpOrSubstr, replacement):替换字符串,regexpOrSubstr可以是字符串或正则表达式,replacement可以是字符串或函数;返回新字符串str.toUpperCase():将字符串转为大写,返回新字符串str.toLowerCase():将字符串转为小写,返回新字符串str.trim():去除字符串首尾空格,返回新字符串str.charAt(index):获取指定索引index的字符,返回单字符字符串- 获得
UTF-16编码、Unicode值需要通过charCodeAt(index)、codePointAt(index)函数
- 获得
str.includes(searchString, position):判断字符串是否包含searchString,从position索引开始搜索,返回布尔值str.startsWith(searchString, position):判断字符串是否以searchString开头,从position索引开始匹配,返回布尔值str.endsWith(searchString, length):判断字符串是否以searchString结尾,length表示只考虑前length个字符,返回布尔值str.substring(start, end):返回从start到end(不包括)之间的子串,end可选,默认到字符串末尾str.slice(start, end):返回从start到end(不包括)之间的子串,支持负数索引表示从末尾开始str.indexOf(searchValue, fromIndex):返回searchValue首次出现的索引,若未找到返回-1,从fromIndex开始搜索str.lastIndexOf(searchValue, fromIndex):返回searchValue最后一次出现的索引,若未找到返回-1,从fromIndex向前搜索str.match(regexp):使用正则表达式regexp匹配字符串,返回匹配结果数组或nullstr.matchAll(regexp):使用全局正则表达式regexp匹配所有结果,返回一个可迭代对象,每个元素是匹配数组str.repeat(count):重复字符串count次,返回新字符串str.padStart(targetLength, padString):在字符串前填充padString到targetLength长度,返回新字符串str.padEnd(targetLength, padString):在字符串后填充padString到targetLength长度,返回新字符串
Math
Math对象的静态属性中有许多数学常量Math.E:自然对数底数Math.PI:圆周率Math.SQRT2:Math.SQRT1_2:Math.LN2:对数底数
Math中包含常用数学函数,都是静态方法Math.abs(x):返回x的绝对值- 取整函数
Math.ceil(x):向上取整Math.floor(x):向下取整Math.round(x):返回最接近的整数,四舍五入
其他的取整方法
~~(x):按位取反再取反,也相当于x | 0,向0取整,超过二进制32位会被截断,读取到NaN和Infinity会返回Math.trunc(x):返回x的整数部分,向取整,是~~(x)的替代,可以处理更大范围的浮点数parseInt(string, radix):将字符串转换成整数,radix指定转换的进制,默认是10进制,方法会从左往右读取字符串,直到遇到非数字字符或.结束,如果开头就不是数字,则返回NaNNumber.prototype.toFixed(digits):返回一个定点数字符串(比如123000),digits指定小数位数,默认是,方法四舍五入Number.prototype.toPrecision(precision):返回一个定点数或指数表示字符串(如果可以使用指数表示的话,会使用指数表示,比如1.23e+5),precision指定有效位数,默认和当前有效位数一致(仅删去小数末尾的),方法四舍五入
Math.max(x1, x2, ...):返回参数中的最大值,还有对应的Math.min()返回参数中的最小值Math.pow(x, y):返回x的y次方Math.sqrt(x):返回x的平方根Math.random():返回一个随机数,范围是[0, 1)- 各类三角函数,比如
Math.sin(),Math.cos(),Math.tan(),参数需要是弧度制弧度制和角度制的换算:在
js中角度制也是通过数字显示的,比如角度对应的数字为30 * Math.PI / 180
时间
Date对象:是目前js用于处理日期和时间的对象,对时区不友好,因此大多数时候都使用外部时间库比如dayjs完成,官方的新时间对象Temporal还在实验中,见MDN Temporal- 静态方法
Date.now():返回当前时间戳,单位是毫秒
- 创建对象
new Date()- 无参数,获取当前时间
- 传入时间戳创建对象
- 传入字符串创建对象,字符串应该为
YYYY-MM-DDTHH:mm:ss.sssZ类型的时间(部分内容可缺省,仅日期被视为UTC时间,日期时间被视为当地时间),其他类型的时间可能会被某些浏览器解析,但不鼓励使用 - 包含年月日时分秒毫秒参数,创建为当地时间
- 年参数传入
0-99时会转换为19xx年,其他转化为对应年份,如果需要公元0-99的年份,需要在之后调用setFullYear()方法设置 - 月参数从
0开始,表示月的索引 - 日参数非必须,默认为1
- 时分秒毫秒参数非必须,默认为0
- 年参数传入
- 获取和设置时间信息
- 获取和设置年:
getFullYear()、setFullYear() - 获取和设置月:
getMonth()、setMonth(),0表示1月 - 获取和设置日:
getDate()、setDate() - 获取和设置时:
getHours()、setHours() - 获取和设置分:
getMinutes()、setMinutes() - 获取和设置秒:
getSeconds()、setSeconds() - 获取和设置毫秒:
getMilliseconds()、setMilliseconds() - 获取星期几:
getUTCDay(),0表示星期天 - 获取时间戳:
getTime()、valueOf(),更早的时间是负数
- 获取和设置年:
- 注意事项
- 传入的时间超过了对应的时间范围,会发生进位
- 如果时间无法解析会得到
Invalid Date toJSON方法定义了将Date对象转为JSON格式字符串的函数,之后时间将为协调世界时UTC的时间Date()可以不带new调用,返回时间字符串- 可以通过
toLocaleString,toLocaleDateString和toLocaleTimeString方法转为本地时间展示字符串 - 已重写
[Symbol.toPrimitive],默认和string返回当地时间字符串,number返回时间戳
哈希结构
- 哈希表
Map:可以使用任意类型作为键set(k, v):添加值get():获取值delete(k):删除元素has(k):判断元素是否存在clear():清空表size:表中元素个数entries():获取所有键值对数组[[k,v],...]keys():获取所有键数组values():获取所有值数组fporEach((k,v)=>{}):迭代器
- 哈希集合
Set:集合中的元素不重复,可以传入元素数组作为构造参数new Set([...]),之后可以使用[...new Set()]转为数组add(e):添加元素delete(e):删除元素has(e):判断元素是否存在clear():清空集合size:集合元素个数- 可以直接使用
[...new Set()]或Array.from(new Set())转为数组
WeakMap:弱映射,使用对象作为键,如果键在外面没有引用,则记录会被垃圾回收,因此WeakMap不能遍历也没有长度。常用于存储私有数据,以变量本身作为键,存储私有字段,如果变量被回收,私有字段也会回收set(o, v):设置键值对get(o):获取值delete(o):删除对象has(o):判断对象键是否存在
WeakSet:弱集合,只能存储对象,如果对象在外面没有引用,则会被垃圾回收,因此WeakSet不能遍历也没有长度add(o):添加对象delete(o):删除对象has(o):判断对象是否存在,比较地址
特性
闭包
- 闭包:一个函数对周围状态的引用捆绑在一起,内层函数可以访问外层函数的作用域,本质是内部函数+引用的外层函数变量
- 原理:内部函数使用了外部函数中部分变量的引用,产生独特的闭包作用域
closure。如果内部函数被返回给外界,这些变量的生命周期延长,闭包保留了外层作用域 - 作用:
创建模块,私有内部变量,使用内部函数修改和获取内部变量,避免全局污染,已可被类取代,但这种写法更方便
function counter() { let count = 0; return function () { return ++count; } } let c = counter(); console.log(c()); console.log(c());只暴露接口方法,隐藏细节实现,比如函数计数器等
创建函数工厂,创建带有固定行为的函数
function makeCounter(m) { return (x) => x**m }延迟执行,按需计算
function delay(a, b) { return () => a + b } let f = delay(1, 2) console.log(f())保证异步函数执行时也能获取到当前上下文
- 限制:
由于函数会保留,引用到的变量使用的堆空间不会被垃圾回收,如果闭包的函数很多可能造成内存泄漏
如果是异步执行,此时的全局变量会随之后的执行而改变,因此如果是循环执行,得到的变量是相同的,这是因为
js代码的执行是有优先级的,主程序优先级最高,会先执行,而异步任务会在之后执行,此时得到的var变量是同一个(var变量不会受到块级作用域限制),解决方法:- 使用let,let声明的变量在每轮次会重新绑定
- 使用函数包裹,让异步执行获取函数的变量
for (var i = 0; i < 10; i++) { // 输出都是 10 setTimeout(()=>console.log(i), 100) // 输出 0 1 2 3 4 5 6 7 8 9 (function(j){ setTimeout(()=>console.log(j), 100) })(i) } for (let i = 0; i < 10; i++) { // 输出 0 1 2 3 4 5 6 7 8 9 setTimeout(()=>console.log(i), 100) }
变量提升
在
js中有奇怪的现象,下列声明会提升到当前作用域的最前面var声明的变量(包括函数),但初始化不会提升,得到undefined- 直接使用
function name() {}进行声明的函数会提升,在开头就可以使用 var变量声明提升到在声明函数之前
这些情况会提升,在
js词法环境中会被创建,但存在暂时性死区TDZ,暂时无法访问ReferenceError: Cannot access ... before initializationclass类声明let和const声明的变量import导入ES模块- 函数的变量列表未按列表顺序创建并使用默认值,比如
foo(x = y, y = 2)
匿名函数,会根据指定变量的声明规则决定在之前是
TDZ还是undefined变量提升是由于
js引擎解析文件时,会预先在词法环境创建变量,也就是变量提升- 早期时仅有
var和函数,当时希望这样能够方便灵活地调用这些变量和函数,并没有考虑过提前使用可能会造成声明不清晰和混乱 - 而在之后的
es6语法新增的let和const都明确声明初始化,减少变量提升带来的混乱
- 早期时仅有
技巧
防抖和节流
防抖:单位时间内频繁触发事件,只执行最后一次
处理的思路在于使用定时器
- 声明一个可存储定时器的变量
- 在事件触发时,判断是否有定时器,有则清除定时器,再重新设置定时器;没有定时器则设置定时器
- 定时器里面执行事件处理逻辑
常用于输入验证和搜索建议
function debounce(fn, delay) { let timer = null return function () { if (timer) clearTimeout(timer) timer = setTimeout(() => { fn() }, delay) } }Lodash中有用于防抖的函数,可以使用_.debounce(fn, delay)调用
节流:在事件触发时,规定一个执行周期,规定时间内只执行一次事件处理逻辑,也可以实现前一个任务还在,则取消触发
- 常用于高频事件比如鼠标移动、页面滚动等
- 处理规定时间内只执行一次的思路在于使用定时器
- 声明一个可存储定时器的变量
- 如果事件触发,先判断是否有定时器,如果有不创建定时器;没有才创建定时器
- 定时器里面执行事件处理逻辑,同时在定时器结束的时候,将定时器置为
null
function throttle(fn, delay) { let timer = null return function () { if (timer) return timer = setTimeout(() => { try { fn() } finally { timer = null } }, delay) } }Lodash中有用于节流的函数,可以使用_.throttle(fn, delay)调用- 处理前一个任务还在,取消触发的思路也类似,只是定时器可以换成一个
flag变量,由于js本身没有锁的实现,可能出现多个线程同时访问锁变量,只适合不强制要求必须只执行一个任务的场景,可以考虑async-mutex
// 简单的实现,可能存在多个任务同时执行,只是在前端可能性极小 function runSkipIfBusy(fn) { let flag = true return function () { if (flag) { flag = false try { fn() } finally { // 防止错误抛出,导致任务无法再次执行 flag = true } } } } // 导入 async-mutex import * as asyncMutex from 'https://cdn.jsdelivr.net/npm/async-mutex@0.5.0/+esm' const mutex = new asyncMutex.Mutex(); async function runSkipIfBusy(fn) { if (mutex.isLocked()) { console.log("任务在执行中") return false; } await mutex.runExclusive(fn); return true; } async function doTask(id) { await runSkipIfBusy(async () => { console.log(`任务 ${id} 开始`); await new Promise(r => setTimeout(r, 2000)); console.log(`任务 ${id} 结束`); }); } doTask(1); doTask(2); doTask(3);如果只希望同一时间只有一个任务在运行,可以使用
promise串联队列实现function createMutex() { let chain = Promise.resolve(); // 保存最后一个任务 return async function runExclusive(fn) { const run = async () => await fn(); chain = chain.then(run, run); return chain; }; }
dom
Node接口,这是所有dom节点继承的接口,它允许我们使用相似的方式对待这些不同类型的dom对象- 所有的节点类型都继承了这个接口
- 此接口有一个父接口
EventTarget,可以接收事件、并且可以创建侦听器,具体见事件部分
document
- 在浏览器全局中有一个
Document接口,接口表示任何在浏览器中载入的网页,并作为网页内容的入口- 描述了任何类型的文档的通用属性与方法,可以操作元素节点
- 在浏览器中有一个
document对象是它的实例,可以访问整个文档
document对象有一些属性可以获取到网站信息document.title: 获取和设置当前网页的标题document.cookie: 获取和添加当前网页的cookiedocument.URL: 获取当前网页的URLdocument.location: 获取当前网页的location对象,同时也可以直接赋值字符串,将浏览器跳转到指定URL(实际上是修改location对象的href属性)document.readyState: 获取当前网页的状态loading: 正在加载interactive: 已经加载完成,但是DOM树还没有构建完成complete: DOM树构建完成
document.visibilityState: 获取当前网页的可见状态,一般情况下可以通过事件监听变化hidden: 当前网页不可见visible: 当前网页可见
获取元素节点
document对象可以通过body、head属性直接访问到body、head节点,也可以通过children访问到根html节点除此之外,
document对象还定义了一些方法,用于获取满足条件的子元素节点getElementById:通过id获取元素节点,返回第一个匹配的元素节点,如果没有匹配的元素节点,则返回nullgetElementsByTagName:通过标签名获取元素节点,返回一个元素节点动态集合HTMLCollection(内容实时更新)getElementsByClassName:通过类名获取元素节点,返回一个元素节点动态集合HTMLCollectiongetElementsByName:通过name属性获取元素节点,返回一个元素节点静态集合NodeList(内容不会变化)querySelector:通过css选择器获取元素节点,返回第一个匹配的元素节点,如果没有匹配的元素节点,则返回nullquerySelectorAll:通过css选择器获取元素节点,返回一个元素节点静态集合NodeList
const byId = document.getElementById('fruit-list'); console.log('getElementById:', byId); // 返回值:<ul id="fruit-list" ...>(Element) const byTag = document.getElementsByTagName('li'); console.log('getElementsByTagName:', byTag, '长度:', byTag.length); // 返回值:HTMLCollection(5),包含5个<li>,动态集合 const byClass = document.getElementsByClassName('item'); console.log('getElementsByClassName:', byClass, '长度:', byClass.length); // 返回值:HTMLCollection(5),包含class="item"的5个<li>,动态集合 const byName = document.getElementsByName('myList'); console.log('getElementsByName:', byName, '长度:', byName.length); // 返回值:NodeList(1),包含<ul name="myList">,静态集合(仅document可调用) const firstSpecial = document.querySelector('.special'); console.log('querySelector:', firstSpecial); // 返回值:第一个class="special"的<li>元素(Element) const allItems = document.querySelectorAll('.item'); console.log('querySelectorAll:', allItems, '长度:', allItems.length); // 返回值:NodeList(5),包含所有class="item"的<li>元素,静态集合其中除了
getElementById、getElementsByName方法外,其他查找方法不依赖全局唯一标识,可以在节点上使用,仅查询子树上的节点
- 通过节点之间的关系查找
- 由于
HTML换行和空格会产生空白文本节点,为了方便获取元素节点,提供了两类获取节点的方法,可以根据是否需要获取文本节点来选择获取方法 - 对文本节点和元素节点可以通过
NodeType属性判断,文本的NodeType为3,元素的为1,当然也可以使用Node.TEXT_NODE等常量判断 - 空白文本节点也就是文本节点中没有内容,
node.textContent.trim() === ''
- 由于
| 属性 / 方法 | 说明 |
|---|---|
element.parentNode | 返回父节点(可能是元素或document) |
element.parentElement | 返回父元素(排除document、文档片段等非元素节点) |
element.children | 返回所有子元素(不含文本节点) |
element.childNodes | 返回所有子节点(含文本、注释) |
element.firstChild / element.lastChild | 返回第一个/最后一个子节点(含文本节点) |
element.firstElementChild / element.lastElementChild | 返回第一个/最后一个子元素节点 |
element.nextSibling / element.previousSibling | 返回相邻的兄弟节点(含文本节点) |
element.nextElementSibling / element.previousElementSibling | 返回相邻的兄弟元素 |
element.contains(node) | 判断当前节点是否包含指定节点node |
element.hasChildNodes() | 检查是否存在子节点 |
- 其他刻获取元素的属性和方法
document.documentElement:获取文档的根元素html(同时也是唯一子元素节点firstElementChildren)document.body:获取文档的 body 元素document.head:获取文档的 head 元素document.script:获取包含所有的 script 元素的列表document.links:获取包含所有的 a 元素和 area 元素的列表document.images:获取包含所有的 img 元素的列表document.forms:获取包含所有的 form 元素的列表document.styleSheets:获取包含所有的 link 元素的列表document.scrollElement:获取滚动文档,应该是documentElement(根元素)document.fullscreenElement:获取当前全屏元素,如果没有全屏元素,则返回 nulldocument.activeElement:获取当前获得焦点的元素document.elementFromPoint(x,y):返回位于浏览器视口指定坐标处的元素
创建节点
- 通过
js动态创建与定义的节点对象的方法有:document.createElement(tagName):创建一个元素节点,tagName表示节点标签名document.createTextNode(text):创建一个文本节点,用于添加文本document.createComment(text):创建一个注释节点,一般很少使用document.createDocumentFragment():创建一个文档片段,用于存放临时节点,之后批量插入到DOM树document.importNode(node, deep):获取一个来自其他文档的节点副本,一般用于从其他文档比如iframe中复制节点,deep表示是否深度导入element.cloneNode(deep):克隆一个节点,deep表示是否深度克隆
影子根节点
ShadowRoot:元素内部的一个独立DOM树,用来实现 封装的DOM子树,可以隔离样式和DOM,避免影响页面其他部分创建节点
- 选择宿主节点
- 创建影子根节点,设置内部元素的外部可见性
mode,外部需要通过宿主元素的shadowRoot访问,如果mode为open,则外部可以访问到shadowRoot,否则外部不可直接访问 - 为影子根节点添加内容
// 选择宿主节点 const element = document.querySelector('#my-element'); // 创建影子根节点 const shadowRoot = element.attachShadow({ mode: 'open' }); // 为影子根节点添加内容 shadowRoot.innerHTML = `<p>Hello World!</p>` // 也可以使用之后的节点插入方法 shadowRoot.append(document.createElement('p')); // 外部访问 console.log(shadowRoot.querySelector('p').textContent); console.log(element.shadowRoot.querySelector('p').textContent);
- 自定义元素:可以对
HTMLElement进行扩展,创建自定义元素类 - 自定义元素类继承
HTMLElement类,定义元素行为- 需要创建一个名为
observedAttributes的静态属性,包含元素需要变更通知的所有属性名称的数组 - 构造函数重写,并使用
super()初始化HTMLElement类构造 - 完善生命周期钩子函数
connectedCallback():元素插入到DOM时调用disconnectedCallback():元素从DOM中移除时调用adoptedCallback():元素被移动到新的document时调用attributeChangedCallback(name, oldValue, newValue):observedAttributes中的属性值变更时调用
- 需要创建一个名为
- 使用
customElements.define(tagName, class, options)tagName:自定义元素标签名,如my-elementclass:自定义元素类options:定义元素行为,目前只包含extends属性,用于继承其他现有元素,现有元素可以设置is属性,属性值对应自定义元素标签名,表明应该按照什么元素标签来显示(safari浏览器不支持is)
- 在定义之后可以使用
customElements.upgrade(element)将自定义节点绑定样式,之后element instanceof MyElement才为true
// <my-element name="张三" age="18"></my-element>
class MyElement extends HTMLElement {
static get observedAttributes() {
return ['name', 'age'];
}
constructor() {
super();
// 考虑使用影子节点,避免样式冲突
let shadowRoot = this.attachShadow({ mode: 'open' });
const pNode = document.createElement('p');
pNode.textContent = `name: ${this.getAttribute('name')} age: ${this.getAttribute('age')}`;
this.shadowRoot.append(pNode);
this.pNode = pNode;
}
attributeChangedCallback(name, oldValue, newValue) {
this.pNode.textContent = `name: ${this.getAttribute('name')} age: ${this.getAttribute('age')}`;
}
}
customElements.define('my-element', MyElement);内部元素的处理
一个挂载了影子节点的元素中可能有子元素标签,这些子元素将作为影子节点中的<slot/>的内容,这和许多前端框架的插槽功能类似
- 默认插槽:仅使用
<slot/>定义的插槽,所有内容默认放在默认插槽中 - 具名插槽:支持对插槽命名
<slot name="xxx"/>,并在子标签使用slot="xxx"属性表明放入哪个插槽中 - 内容回退:如果没有提供对应插槽的内容,插槽标签中的内容将显示出来
- 内容更新:插槽的内容是实时绑定的,来自元素的子元素标签,只需修改子标签,内容就会更新
- 变化监听:插槽的内容发生变化时,会在
slot元素上触发slotchange事件
节点插入
将节点插入在指定位置
如果节点来自已在树上的节点引用,也就是获取到的节点,那么插入将变为移动,而不是复制
preappend和append方法插入的内容将保持插入时的顺序,相当于在开头或结尾按参数从左往右添加一些节点before和after和preappend和append方法插入的规则一致replaceWith方法效果相当于先在前或后插入再移除当前节点,不过效率更好,此方法在元素未被挂载到父节点时无效被移除的节点依旧可以通过引用访问,只是不在
DOM树上兼容性
IE:最早仅有appendChild、insertBefore、replaceChild和removeChild方法,也仅有这四个方法有返回值(被插入或删除的节点),这四个方法仅支持一次插入或删除单个节点nodesOrText的参数可以是节点或字符串,字符串会自动转换为文本节点对象有大量需要插入的节点时考虑使用文档片段
DocumentFragmentconst frag = document.createDocumentFragment(); frag.append(child1, child2); parent.append(frag); // child1, child2 被插入 parent,frag 变空
方法 调用者 说明 parent.appendChild(node)父节点 将节点添加为最后一个子节点 parent.insertBefore(newNode,referenceNode)父节点 在指定子节点前插入新节点 parent.replaceChild(newNode,oldNode)父节点 用新节点替换旧节点 parent.removeChild(node)父节点 删除子节点(返回被删除的节点) element.append(...nodesOrText)父节点 可添加多个节点或文本,位置在末尾 element.prepend(...nodesOrText)父节点 可添加多个节点或文本,位置在开头 element.before(...nodesOrText)一般节点 在当前节点之前插入内容 element.after(...nodesOrText)一般节点 在当前节点之后插入内容 element.replaceWith(...nodesOrText)一般节点 用指定内容替换当前元素节点 element.remove()一般节点 直接从文档中移除自身和子树
元素节点的方法属性
- 元素节点的通用方法属性都是从
HTMLElement继承的,这些属性方法可以在所有的元素节点中使用
基本属性(
Element信息)attributes:属性集合(NamedNodeMap),类数组类型,支持遍历属性,同时支持使用属性名称访问,访问结果是一个属性对象,属性值是属性对象中的value属性,一般直接使用以下属性方法处理属性getAttribute(name):获取属性值字符串setAttribute(name, value):设置属性值hasAttribute(name):检查属性是否存在removeAttribute(name):删除属性toggleAttribute(name, force?):切换布尔属性,第二个参数用于强制设置为true或falsecreateAttribute(name):创建一个属性类型,赋值value后,可以使用setAttribute(attribute)方法直接设置属性dataset:DOMStringMap,访问data-*属性,比如<div id="el" data-user-id="123"></div>可以通过dataset.userId访问到
id:string类型,元素的idtagName:string类型,标签名(大写)nodeName:string类型,节点名,等于tagNamenodeType:number类型,节点类型dir:string类型,元素方向,比如ltr、rtl,autolang:string类型,元素语言,比如en、zh,zh-CNtitle:string类型,元素标题,比如鼠标悬停时显示的提示信息tabIndex:number类型,元素tab键索引顺序(从低到高),0表示元素不能被 tab 访问draggable:boolean类型,元素是否可拖拽,默认为falsehidden:boolean类型,元素是否隐藏,默认为falsetranslate:boolean类型,元素是否可翻译,默认为true- 元素的内部内容:用于返回或设置元素内部内容,
innerHTML被替换将重新生成其中的节点,innerText和contentText被替换将移除其中的节点,改为文本,其中innerText会将\n转换成br标签,而contentText不会,将当成空白文本(作为空格)innerHTML:string类型,元素内部内容,和文件中保持一致,将被按照html解析规则读取成节点结构,不会执行script中的代码,但依旧有可能被XSS注入攻击,比如<img src="x" onerror="alert(1)">中的onerror标签事件内联属性是会执行的innerText:string类型,元素纯内部文本内容,也就是用户看到的文字,其中有因为css样式产生的换行行为textContent:string类型,元素纯内部文本内容,也就是文件中标签除去内部标签以后的剩余文本,换行符也被保留
innerHTML 风险
- 在为了安全或速度考虑时,可能将部分
html结构通过请求获取,但这样可能造成XSS等风险,以下方法可以缓解风险- 使用
Content-Security-Policy也就是CSP控制资源加载地址,不受信的远程地址资源将不被加载,加载白名单- 在响应头中添加
Content-Security-Policy - 在前端
meta标签中添加Content-Security-Policy,禁止内联脚本和不安全功能
- 在响应头中添加
- 后端对需要发送的结构进行清洗,确保服务器发送的内容中不包含脚本和不可信的
url - 前端再清洗一遍,之后可以考虑保存起来
- 对其他来源的内容使用
iframe sandbox加载,<iframe sandbox srcdoc="<p>内容</p>"></iframe>sandbox属性需要填入对应的控制,内部才能使用相应的功能,加载脚本allow-scripts、开放外部源(将用于获取cookie和父页面信息)allow-same-origin、开关新页面或重定向allow-top-navigation
- 使用
http only的cookie等减小恶意脚本带来的损失
- 使用
样式与布局
style:内联样式对象(CSSStyleDeclaration),可以直接挂载属性,属性名会从驼峰转为-连接命名,对应css样式getPropertyValue(name):返回对应的属性值setProperty(name, value, important?):设置属性值,第三个参数表示是否为!importantremoveProperty(name):删除属性
cssText:内联样式字符串,可以直接赋值,和标签的style写法一致,相当于直接操作style属性字符串className:string类型,class字符串classList:管理class字符串列表,间接控制样式,可以使用方法add/remove/toggle/contains添加、删除、切换和判断是否存在某个classoffsetParent:最近的非static定位祖先元素,可能是绝对定位元素的包含块,也可能是以下情况的元素- 使用不同
zoom的祖先元素 - 当是
static元素时,tr/td/table这些祖先元素也可能成为offsetParent - 如果这些情况的祖先元素都没有,则
offsetParent为body display:none或元素是body/html时为nullfirefox下fixed元素定位父元素为body,其他浏览器值为null
- 使用不同
offsetLeft/Top/Width/Height:元素的边框盒子占用空间尺寸和相对于offsetParent的内边距盒子左上角的位置(整数)clientWidth/Height:获取内边距盒子的尺寸(整数),不包含滚动条和边框document.documentElement的clientWidth/Height为视口尺寸,等同于window.innerWidth/Height减去浏览器的滚动条宽度。document.body的clientWidth/Height为页面实际尺寸
scrollWidth/Height:元素内边距盒子的完整大小(整数),包含overflow:hidden;隐藏区域- 如果里面的元素是
absolute元素,负的left和top属性将使元素向左上角移动,使一部分区域不可能被看到(被浏览器忽略),此时scrollWidth和scrollHeight将仅包含可访问区域的大小
- 如果里面的元素是
getBoundingClientRect():元素相对于视口渲染的位置和尺寸(按边框盒子计算的浮点数),会受到缩放的影响,返回一个DOMRect对象,包含以下属性left:元素左边框到视口左边框的距离top:元素上边框到视口上边框的距离right:元素右边框到视口右边框的距离bottom:元素下边框到视口下边框的距离width:元素宽度height:元素高度x:元素左边框到视口左边框的距离y:元素上边框到视口上边框的距离
getClientRects():返回元素的渲染盒DOMRect列表,因为换行和伪元素的存在,渲染盒可能不止一个window.getComputedStyle(element):获取元素样式对象(CSSStyleDeclaration),包含所有样式属性,可以获取到width和height(效果受box-sizing影响)scrollLeft/Top:滚动条位置,初始位置是0,并且值随着向右和向下而增大,增大幅度和内容中的点移动的距离相同。但初始位置会受到文本布局方向的影响,比如direction、write-mode、flex-direction,也就是说展示区域初始会在右上角、左右下角,这时会出现负值scrollIntoView():滚动元素到可见区域
布局属性如
offset/client/scroll/getBoundingClientRect/getComputedStyle的访问会触发立即回流尺寸属性的区别 绝对定位元素定位指南
绝对定位元素通常通过
left、top确定位置,因此定位主要是对left、top的计算- 绝对定位元素是相对包含块左上角定位自身外边距区域的位置,也就是会受到自身的
margin影响 - 需要使用
js动态计算的场景通常和某个动态的clientX和clientY相关 - 计算的重点在于计算这个
clientX和clientY与元素的left、top的相对关系,确保以下等式成立
- 绝对定位元素是相对包含块左上角定位自身外边距区域的位置,也就是会受到自身的
offsetParent可以获取到元素的定位父元素,但最好为父元素添加position: relative,因为offsetParent获取到的元素可能不是定位元素,而是其他的已定位元素获取元素
left、top主要是使用element.offsetLeft、element.offsetTop,需要注意的是offset包含了元素自身的margin和border,需要减去这些宽度才是元素的left、top当然也可以考虑使用
getBoundingClientRect获取元素和父relative边框盒子的视口left、top,获取到的边距包含了margin和相对定位元素的padding,需要减去这个宽度如果是从一般元素转换为
absolute元素时,在有外边距折叠的情况下,margin-top会使元素出现初始的偏移,最方便的解决办法是从一开始就不出现边距折叠,或者还原折叠(需要每个相关元素都有上边距折叠,统一相对偏移额外减去margin-top)
let [resultLeft, resultTop] = [0, 0] const elementStyle = window.getComputedStyle(event.target) // 方法1:基于 offsetLeft 和 offsetTop // 新位置 = 当前鼠标位置 + 初始位置 - 初始鼠标位置 // 应该设置的位置偏移 resultLeft = event.target.offsetLeft - event.clientX - parseFloat(elementStyle.marginLeft) - parseFloat(elementStyle.borderLeft) resultTop = event.target.offsetTop - event.clientY - parseFloat(elementStyle.marginTop) - parseFloat(elementStyle.borderTop) // 方法2:基于 getBoundingClientRect const { left:parentLeft, top:parentTop } = event.target.parentElement.getBoundingClientRect(); const { left, top } = event.target.getBoundingClientRect(); resultLeft1 = event.clientX - left + parentLeft + parseFloat(elementStyle.marginLeft); resultTop1 = event.clientY - top + parentTop + parseFloat(elementStyle.marginTop); // 之后在 mousemove 中设置位置 element.style.left = `${event.clientX + resultLeft}px` element.style.top = `${event.clientY + resultTop}px`其他实用方法
matches(selector): 判断元素是否匹配某个CSS选择器closest(selector): 查找最近的匹配祖先元素(包含自身)contains(node): 检查元素是否包含指定节点focus()/blur(): 聚焦或失去焦点,需要是表单元素或具有tabindex属性,获取到焦点的元素会显示在可视区域中requestFullscreen(): 让元素全屏显示,返回值是Promise对象- 要求在用户点击、按键等事件内才能调用,否则会报错
Pesmission check failed。但经过测试,点击元素后,立刻使用刷新按钮居然会使全局的全屏执行生效 - 退出全屏使用
document.exitFullscreen() document.fullscreenElement属性会返回当前全屏元素,如果没有元素全屏,则返回null
- 要求在用户点击、按键等事件内才能调用,否则会报错
- 图片放大镜中包含了位置转换问题,遇到这种问题需要使用
getBoundingClientRect()获取元素的位置,然后根据缩放倍数进行转换
<div id="img-container">
<div id="hover-block"></div>
<img id="origin-img" src="" alt=""/>
<div id="scale-container">
<img id="scale-pic" src=""/>
</div>
</div>const imgContainer = document.getElementById('img-container')
const img = document.getElementById('origin-img')
const scaleContainer = document.getElementById('scale-container')
const scalePic = document.getElementById('scale-pic')
const hoverBlock = document.getElementById('hover-block')
function svgToBase64(svg) {
const encoded = new TextEncoder().encode(svg); // 转为 UTF-8 bytes
let binary = '';
for (let byte of encoded) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
window.onload = () => {
// 定义一个 200×200 SVG,有明显的彩色块
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<defs>
<pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse">
<rect width="50" height="50" fill="#eee"/>
<rect width="25" height="25" fill="#ff8a00"/>
<rect x="25" y="25" width="25" height="25" fill="#e52e71"/>
</pattern>
</defs>
<rect width="200" height="200" fill="url(#grid)" />
</svg>
`.trim();
// 转换为 Base64 数据 URL
const svgUrl = "data:image/svg+xml;base64," + svgToBase64(svg);
// 设置图片 src
img.src = svgUrl;
scalePic.src = svgUrl;
};
imgContainer.addEventListener('mouseenter', () => {
scaleContainer.style.display = 'block'
hoverBlock.style.display = 'block'
})
imgContainer.addEventListener('mousemove', (e) => {
if (scaleContainer.contains(e.target)) {
scaleContainer.style.display = 'none'
hoverBlock.style.display = 'none'
return
}
const scaleViewRect = scaleContainer.getBoundingClientRect()
const scalePicRect = scalePic.getBoundingClientRect()
const imgRect = imgContainer.getBoundingClientRect()
const scale = scalePicRect.width / imgRect.width
const minLeft = scaleViewRect.width - scalePicRect.width
const minTop = scaleViewRect.height - scalePicRect.height
// 转换坐标,获取到原图放大中心坐标 (clientX, clientY)
// 此坐标减去 放大区域大小/scale/2 对应放大区域左上角应该显示位置
// 再乘上 scale 得到放大后的左上角坐标
let x = e.clientX - imgRect.left
let y = e.clientY - imgRect.top
let left = (x - scaleViewRect.width / scale / 2)
let top = (y - scaleViewRect.height / scale / 2)
let scaleLeft = left * scale
let scaleTop = top * scale
scalePic.style.left = Math.min(Math.max(-scaleLeft, minLeft), 0) + 'px'
scalePic.style.top = Math.min(Math.max(-scaleTop, minTop), 0) + 'px'
// 设置一个额外的 fixed 的块显示放大区域
// 之前的是相对位置坐标,需要转换为页面坐标 (+imgRect.left)
hoverBlock.style.left = (Math.max(Math.min(left, imgRect.width - scaleViewRect.width / scale), 0)) + 'px'
hoverBlock.style.top = (Math.max(Math.min(top, imgRect.height - scaleViewRect.height / scale), 0)) + 'px'
hoverBlock.style.width = scaleViewRect.width / scale + 'px'
hoverBlock.style.height = scaleViewRect.height / scale + 'px'
})
imgContainer.addEventListener('mouseleave', () => {
scaleContainer.style.display = 'none'
hoverBlock.style.display = 'none'
})#img-container {
width: 200px;
height: 200px;
position: relative;
& img {
object-fit: cover;
}
}
#scale-container {
width: 100px;
height: 100px;
overflow: hidden;
position: absolute;
display: none;
z-index: 100;
left: 100%; /* 紧贴右侧 */
top: 0;
margin-left: 5px; /* 间距 */
}
#scale-pic {
position: absolute;
transform: scale(2);
transform-origin: left top;
left: 0;
right: 0;
}
#hover-block {
position: absolute;
border: pink 1px solid;
box-sizing: border-box;
background-color: #fafafaaa;
display: none;
z-index: 100;
transform-origin: left top;
}特殊节点的方法属性
- 在特殊类型的节点上,可以使用一些特殊方法属性
FormData对象用于处理表单数据append(name, value[, fileName]):追加数据项,如果第二参数是Blob对象或File对象,则可以设置文件名,Blob类型的默认名称是blob,File对象的默认名称是该文件的名称delete(name):删除数据项get(name):获取对应名称的第一个数据项getAll(name):获取对应名称的所有数据项has(name):判断数据项是否存在set(name, value[, fileName]):设置数据项,如果存在覆盖values():获取所有数据项的值迭代器
| 名称 | 描述 | 示例用法 |
|---|---|---|
src | 图像源URL,可动态设置 | img.src = 'new.jpg'; |
alt | 替代文本,用于无障碍和SEO | img.alt = '描述'; |
width / height | 显示宽度/高度(像素),不影响原图比例 | img.width = 300; |
naturalWidth / naturalHeight | 原图宽度/高度(只读) | console.log(img.naturalWidth); |
complete | 加载完成布尔值(true/false) | if (img.complete) { ... } |
currentSrc | 当前实际加载的源(考虑 srcset) | console.log(img.currentSrc); |
crossOrigin | 跨域设置('anonymous'/'use-credentials') | img.crossOrigin = 'anonymous'; |
decode() | 异步解码图像(现代浏览器,支持WebP等) | img.decode().then(() => { ... }); |
| 名称 | 描述 | 示例用法 |
|---|---|---|
src | 视频源 URL,或使用 <source> 子元素 | video.src = 'video.mp4'; |
autoplay | 自动播放布尔值(需 muted 以符合浏览器策略) | video.autoplay = true; |
controls | 显示浏览器默认控件布尔值 | video.controls = true; |
loop | 循环播放布尔值 | video.loop = true; |
muted | 静音布尔值(播放前必须设置) | video.muted = true; |
preload | 预加载策略('none' / 'metadata' / 'auto') | video.preload = 'auto'; |
currentTime | 当前播放时间(秒,可设置) | video.currentTime = 10; |
duration | 总时长(秒,只读) | console.log(video.duration); |
volume | 音量(0.0 ~ 1.0) | video.volume = 0.5; |
playbackRate | 播放速度(1.0为正常) | video.playbackRate = 2; |
videoWidth / videoHeight | 视频原宽/高(像素,只读) | console.log(video.videoWidth); |
poster | 封面图像 URL | video.poster = 'poster.jpg'; |
play() | 开始播放(返回 Promise) | video.play(); |
pause() | 暂停播放 | video.pause(); |
load() | 重新加载源 | video.load(); |
canPlayType(type) | 检查是否支持媒体类型(返回 '' / 'maybe' / 'probably') | video.canPlayType('video/mp4'); |
requestFullscreen() | 请求全屏(需用户交互) | video.requestFullscreen(); |
| 名称 | 描述 | 示例用法 |
|---|---|---|
src | 音频源 URL,或使用 <source> 子元素 | audio.src = 'audio.mp3'; |
autoplay | 自动播放布尔值(需 muted) | audio.autoplay = true; |
controls | 显示控件布尔值 | audio.controls = true; |
loop | 循环播放布尔值 | audio.loop = true; |
muted | 静音布尔值 | audio.muted = true; |
preload | 预加载策略 | audio.preload = 'metadata'; |
currentTime | 当前时间(秒) | audio.currentTime = 30; |
duration | 总时长(秒) | console.log(audio.duration); |
volume | 音量 | audio.volume = 0.8; |
playbackRate | 播放速度 | audio.playbackRate = 1.5; |
play() | 开始播放 | audio.play(); |
pause() | 暂停 | audio.pause(); |
load() | 重新加载 | audio.load(); |
canPlayType(type) | 检查支持类型 | audio.canPlayType('audio/ogg'); |
| 名称 | 描述 | 示例用法 |
|---|---|---|
src | string - 外部页面 URL。可动态设置 iframe 的加载页面 | iframe.src = 'https://example.com'; |
srcdoc | string - 内联 HTML 内容字符串。优先级高于 src | iframe.srcdoc = '<h1>Hello iframe</h1>'; |
name | string - iframe 的名称,可用于 window.frames[name] 或表单目标 | iframe.name = 'userFrame'; |
sandbox | DOMTokenList - 空格分隔的限制列表。可用 add / remove / toggle 修改。可选值:allow-scripts、allow-same-origin、allow-forms 等 | iframe.sandbox.add('allow-scripts'); iframe.sandbox.remove('allow-forms'); |
allow | string - iframe 内允许使用的功能。可包含多个权限关键字,如 camera、microphone、fullscreen、payment | iframe.allow = 'camera; microphone; fullscreen'; |
allowFullscreen | boolean - 是否允许 iframe 内容进入全屏模式 | iframe.allowFullscreen = true; |
allowPaymentRequest | boolean - 是否允许 iframe 使用 Payment Request API | iframe.allowPaymentRequest = true; |
referrerPolicy | string - 设置加载 iframe 时的 Referer 策略。可选值:"no-referrer"、"origin"、"origin-when-cross-origin"、"strict-origin" 等 | iframe.referrerPolicy = 'no-referrer'; |
loading | "lazy" / "eager" - 控制加载策略,lazy 延迟加载,eager 立即加载 | iframe.loading = 'lazy'; |
credentialless | boolean - 是否加载内容时不携带凭证(cookies / storage) | iframe.credentialless = true; |
fetchPriority | "high" / "low" / "auto" - 提示浏览器加载优先级 | iframe.fetchPriority = 'low'; |
importance | "high" / "low" / "auto" - 指定加载的重要性提示 | iframe.importance = 'low'; |
csp | string - 内容安全策略(CSP),用于控制 iframe 内部资源加载规则 | iframe.csp = "default-src 'self'; script-src 'self'"; |
width | string - iframe 宽度(像素或百分比) | iframe.width = '600'; |
height | string - iframe 高度 | iframe.height = '400'; |
contentWindow | Window - iframe 内部全局 window 对象,只读。同源时可访问 | const win = iframe.contentWindow; |
contentDocument | Document - iframe 内部文档对象,只读。同源时可访问 | const doc = iframe.contentDocument; |
事件
- 事件是
js应用跳动的心脏,事件是某些事情发生的信号,可以为事件绑定处理逻辑,事件发生对应的处理逻辑就发生了,让网页动起来- 事件可能是用户的操作,比如点击、移动、键盘输入等;还可能是浏览器行为,比如加载、滚动、重绘、重排等
- 可以监听特定事件,并规定让某些新事件发生以回应这些事件
- 事件一般情况下的作用:
- 验证响应用户行为,比如验证输入信息
- 增加页面动画效果
- 增强用户体验度
Event
Event对象:是事件类型的对象,所有事件都继承自这个接口,是操作事件的基础,在事件处理的过程中可以获取到这个对象,当然也可以人为创建new Event(type, options)type:是字符串类型,表示所创建事件的名称字符串options:可选,是EventInit类型的字典,接受以下字段:bubbles:默认值为false,表示该事件是否冒泡(实际上大多数事件都是冒泡的)cancelable:默认值为false,表示该事件能否被取消composed:默认值为false,指示事件是否会传递到影子根节点之外
可以通过
composedPath()获取到调用到当前节点的冒泡路径数组(EventTarget数组),从调用节点向外,最后到window,如果影子根封闭,外部无法得到影子根内部节点信息
Event实例方法- 阻止默认行为
preventDefault():告诉用户代理此事件被显式处理,它默认的动作不应该照常执行(例如页面滚动、链接跳转、复选框点击或粘贴文本) - 阻止其他监听:
stopPropagation():方法阻止捕获和冒泡阶段中当前事件的进一步传播到下一层节点stopImmediatePropagation():阻止当前节点其他同类型监听和后续传播
事件处理的阶段
事件处理经过三个阶段:捕获、目标、冒泡
- 捕获阶段:事件从
window开始,然后document向最内层元素传递,直到事件到达事件触发目标event.target,有的处理函数会在捕获阶段触发 - 目标阶段:监听器会按创建顺序被调用,激活目标节点事件非捕获阶段处理函数
- 冒泡阶段:事件向外反向传播,触发非捕获阶段的所有非捕获阶段触发器,直到回到
window- 事件冒泡是事件在元素内部向元素外部传播的过程,描述了浏览器如何处理针对嵌套元素的事件
- 点击一个嵌套容器内的元素时,事件会从最内层元素开始传播,直到最外层元素
document再回到window为止,依次触发这些元素上的对应事件 - 事件冒泡可以用于事件委托,在父元素上注册事件,就可以在所有子元素上触发事件时启动处理逻辑,由于关注的子元素可能也有子元素,事件本身的触发元素可能不是关注的子元素,可以使用
e.target.closest(selector)获取到真正关心的子元素,再处理 - 事件冒泡有时是不希望的,可以通过
event.stopPropagation()阻止事件冒泡,更外层的父元素将不会收到事件
# 以点击 button 为例 捕获阶段:window -> document -> html -> body -> div -> button 目标阶段:button 冒泡阶段:button -> div -> body -> html -> document -> window- 阻止默认行为
document和window对象都可以处理冒泡事件,在window上还可以处理浏览器级的事件,这些事件只在window对象上触发;document一般负责处理DOM的事件委托Event实例的常用属性- 创建时传入的内容
type、bubbles、cancelable、composed target:只读属性,是对事件被触发到的对象的引用,即触发事件的元素currentTarget:只读属性,用于标识事件处理器被附加的元素,也就是当前元素isTrusted:用户触发为true,由脚本触发为falsetimestamp:触发时间戳,时间原点是事件创建时刻,为了防止用于识别用户,精度有2ms误差defaultPrevented:表示preventDefault()是否被调用过
部分属性只能在事件处理函数还存在时访问到,比如
currentTarget,因此在requestAnimationFrame中,currentTarget可能为null,建议在使用requestAnimationFrame前,预存所有需要使用的属性引用- 创建时传入的内容
自定义事件
CustomEvent接口:继承自Event,用于创建自定义事件,创建自定义事件时,需要传入事件名称,然后通过new CustomEvent(type, options)创建对象,然后dispatchEvent()触发options中除了Event构造函数支持的属性外,还支持detail,一般将自定义信息挂载在detail属性中- 监听和触发的细节见事件处理
// 创建 cost event = new CustomEvent('my-event', { detail: {name: 'my-name'} }) // 监听 document.addEventListener('my-event', (e) => {console.log(e.detail.name)}) // 触发 document.dispatchEvent(event)
浏览器事件
- 主要的事件基本上可以分为这些种类,具体可以在MDN查看,在处理事件时会接收到这些类型的事件,可以获取对应的属性
模拟用户操作的节点函数,比如
click()会触发对应的事件(只要事件监控到的状态改变),当然也可以直接dispatchEvent()触发,但在事件处理逻辑中要避免重复触发
- 主要是用户鼠标或键盘输入事件,可以放在所有页面元素上监听区域内操作
| 事件类型 | 说明 | 直接接口 | 继承自 |
|---|---|---|---|
| 鼠标事件 | 鼠标点击click、双击dblclick、移动mousemove(高频触发)、进入mouseenter、离开mouseleave、按下mousedown、抬起mouseup等事件 | MouseEvent | UIEvent |
| 指针事件 | 统一指针事件(鼠标、触控笔、触摸),包括按下pointerdown、移动pointermove(高频触发)、释放pointerup、进入元素pointerenter等,支持多指触控、指针捕获和压力感应 | PointerEvent | MouseEvent |
| 触摸事件 | 触摸屏事件(移动端),如touchstart、touchmove(高频触发)、touchend | TouchEvent | UIEvent |
| 滚轮事件 | Wheel鼠标滚轮事件(高频触发) | WheelEvent | MouseEvent |
| 页面滚动 | Scroll页面滚动事件(高频触发) | - | UIEvent |
| 键盘事件 | 键盘按键输入,如按下keydown(高频触发)、按下keypress(已弃用)、抬起keyup | KeyboardEvent | UIEvent |
| 输入法事件 | 输入法(IME)输入过程事件,如开始输入compositionstart、输入变化compositionupdate、确认输入compositionend | CompositionEvent | UIEvent |
| 剪贴板事件 | 剪贴板事件(copy/paste/cut) | ClipboardEvent | Event |
| 拖放事件 | 拖放操作(drag/drop),拖动到元素上dragover(drag和dragover高频触发) | DragEvent | MouseEvent |
| 框选事件 | 光标选择的内容变化selectionchange | - | Event |
UIEvent接口表示简单的用户界面事件,相比Event接口增加的属性不多detail:返回当前环境鼠标点击次数(click和dblclick)、返回鼠标点击次数+1(mousedown和mouseup)、其他事件为0view:返回触发事件的窗口对象,window对象
- 鼠标事件,鼠标事件后缀包括进入/离开区域
over/out(会冒泡,进入子元素会导致出现子元素进入和父元素离开事件)、进入/离开区域enter/leave(不会冒泡,似乎比over/out晚发生),按下/抬起时down/up、移动时move,当然mouse和pointer类型中还有一些特殊事件,也有其他类型的事件用于监控鼠标mouseclick:在mouseup事件之后触发
pointerpointerdown:只有第一个按键被按下时才会触发,这一点和mousedown不同(每个按键都触发)pointerup:只有最后的按键抬起时才会触发,这一点和mouseup不同(每个按键都触发)pointercancel:当浏览器认为当前不太可能有指针事件(当前指针过多、开启某些设置忽略误触)或在某次移动设备按下后改为移动视口时触发gotpointercapture/lostpointercapture:当元素获得/丢失指针捕获时触发
scroll:一个通用类型的事件,在元素发生滚动时(包括鼠标滚动、滚动条、js完成)触发,要求元素内可滚动(否则无法触发),还有scrollend事件监听滚动结束- 可以通过
e.target.scrollTop和e.target.scrollLeft获取滚动位置 - 如果是监听页面滚动事件应该挂载在
document对象或window对象上 - 通过设置
document.documentElement.scrollTop可以改变页面滚动位置 - 有滚动结束事件
scrollend,但safari还不支持
- 可以通过
wheel:在元素上滚动滚轮时触发,哪怕元素不可滚动
MouseEvent:可以获取到事件触发时鼠标信息,比如按下情况、位置信息relatedTarget:辅助对象,相对事件的触发对象,比如鼠标移入mouseenter中这个属性得到的是发生鼠标移出的元素- 位置信息类,
X是相对于左侧的水平坐标,Y是相对于顶部的垂直坐标clientX/clientY:只读,返回鼠标在应用程序可视窗口中的位置信息,以可视窗口左上角为原点screenX/screenY:只读,返回鼠标在屏幕中的位置信息,以屏幕左上角为原点pageX/pageY:只读,返回鼠标在页面中的位置信息,以页面顶部左上角为原点offsetX/offsetY:只读,返回鼠标在触发元素中的位置信息,以当前元素的内边距区域左上角为原点
- 按键信息类
botton:本次按下的键位信息对应的数字。0主按键,通常指鼠标左键或click()函数触发时的默认值;1辅助按键,通常指鼠标滚轮中键;2次按键,通常指鼠标右键;3第四个按钮,通常是浏览器后退按钮;4第五个按钮,通常是浏览器的前进按钮bottons:所有被按下的鼠标键位信息数字,每个二进制位代表一个按键。0没有按键或者是没有初始化;1鼠标左键;2鼠标右键;4鼠标滚轮或者是中键;8第四按键;16第五按键
shiftKey/ctrlKey/altKey/metaKey:只读,此时是否按下了shift、ctrl、alt、Meta键(windows键盘的win键和mac键盘的command键)
PointerEvent:继承也很大程度上类似于MouseEvent,但额外提供了其他属性pointerId:只读,返回当前指针的编号,不同指针编号不同pressure:返回当前指针的压力,范围是0到1,0表示没有压力,1表示最大压力width/height:只读,返回指针接触面的宽度,不支持时默认为1tiltX/tiltY:只读,返回触笔的倾斜角度,范围是,正值是向右倾斜,不支持时默认为0setPointerCapture(pointerId)/releasePointerCapture(pointerId):捕获和释放指针,需要传入指针编号,被捕获的指针就像没有离开元素边界,锁定后离开元素也会触发鼠标移动事件,释放前不会再触发所有进入或离开事件,但获取到的pointermove鼠标位置是不断变化的hasPointerCapture(pointerId):判断是否被捕获
TouchEvent:触摸屏幕或触控板,事件可以描述与屏幕的一个或多个接触点,并且包括对检测移动、接触点的增加和移除等的支持touches:包含所有的触摸点的列表,每个触点都是Touch对象,可以获取位置信息targetTouches:包含第一次触摸的点列表changeTouches:包含从上一次到现在,状态发生变化的触摸点列表shiftKey/ctrlKey/altKey/metaKey:只读,此时是否按下了shift、ctrl、alt、Meta键(windows键盘的win键和mac键盘的command键)
WheelEvent:描述了用户滚动操作,有些触控板等也可以进行滚动deltaX/deltaY/deltaZ:返回左右/上下/垂直方向的滚动距离deltaMode:返回滚动距离的单位,0表示像素,1表示行高,2表示页,这个单位不同浏览器可能不同
- 键盘事件:键盘相关的事件
keyup/keydown事件虽然可以挂载在所有元素上,但实际上需要元素被聚焦时才能触发,普通元素需要在标签添加tabindex属性(可通过tab或点击聚焦,数字表示tab切换优先级,越高越优先)
KeyboardEvent:描述了用户与键盘的交互,可以获取到按键和行为信息,每个键会单独触发事件,有以下实例属性和方法key:只读,按键名称字符串,所有按键名称见键盘按键key名称code:按键对应代码,未广泛使用,见键盘事件code名称repeat:只读,按键是否重复触发,用于keydown事件时检测是否是第一次触发(keydown事件在一直按着键时会不停触发)shiftKey/ctrlKey/altKey/metaKey:只读,此时是否按下了shift、ctrl、alt、Meta键isComposing:只读,是否正在输入法输入中,也就是事件是否在compositionstart事件触发后和compositionend事件触发前getModifierState(key):获取指定键是否被按下(按下为true),传入按键名称字符串
CompositionEvent:描述了用户输入法输入过程中的信息,相关事件包括开始输入法输入compositionstart、输入过程中组合文本变化compositionupdate、选择了某个文本结束compositionend,文本框输入过程中每次update后还会触发input输入事件,第一次输入和最后一次选择需要键入文本也会触发update事件data:返回引发相关输入事件的字符,对于start事件是空字符串、update事件是当前的字符串,对于end事件是选择的文本字符串
ClipboardEvent:描述了用户剪贴板操作,比如剪贴、复制、粘贴,可以获取到剪贴板信息clipboardData:只读,返回剪贴板信息,一个DataTransfer对象,在cut和copy可以设置复制的字符串信息setData(str);在paste可以获取剪贴板信息getData()
在事件中应该使用
event.preventDefault()阻止默认行为,默认在剪切板放入读取信息
拖动事件:可以为标签添加
draggable="true"支持原生的元素拖拽,用户可以点击并拖动元素- 拖动相关的事件都可冒泡
- 主要事件包括:
- 作用
event.target在被拖拽元素上,拖动中drag、拖动结束dragend、拖动开始dragstart,会冒泡 - 作用
event.target在被放置元素(光标指向元素)上,拖动进入可放置区域dragenter、拖动离开可元素dragleave、拖动到可放置元素上dragover、拖动放置完成drop,因为事件冒泡,此时的被放置元素可能为子元素
- 作用
- 拖动实际上也可以通过鼠标事件完成
DragEvent:拖动过程中产生的事件,继承自MouseEventdataTransfer:拖动动作,一个DataTransfer对象,见DataTransfer- 允许的拖动类型
effectAllowed:拖动的默认光标视觉效果,必须是none,copy,copyLink,copyMove,link,linkMove,move,all,uninitialized之一 - 拖动类型
dropEffect:根据拖动情况选择光标视觉效果,应该在允许的拖动类型中,有移动节点到此位置move、复制一份节点copy、建立节点连接link、被禁止拖放none - 设置拖动影子节点图像
setDragImage(image, x, y):默认是半透明的原节点样式,跟随鼠标移动,x和y是节点或图像相对指针的偏移量 setdata(format, data)/getdata(format)/cleardata(format):设置、获取或删除拖动过程中存放的数据,其中format为数据格式比如text/plain,data为数据内容files:如果拖拽的是文件,获取到拖拽的文件列表
- 允许的拖动类型
drag拖动指南:- 拖动元素必须设置
draggable="true"属性 - 监听拖拽开始
dragstart事件挂载需要拖动的元素或元素父容器上,在此时记录拖动的标签(比如dataTransfer.setData传入id等唯一标识,或通过外部变量暂存节点引用)- 设置被拖拽元素拖动时的样式(此时设置的样式会应用到被拖动的虚拟节点上),可选设置需要支持的视觉效果
effectAllowed - 如果页面上有很多可拖放元素和容器,希望只有部分元素可以拖放到此容器,可以在拖动开始时通过
setData设置唯一标识
- 拖拽到目标容器
- 监听目标容器的
dragenter事件,添加可放置的目标容器样式,并在dragleave事件中还原 - 由于事件会从子元素冒泡,需要设置计数器将
leave和enter相对次数记入,只有在第一次进入目标和抵消时才添加删除样式
- 监听目标容器的
- 在目标容器内部拖拽中
- 需要阻止默认行为才能在之后触发
drop dragover事件判断元素位置(通过判断鼠标位置clientX,并通过getBoundRect获取当前元素的位置,判断是应该插入在前面还是后面),并使用appendChild、before等方法添加元素- 通过修改节点样式设置被拖拽到的节点占位样式,可选设置确定的视觉效果
dropEffect
- 需要阻止默认行为才能在之后触发
- 拖拽完成
- 监听目标容器的
dragend事件,删除目标容器样式,并删除元素拖动的样式 - 如果有必要可以在放置完成后监听
drog事件,触发处理逻辑 - 重置拖动过程中的数据,比如被拖动节点和计数器
- 监听目标容器的
拖动例子<div class="drag-box"> <span>Drag 事件</span> <ul id="drag"> <li draggable="true">1</li> <li draggable="true">2</li> <li draggable="true">3</li> <li draggable="true">4</li> <li draggable="true">5</li> </ul> </div> <div class="drag-box"> <span>Pointer 事件</span> <ul id="pointer-drag"> <li>1</li> <li>2</li> <li>3</li> <li>4</li> <li>5</li> </ul> </div>.drag-box { width: 150px; float: left; margin-right: 5px; border: 2px solid #fdfd; text-align: center; } ul { list-style: none; margin-block: 0; padding-inline-start: 0; position: relative; } li { width: calc(100% - 10px); height: 30px; margin: 5px; background-color: #4caf50; color: white; border-radius: 5px; cursor: grab; } .dragging { background-color: #ddd; border: 2px dashed #999; box-sizing: border-box; } .dragover { background-color: #ddd; border-color: #733; }const drag = document.getElementById('drag'); const dragObj = { enterCount: 0, draggedItem: null, annimationId: null } // dragstart:拖拽开始,设置拖拽样式 drag.addEventListener('dragstart', (e) => { dragObj.draggedItem = e.target; e.dataTransfer.effectAllowed = "copy"; }); // dragend:拖拽结束,还原样式 drag.addEventListener('dragend', (e) => { drag.parentElement.classList.remove('dragover'); dragObj.draggedItem.classList.remove('dragging'); dragObj.draggedItem = null; dragObj.enterCount = 0; }); // dragenter:拖拽进入目标元素,设置目标元素样式 drag.addEventListener('dragenter', (e) => { dragObj.enterCount++; if (dragObj.enterCount === 1) { dragObj.draggedItem.classList.add('dragging'); drag.parentElement.classList.add('dragover'); } }); // dragover:拖拽进入目标元素,计算并修改元素位置 drag.addEventListener("dragover", function (e) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; if (dragObj.annimationId) return const target = e.target; const offsetY = e.offsetY; console.log(e.target, dragObj.draggedItem, drag) dragObj.annimationId = requestAnimationFrame(() => { dragObj.annimationId = null; if (target !== drag) { dragObj.draggedItem.classList.add('dragging'); if (offsetY > target.offsetHeight / 2) { drag.insertBefore(dragObj.draggedItem, target.nextSibling); } else { drag.insertBefore(dragObj.draggedItem, target); } } }) }) // dragleave:拖拽离开目标元素,还原元素位置和拖拽容器样式 drag.addEventListener('dragleave', (e) => { dragObj.enterCount--; if (dragObj.enterCount === 0) { drag.parentElement.classList.remove('dragover'); dragObj.draggedItem.classList.remove('dragging'); } }); const point = document.getElementById('pointer-drag'); const pointObj = { draggedItem: null, placeholder: null, eleOffWidth: 0, offsetX: 0, offsetY: 0, annimationId: null } // pointerdown:拖拽开始,设置拖拽样式 point.addEventListener('pointerdown', (e) => { e.preventDefault(); if (e.target !== e.currentTarget && e.target !== pointObj.placeholder) { pointObj.draggedItem = e.target; pointObj.eleOffWidth = e.target.offsetWidth + 'px'; pointObj.placeholder = e.target.cloneNode(true); pointObj.placeholder.classList.add('dragging'); // 为了快速定位指针指向的需要换位的元素,让指针穿透元素 pointObj.draggedItem.style.pointerEvents = 'none'; // 因为当前指针会穿透拖拽元素,需要重新设置拖拽光标样式 // 虽然取消本身光标样式,但可以通过父元素影响光标样式 point.style.cursor = 'grabbing'; // 放在后面,避免将当前绝对元素位置下移 point.insertBefore(pointObj.placeholder, e.target.nextSibling); // 设置可跟随 e.target.style.position = 'absolute'; e.target.style.zIndex = '9999'; e.target.style.width = pointObj.eleOffWidth; const elementStyle = window.getComputedStyle(pointObj.draggedItem) pointObj.offsetX = e.clientX - e.target.offsetLeft + parseFloat(elementStyle.marginLeft) + parseFloat(elementStyle.borderLeft); pointObj.offsetY = e.clientY - e.target.offsetTop + 2 * parseFloat(elementStyle.marginTop) + parseFloat(elementStyle.borderTop); pointObj.draggedItem.style.left = (e.clientX - pointObj.offsetX) + 'px'; pointObj.draggedItem.style.top = (e.clientY - pointObj.offsetY) + 'px'; // 锁定指针,不需要在移出容器时修改样式 point.setPointerCapture(e.pointerId); } }) // pointerend:拖拽结束,还原样式 point.addEventListener('pointerup', (e) => { point.insertBefore(pointObj.draggedItem, pointObj.placeholder); point.removeChild(pointObj.placeholder); point.releasePointerCapture(e.pointerId); pointObj.draggedItem.style.position = null; pointObj.draggedItem.style.left = null; pointObj.draggedItem.style.top = null; pointObj.draggedItem.style.zIndex = null; pointObj.draggedItem.style.width = null; pointObj.draggedItem.style.pointerEvents = null; pointObj.draggedItem = null; // document.body.style.cursor = null; point.style.cursor = null; }) // pointermove:拖拽移动,计算并修改元素位置 point.addEventListener('pointermove', (e) => { if (pointObj.annimationId) return const { clientX, clientY } = e; pointObj.annimationId = requestAnimationFrame(() => { pointObj.annimationId = null; if (pointObj.draggedItem) { // 调整被移动元素位置 pointObj.draggedItem.style.left = (clientX - pointObj.offsetX) + 'px'; pointObj.draggedItem.style.top = (clientY - pointObj.offsetY) + 'px'; const element = document.elementFromPoint(clientX, clientY); // 由于锁定,在ul外也能触发事件,所以需要判断 if (element !== point && point.contains(element) && element !== pointObj.placeholder) { if (clientY > element.getBoundingClientRect().top + element.offsetHeight / 2) { point.insertBefore(pointObj.placeholder, element.nextSibling); } else { point.insertBefore(pointObj.placeholder, element); } } } }) })- 拖动元素必须设置
- 框选事件
- 选中内容变化
selectionchange:此事件需要挂载在document上,页面光标选择变化时触发,具体获取选中内容的方法,可见Selection API
- 选中内容变化
| 事件类型 | 说明 | 直接接口 | 继承自 |
|---|---|---|---|
| 表单构建 | 表单本身数据被构建时触发(formdata) | FormDataEvent | Event |
| 表单提交 | 表单本身提交submit,在表单提交前触发,可调用 preventDefault()或使回调返回false阻止提交 | SubmitEvent | Event |
| 表单重置 | 表单本身重置reset,在表单重置前触发,可调用 preventDefault()或使回调返回false阻止重置 | - | Event |
| 文本输入 | 表单文本输入实时变化(input)、输入前(beforeInput),每次输入字符都会触发 | InputEvent | UIEvent |
| 值变更 | 表单值变更change,文本框失焦且值变化后触发,选择框或复选框值变更时立即触发 | - | Event |
| 焦点切换 | 元素获得focus或失去焦点blur时触发,有对应的冒泡版本focusin、focusout | FocusEvent | UIEvent |
- 表单上的事件
FormDataEvent:表单数据构建事件formData属性:表单数据对象FormData,包含表单数据
submitEvent:表单提交事件,可能通过input:text标签回车或button:submit标签点击提交时触发submitter属性:type="submit"提交按钮元素,如果是多个button:submit,点击触发时返回点击的元素,回车触发时返回表单中第一个button:submit元素
reset事件没有专门的接口,在button:reset点击时触发,值将重置到初始状态
formdata事件,submit事件和reset事件只能给表单对象绑定 - 表单项的事件
change:在值改变时触发,是基本的Event对象,在获取修改后值时,可以通过event.target获取- 普通的
input、select、textarea、radio对象,内容在value input[type='checkbox']对象,选择结果在checkedinput[type='file']对象,文件列表在files
- 普通的
FocusEvent:焦点变化事件,通常是由输入框触发,也可以是有tabIndex属性的元素触发- 事件包含一个辅助属性
relatedTarget,表示对应的获取或丢失焦点的元素(如果不存在或页面切换后为null) blur/focusout:输入框失去焦点时触发,focusout会冒泡focus/focusin:输入框获得焦点时触发,focusin会冒泡- 事件顺序:
blur、focusout、focus、focusin
- 事件包含一个辅助属性
InputEvent:文本输入实时变化事件,通常由输入框触发,可获取输入内容isComposing:只读,是否正在输入法输入中,也就是事件是否在compositionstart事件触发后和compositionend事件触发前data:返回插入的单个字符串,如果是删除等更改,值为nullinputType:输入的类型,列表可以在w3c文档中
- 主要包括页面生命周期、窗口交互与布局变化、导航和历史变化
| 事件类型 | 说明 | 直接接口 | 继承自 |
|---|---|---|---|
| 页面加载 | 页面加载完成(load)、页面即将卸载(beforeunload) | - | Event |
| 页面显示/隐藏 | 页面显示/隐藏(pageshow/pagehide),用于往返缓存(Back-Forward Cache) | PageTransitionEvent | Event |
| 历史状态变化 | 浏览器历史状态变化(popstate,history.pushState和history.replaceState不会触发) | PopStateEvent | Event |
| 路由变化 | URL 哈希(#)变化(hashchange) | HashChangeEvent | Event |
| 错误 | 资源或脚本错误(error),可捕获加载失败或运行时异常 | ErrorEvent | Event |
| 存储变化 | 通常是 localStorage 数据变化(storage),跨标签页触发;sessionStorage数据变化仅会在同页面的iframe中触发 | StorageEvent | Event |
| 数据库更新 | IndexedDB 数据库版本更新(versionchange) | IDBVersionChangeEvent | Event |
| 页面可见性变化 | 页面可见性变化(visibilitychange),例如切换标签页或最小化窗口 | - | Event |
| 窗口大小变化 | 浏览器窗口大小变化(resize),常用于响应式布局 | - | UIEvent |
| 全屏切换 | 进入或退出全屏(fullscreenchange),document.fullscreenElement 变化时触发 | - | Event |
| 全屏错误 | 进入或退出全屏失败(fullscreenerror) | - | Event |
| 页面打印 | Event实例,打印开始前beforeprint、开始打印或关闭打印预览后afterprint,一般打印样式处理可以直接在@media print完成 | - | Event |
页面生命周期:chrome文档
load:在相应的资源都加载完成后触发,此事件只能挂载在有资源加载的标签(img、audio、video、script、link等)或window对象上,window.onload在所有元素加载完成后触发,而document.DOMContentLoaded在DOM树渲染完成触发beforeunload:在页面即将卸载时触发,可使用preventDefault寻求关闭确认,开启一个浏览器默认弹窗显示提示信息,safari浏览器不支持此事件,考虑使用pagehide事件代替
window.addEventListener("beforeunload", (event) => { // 页面清理逻辑等操作 console.log("beforeunload"); // 标准表明在此阻止默认事件函数会提供关闭确认弹窗 event.preventDefault(); // 部分浏览器需要事件提供返回值 event.returnValue = ""; // 在此函数中直接调用 alter 等弹窗无效 });页面可见性变化
visibilitychange:此事件需要挂载在document对象上,会在对象的visibilityState属性变化时触发visibilityState:只读,当前可见性状态,可选值有visible(页面处于前景,且未锁屏,用户可见)、hidden(页面被切换或系统已锁屏)、prerender(页面渲染中,开始状态)- 典型用法是在页面不在前景时禁止某些活动
- 早期的替代是在
window上的blur事件和focus事件,基于是否点击当前页面来判断
此事件用于判断页面处于前景还是后台,还可以结合
document.hasFocus属性判断页面是否有输入的焦点全屏
fullscreenchange:全屏状态变化时触发- 此时可以通过
document.fullscreenElement获取当前全屏元素,无元素全屏时此值为null
- 此时可以通过
网页地址哈希值变化
hashchange:在网址#后的锚点信息被修改时触发- 一般由
location.hash属性修改时触发,如果修改前后值相同,不会触发 HashChangeEvent接口包含一个oldURL和newURL属性,用于获取当前和旧URL
- 一般由
历史状态变化
popstate:在用户操作前进后退或history.go等前进后退操作时触发,history.pushState、history.replaceState方法不触发popstate事件接口PopStateEvent包含一个state属性,用于获取当前历史状态对象,此对象通过history接口的history.pushState、history.replaceState方法设置,如果是不是这两个方法添加的历史,state为null
历史记录堆栈中页面条目变化处理的基本流程
- 如果新页面不包含当前文档,即有刷新,新文档会被创建,这个过程包括会向新文档的
window发送DOMContentLoad、load事件,当然新文档加载是异步的,下面的步骤会继续执行 - 如果当前页面条目的
title是通过pushState设置的,则新页面的title属性会设置为当前页面的document.title属性 - 如果新页面和旧页面的
Document对象不同,修改此属性 - 新页面中表单设置了
autoComplete属性,则表单会按规则被重置(清空或恢复初始状态) - 当新文档完成了构建,也就是
readyState为complete时让新文档可见,并触发pageshow事件,此时的persist属性为true,表明来自缓存 - 文档的
URL被修改为新值 - 如果文档替换,比如
navigator({replace:true})和location.replace(url),这本页面条目替换 - 如果有锚点
#且无滚动的历史信息,则将文档滚动到锚点 - 新页面的条目设置为当前条目
- 如果有序列化的历史状态信息,将信息恢复,否则
history.state为null - 如果
state发生改变,触发popstate事件 - 如果浏览器希望恢复页面状态,比如滚动位置和表单内容,在此时恢复
- 如果原文档和现文档的文档相同,仅
URL中的锚点片段不同,触发hashchange事件
error用于捕获全局的资源加载错误和代码执行错误,必须挂载在window对象或有资源的元素对象上- 资源加载错误不会冒泡,因此需要设置
addEventListener第二个参数为true来监听 - 不会捕获
promise的错误,这种错误需要使用promise的catch方法或unhandledrejection事件捕获 ErrorEvent接口包含以下属性:message:错误信息filename:错误文件名lineno:错误行号colno:错误列号error:错误对象
- 资源加载错误不会冒泡,因此需要设置
unhandledrejection是promise的错误处理事件,必须挂载在window对象上,得到的是PromiseRejectionEvent包含reason:错误对象promise:错误对象对应的promise对象
| 事件类型 | 说明 | 直接接口 | 继承自 |
|---|---|---|---|
| 网络进度 | 网络加载进度(XHR、FileReader,高频触发) | ProgressEvent | Event |
| 消息通信 | 跨文档、Worker、Socket 消息通信(message) | MessageEvent | Event |
| 连接关闭 | WebSocket 连接关闭 | CloseEvent | Event |
| 服务请求捕获 | Service Worker 捕获请求 | FetchEvent | ExtendableEvent |
| 支付请求更新 | 支付请求更新 | PaymentRequestUpdateEvent | Event |
| 字体加载完成 | 字体加载完成(Font Loading API) | FontFaceSetLoadEvent | Event |
- 消息通信事件
message:主要是跨文档iframe通信- 通信方使用
postMessage(message, url)方法发送消息 - 接收方利用
message事件监听消息 MessageEvent接口包含以下属性:data:发送的消息数据origin:发送方地址
- 通信方使用
| 事件类型 | 说明 | 直接接口 | 继承自 |
|---|---|---|---|
| 加速度计 | 加速度传感器(高频触发) | DeviceMotionEvent | Event |
| 设备方向 | 方向传感器(陀螺仪,高频触发) | DeviceOrientationEvent | Event |
| 游戏手柄 | 游戏手柄连接/断开 | GamepadEvent | Event |
| HID输入报告 | WebHID 设备输入报告 | HIDInputReportEvent | Event |
- 此类中的事件应该绑定在特定对象上使用,如:
MediaStream、WebRTC、WebGLContext
| 事件类型 | 说明 | 直接接口 | 继承自 |
|---|---|---|---|
| 轨道事件 | 媒体流轨道(WebRTC/MediaStream) | TrackEvent | Event |
| 数据通道事件 | WebRTC 数据通道建立事件 | RTCDataChannelEvent | Event |
| ICE候选事件 | ICE 候选者生成事件 | RTCPeerConnectionIceEvent | Event |
| 离线音频完成 | Web Audio API 离线渲染完成 | OfflineAudioCompletionEvent | Event |
| WebGL上下文事件 | WebGL 上下文丢失或恢复 | WebGLContextEvent | Event |
| 插槽更新事件 | slotchange | - | Event |
事件处理
所有的节点对象
Element、document、window都继承了EventTarget接口,表明可以接受和处理事件,其中有三个实例方法addEventListener(type, listener, options):添加特定类型的事件监听type:事件类型字符串listener:事件处理函数(e)=>{},可以获取到Event对象,e.target可以获取到触发事件的元素,不同的事件实现能够获取到其他相关的数据- 如果
listener是一个函数引用对象,在之前添加过,不会被重复添加
- 如果
options:可选,是AddEventListenerOptions类型的字典,接受以下字段:capture:默认值为false,表示是否提前到捕获阶段处理事件once:默认值为false,表示是否只处理一次,为true在第一次处理后自动移除passive:默认值为false,表示是否允许listener在事件处理过程中阻止默认行为preventDefault(),如滚动或链接跳转signal:可选,是一个AbortSignal对象,表示取消事件的信号,如果取消信号被触发,则事件处理程序将被移除
还可以使用
boolean作为options值,对应capture选项
removeEventListener(type, listener):移除某个监听函数type:事件类型字符串listener:之前使用的事件处理函数
dispatchEvent(e):触发某个事件,传入一个Event对象
标签的内联事件处理器
常见的元素事件几乎都有对应的
on属性,比如onclick等,这些属性接受一个事件处理函数,可以直接将函数提供,进行监听,这来源于内联事件处理器属性onclick="click()"- 现在不推荐直接在
html中定义内联事件处理器,html和js代码混在一起较难阅读 - 这个属性可以赋值,提供唯一的事件处理器,如果被修改后,之前的逻辑就被替换
function click() {} button.onclick = click;
观察器
观察器
Observer,用于异步监听节点对象属性或节点对象属性下的子节点对象属性的变化,并执行相应的回调函数,获取到所有满足要求的元素,因为元素不太容易同时触发事件,一般每次回调列表(回调参数1)中只有一个元素,第二个参数是观测器本身IntersectionObserver:检测元素是否进入或离开视口或某个元素的可视区域,当有元素超过阈值触发回调
const observer = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { console.log(entry.target, '进入视口'); console.log('可见比例:', entry.intersectionRatio); // 得到的信息类似 getBoundingClientRect() console.log('可见区域信息:', entry.intersectionRect); console.log('目标元素信息:', entry.boundingClientRect); console.log('观察区域信息:(root)属性', entry.rootBounds); // 监听对象创建到现在的时间 console.log('时间:', entry.time); } }); }); observer.observe(document.querySelector('#target'), { root: document.querySelector('#container'), // 认为可见的观察区域,默认 null 时为视口 rootMargin: '0px', // 相对观察区域的扩展(+)、收缩(-) threshold: 0.5, // 触发回调时的阈值,可以是个列表 });MutationObserver:监听DOM结构的变化,例如节点的增删、属性变化、文本内容变化等
const observer = new MutationObserver((mutations, observer) => { mutations.forEach(mutation => { console.log('变动类型:', mutation.type); console.log('变动目标:', mutation.target); }); }); observer.observe(document.body, { childList: true, // 监听子节点增删 attributes: true, // 监听属性变化 attributeFilter: ['class'], // 监听的属性列表 characterData: true, // 监听文本内容变化 subtree: true // 监听整个子树 });ResizeObserver:监听某个元素的大小变化
const observer = new ResizeObserver((entries, observer) => { for (const entry of entries) { // contentRect: 获取当前元素大小位置 // 来自旧的事件规范 console.log('新的大小:', entry.contentRect.width, entry.contentRect.height); console.log('元素:', entry.target); } }); observer.observe(document.querySelector('#box'));PerformanceObserver:异步监听浏览器的性能指标
const observer = new PerformanceObserver((list, observer) => { for (const entry of list.getEntries()) { console.log(entry.name, entry.duration); } }); // 'resource': 资源加载信息 // 'paint': 首次绘制(FP, FCP) // 'longtask': 主线程长任务 // 'navigation': 页面加载阶段信息 // 'largest-contentful-paint': 最大内容绘制 observer.observe({ entryTypes: ['resource', 'paint', 'longtask'] });监控步骤
- 创建一个观察器实例,传入
(callback, options) - 使用
observe(element)添加监听元素 - 使用
unobserve(element)移除监听元素 - 使用
disconnect()取消所有监听
- 创建一个观察器实例,传入
canvas
canvas是HTML中的画布标签,一个简单的canvas标签如下:- 通常需要一个
id属性,用于获取到这个节点对象 - 绘图宽高属性,用于设置画布的宽高,默认是
300px宽100px高 - 虽然也可以使用
CSS设置宽高,但canvas标签在绘制时图像会伸缩以适应它的框架尺寸,如果CSS的尺寸与初始画布的比例不一致,它会出现扭曲,最好明确宽高
// 不一致的宽高会带来一个缩放比例 const scaleX = canvas.width / rect.width; // 处理CSS缩放与实际像素比例 const scaleY = canvas.height / rect.height; // 元素尺寸/画布尺寸 = 显示坐标/绘制坐标 // 内部依旧使用 绘图宽高坐标系 // 但显示时的尺寸是画布的宽高 // 如果涉及到尺寸变化,且比例不一致显示就不正确 // 从期望显示坐标到绘图坐标 const x = show.x * scaleX; const y = show.y * scaleY; // 对于高DPI设备,可以考虑修正画布大小,确保显示清晰 const devicePixelRatio = window.devicePixelRatio || 1; canvas.width = canvas.clientWidth * ratio; canvas.height = canvas.clientHeight * ratio; ctx.scale(ratio, ratio); // 修正绘图比例canvas看起来和img很像,除了没有src和alt属性外,img有的属性基本都有,但这些属性不会对内部的绘图产生影响,影响的是画布元素在网页中的显示位置和外观<!-- 开始时将得到一个透明的画布 --> <canvas id="myCanvas" width="300" height="300"></canvas> <!-- 对于 IE9 之前的浏览器兼容,需要在其中添加替换元素 --> <canvas id="clock" width="150" height="150"> <!-- 老版本浏览器不会识别 canvas 标签,导致替换元素显示在界面上 --> <!-- 注意 canvas 需要结束标签,否则之后的内容都不会显示在支持的浏览器上 --> <img src="images/clock.png" width="150" height="150" alt="" /> </canvas>- 通常需要一个
获取
canvas对象进行绘制const canvas = document.getElementById('myCanvas'); // 判断浏览器是否兼容可以查看 canvas 是否有特定的方法 if (canvas.getContext) { const ctx = canvas.getContext('2d'); // 绘制 } else { // canvas-unsupported }
2D 绘制
画布中的二维栅格(使用
Canvas 2D):以canvas标签的左上角为原点,水平方向为x轴、垂直方向为y轴
二维栅格 可以支持两种形式的图形绘制:矩形和路径(由一系列点连成的线段)
绘制矩形
fillRect(x, y, width, height):绘制一个填充的矩形strokeRect(x, y, width, height):绘制矩形边框clearRect(x, y, width, height):清除矩形区域
function draw() { const canvas = document.getElementById("canvas"); if (canvas.getContext) { const ctx = canvas.getContext("2d"); ctx.fillRect(25, 25, 100, 100); ctx.clearRect(45, 45, 60, 60); ctx.strokeRect(50, 50, 50, 50); } }绘制路径:首先需要确定起始点,然后绘制路径,之后把路径封闭,一旦路径生成,就可以描边或填充颜色
- 开始绘制:
beginPath() - 移动笔触:
moveTo(x, y),移动过程中不会产生绘制线 - 绘制路径
- 绘制从当前位置到指定位置的直线路径:
lineTo(x, y),绘制过程中会生成绘制线 - 绘制弧线:
arc(x, y, radius, startAngle, endAngle, anticlockwise),画一个以(x,y)为圆心的以radius为半径的圆弧(圆),从startAngle开始到endAngle结束,角度需要使用弧度制Math.PI(x轴正方向是0),按照anticlockwise给定的方向(默认为顺时针false)来生成 - 绘制封闭弧线:
arcTo(x1, y1, x2, y2, radius),根据给定的控制点和半径画一段圆弧,再以直线连接两个控制点 - 使用二次贝塞尔曲线:
quadraticCurveTo(cpx, cpy, x, y),绘制一个二次贝塞尔曲线,参数依次为控制点坐标和结束点坐标 - 使用三次贝塞尔曲线:
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y),绘制一个贝塞尔曲线,参数依次为两个控制点坐标和结束点坐标 - 矩形路径:
rect(x, y, width, height)绘制一个左上角坐标为(x,y),宽高为width以及height的矩形,执行完此方法后,笔触会回到(0, 0)
- 绘制从当前位置到指定位置的直线路径:
- 填充
fill()描边stroke()
新的
Path2D对象拥有之前的路径绘制方法,也可以接受svg字符串输入,可以直接作为填充和描边的参数new Path2D(path)function draw() { let canvas = document.getElementById("canvas"); if (canvas.getContext) { let ctx = canvas.getContext("2d"); let rectangle = new Path2D(); rectangle.rect(10, 10, 50, 50); let circle = new Path2D(); circle.moveTo(125, 35); circle.arc(100, 35, 25, 0, 2 * Math.PI); ctx.stroke(rectangle); ctx.fill(circle); let p = p = new Path2D("M10 10 h 80 v 80 h -80 Z"); } }- 开始绘制:
设置填充和描边样式,默认的填充颜色描边颜色就是黑色,可以在填充和描边前通过
ctx对象属性修改,支持符合颜色标准的各种值,见颜色值fillStyle:填充颜色strokeStyle:描边颜色globalAlpha:直接设置透明度
描边相关
lineWidth:描边宽度,默认值为1.0,必须为正数lineCap:设置描边端点样式,它可以为下面的三种的其中之一:- butt:默认是
butt,与在起始点开始,结束点终止 - round:端点是圆弧,在端点处外突,加上了半径为一半线宽的半圆
- square:端点处突出,加上了等宽且高度为一半线宽的方块
- butt:默认是
lineJoin:设置描边连接样式- round:连接边角处被磨圆了,圆的半径等于线宽
- bevel:连接处尖角看起来好像被磨平了
- miter:默认是
miter,保留尖角
描边样式<canvas id="canvas" width="150" height="150"></canvas>function draw() { const ctx = document.getElementById("canvas").getContext("2d"); ctx.lineWidth = 10; ["round", "bevel", "miter"].forEach((lineJoin, i) => { ctx.lineJoin = lineJoin; ctx.beginPath(); ctx.moveTo(-5, 5 + i * 40); ctx.lineTo(35, 45 + i * 40); ctx.lineTo(75, 5 + i * 40); ctx.lineTo(115, 45 + i * 40); ctx.lineTo(155, 5 + i * 40); ctx.stroke(); }); } draw();miterLimit:设置miter时描边斜接限制,值越大越容易被判断相交,线段会连接到预估的相交点上虚线相关
setLineDash(segments):设置虚线样式,指定线段和间隔的交替间隔,虚线从线段开始,数组中的元素会被循环使用,默认为空就是直线lineDashOffset:设置虚线偏移,起始虚线的描点会相对起始点沿绘制方向偏移后开始,支持正数(绘制方向偏移,延后绘制)和负数getLineDash():获取虚线样式
canvas还支持绘制渐变图案,见MDN文档
图片填充和转换
canvas支持直接将图片绘制到画布上,也可以出画布转换为图片输出ctx.drawImage:绘制图片的函数,有三种重载ctx.drawImage(image, dx, dy):把整张图片原尺寸绘制到画布的(x, y)位置,图片左上角对齐(x, y),位置为负坐标会从画布外开始绘制ctx.drawImage(img, x, y, width, height);:把整张图片缩放到width和height,并把图片绘制到画布的(x, y)位置,图片左上角对齐(x, y)ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight):参数比较复杂,具体如下
ctx.drawImage( img, sx, sy, sw, sh, // 从源图中截取的矩形区域 dx, dy, dw, dh // 绘制到画布上的位置和大小 );第一个参数必须为图片源对象,也就是
HTMLImageElement(<img>)、HTMLVideoElement、HTMLCanvasElement、ImageBitmap、OffscreenCanvas之一toDataURL(type, encoderOption):将画布内容转换为base64编码的type类型的图片字符串,像素为96dpitype:图片类型,默认为image/png,还可以是image/jpeg、image/webpencoderOption:图片质量(0-1区间内的数字),jpeg和webp图片类型时才有效,默认值为0.92
由于生成的是存储在内存的字符串,当图片很大时,可能影响性能,赋值给图片
src可能超过url限制,通常应该优先选择toBlobtoBlob(callback, type, quality):将画布内容转换为blob对象,像素为96dpi,blob对象是一个二进制对象,通常用于保存文件callback:回调函数,获得到的参数为blob对象type:同toDataURL,图片类型,默认为image/pngquality:同toDataURL,图片质量(0-1区间内的数字),jpeg和webp图片类型时才有效,默认值为0.92
const canvas = document.getElementById("canvas"); // 转换为 blob 对象 canvas.toBlob((blob) => { const newImg = document.createElement("img"); const url = URL.createObjectURL(blob); newImg.onload = () => { // 不再需要读取该 blob,因此释放该对象 URL.revokeObjectURL(url); }; newImg.src = url; document.body.appendChild(newImg); });
3D 绘制
- 三维(使用
WebGL):以canvas标签的中心为原点,水平方向向右为x轴正方向、垂直方向向上为y轴正方向,还有正方向向内的z轴 - 需要通过
canvas.getContext('webgl')获取到webgl对象,使用webgl对象进行绘制
Web API
- 早期浏览器就提供了的**BOM(Browser Object Model)**对象集合,用于操作浏览器窗口、历史记录、导航、弹窗、屏幕信息等浏览器环境,
BOM主要对象包括window:浏览器窗口的全局对象navigator:浏览器信息与权限screen:屏幕信息location:URL 跳转与刷新history:浏览器当前页面历史栈alert/confirm/prompt:弹窗setTimeout/setInterval:定时器
- 除了这些之外,现代浏览器还提供了不少标准接口工具,主要用于操作浏览器能力、网络、设备和多媒体等
- 网络:
fetch、WebSocket,见异步请求 - 存储:
localStorage、IndexedDB - 多媒体:
AudioContext - 权限/设备:
Notification、Geolocation API、摄像头麦克风 - 浏览器脚本:
Service Worker - 文件处理:
FileReader、Blob、File - 其他:全屏、剪贴板、动画渲染
requestAnimationFrame等
- 网络:
window
window是浏览器窗口的全局对象,并且所有全局的对象都是window对象下的属性window对象提供了和显示器窗口有关的属性devicePixelRatio:设备像素比,用于判断设备是否为高清设备,一般大于1为高清设备,safari浏览器不支持此属性screen:屏幕信息对象,包含以下属性availableHeight:可用高度,单位为像素,移除了比如windows系统任务栏这样的永久或半永久占用的界面空间availableWidth:可用宽度,单位为像素height:屏幕高度,单位为像素width:屏幕宽度,单位为像素colorDepth:颜色深度,单位为位,比如24位,32位,64位等等,出于隐私考虑,另一个pixelDepth属性和这个属性的值应该相同orientation:屏幕方向,返回一个枚举值,有portrait-primary、portrait-secondary、landscape-primary、landscape-secondary,分别表示竖屏正方向、竖屏反方向、横屏正方向、横屏反方向
screenX/screenY:返回浏览器窗口距离屏幕左侧和屏幕顶部的距离,单位为像素。别名为screenLeft/screenTopmoveTo(x, y)/moveBy(x, y):移动浏览器窗口,参数为x和y坐标,单位为像素x是相对定点的窗口水平移动的像素数。正值向右移动,负值向左移动y是相对定点的窗口垂直移动的像素数。正值向下移动,负值向上移动moveTo(x, y)和moveBy(x, y)函数的区别在于moveTo是相对屏幕左上角的位置进行移动,而moveBy是相对当前窗口位置进行移动
open(url, target, features):打开新窗口,返回值是新窗口的window对象(仅同源时可获取到)url:要打开的页面的URL地址target:加载到的浏览器上下文的名称,默认为_blank_self:加载到当前窗口_blank:加载到新窗口- 其他的,可用于
a标签的target属性的值
features窗口的属性,多个属性之间用逗号隔开,值是以键值对的形式存储的字符串popup:窗口是否显示为精简的弹出窗口,功能由浏览器决定,可能没有地址栏等内容,默认值为false。由于历史原因,在未指定这个值且location/menubar/sesizable/scrollbars/status等属性(已弃用)为false或不存在时,也会显示为精简的弹出窗口width/height:内容区域的宽高,有别名innerWidth/innerHeightleft/top:窗口左上角在操作系统工作区的初始位置,有别名screenX/screenY,如果初始位置在工作区外,会进行修正- 早期
opener可以带来tabnabbing攻击,通过获取opener修改页面location为钓鱼网站 - 现在的浏览器很多已经默认不允许获取
opener了
- 早期
noopener:如果启用此功能,新窗口将无法通过Window.opener访问原始窗口的window对象noreferrer:如果启用此功能,浏览器将省略*[rel]的ref属性ref属性使新页面通过获取原本的referer能够获取到地址中的token敏感等信息- 现在浏览器存在
Referrer-policy策略,非同源只发域名origin地址;存在协议变化https->http不发地址
close():关闭当前窗口
selection API
selection对象是通过window.getSelection()获取到的对象,可以获取当前选中的文本内容,以及选中文本的开始位置和结束位置- 选取区间的概念介绍
- 锚点:选区起始位置
- 焦点:选区结尾位置
- 输入光标:当选区起始位置和结尾位置相同时(直接点击也会产生光标),对于可输入的元素会显示输入光标,其他元素也会产生隐藏的光标
- 选区会使用被选择的节点的共同父节点来描述一个范围,即父节点和一个基于父节点的偏移
- 关于偏移
offset:如果是文本节点,偏移是的文本偏移字数,如果是元素节点,是相对于childNodes数组的偏移索引
- 实例属性
anchorNode:当前页面选中的起始DOM节点,如果没有选中任何文本,则返回nullfocusNode:当前页面选中的结束DOM节点,如果没有选中任何文本,则返回nullanchorOffset:选区锚点在anchorNode中的字符位置,如果没有选中任何文本,则返回0focusOffset:结束焦点在focusNode中的字符位置,如果没有选中任何文本,则返回0isCollapsed:选区是否为折叠状态,起点和终点相同,即没有选中任何文本rangeCount:选区数量,目前仅Firefox支持按住ctrl选择多个片段type:选区类型,有未选择None,选择了一个范围Range和折叠Caret三种direction:选区文本方向,可能是none、backward和forward三种
- 实例方法
addRange(range):添加一个选择区间,选择区间即之后介绍的Range对象removeRange(range):删除一个选择区间removeAllRanges():删除所有选择区间,别名是empty()collapse(node, offset):将区间压缩到一个点node:插入位置所在的DOM节点,如果为null,则相当于清除所有选区offset:插入位置相对节点开头的偏移位置,默认是0
collapseToStart():将选区折叠到起始位置,如果选区内容聚焦且可编辑,插入号会闪烁collapseToEnd():将选区折叠到结束位置,如果选区内容聚焦且可编辑,插入号会闪烁containsNode(node[, partialContainment]):判断选区是否包含某个节点,第二个参数是指在部分选择时是否算包含节点,默认是false也就是部分选择不算被包含deleteFromDocument():删除选区文本或节点内容,实际上是调用选择区间的Range.deleteContents()方法extend(node[, offset]):将选区焦点移动到指定节点的某个偏移位置,offset参数可选,默认是0getRangeAt(index):返回指定索引的选区区间,索引从0开始,因为大多数浏览器不支持多选取,一般索引为0,此方法不支持影子根节点,返回Range对象getComposedRange(option):一个新支持的方法,可以获取到选区中的影子节点,返回StaticRange对象option的选项shadowRoots:列出希望能被返回的影子节点所在的影子根节点列表
modify(alter, direction, granularity):修改光标位置alter:修改方式,有move、extend两种,表示移动光标(选取重合,出现单个光标)和扩展选区direction:移动方向,有向后forward(right)、向前backward(left)四种granularity:一次移动的粒度,具体有- 一次移动单个字符
character - 移动到下一个词(空格或标点分隔)边缘
word - 移动到下一个句子(以标点结尾)边缘
sentence - 移动当前段落(通常是以换行分隔)的字符数
paragraph - 移动一个视觉显示文本行的字符数
line - 移动到当前行边界
lineboundary - 移动到当前句子边界
sentenceboundary,似乎和sentence一样 - 移动到当前段落边界
paragraphboundary - 移动到页面第一个可选中字符前后
documentboundary
- 一次移动单个字符
Firefox不支持sentence、paragraph、sentenceboundary、paragraphboundary、documentboundary
selectAllChildren(node):选择指定节点的所有子节点,之前的选择会被清除setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset):选择或部分选择两个节点和之间的所有内容anchorNode:选区起始节点anchorOffset:选区起始节点的偏移位置focusNode:选区结束节点focusOffset:选区结束节点的偏移位置
toString():返回选区文本内容,相当于getSelection().toString()
- 区间表示对象
AbstractRange对象:选取范围的抽象对象,StaticRange对象和Range对象都直接继承自AbstractRange,包含以下实例属性collapsed:表示选区是否为折叠状态,起点和终点相同,即没有选中任何文本startContainer:选区起始节点startOffset:选区起始节点的偏移位置,如果是文本节点是指字符偏移,如果是元素节点是指子节点childNodes的索引endContainer:选区结束节点endOffset:选区结束节点的偏移位置commonAncestorContainer:选区所有节点的公共父节点
StaticRange对象:内容不会随着DOM树内部的变化而更新,未扩展新属性Range对象:表示文档的片段,可以包含节点和文本节点的部分,添加了许多有用的方法cloneContents():将选择的内容克隆,放入一个新的DocumentFragment对象中,返回此对象- 如果节点元素被部分选择,这种情况下被克隆的只是被选中的内容
- 使用的方式和
Node.cloneNode()相同,这意味着不会克隆事件侦听
cloneRange():通过值复制拷贝一个新的Range对象,二者变更不会相互影响deleteContents():删除被选中的内容extractContents():将选择内容提取出来,返回一个DocumentFragment对象,此对象包含被选内容,但原选内容被删除,类似cloneContents()和extractContents()的组合getBoundingClientRect():返回一个矩形对象,包含选区边界的坐标信息,如果需要所有的矩形对象,可使用getClientRects()方法insertNode(node):在起始节点插入新节点intersectsNode(node):判断节点是否被选区部分包含isPointInRange(node, offset):判断指定节点的偏移位置是否被选区包含selectNode(node):选择指定节点,容纳其中所有内容selectNodeContents(node):选择节点内的所有内容,相比selectNode(node),范围边界的描述是按此节点进行的(startContainer和endContainer是这个节点,而不是父节点)setStart(node, offset):设置选区起始位置,如果起点设置在终点之后,则会导致范围折叠,都设置在指定位置,offset无默认值不可省略setStartAfter(node)和setStartBefore(node):设置选区起始位置,设置在节点之后或之前setEnd(node, offset):设置选区结束位置,如果终点设置在起点之前,则会导致范围折叠,都设置在指定位置setEndAfter(node)和setEndBefore(node):设置选区结束位置,设置在节点之后或之前compareBoundaryPoints(how, otherRange):比较当前对象的边界点和另一个Range对象的边界点关系,返回一个数字,表示边界点关系how:比较方式,有START_TO_START、START_TO_END、END_TO_END、END_TO_START四种,第一个start或end指定的是otherRange的哪个端点,第二个指定的是当前Range的哪个端点- 返回值为
-1表示当前节点的边界点在前(左),1表示当前节点的边界点在后(右),0表示两个边界点位置相同
collapse(toStart):折叠选区,toStart参数可选,默认是false,表示折叠到结束位置comparePoint(node, offset):比较指定节点的偏移位置和当前选区位置关系,返回一个数字- 指定位置在区间起点位置之前,返回
-1 - 指定位置在区间终点位置之后,返回
1 - 指定位置在区间内,返回
0
- 指定位置在区间起点位置之前,返回
surroundContents(newParent):用指定节点包含选定内容,如果区间部分包含非文本节点会抛出异常newParent:应该用来包裹内容的节点,函数提取内容并用区间内容替换newParent的子节点,并在当前区间插入,最后让区间选择这个新节点
createContextualFragment(input):将TrustedHTML对象实例或字符串解析为DocumentFragment对象,并返回此对象,有xss风险- 出于安全考量,可以设置名称
require-trusted-types-for的CSP指令,要求输入一个TrustedHTML对象
- 出于安全考量,可以设置名称
- 选区指南
获取选择内容:如果需要获取到有哪些文本被选择,因为选区的描述是基于父节点的,无法直接了解选择的区间,不过可以使用以下方法直接获取选中内容
window.getSelection().toString():获取选中内容文本range.cloneContents():获取一个拷贝的选区片段,可获取到节点
修改选区
selection.addRange(range):添加一个选区range对象selection.removeAllRanges():删除所有选区selection.selectAllChildren(node):选择指定节点的所有子节点range.setStart(node, offset)等:设置选区开始结束位置selection.modify(alter, direction, granularity):根据方向、指令和粒度修改扩展选区selection.collapse(node, offset)、selection.collapseToStart()、selection.collapseToEnd():折叠选区
判断节点是否在选区中
range.intersectsNode(node):判断节点是否被选区部分包含range.isPointInRange(node, offset):判断指定节点的偏移位置是否被选区包含range.comparePoint(node, offset):比较指定节点的偏移位置和当前选区位置关系,返回一个数字
获取文本选区对应的节点和位置:
- 已知需要选中的文本内容字数索引,返回这个区间的决定字段
[startContainer, startOffset, endContainer, endOffset] - 已知``
function getNodeAndOffset(wrap_dom, start=0, end=0){ const txtList = []; const map = function(children){ [...children].forEach(el => { if (el.nodeName === '#text') { txtList.push(el) } else { map(el.childNodes) } }) } // 递归遍历,提取出所有 #text map(wrap_dom.childNodes); // 计算文本的位置区间 [0,3]、[3, 8]、[8,10] const clips = txtList.reduce((arr,item,index)=>{ const end = item.textContent.length + (arr[index-1]?arr[index-1][2]:0) arr.push([item, end - item.textContent.length, end]) return arr },[]) // 查找满足条件的范围区间 const startNode = clips.find(el => start >= el[1] && start < el[2]); const endNode = clips.find(el => end >= el[1] && end < el[2]); return [startNode[0], start - startNode[1], endNode[0], end - endNode[1]] } function getRangeOffset(wrap_dom){ const txtList = []; const map = function(children){ [...children].forEach(el => { if (el.nodeName === '#text') { txtList.push(el) } else { map(el.childNodes) } }) } // 递归遍历,提取出所有 #text map(wrap_dom.childNodes); // 计算文本的位置区间 [0,3]、[3, 8]、[8,10] const clips = txtList.reduce((arr,item,index)=>{ const end = item.textContent.length + (arr[index-1]?arr[index-1][2]:0) arr.push([item, end - item.textContent.length, end]) return arr },[]) const range = window.getSelection().getRangeAt(0); // 匹配选区与区间的#text,计算出整体偏移量 const startOffset = (clips.find(el => range.startContainer === el[0]))[1] + range.startOffset; const endOffset = (clips.find(el => range.endContainer === el[0]))[1] + range.endOffset; return [startOffset, endOffset] }- 已知需要选中的文本内容字数索引,返回这个区间的决定字段
navigator
geolocation
geolocation对象是navigator对象中的一个属性,主要用于获取当前位置信息- 此属性需要在
https协议下,才能使用 - 如果是作为嵌入子页面,需要修改请求头,授予特定的第三方来源的权限
Permission-Policy: geolocation=(self suborigin),默认值为self。此外,需要为iframe添加属性allow="geolocation" - 首次获取操作时,会要求用户通过浏览器提示确认授予权限
- 此属性需要在
geolocation相关的变量类型接口GeolocationCoordinates对象,用于表示设备在地球上的位置和海拔高度,以及这些属性的计算精度,所有属性都是double浮点数,如果设备无法提供,值为null- 纬度
latitude - 经度
longitude - 高度
altitude:单位为米 - 精度
accuracy:单位为米 - 高度精度
altitudeAccuracy:单位为米 - 移动速度
speed:单位是米/秒 - 设备朝向
heading:0表示正北方,90表示正东,270表示正西,即数值与正北方的夹角大小有关,按顺时针方向增加
- 纬度
GeolocationPosition对象,示相关设备在给定时间时的位置coords:当前位置的经纬度信息,是GeolocationCoordinates对象timestamp:位置的获取时间,以毫秒为单位的时间戳
GeolocationPositionError对象,用于表示获取位置信息失败的原因code:错误代码,有3种错误代码,1代表被阻止PERMISSION_DENIED,2代表因为错误获取失败POSITION_UNAVAILABLE,3代表获取超时TIMEOUTmessage:用于调试的详细错误信息,规范指出此内容不应该直接显示在用户界面中
geolocation对象的实例方法
getCurrentPosition(success, error, options):异步获取当前位置信息,成功时调用success函数,失败时调用error函数success回调函数唯一参数为GeolocationPosition对象error回调函数唯一参数为GeolocationPositionError对象options:可选参数,用于指定获取位置信息的选项,包括timeout:表示设备返回位置信息所需的最长时间(毫秒),默认值为Infinity,即未获取到信息不返回maximumAge:可接受的缓存时间,默认是0,即不使用缓存位置enableHighAccuracy:是否使用高精度获取位置信息,默认为false,高精度可能导致响应速度变慢和功耗增加
watchPosition(success, error, options):监听设备位置信息变化,每次变化时调用success函数,出现错误时调用error函数,参数介绍和getCurrentPosition一致,函数会返回一个监听IDclearWatch(watchId):取消监听位置信息变化,参数为watchPosition返回的监听ID
clipboard
clipboard对象是navigator对象中的一个属性,用于操作剪贴板,主要方法属性有writeText(text):将文本写入剪贴板readText():从剪贴板读取最新文本,promise异步write(items):将ClipboardItem列表写入剪贴板,可以是非文本数据,比如图片read():从剪贴板读取ClipboardItem列表(剪切板中可能有多种类型的数据,不是包含过往剪切板历史的列表),promise异步,可以是非文本数据type:剪贴板数据类型
对于这些功能,
Firefox于2024年完善支持,之前仅支持写入文本数据
在https协议下,才能使用clipboard对象访问和写入剪切板的内容,且需要用户同意
ClipboardItem是一个可以进行多种类型的剪切板数据操作的新增的对象type:数据类型列表,也就是支持向哪些数据类型转换的列表presentationStyle:剪贴板数据展示样式,值是未指定unspecified、内联式inline或附件attachment,目前支持有限,chrome还不支持- 实例方法
getType(type):返回一个Promise,请求对应类型blob,如果类型未找到则返回错误 - 静态方法
ClipboardItem.supports(type):返回一个Promise,判断当前浏览器是否支持对应类型数据
- 构造方法
new ClipboardItem(data[, option]):构造一个ClipboardItem对象,用来写入剪贴板data:数据对象,可以是字符串或Blob对象,也可以是一个可解析为Blob或字符串的Promise对象option:可选参数presentationStyle:剪贴板数据展示样式
location
location对象是window对象中的一个属性,用于操作当前页面地址栏的URLlocation对象主要属性有href:当前页面的完整URLhash:当前页的URL的#后锚点部分host/hostname:当前页的URL的主机名/域名部分pathname:当前页的URL的第一个/后路径部分search:当前页的URL的?后参数部分protocol:当前页的URL的协议部分
修改这些属性会改变当前页的
URL,修改除hash外的属性会自动触发页面刷新;修改hash属性会触发hashchange事件- 方法
assign(url):跳转到指定页面replace(url):替换当前页,并跳转到指定页面,不保留历史记录reload():重新加载当前页
history
history对象是window对象中的一个属性,用于操作浏览器历史记录由于安全的保护,
history对象是一个只能向前看的历史记录栈,从js中无法读取到完整的历史,只能操作堆栈前进后退,核心方法包括history.back():返回上一页history.forward():前进一页history.go(n):跳转到指定页面,相当于前进或后退n步(正数前进、负数后退)
history.back(); history.forward(); history.go(-1); // 返回上一页 history.go(1); // 前进一页Html5开始,单页面应用成为主流,因此添加了新的方法,允许修改历史堆栈后不触发页面刷新history.pushState(state, title, url):添加历史堆栈state:状态对象,用于保存当前页面的状态信息,通常为对象,需要可序列化为jsontitle:标题,通常为空,目前大多数浏览器忽略此参数url:跳转的URL
history.replaceState(state, title, url):替换当前历史堆栈history.state:当前历史堆栈的状态对象
history.pushState({ Login: false }, '', '/page2'); history.replaceState({ Login: true }, '', '/page1');无刷新路由跳转的局限性
浏览器会记录下这个堆栈的修改,一切看起来都很完美,但实际使用中,可能会有如下问题
- 无刷新意味着不会触发
load事件,通过popstate事件可以处理用户的跳转,但处理不了使用pushState和replaceState进行的跳转 - 每次修改会替换子路由,因此每次都需要完整地写入子路由,比如
/path/page1 - 如果通过跳转后的地址进行页面恢复,会导致请求的内容不存在,得到
404,需要后端进行配置,确保这些路径请求能得到页面 - 不能跨域修改堆栈,不然会报错
- 早期浏览器(
IE9及以下)不支持
弹窗
定时器
setTimeout(fn, delay):延迟执行- 参数包括一个函数和一个延迟时间(
ms) - 返回一个定时器
ID,可以通过clearTimeout(id)取消
const id = setTimeout(() => console.log('Hello'), 1000); clearTimeout(id); // 取消- 参数包括一个函数和一个延迟时间(
setInterval(fn, delay):循环执行- 参数包括一个函数和一个循环间隔时间(
ms) - 返回一个循环器
ID,可以通过clearInterval(id)取消
const id = setInterval(() => console.log('tick'), 1000); clearInterval(id); // 停止周期执行- 参数包括一个函数和一个循环间隔时间(
注意事项
- 定时器不精确:受到浏览器线程调度和系统性能影响
- 最小延迟限制:现代浏览器中有最小延迟限制,通常为
4ms - 页面关闭
beforeunload时需要清除定时器:定时器会继续执行,直到浏览器关闭,造成内存泄漏 setInterval不应该作为长时间精确循环使用,每次执行之后误差都会扩大,应该使用setTimeout嵌套函数
function loop() { console.log('loop'); // 此方法虽然和直接递归很像,但是有区别 // 直接递归会保留当前函数堆栈,最终函数循环调用导致堆栈溢出 // 使用延时会在之后将函数添加到宏任务队列,函数会正常结束 setTimeout(loop, 1000); }
cookie
cookie是一种存储在浏览器端的小数据文件,最多只有4KB,用于保存用户数据,数据保存在浏览器中,可以设置过期时间创建和读取
- 创建和在
setCookie头中写的内容一致,但不能创建httpOnly的cookie - 读取到的是字符串,需要经过转换获取值
// 设置 document.cookie = "username=Tom; expires=Fri, 31 Dec 2025 23:59:59 GMT; path=/"; // 读取 console.log(document.cookie); // "username=Tom; age=20"- 创建和在
前端使用
cookie的情况相对较少,主要因为获取不便,但也可以使用相关库,比如js-cookie处理- 一般
cookie用于存储服务器凭证等信息,这些信息都设置了HttpOnly,不需要前端处理,前端也无法设置这样的cookie - 获取不如
Storage方便,因此一般需要前端处理的内容应该考虑使用storage存储更方便
- 一般
Storage
Storage是浏览器存储API的一个接口,用于进行简单的同步的键值对字符串数据存储,主要有两个实现localStorage:存储同源标签之间共享的数据。数据持久保存,即使关闭并重新打开浏览器也会持续存在,直到主动删除sessionStorage:存储给定页面的临时数据。数据会话保存,关闭页面后数据会丢失(刷新不会)
存储的内容
浏览器端的存储(
localStorage/sessionStorage/indexedDB)是完全不可信的环境- 它适合存放“状态类”或“无安全价值”的数据,而绝不能存储或依赖其中的任何安全关键数据(比如登录状态、验证码等),因为前端完全可以获取到这些数据
- 存在大小限制,
localStorage和sessionStorage存储的数据不能超过5MB,更大的数据需要使用indexedDB存储
Storage接口的实例方法,都是同步的setItem(key, value):设置数据- 存储时会发生隐式转换,存储的键和值数据会以字符串形式保存
- 超出容量会引起错误
QuotaExceededError,默认容量一般是5M - 建议使用命名空间前缀,避免和其他工具冲突,也能方便分类理解用处
getItem(key):获取数据- 不存在的数据返回
null
- 不存在的数据返回
removeItem(key):删除数据- 删除不存在的数据不会报错
clear():清空数据,包括某些库添加的值key(index):返回第index个存储键名,用于遍历数据,需要结合length- 插入顺序和索引顺序可能不一致,因为用户代理底层可能仅使用非顺序结构存储
- 如果值不存在,返回
null
length:数据数量
// 属性 Storage.length // 当前存储中键值对的数量 // 方法 Storage.key(0) Storage.setItem('key', 'value') Storage.getItem('key') Storage.removeItem('key') Storage.clear() window.localStorage instanceof Storage // true window.sessionStorage instanceof Storage // trueStorage接口在修改存储时会在其他页面的window上触发storage事件,这是StorageEvent事件实例key:改变的数据键名,如果数据使用clear被删除,此值为nulloldValue:改变前的数据,如果数据是新增的,此值为nullnewValue:改变后的数据,如果数据被删除,此值为nullstorageArea:受改变的数据存储区域,localStorage或sessionStorage的内容url:触发storage事件的页面地址
window.addEventListener('storage', (e) => { console.log(`${e.storageArea} changed:`, e); });sessionStorage仅在当前页面有效,因此不可能在其他页面触发storage事件,仅在本页面的iframe触发(iframe内部也会导致外部触发);localStorage会在同源的所有其他浏览上下文中触发storage事件,包括同源的其他标签页
IndexedDB
IndexedDB(简称IDB)是浏览器中内置的一个本地持久化数据库,适合存储结构化数据、文件、二进制数据等,比localStorage强大得多IndexedDB是一个异步的基于事务的键值数据库,异步是通过事件回调完成的所有存储、索引和游标操作都与事务关联执行
数据库是按源隔离的,每个源(协议、域名、端口)对应一个
IndexedDB数据库,实际上在存储的时候每个源各一个数据库子目录适合实现离线应用、缓存大量数据、或者用于存储需要前端搜索、索引、版本管理的复杂数据
容量存在上限,虽然没有严格的标准,且可能根据版本变化,通常不小于
250M,如果写入太多数据,可能会触发QuotaExceededError报错- 可以查看最多存储数据量
浏览器 大致上限 特点 Chrome/Edge尽力存储和持久存储模式都最多使用磁盘的 60%左右,每个源最多占全局的20%为了避免用户识别,可能无法达到上限 Firefox尽力存储模式最多使用磁盘 10%或10GB;持久存储最多使用大约磁盘的60%,上限为8TB同样可能不能达到配额 Safari每个源最多占用磁盘的 60%,整体不超过80%如果是非浏览器的 Web应用,最多占用磁盘的20%(每个源不超过15%),如果有相关浏览器缓存,使用和浏览器相同的空间(60%)其他引擎的或移动端的浏览器 一般更小(几十到数百 MB)依设备空间动态变化
使用dexie或idb可以优化
IndexedDB操作。原生语法略显复杂,如果希望能实现同步代码需要在事件中嵌套下一步处理逻辑,很容易写成回调地狱所有的数据操作都依赖对应的对象仓库对象,一个常见的操作流程如下
- 打开数据库,并创建对象存储仓库
- 启动事务并发出请求以获取对象仓库,执行某些数据库作,例如添加或检索数据
- 通过侦听正确类型的事件等待作完成
- 对结果执行某些操作
相关的授权和查看
api查看是尽力存储还是持久存储模式:尽力存储
best-effort和持久化存储persistent会影响浏览器的数据清理策略- 尽力存储
best-effort:这是默认情况下数据的存储方式。只要源低于其配额,设备有足够的存储空间,并且用户不选择通过浏览器的设置删除数据,尽力而为的数据就会持续存在 - 持久化存储
persistent:源可以选择以持久性方式存储其数据。只有在用户选择使用浏览器设置时,才会以这种方式存储的数据被逐出或删除。
// 检查浏览器是否支持持久化存储 if (navigator.storage && navigator.storage.persist) { const isPersisted = await navigator.storage.persisted(); console.log(`当前是否已持久化: ${isPersisted}`); if (!isPersisted) { const granted = await navigator.storage.persist(); console.log(granted ? "已获得持久化权限" : "用户或浏览器拒绝持久化"); } }- 尽力存储
可存储和已使用空间查看
- 使用
navigator.storage.estimate()异步查看当前数据库使用情况和限额,promise返回的对象包含usage和quota属性
- 使用
const quota = await navigator.storage.estimate(); console.log(quota.usage, quota.quota);
数据库对象
数据库相关操作:
IndexedDB需要分数据库执行操作创建或打开数据库:使用
indexedDB.open(name, version)创建或打开数据库,其中需要提供数据库名称和版本号整数- 方法会返回一个数据库打开请求对象
IDBOpenDBRequest,继承了数据库请求对象IDBRequest,请求对象中有success、error事件处理请求状态,而数据库打开请求对象额外提供了upgradeneeded和blocked事件用于数据库升级 - 首先会检查是否有对应的数据库,版本号是多少
- 如果数据库不存在,则创建数据库,设置版本号(必须正整数)
- 如果数据库存在,比较版本号。如果版本号大于现有版本号,则升级数据库;如果版本号小于现有版本号,会报
VersionError错误
- 方法会返回一个数据库打开请求对象
数据库升级:每个数据库只有在版本号提升时才能修改数据库,创建索引和表
- 在升级时触发
upgradeneeded事件,在其中能修改数据库结构 - 数据库升级是异步的,一次只能升级一个版本,不会并发升级
- 升级时连接对象会依次经历
versionchange(旧连接中触发提示,在其中应该手动关闭连接)、blocked(有连接未关闭,升级被阻塞)、upradeneeded(数据库开始升级,如果版本号变化)、success(数据库升级完成)事件,如果出现问题,会触发error事件 - 在触发
upgradeneeded事件的回调中,得到的是IDBVersionChangeEvent,可以使用event.target.result获取数据库对象db,来自IDBRequest获取到的结果- 旧版本号
event.oldVersion - 新版本号
event.newVersion
- 创建对象仓库:使用
db.createObjectStore(name, {keyPath: string, autoIncrement: boolean})创建对象仓库,也就是存储对象的数据表。第二个参数是可选的配置项,可配置两个属性:keyPath和autoIncrement,分别表示每条记录的关键路径(类似主键名,将通过value.keyPath获取键值)和是否使用自动递增的整数作为键名(默认为false,初始值为1) - 创建索引:使用
objectStore.createIndex(name, keyPath, {unique: boolean, multiEntry: boolean})创建索引,索引名为name,索引的列为keyPath。如果设置为唯一索引,则不允许添加重复的索引值,否则添加报错ConstraintError;如果设置多值索引,也就是会将此列中列表的值拆分为多个索引值,需要使用单个索引项才能匹配上,比如[1, 2, 3]原先需要使用[1, 2, 3]将变为使用单个元素1、2、3都能匹配
const request = indexedDB.open("MyAppDB", 3); request.onupgradeneeded = (event) => { const db = event.target.result; const oldVersion = event.oldVersion; console.log(`数据库从版本 ${oldVersion} 升级到 ${db.version}`); // 注意:oldVersion = 0 表示“首次创建数据库” if (oldVersion < 1) { const userStore = db.createObjectStore("users", { keyPath: "id", autoIncrement: true }); userStore.createIndex("name", "name", { unique: false }); userStore.createIndex("email", "email", { unique: true }); userStore.createIndex("tags", "tags", { multiEntry: true }); } if (oldVersion < 2) { db.createObjectStore("orders", { keyPath: "orderId" }); } if (oldVersion < 3) { const settingStore = db.createObjectStore("settings", { keyPath: "key" }); settingStore.put({ key: "theme", value: "light" }); // 初始化默认数据 } }; request.onsuccess = (event) => { const db = event.target.result; console.log("数据库打开成功", db); }; request.onerror = (event) => { console.error("数据库打开失败", event.target.error); };- 在升级时触发
数据库连接关闭:使用数据库对象
db.close()关闭数据库连接数据库对象
db的事件close:数据库意外关闭时触发,普通的close()关闭不会触发事务versionchange:数据库版本号变化时触发
名称查询
- 删除数据库:使用
indexedDB.deleteDatabase(name)删除对应数据库,方法也会返回一个数据库请求对象IDBRequest,但似乎不会触发成功或报错事件 - 当前源所有数据库名称查询:
indexedDB.databases()可以异步promise返回所有数据库名称和版本号列表 - 当前数据库中对象仓库列表:可以使用
db.objectStoreNames返回当前数据库中的所有对象仓库名称的类数组对象 - 当前对象仓库中的索引列表:
objectStore.indexNames返回当前对象仓库索引名称的类数组对象
- 删除数据库:使用
请求对象
- 正如之前看到的,实际上很多数据库操作都会返回一个数据库请求对象
IDBRequest,有以下属性request.readyState:请求状态,pending表示操作正在进行,done表示操作正在完成request.result:返回请求的结果。如果请求失败、结果不可用,读取该属性会报错request.error:请求失败时,返回错误对象。request.source:返回请求的来源(比如索引对象或ObjectStore)request.transaction:返回当前请求正在进行的事务,如果不包含事务,返回null
- 相关事件:
success:请求处理成功时触发error:请求处理失败时触发
事务对象
所有的数据操作(包括存储、索引和游标)都应该在事务中完成
事务创建:使用
db.transaction(storeNames, mode, options)创建事务对象IDBTransaction- 参数
storeNames是字符串或字符串数组,表示对象仓库名称 - 参数
mode是事务模式,可选值有readonly、readwrite,默认为readonly - 定义其他选项的对象,目前有持久性
durability,可以是以下三个字符串文字值之一:"default","strict"(只有验证完所有操作都已经保存时才完成事务),"relaxed"
const transaction = db.transaction(["users", "orders"], "readwrite"); // 获取对象仓库对象 const userStore = transaction.objectStore("users"); const orderStore = transaction.objectStore("orders");- 参数
事务实例中的方法
objectStore(name):获取对象仓库对象objectStoreabort():取消事务commit():提交事务
事务实例带来的事件
abort:事务被取消(回滚)时触发complete:事务正常完成时触发error:事务发生错误时触发,所有的请求报错都会触发此事件- 在某个请求执行出错时会自动触发
abort() - 可以在
error事件中看见之后的操作都因为abort所以直接报错了 - 可以使用
event.preventDefault()阻止事务的取消
- 在某个请求执行出错时会自动触发
事务的生命周期:生命周期文档
active:事务在首次创建时以及从与事务关联的请求调度事件期间处于此状态- 此状态下,事务可以发起新请求
inactive:事务在创建后控制返回到事件循环后(也就是同步代码执行完毕),并且没有正在分派请求时,事务处于此状态- 当事务处于此状态时,不能对事务发出任何请求
committing:一旦与事务关联的所有请求都完成,事务将在尝试提交时进入此状态- 当事务处于此状态时,不能对事务发出任何请求
- 事务会自动提交
finished:一旦事务提交或中止,它就会进入此状态
事务调度:根据创建时提供的事务的作用域,进行事务调度
- 只读事务需要等待之前的访问相同仓库的读写事务执行完成
- 读写事务需要等待之前所有访问相同仓库的事务执行完成
对象仓库对象
对象仓库是实现数据存储的接口,对象存储中的记录根据其键进行排序。这种排序可以实现快速插入、查找和有序检索。所有的方法都将返回一个数据库请求对象
IDBRequest对象仓库的实例属性:
indexNames: 索引名称列表keyPath: 键路径name: 仓库名称autoIncrement: 是否自增transaction: 返回当前仓库所属事务
增加数据,因为创建对象仓库时可能提供了额外配置选项,指明了关键路径
keyPath或自增,所以key参数可选。如果提供了额外配置项情况下添加key参数,会报错DataErroradd(value, key):添加数据。如果键已经存在,操作将失败,触发约束错误ConstraintError- 可用于实现如果存在就不插入数据,用于如果存在会报错,需要在
error事件中阻止默认行为,避免直接事务回滚
- 可用于实现如果存在就不插入数据,用于如果存在会报错,需要在
put(value, key):添加或更新数据。如果键不存在,则添加数据,如果键已经存在,则更新数据
// add:添加新记录(如果键存在则失败) const user1 = { id: 1, name: 'Alice', age: 25 }; const addRequest = store.add(user1); addRequest.onsuccess = () => console.log('添加成功:', user1); addRequest.onerror = (e) => console.error('添加失败(键已存在):', e.target.error); // put:添加或更新记录(存在则更新,不存在则插入) const user2 = { id: 2, name: 'Bob', age: 30 }; const putRequest = store.put(user2); putRequest.onsuccess = () => console.log('添加或更新成功:', user2);对象数据存储使用结构化算法完成,在Object深拷贝部分有介绍,无法克隆函数、原型链数据和
DOM对象,正则对象的部分属性和所有对象的属性描述符无法正确复制删除数据
delete(key):删除数据,删除时如果键不存在,操作也不会失败clear():清空数据
// 删除单条记录 const delRequest = store.delete(2); delRequest.onsuccess = () => console.log('删除 id=2 成功'); // 清空所有对象仓库数据 // const clearRequest = store.clear(); // clearRequest.onsuccess = () => console.log('所有数据已清空');查询数据,结果都存放在
success时event.target的result属性中,如果没有数据,属性为undefinedget(key):获取当前主键对应的数据,key可以是键值或一个键范围对象IDBKeyRangegetAll(query, count):获取所有数据,参数都是可选的,query是键值或键范围对象,count是最多获取的记录数getAllKeys(query, count):获取所有的主键值openCursor(range, direction):方法用于异步遍历所有数据range参数表明获取的数据范围,一个IDBKeyRange或键direction参数表明遍历方向,可选值有next、prev、nextunique、prevunique,默认为next- 获取数据成功时可以获取到游标
IDBCursor对象const result = event.target.result - 其中
result.value为当前行数据值,result.key为当前行键名;result.source为对应对象仓库或索引对象,result.direction为当前遍历方向,result.primaryKey为当前行主键名 - 执行完成当前行处理后需要使用
result.continue()方法继续遍历下一行数据 - 如果在访问过程中遇到行被删除,被删除行和之后的行都不会访问到
- 如果在访问过程中遇到事务提交会在
success执行过程中报错,同时不会触发error事件
// 获取单条 const getRequest = store.get(1); getRequest.onsuccess = (event) => { console.log('查询结果:', event.target.result); }; // 获取所有 const getAllRequest = store.getAll(); getAllRequest.onsuccess = (event) => { console.log('全部数据:', event.target.result); }; // === 使用 openCursor 遍历所有数据 === const cursorRequest = store.openCursor(); cursorRequest.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { console.log(`键: ${cursor.key}, 值:`, cursor.value); // 在此可对每一行数据进行处理 cursor.continue(); // 继续遍历下一条 } else { console.log('遍历结束'); } };IndexedRange
IDBKeyRange对象用于创建索引范围,可以只包含一个值,也可以指定上限和下限,有以下静态方法方便创建IDBKeyRange.upperBound(value, open):创建一个包含指定上限的索引范围IDBKeyRange.lowerBound(value, open):创建一个包含指定下限的索引范围IDBKeyRange.bound(lower, upper, lowerOpen, upperOpen):创建一个包含指定范围的索引范围IDBKeyRange.only(value):创建一个只包含指定值的索引范围
// All keys ≤ x let r1 = IDBKeyRange.upperBound(x); // All keys < x let r2 = IDBKeyRange.upperBound(x, true); // All keys ≥ y let r3 = IDBKeyRange.lowerBound(y); // All keys > y let r4 = IDBKeyRange.lowerBound(y, true); // All keys ≥ x && ≤ y let r5 = IDBKeyRange.bound(x, y); // All keys > x &&< y let r6 = IDBKeyRange.bound(x, y, true, true); // All keys > x && ≤ y let r7 = IDBKeyRange.bound(x, y, true, false); // All keys ≥ x &&< y let r8 = IDBKeyRange.bound(x, y, false, true); // The key = z let r9 = IDBKeyRange.only(z);创建的对象可以使用
lower和upper属性获取范围边界,并通过lowerOpen和upperOpen属性判断边界是否开区间
其他方法
count():获取对象仓库中记录的数量
const countRequest = store.count(); countRequest.onsuccess = (event) => { console.log('对象仓库的记录数量:', event.target.result); }
索引对象
- 索引对象可以根据对应索引取值,可以在对象仓库对象的以下方法得到索引对象实例
- 在
upgradeneeded事件回调中使用indexedDB.createIndex()方法创建索引 store.index(name):获取对应属性的索引对象实例,需要索引事先创建过,不然会报错NotFoundError- 在
upgradeneeded事件回调中使用store.deleteIndex()方法删除索引
- 在
- 索引对象实例方法有:
get(key):获取索引属性对应的数据,可能有多个满足项,将返回第一个getAll(query, count):获取索引属性对应的数据openCursor(range, direction):方法用于异步遍历所有的数据count():获取数据的个数getKey(key):获取索引属性对应主键的值
- 实例属性
name:索引名称keyPath:索引列的键名objectStore:索引所属的对象仓库对象multiEntry:索引属性是否为多值unique:索引列值是否唯一
获取设备媒体
文件处理
获取文件
在浏览器中为了安全不能随意读取用户电脑上的文件,如果需要读取文件,需要使用
input[type=file]的标签,并且监听change事件- 标签本身的
files属性中包含了本次选择的文件列表,对应FileList接口(不可修改的文件列表,可以使用数组方法,但存在修改限制) - 这个列表的每一项都是一个
File对象,包含文件语义和数据内容等信息
如果觉得原始的
input[type=file]标签太丑了,可以将这个标签隐藏,使用自定义的文件选择器元素,然后在用户点击自定义选择器时,触发input[type=file]的click事件实现文件选择<input type="file" id="file" /> <script> const file = document.getElementById('file').files[0]; console.log(file); </script>- 标签本身的
利用拖动事件,拖动文件上传
- 拖动事件
drop中可以得到被拖动到元素上的文件列表e.dataTransfer.files - 拖动相关的事件需要禁用浏览器默认行为并阻止传播,避免误处理
function dragenter(e) { e.stopPropagation(); e.preventDefault(); } function dragover(e) { e.stopPropagation(); e.preventDefault(); } function drop(e) { e.stopPropagation(); e.preventDefault(); const dt = e.dataTransfer; const files = dt.files; handleFiles(files); }- 拖动事件
Blob 与 File 对象
File对象是Blob对象的子类,继承了Blob对象的所有属性和方法,并且主要新增了文件名name和上次修改时间lastModified属性,了解File对象要先从Blob对象开始Blob对象是用于描述文件内容的数据载体,存储文件的二进制数据,可以方便地进行文件分片和传输type:文件类型,比如image/png和text/plainsize:文件大小,单位字节slice(start, end, type):返回一个新的Blob对象,常用于文件分片- 新对象包含从
start到end的二进制数据,start和end参数都是字节数,都是可选参数,默认包括整个文件,如果使用负数则表示从文件末尾开始计数 type表示新的Blob对象的type,也是可选的
- 新对象包含从
text():返回值为一个Promise对象,用于读取文件内容,转换为文本字符串arrayBuffer():返回值为一个Promise对象,用于读取文件内容,转换为二进制数据,适用于图片等二进制数据- 如果是上传操作,一般不需要从
Blob中读取,如果希望上传二进制流,可以考虑读取ArrayBuffer,是一个可传输对象,传输时对应的content-type为application/octet-stream - 如果是下载操作,读取完成后如果希望处理,需要先转换为
uint8Array,因为ArrayBuffer对象无法操作内容,必须使用这种视图读写,之后就可以使用String.fromCharCode()将二进制数据转换为字符串,再使用btoa()将普通字符串数据转换为base64编码 - 一般情况下不需要使用这个方法处理
Blob数据,可以使用下面介绍的FileReader对象
- 如果是上传操作,一般不需要从
bytes():返回值为一个Promise对象,用于读取文件内容,转换为uint8Array对象stream():返回值为一个ReadableStream对象,用于读取文件内容,返回的是一个可读流
构造一个
Blob对象:new Blob(list[, options])可以动态生成一个Blob对象- 列表中可以有:普通字符串;二进制类型,比如
ArrayBuffer、TypeArray、DataView、其他Blob对象 options中可以设置属性:type:指定Blob对象的type属性,默认为''endings:指定Blob对象中的换行符处理方式,可选值有不处理'transparent'和按系统处理'native',默认为'transparent'
const blob = new Blob(['hello world'], { type: 'text/plain' }); const blob = new Blob([new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100])], { type: 'text/plain' });- 列表中可以有:普通字符串;二进制类型,比如
File对象继承了Blob对象的所有属性方法,并且额外扩展了lastModified:文件最后修改时间,毫秒数name:文件名webkitRelativePath:文件的目录在文件系统中的路径,Firefox安卓端2025刚适配,其他浏览器早已支持
- 构造一个
File对象:new file(fileBits, name[, options])fileBits:和Blob对象构造一样,是一个包含普通字符串、二进制数据、Blob对象的列表name:文件名options:除了Blob构造函数支持的type和endings属性,还可以设置lastModified,默认值是Date.now()
读取文件内容
如果需要进一步读取文件或
Blob对象内容进行处理,比如预览上传图片内容,需要使用FileReader对象,这个对象专门用来读取文件内容,转换为字符串或base64编码,常用方法属性包括readAsText(blob):读取文件内容文本,将结果保存在result属性中- ``readAsDataURL(blob)
:读取文件内容作为图片base64编码,将结果保存在result`属性中 readAsArrayBuffer(blob):读取文件内容二进制数据作为arrayBuffer,将结果保存在result属性中readyState:读取状态,0-2分别表示未开始、正在读取、读取完成
对应有同步读取的
FileReaderSync对象,方法相同const reader = new FileReader(); // 需要使用 load 事件监听读取完成 reader.onload = (event) => { console.log(reader.result); } // 所有的读取都是异步的,由浏览器的 I/O 线程或其他线程完成 // 一般一个 reader 用于读取一个文件,必须要在一次读取完成后才能再次调用读取的方法 reader.readAsText(file);base64编码
Base64是一种把二进制数据用文本表示出来的编码方式,用于在某些场景安全传输二进制数据(二进制中可能有不可见字符或控制字符)Base64编码使用了大小写字母、数字、+和/共64个字符编码(编码索引按提及顺序,0对应A,63对应/),并添加=进行补位。有一些变种编码会使用-和_代替+和/- 编码要求将原先的每
3个字节二进制数据拆成4个6位序列,每个序列对应一个编码字符,可以看出实际上序列可能性恰好种,因此称为Base64,同时编码之后6位表示变为8位,数据量膨胀约 - 如果原文字节数不是
3的倍数,字节就不会恰好可被6整除,需要在原文尾部添加0,确保所有原始数据都被编码。此时最终Base64编码就不是4的倍数,此时需要用=进行补位,实际上最多尾部只会添加1-2个= Base64编码的字符串格式为data:[mime];base64,...,其中data:[mime]用于指明对应数据的存储格式,之后使用base64指明编码方式,最后的部分...是具体的内容Base64数据可以作为图片、视频、音频的输入源,不依赖真实的地址和服务器,适合预览。同时Base64生成的数据不同于Blob.url,生成的预览数据由浏览器管理,不需要手动释放
文件上传
- 文件上传是一个常见的场景,将
File对象或Blob对象上传,本质上上传的是原始的二进制数据作为
FormData的一个表项上传:也就是通过formData.append存储之后将整个FormData对象上传const formData = new FormData(); formData.append('file', file); formData.append('name', file.name); fetch('/upload', { method: 'POST', body: formData })直接上传文件:由于名称不会被上传,因此通常需要将名称放入请求头或参数中
fetch('/upload', { method: 'POST', body: file headers: { 'x-name': file.name 'Content-Type': 'application/octet-stream' } })分片上传:当文件较大时,应该考虑分片上传
- 文件分片可以方便实现断点续传,减少网络开销。服务器可以返回接收到的分片信息,然后前端重新传输某些未成功的分片
- 文件很大,应该在上传前验证
hash,如果文件存在,则不需要上传。操作可以在上传开始前完成,同时传输分片数量等其他信息 - 具体实现中一般需要请求3个后端接口,第一个接口进行
hash验证,并通知分片信息,后端返回对应的文件是否存在等信息;第二个接口上传分片,后端需要临时存储;第三个接口将分片合并,并进行校验,如果发现分片未传输完成,需要通知上传
import SparkMD5 from 'spark-md5'; const chunkSize = 50 * 1024 * 1024; const total = Math.ceil(file.size / chunkSize); async function calcFileHash(file, chunkSize = 5 * 1024 * 1024) { const spark = new SparkMD5.ArrayBuffer(); const chunks = Math.ceil(file.size / chunkSize); for (let i = 0; i < chunks; i++) { const chunk = file.slice( i * chunkSize, (i + 1) * chunkSize ); const buffer = await chunk.arrayBuffer(); spark.append(buffer); } return spark.end(); } const hash = await calcFileHash(file); // 预验证 const res = await fetch('/upload/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash, filename: file.name, size: file.size, total }) }); const { exists, uploadedChunks } = await res.json(); if (!exists) { // 分片上传 let requestList = [] for (let i = 0; i < total; i++) { if (uploadedChunks.includes(i)) continue; const chunk = file.slice( i * chunkSize, (i + 1) * chunkSize ); const fd = new FormData(); fd.append('file', chunk); fd.append('hash', hash); // append 方法只支持字符串和blob输入,会隐式转换为字符串 fd.append('index', i); fd.append('total', total); requestList.push(fetch('/upload/chunk', { method: 'POST', body: fd })); } await Promise.all(requestList); // 之后合并 fetch('/upload/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash }) }); }
文件预览
文件预览一般用于提供上传前的查看和处理的能力,常见的可直接预览的文件包括图片、视频、音频,像
pdf这样的文件一般也有相应的库可以实现预览和处理常用的预览方法
- 使用
Blob URL:Blob对象可以通过URL.createObjectURL(blob)生成对应的URL,之后将URL作为源地址就可以预览- 创建的
URL仅对当前页面有效 - 需要手动释放,
URL.revokeObjectURL(url) - 具体和见URL对象的使用部分
- 创建的
- 使用
Base64:Base64编码可以用于video、img和audio标签的源地址Base64编码转码耗时较长,大文件应该优先考虑Blob URL
- 直接获取文本:
FileReader对象可以通过readAsText(blob)读取文件并返回文本内容 - 其他浏览器支持文件的预览:通过
iframe可以实现其他浏览器可打开文件的预览- 实际上就是使用
URL.createObjectURL(blob)生成对应的链接,并作为iframe的src
- 实际上就是使用
- 使用第三方库:如
pdf.js,提供了其他文件或增强的预览方法
// 创建 Blob URL const url = URL.createObjectURL(f) img.src = url URL.revokeObjectURL(url) // 创建 Base64 const reader = new FileReader(); reader.onload = function () { img.src = reader.result; } reader.readAsDataURL(f);- 使用
文件下载
文件下载是另一个常见的场景,一般是通过修改页面地址(新窗口打开)实现下载
后端应该设置
Content-Disposition: attachment;头,也就是默认下载文件,而不是优先预览具体的实现方法
- 通过
a标签:a标签可以用于打开新的页面,最基本的方法就是将a标签的href属性设置为对应的Blob url或真实url地址a标签有一个download属性,用于指定下载的文件名,如果Content-Disposition头包含文件名,则最终名称以服务器返回的文件名为准- 适合各类文件,大文件应该考虑直接使用真实的
url下载,避免Blob格式内存占用过高
- 通过
window.location.href = url:修改页面地址,如果是不支持预览或设置了Content-Disposition: attachment;的地址,会触发下载操作,而不是页面跳转- 文件名称应该添加到
Content-Disposition头
- 文件名称应该添加到
const div = document.querySelector('#download'); div.addEventListener('click', (e) => { // 使用 a 标签 const a = document.createElement('a'); a.href = url; a.download = 'file.txt'; a.click(); // 使用 window.location.href window.location.href = url; })- 通过
其他小工具
全屏切换
URL
URL主要包括:URL对象:创建解析操作URLURLSearchParams对象:操作query查询参数
new URL(url, base):创建解析操作URL,得到一个URL对象url.hostname:网址或主机名url.pathname:子路径url.protocol:协议url.port:端口url.search:query参数url.searchParams:query参数对应的URLSearchParams对象url.hash:锚点#
const u = new URL("https://example.com:8080/path/file?x=1#top"); u.hostname // "example.com" u.port // "8080" u.protocol // "https:" u.pathname // "/path/file" u.search // "?x=1" u.hash // "#top" // 可以修改 url.pathname = "/api"; url.searchParams.set("q", "hello"); // 重新生成 URL console.log(url.toString(), url.href);静态方法:
URL.createObjectURL(blob):创建一个指向blob对象的URL,这个URL可以作为预览内容的地址,地址格式为blob:<域名>/<UUID>- 此地址不会传递给后端,仅当前会话有效
- 一般传入的blob为图片对象,通过
blob地址对应图片,用于显示图片上传预览 - 此方法也可以用于生成
uuid
URL.revokeObjectURL(url):释放一个blob对象对应的URL
const input = document.querySelector('input'); let url; input.addEventListener('change', function (e) { const blob = event.target.files[0]; url = URL.createObjectURL(blob); const img = document.querySelector('img'); // blob 地址作为图片的显示地址 img.src = url; img.style.display = 'block'; }); window.addEventListener('unload', function () { if (url) { URL.revokeObjectURL(url); } })URL.canParse(url, base):判断url是否可以解析成有效的URL,返回true或false(2023才广泛使用)URL.parse(url, base):解析url,返回一个URL对象(2024才广泛使用)
URLSearchParams对象:操作query查询参数,相当于一个参数字典get(name):获取第一个满足的参数值,如果没有满足的参数,返回nullgetAll(name):获取所有满足的参数值列表set(name, value):设置参数值,如果有多个匹配值,将匹配键值的参数全部删除再添加新参数append(name, value):添加参数delete(name):删除参数has(name):判断参数是否存在sort():通过键排序参数size:目前含有的参数个数toString():返回参数字符串- 可迭代,有
keys()、values()、entries()和forEach()方法 toString():返回参数字符串
如果查询中包含多个相同的键值对,现在主流的后端框架返回的是数组,一般都能传递,部分老框架行为可能不同
- 其他的全局处理函数
encodeURI(str):对字符串进行编码,用于编码一个URL,因此最终的; / ? : @ & = + $ , #等URL字符不会被编码encodeURIComponent(str):对字符串进行编码,相比encodeURI,编码的字符更多,和encodeURI相同的是不会编码字符包括A–Z a–z 0–9 - _ . ! ~ * ' ( )btoa(str):将字符串进行Base64编码,包含占用了多个字节的字符的字符串不应该作为输入atob(str):将Base64编码的字符串进行解码
