news 2026/10/2 17:38:07

IndexedDB入门指南:从localStorage到浏览器数据库的进阶实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
IndexedDB入门指南:从localStorage到浏览器数据库的进阶实战

做前端久了,你迟早会遇到 localStorage 不够用的一天。容量上限 5MB,只能存字符串,数据一多,查询全靠 for 循环硬滤,还没法保证写入不冲突。我第一次被 IndexedDB 逼着上手,是因为一个离线台账功能:几千条结构化数据、要按姓名和年龄筛选,还要跨页面共用。用 localStorage 存完再自己写过滤,性能和代码量都让人崩溃。后来静下心来把 IndexedDB 过了一遍,发现这东西概念多、API 绕,但底层思路跟数据库一个路数,摸清之后并不难。这篇文章就用一个最简单的通讯录示例,把打开数据库、建表、增删改查、游标遍历、事务和常见坑位一次讲明白,适合已经会 JavaScript 基础语法、但还没接触过浏览器存储 API 的入门读者。

1. 为什么前端需要 IndexedDB:先搞清楚它解决什么问题

1.1 localStorage 的局限与 IndexedDB 的定位

很多初学者一提到浏览器本地存储,脑子里只有 localStorage 和 sessionStorage,这很正常,因为这两个 API 够简单,存个主题色、用户偏好、购物车数量完全够用。但它们是典型的“小而美”,一旦数据量上来,问题就全暴露了。

首先是容量。localStorage 规范层面约 5MB,各家浏览器略有出入,但对一个要存上千条甚至上万条记录的离线应用来说,5MB 很容易被撑爆。其次是数据格式,localStorage 只能存字符串,存对象得先 JSON.stringify,取出来再 JSON.parse,如果嵌套层级深、数据量大,序列化和反序列化本身就是一笔不小的开销。第三是查询能力,localStorage 没有索引、没有条件查询、没有范围过滤,你要按某个字段过滤,只能把整份数据读出来自己遍历,数据一多性能肉眼可见地下降。第四是写入策略,localStorage 的写入是同步阻塞的,虽然平时感觉不到,但在大数据量场景下,主线程会被明显卡住。

IndexedDB 就是来补这些窟窿的。它是一个真正跑在浏览器里的数据库,底层基于结构化克隆算法存储对象,容量通常远大于 localStorage,单键值对能存 Blob、File 这类二进制,还有事务保证写入一致性,有索引支撑高效查询,API 全部异步,不阻塞主线程。说得直白点,localStorage 像一个只能放杂物的纸盒子,IndexedDB 是一个带书架、带标签系统、还带事务日志的档案柜。

1.2 从数据库的视角理解 IndexedDB

我第一次看 IndexedDB 的文档时,最大的障碍是一堆陌生名词:objectStore、transaction、index、IDBKeyRange、cursor。后来我换了个角度看,瞬间就通了——它跟传统数据库是一一对应的。

  • 数据库(database):对应一个库,一个站点可以创建多个,用名称和版本号区分。
  • 对象仓库(object store):对应数据库里的“表”,里面存的是对象,而不是行记录。
  • 主键(key):对应表的主键,可以是对象里的某个字段(keyPath),也可以是由浏览器自动生成的递增序列。
  • 索引(index):对应表的索引,建在某个字段上,方便按这个字段快速查询。
  • 事务(transaction):对应数据库事务,读写操作必须在事务里执行,事务提交前失败可以整体回滚。
  • 游标(cursor):对应 SQL 里的游标,用来遍历结果集。

理解了这层映射,IndexedDB 的文档就不再是天书了。它无非是把 SQL 换成了 request 加 event 的模式,把 SQL 里的 WHERE 换成了 IDBKeyRange,把 JOIN 换成了你自己的代码逻辑。没有 SQL 语法是它跟后端数据库最大的区别,却也是它不需要服务器、不依赖网络就能跑起来的原因。

1.3 什么时候该用,什么时候不该用

IndexedDB 不是万能的,我见过一些项目盲目用它,最后给自己添了一堆麻烦。根据我的实际经验,适合用 IndexedDB 的场景有这几类:

  • 离线数据:Web 应用需要离线可用,比如离线台账、离线工单、PWA 的本地数据层。
  • 大数据量结构化存储:几千条以上、字段不固定、需要按条件查询的数据。
  • 本地草稿与断点续传:编辑器草稿、上传任务列表、输入表单的增量保存。
  • 缓存增强:除了 HTTP 缓存之外,需要把接口响应结构化缓存到本地,并做版本管理。

不适合用 IndexedDB 的场景也不少。比如只是存几个偏好设置、登录 token,用 localStorage 就够了;比如数据必须实时跟服务端同步,本地存储只是缓存,那应该优先考虑 HTTP 缓存和服务端状态管理;再比如需要复杂关联查询、多表 join、聚合统计,IndexedDB 并不擅长,硬用只会把代码写得又臭又长。简单说,IndexedDB 是给“需要在本地做一点数据管理”的场景用的,不是给所有存储需求用的。

2. 动手前的四个核心概念:不弄懂这几个词,代码肯定写不顺

2.1 数据库版本号:升级的唯一入口

IndexedDB 有个跟传统数据库不太一样的机制:版本号。它不是可有可无的元信息,而是控制整个数据库结构升级的开关。你调用indexedDB.open('contactBook', 1)里的那个1,就是版本号。

当你要建表、建索引、修改字段结构时,必须把版本号往上加,比如从 1 升到 2。每次版本号变化,浏览器都会触发onupgradeneeded事件,这个事件是唯一允许创建和删除对象仓库、索引的地方。如果版本号不变,即使你修改了建表代码,浏览器也不会重建结构,这就是很多人“改了建表代码但数据库没变化”的原因。

版本号还有两个容易踩的坑。第一,版本号只能升不能降,如果先打开了版本 2,再拿旧代码去open一个版本 1,会抛 VersionError。第二,当你打开一个更高版本时,如果其它标签页还开着旧版本,新版本会卡在onblocked事件上,直到旧页面关闭或主动调用db.close()。这是多标签页应用最常遇到的坑,后面我会专门讲。

2.2 对象仓库与主键的三种玩法

对象仓库的创建方式是在onupgradeneeded里调用db.createObjectStore(storeName, options)。options 里最关键的是主键配置,有三种玩法:

第一种,指定keyPath,也就是取对象里的某个字段当主键。比如联系人对象里有id字段,你可以写{ keyPath: 'id' },每次添加的数据必须自带id,而且不能重复。

第二种,开autoIncrement,让浏览器自动生成一个从 1 开始递增的主键。比如{ keyPath: 'id', autoIncrement: true },浏览器会帮你填id,你不传也能存。

第三种,既不用keyPath也不用autoIncrement,这时主键由外部指定,比如store.add(data, 'key001')显式传一个主键。这种方式常见于分表存储或需要自定义主键的场景,但日常开发中用得少一些。

主键选得好不好,直接影响后续查询。我之前做过一个联系人导入功能,一开始用姓名当主键,结果同一姓名的人一导入就报错,后来改成自增 id 加姓名索引才解决。记住一条原则:主键要选业务上天然唯一、且不常变的字段,拿姓名、邮箱这类可能重复或者可能修改的值当主键,迟早出问题。

2.3 索引:名字都叫 Indexed 了

IndexedDB 这个名字里的 Indexed,说的就是索引。索引建在对象仓库的某个字段上,作用是让你能按这个字段快速查询。没有索引的时候,你只能按主键查;有了索引,你就能按任意字段查。

建索引的代码很简单,在创建对象仓库之后调store.createIndex(indexName, keyPath, options)。比如给联系人按姓名建索引,可以写store.createIndex('name_index', 'name', { unique: false })。第三个参数里unique控制这个字段是否唯一,如果设成true,那么插入两条相同姓名的数据就会失败,这个设计很实用,比如手机号、身份证号这种天然唯一的字段就应该建唯一索引。

索引虽然是自动维护的,但要注意它不会帮你在“查询逻辑”层面做文章。比如你按姓名建了索引,想查“所有姓张的人”,你仍然需要结合 IDBKeyRange 去构造范围查询,索引只是让这个范围查询变得高效。另外,索引字段本身如果在数据更新后被删掉或改成undefined,这条记录在索引里会消失,查询时可能莫名其妙少数据,这点我在第 4 部分会细讲。

2.4 事务(Transaction)的自动提交陷阱

IndexedDB 的读写操作必须在事务里执行,事务保证一组操作要么全部成功、要么全部回滚。创建事务的方式是db.transaction(storeNames, mode),mode 有两种:readonly和readwrite。查询用 readonly,增删改用 readwrite。

事务最大的陷阱是“自动提交”——当事件循环里没有待处理的任务时,事务会自动结束。很多人第一次写 IndexedDB 会写出这样的代码:

const tx = db.transaction('people', 'readwrite'); const store = tx.objectStore('people'); store.add({ name: '张三', age: 18 }); setTimeout(() => { store.add({ name: '李四', age: 20 }); }, 1000);

这段代码里第二次store.add大概率会抛出TransactionInactiveError,因为事务在 setTimeout 回调真正执行前,就已经在事件循环空转时自动提交了。理解这个机制,是绕开 IndexedDB 一半坑的关键。后面我有两种解决办法,一种是所有操作放在同一轮事件循环里连续执行,另一种是封装成 Promise,严格让请求在前一个完成后紧跟下一个。

3. 一个可直接运行的通讯录示例:从建库到增删改查

3.1 第一步:打开数据库与建表

下面这个通讯录示例,我会把它拆成几个步骤,每一步给出一段可以直接复制运行的代码。先看打开数据库:

function openDatabase() { return new Promise((resolve, reject) => { const request = indexedDB.open('contactBook', 1); request.onupgradeneeded = function (event) { const db = request.result; if (!db.objectStoreNames.contains('people')) { const store = db.createObjectStore('people', { keyPath: 'id', autoIncrement: true }); store.createIndex('name_index', 'name', { unique: false }); store.createIndex('age_index', 'age', { unique: false }); } }; request.onsuccess = function () { const db = request.result; resolve(db); }; request.onerror = function () { reject(request.error); }; }); }

这里有三件事需要你特别留意。第一,onupgradeneeded只在数据库不存在,或者版本号比现有数据库高时触发,第一次运行会触发,之后旧不会了,所以建表代码必须放在这个事件里。第二,request.result和event.target.result指向同一个数据库连接对象,但要注意在onsuccess里,event.target和request是等价的,两种写法都行,别混到事务里就行。第三,db.objectStoreNames.contains('people')是幂等保护,防止同一段升级代码被重复执行时重复建表,养成这个习惯能避免很多诡异问题。

打开数据库之后,记得用完后调用db.close()。数据库连接是有限资源,不关闭会导致版本升级时onblocked一直卡住,也会让后续的数据库操作在部分浏览器里变得异常慢。

3.2 第二步:写入数据,add 与 put 的区别

建完表,接下来就是往里写联系人。先看 add:

function addContact(db, contact) { return new Promise((resolve, reject) => { const tx = db.transaction('people', 'readwrite'); const store = tx.objectStore('people'); const request = store.add(contact); request.onsuccess = function () { resolve(request.result); }; request.onerror = function () { reject(request.error); }; }); }

调用方式类似addContact(db, { name: '张三', age: 28, phone: '13800000000' }),成功后request.result里返回的是这条数据的主键值。这里有个关键点:add在遇到主键重复时会报错,而put则会把同主键的数据整个覆盖掉。简单理解,add是“新增”,put是“新增或更新”。如果需要做“存在就更新、不存在就插入”的同步逻辑,直接用put更省心。

另外,如果你的对象仓库指定了autoIncrement,add 时不传 id 也能成功,浏览器会自动生成。但我建议你在业务逻辑里还是主动维护一个业务编号,自动主键适合内部引用,不适合直接暴露给用户或者做跨端同步,因为不同浏览器环境下的自增序列没有全局唯一性。

3.3 第三步:读取数据,按主键、按索引、按范围

按主键读取最简单:

function getContactById(db, id) { const tx = db.transaction('people', 'readonly'); const store = tx.objectStore('people'); const request = store.get(id); request.onsuccess = function () { console.log('查询结果:', request.result); }; }

按姓名索引读取,写法略有不同:

function getContactByName(db, name) { const tx = db.transaction('people', 'readonly'); const store = tx.objectStore('people'); const index = store.index('name_index'); const request = index.get(name); request.onsuccess = function () { console.log('按姓名查询结果:', request.result); }; }

如果名字唯一,index.get(name)就能拿到单条数据;如果同名的有多条,就要用游标openCursor配合范围去取。按某个字段做范围过滤,需要用IDBKeyRange。比如我想查年龄在 18 到 30 之间的人:

const range = IDBKeyRange.bound(18, 30); const index = store.index('age_index'); const request = index.openCursor(range);

IDBKeyRange有四个静态方法值得记住:only(value)查唯一值,lowerBound(value)查大于等于某个值的范围,upperBound(value)查小于等于某个值的范围,bound(lower, upper)查 between 范围。这四个方法几乎覆盖了所有常见查询需求,在入门阶段先掌握only和bound两个就够了。

3.4 第四步:用游标遍历所有数据

游标是 IndexedDB 遍历数据的主方式,逻辑很像链表——每次onsuccess拿到一条记录,调cursor.continue()就跳到下一条,直到cursor为null说明遍历结束。

function listAllContacts(db) { const tx = db.transaction('people', 'readonly'); const store = tx.objectStore('people'); const request = store.openCursor(); request.onsuccess = function (event) { const cursor = event.target.result; if (cursor) { console.log('联系人:', cursor.value.name, cursor.value.age); cursor.continue(); } else { console.log('遍历结束'); } }; }

游标不仅能读,还能在遍历过程中更新和删除。在cursor.onsuccess里拿到cursor后,可以调cursor.update(newData)更新当前记录,也可以调cursor.delete()删除当前记录,这比“查出 id 再重新发起一次删除请求”要高效。我在做批量修改(比如给所有人年龄加一岁)的时候,就是靠游标加 update 一行一行处理后提交的,代码清爽、性能也可控。

3.5 完整代码串联:一个最小可运行的示例

把上面的函数拼起来,就是一个完整的通讯录。我习惯在本地用一个 HTML 加一个 script 标签直接跑,不用任何构建工具:

<!DOCTYPE html> <html lang="zh-CN"> <body> <script> let db; const openDatabase = () => new Promise((resolve, reject) => { const request = indexedDB.open('contactBook', 1); request.onupgradeneeded = () => { const database = request.result; const store = database.createObjectStore('people', { keyPath: 'id', autoIncrement: true }); store.createIndex('name_index', 'name', { unique: false }); store.createIndex('age_index', 'age', { unique: false }); }; request.onsuccess = () => { db = request.result; resolve(db); }; request.onerror = () => reject(request.error); }); const addContact = (contact) => new Promise((resolve, reject) => { const tx = db.transaction('people', 'readwrite'); const store = tx.objectStore('people'); const request = store.add(contact); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const listAll = () => { const tx = db.transaction('people', 'readonly'); const store = tx.objectStore('people'); const request = store.openCursor(); request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { console.log(cursor.value.name, cursor.value.age); cursor.continue(); } }; }; (async () => { await openDatabase(); await addContact({ name: '张三', age: 28 }); await addContact({ name: '李四', age: 20 }); listAll(); })(); </script> </body> </html>

打开开发者工具的 Application 面板,找到 IndexedDB,你能直观看到数据库、对象仓库、索引和数据记录。看到数据落进去的那一刻,IndexedDB 的神秘感基本就破了一半。这也是我给所有入门朋友的建议:先跑通这个最小示例,再去啃文档,远比从文档读到代码要快。

4. 常见问题与排查技巧实录

4.1 版本号引发的 onblocked:多标签页卡死现场

有一次我给线上项目升级数据表,加了两个索引,信心满满地发布,结果用户反馈页面一直加载不出来。排查了半天,发现问题是数据库从版本 1 升到 2 时,用户另一个标签页还开着旧版本页面,旧版本持有着旧数据库连接,新版本就一直停留在onblocked状态,新页面白屏。

正确的处理方式有两个层面。第一,在代码里监听onblocked和onversionchange事件,给用户一个明确的提示,避免白屏无响应。第二,收到onversionchange事件时,主动关闭旧连接的数据库:

db.onversionchange = function () { db.close(); window.location.reload(); };

这是多标签页应用的标准做法:当检测到版本变更请求,主动释放旧连接,让新版本可以继续升级。如果你没做任何处理,至少在开发阶段要意识到这个机制,别把onblocked当成代码 bug 去瞎调。

4.2 事务提前结束:写入静默丢失的元凶

事务自动提交这个问题,我在第 2 部分已经提过,这里给一个更隐蔽的案例。假设你想循环插入一万条数据,写了这样的代码:

function insertMany(db, items) { const tx = db.transaction('people', 'readwrite'); const store = tx.objectStore('people'); items.forEach(item => store.add(item)); }

这段代码通常是能成功的,因为store.add的请求在同一个同步循环里全部发出去,事务不会提前结束。但如果你把它改成异步版本,比如forEach内部等待某个异步操作,事务就可能在等待期间自动提交,后续请求全部失败,而且因为你没有监听错误,数据就“静默丢失”了。

一个稳妥的做法是监听事务的onerror和onabort,至少把失败暴露出来:

tx.onerror = function (event) { console.error('事务出错:', event.target.error); }; tx.onabort = function () { console.error('事务被回滚'); };

另一个做法是把写入任务拆成批次,一批一个事务,比如每 100 条一个事务,这样既避免单个事务过大导致性能问题,也能在某个批次失败时不影响其它批次。写批处理之前先想清楚事务边界,这是 IndexedDB 开发里最重要的心智模型。

4.3 回调地狱与执行顺序混乱:一切异步都要排队

IndexedDB 的 API 全是异步的,但它设计得很老派——事件回调,没有 Promise。这意味着你一旦写复杂逻辑,很容易出现“回调里套回调”,然后执行顺序完全失控。

我见过一个很典型的 bug:先写一条记录,马上按名字去查,结果查出来是空的。原因是写入的onsuccess虽然触发了,但事务还没完全提交,紧接着的只读事务去查的时候,数据还没真正入库。不同事务之间没有自动的先后等待,你必须自己保证顺序。

所以入门阶段就要树立一个习惯:把 IndexedDB 的所有操作都封装成 Promise,然后用async/await串起来。这样写代码不仅顺序可控,错误处理也集中。后面第 5 部分我会给一个完整封装,这里先记住一句话:IndexedDB 的请求不是函数返回值,而是事件结果,你需要把“事件”翻译成“可等待的值”。

4.4 索引字段变化导致查询不到数据

有一次用户反馈,某条联系人记录了手机号,但按手机号索引查询就是查不到。逐条排查发现,那条数据是在旧版本代码里写入的,当时并没有手机号这个字段,索引在写入时把缺失字段的记录跳过了。升级代码后,虽然建了索引,但旧数据没有被重新索引。

这个问题的根源是:索引只覆盖“建索引之后写入或更新的数据”,不会自动回填历史数据。解决办法也简单,升级建索引之后,需要用游标把所有旧数据读出来,重新put一遍,让索引重新生成。类似的情况也出现在唯一索引上,如果旧数据里有两个人的手机号相同,那么建立唯一索引时会直接报错,这种数据清洗工作只能在升级脚本里手动做,没有捷径。

4.5 私有模式与存储配额:跨浏览器的兼容细节

IndexedDB 在主流浏览器里都支持,但隐私模式是例外。Safari 的私有模式曾经对本地存储限制很严格,IndexedDB 写入会直接抛 QuotaExceededError;Firefox 的隐私窗口早期也有类似限制。如果你的应用面向隐私模式用户,务必监听onerror并把错误信息展示出来,而不是让用户以为功能坏了。

另外,浏览器给 IndexedDB 的配额并不是无限大。Chrome 通常会根据磁盘剩余空间动态分配,但如果同时使用了 Cache Storage、IndexedDB 和 localStorage,总量超限时就会报配额错误。处理策略是定期清理无用的旧数据,比如给数据加时间戳,用游标批量删除超过保留期限的记录。真到配额问题爆发的那天,再回头补清理逻辑,成本会非常高。

5. 把代码包装成 Promise 工具:让 IndexedDB 好用十倍

5.1 为什么几乎每个项目都要封装一层

如果你看过 GitHub 上一些老项目,会发现很早就有社区库帮你封装 IndexedDB,比如 idb、Dexie.js 这些。它们解决的核心问题只有一个:把“事件回调”翻译成“Promise”,让你能像写普通 JavaScript 异步函数一样去操作数据库。

我不建议一上来就引库,因为 IndexedDB 的主要难点不在代码量,而在理解模型。但当你写完几个增删改查之后,再回头写任何新功能,如果每次都要手动 new Promise、再监听 onsuccess,代码会非常啰嗦。封装一层轻量的 Promise 工具,是收益极高的投资。

5.2 一个极简封装思路

核心是三个工具函数。

第一个,把单个请求包装成 Promise:

function requestToPromise(request) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); }

第二个,把打开数据库包装成 Promise,并在升级时执行建表逻辑:

function openDB(name, version, upgrade) { return new Promise((resolve, reject) => { const request = indexedDB.open(name, version); request.onupgradeneeded = () => upgrade(request.result); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); }

第三个,组合事务的完成时机。这里有个细节,请求的 onsuccess 不代表事务已提交,如果你要在写入后立刻发起下一个事务,最好等待事务的 oncomplete:

function transactionDone(tx) { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); }

有了这三个工具,增删改查就变成了:

const db = await openDB('contactBook', 1, (database) => { const store = database.createObjectStore('people', { keyPath: 'id', autoIncrement: true }); store.createIndex('name_index', 'name', { unique: false }); }); async function addPerson(data) { const tx = db.transaction('people', 'readwrite'); const store = tx.objectStore('people'); const id = await requestToPromise(store.add(data)); await transactionDone(tx); return id; } async function getPerson(id) { const tx = db.transaction('people', 'readonly'); const store = tx.objectStore('people'); return requestToPromise(store.get(id)); }

比起裸 API,这套封装最大的价值是:你不再需要记每个操作是 request 还是 transaction,所有函数统一async/await,错误处理也能通过 try-catch 靠拢到同一条代码路径上。实际项目里我还会在此基础上加一个“自动重试”和“限流”的逻辑,但入门阶段做到这一步已经足够。

5.3 进阶扩展:备份导出和跨页面同步

封装好之后,IndexedDB 真正好用的功能才能舒展开。我最常用的两个扩展,一个是数据备份,一个是跨标签页同步。

数据备份很简单,用游标遍历所有数据,组装成 JSON,然后下载到本地:

function exportDatabase(db, storeName) { return new Promise((resolve) => { const tx = db.transaction(storeName, 'readonly'); const store = tx.objectStore(storeName); const request = store.openCursor(); const result = []; request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { result.push(cursor.value); cursor.continue(); } else { resolve(result); } }; }); }

这个函数能帮用户把本地数据导出成 JSON 文件,需要恢复时再写一个导入函数,逐条put回去。对于纯本地应用来说,这是最朴素的灾备方案。

跨标签页同步则可以监听storage事件或者直接监听 IndexedDB 相关的自定义窗口事件,当数据更新时通知其它标签页刷新视图。IndexedDB 本身没有原生的跨标签页通知机制,但你可以配合BroadcastChannel或者 localStorage 的storage事件来广播“数据已更新”,让所有标签页重新查询并渲染。这样就能把 IndexedDB 用成一个简易的“本地共享数据中心”,多页签之间数据保持一致。

我的个人体会是,IndexedDB 真正难的不是 API 本身,而是你把后端数据库那套思维搬不搬得过来。只要理解对象仓库、索引、事务这三个关键词,再写一遍增删改查和游标遍历,它就会从一个“看不懂的怪东西”变成“一个熟悉的数据库”。如果你第一次接触,别急着引库,先把我这段最小示例在自己浏览器里跑通,亲手打断点看看每次事件触发的顺序,再回去读文档,一定事半功倍。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 17:37:31

LVI-SAM多传感器融合实战:Ubuntu 20.04+ROS Noetic环境搭建与Ceres调优

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 17:37:00

指令流水线实战:从Logisim搭建到CPU性能优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 17:36:39

深度学习驱动的角色动画:MotorNerve交互动画系统实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 17:33:52

SecureCRT-9.6.3添加配色文件

目的默认白色背景闪瞎眼&#xff0c;并且关键词不能高亮&#xff0c;所以需要调整下。下面楼主演示用的版本是SecureCRT-9.6.3中文绿色版的&#xff0c;感兴趣的读者可以网上找一下&#xff0c;或者淘宝买一个正版的第一步下载配色文件的目录&#xff0c;可以自行去网上搜索&am…

作者头像 李华
网站建设 2026/10/2 17:33:45

生物燃料颗粒服务商哪个靠谱?福建振裕新能源质量参考评选

福建振裕新能源有限公司扎根福建省会福州&#xff0c;是省内专注生物质燃料颗粒研发生产的本土源头厂家&#xff0c;依托福建丰富林业资源&#xff0c;以实木纯刨花为原料&#xff0c;为全省企事业单位提供清洁、经济的用能解决方案。福建振裕新能源有限公司成立以来始终扎根实…

作者头像 李华