资讯详情

Vue实战笔记本案例:组件通信与本地存储全解析

📅 2026/10/15 11:32:36 | 华诺云谱 👁 阅读
Vue实战笔记本案例:组件通信与本地存储全解析
1. 为什么选这个笔记本案例来练手1.1 从标题拆出来的真实需求“教你使用Vue实战笔记本案例”这个标题表面看是一个练手项目但真正做过前端的人一眼就能看出它背后藏着三层需求。第一层是框架入门很多人学完Vue的模板语法、计算属性、组件注册之后不知道下一步该干什么需要一个完整的小项目把知识点串起来。第二层是数据持久化笔记本类应用天然涉及数据的增删改查而前端新手最容易忽略的就是“刷新页面数据就没了”这个问题所以这个案例必然要处理本地存储。第三层是交互体验一个笔记本如果只是几个输入框加列表那跟记事本没区别真正有价值的是搜索、筛选、编辑、删除、时间戳这些细节。我见过太多人学Vue卡在“单个知识点都懂但让我从零写一个东西就懵了”的阶段。这个笔记本案例恰好是一个最小可闭环的项目功能不多不少既不会像后台管理系统那样庞大到让人放弃也不会像计数器那样简单到学不到东西。它覆盖了组件拆分、状态管理、事件通信、生命周期、本地存储、列表渲染、条件渲染、表单绑定这些核心概念而且每一个都能在实际编码中用到。1.2 这个案例适合谁不适合谁如果你已经能独立用Vue写一个带路由和状态管理的完整项目那这个案例对你来说偏基础可以快速扫一眼然后去挑战更复杂的场景。但如果你属于以下任何一种情况这个案例值得你从头到尾手敲一遍刚学完Vue基础语法还没写过超过200行的完整组件写过一些Demo但代码组织混乱不知道哪些该拆成子组件对v-model、props、$emit的配合使用总是记不清没处理过本地存储不知道什么时候该存、什么时候该读想找一个能放在简历里的小项目但不知道做什么我个人的经验是学框架最有效的方式不是看文档看到吐而是找一个功能闭环的小项目先自己写一遍再对照别人的实现改一遍。这个笔记本案例就非常适合这个流程。1.3 最终效果长什么样在开始写代码之前先明确我们要做出来的东西。整个应用是一个单页面的笔记本顶部有标题和搜索框中间是笔记列表底部或顶部有一个“新建笔记”的按钮。每条笔记包含标题、内容、创建时间、最后修改时间。点击笔记可以展开编辑编辑状态下可以修改标题和内容保存后更新时间戳。搜索框输入关键词后列表实时过滤出匹配的笔记。删除笔记时有一个确认提示防止误操作。界面不需要多华丽但交互要顺畅。我见过一些教程把界面做得花里胡哨结果核心逻辑讲得含糊不清读者跟着敲完也不知道自己学了什么。所以这个案例的重点放在数据流和组件通信上样式用最基础的CSS保证你复制过去就能跑。2. 项目整体设计与技术选型2.1 为什么用Vue而不是其他框架这个问题其实值得认真想一下。React、Vue、Angular、Svelte都能做笔记本应用为什么选Vue从学习成本来说Vue的模板语法对新手最友好v-for、v-if、v-model这些指令一看就懂不需要先理解JSX和函数式编程的概念。从项目规模来说笔记本这种中小型应用不需要引入复杂的状态管理库Vue的响应式系统配合组件自身的data就足够了。从生态来说Vue的官方文档质量很高遇到问题查文档基本能解决。还有一个很实际的原因Vue的单文件组件结构清晰。一个.vue文件里template、script、style分得明明白白对于新手来说这种结构比React的JSX混写更容易理解。当然这不是说React不好只是在这个特定场景下Vue的上手曲线更平缓。2.2 组件拆分方案一个常见的误区是把所有东西都写在一个组件里。我见过一个笔记本应用整个文件超过800行template里嵌套了五六层改一个功能要上下翻半天。所以这个案例从一开始就要做好组件拆分。我的方案是拆成三个组件App.vue根组件负责整体布局和全局状态管理持有笔记列表数据NoteList.vue笔记列表组件接收笔记数组作为prop负责渲染列表和触发编辑/删除事件NoteEditor.vue笔记编辑组件接收当前编辑的笔记对象负责表单绑定和保存/取消操作这样拆的好处是每个组件的职责单一。App管数据NoteList管展示NoteEditor管编辑。数据流向也很清晰App通过props把数据传给子组件子组件通过$emit把事件传回App。这就是Vue最经典的单向数据流模式。注意不要为了拆分而拆分。如果某个组件只有两三行模板那没必要单独拆出来。拆分的标准是“这个部分是否有独立的状态或逻辑”。2.3 数据存储方案的选择笔记本应用的数据存哪里有三种常见方案方案优点缺点适用场景内存data简单无需额外代码刷新即丢失纯演示localStorage持久化API简单容量有限同步操作小型本地应用IndexedDB容量大支持复杂查询API复杂异步操作大型离线应用这个案例选localStorage。原因很简单笔记本的数据量通常不大localStorage的5MB容量足够存几千条笔记API只有getItem和setItem两个方法不需要额外学习成本同步操作意味着不需要处理异步回调代码更直观。但localStorage有一个坑它只能存字符串。所以存的时候要用JSON.stringify把数组转成字符串读的时候用JSON.parse转回来。这个转换过程如果忘了做读出来的就是[object Object]排查起来很浪费时间。2.4 项目文件结构src/ ├── App.vue ├── main.js ├── components/ │ ├── NoteList.vue │ └── NoteEditor.vue └── assets/ └── style.css这个结构足够简单不需要引入Vue Router或Pinia。对于这个规模的应用多引入一个库就多一层学习成本得不偿失。等你把这个案例吃透了再去学路由和状态管理也不迟。3. 核心功能实现与代码拆解3.1 数据模型设计在写任何界面之前先把数据结构定下来。一条笔记应该包含哪些字段我的设计是这样的{ id: 1, // 唯一标识用于列表渲染的key和查找 title: 笔记标题, // 标题用于列表展示和搜索 content: 笔记内容, // 正文内容 createdAt: 2024-01-01, // 创建时间 updatedAt: 2024-01-01 // 最后修改时间 }id的生成方式有很多种。简单一点可以用Date.now()但如果在同一毫秒内创建两条笔记就会冲突。更稳妥的方式是用Date.now() Math.random()或者维护一个自增计数器。我一般用时间戳加随机数实际项目中冲突概率极低。createdAt和updatedAt用ISO格式的字符串存储展示的时候再格式化成用户友好的形式。这样做的好处是存储和展示分离以后要改展示格式不需要动存储的数据。3.2 根组件App.vue的实现App.vue是整个应用的核心它持有笔记列表数据并负责协调子组件之间的通信。先看script部分export default { name: App, data() { return { notes: [], searchKeyword: , editingNote: null } }, computed: { filteredNotes() { if (!this.searchKeyword.trim()) { return this.notes } const keyword this.searchKeyword.toLowerCase() return this.notes.filter(note note.title.toLowerCase().includes(keyword) || note.content.toLowerCase().includes(keyword) ) } }, watch: { notes: { handler(newVal) { localStorage.setItem(notes, JSON.stringify(newVal)) }, deep: true } }, created() { const saved localStorage.getItem(notes) if (saved) { try { this.notes JSON.parse(saved) } catch (e) { this.notes [] } } }, methods: { addNote() { const now new Date().toISOString() const newNote { id: Date.now() Math.random(), title: 新建笔记, content: , createdAt: now, updatedAt: now } this.notes.unshift(newNote) this.editingNote newNote }, saveNote(updatedNote) { const index this.notes.findIndex(n n.id updatedNote.id) if (index ! -1) { this.notes.splice(index, 1, { ...updatedNote, updatedAt: new Date().toISOString() }) } this.editingNote null }, deleteNote(id) { const index this.notes.findIndex(n n.id id) if (index ! -1) { this.notes.splice(index, 1) } }, editNote(note) { this.editingNote { ...note } }, cancelEdit() { this.editingNote null } } }这里有几个关键点值得展开说。computed和watch的分工。filteredNotes是计算属性它依赖notes和searchKeyword只要这两个中任何一个变化它就会重新计算。这比在模板里写过滤逻辑要清晰得多而且Vue会缓存计算结果性能更好。watch监听notes的变化一旦变化就写入localStorage。注意这里用了deep: true因为我们要监听数组内部元素的变化而不仅仅是数组引用的变化。created生命周期读数据。在组件创建时从localStorage读取数据这里加了一个try-catch因为如果存储的数据被手动改坏了JSON.parse会抛异常不加捕获的话整个应用会白屏。这种防御性编程在实际项目中非常重要。addNote用unshift而不是push。新笔记放在列表最前面用户创建后立刻能看到体验更好。如果用push放在最后用户还得往下滚才能找到。saveNote用splice替换而不是直接赋值。Vue 2的响应式系统对数组的索引赋值this.notes[index] newNote不会触发视图更新必须用splice或者Vue.set。这是新手最容易踩的坑之一。Vue 3用Proxy重写了响应式系统这个问题不存在了但如果你还在用Vue 2这一点必须记住。3.3 笔记列表组件NoteList.vueNoteList的职责很纯粹接收笔记数组渲染列表把用户的编辑和删除意图通过事件传出去。export default { name: NoteList, props: { notes: { type: Array, required: true } }, methods: { formatDate(isoString) { const date new Date(isoString) const year date.getFullYear() const month String(date.getMonth() 1).padStart(2, 0) const day String(date.getDate()).padStart(2, 0) const hours String(date.getHours()).padStart(2, 0) const minutes String(date.getMinutes()).padStart(2, 0) return ${year}-${month}-${day} ${hours}:${minutes} }, handleEdit(note) { this.$emit(edit, note) }, handleDelete(id) { if (confirm(确定要删除这条笔记吗)) { this.$emit(delete, id) } } } }formatDate方法把ISO格式的时间转成2024-01-01 12:30这种易读格式。padStart(2, 0)确保月份和日期都是两位数这是很实用的小技巧。删除确认用confirm而不是自定义弹窗虽然样式不好看但胜在简单可靠。如果你想让界面更精致可以换成自定义的Modal组件但那是另一个话题了。模板部分template div classnote-list div v-fornote in notes :keynote.id classnote-item clickhandleEdit(note) div classnote-header h3 classnote-title{{ note.title || 无标题 }}/h3 button classdelete-btn click.stophandleDelete(note.id)删除/button /div p classnote-preview{{ note.content || 暂无内容 }}/p div classnote-meta span创建{{ formatDate(note.createdAt) }}/span span修改{{ formatDate(note.updatedAt) }}/span /div /div div v-ifnotes.length 0 classempty-tip 还没有笔记点击上方按钮新建一条吧 /div /div /template这里有两个细节。第一删除按钮的点击事件用了click.stop阻止事件冒泡到父级的click否则点删除会同时触发编辑。第二v-ifnotes.length 0显示空状态提示这是提升用户体验的小细节很多教程会忽略。3.4 笔记编辑组件NoteEditor.vueNoteEditor接收一条笔记的副本在本地编辑保存时把修改后的数据传回父组件。export default { name: NoteEditor, props: { note: { type: Object, required: true } }, data() { return { localNote: { id: null, title: , content: , createdAt: , updatedAt: } } }, watch: { note: { handler(newVal) { this.localNote { ...newVal } }, immediate: true } }, methods: { handleSave() { if (!this.localNote.title.trim() !this.localNote.content.trim()) { alert(标题和内容不能同时为空) return } this.$emit(save, { ...this.localNote }) }, handleCancel() { this.$emit(cancel) } } }这里的关键设计是本地副本。为什么不直接修改prop因为Vue的单向数据流原则规定子组件不能直接修改父组件传下来的数据。如果直接改prop虽然Vue不会报错对象类型但会导致数据流向混乱排查问题的时候很痛苦。所以用localNote作为本地副本编辑它保存时再emit出去。watch里的immediate: true确保组件创建时就把note的值同步到localNote。如果不加这个第一次打开编辑时localNote是空的。模板部分template div classnote-editor div classeditor-header input v-modellocalNote.title classtitle-input placeholder请输入标题 / /div textarea v-modellocalNote.content classcontent-input placeholder请输入内容 rows10 /textarea div classeditor-actions button classsave-btn clickhandleSave保存/button button classcancel-btn clickhandleCancel取消/button /div /div /templatev-model在input和textarea上的使用是Vue表单绑定的基础但有一个细节v-model默认监听input事件对于中文输入法来说在拼音阶段就会触发更新。如果你需要在输入完成后再处理可以用compositionstart和compositionend配合但这个案例不需要这么复杂。3.5 根组件模板与组件通信App.vue的模板把三个部分串起来template div idapp header classapp-header h1我的笔记本/h1 div classheader-actions input v-modelsearchKeyword classsearch-input placeholder搜索笔记... / button classadd-btn clickaddNote新建笔记/button /div /header main classapp-main NoteList :notesfilteredNotes editeditNote deletedeleteNote / NoteEditor v-ifeditingNote :noteeditingNote savesaveNote cancelcancelEdit / /main /div /template注意NoteList接收的是filteredNotes而不是notes这样搜索功能就自动生效了。NoteEditor用v-ifeditingNote控制显示没有编辑中的笔记时整个编辑器不渲染。组件通信的完整链路是这样的用户在NoteList里点击某条笔记NoteList触发edit事件App接收到后把editingNote设为该笔记NoteEditor因为v-if变为true而显示同时通过prop接收到笔记数据。用户编辑后点击保存NoteEditor触发save事件App更新notes数组editingNote置空NoteEditor隐藏NoteList因为notes变化而重新渲染。这条链路走通了Vue的组件通信你就掌握了。4. 样式处理与交互优化4.1 基础样式方案这个案例不引入UI框架用原生CSS写样式。原因有两个一是避免引入额外依赖保持项目纯净二是让你理解样式是怎么作用于组件的。* { margin: 0; padding: 0; box-sizing: border-box; } #app { max-width: 800px; margin: 0 auto; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; } .app-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #eee; } .header-actions { display: flex; gap: 12px; } .search-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; width: 200px; outline: none; } .search-input:focus { border-color: #4a90d9; } .add-btn { padding: 8px 16px; background: #4a90d9; color: white; border: none; border-radius: 6px; cursor: pointer; } .add-btn:hover { background: #3a7bc8; }box-sizing: border-box是必须的否则padding会把元素撑大计算宽度的时候很麻烦。max-width: 800px配合margin: 0 auto让内容居中这是最经典的布局方式。4.2 笔记列表的样式细节.note-item { padding: 16px; border: 1px solid #eee; border-radius: 8px; margin-bottom: 12px; cursor: pointer; transition: box-shadow 0.2s; } .note-item:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .note-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .note-title { font-size: 16px; font-weight: 600; color: #333; } .delete-btn { padding: 4px 8px; background: transparent; color: #e74c3c; border: 1px solid #e74c3c; border-radius: 4px; cursor: pointer; font-size: 12px; opacity: 0; transition: opacity 0.2s; } .note-item:hover .delete-btn { opacity: 1; } .note-preview { color: #666; font-size: 14px; line-height: 1.5; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .note-meta { display: flex; gap: 16px; font-size: 12px; color: #999; }删除按钮默认opacity: 0鼠标悬停在笔记上时才显示。这样做的好处是列表看起来更干净不会满屏都是删除按钮。transition让显示和隐藏有过渡效果不会太突兀。note-preview用text-overflow: ellipsis和white-space: nowrap实现单行省略如果内容太长就显示省略号。这是列表展示的常用技巧。4.3 编辑器的样式与交互.note-editor { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; z-index: 100; } .note-editor div { background: white; border-radius: 12px; padding: 24px; width: 90%; max-width: 600px; } .title-input { width: 100%; padding: 12px; font-size: 18px; font-weight: 600; border: none; border-bottom: 2px solid #eee; outline: none; margin-bottom: 16px; } .title-input:focus { border-bottom-color: #4a90d9; } .content-input { width: 100%; padding: 12px; font-size: 14px; line-height: 1.6; border: 1px solid #eee; border-radius: 8px; outline: none; resize: vertical; margin-bottom: 16px; } .content-input:focus { border-color: #4a90d9; } .editor-actions { display: flex; justify-content: flex-end; gap: 12px; } .save-btn { padding: 8px 20px; background: #4a90d9; color: white; border: none; border-radius: 6px; cursor: pointer; } .cancel-btn { padding: 8px 20px; background: #f5f5f5; color: #666; border: none; border-radius: 6px; cursor: pointer; }编辑器用position: fixed覆盖全屏背景半透明黑色内容居中。这是最经典的Modal实现方式不需要任何JavaScript计算位置。resize: vertical允许用户垂直拖动调整textarea高度但禁止水平拖动防止布局被破坏。5. 常见问题与排查技巧实录5.1 数据不更新问题速查现象可能原因解决方法修改数组元素后视图不变Vue 2无法检测索引赋值用splice或Vue.set修改对象属性后视图不变属性不在data中声明提前在data中声明所有属性搜索后列表不刷新computed依赖未正确建立检查computed中是否引用了响应式数据刷新页面数据丢失未写入localStorage检查watch是否配置deep读取localStorage报错存储的不是合法JSON加try-catch并给默认值这个表格里的每一条我都实际遇到过。特别是第一条当年用Vue 2写列表的时候直接this.list[0] newItem视图死活不更新排查了半天才发现是响应式系统的限制。后来养成习惯凡是修改数组一律用splice。5.2 组件通信的常见坑坑一子组件直接修改prop。有些新手图省事在子组件里直接this.note.title 新标题因为传的是对象引用父组件的数据确实会变视图也会更新。但这破坏了单向数据流一旦数据出问题你根本不知道是哪个组件改的。正确做法是用本地副本保存时emit事件。坑二事件名大小写。Vue 2中$emit(saveNote)和save-note是不匹配的HTML属性不区分大小写所以事件名建议用kebab-case短横线分隔。Vue 3支持了自动转换但为了兼容性统一用短横线最稳妥。坑三v-if和v-show混用。编辑器的显示用v-if而不是v-show因为编辑器只在需要时渲染不需要一直存在于DOM中。如果频繁切换用v-show性能更好但编辑器不是频繁切换的场景。5.3 本地存储的边界情况localStorage虽然简单但有几个边界情况必须处理存储满了localStorage有5MB限制存几千条笔记可能就满了。写入时应该用try-catch包裹满了就提示用户清理。隐私模式某些浏览器的隐私模式下localStorage不可用setItem会直接抛异常。所以读写都要加try-catch。数据被手动修改用户可能在开发者工具里手动改了localStorage的值导致JSON格式错误。读取时的try-catch就是防这个的。多标签页同步如果用户开了两个标签页一个标签页的修改不会自动同步到另一个。可以用window.addEventListener(storage, ...)监听变化但这个案例不需要这么复杂。// 更健壮的存储方案 function safeSetItem(key, value) { try { localStorage.setItem(key, JSON.stringify(value)) return true } catch (e) { console.error(存储失败, e) return false } } function safeGetItem(key, defaultValue null) { try { const item localStorage.getItem(key) return item ? JSON.parse(item) : defaultValue } catch (e) { console.error(读取失败, e) return defaultValue } }这两个函数我建议直接抄到你的项目里以后所有localStorage操作都走这两个函数能省掉很多排查时间。5.4 性能优化的几个实用技巧笔记本应用数据量不大性能问题不明显但养成好习惯很重要。列表渲染必须加key。v-for的:key用笔记的id不要用索引。用索引的话删除中间某条笔记后后面所有笔记的索引都会变Vue会错误地复用DOM节点导致输入框内容错位等问题。搜索用computed而不是method。computed有缓存只有依赖变化时才重新计算。如果写成method每次重新渲染都会执行过滤数据量大时会有明显卡顿。防抖处理搜索输入。如果笔记很多每次输入都触发过滤会有延迟。可以用防抖让用户停止输入300毫秒后再过滤data() { return { searchKeyword: , debouncedKeyword: } }, watch: { searchKeyword: { handler(val) { clearTimeout(this._timer) this._timer setTimeout(() { this.debouncedKeyword val }, 300) } } }, computed: { filteredNotes() { if (!this.debouncedKeyword.trim()) return this.notes // ...过滤逻辑 } }这个优化在笔记数量少的时候感知不明显但养成习惯后以后做大数据量列表时就不会手忙脚乱。6. 从能跑到好用几个提升体验的细节6.1 自动聚焦与快捷键新建笔记后光标应该自动聚焦到标题输入框用户可以直接开始打字。实现方式是在NoteEditor的mounted钩子里调用focusmounted() { this.$nextTick(() { this.$refs.titleInput.focus() }) }$nextTick确保DOM已经渲染完成。如果不加focus可能作用在还不存在的元素上。另外可以加一个快捷键按Esc取消编辑按CtrlEnter保存。这需要在mounted里监听键盘事件在beforeDestroy里移除监听防止内存泄漏。6.2 空状态与加载状态空状态提示前面已经提到了但还有一个细节搜索无结果时的提示应该和完全没有笔记时的提示区分开。前者是“没有找到匹配的笔记”后者是“还没有笔记点击新建”。这样用户能明确知道是搜索没匹配到还是真的没有数据。6.3 数据导出与导入这个功能不是必须的但加上去会让项目更完整。导出就是把notes数组转成JSON字符串用Blob和URL.createObjectURL生成下载链接。导入就是读取用户选择的JSON文件解析后合并到notes数组。这两个功能加起来不到50行代码但能让你的项目看起来专业很多。exportNotes() { const dataStr JSON.stringify(this.notes, null, 2) const blob new Blob([dataStr], { type: application/json }) const url URL.createObjectURL(blob) const link document.createElement(a) link.href url link.download notes-backup-${Date.now()}.json link.click() URL.revokeObjectURL(url) }URL.revokeObjectURL别忘了调用否则内存不会释放。6.4 移动端适配如果想让这个笔记本在手机上也能用需要加一个viewport meta标签并把布局改成响应式。搜索框在窄屏下可以换行显示编辑器的宽度用90%而不是固定像素。这些改动不大但能让你的项目适配更多设备。我在实际开发中踩过最深的坑不是某个API不会用而是数据流设计混乱。一开始我把笔记数据放在NoteList组件里编辑功能放在NoteEditor里两个组件各自维护一份数据结果保存后列表不更新因为数据不同步。后来把数据统一提到App组件子组件只负责展示和触发事件问题才解决。这个教训让我明白Vue的组件通信核心就是“数据往下传事件往上传”把这个原则守住再复杂的应用也不会乱。另外分享一个调试技巧在Vue DevTools里可以实时查看每个组件的数据和props排查问题时比console.log高效得多。特别是组件嵌套深的时候DevTools能直接定位到数据在哪一层出了问题。这个工具建议每个Vue开发者都装上。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑