IndexedDB,浏览器内置的本地数据库,很多前端开发者听说过但真正上手用过的并不多。这个API能把结构化数据存在用户浏览器里,支持索引、事务、游标遍历、范围查询,容量上限也远高于localStorage。我大概在两年前的一个离线数据同步项目里第一次深入使用IndexedDB,几万条业务数据要存在本地,localStorage撑不住,服务端下发的索引数据又必须离线可用,折腾了一圈才把IndexedDB真正用顺。这篇文章我就把IndexedDB基本使用从概念到案例完整写一遍,配合一个可以直接跑的商品管理Demo,尽量让没接触过的同学看完能上手,用过但对事务、游标这些机制还迷糊的同学也能找到一些排查思路。
1. IndexedDB到底是干什么的
1.1 浏览器本地存储家族里的"重型武器"
先搞清楚IndexedDB在自己生态里的定位。浏览器本地存储,大家最早接触的是Cookie,之后是localStorage和sessionStorage,然后还有Cache Storage,再就是IndexedDB。Cookie能存几个KB,localStorage一般5-10MB,存点字符串和设置项绰绰有余。可一旦数据量到几万条、几十万条,或者数据结构复杂、需要按条件查询的时候,localStorage很快就露馅了。它没有索引机制,所有数据都是字符串键值对,取出来还得自己parse、自己遍历过滤,性能和数据量都扛不住。
IndexedDB就是来解决这种问题的。它本质上是浏览器内置的NoSQL数据库,底层结构类似key-value存储,但在这个基础上加了对象仓库、索引、事务、游标这些关系型数据库才有的机制。存进去的数据不用转成字符串,对象、数组、二进制Blob都能直接存,读取的时候也能按主键、按索引快速查找,性能和处理能力跟localStorage完全是两个量级。
| 能力 | localStorage | IndexedDB |
|---|---|---|
| 存储类型 | 仅字符串 | 结构化对象、Blob、ArrayBuffer |
| 容量上限 | 通常5-10MB | 通常可达数百MB甚至GB级 |
| 索引查询 | 不支持,需手动遍历 | 支持,按索引快速查找 |
| 事务 | 无 | 支持,保证数据一致性 |
| 异步/同步 | 同步阻塞 | 全异步,不阻塞UI |
| 适用数据量 | 少量配置、简单状态 | 大量业务数据、离线数据 |
很多人误以为IndexedDB很难搞,其实它难的地方不在API,而在于事件驱动的异步模型和事务的生命周期。这两个点我后面会重点讲,因为它们决定了90%以上的报错来自哪里。
1.2 什么项目真的需要IndexedDB
从实际场景来看,以下几种情况我会毫不犹豫选择IndexedDB。第一种是离线优先的应用,比如笔记工具、待办事项、营销活动配置台,用户在没有网络的时候也要读写数据,等联网后再和服务器同步。第二种是需要在本地缓存大量列表数据的场景,比如一个管理后台要缓存全量商品、订单数据,数据量上万,而且用户希望首屏渲染很快,那就可以第一次拉全量后存进IndexedDB,后面直接从本地读。第三种是数据导出、上传前暂存的场景,比如先在前端批量生成数据文件,暂存在本地,再统一上传。
不适合的场景也要说清楚。如果你只是想在页面刷新后记一个用户偏好、一个token、一个主题色,用localStorage就够了。如果你要存的是敏感凭证信息,比如密码、token这类,不管什么存储方式都不安全,IndexedDB也一样。该走服务端鉴权就走服务端,别指望浏览器本地存储来保证安全。IndexedDB里的数据对同一浏览器的任何脚本都是可见的,并没有传说中的"更安全"光环。
选型这件事,核心看两样:数据量是否大到localStorage扛不住,查询条件是否复杂到必须用索引。两个都不占,老老实实localStorage;占任何一个,就可以考虑IndexedDB了。
2. 用之前必须搞明白的六个核心概念
2.1 数据库与对象仓库:先分清"库"和"表"
IndexedDB里可以创建多个数据库,每个数据库有唯一名字。一个数据库内部,核心存储单位叫对象仓库(Object Store)。类比一下,数据库相当于一个MySQL实例,对象仓库就相当于一张表。每个对象仓库存一类对象,比如"product"仓库存商品对象、"order"仓库存订单对象。
对象仓库里的每一条数据记录,本身就是一个JavaScript对象,没有固定的列结构,你存什么字段就是什么字段。这一点和MySQL那种强约束的表结构完全不同,更接近MongoDB这类文档型数据库。同一个对象仓库里,每条记录的字段可以不一样,但这并不意味着你可以乱存,工程上我仍然建议保持结构统一,否则后续查询和升级会非常痛苦。
每个对象仓库必须有一个主键,用来唯一标识一条记录。主键可以来自对象里的某个字段,通过keyPath指定,比如商品对象里的id字段;也可以让IndexedDB自动生成,比如自增整数。设置方式是在创建对象仓库时传入options:{ keyPath: 'id' }指定字段主键,或者{ autoIncrement: true }用自增主键。主键一旦设定,add相同主键的数据会报错,这就是增操作里最常见的错误。
2.2 索引:没有它查询就是全表扫描
对象仓库本身只能按主键查询。如果想知道"价格小于100的商品有哪些",直接在主键上做不了高效查询,只能遍历所有记录再逐条判断。为了支持这种按业务字段查询的场景,IndexedDB允许在对象仓库上创建索引(Index)。
索引可以理解为给某个字段单独建立的一份"副本查找表",里面维护了字段值与主键的对应关系。创建索引的API是objectStore.createIndex(indexName, keyPath, options)。第三个参数里有个unique选项,表示索引键值是否唯一。比如给email建唯一索引,那重复email数据就插入不进去。
创建索引之后,查询就可以走索引而不是遍历全表,价格、分类这类字段都能查。注意一点:索引键也会在数据写入时同步维护,所以索引不是越多越好,每多一个索引,写路径就要多做一次更新,数据量大时会影响写入性能。我的经验是,只对"高频查询字段"建索引,其他字段宁可查询时过滤。
2.3 事务:IndexedDB的一致性保障
IndexedDB里所有读写操作都必须在事务中执行。事务是数据库执行操作的基本单元,一个事务可以包含多个请求,这些请求要么全部成功,要么全部失败,不会出现写到一半数据没了这种状态。默认情况下,事务有三种模式:readonly只读、readwrite读写、versionchange版本变更,其中versionchange由版本升级自动触发,不需要手动指定。
这个机制对业务很重要。比如用户填写的一张表单要同时更新主表记录和操作日志表,如果两个操作放在同一个事务里,任何一个失败整个事务都回滚,数据不会出现一边成功一边失败的情况。很多刚接触IndexedDB的人栽在事务上,主要就是TransactionInactiveError这个报错。事务的生命周期极短,一旦当前事件循环的任务结束,事务就自动提交并进入inactive状态,这时候再发异步请求就会报错。这个坑后面我会单独再讲。
2.4 请求与事件:全程都是异步
IndexedDB的API设计比较老派,它不是Promise风格的,而是基于IDBRequest请求对象加事件回调。几乎每个操作(打开数据库、增删改查)都会返回一个IDBRequest对象,你在这个对象上监听onsuccess、onerror、onupgradeneeded事件拿结果。这种事件驱动模式如果你之前写过早期风格的Node.js代码,会觉得很亲切,但如果习惯了现代Promise语法,头一次上手会觉得到处都是回调,很不舒服。
每个IDBRequest对应一次异步操作,操作成功时,结果放在request.result里。不同操作的result含义不一样:打开数据库时result是IDBDatabase实例;获取数据时result是查询到的对象或者undefined;游标遍历时result是IDBCursorWithValue实例或者null。操作失败时,错误信息在request.error里,同时事务会中止。
这个异步模型带来的直接后果是:你不能把open结果直接赋值给全局变量去同步使用,必须等onsuccess触发之后才能拿到数据库实例。我见过太多人第一次写IndexedDB就卡在这里,后面我会给出一个Promise封装方案,实际项目中基本上都这么用。
2.5 游标:遍历多条记录的常规手段
按主键get只能取单条数据,按索引getAll能取满足条件的全部数据,但如果你想从第一条开始逐条处理、边读边判断,或者做分页跳着读,就需要游标(Cursor)。游标有点类似数据库术语里的"只读流式指针",你可以控制它逐步移动,每移动一步取到一条记录,决定是继续还是停止。
IndexedDB的游标除了支持顺序遍历,还可以配合方向参数以及IDBKeyRange范围条件。关于游标的具体使用方式和几个注意点,我放到第3章的实操部分展开,这里先建立概念。脑子里要有这么一条线:单条查询用get,批量定值查询用getAll,分页或逐条过滤用游标。
2.6 版本号:升级数据库结构的钥匙
IndexedDB打开数据库时,indexedDB.open(name, version)会带一个版本号。版本号的作用是触发onupgradeneeded事件,这个事件在"数据库第一次创建"或"版本号变高"时触发,它是唯一能创建对象仓库、创建索引、删除索引的地方。换句话说,建表、加索引这类结构变更,必须写在onupgradeneeded里,其他事件里做不了。
版本号只能从低往高改动,不能倒退。如果你用indexedDB.open('myDb', 2)打开,但之前已经用版本3打开过这个库,就会直接触发constraint错误或者blocked,甚至VersionError。这个设计是为了防止不同标签页之间版本不一致导致的结构混乱。所以版本号管理要有一个明确策略,最简单的就是只用正整数,每次结构变化加1。
3. 基本API实操:打开库、建仓库、增删改查一次走通
3.1 打开数据库与三个关键事件
先给原生写法。打开数据库是第一步,也是很多新手的第一个坎。
let db = null; function openDB() { return new Promise((resolve, reject) => { const request = indexedDB.open('myAppDB', 1); request.onupgradeneeded = function (event) { const db = request.result; // 这里创建对象仓库和索引 if (!db.objectStoreNames.contains('product')) { const store = db.createObjectStore('product', { keyPath: 'id' }); store.createIndex('idx_price', 'price', { unique: false }); store.createIndex('idx_category', 'category', { unique: false }); } }; request.onsuccess = function (event) { db = request.result; resolve(db); }; request.onerror = function (event) { reject(request.error); }; }); }几个细节需要注意。open的第一个参数是数据库名,第二个参数是版本号,如果不传第二个参数,默认版本是1。onupgradeneeded在两种情况下触发:一是这个数据库从未存在过,二是传入了比当前版本更高的版本号。事件里需要判断db.objectStoreNames.contains('product'),因为如果数据库已经存在且版本升级,之前建过的仓库还存在,再create一个同名仓库会报ConstraintError。
onsuccess里拿到的request.result就是IDBDatabase实例,可以保存在变量里供后续读写使用。数据库打开过一次之后,后面的登录不上upgradeneeded,而是直接走onsuccess。onerror是权限被拒、版本回退等情况。当初学的时候困扰我的一个点:indexedDB.open('myAppDB')和indexedDB.open('myAppDB', 1)看起来一样,但版本管理上有一个特别需要留意的差异——不传版本号时,如果遇到已有数据库版本更高,IndexedDB会以已存在的版本为准;而显式传一个更低的版本号则会直接VersionError。所以正式项目中我强烈建议显式传版本号,并且用常量管理。
注意:onupgradeneeded里所有建结构操作都是同步生效的,不需要额外await。真正需要await的是事务里那些读写请求。
3.2 新增数据:add与put的区别
往对象仓库里加数据,最常见的两个方法是add(data)和put(data)。两者的区别在于:add在遇到主键冲突时会失败,报ConstraintError;put则是覆盖更新。主键不存在时put的行为和add一样,都是插入。所以如果你想实现"有则更新、无则插入"的upsert语义,就直接用put;如果你希望"重复插入必须报错",用add。
function addProduct(product) { return new Promise((resolve, reject) => { const tx = db.transaction('product', 'readwrite'); const store = tx.objectStore('product'); const request = store.add(product); request.onsuccess = () => { resolve(request.result); }; request.onerror = () => { reject(request.error); }; }); }这里resolve出去的request.result是新增记录的主键值,不是对象本身。事务创建之后,紧接着用tx.objectStore('product')拿到对象仓库,再调用加数据的方法。这个调用必须发生在同一个同步代码块里。如果事务创建之后,你又去发起网络请求,等网络返回再回头调用store.add,事务可能已经inactive了,会报TransactionInactiveError。
还有一个常见误解:以为request.onsuccess触发了数据就已经牢靠落盘。实际上单个请求的成功只代表写入操作本身通过,要保证整个事务成功提交,更稳妥的做法是监听tx.oncomplete事件。如果事务里有很多个请求,只要有一个失败,整个事务都不会complete,已经成功的部分也会回滚。这个认识对后续写批量操作非常重要。
3.3 读取数据:get、getAll、getByKey 与索引查询
读取数据的API比较多,按使用频率排个序:get(取单条)、getAll(取全部或满足索引条件的全部)、index(索引对象)配合get/getAll、openCursor(游标遍历)。
async function getProduct(id) { const result = await new Promise((resolve, reject) => { const tx = db.transaction('product', 'readonly'); const store = tx.objectStore('product'); const request = store.get(id); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); return result; }get(id)传入主键,返回对象或undefined。要查询所有记录,直接store.getAll()。要按某个索引的值来取,先store.index('idx_category'),再调用index.get('家电')——这里返回的是索引键值等于"家电"的第一条记录,不是所有家电记录。如果想拿到所有家电记录,用index.getAll('家电'),返回数组。如果想做范围查询,比如价格在50到100之间,需要配合IDBKeyRange:
const range = IDBKeyRange.bound(50, 100); store.index('idx_price').getAll(range);IDBKeyRange有四种构造方式:only(唯一值)、lowerBound(下界,可控制开闭)、upperBound(上界)、bound(上下界,两端开闭可选)。这是IndexedDB查询里最容易被低估的API,学会了它可以替代很多"先取全量再手动过滤"的笨办法。
3.4 更新与删除:put、delete、clear与事务提交
更新一条记录,最常见的做法是:先get出原对象,合并字段之后再put回去。之所以不直接put一个残缺对象,是因为put是整条记录覆盖,你只传了部分字段,其他字段就丢了。当然,如果你的业务对象本身格式受控,也可以直接put完整对象。
function updateProduct(product) { return new Promise((resolve, reject) => { const tx = db.transaction('product', 'readwrite'); const store = tx.objectStore('product'); store.put(product); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } function deleteProduct(id) { return new Promise((resolve, reject) => { const tx = db.transaction('product', 'readwrite'); const store = tx.objectStore('product'); store.delete(id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); }delete传入主键,删除对应记录。clear则是清空整个对象仓库。这里我把成功回调从request.onsuccess换成了tx.oncomplete,原因前面已经说了,请求成功不代表事务提交成功,事务级的事件才是最终结果。这个习惯一旦养成,后面写批量增删改就少踩很多坑。
删除还有个特殊玩法:可以用游标遍历后边读边删,比如"删除所有库存为0的商品",此时可以用索引加游标筛选,再调用游标对象上的delete()方法。游标上的delete是删除当前游标记录,这是游标相对getAll后循环逐条delete的一个重要优势,因为它在事务内操作,效率更高且不会出现事务提前提交的问题。
3.5 游标遍历与范围查询实战
游标最经典的三种用法:全表遍历、带范围遍历、按索引遍历。先说全表遍历:
function traverseAll() { return new Promise((resolve, reject) => { const tx = db.transaction('product', 'readonly'); const store = tx.objectStore('product'); const request = store.openCursor(); const results = []; request.onsuccess = function (event) { const cursor = event.target.result; if (cursor) { results.push(cursor.value); cursor.continue(); } else { resolve(results); } }; request.onerror = () => reject(request.error); }); }这段代码的执行逻辑是:每次onsuccess触发时判断cursor是否存在,如果存在就取当前记录并调用cursor.continue()移动到下一条,直到cursor为null表示遍历结束。注意,游标的onsuccess会被触发多次,每移动一次就触发一次,这和get那种"只触发一次"完全不同。很多新手以为游标走一次回调就该结束了,结果只拿到第一条记录,这是最常见的误区。
如果要遍历某个分类下的商品并做分页,就用store.index('idx_category').openCursor(IDBKeyRange.only('家电')),然后在游标循环里配合计数实现跳过前N条、取M条的伪分页。IndexedDB本身没有skip语法,但游标支持continue(primaryKey),可以在已知主键位置后精准跳过一批记录,性能比逐条continue好很多。
3.6 用Promise封装统一入口
原生API到处是回调,写起来啰嗦,事务生命周期的坑也容易踩。我在多个项目里都是先封装一个极简的DB工具类:内部维护一个打开数据库的Promise,提供一个run(storeName, mode, handler)方法,handler里拿到store后随便操作,最后返回事务Promise。这样做的好处有三点:所有调用方不用再关心事务怎么创建;事务生命周期由封装统一管理,避免误用;配合async/await组合逻辑可读性大幅提升。
class IndexedDBHelper { constructor(dbName, version) { this.dbName = dbName; this.version = version; this.dbPromise = this.open(); } open() { return new Promise((resolve, reject) => { const req = indexedDB.open(this.dbName, this.version); req.onupgradeneeded = (event) => { this.upgrade(event.target.result); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } upgrade(db) { // 子类或具体模块按需实现,默认不建任何仓库 } async run(storeName, mode, handler) { const db = await this.dbPromise; return new Promise((resolve, reject) => { const tx = db.transaction(storeName, mode); const store = tx.objectStore(storeName); handler(store, tx); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } }这个封装思路非常简单,但很实用。实际项目中可以在这个基础上扩展批量操作方法,或者把getAll的结果直接返回。第4章的案例里我会用一个简化但不失完整性的版本演示全过程。
4. 完整案例:做一个可运行的商品管理Demo
4.1 需求拆解
为了把上面的API真正串起来,我来做一个商品管理的单页Demo。场景设定:仓库管理员需要在本地维护一份商品清单,包含商品名称、价格、分类、库存和入库时间。页面支持添加商品、编辑商品、删除商品、按分类过滤、按价格区间查询。所有数据全程不依赖网络,只存在浏览器本地IndexedDB里,刷新页面数据不丢。
这个案例就是一个缩略版的后台管理端数据层。UI做到最简,用纯HTML加原生JavaScript,不引入任何框架,拿到浏览器里直接就能跑。页面划分四个区域:顶部是添加/编辑表单,中间是筛选条件区,下面是商品表格,底部有一个清空所有数据的按钮。
数据模型设计如下:
- 对象仓库名:product
- 主键:id,autoIncrement自动生成
- 索引1:idx_category,分类
- 索引2:idx_price,价格
这里我故意把主键设计成自增,不去手工维护id,这样能演示"自动生成主键+按主键操作"的配合方式。实际后台系统里,主键用服务端下发的唯一ID或UUID会更常见,但自增主键在纯本地工具类项目里很常用,两者都要会。
4.2 初始化数据库结构
先定义数据库名、版本号、建仓库和索引的逻辑。
const DB_NAME = 'product-manager-demo'; const DB_VERSION = 1; const STORE_NAME = 'product'; let dbInstance = null; function openDatabase() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); store.createIndex('idx_category', 'category', { unique: false }); store.createIndex('idx_price', 'price', { unique: false }); } }; request.onsuccess = (event) => { dbInstance = event.target.result; resolve(dbInstance); }; request.onerror = (event) => { reject(request.error); }; }); }版本号和仓库名用常量维护,这是长期养成的习惯。如果哪天要加一个"库存变更记录"表,只要把DB_VERSION改成2,在onupgradeneeded里再加一个对象仓库就行。升级过程中老数据不会丢失,IndexedDB会自动保留。
4.3 增删改查的封装方法
为了方便页面调用,把操作都封装成小函数,统一使用Promise返回结果。
async function ensureDB() { if (dbInstance) return dbInstance; return openDatabase(); } async function addProduct(product) { const db = await ensureDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); const request = store.add(product); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async function updateProduct(product) { const db = await ensureDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); store.put(product); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function deleteProduct(id) { const db = await ensureDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); store.delete(id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function clearProducts() { const db = await ensureDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); store.clear(); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); }ensureDB的逻辑很简单:如果dbInstance已经存在直接返回,否则调用openDatabase并缓存实例。这里我缓存的是数据库实例本身,不是Promise。如果缓存了Promise,后续逻辑要等状态resolve,其实也能用,但跨模块共享状态时实例更好维护。多标签页同时打开页面时,实例可能因为另一个标签页触发版本升级而断开,这个边界场景我在第6章单独说。
4.4 查询与筛选逻辑
查询部分提供三个入口:查询全部、按分类精确过滤、按价格区间过滤。这三种场景基本覆盖IndexedDB的常规查询姿势。
async function getAllProducts() { const db = await ensureDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readonly'); const store = tx.objectStore(STORE_NAME); const request = store.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async function getProductsByCategory(category) { const db = await ensureDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readonly'); const store = tx.objectStore(STORE_NAME); const index = store.index('idx_category'); const request = index.getAll(category); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async function getProductsByPriceRange(min, max) { const db = await ensureDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readonly'); const store = tx.objectStore(STORE_NAME); const index = store.index('idx_price'); const range = IDBKeyRange.bound(min, max); const request = index.getAll(range); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); }这三个函数对应SQL里的select * from product、select * from product where category = ?、select * from product where price between ? and ?。IndexedDB没有SQL语法,但语义完全能对齐。
价格范围查询这里有个隐形细节:索引是按数值排序的,IDBKeyRange.bound(50, 100)默认是闭区间,两边都包含。如果你的需求是左闭右开,可以写成IDBKeyRange.bound(50, 100, false, true)。每个参数最后一个布尔值控制开闭,分别是lowerOpen和upperOpen。很多人第一次用IDBKeyRange就栽在开闭边界上,调试时建议先手动构造几个边界值测一下。
我的经验里有一条很值得分享:凡是要做"按某字段过滤加排序"的查询,尽量走索引而不是先getAll再手动filter和sort。IndexedDB的索引天然有序,存储引擎能直接利用B树结构快速定位和遍历,性能差距在大数据量下非常明显。如果你发现一个查询不得不依赖getAll再内存过滤,先停下来想想这个字段是不是应该建索引。
4.5 页面渲染与事件绑定
页面部分相当朴素,我用最基础的DOM操作来渲染。renderList方法接收商品数组,生成表格行。表单提交时先收集字段做简单校验,然后调用addProduct或updateProduct。点击删除按钮时调用deleteProduct。筛选区下拉框和按钮分别触发对应查询方法。
async function initPage() { await ensureDB(); await renderAll(); } async function renderAll() { const products = await getAllProducts(); renderList(products); } function renderList(products) { const tbody = document.getElementById('productList'); tbody.innerHTML = ''; products.forEach((p) => { const tr = document.createElement('tr'); tr.innerHTML = ` <td>${p.id}</td> <td>${p.name}</td> <td>${p.category}</td> <td>${p.price}</td> <td>${p.stock}</td> <td>${new Date(p.createdAt).toLocaleString()}</td> <td> <button class="edit-btn">