IndexedDB 实战:封装一个通用工具类,搞定所有本地存储需求

IndexedDB 完全指南:从基础使用到封装实战(含完整 API 与最佳实践)

在前端开发中,本地存储是实现数据持久化、提升用户体验的核心技术之一。常见的浏览器存储方案各有优劣,而 IndexedDB 作为一种高性能的本地数据库,凭借其大容量、异步操作、支持复杂数据类型等特性,成为处理大量结构化数据的首选方案。本文将系统讲解 IndexedDB 的核心概念、基础用法、完整封装以及最佳实践,弥补基础用法的遗漏点,帮助开发者快速上手并灵活运用。

一、前端存储方案对比(补充细节)

在深入 IndexedDB 之前,先明确它与其他存储方案的差异,方便根据场景选型:

存储方案 存储容量 时效性 数据类型 核心特性 适用场景
LocalStorage 5MB-10MB(浏览器差异) 永久存储(手动清除) 仅字符串(需序列化) 同步操作,简单键值对 少量用户配置、token 存储
SessionStorage 5MB-10MB(浏览器差异) 会话级(页面关闭清除) 仅字符串(需序列化) 同步操作,页面隔离 临时表单数据、会话状态
Cookie 4KB 可设置过期时间(默认会话) 仅字符串 随请求携带,同域共享 用户身份标识、跟踪统计
IndexedDB 无固定上限(依赖设备存储空间,通常 >250MB) 永久存储(手动清除) 字符串、数字、对象、二进制数据(Blob/ArrayBuffer) 异步操作,事务支持,索引查询 大量结构化数据、离线应用、文件缓存

关键补充

  • LocalStorage/SessionStorage 同步操作会阻塞主线程,处理大量数据时可能导致页面卡顿;IndexedDB 异步操作不会阻塞 UI,性能更优。
  • Cookie 每次请求都会携带到服务器,增加带宽消耗;IndexedDB 仅在本地操作,不与服务器交互。
  • IndexedDB 支持事务(Transaction),确保数据操作的原子性(要么全部成功,要么全部失败),这是其他存储方案不具备的核心优势。

二、IndexedDB 核心概念(补充基础认知)

在使用前需理解以下核心术语,避免混淆:

  1. 数据库(Database) :IndexedDB 的顶层容器,每个数据库有唯一名称和版本号,版本号升级时会触发 onupgradeneeded 事件。
  2. 对象仓库(Object Store) :类似关系型数据库的"表",用于存储结构化数据,每个对象仓库有唯一主键(keyPath)。
  3. 事务(Transaction) :所有数据操作(增删查改)必须通过事务执行,支持 readonly(只读)和 readwrite(读写)两种模式,确保数据一致性。
  4. 索引(Index) :基于对象仓库的某个属性创建,用于快速查询数据(类似数据库索引),支持唯一索引(unique: true)和非唯一索引。
  5. 主键(KeyPath) :对象仓库中每条数据的唯一标识,可手动指定(如 deviceId)或自动生成(autoIncrement: true)。
  6. 游标(Cursor) :用于遍历对象仓库中的数据,支持条件筛选、排序等复杂查询(基础用法中未提及,下文补充)。

三、IndexedDB 基础用法(完善遗漏 API 与场景)

1. 环境兼容处理

不同浏览器对 IndexedDB 的前缀支持不同,需先做兼容处理(补充完整前缀):

javascript 复制代码
// 完整兼容方案
const indexedDB = window.indexedDB || 
                  window.webkitIndexedDB || 
                  window.mozIndexedDB || 
                  window.msIndexedDB; // IE 浏览器支持
const IDBTransaction = window.IDBTransaction || 
                       window.webkitIDBTransaction || 
                       window.mozIDBTransaction;
const IDBCursor = window.IDBCursor || 
                  window.webkitIDBCursor || 
                  window.mozIDBCursor;

// 检测浏览器是否支持
if (!indexedDB) {
  console.error('当前浏览器不支持 IndexedDB,请升级浏览器');
}

2. 打开/创建数据库

使用 indexedDB.open(dbName, version) 打开数据库,版本号必须为正整数,升级版本时会触发 onupgradeneeded 事件(补充错误处理细节):

javascript 复制代码
// 打开数据库(数据库名:deviceDB,版本号:2)
const request = indexedDB.open('deviceDB', 2);

// 数据库打开失败(如权限不足、存储满)
request.onerror = (event) => {
  console.error('数据库打开失败:', event.target.error.message);
};

// 数据库打开成功
request.onsuccess = (event) => {
  const db = event.target.result;
  console.log('数据库打开成功,版本号:', db.version);
  
  // 操作完成后关闭数据库(避免资源占用)
  // db.close();
};

// 数据库创建或版本升级时触发(仅一次)
request.onupgradeneeded = (event) => {
  const db = event.target.result;
  console.log('数据库升级,旧版本:', event.oldVersion, '新版本:', event.newVersion);
  
  // 此处可执行建表、删表、创建索引等操作
};

3. 操作对象仓库(补充完整场景)

(1)创建对象仓库(表)

onupgradeneeded 事件中创建对象仓库,支持两种主键模式(补充自动生成主键示例):

javascript 复制代码
request.onupgradeneeded = (event) => {
  const db = event.target.result;
  
  // 方式1:手动指定主键(如 deviceId)
  if (!db.objectStoreNames.contains('cameraDevice')) {
    const cameraStore = db.createObjectStore('cameraDevice', { 
      keyPath: 'deviceId' // 主键字段
    });
    
    // 创建索引:基于 deviceName(非唯一)、status(唯一)
    cameraStore.createIndex('idx_deviceName', 'deviceName', { unique: false });
    cameraStore.createIndex('idx_status', 'status', { unique: false });
  }
  
  // 方式2:自动生成主键(autoIncrement: true)
  if (!db.objectStoreNames.contains('user')) {
    const userStore = db.createObjectStore('user', { 
      autoIncrement: true // 主键自动递增(默认字段名:id)
    });
    userStore.createIndex('idx_username', 'username', { unique: true }); // 用户名唯一索引
  }
  
  // 删除旧表(如需)
  if (db.objectStoreNames.contains('oldDevice')) {
    db.deleteObjectStore('oldDevice');
    console.log('旧表已删除');
  }
};
(2)新增数据(补充批量新增与重复主键处理)

通过 add() 方法新增数据,主键重复会报错 ;若需覆盖重复数据,可使用 put() 方法(后续讲解):

javascript 复制代码
// 数据库打开成功后执行新增
request.onsuccess = (event) => {
  const db = event.target.result;
  
  // 开启读写事务(指定操作的表名)
  const transaction = db.transaction(['cameraDevice'], 'readwrite');
  const cameraStore = transaction.objectStore('cameraDevice');
  
  // 单个新增
  cameraStore.add({
    deviceId: 'cam_888',
    deviceName: '成都匝道摄像头',
    status: 1, // 1:在线,0:离线
    resolution: '1080P',
    createTime: new Date().toISOString()
  });
  
  // 批量新增(补充)
  const batchData = [
    { deviceId: 'cam_999', deviceName: '北京路口摄像头', status: 1, resolution: '4K', createTime: new Date().toISOString() },
    { deviceId: 'cam_777', deviceName: '上海隧道摄像头', status: 0, resolution: '720P', createTime: new Date().toISOString() }
  ];
  batchData.forEach(data => cameraStore.add(data));
  
  // 事务完成回调
  transaction.oncomplete = () => {
    console.log('数据新增成功');
    db.close(); // 关闭数据库
  };
  
  // 事务失败回调(如主键重复)
  transaction.onerror = (event) => {
    console.error('数据新增失败:', event.target.error.message);
    db.close();
  };
};
(3)查询数据(补充游标查询、条件筛选)

IndexedDB 支持主键查询、索引查询、全量查询和游标查询,满足不同场景需求:

ini 复制代码
request.onsuccess = (event) => {
  const db = event.target.result;
  const transaction = db.transaction(['cameraDevice'], 'readonly');
  const cameraStore = transaction.objectStore('cameraDevice');
  
  // 1. 主键查询(精准匹配)
  const keyRequest = cameraStore.get('cam_888');
  keyRequest.onsuccess = () => {
    console.log('主键查询结果:', keyRequest.result);
  };
  
  // 2. 索引查询(基于索引字段匹配)
  const indexRequest = cameraStore.index('idx_deviceName').get('北京路口摄像头');
  indexRequest.onsuccess = () => {
    console.log('索引查询结果:', indexRequest.result);
  };
  
  // 3. 全量查询(获取所有数据)
  const allRequest = cameraStore.getAll();
  allRequest.onsuccess = () => {
    console.log('全量查询结果:', allRequest.result);
  };
  
  // 4. 游标查询(补充:遍历数据、条件筛选、排序)
  const cursorRequest = cameraStore.openCursor(null, 'next'); // next:正序,prev:倒序
  const onlineCameras = [];
  
  cursorRequest.onsuccess = (event) => {
    const cursor = event.target.result;
    if (cursor) {
      // 条件筛选:只保留在线设备(status: 1)
      if (cursor.value.status === 1) {
        onlineCameras.push(cursor.value);
      }
      cursor.continue(); // 继续遍历下一条
    } else {
      console.log('游标查询(在线设备):', onlineCameras);
    }
  };
  
  transaction.oncomplete = () => {
    db.close();
  };
};
(4)修改数据(补充批量修改、索引定位修改)

使用 put() 方法修改数据,存在主键则更新,不存在则新增 (与 add() 的核心区别):

ini 复制代码
request.onsuccess = (event) => {
  const db = event.target.result;
  const transaction = db.transaction(['cameraDevice'], 'readwrite');
  const cameraStore = transaction.objectStore('cameraDevice');
  
  // 方式1:主键定位修改
  const getRequest = cameraStore.get('cam_777');
  getRequest.onsuccess = () => {
    const data = getRequest.result;
    data.status = 1; // 状态改为在线
    data.resolution = '1080P'; // 更新分辨率
    cameraStore.put(data); // 提交修改
  };
  
  // 方式2:索引定位修改(补充)
  const indexGetRequest = cameraStore.index('idx_deviceName').get('成都匝道摄像头');
  indexGetRequest.onsuccess = () => {
    const data = indexGetRequest.result;
    data.createTime = new Date().toISOString(); // 更新时间
    cameraStore.put(data);
  };
  
  transaction.oncomplete = () => {
    console.log('数据修改成功');
    db.close();
  };
};
(5)删除数据(补充批量删除、索引定位删除)

支持主键删除和索引定位删除,批量删除需结合游标实现:

ini 复制代码
request.onsuccess = (event) => {
  const db = event.target.result;
  const transaction = db.transaction(['cameraDevice'], 'readwrite');
  const cameraStore = transaction.objectStore('cameraDevice');
  
  // 1. 主键删除
  cameraStore.delete('cam_999');
  
  // 2. 索引定位删除(补充)
  const indexGetRequest = cameraStore.index('idx_deviceName').get('上海隧道摄像头');
  indexGetRequest.onsuccess = () => {
    const data = indexGetRequest.result;
    if (data) {
      cameraStore.delete(data.deviceId); // 通过主键删除
    }
  };
  
  // 3. 批量删除(补充:删除所有离线设备)
  const cursorRequest = cameraStore.openCursor();
  cursorRequest.onsuccess = (event) => {
    const cursor = event.target.result;
    if (cursor) {
      if (cursor.value.status === 0) {
        cursor.delete(); // 删除当前游标指向的数据
      }
      cursor.continue();
    }
  };
  
  transaction.oncomplete = () => {
    console.log('数据删除成功');
    db.close();
  };
};
(6)清空表与删除数据库(补充)
ini 复制代码
// 1. 清空表数据
request.onsuccess = (event) => {
  const db = event.target.result;
  const transaction = db.transaction(['cameraDevice'], 'readwrite');
  const cameraStore = transaction.objectStore('cameraDevice');
  
  cameraStore.clear(); // 清空表
  
  transaction.oncomplete = () => {
    console.log('表数据清空成功');
    db.close();
  };
};

// 2. 删除数据库(需先关闭所有连接)
const deleteRequest = indexedDB.deleteDatabase('deviceDB');
deleteRequest.onsuccess = () => {
  console.log('数据库删除成功');
};
deleteRequest.onerror = (event) => {
  console.error('数据库删除失败:', event.target.error.message);
};

四、IndexedDB 封装(优化健壮性与易用性)

基础用法存在代码冗余、错误处理繁琐等问题,下面封装一个通用的 IndexedDB 工具类,支持 Promise 链式调用,简化开发:

封装后的 IndexedDB 工具类(indexedDB.js)

javascript 复制代码
/**
 * IndexedDB 工具类(优化版)
 * 支持 Promise 链式调用、自动兼容、事务管理、完整 API
 */
class IndexedDB {
  constructor(dbName, dbVersion = 1) {
    this.dbName = dbName;
    this.dbVersion = dbVersion;
    this.db = null; // 数据库实例
    this.supported = !!window.indexedDB; // 检测浏览器支持
    
    // 兼容前缀
    this.indexedDB = window.indexedDB ||
                    window.webkitIndexedDB ||
                    window.mozIndexedDB ||
                    window.msIndexedDB;
    this.IDBTransaction = window.IDBTransaction ||
                          window.webkitIDBTransaction ||
                          window.mozIDBTransaction;
  }

  /**
   * 初始化数据库(创建表、索引)
   * @param {Array} tableConfigs 表配置:[{ tableName, keyPath, autoIncrement, indexList }]
   * @returns {Promise}
   */
  init(tableConfigs = []) {
    if (!this.supported) {
      return Promise.reject(new Error('当前浏览器不支持 IndexedDB'));
    }

    return new Promise((resolve, reject) => {
      const request = this.indexedDB.open(this.dbName, this.dbVersion);

      // 数据库打开成功
      request.onsuccess = (event) => {
        this.db = event.target.result;
        console.log(`数据库 ${this.dbName} 打开成功,版本号:${this.db.version}`);
        resolve(this.db);
      };

      // 数据库打开失败
      request.onerror = (event) => {
        reject(new Error(`数据库打开失败:${event.target.error.message}`));
      };

      // 数据库创建/升级
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        console.log(`数据库升级:旧版本 ${event.oldVersion} → 新版本 ${event.newVersion}`);

        // 创建表和索引
        tableConfigs.forEach(({ tableName, keyPath, autoIncrement = false, indexList = [] }) => {
          if (!db.objectStoreNames.contains(tableName)) {
            // 配置主键
            const storeOptions = keyPath 
              ? { keyPath, autoIncrement } 
              : { autoIncrement: true }; // 无 keyPath 时自动生成主键 id

            const objectStore = db.createObjectStore(tableName, storeOptions);

            // 创建索引
            indexList.forEach(({ indexName, propName, unique = false }) => {
              objectStore.createIndex(indexName, propName, { unique });
            });

            console.log(`表 ${tableName} 创建成功`);
          }
        });

        resolve(db);
      };
    });
  }

  /**
   * 开启事务
   * @param {String|Array} tableNames 表名(单个或多个)
   * @param {String} mode 事务模式:readonly / readwrite
   * @returns {Object} objectStore 实例
   */
  _transaction(tableNames, mode = 'readonly') {
    if (!this.db) {
      throw new Error('数据库未初始化,请先调用 init 方法');
    }

    const transaction = this.db.transaction(tableNames, mode);
    // 事务失败处理
    transaction.onerror = (event) => {
      throw new Error(`事务失败:${event.target.error.message}`);
    };

    // 支持单个表名直接返回 objectStore,多个表名返回事务实例
    return Array.isArray(tableNames) && tableNames.length > 1 
      ? transaction 
      : transaction.objectStore(Array.isArray(tableNames) ? tableNames[0] : tableNames);
  }

  /**
   * 新增数据
   * @param {String} tableName 表名
   * @param {Object|Array} data 单个数据对象或数组
   * @returns {Promise}
   */
  add(tableName, data) {
    return new Promise((resolve, reject) => {
      try {
        const objectStore = this._transaction(tableName, 'readwrite');
        const isBatch = Array.isArray(data);

        // 批量新增
        if (isBatch) {
          data.forEach(item => objectStore.add(item));
        } else {
          objectStore.add(data);
        }

        // 事务完成
        objectStore.transaction.oncomplete = () => {
          resolve(isBatch ? `批量新增 ${data.length} 条数据成功` : '数据新增成功');
        };

        // 事务失败
        objectStore.transaction.onerror = (event) => {
          reject(new Error(`数据新增失败:${event.target.error.message}`));
        };
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * 查询数据
   * @param {String} tableName 表名
   * @param {Object} options 查询配置:{ key, indexName, indexValue, cursorFilter }
   * @returns {Promise}
   */
  get(tableName, options = {}) {
    return new Promise((resolve, reject) => {
      try {
        const objectStore = this._transaction(tableName);
        const { key, indexName, indexValue, cursorFilter } = options;
        let request;

        // 1. 主键查询
        if (key !== undefined) {
          request = objectStore.get(key);
        }
        // 2. 索引查询
        else if (indexName && indexValue !== undefined) {
          request = objectStore.index(indexName).get(indexValue);
        }
        // 3. 游标查询(支持筛选)
        else if (cursorFilter && typeof cursorFilter === 'function') {
          request = objectStore.openCursor();
          const result = [];

          request.onsuccess = (event) => {
            const cursor = event.target.result;
            if (cursor) {
              if (cursorFilter(cursor.value)) {
                result.push(cursor.value);
              }
              cursor.continue();
            } else {
              resolve(result);
            }
          };

          return;
        }
        // 4. 全量查询
        else {
          request = objectStore.getAll();
        }

        // 普通查询结果处理
        request.onsuccess = () => {
          resolve(request.result);
        };

        request.onerror = (event) => {
          reject(new Error(`数据查询失败:${event.target.error.message}`));
        };
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * 修改数据
   * @param {String} tableName 表名
   * @param {Object} data 要修改的数据(必须包含主键)
   * @returns {Promise}
   */
  update(tableName, data) {
    return new Promise((resolve, reject) => {
      try {
        const objectStore = this._transaction(tableName, 'readwrite');
        const request = objectStore.put(data); // 存在则更新,不存在则新增

        request.onsuccess = () => {
          resolve('数据修改成功');
        };

        request.onerror = (event) => {
          reject(new Error(`数据修改失败:${event.target.error.message}`));
        };
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * 删除数据
   * @param {String} tableName 表名
   * @param {String|Number} key 主键值
   * @returns {Promise}
   */
  delete(tableName, key) {
    return new Promise((resolve, reject) => {
      try {
        const objectStore = this._transaction(tableName, 'readwrite');
        const request = objectStore.delete(key);

        request.onsuccess = () => {
          resolve('数据删除成功');
        };

        request.onerror = (event) => {
          reject(new Error(`数据删除失败:${event.target.error.message}`));
        };
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * 清空表数据
   * @param {String} tableName 表名
   * @returns {Promise}
   */
  clear(tableName) {
    return new Promise((resolve, reject) => {
      try {
        const objectStore = this._transaction(tableName, 'readwrite');
        const request = objectStore.clear();

        request.onsuccess = () => {
          resolve('表数据清空成功');
        };

        request.onerror = (event) => {
          reject(new Error(`表数据清空失败:${event.target.error.message}`));
        };
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * 删除数据库
   * @returns {Promise}
   */
  deleteDatabase() {
    return new Promise((resolve, reject) => {
      if (this.db) {
        this.db.close(); // 先关闭数据库连接
      }

      const request = this.indexedDB.deleteDatabase(this.dbName);
      request.onsuccess = () => {
        resolve(`数据库 ${this.dbName} 删除成功`);
      };
      request.onerror = (event) => {
        reject(new Error(`数据库删除失败:${event.target.error.message}`));
      };
    });
  }

  /**
   * 关闭数据库连接
   */
  close() {
    if (this.db) {
      this.db.close();
      this.db = null;
      console.log(`数据库 ${this.dbName} 已关闭`);
    }
  }
}

export default IndexedDB;

工具类使用示例

javascript 复制代码
// 1. 初始化数据库
import IndexedDB from './indexedDB';

// 配置表结构
const tableConfigs = [
  {
    tableName: 'cameraDevice',
    keyPath: 'deviceId', // 主键
    autoIncrement: false,
    indexList: [
      { indexName: 'idx_deviceName', propName: 'deviceName', unique: false },
      { indexName: 'idx_status', propName: 'status', unique: false }
    ]
  },
  {
    tableName: 'user',
    autoIncrement: true, // 自动生成主键 id
    indexList: [
      { indexName: 'idx_username', propName: 'username', unique: true }
    ]
  }
];

// 创建数据库实例(数据库名:deviceDB,版本号:2)
const db = new IndexedDB('deviceDB', 2);

// 初始化并操作数据
db.init(tableConfigs)
  .then(() => {
    // 2. 新增数据
    return db.add('cameraDevice', {
      deviceId: 'cam_1001',
      deviceName: '广州大桥摄像头',
      status: 1,
      resolution: '4K',
      createTime: new Date().toISOString()
    });
  })
  .then((msg) => {
    console.log(msg);
    // 3. 查询数据(游标筛选在线设备)
    return db.get('cameraDevice', {
      cursorFilter: (item) => item.status === 1
    });
  })
  .then((onlineCameras) => {
    console.log('在线设备:', onlineCameras);
    // 4. 修改数据
    return db.update('cameraDevice', {
      deviceId: 'cam_1001',
      deviceName: '广州大桥摄像头',
      status: 1,
      resolution: '8K', // 更新分辨率
      createTime: new Date().toISOString()
    });
  })
  .then((msg) => {
    console.log(msg);
    // 5. 删除数据
    return db.delete('cameraDevice', 'cam_1001');
  })
  .then((msg) => {
    console.log(msg);
    // 6. 关闭数据库
    db.close();
  })
  .catch((error) => {
    console.error('操作失败:', error.message);
    db.close();
  });

五、最佳实践与注意事项(补充关键细节)

  1. 版本号管理:数据库版本号必须为正整数,升级后无法回退,修改表结构时需递增版本号。

  2. 事务生命周期:事务会在操作完成后自动提交,若长时间不操作(如超过 5 秒)会被浏览器终止,建议操作完成后立即关闭数据库。

  3. 错误处理:所有操作都需捕获错误(如主键重复、权限不足、存储满),避免影响页面正常运行。

  4. 性能优化

    1. 批量操作时尽量合并为一个事务,减少事务创建次数。
    2. 大量数据查询使用游标(Cursor),避免使用 getAll() 导致内存占用过高。
    3. 合理创建索引,提升查询效率,但避免过多索引(会影响新增/修改性能)。
  5. 数据序列化 :虽然 IndexedDB 支持存储对象,但复杂对象(如函数、循环引用对象)无法存储,需提前序列化(如 JSON.stringify)。

  6. 浏览器兼容性 :主流浏览器(Chrome、Firefox、Edge、Safari 10.1+)均支持 IndexedDB,如需兼容旧浏览器(如 IE9-),需使用 localForage 等兼容库。

  7. 安全限制 :IndexedDB 受同源策略限制,不同域名无法访问彼此的数据库;本地文件(file:// 协议)无法使用 IndexedDB,需通过服务器(http:///https://)访问。

六、总结

IndexedDB 作为前端高性能本地数据库,适用于需要存储大量结构化数据、实现离线功能的场景(如离线应用、数据缓存、本地日志存储等)。本文从存储方案对比、核心概念、基础用法、完整封装到最佳实践,全面覆盖了 IndexedDB 的关键知识点,弥补了基础用法的遗漏(如游标查询、批量操作、错误处理),封装的工具类简化了开发流程,提升了代码复用性。

相关推荐
UIUV2 小时前
JavaScript中this指向机制与异步回调解决方案详解
前端·javascript·代码规范
liuniansilence2 小时前
🚀 高并发场景下的救星:BullMQ如何实现智能流量削峰填谷
前端·分布式·消息队列
再花2 小时前
在Angular中实现基于nz-calendar的日历甘特图
前端·angular.js
San302 小时前
从零到一:彻底搞定面试高频算法——“列表转树”与“爬楼梯”全解析
javascript·算法·面试
GISer_Jing2 小时前
今天看了京东零售JDS的保温直播,秋招,好像真的结束了,接下来就是论文+工作了!!!加油干论文,学&分享技术
前端·零售
Mapmost2 小时前
【高斯泼溅】如何将“歪头”的3DGS模型精准“钉”在地图上,杜绝后续误差?
前端
JellyDDD2 小时前
h5上传大文件可能会导致手机浏览器卡死,重新刷新的问题
javascript·上传文件
废春啊3 小时前
前端工程化
运维·服务器·前端
爱上妖精的尾巴3 小时前
6-9 WPS JS宏Map、 set、get、delete、clear()映射的添加、修改、删除
前端·wps·js宏·jsa