一、整体架构:为什么"一个文件"就够了
这个应用没有任何依赖:
- 没有前端框架——原生 HTML/CSS/JavaScript;
- 没有后端——数据存在浏览器的
localStorage里; - 没有构建工具——保存为
memo.html双击即可运行。
它的架构可以概括为一条极简的数据流:
用户输入 → JS 内存对象数组 → persist() 写入 localStorage → render() 重绘 DOM
整个应用的"数据库"只有两行核心代码:
const KEY = "memos.v1";
function persist() { localStorage.setItem(KEY, JSON.stringify(memos)); }
memos 是一个普通 JavaScript 数组,每条备忘是一个对象:
{
id: "m8f2k1x9a", // 唯一 ID
title: "购物清单", // 标题
content: "牛奶、鸡蛋", // 内容
tag: "生活", // 分类
pinned: false, // 是否置顶
createdAt: 1690000000000,// 创建时间戳
updatedAt: 1690000000000 // 更新时间戳
}
启动时反向读取并做容错处理——即使 localStorage 中存了脏数据也不会崩溃:
try { memos = JSON.parse(localStorage.getItem(KEY)) || []; } catch { memos = []; }
设计要点:所有操作(增、删、改、置顶)都只做一件事——修改内存中的 memos 数组,然后调用 persist() + render()。这种"单一数据源 + 全量重绘"的模式在几百条数据规模下性能完全够用,却把代码复杂度降到了最低。
“C:\Users\86182\Desktop\Todolist\memos\memo.html”

二、视觉系统:用 CSS 造出一张纸
2.1 设计变量
整个配色和字体收敛在 :root 的 CSS 变量中,形成一套"纸质手帐"主题:
:root {
--paper: #f7f1e3; /* 米黄纸面 */
--ink: #33302b; /* 墨色文字 */
--accent: #d9552c; /* 柿红色强调色 */
--serif: "Noto Serif SC", "SimSun", serif;
--hand: "Ma Shan Zheng", "KaiTi", cursive;
}
字体做了三层兜底:优先加载 Google Fonts 的书法字体「马善政」,加载失败时退回系统的楷体,再退回通用手写体——离线打开也不会变成难看的默认宋体。
2.2 纸张噪点纹理
背景不是纯色,而是叠了两层东西:
body {
background:
radial-gradient(ellipse at 15% 10%, rgba(217,161,59,.14), transparent 45%),
radial-gradient(ellipse at 85% 90%, rgba(109,138,91,.12), transparent 45%),
var(--paper);
}
两个对角放置的柔和径向渐变营造光影;再用一段内联的 SVG feTurbulence 滤镜生成随机噪点,以 data:image/svg+xml 的形式嵌入 CSS,模拟真实纸张的纤维颗粒感——零图片请求:
body::before {
background-image: url("data:image/svg+xml,...feTurbulence...");
opacity: .35;
}
2.3 和纸胶带与图钉:纯 CSS 道具
编辑卡片顶部那条斜贴的绿色胶带,是一个 ::before 伪元素 + 重复线性渐变:
.editor::before {
background: repeating-linear-gradient(45deg,
rgba(109,138,91,.5) 0 6px, rgba(109,138,91,.65) 6px 12px);
transform: translateX(-50%) rotate(-2deg);
}
每张便签左上角的图钉则是一枚径向渐变小球加投影,制造出金属高光的立体错觉:
.note::before {
background: radial-gradient(circle at 35% 30%, #ff8a66, var(--accent-deep));
box-shadow: 0 3px 5px rgba(0,0,0,.3);
}
便签本身还带了 ±0.7° 的随机微倾斜(奇偶交替),排列起来像真的钉在软木板上:
.note:nth-child(odd) { transform: rotate(-.7deg); }
.note:nth-child(even) { transform: rotate(.6deg); }
三、动画系统:四组关键帧撑起的"呼吸感"
动画全部由 CSS 关键帧完成,不依赖任何动画库:
| 关键帧 | 用途 | 效果 |
|---|---|---|
rise | 页眉、工具栏、输入区入场 | 自下而上淡入,配合 animation-delay: .1s/.2s 形成瀑布式错峰入场 |
dropIn | 便签卡片入场 | 从上方掉落 + 回弹曲线 cubic-bezier(.22,1,.36,1) |
popIn | 编辑弹窗 | 缩放 85%→100%,带回弹 |
fadeIn | 弹窗遮罩 | 简单淡入 |
其中最讲究的是卡片入场的交错延迟,在渲染时按索引动态写入:
style="animation-delay:${Math.min(i*60, 480)}ms"
第 N 张卡片延迟 N×60ms 出现,封顶 480ms——既保证节奏感,又避免记录太多时等半天。
悬停交互也做了拟物化处理:鼠标移上去时便签会"摆正 + 抬起 + 放大",像被手指按住的纸片:
.note:hover { transform: rotate(0deg) translateY(-6px) scale(1.02); }
按钮则用了带过冲的弹性曲线 cubic-bezier(.34,1.56,.64,1) 和"硬阴影位移"风格:
.btn:hover { transform: translateY(-3px) rotate(-1deg);
box-shadow: 3px 6px 0 var(--ink); }
四、功能拆解
4.1 录入:分类选择器是"渲染出来的"
四个彩色圆点不是写死的 HTML,而是由 JS 根据 TAGS 配置生成的:
const TAGS = [
{ name: "生活", color: "#6d8a5b" },
{ name: "工作", color: "#d9552c" },
{ name: "学习", color: "#4a7ba6" },
{ name: "灵感", color: "#d9a13b" },
];
renderPick(elId, selected) 把配置渲染成一组 .tagdot,点击任意圆点会以新选中值重新渲染这一组——选中的圆点会放大并描上墨色边框。要新增分类只需往 TAGS 里加一行。
保存函数 saveMemo() 做了三件事:
- 校验标题和内容不能同时为空(否则弹 toast 提示);
- 构造新对象压入
memos,其中 ID 由时间戳+随机串保证唯一:
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
- 清空表单、持久化、重绘。
4.2 搜索:实时过滤 + 高亮
搜索框监听 input 事件,每敲一个字符就触发一次全量过滤重绘——无需按回车:
document.getElementById("q").addEventListener("input", render);
function filtered() {
const kw = document.getElementById("q").value.trim().toLowerCase();
return memos
.filter(m => !curTag || m.tag === curTag)
.filter(m => !kw || m.title.toLowerCase().includes(kw)
|| m.content.toLowerCase().includes(kw))
.sort((a, b) => (b.pinned - a.pinned) || b.createdAt - a.createdAt);
}
注意排序逻辑:置顶优先,其次按更新时间倒序。
高亮的实现分两步走,这是本文件里一个容易忽略的安全细节——先转义再匹配:
function esc(s) { // HTML 转义,防注入
return (s||"").replace(/[&<>"']/g,
c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
}
function hi(safe, kw) { // 在已转义文本上做关键词高亮
const k = esc(kw).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // 转义正则元字符
return safe.replace(new RegExp(k, "gi"), m => "<mark>" + m + "</mark>");
}
用户如果搜 <b> 或者 ( 这类特殊字符,既不会破坏页面结构,也不会让正则报错。
4.3 分类筛选:chips 统计
工具栏的分类胶囊(全部 12 / 生活 5 / 工作 3 …)每次渲染都重新统计:
const counts = {};
memos.forEach(m => counts[m.tag] = (counts[m.tag] || 0) + 1);
点击 chip 切换全局状态 curTag 后统一走 render(),与搜索条件叠加生效。
4.4 编辑:弹窗复用同一套表单逻辑
点便签上的 ✏️ 打开遮罩层弹窗,把该条记录回填进 m-title / m-content 输入框,并记住 editingId。"保存"时按 ID 找到原对象原地更新:
const m = memos.find(x => x.id === editingId);
m.title = ...; m.tag = pickOf("m-tagpick"); m.updatedAt = Date.now();
persist(); closeModal(); render();
弹窗支持三种关闭方式:点遮罩空白处、点取消按钮、按 Esc 键盘快捷键。
4.5 删除:先播动画,再删数据
删除有一个 280ms 的"缩小旋转消失"过渡——先找到对应 DOM 卡片做 CSS 过渡,动画结束才真正从数组中移除:
note.style.opacity = "0";
note.style.transform = "scale(.7) rotate(6deg)";
setTimeout(() => { memos = memos.filter(x => x.id !== id); ... }, 280);
删除前还有一层 confirm() 二次确认,防止误触。
4.6 置顶
m.pinned = !m.pinned;
置顶的记录在排序中被提前(b.pinned - a.pinned),卡片右上角出现一枚 📌 图标。状态同样随数组持久化。
4.7 导出 JSON
function download(name, text, type) {
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([text], { type }));
a.download = name;
a.click();
}
利用 Blob + URL.createObjectURL 在内存中生成临时下载链接,把 memos 数组序列化为格式化的 JSON 文件下载——这就是"导出备份"的全部实现。
4.8 导出静态网页
exportHTML() 会把当前筛选结果拼装成一个独立的、自带样式的 HTML 文件(含卡片布局和入场动画),下载后可直接分享给别人或留档。它复用了同一套 esc() / fmt() 工具函数,导出的是当前视图(含搜索词和分类筛选的结果),所见即所得。
4.9 导入合并
导入 JSON 时做了三重防御:
const have = new Set(memos.map(m => m.id)); // 已有 ID 集合
arr.forEach(o => {
if (o && o.id && !have.has(o.id)) { ... } // 只收合法且不重复的记录
});
- 校验解析结果是数组;
- 按 ID 去重合并(Set 查询 O(1));
- 对每条记录的字段做类型修正(时间戳取整、tag 白名单校验)。
坏文件不会污染现有数据,只会得到一条 toast:“文件格式不正确”。
4.10 Toast 提示条
底部滑入的黑色提示条,用一个全局 timer 保证连续触发时不会闪烁:
t.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove("show"), 1800);
CSS 侧用 transition 配合 translateY(80px) 实现弹出/收回。
4.11 快捷键
document.addEventListener("keydown", e => {
if (e.key === "Escape") closeModal(); // Esc 关弹窗
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { // Ctrl/⌘+Enter 保存
document.getElementById("overlay").classList.contains("show")
? applyEdit() : saveMemo();
}
});
Ctrl+Enter 会根据"弹窗是否打开"智能判断保存的是新备忘还是正在编辑的记录。
五、值得借鉴的工程细节
- XSS 防御前置:所有用户输入进入 DOM 前必经
esc(),正则高亮也在转义后的文本上进行。 - 渐进增强的字体策略:Web 字体 → 系统楷体 → 通用手写体,断网不影响使用。
- 数据版本化:localStorage key 叫
memos.v1,未来改数据结构可以换 key 平滑迁移。 - 响应式:便签墙用 CSS 多列布局
columns: 3 260px自动适配列数,窄屏媒体查询下变单列。 - 零依赖的可维护性:全文没有一个外部 JS/CSS 文件(仅字体走 CDN 且有兜底),复制粘贴整个文件就能在任何机器上运行。
六、结语
这个项目证明了:在"记事"这个需求尺度上,浏览器本身就是最好的运行时——localStorage 是数据库,Blob 是文件系统,CSS 是动画引擎。600 行代码,双击即用,关机不丢数据。
如果你想在它基础上继续演进,可以尝试:
- 给 localStorage 数据加上
updatedAt触发的相对时间显示(“3 分钟前”); - 用
navigator.share()接入系统分享; - 通过 File System Access API 让导出直接写回本地文件夹。
完整的单文件源码见项目目录下的 memo.html。
源码全解析&spm=1001.2101.3001.5002&articleId=164022734&d=1&t=3&u=53b527f41f654b82b17fec0d54754de6)
568

被折叠的 条评论
为什么被折叠?



