32.JS高级-JSON序列化和数据存储

该系列文章连载于公众号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随着时代的发展,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 数据的顶层可以是以下三种类型:

    1. 简单值 :这是一个基础类型的值,例如一个数字、字符串、布尔值或 null

    2. 对象值 :这是用 {} 包裹的键-值对集合,通常用来表示复杂的结构或具有命名字段的数据。

    3. 数组值 :这是用 [] 包裹的一组值,值可以是简单值、对象、或其他数组。

  • 顶层的概念是指,整个 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.StringifyJSON.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 }
  • 在这里简单回顾深浅拷贝概念:
    1. 深拷贝(Deep Copy)是指将一个对象中的所有数据,包括嵌套的对象数组 ,都完全复制出来,生成一个新的对象,它与原来的对象在内存中是完全独立的
    2. 与之相对的**浅拷贝(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 的数据存储在内存中,读取速度更快,适合在页面短期内频繁访问数据的场景
  • 其中最需要记住的是以下三点表现:
    1. 关闭网页后重新打开,localStorage会保留,而sessionStorage会被删除
    2. 在页面内实现跳转,localStorage会保留,sessionStorage也会保留
    3. 在页面外实现跳转(打开新的网页),localStorage会保留,sessionStorage不会被保留

2.2 Storage常见方法与属性

  • Storage作为Web Storage API系列,一共有五个实例方法和一个实例属性,我们都会进行使用上的学习

  • 首先是存储和读取的经典方法:setItemgetItem

    • 内存存储的数据结构为键值对,因此使用方式将围绕该方式展开,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(localStoragesessionStorage)更为强大
    • 有时候我们可能会存储一些简单的数据到本地(浏览器中),比如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是一个对象,主要有两个配置项keyPathautoIncrement
    • 对象存储的名称用于唯一标识对象存储,可以理解为数据库中的表名
    • 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判断当前用户是否登录,是哪一个用户等信息,再决定返回什么内容给浏览器
  • Cookie过期后,则由默认值undefined替代原有内容

图32-12 服务器与浏览器的Cookie交互

4.1 cookie常见属性

  • cookie存在对应的生命周期(生效阶段):

    • 会话 Cookie(Session Cookie):默认情况下,Cookie 是会话级别的,浏览器关闭后,Cookie 会自动失效
    • 持久 Cookie (Persistent Cookie):如果设置了 expiresmax-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

图32-13 浏览器查看Cookie位置

4.2 客户端设置cookie

  • 但能够通过这两种方式来找到对应Cookie选择进行删除的,往往都是程序员群体
    • 对于普通用户而言,这两种做法都不太方便,也很难想到,因此各类网站往往会提供一个清除Cookie的按钮,通常这个按钮就叫做退出登录,当点击退出登录时,相当于删除了Cookie
  • 那我们要如何通过代码来删除Cookie?
    1. 通过window中的document.cookie是拿不到对应的cookie的,但我们可以通过这里设置Cookie(不能获取原来服务器的Cookie)
    2. 通过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的看过去)更加高效,知识之间不会形成一个个孤岛,印象也更加深刻,不容易产生后面学完,前面忘记的情况
相关推荐
开心工作室_kaic1 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
刚刚好ā1 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
沉默璇年2 小时前
react中useMemo的使用场景
前端·react.js·前端框架
yqcoder2 小时前
reactflow 中 useNodesState 模块作用
开发语言·前端·javascript
2401_882727572 小时前
BY组态-低代码web可视化组件
前端·后端·物联网·低代码·数学建模·前端框架
SoaringHeart3 小时前
Flutter进阶:基于 MLKit 的 OCR 文字识别
前端·flutter
会发光的猪。3 小时前
css使用弹性盒,让每个子元素平均等分父元素的4/1大小
前端·javascript·vue.js
天下代码客3 小时前
【vue】vue中.sync修饰符如何使用--详细代码对比
前端·javascript·vue.js
猫爪笔记3 小时前
前端:HTML (学习笔记)【1】
前端·笔记·学习·html
前端李易安4 小时前
Webpack 热更新(HMR)详解:原理与实现
前端·webpack·node.js