一个 HTML 文件的备忘录:纸间备忘(Memo on Paper)源码全解析

一、整体架构:为什么"一个文件"就够了

这个应用没有任何依赖:

  • 没有前端框架——原生 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() 做了三件事:

  1. 校验标题和内容不能同时为空(否则弹 toast 提示);
  2. 构造新对象压入 memos,其中 ID 由时间戳+随机串保证唯一:
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
  1. 清空表单、持久化、重绘。

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 => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[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 会根据"弹窗是否打开"智能判断保存的是新备忘还是正在编辑的记录。


五、值得借鉴的工程细节

  1. XSS 防御前置:所有用户输入进入 DOM 前必经 esc(),正则高亮也在转义后的文本上进行。
  2. 渐进增强的字体策略:Web 字体 → 系统楷体 → 通用手写体,断网不影响使用。
  3. 数据版本化:localStorage key 叫 memos.v1,未来改数据结构可以换 key 平滑迁移。
  4. 响应式:便签墙用 CSS 多列布局 columns: 3 260px 自动适配列数,窄屏媒体查询下变单列。
  5. 零依赖的可维护性:全文没有一个外部 JS/CSS 文件(仅字体走 CDN 且有兜底),复制粘贴整个文件就能在任何机器上运行。

六、结语

这个项目证明了:在"记事"这个需求尺度上,浏览器本身就是最好的运行时——localStorage 是数据库,Blob 是文件系统,CSS 是动画引擎。600 行代码,双击即用,关机不丢数据。

如果你想在它基础上继续演进,可以尝试:

  • 给 localStorage 数据加上 updatedAt 触发的相对时间显示(“3 分钟前”);
  • navigator.share() 接入系统分享;
  • 通过 File System Access API 让导出直接写回本地文件夹。

完整的单文件源码见项目目录下的 memo.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值