这个30天轻量项目系列写到第6天,Day06算是让我真正感受到“重构比新写更费脑子”的一天。前5天我已经把一个用于收集碎片链接的书签小工具从空白HTML文件做到了能增删改查的可用状态,但第5天晚上自己试用几轮之后,还是觉得它距离“每天主动打开”差得很远:数据刷新靠手动、搜索筛选排序的逻辑散落在各种函数里、键盘彻底没法用。于是Day06那天我砍掉了原计划里“加标签云”这个新功能,把一整天都砸在了数据层梳理和交互打磨上。这篇文章就把这天的思路、代码实现、踩坑记录都摊开来讲,给同样在按日推进小项目的朋友一个参考。
1. 项目复盘:前5天做了什么,Day06为什么选这条路
1.1 30天挑战的背景与项目范围
我给自己定的挑战是30天内只用一个原生前端技术栈,做出一套能真的天天用的效率工具。选这个方向的原因很简单:日常我们收藏文章、链接都是往浏览器书签栏一丢,时间一长就变成乱葬岗,存了等于没存。所以这个项目的目标很具体——做一个离线的、可以打标签、快速筛选、支持全文搜索的书签管理面板。
项目范围被刻意压得很小,界面就三个区域:左侧是筛选栏,中间是书签列表,底部是一个收集输入条。不做登录、不做服务端同步、不做浏览器插件,先保证本地体验足够好。30天听起来很宽裕,实际上前3天搭完基础功能之后,第4、5天基本都在处理各种边角问题,到Day06才真正有机会把“能跑”和“好用”之间的差距补上。
1.2 前5天做到哪儿了
前5天的技术选型和完成情况大致是:
- 技术栈:原生JavaScript + Vite + 手写CSS,没有引框架,就是想借这个项目把原生DOM操作重新练扎实。
- 已完成功能:添加链接、删除、编辑标题、一键收藏、归档。
- 数据层:一开始偷懒直接用localStorage存数组,加一两百条数据时还挺顺畅,到第5天塞了几百条测试数据之后,每次操作都要序列化整个数组,明显能感觉到卡顿。
- 交互状态:搜索框、状态筛选、排序方式这3个维度各自独立改变,然后直接改DOM,结果就是“输入一个关键词之后,刚才选好的排序又乱了”。
说白了,前5天是把一个原型搭出来了,但它还是一个“只能自己看着办”的样子,距离“可以推荐给身边人用”还差一轮结构性的整理。
1.3 Day06的任务拆解:从“能跑”到“好用”
Day06我给自己定了三个小目标,按优先级排:
- 把数据层从localStorage迁到IndexedDB,并封装成一个可复用的异步数据仓库;
- 把状态统一收敛到一个简单的全局Store里,所有UI渲染都从状态推导,而不是到处散落着直接操作DOM的语句;
- 补上搜索防抖、筛选排序组合、键盘快捷键这类“用了就回不去”的交互细节。
这三个目标本质上都指向同一件事:让项目在数据量变大、交互变复杂之后,心智负担不会跟着失控。我见过太多小工具死在“功能不多但代码乱成一团麻”的阶段,Day06的核心价值就是把混乱的部分提前拆掉,而不是继续往上叠功能。事实证明,这个选择是对的,因为后面做批量编辑和数据导出时,我基本没有返工。
2. 核心设计:数据结构、状态管理与持久化选型
2.1 书签对象的数据结构设计
动手写代码之前,先把数据模型定清楚。这个步骤看着基础,但很多人就是懒得做,最后所有混乱都从这里来。我定义的书签对象长这样:
// link 对象结构 { id: 1, // 主键,自增 title: 'MDN Web Docs', url: 'https://developer.mozilla.org/', description: '前端开发参考资料', tags: ['reference', 'frontend'], status: 'unread', // 'unread' | 'starred' | 'archived' createdAt: 1737456000000, // 时间戳,创建时生成 updatedAt: 1737456000000 // 时间戳,每次修改后更新 }几个有意的设计决定,这里说清楚为什么:
- 用自增数字id当主键,而不是直接用url。原因很实际:同一个链接你可能想存两次,一次是“工作相关的重要参考”,一次是“周末的灵感来源”,两者的标签和批注完全不一样。如果用url做主键,数据会互相覆盖,损失很大。
- tags用数组,不用逗号分隔字符串。数组在展示和筛选时都不用再拆解,配合IndexedDB还能很方便地做索引查询。
- createdAt和updatedAt都用数字时间戳,不用日期字符串。字符串日期在排序时会按字典序排列,很容易在“1月”和“2月”之间翻车,时间戳则是纯数值比较,稳妥得多。
- status就三种:unread待读、starred标星、archived归档。不搞复杂的状态机,够用即可。
2.2 状态管理:为什么不能靠“随手改DOM”
前端项目一开始写起来最快的方式,确实是哪里需要变化就直接操作DOM。比如搜索结果变了,就去清空列表容器,再循环插入新行。但这种做法的问题是:当搜索、筛选、排序这三个维度同时存在时,“现在应该展示哪些数据”这个问题的答案散落在各个函数和临时变量里,很容易互相覆盖。
Day06我做了一个很小的全局状态对象,配合发布订阅模式,所有UI都从一份数据推导出来:
// store.js const store = { links: [], filterStatus: 'all', // all | unread | starred | archived keyword: '', sortBy: 'createdAt', // createdAt | title sortDir: 'desc', // asc | desc _listeners: new Set(), }; export function getState() { return store; } export function setState(patch) { Object.assign(store, patch); store._listeners.forEach((listener) => listener()); } export function subscribe(listener) { store._listeners.add(listener); return () => store._listeners.delete(listener); }这个模式虽然只有十几行,但价值非常大。页面上的搜索输入框、筛选按钮、排序下拉框都只做一件事:调用setState更新状态。唯一订阅状态的函数则统一负责渲染列表。从此之后,“UI为什么显示成这样”这个问题永远有唯一答案——去看store里的状态就行。这是Day06最简单也最值得抄走的一段代码。
2.3 持久化选型:localStorage还是IndexedDB
前5天用localStorage,到了第6天决定迁移到IndexedDB。我知道很多读者会问:几百条数据localStorage明明也能跑,为什么非要换?我把两者的对比列一下:
| 维度 | localStorage | IndexedDB |
|---|---|---|
| 存储上限 | 通常5MB左右 | 远大于这个量级,数百MB没问题 |
| 数据结构 | 只能存字符串,需要自己序列化 | 可以直接存对象、数组、Blob |
| 查询能力 | 读取后全量遍历 | 支持索引、游标、范围查询 |
| API形态 | 同步API,简单 | 异步API,回调风格,比较难用 |
| 适合场景 | 配置项、短字符串 | 结构化数据、附件、大量记录 |
换成IndexedDB不仅仅是“存得下”的问题,更重要的是搜索和排序可以按索引取数。后面几天我还计划加数据导出和批量编辑功能,如果数据层还是localStorage这种“一次性全量读取”的方案,后面操作会越来越吃力。所以这个迁移早晚都要做,Day06做刚刚好。
2.4 把IndexedDB封装成好用的异步仓库
IndexedDB本身API设计确实啰嗦,直接裸写的话每次操作都要处理onupgradeneeded、onsuccess、onerror三套回调。为了让Day06后面几天能写得舒服,我在storage/db.js里做了一层Promise封装:
const DB_NAME = 'daily-link-db'; const DB_VERSION = 1; let dbInstance = null; function openDB() { return new Promise((resolve, reject) => { if (dbInstance) { resolve(dbInstance); return; } const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('links')) { const store = db.createObjectStore('links', { keyPath: 'id', autoIncrement: true, }); store.createIndex('createdAt', 'createdAt', { unique: false }); store.createIndex('status', 'status', { unique: false }); store.createIndex('tags', 'tags', { unique: false, multiEntry: true }); } }; request.onsuccess = (event) => { dbInstance = event.target.result; resolve(dbInstance); }; request.onerror = (event) => { reject(event.target.error); }; }); }在openDB里特别注意两点:一是把dbInstance缓存起来,避免每次操作都重新打开数据库;二是只在onupgradeneeded里创建对象仓库和索引,索引的维护由浏览器在写数据时自动完成,不需要业务代码额外处理。
接着封装CRUD和查询方法:
// storage/linkRepository.js import { openDB } from './db.js'; export const LinkRepository = { async add(link) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction('links', 'readwrite'); const store = tx.objectStore('links'); const request = store.add(link); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); }, async getAll() { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction('links', 'readonly'); const store = tx.objectStore('links'); const request = store.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); }, async update(id, patch) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction('links', 'readwrite'); const store = tx.objectStore('links'); const getRequest = store.get(id); getRequest.onsuccess = () => { const data = getRequest.result; if (!data) { reject(new Error(`Item with id ${id} not found`)); return; } const updated = { ...data, ...patch, updatedAt: Date.now() }; store.put(updated); resolve(updated); }; getRequest.onerror = () => reject(getRequest.error); }); }, async remove(id) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction('links', 'readwrite'); const store = tx.objectStore('links'); const request = store.delete(id); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); }, };这段代码看起来“土”,但真的很可靠。事务的commit由浏览器自动管理,我不需要显式调用,只要确保所有request都在事务里完成即可。实际用下来,这套封装应付Day06到Day10的迭代绰绰有余。
3. Day06实操记录:搜索、筛选、排序与键盘交互
3.1 搜索:防抖、分词与命中逻辑
搜索是“每天打开”这个动作里最常触发的一个入口。Day06我给搜索框接上了250毫秒防抖,避免每敲一个字就立刻触发一次全量筛选。
function debounce(fn, delay = 250) { let timer = null; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; } const handleSearch = debounce((value) => { setState({ keyword: value.trim() }); }, 250);有人会觉得250毫秒太久,但实际测试下来,连续输入时每40毫秒左右就会触发一个输入事件,不防抖的话,一次“前端开发”四个字能触发六七次搜索,而且在数据量大时还可能出现旧结果覆盖新结果的错乱问题。250毫秒在感知上几乎没有延迟,又能把触发次数压到最低。
输入后的命中逻辑,我做了关键词分词和AND匹配。也就是说,用户输入“前端 设计”,会同时要求标题、URL或者标签里既包含“前端”又包含“设计”,而不是把它们当成一个完整字符串去匹配。这样搜出来的结果更符合“我记得有一篇讲前端的,好像还提到设计”这种模糊记忆场景。
export function queryLinks(links, params) { const { keyword = '', filterStatus = 'all', sortBy = 'createdAt', sortDir = 'desc' } = params; let result = [...links]; if (filterStatus !== 'all') { result = result.filter((item) => item.status === filterStatus); } if (keyword.trim()) { const words = keyword.toLowerCase().split(/\s+/).filter(Boolean); result = result.filter((item) => { const haystack = `${item.title} ${item.url} ${(item.tags || []).join(' ')}`.toLowerCase(); return words.every((word) => haystack.includes(word)); }); } result.sort((a, b) => { if (sortBy === 'title') { const diff = a.title.localeCompare(b.title, 'zh-Hans-CN', { sensitivity: 'base' }); return sortDir === 'asc' ? diff : -diff; } const diff = a.createdAt - b.createdAt; return sortDir === 'asc' ? diff : -diff; }); return result; }这里所有匹配都统一转成小写再做比较,避免“JavaScript”和“javascript”被视为不同关键词。标签数组在拼接时用空格隔开,这样分词逻辑就不用区分标签边界了,处理起来很省事。
3.2 筛选和排序:状态组合决定渲染结果
筛选和排序不是彼此独立的功能,它们共同决定“最终展示哪些数据”。Day06之前我踩过的坑就是:筛选函数里处理了状态过滤,排序函数里又单独处理排序,两个函数各做各的,结果先筛选后排序和先排序后筛选,在数据量变大时会出现不一致。
现在所有读取列表的路由都只有一条:store发生变化 -> 调用queryLinks -> 拿到结果渲染DOM。这样筛选和排序的优先级就非常明确:先过滤,再排序。不管用户先点“已归档”还是先改排序方式,最终结果都由同一段代码算出。
更关键的是,筛选按钮的UI状态也存到store里了:
document.querySelectorAll('[data-filter]').forEach((btn) => { btn.addEventListener('click', () => { setState({ filterStatus: btn.dataset.filter }); }); });按钮的选中态同样由状态渲染,不偷懒直接改class。这样做的好处是,下次刷新页面后想恢复筛选条件,甚至可以顺手存进sessionStorage,整个UI和状态严格对应,排查问题极其方便。
3.3 键盘快捷键:从“能用”到“愿意用”
一个自己天天用的工具,鼠标点来点去很快就会烦。Day06我加了一组键盘快捷键,按我这个“效率工具党”的习惯来设计:
| 快捷键 | 动作 |
|---|---|
/ | 聚焦搜索框 |
Alt + A | 把当前选中项归档 |
Alt + S | 给当前选中项标星 |
Esc | 清空搜索关键词并恢复全部列表 |
? | 显示快捷键帮助 |
第一个踩坑点是:如果不加判断,在搜索框里打字时按/会变成输入内容,而不是聚焦搜索框。所以我写了一个工具函数来判断当前焦点是否在可输入元素上:
function isTypingTarget(target) { return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable; } document.addEventListener('keydown', (event) => { if (event.isComposing) return; // 输入法组合态忽略 if (event.key === '/' && !isTypingTarget(event.target)) { event.preventDefault(); searchInput.focus(); } if (event.key === 'Escape' && isTypingTarget(event.target)) { searchInput.value = ''; setState({ keyword: '' }); } });我特别把event.isComposing的判断放到了最前面,这个细节后面在踩坑部分会详细说。快捷键不一定要多,但每个都得稳定触发,如果按下去没反应或者误触,还不如不放。
3.4 空状态与加载状态:被低估的体验细节
数据列表在“有数据”和“无数据”之间的过渡,很多时候被当成边角料处理。但实际用下来,空状态做得不好,会让人怀疑数据是不是丢了。Day06我给列表区分了三种空状态:
- 收藏夹本身为空时,显示“还没有保存任何链接”和一句提示;
- 筛选条件导致为空时,显示“当前筛选条件下没有内容”,并给出清除筛选的按钮;
- 搜索关键词没有命中时,显示“没找到匹配结果”,并提醒可以试试更短的关键词。
第2、3种状态特别重要。用户搜索之后一无所获,如果界面直接变成空白,第一反应往往是“我是不是操作坏了”。加上这种有引导性的提示,工具就给人“靠谱”的感觉。这个细节虽然不涉及复杂技术,但对工具的日常使用体验相当加分。
4. 踩坑实录:Day06修的5个问题
4.1 数据库一直用的旧数据:版本号升级的坑
第一天把IndexedDB接好之后,我在代码里给links对象仓库加了一个tags索引,然后怎么调试都查不到新索引,读出来的数据永远是老的。折腾了半天,发现indexedDB的版本号还停在1。
浏览器判断“要不要触发onupgradeneeded”的标准,不是看你的代码有没有变化,而是看你传入的版本号变大没有。所以我改数据结构之后必须把DB_VERSION从1改成2:
const DB_VERSION = 2;升级版本后,onupgradeneeded里除了创建对象仓库,还要判断旧仓库是否需要迁移:
request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('links')) { const store = db.createObjectStore('links', { keyPath: 'id', autoIncrement: true }); store.createIndex('createdAt', 'createdAt', { unique: false }); } else { const store = event.target.transaction.objectStore('links'); if (!store.indexNames.contains('tags')) { store.createIndex('tags', 'tags', { unique: false, multiEntry: true }); } } };这个教训给我最大的启发是:IndexedDB的版本号是给“数据结构迁移”用的,不是随便写个数字就完事。以后每次改字段、加索引,第一件事就是把版本号往上加,不然你面对的问题会非常隐蔽。
4.2 搜索结果被旧请求覆盖:异步竞态
把IndexedDB封装成Promise之后,我用了一个很自然的方式去绑定搜索事件:每输入一次,就执行一次异步查询然后渲染结果。看起来逻辑没问题,但实测连敲“前端设计”四个字时,页面上有时会闪出旧的关键词搜索结果。
原因我想了一会儿才反应过来:异步操作的返回顺序由内部耗时决定,不一定和发送顺序一致。第一次搜索“前”可能因为数据库稍忙,返回比第二次搜索“前端”还晚,晚返回的旧结果就把新结果覆盖了。
解决办法是给每次查询带一个自增的请求序号,只有最新一次请求的结果才允许渲染:
let querySeq = 0; async function refreshLinks() { const currentSeq = ++querySeq; const all = await LinkRepository.getAll(); if (currentSeq !== querySeq) return; // 已经过期,丢弃 const result = queryLinks(all, getState()); renderList(result); }这种方式在带异步操作的前端交互里很常见,也让Day06之后的批量编辑功能少踩了很多坑。
4.3 输入法打字时按下快捷键:isComposing
键盘快捷键刚上线时,我用中文输入法在搜索框里打字,按/希望输入一个“/”符号,结果页面焦点直接跳走了。这个问题的根源是中文输入法打字时会有一段“组合输入”过程,在组合态里,按键事件代表的不是最终字符,而是输入法的中间状态。
解决方案就是本章前面提到的event.isComposing判断。它在支持组合输入事件的现代浏览器里非常可靠:
document.addEventListener('keydown', (event) => { if (event.isComposing) return; // 正常快捷键逻辑 });另外,我还把所有快捷键在isTypingTarget时直接跳过,只有Esc这种“清空”操作例外。这个组合判断一出,中文用户输入时的心情简直好了十倍。
4.4 初始化函数被重复调用:事件重复绑定
Day06做到一半,我发现自己写了一个init()函数,用来绑定搜索、筛选按钮和键盘事件。本来是好事,但在调试刷新时,我先调了一次init(),又手动调了一次,结果键盘事件被绑定了两次,按一次Esc清空一次搜索,页面要闪烁两下才正常。
这类“重复初始化”问题在原生JS项目里太容易出现了。解决办法是给init加一个简单幂等标记:
let initialized = false; export function init() { if (initialized) return; initialized = true; bindEvents(); loadInitialData(); }如果你用模块化开发,这个标记放模块级再合适不过,整个生命周期只初始化一次。后来我把这个思路也带到了其他工具项目里,非常有效。
4.5 排序时英文大小写和中文混排乱序
筛选排序做好以后,我按标题排序时发现一堆以“go”和“Go”开头的条目没有排在一起,英文大小写挨着中文时排序结果也不符合直觉。原因是我用普通的a.title.localeCompare(b.title),没有指定语言和灵敏度。
修正后的代码顺便解决了中文拼音排序的问题:
result.sort((a, b) => { const titleA = a.title || ''; const titleB = b.title || ''; return titleA.localeCompare(titleB, 'zh-Hans-CN', { sensitivity: 'base' }); });sensitivity设为base时,比较会忽略大小写和音标差异,这样“Go”和“go”就被当成同一个顺序级别,旁边“指南”这类中文会按照拼音去排。这个设置对中文用户真的友好。
5. 第7天开工计划与这天的个人体会
5.1 第7天要做的数据导出/导入
Day07我计划给工具加数据导出/导入功能,这是本地工具最后的保命索。思路很简单:把所有数据从IndexedDB读出来,序列化成JSON文件下载;导入时读取用户选择的JSON文件,清空现有仓库后批量写入。
技术难点主要在“批量写入IndexedDB时的进度控制”,不能一次性无脑发起大量事务,否则浏览器会卡顿。考虑用分批chunk的方式,每批50条,写入完成后再处理下一批。这样既能看到导入进度,也避免主线程长时间无响应。
为什么把导出导入放Day07而不是更早?因为Day06完成了store的统一,导出时的数据来源非常清楚,直接从state.links序列化就行。要是放在Day05之前做,还要面对“列表展示的数据和库里真实的数据不一致”这种混乱局面,反而更难写。
5.2 连写6天之后,我最想提醒自己的三件事
连续写到第6天,我对自己这个项目的推进方式有了更清楚的判断。这里分享三句实在话:
第一,状态统一得越早,后面几天越轻松。Day06做store的整理虽然花了大半天,但当天晚上我加“标签点击筛选”功能时只写了几行代码,因为只需要更新filterStatus和filterTag这两个状态,渲染逻辑完全不用动。如果继续沿用之前到处改DOM的方式,这个功能又要零散写一堆。
第二,IndexedDB的封装值得花时间做好。很多教程把IndexedDB的README贴一贴就完事,但真正好用的是Promise封装和将异常、竞态都收口在一起的数据访问层。Day06把这部分做扎实之后,之后无论是批量编辑还是导出,都只是在这个Repository上继续加方法。
第三,做工具类项目,体验细节和功能本身同样重要。搜索防抖、快捷键、空状态提示,这些单看都不起眼,合在一起决定了用户到底会不会每天打开它。Day06让我重新理解了什么叫“完工度”——不是所有按钮都存在,而是常用路径润得足够顺滑。
我期待Day07实测完批量导入后再回来更新,踩坑记录应该比这一篇还多。