该系列文章连载于公众号coderwhy和掘金XiaoYu2002中
- 对该系列知识感兴趣和想要一起交流的可以添加wx:coderwhy666,拉你进群参与共学计划,一起成长进步
- 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力
脉络探索
- 浏览器中的知识是非常丰富的,由于这部分知识不属于JS高级的一部分,我们本章节所学习的只是其中扩展出来的一部分
- 且该部分都是浏览器的各种各样API,这些内容从使用角度来说,大家学习到目前阶段想要自主学习都是没有问题的
- 此次我们所学习的是JSON序列化与解析以及在浏览器中数据存储方式(Storage、IndexedDB、Cookie)
一、JSON的由来
- 在目前的开发中,JSON是一种非常重要的数据格式,它并不是编程语言,而是一种可以在服务器和客户端之间传输的数据格式,它诞生于 JavaScript 语言,其名称中的 "JavaScript Object Notation"(对象符号) 表明它是一种基于 JavaScript 语法的标记格式,虽然它的语法源于 JavaScript,但它不仅限于 JavaScript,也被广泛用于不同语言之间的数据交换
- 在 1990 年代末和 2000 年代初,随着互联网的快速发展,浏览器与服务器之间的数据交换需求 迅速增长。尤其是 Web 应用程序 的兴起,需要一种轻量、可读 且易于解析 的方式来在客户端和服务器之间传输数据,这是JSON出现所需的土壤环境
- 在 JSON 出现之前,数据交换的主要方式是 XML ,这也是一种结构化的数据格式,在目前的Java中,仍旧可以看到该身影,但
XML
的缺点包括格式冗长 、解析复杂 、可读性差。这些问题激发程序员寻找一种更为简洁的替代方案,JSON 因此应运而生 - Douglas Crockford 是 JSON 的主要提出者之一。在 2001 年左右,他对 JavaScript 对象字面量 (即
{}
的方式)进行了扩展,提出了 JSON 的概念 - Crockford 观察到 JavaScript 对象的结构非常适合用于数据交换------它简单、易于理解,同时也非常符合 JavaScript 的天然结构。因此,他推动将这种格式作为一种独立的数据格式,并为其命名为 JavaScript Object Notation(JSON),当独立的那一刻,就与JS对象走向不同的发展道路,因此两者并不一致
- 在 JSON 出现之前,数据交换的主要方式是 XML ,这也是一种结构化的数据格式,在目前的Java中,仍旧可以看到该身影,但
- 随后JSON随着时代的发展,2006年标准化,2013年进一步更新规范为RFC 7159,2013当年被纳入ECMA规范之中,这就是JSON诞生的过程,另外一个在网络传输中目前已经越来越多使用的传输格式是protobuf,但是直到2021年的3.x版本才支持JavaScript,所以目前在前端使用的较少,所以这里不过多讲解
js
//XML格式
<person>
<name>coderwhy</name>
<age>35</age>
</person>
//JSON格式
{
"name": "coderwhy",
"age": 35
}
- 目前JSON被使用的场景也越来越多:
- 网络数据的传输JSON数据
- 项目的某些配置文件,例如小程序配置文件(甚至做出了可视化效果)
- 非关系型数据库(NoSQL)将json作为存储格式
图32-1 小程序的配置文件
1.1 JSON基础语法
-
在 JSON 中,顶层 指的是 JSON 数据结构的最外层部分,也就是整个 JSON 文档的根级别,它决定了整个 JSON 数据的主要结构类型。JSON 数据的顶层可以是以下三种类型:
-
简单值 :这是一个基础类型的值,例如一个数字、字符串、布尔值或
null
。 -
对象值 :这是用
{}
包裹的键-值对集合,通常用来表示复杂的结构或具有命名字段的数据。 -
数组值 :这是用
[]
包裹的一组值,值可以是简单值、对象、或其他数组。
-
-
顶层的概念是指,整个 JSON 文档最外面的部分必须是这三种之一
js
// 1. 简单值(Simple Values)
// 顶层是一个简单值:可以是数字、字符串、布尔值或 null。
// 数字
42
// 字符串(必须使用双引号)
"Hello, World!"
// 布尔值
true
// null 值
null
// 2. 对象值(Object Values)
// 顶层是一个对象:由键-值对组成,键必须是字符串并用双引号括起来,值可以是简单值、对象或数组。
{
"name": "coderwhy",
"age": 25,
"isStudent": false,
"address": {
"city": "New York",
"zip": "10001"
},
"hobbies": ["reading", "cycling", "traveling"],
"phoneNumber": null
}
// 3. 数组值(Array Values)
// 顶层是一个数组:数组中的元素可以是简单值、对象或其他数组。
[
"apple",
"banana",
"cherry"
]
[
{
"name": "XiaoYu",
"age": 20
},
{
"name": "coderwhy",
"age": 22
}
]
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
- 理解顶层很重要,因为JSON 的顶层结构类型决定了 JSON 文档的用途和适用场景
- 如果顶层是一个对象,通常表示一组相关的数据(例如一个用户、一个配置项等),这是最常见的结构
- 如果顶层是一个数组,通常表示多个同类型的数据的集合(例如用户列表、商品列表等)
- 如果顶层是一个简单值,通常用于一些单独的结果返回(例如状态码、简单消息等)
- 当编写代码来解析 JSON 时,处理方式会根据顶层的结构不同而变化。假如 JSON 顶层是一个对象,解析时会期待用对象的方式来访问其键-值对。如果顶层是数组,则需要用数组的方式来访问其元素。因此类型决定了 JSON 结构的复杂度和解析方式
1.2 JSON序列化
- 某些情况下我们希望将JavaScript中的复杂类型转化成JSON格式的字符串,这样方便对其进行处理:
- 比如我们希望将一个对象保存到localStorage中(暂时理解为一种本地存储)
- 但是如果我们直接存放一个对象,这个对象会被转化成
[object Object] 格式
的字符串,并不是我们想要的结果 - 这是因为
localStorage.setItem
方法要求接收的value需要为string类型,而我们传入了一个对象。传入不符合要求的内容,容易出现不可预测的事情,但好在这里较为明显,是以往我们遇到过的情况,在需要字符串而传入非字符串时,会被强制通过toString转为字符串,对象被toStirng方法强转的表现行为是[object Object]
js
const info = {
name: "coderwhy",
age: 18,
friends: {
name: "XiaoYu"
},
hobbies: ["篮球", "足球"]
}
// 将对象数据存储localStorage,setItem方法的参数1为key,参数2为value
localStorage.setItem("info", info)
图32-2 浏览器中localStorage的存储表现
-
强转的结果非理想预期,因为具备实际意义的Value值丢失了,传入的是对象本身强转的结果,而非对象内容,当我想取出value时,就只能拿到[object Object]
-
因此在传入时,我们需要先将info对象人为控制的转为字符串,以防出现强转结果,那我们要如何合理转为字符串而不丢失对象内容呢?
- 首先对象原型身上的
toString方法
是绝对不行的,我们需要避开的就是该情况 - 这时需要利用上JSON格式,由于JSON顶层接受
简单值
,string字符串类型就属于简单值,而且经过JSON转化后想要读取也会更加简单
- 首先对象原型身上的
-
那我们要如何转化为JSON格式呢?这就需要说到来自JS本身的JSON内置对象了,它提供了"序列化"的方式,也被称为序列化方法
1.2.1 什么是序列化
JSON 序列化(Serialization)是指将数据(通常是编程语言中的对象、数组或其他数据结构)转换为JSON格式。这个过程将数据结构转换成字符串,从而做到数据能够被保存、传输或在不同的系统之间交换
- 序列化非常重要,因为计算机程序内部的数据通常是复杂的内存结构 (如对象、列表等),这些结构不能直接通过网络传输或者直接保存到磁盘。通过序列化 ,我们将这些复杂的数据结构转换为一种标准的、可传输的字符串格式
- 这种做法很有意思,所有被
JSON字符串
包裹的类型都会变成字符串,在计算机眼里都会失去原本的含义,在我们看来无非是披上一层字符串马甲,结构的表达还是一样的,但在计算机的眼中,字符串包裹起来的内容只能算是"文本",顶多就是一个长得和对象一样的"文本",但底层本质已经是改变了,它是一个JSON格式的字符串,由于 JSON 是标准化的,几乎所有编程语言都能解析 JSON,因此它在系统之间的数据交换中非常常用 - 因此诞生了一种很实用的技巧,我们可以传输的时候套上马甲,使其失去"意义",从而做到能在多个地方进行交换,等交换结束,再把这层马甲脱掉
1.2.2 序列化方法
JSON 是一种语法,用来序列化对象、数组、数值、字符串、布尔值和 null
。它基于 JavaScript 语法,但与之不同:大部分 JavaScript 不是 JSON
- 在JS中有一个标准内置对象JSON,提供的两个静态方法
JSON.Stringify
和JSON.parse
很有用,我们来学习一下 JSON.stringify()
方法将一个 JavaScript 对象或值转换为 JSON 字符串
js
//之前学习过的语法
//value:将要序列化成 一个 JSON 字符串的值
JSON.stringify(value[, replacer [, space]])
- 此时对我们一开始的info对象进行JSON序列化,再存储进localStorage,观察其反馈,能够发现localStorage的value能够正确传入对象内容
- 对JSON序列化的对象进行typeof类型判断,也返回string结果,印证我们的想法
js
// 将obj转成JSON格式的字符串
const infoString = JSON.stringify(info)
console.log(infoString);
//{"name":"coderwhy","age":18,"friends":{"name":"XiaoYu"},"hobbies":["篮球","足球"]}
console.log(typeof infoString);//string
// 将对象数据存储localStorage
localStorage.setItem("info", infoString)
图32-3 JSON序列化后的存储结果
- JSON序列化后,相当于披上一层马甲,我们是没办法对一个字符串进行各种常规读取属性操作的,因此需要脱下马甲
JSON.parse
这个静态方法就是专门做这件事情的,parse可以翻译为解析,与功能一致,是用来解析 JSON 字符串的
js
//text:要被解析成 JavaScript 值的字符串
JSON.parse(text, reviver)
- 因此
穿上马甲
和脱下马甲
对应了两个专业名词:JSON序列化
和JSON解析
,这两套组合拳通常是结合使用才能发挥最大威力,值得了解一下
js
// 将obj转成JSON格式的字符串
const infoString = JSON.stringify(info)
console.log(typeof infoString);//string
//将JSON格式字符串转回obj
const infoParse = JSON.parse(infoString)
console.log(typeof infoParse);//object
1.2.3 stringify序列化细节
- 在学习JSON这两个静态方法的语法时,有看到stringify静态方法的第二个参数
replacer
,那该参数是做什么的?为什么序列化需要这个参数呢?它解决了什么痛点问题?replacer参数
是可选的,作用是筛选过滤所需部分- 当一个对象想转为JSON序列化,又不希望全部转化时,就可以采用replacer参数,以
数组形式
传入希望保留的属性即可,这种最常见的用法
js
const info = {
name: "coderwhy",
age: 18,
friends: {
name: "XiaoYu"
},
hobbies: ["篮球", "足球"]
}
//replacer参数为数组
const infoString = JSON.stringify(info,['name','age'])
console.log(infoString);//{"name":"coderwhy","age":18}
- 除此之外,还允许传入回调函数或者null,默认则认定为未提供
- 传入回调函数则有两个参数,分别为key和value,也就是键值对的遍历,我们可以在回调函数中拿到即将转为JSON字符串数据的每一个键值对进行处理,我们可以简单理解为一个拦截器作用
- 若
replacer参数
为null或者未填,则为全部序列化
js
// 将obj转成JSON格式的字符串
const infoString = JSON.stringify(info, (key, value) => {
if (key === 'name') value = '小余'
return value
})
console.log(infoString);//{"name":"小余","age":18,"friends":{"name":"小余"},"hobbies":["篮球","足球"]}
- MDN文档的解释更为精准:如果该参数是一个函数,则在序列化过程中,被序列化的值的每个属性都会经过该函数的转换和处理;如果该参数是一个数组,则只有包含在这个数组中的属性名才会被序列化到最终的 JSON 字符串中;如果该参数为 null 或者未提供,则对象所有的属性都会被序列化
- 我们能够发现,这些操作本可以声明一个对象变量进行调整修改再传入stringify静态方法中,而
replacer参数
做到结合效果,说明需要进行拦截修改的操作频率非常高- 直接在序列化的过程中进行拦截,而不需要更改原来的数据结构。这种无副作用的操作方式更符合函数式编程 的理念,即通过纯函数来避免修改原始数据
- 减少一次对对象的额外操作,逻辑简化在replacer参数中,从而提高效率,并且传入改变的对象依旧是原始对象,不存在中转对象的干扰,阅读线路更清晰明确
- 统一处理,在代码结构也更为清晰,stringify方法所负责的对象将一直保持为原始对象,而无需考虑需要对原始对象负责还是对新声明对象变量负责
图32-4 replacer参数的拦截
- stringify方法还存在第三个参数
space
,该参数接收string或者number两种类型,指定缩进用的空白字符串,用于美化输出(pretty-print)- 传递string类型,则字符串被视为空格填写内容
- 传递number类型,则指定空格数量
- 但不管哪种方式,缩进上限都为10,字符串内容如果超出10位就选择前10位
js
const infoString = JSON.stringify(info, null,2)//缩进为2
- MDN文档解释:
space
参数用来控制结果字符串里面的间距。如果是一个数字,则在字符串化时每一级别会比上一级别缩进多这个数字值的空格(最多 10 个空格);如果是一个字符串,则每一级别会比上一级别多缩进该字符串(或该字符串的前 10 个字符)
图32-5 space缩进效果展示
- 若一个被序列化的对象拥有
toJSON
方法,会直接将toJSON的返回值作为结果,这一点和Promise的thenable很相似 - 该
toJSON
方法就会覆盖该对象默认的序列化行为:不是该对象被序列化,而是调用toJSON
方法后的返回值会被序列化
js
const info = {
name: "coderwhy",
age: 18,
friends: {
name: "XiaoYu"
},
hobbies: ["篮球", "足球"],
toJSON: function () {
return "toJSON已经设置"
}
}
const infoString = JSON.stringify(info)
console.log(infoString);//"toJSON已经设置"
1.2.4 字符串解析parse细节
- parse静态方法除了参数1能够用来传递解析,还有第二参数reviver,而这又有什么作用?
reviver
的单词含义来源于 "revive" ,直接翻译是复活、恢复。在这里需要结合语境去考虑,可以翻译为"还原回调函数",该"还原回调函数"作用与stringify方法中的replacer参数
作用一致,回调参数也一致(都为key、value)- 不同之处在于reviver参数是作用于解析阶段,在将JSON序列化内容还原后,返回数据前进行拦截,拦截后则可以进行处理
- 因此我们能够认为作用都是拦截,但
作用时机不同
(序列化与阶段两个不同阶段),作用的顺序也不同
,replacer参数是在序列化前拦截,reviver参数是在还原解析后,返回内容前进行拦截
图32-6 JSON序列化与解析流程
js
const JSONString = '{"name":"coderwhy","age":19,"friends":{"name":"小余"},"hobbies":["篮球","足球"]}'
const info = JSON.parse(JSONString, (key, value) => {
if (key === "age") {
return value - 1
}
return value
})
console.log(info)
// {
// name: 'coderwhy',
// age: 18,
// friends: { name: '小余' },
// hobbies: [ '篮球', '足球' ]
// }
- MDN文档解释reviver参数:如果指定了
reviver
函数,则解析出的 JavaScript 值(解析值)会经过一次转换后才将被最终返回(返回值)。更具体点讲就是:解析值本身以及它所包含的所有属性,会按照一定的顺序(从最最里层的属性开始,一级级往外,最终到达顶层,也就是解析值本身)分别的去调用reviver
函数,在调用过程中,当前属性所属的对象会作为this
值,当前属性名和属性值会分别作为第一个和第二个参数传入reviver
中。如果reviver
返回undefined
,则当前属性会从所属对象中删除,如果返回了其他值,则返回的值会成为当前属性新的属性值。
1.2.5 序列化深拷贝
-
JSON 序列化深拷贝是一种利用 JSON 的
序列化(serialization)
和反序列化(deserialization)
来实现对象深拷贝的技术手段。这种方法能够将一个对象的所有层次的数据完全复制出来,从而生成一个与原始对象没有引用关系的全新对象 -
在前面的学习中,我们打下坚实的基础,我们能够理解在JS中是存在
引用赋值
的情况,当两个变量共享同一个引用地址时,对其一的改变会同时影响其二,有时候我们并不希望产生这种藕断丝连的关系- 简单回顾案例,创建obj对象,将obj赋值给新变量info,对info进行修改,打印obj对象
- obj对象被改变,因为obj赋值info时,是赋值其堆内存地址(类似0xa00格式)
- 只有存放与栈内存的基础类型数据不需要担心被改变
js
const obj = {
name:"小余",
age:18
}
const info = obj
info.name = "coderwhy"
console.log(obj);//{ name: 'coderwhy', age: 18 }
- 在这里简单回顾深浅拷贝概念:
- 深拷贝(Deep Copy)是指将一个对象中的所有数据,包括嵌套的对象 和数组 ,都完全复制出来,生成一个新的对象,它与原来的对象在内存中是完全独立的
- 与之相对的**浅拷贝(Shallow Copy)**只会复制对象的第一层引用,而对嵌套对象或数组的引用不会进行深层次的复制,结果是拷贝出来的对象和原对象共享嵌套对象或数组
- 对于浅拷贝,只复制其中一层,对于像上面案例obj只有一层来说,无异于深拷贝,但如果不止一层,对象内部还有对象,则只有第一层内容是深拷贝,继续深层则都为引用赋值关系
- 新变量info使用对象字面量的方式创建了一个对象,将原有对象内部的数据搬迁进去,从内存角度来说,这已经指向两处不同的内存空间,但若搬迁对象内部依旧还有引用情况,则深处依旧指向同一内容
图32-7 浅拷贝示意图
js
const obj = {
name:"小余",
names:{
name1:'coderwhy1',
name2:'coderwhy2',
}
}
//浅拷贝 改变了第一层的内存存储地址
const info = {...obj}
info.name = '小余007'//位于第一层,info内存空间与obj不同,不会影响obj
info.names.name1 = 'coderwhy123'//位于第二层,info的names与obj的names相同,会影响obj的names属性
console.log(obj);//{ name: '小余', names: { name1: 'coderwhy123', name2: 'coderwhy2' } }
- 而利用JSON中的序列化与解析两步骤是能够实现深拷贝的,之所以可以实现,则需要回顾前面特地给出的MDN文档对reviver参数的解释:从最里层向外一层层调用reviver参数
- 这说明我们能够拿到对象中的每一个基础数据类型,去除所有引用情况,实现所有深层内容的遍历并拷贝
- 但JSON深拷贝并不是万能的,虽简单易用,但JSON序列化是无法对函数进行处理,默认会进行移除,还有无法处理循环引用、不支持特殊对象、性能不佳(不适合大型对象)等问题
- 对于该缺陷我们可以在stringify方法的replacer参数中进行拦截单独处理,但终究需要考虑其缺陷与需求对冲问题
- 我们后续会讲解如何编写深拷贝的工具函数,那么这样就可以对函数的拷贝进行处理了
- 而JSON深拷贝在该过程分为两步骤是密不可分的,也被称为序列化和反序列化(解析):
- 序列化 :将对象转换为 JSON 字符串,这是一个深层次的数据提取过程,确保对象的所有属性(包括嵌套的对象和数组)都被提取
- 反序列化 :将生成的 JSON 字符串转换为一个新的 JavaScript 对象,得到的对象是原始对象的深层拷贝
js
//先序列化后解析
const info = JSON.parse(JSON.stringify(obj))
info.name = '小余007'
info.names.name1 = 'coderwhy123'
console.log(obj);//{ name: '小余', names: { name1: 'coderwhy1', name2: 'coderwhy2' } }
尽管如此,在大多数需要对普通数据对象 进行深拷贝的情况下,JSON 序列化深拷贝依然是非常有效的方法,适用于处理没有复杂结构的对象
二、初识Storage
Storage
是浏览器提供的Web 存储(Web Storage) API,用于在用户浏览器中保存数据。它为 Web 应用提供了一种简单、高效的数据持久化方案,允许开发者以键-值对的形式在客户端存储数据,且数据不会随页面刷新而丢失
2.1 localStorage和sessionStorage的区别
-
WebStorage主要提供了一种机制,可以让浏览器提供一种比cookie(后续学习)更直观的key、value存储方式,其中Storage 包括两种主要类型:
-
localStorage:本地存储,提供的是一种永久性的存储方法,即使关闭浏览器或重新启动计算机,数据依然存在,除非手动删除
-
sessionStorage:会话存储,提供的是本次会话的存储,在关闭掉会话时,存储的内容会被清除
-
图32-8 Storage存储对应浏览器位置
- 由于两种类型的特点不同,导致了应用场景的不一致,也是两者最大的区别所在
- 在用户登录时,可以使用
localStorage
存储用户 token 或身份信息(需注意安全性),方便在页面中进行简单的认证操作,这样用户就不必每次上线都需要重新登录 - 而在用户填写长表单或进行某些操作时,可以将中间状态保存在
sessionStorage
中,避免刷新页面导致的数据丢失。当然我们可能会想,没事的情况怎么会故意去刷新,这里的刷新涵盖含义较为广泛,不仅指Ctrl+R的页面刷新,也包括了页面跳转再返回,表单关闭等各种业务情况,因此使用范围也很大,在学习项目时,能够更深刻理解这点
- 在用户登录时,可以使用
- 以上是从应用的角度出发,因此如何界定使用方式,更应该从数据在实际应用的生命周期进行判断
- 这里提到一个概念,数据的生命周期,两个类型的周期都是如何体现的,这则需要深入浏览器原理
localStorage
中的数据被写入到浏览器缓存数据库(通常是基于文件的存储),并且持久保存在设备的硬盘中。浏览器会将这些数据存储在其特定的存储目录中,因此即使用户关闭浏览器,数据也不会丢失- 而
sessionStorage
中的数据是与浏览器标签页 或者窗口实例 相关联的。浏览器为每个打开的标签页(或窗口)分配一个独立的 session context ,并在该上下文中存储数据。这些数据不会被持久化到硬盘,而是保存在内存中或类似于内存的临时存储中,因此它们的生命周期通常限于当前浏览器标签页的打开状态
- 由于存储位置的不同,导致其数据的使用范围(作用域)也不同
localStorage
数据位于磁盘,可以通过浏览器的内置存储管理 来实现同一个源 下的所有页面中保持一致的数据,所以是跨会话、跨标签页的,如果多个标签页属于同一个域名,它们可以读取并共享相同的localStorage
数据。这对于在同一个应用中不同页面之间共享一些用户信息非常有用,例如认证 token 等(例如同时打开多个掘金都是登录状态)sessionStorage
数据的作用域仅限于当前标签页或窗口 ,同一网站在不同的标签页中拥有各自独立的sessionStorage
实例,彼此之间的数据无法共享。每个打开的标签页(或窗口)都有一个单独的sessionStorage
实例。但从当前标签页打开的其他页面,会被视为同一会话的延伸(除非特地设置打开的是一个全新空白页),这并不与会话存储的特点冲突
- 而且由于我们在pnpm中,对操作系统已经有一定的了解,知道磁盘与内存读写速度是有差距的,因此还可以从性能角度去看待区别问题
localStorage
的数据会持久化到磁盘中,读写时相对较慢,尤其是当存储的数据量较大时,磁盘 I/O 的开销会影响性能sessionStorage
的数据存储在内存中,读取速度更快,适合在页面短期内频繁访问数据的场景
- 其中最需要记住的是以下三点表现:
- 关闭网页后重新打开,localStorage会保留,而sessionStorage会被删除
- 在页面内实现跳转,localStorage会保留,sessionStorage也会保留
- 在页面外实现跳转(打开新的网页),localStorage会保留,sessionStorage不会被保留
2.2 Storage常见方法与属性
-
Storage作为Web Storage API系列,一共有五个实例方法和一个实例属性,我们都会进行使用上的学习
-
首先是存储和读取的经典方法:
setItem
和getItem
- 内存存储的数据结构为键值对,因此使用方式将围绕该方式展开,setItem传入所需键值对,getItem传入所需键获取所需值,在这点上和JS对象的使用方式相似
js
//keyName:要创建的键
//keyValue:要创建的值(必须为字符串)
setItem(keyName, keyValue)
//keyName:传入键,返回需要的值
getItem(keyName)
- Storage还提供类似数组的使用方式:key方法
- 该方法接受一个数值n作为参数,返回存储中的第n个key名称
- 我们可以理解为有一个数组,有序存储了所有的key值,可以以索引获取对应的key值(索引以0为起点)
js
// 1.setItem
localStorage.setItem("name", "coderwhy")
localStorage.setItem("age", 18)
// 2.key方法
console.log(localStorage.key(0))//name
- 在key方法的基础上,我们延伸出对Storage属性length的学习
- key方法作为一个数组形式的使用,我们明确知道索引的起点,也知道索引的有序规律(0开始,每次加1),但我们不知道这个数组有多长,无法精准遍历出所有的key值
- 因此Storage提供了唯一的一个属性length,使用方式与数组相同,配合key方法能够将所有的key值遍历出来,并通过判断操作筛选出所需的key值进行针对性操作
js
localStorage.setItem("name", "coderwhy")
localStorage.setItem("age", 18)
console.log(localStorage.length)
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
//获取所有key中对应的值
console.log(localStorage.getItem(key))
}
- 除此之外,Storage还有两个删除方法:removeItem和clear,区别在于前者精准清除,后者全部删除
- removeItem方法接受一个key值参数,以便实现定点删除
- 而clear作为无差别全删除并不需要参数,直接调用即可
js
//针对性删除name
localStorage.removeItem("name", "coderwhy")
//全部删除
localStorage.clear()
- 以上进行演示时,我们采用localStorage(本地存储)进行操作,但这些做法同样存在sessionStorage,具备的五个方法和一个属性都是一模一样的,包括使用方式和效果,这里则不再演示
2.3 封装Storage
- 在实际应用中,我们需求往往较为复杂,此时原生的Storage方法无法满足我们的需求,就可以利用原生方法封装一个关于Storage的定制化工具类,实现更为强大的效果,例如深拷贝就可以两个原生方法封装为一个方法使用,更为高效便捷
- 想法产生,开始实践,首先需要搭建一个基础的工具类模板
js
class HYCache {
constructor() {
}
}
const localCache = new HYCache()
const sessionCache = new HYCache()
//统一导出
export {
localCache,
sessionCache
}
- 在完成框架搭建后,可以来实现具体的逻辑,在这里需要进一步细分,如何区分本地存储和会话存储,毕竟都是使用同一个类
- 由于只有两个类型,非常适合布尔值,因此我们采用该方法来判断,默认true为本地存储,第一参数传入false为会话存储。然后根据用户传入布尔值结果,来选择逻辑实现,工具本身不需要操心需要选择哪一个类型
js
class HYCache {
constructor(isLocal = true) {
// 由实例传入参数决定本地存储 or 会话存储
this.storage = isLocal ? localStorage : sessionCache
}
}
const localCache = new HYCache()
const sessionCache = new HYCache(false)
- Storage存储传入的数据必须是一个字符串,在使用原生方法时,需要时刻记住这点,提前转为JSON字符串,在这里可以进行封装,不管是序列化还是解析的过程,都直接封装起来,用户使用工具时,可以直接拿到转化后的结果,剩下内容按部就班即可,有需求再根据需求具体调整,这样就是一个扩展性很好的工具类
js
class HYCache {
constructor(isLocal = true) {
this.storage = isLocal ? localStorage: sessionStorage
}
setItem(key, value) {
if (value) {
this.storage.setItem(key, JSON.stringify(value))
}
}
getItem(key) {
let value = this.storage.getItem(key)
if (value) {
value = JSON.parse(value)
return value
}
}
removeItem(key) {
this.storage.removeItem(key)
}
clear() {
this.storage.clear()
}
key(index) {
return this.storage.key(index)
}
length() {
return this.storage.length
}
}
三、初识IndexedDB
- 什么是IndexedDB呢?我们能看到DB这个词,就说明它其实是一种数据库(Database),通常情况下在服务器端比较常见
- 在实际的开发中,用于浏览器的情况非常少见且不太合适,因为缓存内容会非常多导致浏览器负荷非常大,产生性能上的问题,大量的数据都是存储在数据库的,客户端主要是请求这些数据并且展示
- 而IndexedDB的浏览器提供的一种低级别的客户端数据库 ,是一个非关系型(NoSQL)数据库 ,比 Web Storage(
localStorage
和sessionStorage
)更为强大 - 有时候我们可能会存储一些简单的数据到本地(浏览器中),比如token、用户名、密码、用户信息等,比较少存储大量的数据,那么如果确实有大量的数据需要存储,这个时候可以选择使用IndexedDB,因为Storage存储上限为5-10MB,而IndexedDB能够达到上百MB
- IndexedDB 是一个浏览器提供的底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))
- 也被称为是事务型数据库系统,类似于基于 SQL 的 RDBMS。然而,不像 RDBMS 使用固定列表,IndexedDB 是一个基于 JavaScript 的面向对象数据库
- 在 IndexedDB 中,事务 是指用于管理一组数据库操作的逻辑单元 ,确保这些操作要么
全部成功
,要么全部不执行
- 举个简单的例子,转账操作,当转给他人100块钱时,我们的钱包余额需要同步减去100,必须同时发生,不能因为转账过程的某个步骤出了问题,导致我钱扣了然后没转过去,也不能转过去了然后钱包没扣,这种逻辑是必须保持连贯的,连贯的一个逻辑就被称为一个单元,出现问题就会整个单元进行回滚操作,这个概念非常重要,但这些更多是后端方面的知识,这里作为简单了解即可
- IndexedDB本身就是基于事务的,我们只需要指定数据库模式,打开与数据库的连接,然后检索和更新一系列事务即可
- 有很多的数据,为什么不直接存储在一个本地文件里,然后直接读取就好,而是专门需要数据库来进行,看似好像多了一个环节更"麻烦"了
- 但其实这是更简单了,因为任何内容在面对基数越庞大的问题上,对性能的要求就越高,因为每一点的性能浪费在庞大基数的基础上,都会带来非常明显的感受
- 数据库拥有各种数据结构,都是为了对数据操作性能上的极致优化,做到效率上的提升
3.1 IndexedDB连接数据库
- 数据库中所有的操作都来自最基础的增删改查,在这里我们也主要演练这四种操作
- 不过在开始操作之前,我们需要先连接上数据库。连接数据库后,才能对数据库中的数据进行操作
- 为了获取数据库的访问权限,需要在 window 对象的 indexedDB 属性上调用 open()实例方法。该方法返回一个
IDBRequest
对象
js
//name:数据库名称
//version:指定数据库版本
open(name, version)
- 此时可能会有一个疑惑,我们不是要创建数据库吗?怎么看open方法是打开数据库呢?和目的不太一致
- open方法同时兼顾创建和打开数据库的功能,如果当前没有数据库则创建,如果有数据库则打开
js
//创建数据库,库名coderwhy
const dbRequest = indexedDB.open("coderwhy", 3)
3.2 IndexedDB数据库操作
- 在完成打开数据库后,通过返回的实例进行操作
- 伴随着创建数据库后,主要的数据库操作时机包括:打开失败(onerror)、打开成功(onsuccess)、第一次打开或者数据库版本升级时(onupgradeneeded),每一个操作都是回调函数
- 回调函数里的回调参数是一个event,通过获取
event.target.result
可以对数据库开始进行操作 - 同时这里补充一个概念:一个软件中允许存在多个数据库,一个数据库允许存在多张表,在IndexedDB中,数据库内存在的不是表,而是存储对象,但性质是差不多的
- 通常创建数据库后,第一件事情就是创建存储对象(表),参数1是对象存储名称,参数2是一个对象,主要有两个配置项
keyPath
和autoIncrement
- 对象存储的名称用于唯一标识对象存储,可以理解为数据库中的表名
keyPath
是主键,理解为一个唯一ID。**autoIncrement
**则是一个布尔值,表示是否在存储数据时自动生成主键。如果设置为true
,则每次添加数据时,数据库会自动生成一个递增的主键值
js
dbRequest.onerror = function(err) {
console.log("打开数据库失败~")
}
//全局存储db,共享使用
let db = null
dbRequest.onsuccess = function(event) {
db = event.target.result
}
// 第一次打开/或者版本发生升级
dbRequest.onupgradeneeded = function(event) {
const db = event.target.result
console.log(db)
// 创建一些存储对象
db.createObjectStore("users", { keyPath: "id" })
}
- 在浏览器中,可以看到IndexedDB中已经显示我们创建的数据库了,包括其中的配置
图32-9 IndexedDB数据库信息
- 我们接下来想对数据库进行操作,则需要交互按钮,在index.html中创建四个按钮,获取DOM元素,将其绑定到JS点击事件中即可
js
//index.html
<button>新增</button>
<button>查询</button>
<button>删除</button>
<button>修改</button>
js
// 获取btns, 监听点击
const btns = document.querySelectorAll("button")
for (let i = 0; i < btns.length; i++) {
btns[i].onclick = function() {
switch(i) {
case 0:
console.log("点击了新增")
case 1:
console.log("点击了查询")
case 2:
console.log("点击了删除")
case 3:
console.log("点击了修改")
}
}
}
- 我们将每一次操作,都归纳为一个事务对象,作为数据库的一次操作,保证数据库的一致性,而
db.transaction()
方法用于在数据库中创建一个事务- 参数
"users"
指定对哪一个或哪几个对象存储(object store)进行操作。在这里,"users"
是对象存储的名称,表示要对"users"
对象存储进行操作,也可以使用数组形式来一次性创建多个事务 - 参数
"readwrite"
表示事务的类型,这里是"readwrite"
,意味着事务允许对对象存储中的数据进行读写操作 。还有另外一种常用的事务模式是"readonly"
,表示只对数据进行读取,不允许写入 - 返回值transaction则是一个事务对象
- 参数
- 创建明确的事务后,
transaction.objectStore("users")
通过事务获取对象存储 ,即对数据库中的"users"
对象存储进行操作,返回的store
是对象存储的引用,之后可以通过这个引用来进行数据的增、删、改、查等操作
js
const transaction = db.transaction("users", "readwrite")
console.log(transaction)
const store = transaction.objectStore("users")
- 想要对数据库进行操作的所有前置要求均已完成,接下来对数据进行操作前,先创建一点数据
js
class User {
constructor(id, name, age) {
this.id = id
this.name = name
this.age = age
}
}
const users = [
new User(100, "why", 18),
new User(101, "coderwhy", 40),
new User(102, "小余", 30),
]
- 对DOM进行监听,增删改查四个按钮绑定的索引分别为0至3,也对应了watch循环中的4个阶段
- 我们在0中增添数据,1中删除数据、2中修改数据、3中查询数据
- 返回的store通过add方法进行增添数据,每一次增添都设置成功的回调来提示我们数据插入成功,第一次事务操作完成时,通过oncomplete来提示我们已经全部添加完成
js
case 0:
console.log("点击了新增")
for (const user of users) {
const request = store.add(user)
request.onsuccess = function() {
console.log(`${user.name}插入成功`)
}
}
transaction.oncomplete = function() {
console.log("添加操作全部完成")
}
break
图32-10 IndexedDB数据库添加内容展示
- 查询有两种方式:
- 方式一根据主键查询,适用于知道具体的主键值(
keyPath
)的情况,简单直接且高效 - 方式二使用游标遍历查询,可以用于实现复杂的过滤逻辑,但效率较低
- 方式一根据主键查询,适用于知道具体的主键值(
- 方式一直接使用get方法非常高效,因为它直接通过主键进行定位,相当于传统数据库中通过索引查询,找到目标后立即返回
- 方式二通过
store.openCursor()
打开一个游标 ,遍历对象存储中的每一条记录,通过逻辑条件筛选数据,适合查找满足特定条件的数据- 游标(Cursor) 是 IndexedDB 中用于遍历对象存储(object store)或索引(index)的一个工具,它相当于一个指针,指向对象存储中的每一个记录,允许我们逐条访问、读取或操作数据
- 指针是C语言中的难点概念,我们可以理解为在对象存储中存在着许多小块,每一块都是一个记录,指针指向首块记录cursor.key与cursor.value指向对象的键值对,然后通过
cursor.continue()方法
移动该指针
js
case 1:
console.log("点击了查询")
// 1.查询方式一(知道主键, 根据主键查询)
// const request = store.get(102)
// request.onsuccess = function(event) {
// console.log(event.target.result)
// }
// 2.查询方式二:
const request = store.openCursor()//打开一个游标
request.onsuccess = function(event) {
const cursor = event.target.result
if (cursor) {
if (cursor.key === 101) {
console.log(cursor.key, cursor.value)
} else {
cursor.continue()
}
} else {
console.log("查询完成")
}
}
break
-
需要注意的是游标查询的效率较低,因为它需要逐条遍历所有记录,尤其当对象存储包含大量数据时,这种方式的性能会受到影响,更适合需要筛选特定条件内容,对于简单的查找而言,get方法是首选
-
对应删除和修改来说,也都是利用游标来进行操作,毕竟拿到了具体的每一条数据,想要进行针对性(批量)修改或者删除都是非常简单,游标在遍历到每一个属性的基础上,提供了对应修改数据和删除数据的方法,分别为:
cursor.update(value)
和cursor.delete()
js
//删除
case 2:
console.log("点击了删除")
const deleteRequest = store.openCursor()
deleteRequest.onsuccess = function(event) {
const cursor = event.target.result
if (cursor) {
if (cursor.key === 101) {
cursor.delete()
} else {
cursor.continue()
}
} else {
console.log("查询完成")
}
}
break
//修改
case 3:
console.log("点击了修改")
const updateRequest = store.openCursor()
updateRequest.onsuccess = function(event) {
const cursor = event.target.result
if (cursor) {
if (cursor.key === 101) {
const value = cursor.value;
value.name = "curry"
cursor.update(value)
} else {
cursor.continue()
}
} else {
console.log("查询完成")
}
}
break
}
- 对于IndexedDB作一个了解到目前就足够了,感兴趣的可以从MDN文档中了解更多细节,完整操作代码如下:
js
const dbRequest = indexedDB.open("coderwhy", 3)
dbRequest.onerror = function (err) {
console.log("打开数据库失败~")
}
//全局存储db,共享使用
let db = null
dbRequest.onsuccess = function (event) {
db = event.target.result
}
// 第一次打开/或者版本发生升级
dbRequest.onupgradeneeded = function (event) {
const db = event.target.result
console.log(db)
// 创建一些存储对象
db.createObjectStore("users", { keyPath: "id" })
}
class User {
constructor(id, name, age) {
this.id = id
this.name = name
this.age = age
}
}
const users = [
new User(100, "why", 18),
new User(101, "coderwhy", 40),
new User(102, "小余", 30),
]
// 获取btns, 监听点击
const btns = document.querySelectorAll("button")
for (let i = 0; i < btns.length; i++) {
btns[i].onclick = function () {
const transaction = db.transaction("users", "readwrite")
console.log(transaction)
const store = transaction.objectStore("users")
switch (i) {
case 0:
console.log("点击了新增")
for (const user of users) {
const request = store.add(user)
request.onsuccess = function () {
console.log(`${user.name}插入成功`)
}
}
transaction.oncomplete = function () {
console.log("添加操作全部完成")
}
break
case 1:
console.log("点击了查询")
// 1.查询方式一(知道主键, 根据主键查询)
// const request = store.get(102)
// request.onsuccess = function(event) {
// console.log(event.target.result)
// }
// 2.查询方式二:
const request = store.openCursor()//打开一个游标
request.onsuccess = function (event) {
const cursor = event.target.result
if (cursor) {
if (cursor.key === 101) {
console.log(cursor.key, cursor.value)
} else {
cursor.continue()
}
} else {
console.log("查询完成")
}
}
break
//删除
case 2:
console.log("点击了删除")
const deleteRequest = store.openCursor()
deleteRequest.onsuccess = function (event) {
const cursor = event.target.result
if (cursor) {
if (cursor.key === 101) {
cursor.delete()
} else {
cursor.continue()
}
} else {
console.log("查询完成")
}
}
break
//修改
case 3:
console.log("点击了修改")
const updateRequest = store.openCursor()
updateRequest.onsuccess = function (event) {
const cursor = event.target.result
if (cursor) {
if (cursor.key === 101) {
const value = cursor.value;
value.name = "curry"
cursor.update(value)
} else {
cursor.continue()
}
} else {
console.log("查询完成")
}
}
break
}
}
四、初识Cookie
Cookie(复数形态Cookies),又称为"小甜饼"。类型为"小型文本文件",某些网站为了辨别用户身份而存储在用户本地终端(Client Side)上的数据
- 浏览器会在特定的情况下携带上cookie来发送请求,我们可以通过cookie来获取一些信息
- Cookie 是由服务器生成的,并存储在浏览器中,浏览器会将 Cookie 作为请求头的一部分 发送给服务器,从而在客户端和服务器之间共享数据
- 通常用来验证身份,当我们首次登录账号时,将用户名与密码传给服务器,服务器校验正确后,会返回一系列数据,在返回数据时会连带着Cookie一起返回
- 当做出一些需要登录账号才能做的操作时,在将操作请求发送服务器时,cookie也会从浏览器中一起被携带过去,作为服务器判断当前是否登录的凭证,登录则返回数据进行操作,否则拒绝
- Cookie总是保存在客户端中,按在客户端中的存储位置,Cookie可以分为内存Cookie和硬盘Cookie
- 内存Cookie由浏览器维护,保存在内存中,浏览器关闭时Cookie就会消失,其存在时间是短暂的
- 硬盘Cookie保存在硬盘中,有一个过期时间,用户手动清理或者过期时间到时,才会被清理
- 这个概念与前面Storage的两种类型相似
- 那如何判断一个cookie是内存cookie还是硬盘cookie呢?
- 没有设置过期时间,默认情况下cookie是内存cookie,在关闭浏览器时会自动删除
- 有设置过期时间,并且过期时间不为0或者负数的cookie,是硬盘cookie,需要手动或者到期时,才会删除。这个过期时间是由服务器设置的,但有一致命点需要注意,一旦服务器设置并且返回客户端后,将无法对该过期时间进行任何操作,主动权从服务器交到用户手中
- 如下图中的set-Cookie中就存在内容以及对应的过期时间
图32-11 Cookie显示图
- 常见设置Cookie都在后端进行操作,前端更多作为接收方,在这里以Node的Koa框架进行举例
- 第一次浏览器发起请求,将用户名与密码传递给服务器,服务器接收后,返回登录信息和通过 https 响应头
Set-Cookie
向客户端发送一个或多个 Cookie,浏览器接收到Set-Cookie
后,会存储这些 Cookie,并在后续的请求中将它们自动添加到请求头中,发送给服务器 - 第二次浏览器发起请求,携带信息和对应Cookie到服务器中进行验证,服务器根据Cookie判断当前用户是否登录,是哪一个用户等信息,再决定返回什么内容给浏览器
- 第一次浏览器发起请求,将用户名与密码传递给服务器,服务器接收后,返回登录信息和通过 https 响应头
- Cookie过期后,则由默认值undefined替代原有内容
图32-12 服务器与浏览器的Cookie交互
4.1 cookie常见属性
-
cookie存在对应的生命周期(生效阶段):
- 会话 Cookie(Session Cookie):默认情况下,Cookie 是会话级别的,浏览器关闭后,Cookie 会自动失效
- 持久 Cookie (Persistent Cookie):如果设置了
expires
或max-age
属性,则 Cookie 可以被持久保存,即使关闭浏览器,直到到达指定的过期时间后才失效 - expires:设置的是Date.toUTCString(),设置格式是;expires=date-in-GMTString-format
- max-age:设置过期的秒钟,max-age=max-age-in-seconds (例如一年为
60*60*24*365
)
-
Cookie 与特定的域名和路径 相关联,因此它们只能被同一域名下的页面访问,确保数据的安全性
- 这说明Cookie是存在对应作用域范围的,也是做到当前页面发起请求,"其他"页面也能共享对应Cookie的原因,这都是基于同一域名下的前提条件才能实现
- 在这一点上,Cookie与Storage存储有一定的相似性,但Cookie还可以主动设置哪些主机可以接受cookie
-
Domain:指定哪些主机可以接受cookie
- 如果不指定,那么默认是 origin,不包括子域名
- 如果指定Domain,则包含子域名。例如,如果设置 Domain=mozilla.org,则 Cookie 也包含在子域名中(如developer.mozilla.org),developer是子域名
-
Path:指定主机下哪些路径可以接受cookie
- 例如,设置 Path=/docs,则以下地址都会匹配:
/docs
、/docs/Web/
、/docs/Web/https
- 通过浏览器左上角的
i符号
或者DevTools中Application的Cookies选项(位于Storage列表中),能够查看对应的cookie
- 例如,设置 Path=/docs,则以下地址都会匹配:
图32-13 浏览器查看Cookie位置
4.2 客户端设置cookie
- 但能够通过这两种方式来找到对应Cookie选择进行删除的,往往都是程序员群体
- 对于普通用户而言,这两种做法都不太方便,也很难想到,因此各类网站往往会提供一个清除Cookie的按钮,通常这个按钮就叫做
退出登录
,当点击退出登录时,相当于删除了Cookie
- 对于普通用户而言,这两种做法都不太方便,也很难想到,因此各类网站往往会提供一个清除Cookie的按钮,通常这个按钮就叫做
- 那我们要如何通过代码来删除Cookie?
- 通过window中的document.cookie是拿不到对应的cookie的,但我们可以通过这里设置Cookie(不能获取原来服务器的Cookie)
- 通过
document.cookie = ''
掷为空是删不掉Cookie的,正确的操作是document.cookie="name='coderwhy';max-age=0"
,首先选择我们所想要删除的内容,然后将其过期时间设置为0,相当于马上过期,则值就会默认转为undefined,实现"删除"效果
js
//js直接设置和获取cookie
console.log(document.cookie);
//这个cookie会在会话关闭时被删除掉
document.cookie = "name=coderwhy"
document.cookie = "age=18"
//设置cookie,同时设置过期时间(默认单位是秒钟)
document.cookie = "name=coderwhy;max-age=10"
- 但目前,使用Cookie的情况越来越少了,因为Cookie会附加到每一次https请求中(浏览器自动携带),哪怕我们不需要这个Cookie也是,会浪费用户一定的流量
- Cookie是明文传输的,哪怕是加密后的内容(类似md5)再"加盐",也有被破解的安全风险,这种能够避免应该尽可能避免
- Cookie有大小限制4KB
- 验证登录时如果通过Cookie,则会产生很强的依赖性,必须依赖Cookie来确定登录,但我们 客户端不止浏览器,还有IOS、Android、小程序等等,有些客户端有可能是没办法设置Cookie或者需要手动添加Cookie的
- Cookie随着时代的发展只会使用得越来越少,现在流行的做法是使用token,等到做项目时,就会学习到这方面相关的知识点了
后续预告
- 在下一章节中,我们会来简单学习一下BOM与DOM这两大块知识点
- 这两块内容都是浏览器方面的内容,如果需要详细学习的话,需要较长时间,而我们该系列主要的还是讲解JS高级
- 因此这方面主要以架构的角度去学习这些内容,不会深入其中的细节
- 这也是一种很好的学习方式,从整体的角度去看待知识,知道哪些更重要,哪些内容之间有所关联,从哪入手更简单,如何更加合理的进行学习布局等等,这会比线性学习(一个个API的看过去)更加高效,知识之间不会形成一个个孤岛,印象也更加深刻,不容易产生后面学完,前面忘记的情况