做高仿项目最怕的就是做到一半发现某个“不起眼的小功能”比想象中复杂。day4 我本来计划半天搞定笔记记录模块,结果硬是折腾了一整天。这个模块表面上看就是写个文字输入框、存一存数据,但真正做下来发现它牵扯到状态管理、数据持久化、组件拆分、交互细节一堆事。这篇就把我在 day4 的实现过程和踩坑记录完整复盘一下,给同样在做仿写项目、或者打算自己做笔记类功能的朋友一个参考。
1. 笔记记录模块的整体设计与技术选型
1.1 需求拆解:笔记记录到底要做什么
先理清楚“笔记记录”在这个高仿网易云项目里的定位。它不是做一个独立的备忘录 App,而是跟音乐播放场景深度结合的记录功能:用户可以在听歌的时候,对当前这首歌写下感想、标注歌词里打动自己的句子,或者给某个歌单写一段整体评价。所以我第一件事就是把需求拆成几个功能点:
- 新建笔记:可以从歌曲详情页、播放页进入,默认带上当前歌曲信息
- 编辑笔记:支持修改标题和正文,自动保存
- 笔记列表:按更新时间倒序排列,展示摘要和所属歌曲
- 删除笔记:带确认交互,防止误触
- 搜索过滤:按关键词过滤笔记内容(这个我放到后期做,day4 先留好接口)
需求拆完之后会发现,这个模块的难点不在于单个功能有多复杂,而在于它跟播放器模块之间的数据联动。比如从播放页跳转到笔记编辑页时,要能拿到当前播放歌曲的 id、歌名、歌手,这些数据平时都在播放器 store 里,如果笔记模块自己再维护一份,很容易出现数据不同步的问题。所以设计上第一步就是确定数据流:笔记数据独立管理,但创建入口可以通过参数从播放器传入歌曲信息。
1.2 技术栈选型:为什么是 Vue 3 + Pinia + localStorage
我这次的高仿项目用的是 Vue 3 + Vite,状态管理选了 Pinia。之前在 Vue 2 时代我习惯用 Vuex,但 Vue 3 的 Composition API 跟 Pinia 配合起来确实更顺。Pinia 最大的优势是写法更简洁,没有 mutations 那一层概念,直接在 actions 里改 state,对于我这种一个人开发的小项目来说减少了很多模板代码。如果团队项目或者有严格的数据流审计需求,Vuex 可能更合适,但个人项目追求开发效率,Pinia 是更务实的选择。
数据持久化这块,day4 我先用 localStorage 搞定。原因很简单:笔记数据量小,单条文本几 KB,用户正常使用也就存个几十条笔记,撑死几百 KB,远没到 localStorage 5MB 的上限。而且不需要后端配合,前端自己就能闭环。我见过不少人一上来就上 IndexedDB,或者直接接后端接口,结果把简单问题复杂化。笔记功能的第一版,把本地存储做到位,后面再平滑迁移到后端也不迟。
组件划分上我拆了三个:NoteList(列表页)、NoteEditor(编辑页)、NoteItem(列表项卡片)。另外把自动保存的逻辑封装成了一个 composable,叫 useAutoSave,这样编辑器组件里直接复用,其他地方如果也要做自动保存也能调用。组件拆分的核心原则是一个组件只干一件事,列表页不做编辑逻辑,编辑器不关心列表怎么渲染。
2. 数据结构与状态管理:先把地基打好
2.1 笔记数据结构设计
数据结构是整个笔记模块的地基,这块我在动工前花了比较长时间想清楚。如果设计得不好,后面加功能很容易这补一块那补一块,代码越来越乱。我的笔记对象结构设计如下:
{ id: 'note_1721314567890_abc123', title: '《晴天》单曲循环中', content: '前奏一响,直接回到学生时代...', musicId: 'song_12345', musicName: '晴天', artist: '周杰伦', createdAt: 1721314567890, updatedAt: 1721314867890 }这里有几个字段是刻意加上的。musicId 和 musicName 是关联歌曲信息的,用途是笔记列表里展示“这首歌写的笔记”的上下文。很多人做笔记功能只存标题和正文,但在这个场景里,如果不记录歌曲信息,以后想按歌曲维度去检索笔记就没法做。createdAt 和 updatedAt 用的是时间戳而不是格式化后的字符串,这个细节很重要:时间戳是数字,方便排序和计算,展示的时候再格式化成“刚刚”“5 分钟前”“昨天”这种相对时间,比直接存字符串灵活得多。
id 的生成我用了时间戳加随机字符串的方式,保证同一毫秒内创建多条笔记也不会冲突。如果你项目里引入了 uuid 库也可以用,但为了一个 id 引入额外依赖个人觉得不值,手写一个简易函数就够用。
2.2 Pinia 中如何管理笔记状态
Pinia store 的写法非常直观,notes 模块的核心逻辑如下:
import { defineStore } from 'pinia' export const useNoteStore = defineStore('note', { state: () => ({ notes: [], currentNoteId: null, loading: false }), getters: { currentNote(state) { return state.notes.find(note => note.id === state.currentNoteId) || null }, sortedNotes(state) { return [...state.notes].sort((a, b) => b.updatedAt - a.updatedAt) }, notesCount(state) { return state.notes.length } }, actions: { async addNote(data) { const now = Date.now() const note = { id: generateNoteId(), ...data, createdAt: now, updatedAt: now } this.notes.push(note) this.saveToStorage() return note }, async updateNote(id, data) { const index = this.notes.findIndex(note => note.id === id) if (index === -1) return this.notes[index] = { ...this.notes[index], ...data, updatedAt: Date.now() } this.saveToStorage() }, async deleteNote(id) { this.notes = this.notes.filter(note => note.id !== id) this.saveToStorage() }, saveToStorage() { localStorage.setItem('netease_notes', JSON.stringify(this.notes)) }, loadFromStorage() { const data = localStorage.getItem('netease_notes') if (data) { try { this.notes = JSON.parse(data) } catch (e) { console.error('笔记数据解析失败', e) this.notes = [] } } } } })所有修改 notes 的操作都通过 action 完成,每个 action 在修改完 state 后统一调用 saveToStorage。这样做的好处是持久化逻辑收敛在一个地方,不会出现某个组件里改完了 state 却忘了存 localStorage 的情况。你可能注意到 action 是 async 的,虽然目前没有真正的异步操作,但将来切后端接口时,这里只需要改内部实现,调用方不用动。
2.3 时间戳的处理与显示
时间戳存的是一串毫秒数字,用户界面不能直接这么展示。我封装了一个 formatRelativeTime 函数,根据当前时间和笔记更新时间的差值,动态显示不同粒度的相对时间:
export function formatRelativeTime(timestamp) { const diff = Date.now() - timestamp const minute = 60 * 1000 const hour = 60 * minute const day = 24 * hour if (diff < minute) return '刚刚' if (diff < hour) return Math.floor(diff / minute) + ' 分钟前' if (diff < day) return Math.floor(diff / hour) + ' 小时前' if (diff < 30 * day) return Math.floor(diff / day) + ' 天前' const date = new Date(timestamp) return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日` }这里有个小坑是列表渲染时每个笔记项都会调用一次格式化函数,如果列表很长,频繁计算 Date.now() 会有轻微性能损耗,不过对笔记这种低频场景来说无伤大雅。如果要做实时性更强的时间刷新,可以加个定时器每隔一分钟触发一次重渲染,但一般情况下进入页面时计算一次就够了。
排序规则方面,我让 sortedNotes 这个 getter 按 updatedAt 做倒序排列。这意味着用户编辑过的笔记会自动置顶,符合使用习惯。注意不要直接在 state 的 notes 上做原地排序,那样会修改原始数组,应该用展开运算符创建副本再排序。
3. 笔记列表与编辑器的实操实现
3.1 列表页:仿网易云的卡片式布局
网易云界面的视觉特点我一直觉得是“克制”:大量留白、色彩以红黑灰为主、卡片圆角适中、不花哨。笔记列表页我沿用了这个风格,整体布局是上下结构,顶部导航栏放返回按钮和“笔记”标题,下面是一个滚动列表。
列表项卡片的设计逻辑参考了网易云歌单卡片:左侧是一个 56x56 的圆角封面位,暂时用歌曲封面图(没有封面就用网易云标志性的红色渐变底加音乐图标),右侧是笔记标题、内容摘要和歌曲信息。摘要部分通过 CSS 强制单行省略号展示,超过一行就截断,防止列表页被长文本撑得参差不齐:
.note-content-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: #999; }空状态这块容易被忽略,但实际体验影响很大。用户第一次进来什么都没有,不能白屏,我设计了一个居中的空状态:一个浅灰色的音符图标,下面写着“写点关于音乐的想法吧”,再配一个“新建笔记”按钮。这里的文案我用的是“写点想法”而不是“新建笔记”,因为前者更口语化,和网易云整体的社区氛围更搭。
列表进入编辑页我用了 vue-router 的命名路由,跳转时带上笔记 id:
router.push({ name: 'note-edit', params: { id: note.id } })新建笔记的入口统一走一个不带 id 的路由,编辑器组件内部通过判断有没有 id 来决定是编辑模式还是新建模式。这样做的好处是列表页不用关心新建和编辑的区别,只负责跳转。
3.2 编辑器:带自动保存的输入体验
笔记编辑页是这个模块的核心,交互设计上我参考了网易云评论框的思路:一个简洁的标题输入框,一个占满剩余空间的正文编辑区,底部一行小字显示“内容自动保存至本地”。同时支持从播放页跳转过来时,标题和正文都为空,但歌曲信息直接展示在顶部,用户不需要自己填写歌名。
自动保存的实现我封装了一个 useAutoSave composable:
import { watch, onBeforeUnmount } from 'vue' export function useAutoSave(source, saveFn, delay = 800) { let timer = null watch(source, () => { clearTimeout(timer) timer = setTimeout(() => { saveFn() }, delay) }, { deep: true }) onBeforeUnmount(() => { clearTimeout(timer) }) }使用方式很简单,在编辑器组件里把表单数据传进去,再传一个保存函数:
const form = reactive({ title: '', content: '' }) useAutoSave(form, () => { if (currentNote.value) { noteStore.updateNote(currentNote.value.id, { title: form.title, content: form.content }) } else { noteStore.addNote({ title: form.title, content: form.content, ...songInfo.value }) } })这里有几个细节值得说。防抖时间设了 800ms,用户输入停顿了 0.8 秒才触发保存,既不会每敲一个字符就写一次 localStorage,也不会因为防抖时间太长导致关页面时数据还没存上。编辑器底部加了一个保存状态提示,监听 store 里的 notes 变化,数据写成功后提示一下,不然用户不知道到底保存了没有,体验很悬。
3.3 删除与确认交互
删除笔记这种不可逆操作,我设计了两种入口:列表页左滑出现删除按钮(移动端),以及编辑页右上角的删除图标(桌面端)。因为做的是响应式布局,左滑操作在鼠标环境下不好用,所以 PC 端以编辑页删除为主,列表页只保留一个长按删除的能力,桌面端暂时隐藏。
删除确认弹窗我完全自己写了一个轻量组件,没有引第三方 UI 库。网易云的弹窗风格是底部弹出的 ActionSheet 形式,我照着做了一个简化版:一个半透明遮罩,中间一个白色圆角卡片,上面写着“删除这篇笔记?”,下面两个按钮,“取消”和“删除”。“删除”按钮用红色文字,这是网易云里危险操作的标准配色,用户看一眼就明白。
删除完成后有一个小细节:从列表页删除,返回列表页时列表已经自动少了那条数据,因为 store 是响应式的;从编辑页删除,需要手动 router.back() 回到列表页,不然会停留在一个已经被删除的笔记编辑界面,再输入内容时会报错。
4. 数据持久化与边缘情况处理
4.1 localStorage 的容量限制与异常处理
localStorage 虽然用起来简单,但有几个坑在开发时就要处理好。第一是容量上限,大多数浏览器是 5MB,按单条笔记平均 5KB 算,理论上能存 1000 条。但要注意 localStorage 存储的是字符串,保存的时候 JSON.stringify 整个 notes 数组,如果用户长时间高频使用,这个数组可能会非常大。我加了一层保护:保存前判断 JSON 字符串长度,超过 4MB 就只保留最近 100 条笔记,并在控制台警告用户数据已达上限。
第二是解析异常。localStorage 里的数据如果被用户手动改了格式,或者旧版本的数据结构和现在不兼容,JSON.parse 会直接抛错。我在 loadFromStorage 里用 try...catch 包了一层,解析失败就清空存储从零开始,而不是让页面白屏。这里唯一的缺陷是用户数据会丢失,但至少应用不会崩,比报错卡死强得多。
第三是隐私模式下的问题。Safari 的隐私模式对 localStorage 的写入会抛异常,一些旧版浏览器的隐私模式里 localStorage 根本不可用。处理办法是在 saveToStorage 里同样做 try...catch,捕获失败后用内存变量兜底。这样至少当前会话还能正常使用,虽然刷新后会丢数据,但不会影响整体功能。
4.2 笔记与播放器的联动设计
笔记模块跟播放器联动是这个项目的点睛之笔。我的方案是播放页底部加一个“记笔记”的悬浮按钮,点击后把当前歌曲信息通过路由参数传到笔记编辑页:
function openNoteForCurrentSong() { const current = playerStore.currentSong if (!current) return router.push({ name: 'note-edit', query: { musicId: current.id, musicName: current.name, artist: current.artist } }) }编辑页在 onMounted 时检查路由参数,有 musicId 就在顶部展示歌曲信息条,并把歌曲名自动填入标题(用户可修改),正文留空。用户写完保存,笔记就自动带上了歌曲上下文。这个联动设计的核心思路是“尽量少让用户输入”:歌曲信息自动带出、标题预填、正文留白,用户只需要写最核心的内容。
还有一个处理:如果用户从歌词页的长按菜单进入笔记,歌词中选中的文本会自动带入正文内容。这个功能我 day4 先做了接口预留,实际实现放在了后面的轮次。交互上其实很简单,歌词组件里用 selection 相关 API 取选中文本,跳转时通过 query 参数传过去,编辑器读到了就填进正文。
4.3 多标签页同步与刷新丢失问题
localStorage 有一个天然的坑:如果用户开了两个标签页,A 标签页改了笔记,B 标签页的 store 里还是旧数据。解决方法是监听 storage 事件,这个事件只会在其他标签页修改 localStorage 时触发,当前标签页自身的修改不会触发:
window.addEventListener('storage', (event) => { if (event.key === 'netease_notes') { noteStore.loadFromStorage() } })加上这段后,两个标签页之间就能自动同步了,比较适合用户一边开着播放器页面、一边开着笔记页面的使用场景。
刷新丢失的问题就更重要了。用户写了半天笔记,一刷新全没了,这种体验是致命的。我做了两层保障:第一层是编辑器里所有内容都实时绑定到响应式表单,并且通过防抖同步到 store,只要用户停顿了 0.8 秒数据就在 store 里了;第二层是 store 初始化时调用 loadFromStorage,把上次保存的数据读出来。App.vue 的 onMounted 里也主动调用一次 loadFromStorage,确保组件树里任何地方拿到的都是持久化之后的数据。
5. 常见问题与排查技巧实录
5.1 输入大量文字时页面卡顿
我在测试时遇到过一个很实际的问题:当笔记正文超过几千字,输入每个字符都能感觉到明显的延迟。排查后发现原因主要有两个。第一是 v-model 直接绑定到 pinia store 里的笔记对象上,每次输入都会触发整个 store 的更新和 localStorage 的写入(虽然写了防抖,但 watch 的 deep 监听会让整个 store 依赖的组件都重新渲染)。第二是编辑器页面没有拆小组件,输入内容一长,整个页面包括侧边栏、底部栏全部重渲染。
解决办法是编辑器内部用 reactive 表单作为中间层,不直接改 store 数据,只在保存时一次性提交到 store。同时把编辑器的输入区域拆成独立组件,输入区域内部用 computed 的 getter/setter 把 v-model 映射到本地变量:
const localContent = ref(form.content) watch(localContent, (val) => { form.content = val })Vue 的响应式系统本身性能很强,但要尽量避免大范围依赖。表单数据用 ref 和 reactive 包一层,跟 store 解耦,输入性能立刻上来了。
5.2 自动保存没生效,刷新后内容丢失
这个问题的原因比较隐蔽。我刚写完自动保存时,测试发现输入内容后立即刷新,数据十有八九会丢。排查后发现是防抖时间太长,用户输入完还没到 800ms 就按了刷新,定时器被销毁,保存函数根本没执行。
解决方式是在 beforeUnmount 和 beforeRouteLeave(vue-router 的组件内守卫)里手动执行一次立即保存:
onBeforeRouteLeave(() => { saveNow() })同时监听浏览器的 beforeunload 事件,用户关闭或刷新页面时强制保存一次:
window.addEventListener('beforeunload', saveNow)这样即使防抖没等完,页面离开前也会把当前输入内容写入 localStorage。不过要注意 saveNow 里不能再用 setTimeout 了,必须是同步调用 localStorage.setItem。
5.3 列表页与编辑页数据不同步
我在把列表页和编辑页联调时发现问题:在编辑页修改标题后返回列表页,列表里显示的标题还是旧的。排查过程是这样的:列表页的数据来自 noteStore 的 sortedNotes getter,而编辑页修改的是编辑器的本地表单,保存时把数据写进了 store。理论上 getter 会自动更新,但列表页组件用了 keep-alive 缓存,返回时不会重新渲染,所以界面停留在了旧状态。
解决方法是列表页的 activated 生命周期钩子里强制刷新一下列表数据。如果项目里没用 keep-alive,也可以把列表页的 onMounted 改成 onActivated,这样需要缓存时能生效,不需要时也不影响。另一种更通用的做法是列表项组件用 key 绑定笔记 id,数据变化时 Vue 会根据 key 自动重建组件,但这样会把 input 焦点清掉,编辑器场景不适用。
5.4 样式在不同设备上的适配问题
网易云的界面风格在手机上很耐看,但放到宽屏显示器上容易显得内容过宽、信息密度太低。我的做法是给列表和编辑器都设置了最大宽度,超过 480px 时居中显示,模拟移动端 App 的观感。编辑器内部用 flex 布局,标题输入和正文区域分别撑满,不让底部保存状态条被键盘顶出视线。
移动端的另一个坑是软键盘弹出后,固定定位的底部栏会被顶到键盘上方,导致布局跳变。处理方法是监听 visualViewport 的 resize 事件,键盘弹起时底部栏改回静态定位,收起时恢复固定定位。这个处理能明显提升移动端输入体验。
写在最后的一点心得
day4 做下来,最大的体会是一个看起来不起眼的笔记功能,真正落地要处理的细节比预想的多得多。数据设计、状态管理、组件通信、异常恢复、多端适配,每个环节都会冒出新问题。但反过来看,这种从 0 到 1 把功能完整做出来的过程,才是做仿写项目最有价值的地方——你以为你在模仿,其实你在学习设计。
如果后续还有 day5、day6,我会优先把笔记搜索、歌词联动笔记、图片粘贴上传做出来,同时把本地存储迁移到后端接口,做成多端同步。做笔记功能的朋友可以留意一下我上面提到的这些坑,特别是保存时机和组件缓存这两个问题,提前规避能省下不少调试时间。