[TypeScript]前端存储方案介绍及IndexedDB的使用封装

1,822 阅读10分钟

前端存储概念

  1. Web 应用允许使用浏览器提供的 API 将数据存储在客户端;
  2. 客户端存储遵守“同源策略”,不同的站点页面之间不能相互读取彼此的数据;
  3. 在同一个站点的不同页面之间,存储的数据是共享的;
  4. 数据的存储有效期可以是临时的,比如在页面刷新之后,我们的所有数据都会被清空以及关闭浏览器数据就销毁; 也可以是永久的要用到本地存储技术,就可以在客户端电脑上存储任意时间;
  5. 在使用数据存储是需要考虑安全问题,比如银行卡账号密码

附 - 同源策略

同源由协议、域名、端口三者来确定,如:
www.xxxx.com/ 协议http,域名xxxx.com,端口80默认端口。

www.xxxx.com/ 协议https,与上面不同源
www.yyyy.com/ 域名yyyy.com,与上面不同源
www.xxxx.com:81/ 端口81,与上面不同源

目前主流的前端本地化存储方案有以下3种:

1. Cookie

Cookie 本身并不是为了解决「在浏览器上存东西」而被发明,它的出现是为了解决 HTTP 协议无状态特性的问题

Cookie 的不足:

  • Cookie 存在安全问题
    • Cookie 在每次请求中都会被发送,如果不使用 HTTPS 并对其加密,其保存的信息很容易被窃取,导致安全风险。可利用 HttpOnly=true 设置无法通过 js 读取,从而减少被 XSS 攻击恶意读取 Cookie 信息的风险。
    • 举个例子,在一些使用 Cookie 保持登录态的网站上,如果 Cookie 被窃取,他人很容易利用你的 Cookie 来假扮成你登录网站。
    • 当然可以用 Session 配合 Cookie 来缓解这个问题,但是 Session 会占用额外的服务器资源。
    • Cookie 每次请求自动发送的特性还会导致 CSRF 攻击的安全风险。
  • Cookie 只允许存储 4kb 的数据。

Cookie 的应用领域:

最常见的就是用在广告中,用来跨站标记用户与跟踪用户行为,这样在你访问不同页面时,广告商也能知道是同一个用户在访问,从而实现后续的商品推荐等功能。
实现手段:第三方 Cookie,需设置 SameSite 属性为 None。

Cookie 和 Session 的异同点:

  • Cookie 和 Session 都是普遍用来跟踪浏览用户身份的会话方式。
  • Session 鉴权需要借助 Cookie 来存储此 Session 的唯一标识信息 SessionID。SessionID 是连接 Cookie 和 Session 的一道桥梁。
  • 因为这两种方式鉴权都需要借助 Cookie 来存储用户标识,因此容易受到CRSF(跨站点请求伪造,Cross-Site Request Forgery)攻击的安全风险。
  • 存储方式不同。 Cookie 数据存放在客户端,Session 数据放在服务器端。因此 Session 相比 Cookie 数据安全性更好,但同时更占用服务器资源。
  • 存储大小不同。 单个 Cookie 保存的数据不能超过 4K,很多浏览器都限制一个域名最多保存 50 个 Cookie。 Session 可存储数据远高于 Cookie,但是当访问量过多,会占用过多的服务器资源。
  • 存取值的类型不同。 Cookie 只支持存字符串数据,想要设置其他类型的数据,需要将其转换成字符串,Session 可以存任意数据类型。

附 - CRSF(跨站点请求伪造,Cross-Site Request Forgery)

image.png
图 CRSF 原理示意图

CSRF 攻击几种常见类型:

  1. GET类型的CSRF:攻击站点利用 JSONP 方式控制访问用户向被攻击站点发送跨域请求。
  2. POST类型的CSRF:攻击站点利用 method=POST 的自动提交表单控制访问用户向被攻击站点发送跨域请求。

CSRF的特点:

  1. 攻击一般发起在第三方网站,而不是被攻击的网站。被攻击的网站无法防止攻击发生。
  2. 攻击利用受害者在被攻击网站的登录凭证,冒充受害者提交操作;而不是直接窃取数据。
  3. 整个过程攻击者并不能获取到受害者的登录凭证,仅仅是“冒用”。
  4. 跨站请求可以用各种方式:图片URL、超链接、CORS、Form提交等等。部分请求方式可以直接嵌入在第三方论坛、文章中,难以进行追踪。

防御 CSRF 攻击主要有 3 种策略:

  1. 同源检测。验证 HTTP Referer / Origin Header 字段,
  2. 请求地址中添加 token 并验证
  3. 在 HTTP 头中自定义属性并验证

2. Web Storage(LocalStorage + SessionStorage)

在 Web 本地存储场景上,Cookie 的使用受到种种限制,最关键的就是存储容量太小和数据无法持久化存储。在 HTML 5 的标准下,出现了 LocalStorage 和 SessionStorage 供我们使用。

Cookie、LocalStorage 以及 SessionStorage 的异同点:

生命周期作用域存储容量存储位置
Cookie默认保存在内存中,随浏览器关闭失效(如果设置过期时间,在到过期时间后失效)Domain 和 Path属性用来指示其作用域,Domain本身以及Domain下的所有子域名可以共享数据4KB保存在客户端,每次请求时都会带上。
LocalStorage理论上永久有效的,除非主动清除。同一个浏览器内,同源文档之间可以共享数据。4.98MB(不同浏览器情况不同,safari 2.49M)保存在客户端,不与服务端交互。
SessionStorage仅在当前网页会话下有效,关闭页面或浏览器后会被清除。同一浏览器、同一窗口的同源文档才能共享数据。4.98MB(部分浏览器没有限制)保存在客户端,不与服务端交互。

基于 TypeScript 对 LocalStorage 的功能封装:

以下是本人针对 LocalStorage 无法存储对象数据类型的一些问题寻求的解决方案,最终封装可实现便捷地对 LocalStorage 中存储的某个 key 进行读取、存储、清除操作,同时兼容了对某些对象类型数据的存储。

/**
 * @title LocalStore
 * @description e.g., const lsUser = new LocalStore('user'); lsUser.get(); lsUser.set('name'); 
 */

import { jsonStringify, jsonParse, isNull } from '@/utils/checkType'

export class LocalStore {
    key: string
    value: any
    constructor(key: string) {
        this.key = key
    }
    _stringifyValue(value: any = this.value): string {
        return jsonStringify(value)
    }
    _parseValue(text: string): any {
        return this.value = jsonParse(text)
    }
    set(value: any): boolean {
        const finalKey = this.key
        if (!finalKey) return false
        this.value = value
        const text = this._stringifyValue(value)
        localStorage.setItem(this.key, text)
        return true
    }
    get(key?: string): any {
        const finalKey = key ?? this.key
        if (!finalKey) return null
        const localStoreVal = localStorage.getItem(finalKey)
        if (isNull(localStoreVal)) return localStoreVal
        return this._parseValue(localStoreVal)
    }
    clear(key?: string): boolean {
        const finalKey = key ?? this.key
        if (!finalKey) return false
        localStorage.removeItem(finalKey)
        return true
    }
}

以上采用了_stringifyValue_parseValue方法就是是为了一定程度上解决 LocalStorage 不支持存储对象的问题。其实现基于 JSON.parse / JSON.stringify 方法,解决了原生方法不支持转换undefined、NaN、Infinity和-Infinity等情况的处理,但仍不支持对Function、symbol、Date、循环引用等问题转换的处理,原理如下:

// JSON.stringify问题:
// 对象中有时间类型的时候,序列化之后会变成字符串类型。
// 对象中有undefined和Function类型以及symbol值数据的时候,序列化之后会直接丢失。
// 对象中有NaN、Infinity和-Infinity的时候,序列化之后会显示null。
// 对象循环引用的时候,会直接报错。
export const jsonStringify = (value: any): string => JSON.stringify(value, (key, value) => {
    if (isUndefined(value)) return ''
    if (isNaNNum(value) || isInfinity(value)) return `${value}`
    return value
})

export const jsonParse = (text: string): any => JSON.parse(text, (key, value) => {
    if (['-Infinity', 'Infinity', 'NaN'].includes(value)) return parseFloat(value);
    return value
})

LocalStorage 和 SessionStorage 的不足:

  • 只能存入字符串,无法直接存对象。需要借助 JSON.parse / JSON.stringify 。
  • 存储空间仍然有限,无法存储大体量的数据。

3. IndexedDB

IndexedDB 基本概念

IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。从名字中就可以看出,它是一个数据库。其实在前端存储方案中还有一种数据库存储方案 WebSQL ,但由于操作繁琐且是一种关系型数据库,逐渐被废弃而由 IndexedDB 取代。
IndexedDB 是一个事务型数据库系统,类似于基于 SQL 的 RDBMS。然而,不像 RDBMS 使用固定列表,IndexedDB 是一个基于 JavaScript 的面向对象数据库。IndexedDB 允许您存储和检索用键索引的对象;可以存储结构化克隆算法支持的任何对象。您只需要指定数据库模式,打开与数据库的连接,然后检索和更新一系列事务。

IndexedDB 特点

  1. 非关系型数据库
  2. 持久化存储
  3. 异步操作
  4. 支持事务
  5. 同源策略
  6. 存储容量大

IndexedDB 优势

  1. 储存空间足够大
  2. 原生支持 JS 对象类型,能更好的储存数据
  3. 以数据库的形式储存数据,数据管理更规范
  4. 使用 IndexedDB 执行的操作是异步执行的,可以很好的避免阻塞应用程序。

IndexedDB 推荐使用的基本模式:

  1. 打开数据库。
  2. 在数据库中创建一个对象仓库(object store)。
  3. 启动一个事务,并发送一个请求来执行一些数据库操作,像增加或提取数据等。
  4. 通过监听正确类型的 DOM 事件以等待操作完成。
  5. 在操作结果上进行一些操作(可以在 request 对象中找到)。

IndexedDB 使用及基本操作封装实现(基于 TypeScript )

1. 创建或打开数据库

执行indexedDB.open打开数据库会创建并返回一个请求,该请求将触发 3 种事件:

  • onsuccess:数据库打开成功或者创建成功后的回调。
  • onerror:数据库打开或创建失败后的回调。
  • onupgradeneeded:当数据库版本有变化时的回调。经测试,chrome 浏览器将在初次创建该数据库时触发此事件,后续需要触发此事件必须手动更新数据库版本。并且创建存储库**createStore**的操作必须在此事件触发的回调中执行(在onsuccess中执行会报错),意味着要在应用中使用 IndexDB 建议在初始化过程中就创建好应用所需的存储库。

以下对打开数据库过程做了 Promise 封装,并做了 reqest timeout 处理。可通过reqDB().then(db => {})的方式来调用。
关键处理:

  1. reqDBFn函数中利用闭包的方式存储了reqOpenDB变量,可保证在后续频繁调用reqDB时,如果当前还有reqOpenDB正在请求将不再触发indexedDB.open方法重复执行导致生成多个请求。
  2. onupgradeneeded事件回调中,执行了createAllStore创建初始化配置好的所有 store,并在创建完后利用setTimeout创建了一次事件循环 tick ,在下一次事件循环中才resolve(db),这样设置主要是为了给创建好的 db 一定缓冲时间有利于后续通过事务获取刚创建的 store 对象时不会报错(经测试,若在onupgradeneeded事件触发后直接resolve(db),通过此 db 建立事务获取刚创建的 store 对象将会报错该 store 不存在)。
// 发送IndexedDB打开请求
const reqDBFn = ({
    name = DB_NAME,
    version = DB_VERSION,
    timeout = 1500,
}: InitDBInfo = {}): ((version_?: number) => Promise<IDBDatabase>) => {
    // 初始化 打开数据库请求
    let reqOpenDB: IDBOpenDBRequest | null;
    return (version_?: number) =>
        new Promise((resolve, reject) => {
            //  兼容浏览器
            const indexedDB = compatIndexedDB();
            // 打开数据库,若没有则会创建
            if (!reqOpenDB) reqOpenDB = indexedDB.open(name, version_ ?? version);
            reqOpenDB.onsuccess = (evt) => {
                const db = (evt.currentTarget as IDBOpenDBRequest).result;
                reqOpenDB = null;
                resolve(db); // 数据库对象
            };
            reqOpenDB.onerror = (evt) => {
                reqOpenDB = null;
                reject((evt.target as IDBOpenDBRequest).error);
            };
            reqOpenDB.onupgradeneeded = (evt) => {
                const db = (evt.currentTarget as IDBOpenDBRequest).result;
                reqOpenDB = null;
                createAllStore(db);
                setTimeout(() => {
                    resolve(db)
                }, timeout * 1 / 2)
            };
            setTimeout(() => {
                reqOpenDB = null;
                reject('Request IndexedDB Timeout!');
            }, timeout);
        });
};
const reqDB = reqDBFn();

2. 创建 ObjectStore

利用db.createObjectStore来创建 store ,需要传入 store 名称storeName和其主键相关配置keyPathOptions参数,创建完成后可通过store.createIndex方法创建索引。
创建 ObjectStore 是同步的过程,但正如我前面所说的,虽然是同步,但是刚创建完就直接通过 db 创建事务来获取该 store 是会报错的,原因不明,欢迎大神指点。
另外前面也同样提到,创建 ObjectStore 必须在数据库更新时创建,即 reqOpenDB.onupgradeneededdb.onversionchange时创建。

// 创建store(ObjectStore)
const createStore = (
    db: IDBDatabase,
    storeName: string,
    keyPathOptions: IDBObjectStoreParameters,
    indexList?: {
        name: string;
        options?: IDBIndexParameters;
    }[]
) => {
    // `createStore` must be called when the database is `onupgradeneeded` or `onversionchange`.
    if (db.objectStoreNames.contains(storeName)) return false;
    const store = db.createObjectStore(storeName, keyPathOptions);
    indexList?.forEach(({ name, options }) => {
        store.createIndex(name, name, options);
    });
    return store;
};

3. 删除 ObjectStore

原理同创建 ObjectStore,也是同步的过程。

// 删除store(ObjectStore)
const deleteStore = (db: IDBDatabase, storeName: string) => {
    if (!db.objectStoreNames.contains(storeName)) return false;
    db.deleteObjectStore(storeName);
    console.info(`[indexedDB] Store ${storeName} is deleted!`);
    return true;
};

4. 建立事务获取store(ObjectStore)

通过db.transaction来创建事务并通过tx.objectStore(storeName),利用此事务来操作 store 对象。

// 建立事务获取store(ObjectStore)
const getStore = (
    db: IDBDatabase,
    storeName: string,
    mode: IDBTransactionMode
) => {
    let tx: IDBTransaction;
    try {
        tx = db.transaction(storeName, mode);
    } catch (err) {
        throw new Error(
            `[IndexDB] Store named ${storeName} cannot be found in the database`
        );
    }
    return tx.objectStore(storeName);
};

5. 基于事务获取的 store 实现对数据的基本操作

5.1 增

store.add(data),data 数据中需包含 store 应有的主键 keyPath。store 应为getStore(db, storeName, 'readwrite')该事务模式获取得到,事务模式应为:readwrite。

// 增
export const add = (store: IDBObjectStore, data: any) =>
    new Promise((resolve, reject) => {
        const req = store.add(data);
        req.onsuccess = resolve;
        req.onerror = reject;
    });

5.2 改

store.put(data),实现方式同上。

// 改
export const put = (store: IDBObjectStore, data: any) =>
    new Promise((resolve, reject) => {
        const req = store.put(data);
        req.onsuccess = resolve;
        req.onerror = reject;
    });

5.3 根据主键值查询

store.get(keyPathValue),keyPathValue 为主键值
store.getAll(),查询所有数据

// 根据 主键 keyPath 查询
export const get = (
    store: IDBObjectStore,
    keyPathValue?: IDBValidKey | IDBKeyRange
): Promise<any> =>
    new Promise((resolve, reject) => {
        const req = keyPathValue ? store.get(keyPathValue) : store.getAll();
        req.onsuccess = (evt) => {
            resolve((evt.target as IDBRequest).result);
        };
        req.onerror = reject;
    });

5.4 利用游标根据索引 Index 查询

store.index(indexName)指定索引
store.index(indexName).openCursor(indexValue, direction)开启游标索引,其中 indexValue 可以为 IDBValidKey 或 IDBKeyRange 类型,direction 指定游标查询的方向。
游标依次遍历每一行数据,每成功一次都会触发查询请求成功回调,在回调中通过cursor.value获取到当前游标的数据,存储在 list 中,最后将满足查询要求的所有数据 list 返回。

// 根据 索引 Index 查询(游标)
export const getByIndex = (
    store: IDBObjectStore,
    indexName: string,
    indexValue: IDBValidKey | IDBKeyRange,
    direction?: IDBCursorDirection
) =>
    new Promise((resolve, reject) => {
        const req = store.index(indexName).openCursor(indexValue, direction);
        const list: any[] = [];
        req.onsuccess = (evt) => {
            const cursor: IDBCursorWithValue = (evt.target as IDBRequest)
                .result;
            if (cursor) {
                list.push(cursor.value);
                cursor.continue();
            } else {
                resolve(list);
            }
        };
        req.onerror = reject;
    });

5.5 根据主键值删除

store.delete(keyPathValue),实现方式同 5.3 根据主键值查询

// 根据 主键 keyPath 删除
export const remove = (
    store: IDBObjectStore,
    keyPathValue: IDBValidKey | IDBKeyRange
) =>
    new Promise((resolve, reject) => {
        const req = store.delete(keyPathValue);
        req.onsuccess = (evt) => {
            resolve((evt.target as IDBRequest).result);
        };
        req.onerror = reject;
    });

5.6 利用游标根据索引 Index 删除

调用cursor.delete()实现数据删除,其它原理同 5.4 利用游标根据索引 Index 查询

// 根据 索引 Index 删除(游标)
export const removeByIndex = (
    store: IDBObjectStore,
    indexName: string,
    indexValue: IDBValidKey | IDBKeyRange,
    direction?: IDBCursorDirection
) =>
    new Promise((resolve, reject) => {
        const req = store.index(indexName).openCursor(indexValue, direction);
        req.onsuccess = (evt) => {
            const cursor: IDBCursorWithValue = (evt.target as IDBRequest)
                .result;
            if (cursor) {
                const reqDelete = cursor.delete();
                reqDelete.onerror = () => {
                    console.error(
                        `[IndexDB] Failed to delete the record ${cursor}`
                    );
                };
                reqDelete.onsuccess = () => { };
                cursor.continue();
            } else {
                resolve({ delete: 'done' });
            }
        };
        req.onerror = reject;
    });

6. 若干优化注意点:(important)

IndexedDB 可以通过限制事务的作用域和模式来加速数据库访问。

  • 定义作用域时,只指定你用到的对象仓库。这样,你可以同时运行多个不含互相重叠作用域的事务。
  • 只在必要时指定 readwrite 事务。可以同时执行多个 readonly 事务,哪怕它们的作用域有重叠;但对于在一个对象仓库上你只能运行一个 readwrite 事务。

IndexedDB 初始化数据库封装实现(基于 TypeScript )

基于前面封装的基本操作,初始化数据库setupDB函数实现原理如下:

  1. 利用闭包存储_db变量,返回一个异步函数,该异步函数隐式地返回一个 Promise 对象,PromiseResult 为数据库_db对象。
  2. 如果闭包中的_db已经存在则无需请求打开数据库,直接返回该数据库对象.
  3. 如果闭包中的_db不存在则基于上述 1. 创建或打开数据库 封装的reqDB方法请求打开数据库建立连接,获取到_db对象。
  4. 获取到的_db对象为其添加成员方法db.deleteStoredb.getStore方便调用。
  5. 同时监听 db 对象的相关事件钩子:
  • onerror:监听 db 操作过程中发生的错误。同时,IndexedDB 的错误事件遵循冒泡机制。错误事件都是针对产生这些错误的请求的,然后事件冒泡到事务,然后最终到达数据库对象。因此,可以通过**db.onerror**监听到所有的错误。
  • onversionchange:监听 db 版本发生改变。
  • onabort:监听 db 在应用运行过程中中断。
  • onclose:监听 db 关闭。
  1. 监听浏览器 onbeforeunload 事件,在其触发时关闭数据库的连接。
const setupDB = (() => {
    let _db: InitDB | null;
    const addDBListener = (db: InitDB) => {
        // `addDBListener` must be called when the database is opened.
        db.onerror = (ev) => console.error(ev.target);
        db.onversionchange = (ev) => {
            _db = ev.currentTarget as InitDB;
            addDBFcn(_db);
            createAllStore(_db);
        };
        db.onabort = (ev) => {
            _db = null;
        };
        db.onclose = (ev) => {
            _db = null;
        };
    };
    const addDBFcn = (db: InitDB) => {
        db.deleteStore = (storeName) =>
            deleteStore(db as IDBDatabase, storeName);
        db.getStore = (storeName, mode) =>
            getStore(db as IDBDatabase, storeName, mode);
    };
    return async () => {
        if (_db) return _db;
        return await reqDB().then((db) => {
            const iDb = db as InitDB;
            addDBFcn(iDb);
            _db = iDb;
            addDBListener(iDb);
            window.onbeforeunload = () => iDb.close();
            return iDb;
        });
    };
})();
export default setupDB;

IndexedDB 初始化 store 封装实现(基于 TypeScript )

基于上述实现,可以很快实现一个基础的 store ,实现一个基于主键对 store 中数据的增删改查。以下均基于 Promise 实现了对 store 数据的读取,写入,删除以及 store 的删除操作。

export default function initStore<T extends Record<KeyPath, string | number>, KeyPath extends string = string>(
    storeName: keyof typeof DbStoreName,
) {
    async function getData(keyPathValue: T[KeyPath]) {
        return await setupDB().then(async db => {
            return (await get(db.getStore(storeName, 'readonly'), keyPathValue)) as T;
        })
    }
    async function putData(data: T) {
        return await setupDB().then(async db => {
            return await put(db.getStore(storeName, 'readwrite'), data);
        })
    }
    async function removeData(keyPathValue: T[KeyPath]) {
        return await setupDB().then(async db => {
            return await remove(db.getStore(storeName, 'readwrite'), keyPathValue);
        })
    }
    async function deleteStore() {
        return await setupDB().then(db => {
            return db.deleteStore(storeName);
        })
    }
    return { getData, putData, removeData, deleteStore };
}

IndexedDB 在 Vue 项目中的实践

利用 IndexedDB 实现一个前端表单填写离开时自动保存的案例,实现如下:

  1. 可以将此场景业务逻辑封装为一个 hook (在 Vue 中称为 Composition API ),如下:
    1. 首先基于上述实现的initStore初始化一个 store ,指定 storeName='IDB_FORM_USER_VERIFY',keyPath='formId'(主键名),以及存储的数据类型 T 。
    2. get方法即通过store.getData获取其主键为指定值的该行数据。
    3. remove方法即通过store.removeData移除其主键为指定值的该行数据。
    4. save方法即通过store.putData存入传入的改行数据。
    5. 此外,监听了window.onbeforeunloadVueComponent.onUnmounted事件,在事件触发时自动保存数据。
export default function useIdbFormUserVerify<T extends { formId: number }>(rowData: T | Ref<T>) {
    // keyPath `formId`
    const store = initStore<T, 'formId'>('IDB_FORM_USER_VERIFY')

    const get = async () => await store.getData(unref(rowData).formId).then((val) => {
        if (!val?.formId) return
        const { formId, ...form } = val
        return form
    })

    const remove = async () => await store.removeData(unref(rowData).formId).then(() => {
        console.log('remove success!')
    })


    const save = async () => await store.putData(unref(rowData)).then(() => {
        console.log('save success!')
    })

    window.addEventListener('beforeunload', save)
    onUnmounted(() => {
        save()
        window.removeEventListener('beforeunload', save)
    })

    return { get, remove, save }
}
  1. 在表单组件中,调用此 hook。以下为 FormUserVerify 组件中<script setup lang="ts"></script>中截取的关于 indexedDb 的部分代码。
    1. 在组件 setup 时获取 indexedDB 中的数据,并覆盖 form 中的数据(changeForm)。
    2. 在表单提交成功时,移除在 indexedDB 中存储的此条数据
import useIdbFormUserVerify from '@/store/indexedDB/useIdbFormUserVerify'
// ... ...
// indexedDb
const saveForm = computed(() => ({ formId: 1, ...form }))
const { get, remove } = useIdbFormUserVerify(saveForm)
get().then(val => {
    console.log(val)
    if (val) changeForm(val)
})

const onSubmit = () => {
    if (valid()) {
        dVerifyUser(format('userRole')).then(debounce(() => { remove(); router.go(-1) }, 500)).catch((err) => Toast(err.msg))
    }
}

基于以上就完美的实现了一个自动保存表单内容的 IndexedDB 应用。


🍻以上基于本人查阅的资料和一些个人理解实践,希望对大家有帮助。如有理解错误的地方,还望大神指出!

📃参考资料
zhuanlan.zhihu.com/p/505031430
juejin.cn/post/712386…
tech.meituan.com/2018/10/11/…
juejin.cn/post/684490…
juejin.cn/post/684490…
developer.mozilla.org/zh-CN/docs/…
juejin.cn/post/702690…