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

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

这个应用没有任何依赖:

  • 没有前端框架------原生 HTML/CSS/JavaScript;
  • 没有后端 ------数据存在浏览器的 localStorage 里;
  • 没有构建工具 ------保存为 memo.html 双击即可运行。

它的架构可以概括为一条极简的数据流:

复制代码
用户输入 → JS 内存对象数组 → persist() 写入 localStorage → render() 重绘 DOM

整个应用的"数据库"只有两行核心代码:

javascript 复制代码
const KEY = "memos.v1";
function persist() { localStorage.setItem(KEY, JSON.stringify(memos)); }

memos 是一个普通 JavaScript 数组,每条备忘是一个对象:

javascript 复制代码
{
  id: "m8f2k1x9a",        // 唯一 ID
  title: "购物清单",       // 标题
  content: "牛奶、鸡蛋",   // 内容
  tag: "生活",             // 分类
  pinned: false,           // 是否置顶
  createdAt: 1690000000000,// 创建时间戳
  updatedAt: 1690000000000 // 更新时间戳
}

启动时反向读取并做容错处理------即使 localStorage 中存了脏数据也不会崩溃:

javascript 复制代码
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 变量中,形成一套"纸质手帐"主题:

css 复制代码
:root {
  --paper: #f7f1e3;      /* 米黄纸面 */
  --ink: #33302b;        /* 墨色文字 */
  --accent: #d9552c;     /* 柿红色强调色 */
  --serif: "Noto Serif SC", "SimSun", serif;
  --hand:  "Ma Shan Zheng", "KaiTi", cursive;
}

字体做了三层兜底:优先加载 Google Fonts 的书法字体「马善政」,加载失败时退回系统的楷体,再退回通用手写体------离线打开也不会变成难看的默认宋体。

2.2 纸张噪点纹理

背景不是纯色,而是叠了两层东西:

css 复制代码
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,模拟真实纸张的纤维颗粒感------零图片请求:

css 复制代码
body::before {
  background-image: url("data:image/svg+xml,...feTurbulence...");
  opacity: .35;
}

2.3 和纸胶带与图钉:纯 CSS 道具

编辑卡片顶部那条斜贴的绿色胶带,是一个 ::before 伪元素 + 重复线性渐变:

css 复制代码
.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);
}

每张便签左上角的图钉则是一枚径向渐变小球加投影,制造出金属高光的立体错觉:

css 复制代码
.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° 的随机微倾斜(奇偶交替),排列起来像真的钉在软木板上:

css 复制代码
.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 弹窗遮罩 简单淡入

其中最讲究的是卡片入场的交错延迟,在渲染时按索引动态写入:

javascript 复制代码
style="animation-delay:${Math.min(i*60, 480)}ms"

第 N 张卡片延迟 N×60ms 出现,封顶 480ms------既保证节奏感,又避免记录太多时等半天。

悬停交互也做了拟物化处理:鼠标移上去时便签会"摆正 + 抬起 + 放大",像被手指按住的纸片:

css 复制代码
.note:hover { transform: rotate(0deg) translateY(-6px) scale(1.02); }

按钮则用了带过冲的弹性曲线 cubic-bezier(.34,1.56,.64,1) 和"硬阴影位移"风格:

css 复制代码
.btn:hover { transform: translateY(-3px) rotate(-1deg);
             box-shadow: 3px 6px 0 var(--ink); }

四、功能拆解

4.1 录入:分类选择器是"渲染出来的"

四个彩色圆点不是写死的 HTML,而是由 JS 根据 TAGS 配置生成的:

javascript 复制代码
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 由时间戳+随机串保证唯一:
javascript 复制代码
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
  1. 清空表单、持久化、重绘。

4.2 搜索:实时过滤 + 高亮

搜索框监听 input 事件,每敲一个字符就触发一次全量过滤重绘------无需按回车:

javascript 复制代码
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);
}

注意排序逻辑:置顶优先,其次按更新时间倒序

高亮的实现分两步走,这是本文件里一个容易忽略的安全细节------先转义再匹配:

javascript 复制代码
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 ...)每次渲染都重新统计:

javascript 复制代码
const counts = {};
memos.forEach(m => counts[m.tag] = (counts[m.tag] || 0) + 1);

点击 chip 切换全局状态 curTag 后统一走 render(),与搜索条件叠加生效。

4.4 编辑:弹窗复用同一套表单逻辑

点便签上的 ✏️ 打开遮罩层弹窗,把该条记录回填进 m-title / m-content 输入框,并记住 editingId。"保存"时按 ID 找到原对象原地更新:

javascript 复制代码
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 过渡,动画结束才真正从数组中移除:

javascript 复制代码
note.style.opacity = "0";
note.style.transform = "scale(.7) rotate(6deg)";
setTimeout(() => { memos = memos.filter(x => x.id !== id); ... }, 280);

删除前还有一层 confirm() 二次确认,防止误触。

4.6 置顶

javascript 复制代码
m.pinned = !m.pinned;

置顶的记录在排序中被提前(b.pinned - a.pinned),卡片右上角出现一枚 📌 图标。状态同样随数组持久化。

4.7 导出 JSON

javascript 复制代码
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 时做了三重防御:

javascript 复制代码
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 保证连续触发时不会闪烁:

javascript 复制代码
t.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove("show"), 1800);

CSS 侧用 transition 配合 translateY(80px) 实现弹出/收回。

4.11 快捷键

javascript 复制代码
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

相关推荐
计算机魔术师5 小时前
Dario Amodei 发文呼吁 AI 行业放慢前沿速度并公布三步计划
前端
EatFan5 小时前
前后端分离项目中 Token 到底应该怎么设计?Access Token + Refresh Token 实战
java·前端·后端
dsyyyyy11015 小时前
Typescript装饰器
前端·javascript·typescript
一拳不是超人6 小时前
一个没测暗色模式的 Bug,吃掉了我一半 谷歌扩展用户
前端·javascript·程序员
liangshanbo12156 小时前
React useState 函数式更新面试题整理
前端·react.js·前端框架
涛涛ing7 小时前
为什么你的页面在 Safari 上总出问题?Interop 2027 正在解决这个 20 年老毛病
前端
不一样的少年_7 小时前
WebP 压缩到底在干嘛?小白也能看懂的原理拆解
前端·后端·图片资源
问心无愧05137 小时前
ctf show web 177
前端·笔记
不一样的少年_7 小时前
PNG/JPG 如何变成 WebP?真相不是改后缀!
前端·后端·图片资源
杉氧7 小时前
RN 性能调优指南:重渲染(Re-renders)控制与长列表(FlatList)优化
android·前端·react native