查资料时,临时记下一条命令;学习时,写下还没弄懂的问题;工作时,随手记录接下来要处理的事情。
这些内容往往很短,却经常需要临时找地方放。
这一课,我们做一个浏览器便签:打开网页就能写,停止输入后自动保存,再次打开同一个地址时恢复内容。
完成后,你会得到一个包含这些功能的小工具:
- 多行文本输入。
- 停止输入约半秒后自动保存。
- 页面重新打开时恢复上次保存的内容。
- 显示字符数量和保存状态。
- 保存失败时明确提示,并支持手动重试。
本篇提供独立完整代码,没有看过前四课,也可以直接运行。
如果你一直在跟做前面的效率工具箱,文末也会说明怎样把便签放回原来的项目。
先看一个使用场景:
今天的学习记录 1. 弄懂 localStorage 的用途 2. 练习 input 事件 3. 给便签加上自动保存输入这些文字,稍微停顿一下,页面就会显示:
已保存到本浏览器 · 具体时间此时刷新页面,刚才保存的文字就会重新出现在输入框中。
这里的“保存到本浏览器”有明确范围:它使用 localStorage 保存内容,不会自动同步到另一台电脑。清理网站数据、使用不同浏览器或无痕窗口,也可能影响内容保留。直接用file:///打开的 HTML,其存储行为没有统一保证,练习时建议使用固定的本地 HTTP 地址。参考:MDN localStorage
先把小工具做出来,再拆开看它的实现。
在项目文件夹中新建note.html,将下面的代码完整复制进去。使用记事本保存时,文件类型选择“所有文件”,编码选择 UTF-8,确认文件名没有变成note.html.txt。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0" > <title>自动保存便签</title> <style> * { box-sizing: border-box; } body { margin: 0; padding: 40px 16px; background: #f5f7fb; color: #1f2937; font-family: "Microsoft YaHei", Arial, sans-serif; } .note-panel { width: 100%; max-width: 760px; margin: 0 auto; padding: 28px; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 18px; box-shadow: 0 12px 35px rgba(31, 41, 55, 0.06); } .note-panel h1, .note-panel h2 { margin: 0 0 12px; font-size: 26px; } .note-panel .note-description { margin: 0 0 24px; color: #6b7280; line-height: 1.8; } .note-panel label { display: block; margin-bottom: 10px; font-weight: bold; } .note-panel textarea { display: block; width: 100%; min-height: 300px; padding: 16px; border: 1px solid #d1d5db; border-radius: 12px; background: #fcfcff; color: #1f2937; font: inherit; font-size: 16px; line-height: 1.8; resize: vertical; } .note-panel textarea:focus { outline: 3px solid #ede9fe; border-color: #7c3aed; } .note-panel .note-actions { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: 16px; } .note-panel #note-count { color: #6b7280; font-size: 14px; } .note-panel button { padding: 10px 18px; border: 0; border-radius: 8px; background: #7c3aed; color: #ffffff; font: inherit; cursor: pointer; } .note-panel button:disabled { opacity: 0.5; cursor: not-allowed; } .note-panel #note-status { min-height: 24px; margin: 16px 0 0; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; } .note-panel [data-state="saved"] { color: #15803d; } .note-panel [data-state="pending"] { color: #92400e; } .note-panel [data-state="error"] { color: #b91c1c; } .note-panel .note-tip { margin: 12px 0 0; color: #6b7280; font-size: 13px; line-height: 1.7; } </style> </head> <body> <main class="note-panel"> <h1>📝 我的随手便签</h1> <p class="note-description"> 记录想法、命令和学习笔记,停止输入后自动保存。 </p> <label for="note-editor">便签内容</label> <textarea id="note-editor" placeholder="例如:今天要弄懂 JavaScript 的 input 事件……" aria-describedby="note-tip" ></textarea> <div class="note-actions"> <span id="note-count">0 个字符</span> <button id="save-note" type="button">立即保存</button> </div> <p id="note-status" role="status"></p> <p id="note-tip" class="note-tip"> 内容保存在当前浏览器,重要记录请另行备份。 </p> </main> <script> (() => { const editor = document.querySelector("#note-editor"); const count = document.querySelector("#note-count"); const status = document.querySelector("#note-status"); const saveButton = document.querySelector("#save-note"); const KEY = "efficiency-toolbox.note.v1"; let timer = null; let dirty = false; let composing = false; let readable = true; // 更新字符数量 function updateCount() { count.textContent = Array.from(editor.value).length + " 个字符"; } // 取消之前安排的保存 function cancelPendingSave() { clearTimeout(timer); timer = null; } // 保存当前内容 function saveNote() { cancelPendingSave(); if (!readable || !dirty) return; try { localStorage.setItem(KEY, editor.value); dirty = false; status.textContent = "已保存到本浏览器 · " + new Date().toLocaleTimeString(); status.dataset.state = "saved"; } catch (error) { status.textContent = "保存失败:请先复制内容备份," + "再点击立即保存重试。"; status.dataset.state = "error"; console.error("便签保存失败:", error); } } // 内容改变后,重新安排保存 function scheduleSave() { dirty = true; updateCount(); cancelPendingSave(); status.textContent = composing ? "输入中……" : "尚未保存,停止输入后自动保存……"; status.dataset.state = "pending"; if (!composing) { timer = setTimeout(saveNote, 500); } } // 页面打开时恢复内容 try { const saved = localStorage.getItem(KEY); editor.value = saved ?? ""; status.textContent = saved === null ? "写点什么吧,停止输入后会自动保存。" : "已恢复上次保存的便签。"; status.dataset.state = "saved"; } catch (error) { readable = false; editor.readOnly = true; saveButton.disabled = true; status.textContent = "无法读取本地便签,已暂停编辑。" + "请检查浏览器存储权限后重试。"; status.dataset.state = "error"; console.error("便签读取失败:", error); } updateCount(); // 中文输入法开始组词 editor.addEventListener("compositionstart", () => { composing = true; cancelPendingSave(); }); // 中文输入法结束组词 editor.addEventListener("compositionend", () => { composing = false; scheduleSave(); }); // 输入、粘贴、删除内容时安排保存 editor.addEventListener("input", scheduleSave); // 输入框失去焦点时尝试立即保存 editor.addEventListener("blur", saveNote); // 手动保存或失败后重试 saveButton.addEventListener("click", saveNote); // 页面进入后台时,尝试保存待保存内容 document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { saveNote(); } }); // 离开页面时再尝试保存 window.addEventListener("pagehide", saveNote); })(); </script> </body> </html>已经使用第四课本地服务器的同学,保持服务器运行,在同一端口打开note.html即可。
如果电脑已经安装 Python 3,也可以在存放该文件的文件夹中打开终端,运行:
python -m http.server 8000 --bind 127.0.0.1然后访问:
打开本地便签页面
后续继续使用同一个浏览器和地址进行测试。
先输入两行文字,等待页面显示“已保存”,再刷新。确认内容恢复后,我们来看自动保存究竟是怎样发生的。
便签需要监听用户修改文本的动作,因此代码使用了input事件:
editor.addEventListener("input", scheduleSave);它能响应用户输入、删除和粘贴等导致输入框内容变化的操作。相比只监听键盘按键,这更贴合“文本内容改变了”这一需求。不过,通过 JavaScript 直接设置value,不会自动触发这个事件。参考:MDN input 事件
每次内容变化,程序先做三件事:
dirty = true; updateCount(); cancelPendingSave();dirty表示“当前内容有尚未保存的修改”。
它不是在判断内容是否错误,而是在记录:页面里的内容已经变化,需要重新保存。
随后,程序安排半秒后执行保存:
timer = setTimeout(saveNote, 500);如果你在这半秒内继续输入,之前的计时器就会被取消,再重新安排一次。
例如,你连续输入三个字:
输入“学” → 安排保存 输入“习” → 取消上一次,重新安排 输入“中” → 再次取消,重新安排 停止输入 → 等待约半秒,保存“学习中”这就是常见的防抖:连续操作时不断推迟执行,等操作停下来再处理。
本例中的 500 毫秒是等待时间设置,不是精确执行保证。浏览器忙碌时,回调可能更晚执行;是否保存成功,要看页面实际显示的状态。
对于这份小便签,每输入一次就保存也能作为基础实现。这里增加防抖,是为了练习如何合并连续操作。以后实现搜索建议、输入校验时,也会遇到类似需求。
接着看真正写入数据的位置:
localStorage.setItem(KEY, editor.value);上一课保存待办清单,需要把数组转换成 JSON。
这一课保存的是输入框里的纯文本,它本来就是字符串,所以可以直接保存。
恢复时:
const saved = localStorage.getItem(KEY); editor.value = saved ?? "";??的含义是:如果读取结果是null或undefined,就使用右边的空字符串。
第一次使用没有保存内容,输入框显示为空;之前保存过内容,就显示之前的文本。
删除全部文字,也应该保存。
假设便签原来写着:
明天下午整理学习笔记你已经完成这件事,于是把它全部删掉。
如果程序只在“有文字”时保存,刷新后旧内容还会回来。因此,这个便签允许保存空字符串。
代码中没有这样的限制:
// 不要用这个条件阻止空内容保存 if (editor.value.trim()) { // 保存 }空内容同样代表用户最新的编辑结果。
你可能还注意到,程序没有对便签内容调用trim()。
待办任务通常是一小段标题,去掉首尾空格比较合理。但便签可能包含换行、缩进和代码片段。本例直接保存原始文本,让这些格式保留下来。
对于中文输入,还有一个需要考虑的过程。
使用拼音输入法时,输入字母、选择候选词和最终确认文字,并不一定是一次完成的。如果在组词过程中频繁安排保存,可能记录到尚未确认的输入内容。
因此,我们监听输入法组合事件:
editor.addEventListener("compositionstart", () => { composing = true; cancelPendingSave(); });editor.addEventListener("compositionend", () => { composing = false; scheduleSave(); });组词期间暂停安排定时保存,文字确认后重新安排。对于用户实际输入体验,这类细节往往比增加一个新按钮更有用。
再看保存状态。
只有localStorage.setItem()成功执行后,程序才会显示:
已保存到本浏览器如果写入失败,程序保留“尚有修改”的状态,并提示先复制备份,再点击“立即保存”重试。
因此,“立即保存”按钮虽然不是每次都要点,但在保存失败后仍然有用途。
如果一开始就无法读取存储,程序会暂时将输入框设为只读,避免在不知道旧内容的情况下继续覆盖。检查浏览器权限、恢复正常访问后,再重新打开页面。
还有一个实际问题:你刚输入完,还没等到半秒,就切换到其他页面了。
本例增加了输入框失去焦点、页面进入后台和离开页面时的保存尝试。其中visibilitychange可以帮助我们在页面变为隐藏状态时处理待保存内容。参考:MDN visibilitychange 事件
不过,这些事件不能保证在浏览器崩溃、设备断电等情况下都执行。重要内容编辑后,确认页面显示“已保存”,并保留额外备份。
完成代码后,可以按这个顺序验收:
| 测试操作 | 应看到的结果 |
|---|---|
| 输入一段文字后停顿 | 状态从等待保存变为已保存 |
| 显示已保存后刷新 | 恢复刚才的文字 |
| 连续快速输入 | 停顿后保存最终内容 |
| 粘贴多行文字 | 换行保留,内容可恢复 |
| 删除全部文字,保存后刷新 | 输入框保持为空 |
| 使用中文输入法确认文字 | 确认后正常安排保存 |
| 修改后点击立即保存 | 立即尝试保存当前内容 |
| 修改后切换标签页 | 页面隐藏时尝试保存 |
字符计数使用的是:
Array.from(editor.value).length这里按 Unicode 码点计数,换行也会计入;某些组合表情可能包含多个码点。所以它是一个简单的长度提示,不是严格的中文字数统计。
如果你想继续让 AI 改进这个工具,可以把当前代码和下面的需求一起发给它:
请在现有便签基础上增加“导出 TXT”功能。
导出当前输入框中的文字,保留换行。
导出操作不要清空便签,也不要改变自动保存状态。
请说明新增代码的位置,并提供测试步骤。
这样,你是在一个已经能运行的版本上增加明确功能,出了问题也更容易找到修改范围。
一直跟做前面课程的同学,可以把便签接回效率工具箱:
- 将原来的便签占位卡片替换为本例
<main>内的内容,外层使用<article class="card note-panel">,标题改为<h2>。 - 将以
.note-panel开头的样式复制到工具箱原来的<style>中,保留工具箱自身的页面布局。 - 添加
.card.note-panel { max-width: none; margin: 0; },让便签卡片跟随原有网格排列。 - 删除原来绑定
#note-preview按钮的占位事件,再把本例<script>内的代码放进现有脚本。
便签使用的存储名称是:
efficiency-toolbox.note.v1待办清单使用的是:
efficiency-toolbox.tasks.v1两个模块使用不同名称,便签保存时不会覆盖待办任务。若独立便签与工具箱使用同一来源地址、同一个便签存储名称,它们就能读取同一份便签内容。
本例暂时按单个页面编辑设计。多个标签页同时修改同一份便签时,后保存的内容可能覆盖先前内容,这可以作为后续版本的改进方向。
下一课,我们将把番茄钟、待办清单和便签整理成一个完整工具箱,补上统一布局和模块切换,让它成为一个可以持续使用的小项目。
如果跟做时遇到问题,描述“做了什么、出现什么、原本希望什么”,并附上控制台报错,会比只写“运行不了”更容易定位原因。