HTTP cookie 通常也叫作 cookie,最初用于在客户端存储会话信息。这个规范要求服务器在响应
HTTP 请求时,通过发送 Set-Cookie HTTP 头部包含会话信息。例如, 下面是包含这个头部的一个 HTTP
响应 :HTTP/1.1 200 OK Content-type: text/html Set-Cookie: name=value Other-header: other-header-value
这个 HTTP 响应会设置一个名为"name",值为"value"的 cookie。名和值在发送时都会经过 URL
编码。浏览器会存储这些会话信息,并在之后的每个请求中都会通过 HTTP 头部 cookie 再将它们发回服
务器,比如:GET /index.jsl HTTP/1.1 Cookie: name=value Other-header: other-header-value
这些发送回服务器的额外信息可用于唯一标识发送请求的客户端。
cookie 是与特定域绑定的。设置 cookie 后,它会与请求一起发送到创建它的域。这个限制能保证
cookie 中存储的信息只对被认可的接收者开放,不被其他域访问。因为 cookie 存储在客户端机器上,所以为保证它不会被恶意利用,浏览器会施加限制。 同时, cookie也不会占用太多磁盘空间。cookie 在浏览器中是由以下参数构成的(这些参数在 Set-Cookie 头部中使用分号加空格隔开 ) :
- 名称:唯一标识 cookie 的名称。 cookie 名不区分大小写,因此 myCookie 和 MyCookie 是同一
个名称。不过,实践中最好将 cookie 名当成区分大小写来对待,因为一些服务器软件可能这样
对待它们。 cookie 名必须经过 URL 编码。- 值:存储在 cookie 里的字符串值。这个值必须经过 URL 编码。
- 域: cookie 有效的域。发送到这个域的所有请求都会包含对应的 cookie。这个值可能包含子域(如
www.wrox.com
),也可以不包含(如.wrox.com 表示对 wrox.com 的所有子域都有效)。如果不明
确设置,则默认为设置 cookie 的域。- 路径:请求 URL 中包含这个路径才会把 cookie 发送到服务器。例如,可以指定 cookie 只能由
http://www.wrox.com/books/
访问,因此访问http://www.wrox.com/
下的页面就不会发送 cookie,即
使请求的是同一个域。- 过期时间:表示何时删除 cookie 的时间戳(即什么时间之后就不发送到服务器了)。默认情况下,
浏览器会话结束后会删除所有 cookie。不过,也可以设置删除 cookie 的时间。这个值是 GMT 格
式( Wdy, DD-Mon-YYYY HH:MM:SS GMT),用于指定删除 cookie 的具体时间。这样即使关闭
浏览器 cookie 也会保留在用户机器上。把过期时间设置为过去的时间会立即删除 cookie。- 安全标志:设置之后,只在使用 SSL 安全连接的情况下才会把 cookie 发送到服务器。例如,请
求https://www.wrox.com
会发送 cookie,而请求http://www.wrox.com
则不会 ;HTTP/1.1 200 OK Content-type: text/html Set-Cookie: name=value; expires=Mon, 22-Jan-07 07:10:24 GMT; domain=.wrox.com Other-header: other-header-value
这个头部设置一个名为"name"的 cookie,这个 cookie 在 2007 年 1 月 22 日 7:10:24 过期,对
www.wrox.com
及其他 wrox.com 的子域(如 p2p.wrox.com)有效。
安全标志 secure 是 cookie 中唯一的非名/值对,只需一个 secure 就可以了。比如:
HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value; domain=.wrox.com; path=/; secure
Other-header: other-header-value
这里创建的 cookie 对所有 wrox.com 的子域及该域中的所有页面有效(通过 path=/指定)。不过,
这个 cookie 只能在 SSL 连接上发送,因为设置了 secure 标志。
要知道,域、路径、过期时间和 secure 标志用于告诉浏览器什么情况下应该在请求中包含 cookie。
这些参数并不会随请求发送给服务器,实际发送的只有 cookie 的名/值对 ;在 JavaScript 中处理 cookie 比较麻烦,因为接口过于简单,只有 BOM 的 document.cookie 属性。 根据用法不同,该属性的表现迥异。要使用该属性获取值时, document.cookie 返回包含页面中所有 有效 cookie 的字符串(根据域、路径、过期时间和安全设置),以分号分隔,如下面的例子所示: name1=value1;name2=value2;name3=value3 所有名和值都是 URL 编码的,因此必须使用 decodeURIComponent()解码。 在设置值时,可以通过 document.cookie 属性设置新的 cookie 字符串。这个字符串在被解析后会 添加到原有 cookie 中。设置 document.cookie 不会覆盖之前存在的任何 cookie,除非设置了已有的 cookie。设置 cookie 的格式如下,与 Set-Cookie 头部的格式一样: name=value; expires=expiration_time; path=domain_path; domain=domain_name; secure 在所有这些参数中,只有 cookie 的名称和值是必需的。下面是个简单的例子: document.cookie = "name=Nicholas"; 这行代码会创建一个名为"name"的会话 cookie,其值为"Nicholas"。这个 cookie 在每次客户端向 服务器发送请求时都会被带上,在浏览器关闭时就会被删除。虽然这样直接设置也可以,因为不需要在 名称或值中编码任何字符,但最好还是使用 encodeURIComponent()对名称和值进行编码,比如: document.cookie = encodeURIComponent("name") + "=" + encodeURIComponent("Nicholas"); 要为创建的 cookie 指定额外的信息,只要像 Set-Cookie 头部一样直接在后面追加相同格式的字 符串即可: document.cookie = encodeURIComponent("name") + "=" + encodeURIComponent("Nicholas") + "; domain=.wrox.com; path=/";
因为在 JavaScript 中读写 cookie 不是很直观,所以可以通过辅助函数来简化相应的操作。与 cookie
相关的基本操作有读、写和删除。这些在 CookieUtil 对象中表示如下:class CookieUtil { static get(name) { let cookieName = `${encodeURIComponent(name)}=`, cookieStart = document.cookie.indexOf(cookieName), cookieValue = null; if (cookieStart > -1){ let cookieEnd = document.cookie.indexOf(";", cookieStart); if (cookieEnd == -1){ cookieEnd = document.cookie.length; } cookieValue = decodeURIComponent(document.cookie.substring(cookieStart + cookieName.length, cookieEnd)); } return cookieValue; } static set(name, value, expires, path, domain, secure) { let cookieText =`${encodeURIComponent(name)}=${encodeURIComponent(value)}` if (expires instanceof Date) { cookieText += `; expires=${expires.toGMTString()}`; } if (path) { cookieText += `; path=${path}`; } if (domain) { cookieText += `; domain=${domain}`; } if (secure) { cookieText += "; secure"; } document.cookie = cookieText; } static unset(name, path, domain, secure) { CookieUtil.set(name, "", new Date(0), path, domain, secure); } };
// 可以像下面这样使用这些方法: // 设置 cookie CookieUtil.set("name", "Nicholas"); CookieUtil.set("book", "Professional JavaScript"); // 读取 cookie alert(CookieUtil.get("name")); // "Nicholas" alert(CookieUtil.get("book")); // "Professional JavaScript" // 删除 cookie CookieUtil.unset("name"); CookieUtil.unset("book"); // 设置有路径、域和过期时间的 cookie CookieUtil.set("name", "Nicholas", "/books/projs/", "www.wrox.com", new Date("January 1, 2010")); // 删除刚刚设置的 cookie CookieUtil.unset("name", "/books/projs/", "www.wrox.com"); // 设置安全 cookie CookieUtil.set("name", "Nicholas", null, null, null, true); // 这些方法通过处理解析和 cookie 字符串构建,简化了使用 cookie 存储数据的操作
Web Storage 的定义了两个对象: localStorage 和 sessionStorage。 localStorage 是永久存储机制, sessionStorage 是跨会话的存储机制。这两种浏览器存储 API 提供了在浏览器中不受页面刷新影响而存储数据的两种方式。
Storage 类型用于保存名/值对数据,直至存储空间上限(由浏览器决定)。 Storage 的实例与其他
对象一样,但增加了以下方法 :
- clear():删除所有值;不在 Firefox 中实现。
- getItem(name):取得给定 name 的值
- key(index):取得给定数值位置的名称
- removeItem(name):删除给定 name 的名/值对
- setItem(name, value):设置给定 name 的值。
sessionStorage:
因为 sessionStorage 对象是 Storage 的实例,所以可以通过使用 setItem()方法或直接给属
性赋值给它添加数据。下面是使用这两种方式的例子:
// 使用方法存储数据
sessionStorage.setItem("name", "Nicholas");
// 使用属性存储数据
sessionStorage.book = "Professional JavaScript";
可以使用 getItem()或直接访问属性名来取得。下面是使用这两种方式的例子:
// 使用方法取得数据
let name = sessionStorage.getItem("name");
// 使用属性取得数据
let book = sessionStorage.book;
可以结合 sessionStorage 的 length 属性和 key()方法遍历所有的值:
for (let i = 0, len = sessionStorage.length; i < len; i++){ let key = sessionStorage.key(i); let value = sessionStorage.getItem(key); alert(`${key}=`${value}`); }
for (let key in sessionStorage){ let value = sessionStorage.getItem(key); alert(`${key}=${value}`); }
要从 sessionStorage 中删除数据,可以使用 delete 操作符直接删除对象属性,也可以使用
removeItem()方法。下面是使用这两种方式的例子:// 使用 delete 删除值 delete sessionStorage.name; // 使用方法删除值 sessionStorage.removeItem("book");
sessionStorage 对象应该主要用于存储只在
会话期间
有效的小块数据。如果需要跨会话持久存储
数据,可以使用 globalStorage 或 localStorage存储在 localStorage 中的数据会保留到通过 JavaScript 删除或者用户清除浏览器缓存。 localStorage 数据不受页面刷新影响,也不会因关闭窗口、标签页或重新启动浏览器而丢失。
存储事件
每当 Storage 对象发生变化时,都会在文档上触发 storage 事件。使用属性或 setItem()设置
值、使用 delete 或 removeItem()删除值,以及每次调用 clear()时都会触发这个事件。这个事件的
事件对象有如下 4 个属性。
domain:存储变化对应的域。
key:被设置或删除的键。
newValue:键被设置的新值,若键被删除则为 null。
oldValue:键变化之前的值。
可以使用如下代码监听 storage 事件:
window.addEventListener("storage",(event) => alert('Storage changed for ${event.domain}' ));
对于 sessionStorage 和 localStorage 上的任何更改都会触发 storage 事件,但 storage 事
件不会区分这两者。
IndexedDB和传统的关系型数据不同的是,它是一个key-value型的数据库。
value可以是复杂的结构体对象,key可以是对象的某些属性值也可以是其他的对象(包括二进制对象)。你可以使用对象中的任何属性做为index,以加快查找。
IndexedDB是自带transaction的,所有的数据库操作都会绑定到特定的事务上,并且这些事务是自动提交了,IndexedDB并不支持手动提交事务。
IndexedDB API大部分都是异步的,在使用异步方法的时候,API不会立马返回要查询的数据,而是返回一个callback。
异步API的本质是向数据库发送一个操作请求,当操作完成的时候,会收到一个DOM event,通过该event,我们会知道操作是否成功,并且获得操作的结果。
IndexedDB是一种 NoSQL 数据库,和关系型数据库不同的是,IndexedDB是面向对象的,它存储的是Javascript对象。
IndexedDB还有一个很重要的特点是其同源策略,每个源都会关联到不同的数据库集合,不同源是不允许访问其他源的数据库,从而保证了IndexedDB的安全性。
当我们进行一些较大的SPA页面开发时,我们会需要进行一些数据的本地存储。
当数据量不大时,我们可以通过SessionStorage或者LocalStorage来进行存储,但是当数据量较大,或符合一定的规范时,我们可以使用数据库来进行数据的存储。
在浏览器提供的数据库中,共有web sql和IndexedDB两种。相较于HTML5已经废弃的web sql来说,更推荐大家使用IndexedDB。
// 假如一开始有这样的数据需要存储
let data = [
{
id: 1,
name: "lzc",
age: 18,
sex: "man",
addTime: "2021-2-1"
},
{
id: 2,
name: "cb",
age: 19,
sex: "man",
addTime: "2021-2-2"
},
{
id: 3,
name: "kj",
age: 20,
sex: "woman",
addTime: "2021-2-3"
},
{
id: 4,
name: "juanjuan",
age: 21,
sex: "woman",
addTime: "2021-2-4"
}
]
/*
不同的浏览器对于IndexedDB有不同的实现,正常来说,我们可以使用window.indexedDB来获取到浏览器的indexedDB对象。但是对于某些浏览器来说,还没有使用标准的window.indexedDB,而是用带前缀的实现
使用IndexedDB第一步,就是创建或打开一个数据库。我们使用window.indexedDB.open(DBName)这个API来打进行操作。
*/
window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
if (!window.indexedDB) {
console.log("Your browser doesn't support a stable version of IndexedDB. Such and such feature will not be available.");
} else {
const request = window.indexedDB.open('people');
request.onupgradeneeded = function (event) {
}
request.onsuccess = function(event) {
console.log(request === event.target) // true
}
request.onerror = function(event) {
}
}
- 调用此接口时,如果当前数据库不存在,则会创建一个新的数据库;
- 当数据库建立连接时,会返回一个
IDBOpenDBRequest
对象。- 在连接建立成功时,会触发
onsuccess
事件,其中函数参数event
的target
属性就是request
对象。- 而在数据库创建或者版本更新时,会触发
onupgradeneeded
事件。
window.indexedDB.open
的第二个参数即为版本号。在不指定的情况下,默认版本号为1。
const request = window.indexedDB.open('people', 2);
在需要更新数据库的
schema(模式)
时,需要更新版本号。此时我们指定一个高于之前版本的版本号,就会触发onupgradeneeded
事件。类似的,当此数据库不存在时,也会触发此事件并且将版本更新到置顶版本。我们需要注意的是,版本号是一个
Unsigned long long
数字,这意味着它可以是一个非常大的整数。但是,它不能是一个小数,否则它将会被转为最近的整数,同时有可能导致onUpgradeneeded
事件不触发(bug)。
我们使用
createObjectStore
来创建一个存储空间。同时,使用createIndex
来创建它的索引。var request = window.indexedDB.open('people', 1); request.onupgradeneeded = function (event) { console.log(request === event.target) // true var db = event.target.result; var objectStore = db.createObjectStore('user', {keyPath: 'id', autoIncrement: true}); // 可以循环遍历,给对象数组或者对象的每个键都生成索引(每个索引里面的数据项的排序按索引的升序来排列,索引没有顺序可言,就按原始顺序) if (Object.prototype.toString.call(data) === "[object Array]" && data.length) { for (let i in data[0]) { objectStore.createIndex(i, i, { unique: false }) } } else if (Object.prototype.toString.call(data) === "[object object]") { for (let i in data) { objectStore.createIndex(i, i, { unique: false }) } } } request.onerror = function (event) { alert("Why didn't you allow my web app to use IndexedDB?!"); };
注:只能在
onupgradeneeded
回调函数中创建存储空间,而不能在数据库打开后的success
回调函数中创建。通过
createObjectStore
能够创建一个存储空间。接受两个参数:
- 第一个参数,存储空间的名称,即我们上面的
user
。- 第二个参数,指定存储的
keyPath
值为存储对象的某个属性,这个属性能够在获取存储空间数据的时候当做key值使用。autoIncrement
指定了key
值是否自增(当key值为默认的从1开始到2^53的整时)。而
createIndex
能够给当前的存储空间设置一个索引。它接受三个参数:
- 第一个参数,索引的名称。
- 第二个参数,指定根据存储数据的哪一个属性来构建索引。
- 第三个属性, options对象,其中属性
unique
的值为true
表示不允许索引值相等。
在IndexedDB
中,我们使用事务transaction
来进行数据库的操作。事务有三个模式(常量已经弃用)
readOnly
:只读。readwrite
:读写。versionchange
:数据库版本变化。
我们创建一个事务时,需要从上面选择一种模式,如果不指定的话,则默认为只读模式
。
const transaction = db.transaction(['user'], 'readwrite');
事务函数
transaction
的第一个参数为需要关联的存储空间,第二个可选参数为事务模式。与上面类似,事务成功时也会触发onsuccess
函数,失败时触发onerror
函数。
当存储空间objectStore初始化完成后,我们可以把数据放入存储空间中。可以使用 add()或 put()写入数据。这两个方法都接收一个参数, 即要存储的对象,并把对象保存到对象存储。
这两个方法只在对象存储中已存在同名的键时有区别。这 种情况下,add()会导致错误,而 put()会简单地重写该对象
。var request = window.indexedDB.open('people', 1); request.onsuccess = function (event) { var db = event.target.result; var transaction = db.transaction(['user'], 'readwrite'); var objectStore = transaction.objectStore('user'); for (let item of data) { objectStore.add(item); } }
注:
add
方法中的第二个参数key值是指定存储空间中的keyPath
值,如果data
中包含keyPath
值或者此值为自增值,那么可以略去此参数。// 1、createObjectStore第二个参数里加 { autoIncrement: true },这个时候key使用从1开始的自增数,遍历出来的数据和原始要存储的数据顺序一致;
// 2、objectStore.add(item, item.name); 指定了keyPath值,这时候遍历出来的数据顺序,可能和原始的不一样,这里是按照keyPath值字母语序来排列的;这里写不写{ autoIncrement: true }都可以;
// 3、createObjectStore第二个参数里加 { keyPath: "name" },这里不能再在objectStore.add(item)里面添加第二个参数,即使这里第二个参数添加的和createObjectStore里面的第二个参数的keyPath的一致;
上面的只影响user对象存储里面的排序,其它索引的排序只和自己的索引有关;
var request = window.indexedDB.open('people', 1); request.onsuccess = function (event) { var db = event.target.result; var transaction = db.transaction(['user'], 'readwrite'); var objectStore = transaction.objectStore('user'); var request = objectStore.get(1); request.onsuccess = function (event) { //打印搜索的结果 console.log(e.target.result); }; request.onerror = function (event) { // 错误处理! }; }
当你需要遍历整个存储空间中的数据时,你就需要使用到游标,与传统数据库查询不同,游标不会事先收集所有结果。相反,游标指向 第一个结果,并在接到指令前不会主动查找下一条数据
var request = window.indexedDB.open('people', 1); request.onsuccess = function (event) { var db = event.target.result; var transaction = db.transaction(['user'], 'readwrite'); var objectStore = transaction.objectStore('user'); var request = objectStore.openCursor(); request.onsuccess = function (event) { var cursor = event.target.result; if (cursor) { console.log(cursor.key, cursor.value); cursor.continue(); } }; request.onerror = function (event) { // 错误处理! }; }
使用游标时有一个需要注意的地方,当游标遍历整个存储空间但是并未找到给定条件的值时,仍然会触发
onsuccess
函数。
openCursor
和openKeyCursor
有两个参数:**openKeyCursor
**遍历出来的值里面没有value对象值,其它和openCursor
一致
- 第一个参数(占位时可以使用null),遍历范围,指定游标的访问范围。该范围通过一个
IDBKeyRange
参数的方法来获取。 遍历范围参数具体示例如下: ===>这种键范围只能比较其键能比较大小的对象存储或者only这种// 匹配值 key === 1 const singleKeyRange = IDBKeyRange.only(1);
// 匹配值 key >= 1,lowerBound用于设定结果集的下限,闭集 const lowerBoundKeyRange = IDBKeyRange.lowerBound(1);
// 匹配值 key > 1,lowerBound用于设定结果集的下限,包含该值,设置第二个参数为true时,变为开集 const lowerBoundOpenKeyRange = IDBKeyRange.lowerBound(1, true);
// 匹配值 key < 2,upperBound用于设定结果集的上限,包含该值,设置第二个参数为true时,变为开集 const upperBoundOpenKeyRange = IDBKeyRange.upperBound(2, true);
// 匹配值 key >= 1 && key < 2,指定一个左右范围 const boundKeyRange = IDBKeyRange.bound(1, 2, false, true);
index.openCursor(boundKeyRange).onsuccess = function(event) { const cursor = event.target.result; if (cursor) { // Do something with the matches. cursor.continue(); } };
- 第二个参数,默认值是next,遍历顺序,指定游标遍历时的顺序和处理相同id(keyPath属性指定字段)重复时的处理方法。改范围通过特定的小写字符串(IDBCursor的常量已经弃用)来获取。其中:
next
,从前往后获取所有数据(重复值也会遍历出来)prev
,从后往前获取所有数据(重复值也会遍历出来)nextunique
,从前往后获取数据(重复数据只取第一条,索引重复即认为重复,下同)prevunique
,从后往前获取数据(重复数据只取第一条)var request = window.indexedDB.open('test', 1); request.onsuccess = function (event) { var db = event.target.result; var transaction = db.transaction(['user'], 'readwrite'); var objectStore = transaction.objectStore('user'); var lowerBoundOpenKeyRange = IDBKeyRange.lowerBound(1, false); var request = objectStore.openCursor(lowerBoundOpenKeyRange, "prev"); request.onsuccess = function (event) { var cursor = event.target.result; if (cursor) { console.log(cursor.value); cursor.continue(); } }; request.onerror = function (event) { // 错误处理! }; }
在前面构建数据库时,我们创建了所有的索引。现在我们也可以通过索引来进行数据检索。他的本质还是通过之前获取数据的API来进行,只是将原来使用的
keyPath
属性转换成为了索引指定的属性。var request = window.indexedDB.open('test', 1); request.onsuccess = function (event) { var db = event.target.result; var transaction = db.transaction(['user'], 'readwrite'); var objectStore = transaction.objectStore('user'); // 使用索引 var index = objectStore.index('name'); // 第一种,get方法 index.get('a').onsuccess = function (event) { console.log(event.target.result); } // 第二种,普通游标方法 index.openCursor().onsuccess = function (event) { console.log('openCursor:', event.target.result.value); } // 第三种,键游标方法,该方法与第二种的差别为:普通游标带有value值表示获取的数据,而键游标没有 index.openKeyCursor().onsuccess = function (event) { console.log('openKeyCursor:', event.target.result.key); } }
注:put
方法不仅能够修改现有数据,也能够往存储空间中增加新的数据。
使用objectStore.put
方法时,参数为已存在的键,即为修改,为不存在的键时,即为增加;
这里不能像查询一样,通过store.index()
来修改索引,index上只有get方法
var transaction = db.transaction(['user'], 'readwrite'); var store = transaction.objectStore("user"); // 用put增加一条新数据 var request1 = store.put({ id: 5, name: "李志聪", age: 22, sex: "man", addTime: "2021-2-5" }); // 用put增加一条新数据,改sex描述===>是修改时,须带上这条数据的索引键 var request2 = store.put({ id: 2, name: "lzc", age: 18, sex: "男", addTime: "2021-2-2" }); request1.onsuccess = function (event) { }
var transaction = db.transaction(['user'], 'readwrite');
var store = transaction.objectStore("user");
var request = store.delete(对应某一条keyPath值);
request.onsuccess = function (event) {
}
// 比如在destoryed钩子里面可以关闭数据库
db.close();
IndexedDB
也受到浏览器同源策略的限制。
dexie.js:A Minimalistic Wrapper for IndexedDB
基于indexDB的Dexie数据库
// 下面配一个简单的演示
// 创建一个数据库 若数据库已存在则为打开
// 打开数据库时,会判断当前version值是否大于已经存在的version值,若大于则会upgrade即升到最高版本
var db = new Dexie("mydb");
// 设定版本,添加一个person表,里面有自增的id主键,有name和age两个索引
// 注意:不要像在SQL中那样声明所有列。只声明要索引的属性,即要在where(…)查询中使用的属性。
db.version(1).stores({
person: "++id, name, age"
})
// 增加(还是使用上面的数据进行存储)
for(let item of data) {
db.person.add(item)
}
// 修改
setTimeout(() => {
db.person.put({
id: 2,
name: "聪波",
age: 18,
sex: "boy",
addTime: "2021-2-2"
})
console.log(db.person.get(2)) // 一个promise对象
// 普通查询
db.person.get(2).then(res => {
console.log("查询的结果为===>", res)
})
}, 1000)
// 带条件查询
setTimeout(() => {
// adove、aboveOrEqual、below、belowOrEqual、between、equals
db.person.where("age").aboveOrEqual(18).toArray().then(res => {
console.log("年龄大于等于18岁的有===>", res)
})
}, 2000)
// 删除
setTimeout(() => {
// 删除第一条
db.person.delete(1);
db.person.get(1).then(res => {
console.log("第一条数据===>", res)
})
}, 3000)
setTimeout(() => {
db.close();
console.log("要是数据库没有关闭,下面将打印第三天数据");
db.person.get(3).then(res => {
console.log("第三条数据===>", res)
})
}, 4000)