写在前面
你可能会奇怪,为什么要前端来实现 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 的存储形式,也就是每一条数据都由key
和value
组成。key
是value
的标记值,即为主键 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);
}
}
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。