前端实战|不依赖后端,如何实现本地的CRUD操作

写在前面

你可能会奇怪,为什么要前端来实现 CRUD 操作,这不是后端的活吗,咱们前端只需要接口一顿调就行了。

没错,这确实是后端应该做得,相比较于前端实现,后端可以提供更高的安全性、实时操作数据库和数据处理能力,通过暴露给前端调用的接口,共同实现创建、查询、更新、删除数据的操作。

但在一些特殊的使用场景中,前端实现本地的 CRUD 操作也能有一定的优势。例如:

  • 不需要存库,但涉及批量数据处理的需求
  • 不需要即时提交给后端,但需要本地存储的数据需求
  • 客户端项目中的离线数据存储处理等
  • 后端实现时工期长、排期不允许的时候(其实就是懒)

所以,当以上场景出现的时候,我们前端作为「啥都能干」的大冤种,就派上用场了。

后端:谁干不是干。

前端:我来就我来(嫌弃脸)。

技术选型和功能 API

我们知道浏览器实现本地存储的方式不止一种,例如 Cookie、Web storage 等,但这两者肯定不合适,不仅是因为存储容量有限制,还不支持自定义索引,也不方便进行 CRUD 操作的封装。

随着浏览器功能的不断增强,为了解决浏览器可以存储、操作大量数据的场景,出现了 indexedDB,它可以理解为浏览器本地的数据库,通过 JavaScript 脚本操作,并且提供索引和查询 API,需要注意的是,它并不属于关系型数据库。

综述,indexedDB 是比较适合做为 CRUD 操作的技术栈。这篇文章旨在分析并实现以下功能 API 逻辑,共 5 个:

  • 创建/连接数据库
  • 添加数据
  • 更新数据
  • 删除数据
  • 分页查询数据

浏览器兼容性

目前 indexedDB 的兼容大部分主流浏览器的近几个版本,尤其是 Chrome,即便是隐私模式也支持。完整的兼容性信息可查阅:caniuse.com/indexeddb

const db = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.msIndexedDB

if (!db) { console.error('浏览器不支持IndexedDB') }

创建/连接数据库

想要连接至数据库,只需要调用 indexedDB 对象中的open方法即可,如果数据库不存在,则会新建一个数据库。该方法传入两个参数

  • name数据库名
  • version版本号

其中version仅支持整数,在数据库结构进行变更的时候,需要更新版本号,只升不降。

const openDB = window.indexedDB.open('mydb', 1)

// 打开/连接成功
openDB.onsuccess = e => {
  const _db = e.target.result
}

// 版本号更新/数据库结构变更触发
openDB.onupgradeneeded = e => {
  const _db = e.target.result
}

// 打开/连接失败
openDB.onerror = e => {
	console.error('[ 打开数据库出错 ]', e.target.error);
};

创建数据表

在 indexedDB 中,数据表被称为 objectStore,这是非常核心的概念,是用于存放数据的仓库,采用 key-value 的存储形式,也就是每一条数据都由keyvalue组成。keyvalue的标记值,即为主键 ID,也就是keypath的值。

// 创建数据表
const objectStore = db.createObjectStore(key, { keyPath: store.id });

只要是可以被序列化的值,都能被 indexedDB 存储,实例、function等不能被结构化的数据,则不能存进去。

实例封装

在项目实际使用中,为了便捷的声明并调用,需要将各个操作封装并暴露对外接口以便使用,例如在 Vue 中,我们希望实现如下的开发体验:

import MyDB from '@/libs/my-db';

...

data(){
    return {
        db: null
    }
}
mounted() {
    this.db = new MyDB({
        db: { name: 'my-table', version: 1 },
        stores: { user: { id: 'userId' } },
        init: this.init,
    });
}
methods: {
    init() {
        // ...
    }
}

通过new MyDB,并传入数据库相关配置config完成实例化,其中,config 配置包括

  • db数据库名以及版本号
  • stores数据表信息,键为表名,值为包括主键 ID 的其它信息
  • init数据库连接成功之后自动执行的回调函数
export default class JwDB {
  constructor(config) {
    this.DB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.msIndexedDB;

    if (!this.DB) {
      console.error('浏览器不支持IndexedDB');
    }

    const { name, version } = config.db;
    this._config = { name, version };

    this._db = null; // DB 实例

    // 打开或创建数据。
    this.openDB = this.DB.open(this._config.name, this._config.version);

    this.openDB.onupgradeneeded = e => {
      const db = e.target.result;
			
			// 建立数据表
      for (const key in config.stores) {
        const store = config.stores[key];
        if (!db.objectStoreNames.contains(key)) {
          const objectStore = db.createObjectStore(key, { keyPath: store.id });

          for (const key2 in store.index) {
            const unique = store.index[key2];
            objectStore.createIndex(key2, key2, { unique: unique });
          }
          continue;
        }
      }
    };

    // 打开/连接成功
    this.openDB.onsuccess = e => {
      this._db = e.target.result;

      typeof config.init === 'function' && config.init();
    };

    // 打开/连接失败
    this.openDB.onerror = e => {
      console.error('[ 打开数据库出错 ]', e.target.error);
    };
  }
}

事务 Transaction

几乎所有主流数据库都有事务的概念,可以保证在执行复杂操作逻辑时的稳定性,如果中途出错,则会执行回滚。

transaction 方法包含两个参数

  • objectStores 数据表
  • type事务类型,也就是读写权限控制

这里我们将事务单独封装并引入,暴露两个事务类型类型以供使用,后续的数据操作均需要用到事务。

/**
 * 开启一个的事务
 * @param {*} help indexedDB 的实例
 * @param {Array} storeName 字符串的数组,对象仓库的名称
 * @param {string} type readwrite:读写,readonly:只读
 * @returns
 */
const Tran = (help, storeName, type = 'readwrite') => {
  return new Promise((resolve, reject) => {
    const _tran = () => {
      const tranRequest = help._db.transaction(storeName, type);
      tranRequest.onerror = event => {
        reject(`${type} 事务出错:${event.target.error}`);
      };
      resolve(tranRequest);
      tranRequest.oncomplete = event => {
         console.log('事务结束:', window.performance.now())
      };
    };
		
    help._db && _tran();
  });
};

export default Tran;
import _tran from './tran'

export default class JwDB {
  constructor(config) { ... }
	
	// 读写的事务
  beginWrite(storeName) {
    return _tran(this, storeName, 'readwrite');
  }

  // 只读的事务
  beginReadonly(storeName) {
    return _tran(this, storeName, 'readonly');
  }
}

新增数据

同样,将新增的操作单独封装并引入,后续的操作依然如此。

利用objectStore暴露的add方法实现 indexedDB 的数据新增,需要注意,新增的数据中必须包含有主键属性。

/**
 * 添加数据
 * @param { DB } db 实例
 * @param { String } storeName 表名
 * @param { Object } data 对象
 * @returns
 */
const addModel = (db, storeName, data) => {
  return new Promise((resolve, reject) => {
    const _add = _tran => {
      _tran
        .objectStore(storeName)
        .add(data).onsuccess = e => {
        resolve(e.target.result);
      };
    };

    db.beginWrite([storeName]).then(tran => {
      _add(tran);
    });
  });
};

export default addModel;

修改数据

数据的修改利用put实现,有两个参数:

  • object更新的数据
  • key 可不传

具体实现如下:

/**
 * 修改数据
 * @param { DB } db 实例
 * @param { String } storeName 表名
 * @param { Object } data 数据
 * @param { string } id
 * @returns
 */
const putModel = (db, storeName, data, id) => {
  return new Promise((resolve, reject) => {
    const _put = _tran => {
      const store = _tran.objectStore(storeName);

      store.get(id).onsuccess = e => {
        const newObject = {};
        Object.assign(newObject, e.target.result, data);

        store.put(newObject).onsuccess = e => {
          resolve(e.target.result);
        };
      };
    };
		
    db.beginWrite([storeName]).then(tran => {
      _put(tran);
    });
  });
};

export default putModel;

删除数据

删除操作,只需要传入需要删除对象的 ID 即可。

/**
 * 删除数据
 * @param { DB } db 实例
 * @param { String } storeName 表名
 * @param { string } id 需要删除数据的ID
 * @returns 新对象的ID
 */
const deleteModel = (db, storeName, id) => {
  return new Promise((resolve, reject) => {
    const _del = _tran => {
      _tran.objectStore(storeName).delete(id).onsuccess = e => {
        resolve(e.target.result);
      };
    };

    db.beginWrite([storeName]).then(tran => {
      _del(tran);
    });
  });
};

export default deleteModel;

分页查询

这里涉及到一个概念cursor-游标,一个用来记录数组正在被操作的某个下标位置的迭代器。可以记录下一个需要操作的数据是哪一条。

因为 indexedDB 并不支持原生的分页查询 API,所以得自己手动实现~

/**
 * 分页查询数据
 * @param { DB } db 实例
 * @param { String } storeName 表名
 * @param {Number,String} pageNum 当前页
 * @param {Number,String} pageSize 每页条数
 */
const queryModel = (db, storeName, pageNum = 1, pageSize = 10) => {
  let list = [],
    counter = 0,
    totalCount = 0,
    advanced = true;

  return new Promise((resolve, reject) => {
    const _query = _tran => {
      const store = _tran.objectStore(storeName);
      store.getAll().onsuccess = e => {
        totalCount = e.target.result.length;
      };

      var request = store.openCursor();
      request.onsuccess = function(e) {
        var cursor = e.target.result;
        if (pageNum > 1 && advanced) {
          advanced = false;
          cursor.advance((pageNum - 1) * pageSize);
          return;
        }
        if (cursor) {
          list.push(cursor.value);
          counter++;

          if (counter < pageSize) {
            cursor.continue();
          } else {
            cursor = null;
            resolve({ list, pageNum, pageSize, totalCount });
          }
        } else {
          resolve({ list, pageNum, pageSize, totalCount });
        }
      };
    };

    db.beginReadonly([storeName]).then(tran => {
      _query(tran);
    });
  });
};

export default queryModel;

最后的封装

最后将各个操作方法引入并暴露出去,方便在业务代码中调用。

import _add from './add';
import _put from './put';
import _del from './delete';
import _query from './query';

import _tran from './tran'

export default class JwDB {
  constructor(config) { ... }
	
	// 读写的事务
  beginWrite(storeName) {
    return _tran(this, storeName, 'readwrite');
  }

  // 只读的事务
  beginReadonly(storeName) {
    return _tran(this, storeName, 'readonly');
  }									 

  /**
   * 新增数据
   * @param {String} storeName 表名
   * @param {Object} data 要添加的对象
   * @returns
   */
  addModel(storeName, data, tranRequest = null) {
    return _add(this, storeName, data, (tranRequest = null));
  }

  /**
   * 修改数据
   * @param {String} storeName 表名
   * @param {Object} data 要修改的对象
   * @param {String} id ID
   * @returns
   */
  putModel(storeName, data, id = null, tran = null) {
    return _put(this, storeName, data, id, tran);
  }

  /**
   * 删除数据
   * @param {String} storeName 表名
   * @param {String} id ID
   * @returns
   */
  delModel(storeName, id, tran = null) {
    return _del(this, storeName, id, tran);
  }

  /**
   * 分页查询数据
   * @param {String} storeName
   * @param {String} pageNum
   * @param {String} pageSize
   * @returns
   */
  queryModel(storeName, pageNum = 1, pageSize = 10) {
    return _query(this, storeName, pageNum, pageSize);
  }
}

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

猜你喜欢

转载自juejin.im/post/7123396735509987358