一个网页在电脑上能运行,距离“可以交付”还有哪些步骤?
前几课,我们实现了番茄钟、待办清单、关键词搜索、状态筛选和自动保存便签。第九课进一步处理了筛选误删、对象引用和保存失败。
这一课,把这些内容整理成一个完整项目。
最终得到的工具箱具备:
- 番茄钟:自定义时长、暂停、继续、结束提示。
- 待办清单:新增、完成、删除、搜索和筛选。
- 任务保存:写入成功后提交状态,失败时保留旧状态。
- 便签:自动保存、手动重试、字符计数。
- 页面布局:模块切换和窄屏适配。
同时,我们会解决三个发布时常见的问题:
本地正常,上线后脚本加载失败。 网页上线了,本地任务却没有出现。 更新后出现问题,不知道怎样恢复旧版本。先明确项目边界。
这是一个浏览器本地数据工具。服务器提供页面文件,用户的任务和便签保存在各自浏览器中。
静态托管服务 ↓ 提供 HTML、CSS、JavaScript 用户浏览器 ↓ 执行代码 浏览器本地存储 ↓ 保存当前用户的任务和便签所以,发布网页并不会自动上传你的任务,也不会让电脑和手机同步数据。
如果以后需要登录、多设备同步和多人协作,就需要进一步设计身份验证、服务端存储和冲突处理。
现在先完成这个边界清楚的小项目。
我们把代码整理成四个文件:
efficiency-toolbox/ ├── index.html ├── styles.css ├── task-store.js └── app.js它们的职责是:
| 文件 | 负责什么 |
|---|---|
index.html | 页面结构、按钮、输入框 |
styles.css | 排版、颜色、窄屏布局 |
task-store.js | 任务数据、校验、查询与提交 |
app.js | 事件绑定、页面显示、番茄钟和便签 |
这里最重要的分界是:
界面负责表达用户操作,任务模块负责决定数据能否改变。
删除按钮传递任务 ID,任务模块生成候选状态;保存成功后,界面再显示新结果。
这样,保存失败的处理就有了统一入口。
下面四份代码放在同一个文件夹中即可,不需要安装前端框架或执行打包命令。
先创建index.html:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0" > <title>我的效率工具箱</title> <link rel="stylesheet" href="./styles.css"> </head> <body> <div class="app"> <header> <h1>我的效率工具箱</h1> <p class="muted"> 记录任务、保持专注、随手记下想法。 <span id="version"></span> </p> <p id="timer-overview">准备开始 · 25:00</p> </header> <nav id="tool-nav" aria-label="工具切换"> <button type="button" data-view="focus-panel" aria-controls="focus-panel" aria-pressed="true" >番茄钟</button> <button type="button" data-view="tasks-panel" aria-controls="tasks-panel" aria-pressed="false" >待办清单</button> <button type="button" data-view="note-panel" aria-controls="note-panel" aria-pressed="false" >随手便签</button> </nav> <main> <section id="focus-panel" class="panel" data-panel aria-labelledby="focus-title" > <h2 id="focus-title">番茄钟</h2> <label for="duration">专注时长,单位:分钟</label> <input id="duration" type="number" min="1" max="120" step="1" value="25" > <div id="clock">25:00</div> <div class="actions"> <button id="start" type="button">开始 / 继续</button> <button id="pause" type="button">暂停</button> <button id="reset" type="button">重置</button> </div> <p id="timer-status" role="status">准备开始</p> <p class="muted"> 切换工具可以继续计时,刷新页面会重置本轮计时。 </p> </section> <section id="tasks-panel" class="panel" data-panel aria-labelledby="tasks-title" hidden > <h2 id="tasks-title">待办清单</h2> <form id="task-form"> <input id="task-input" type="text" maxlength="80" placeholder="输入一件具体的小事" aria-label="新任务内容" autocomplete="off" > <button id="add-task" type="submit">添加</button> </form> <div class="toolbar"> <label> 关键词 <input id="task-search" type="search" placeholder="搜索任务" > </label> <label> 状态 <select id="task-filter"> <option value="all">全部</option> <option value="pending">待完成</option> <option value="done">已完成</option> </select> </label> <button id="reset-view" type="button"> 清除筛选 </button> </div> <p id="task-summary" class="muted"></p> <p id="task-empty"></p> <ul id="task-list"></ul> <p id="task-status" role="status"></p> </section> <section id="note-panel" class="panel" data-panel aria-labelledby="note-title" hidden > <h2 id="note-title">随手便签</h2> <label for="note-editor">便签内容</label> <textarea id="note-editor" placeholder="记录想法、命令和学习问题……" ></textarea> <div class="actions"> <span id="note-count" class="muted"></span> <button id="save-note" type="button">立即保存</button> </div> <p id="note-status" role="status"></p> </section> </main> <p class="muted"> 数据保存在当前浏览器,重要内容请另行备份。 </p> </div> <script type="module" src="./app.js"></script> </body> </html>接着创建styles.css:
* { box-sizing: border-box; } [hidden] { display: none !important; } body { margin: 0; background: #f5f7fb; color: #1f2937; font-family: "Microsoft YaHei", Arial, sans-serif; } .app { width: 100%; max-width: 900px; margin: auto; padding: 28px 16px; } h1 { font-size: clamp(24px, 5vw, 32px); } h2 { margin-top: 0; } .muted { color: #6b7280; line-height: 1.8; } #timer-overview { padding: 12px 16px; border-radius: 10px; background: #ede9fe; color: #5b21b6; } nav, .actions, #task-form { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } nav { margin: 24px 0 16px; } button, select, textarea, input:not([type="checkbox"]) { font: inherit; font-size: 16px; min-height: 44px; } button { padding: 10px 16px; border: 0; border-radius: 8px; background: #7c3aed; color: white; cursor: pointer; } button:disabled { opacity: 0.5; cursor: not-allowed; } nav button { flex: 1 1 120px; min-width: 0; background: #e5e7eb; color: #374151; overflow-wrap: anywhere; } nav button[aria-pressed="true"] { background: #7c3aed; color: white; } .panel { min-width: 0; padding: 24px; border: 1px solid #e5e7eb; border-radius: 16px; background: white; } input:not([type="checkbox"]), select, textarea { width: 100%; min-width: 0; padding: 10px; border: 1px solid #d1d5db; border-radius: 8px; background: white; } :focus-visible { outline: 3px solid #c4b5fd; outline-offset: 2px; } #duration { width: 100px; margin-top: 10px; } #clock { margin: 28px 0; text-align: center; font-size: clamp(40px, 12vw, 64px); font-weight: bold; font-variant-numeric: tabular-nums; } #task-input { flex: 1 1 200px; } .toolbar { display: grid; grid-template-columns: minmax(0, 1fr) 150px auto; align-items: end; gap: 12px; margin-top: 20px; } .toolbar label { min-width: 0; line-height: 1.8; } #task-list { padding: 0; list-style: none; } .task { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: start; gap: 10px; padding: 14px 0; border-bottom: 1px solid #e5e7eb; } .task input { width: 24px; height: 24px; margin: 8px 0 0; } .task span { min-width: 0; padding-top: 8px; line-height: 1.6; overflow-wrap: anywhere; } .task.done span { color: #9ca3af; text-decoration: line-through; } .danger { background: #fef2f2; color: #b91c1c; } #note-editor { display: block; min-height: 260px; margin: 12px 0; line-height: 1.8; } [id$="-status"], #task-empty { line-height: 1.8; overflow-wrap: anywhere; } @media (max-width: 600px) { .app { padding: 16px 12px; } .panel { padding: 16px; } .toolbar { grid-template-columns: minmax(0, 1fr); } #task-form { flex-direction: column; align-items: stretch; } #task-input { flex: none; } nav button { flex: 1 1 0; padding: 10px 6px; } .actions button { flex: 1 1 120px; } }然后创建task-store.js。
这是第九课状态模块的发布版本:查询返回副本,新增、完成和删除统一经过提交入口,保存失败不替换内部状态。
export function createTaskStore(storage) { const key = "efficiency-toolbox.tasks.v1"; const copy = tasks => tasks.map(task => ({ id: task.id, text: task.text, done: task.done })); const fail = (code, message) => ({ ok: false, code, message }); const raw = storage.getItem(key); let state = []; if (raw !== null) { const data = JSON.parse(raw); if ( !data || data.version !== 1 || !Array.isArray(data.tasks) ) { throw new Error("不支持的任务格式"); } const ids = new Set(); for (const task of data.tasks) { if ( !task || !Number.isSafeInteger(task.id) || task.id < 1 || ids.has(task.id) || typeof task.text !== "string" || !task.text.trim() || task.text.length > 80 || typeof task.done !== "boolean" ) { throw new Error("任务字段错误或编号重复"); } ids.add(task.id); } state = copy(data.tasks); } let nextId = state.reduce( (max, task) => Math.max(max, task.id), 0 ) + 1; function commit(next) { try { storage.setItem( key, JSON.stringify({ version: 1, tasks: next }) ); } catch (error) { return fail( "SAVE_FAILED", "保存失败,操作未提交,请重试。" ); } state = next; return { ok: true }; } return { getAll: () => copy(state), select(word = "", mode = "all") { if (!["all", "pending", "done"].includes(mode)) { throw new Error("筛选状态错误"); } const keyword = word.trim().toLowerCase(); return copy( state.filter(task => task.text.toLowerCase().includes(keyword) && ( mode === "all" || (mode === "done" ? task.done : !task.done) ) ) ); }, add(text) { if ( typeof text !== "string" || !text.trim() || text.trim().length > 80 ) { return fail( "INVALID_TEXT", "请输入 1~80 个字符的任务。" ); } if (!Number.isSafeInteger(nextId)) { return fail( "ID_EXHAUSTED", "任务编号超出范围。" ); } const id = nextId; const result = commit([ ...state, { id, text: text.trim(), done: false } ]); if (!result.ok) return result; nextId++; return { ok: true, id }; }, setDone(id, done) { if ( !Number.isSafeInteger(id) || typeof done !== "boolean" ) { return fail( "INVALID_ARGUMENT", "参数错误。" ); } if (!state.some(task => task.id === id)) { return fail("NOT_FOUND", "任务不存在。"); } return commit( state.map(task => task.id === id ? { ...task, done } : task ) ); }, remove(id) { if (!Number.isSafeInteger(id)) { return fail( "INVALID_ARGUMENT", "编号错误。" ); } if (!state.some(task => task.id === id)) { return fail("NOT_FOUND", "任务不存在。"); } return commit( state.filter(task => task.id !== id) ); } }; }最后创建app.js,完成界面与功能的连接:
import { createTaskStore } from "./task-store.js"; document.querySelector("#version").textContent = "v1.0.0"; function initNavigation() { const buttons = document.querySelectorAll("[data-view]"); const panels = document.querySelectorAll("[data-panel]"); buttons.forEach(button => { button.addEventListener("click", () => { buttons.forEach(item => { item.setAttribute( "aria-pressed", String(item === button) ); }); panels.forEach(panel => { panel.hidden = panel.id !== button.dataset.view; }); }); }); } function initTasks() { const panel = document.querySelector("#tasks-panel"); const form = panel.querySelector("#task-form"); const input = panel.querySelector("#task-input"); const search = panel.querySelector("#task-search"); const mode = panel.querySelector("#task-filter"); const list = panel.querySelector("#task-list"); const summary = panel.querySelector("#task-summary"); const status = panel.querySelector("#task-status"); let store; try { store = createTaskStore(localStorage); } catch (error) { input.disabled = true; panel.querySelector("#add-task").disabled = true; status.textContent = "读取失败,已暂停任务编辑并保留原数据。"; console.error(error); return; } function render() { const all = store.getAll(); const visible = store.select(search.value, mode.value); const completed = all.filter(task => task.done).length; summary.textContent = `全部 ${all.length} 项,已完成 ${completed} 项,` + `当前显示 ${visible.length} 项。`; list.replaceChildren(); const empty = panel.querySelector("#task-empty"); empty.hidden = visible.length > 0; empty.textContent = all.length ? "没有符合条件的任务,可清除筛选。" : "还没有任务,先添加一件事吧。"; visible.forEach(task => { const row = document.createElement("li"); row.className = task.done ? "task done" : "task"; const check = document.createElement("input"); check.type = "checkbox"; check.checked = task.done; check.dataset.id = String(task.id); check.setAttribute( "aria-label", "切换完成状态:" + task.text ); const text = document.createElement("span"); text.textContent = task.text; const button = document.createElement("button"); button.type = "button"; button.className = "danger"; button.dataset.id = String(task.id); button.textContent = "删除"; button.setAttribute( "aria-label", "删除任务:" + task.text ); row.append(check, text, button); list.append(row); }); } function report(result) { // 失败时也重绘,让复选框恢复为内部状态 render(); status.textContent = result.ok ? "操作已保存。" : result.message; } form.addEventListener("submit", event => { event.preventDefault(); const result = store.add(input.value); report(result); if (result.ok) { input.value = ""; const shown = store .select(search.value, mode.value) .some(task => task.id === result.id); if (!shown) { status.textContent = "已保存新任务,它不符合当前筛选条件。"; } } input.focus(); }); list.addEventListener("change", event => { const target = event.target; if (!target.matches('input[type="checkbox"]')) return; const id = Number(target.dataset.id); report(store.setDone(id, target.checked)); const next = list.querySelector( 'input[data-id="' + id + '"]' ); (next || mode).focus(); }); list.addEventListener("click", event => { const target = event.target; if (!target.matches("button[data-id]")) return; report(store.remove(Number(target.dataset.id))); mode.focus(); }); search.addEventListener("input", render); mode.addEventListener("change", render); panel.querySelector("#reset-view") .addEventListener("click", () => { search.value = ""; mode.value = "all"; render(); search.focus(); }); status.textContent = "任务修改后保存,筛选只改变显示。"; render(); } function initTimer() { const input = document.querySelector("#duration"); const clock = document.querySelector("#clock"); const status = document.querySelector("#timer-status"); const overview = document.querySelector("#timer-overview"); let remaining = 25 * 60000; let endAt = 0; let interval = null; let started = false; function render() { const seconds = Math.ceil(remaining / 1000); const time = String(Math.floor(seconds / 60)).padStart(2, "0") + ":" + String(seconds % 60).padStart(2, "0"); clock.textContent = time; overview.textContent = status.textContent + " · " + time; document.title = started ? time + "|效率工具箱" : "我的效率工具箱"; } function stop() { clearInterval(interval); interval = null; } function tick() { remaining = Math.max(0, endAt - Date.now()); if (remaining === 0) { stop(); started = false; input.disabled = false; status.textContent = "本轮专注结束"; } render(); if (remaining === 0) { document.title = "时间到!|效率工具箱"; } } function read() { const minutes = Number(input.value); if ( !Number.isInteger(minutes) || minutes < 1 || minutes > 120 ) { status.textContent = "请输入 1~120 的整数分钟"; render(); return null; } return minutes * 60000; } function reset() { const value = read(); if (value === null) return; stop(); started = false; input.disabled = false; remaining = value; status.textContent = "准备开始"; render(); } document.querySelector("#start") .addEventListener("click", () => { if (interval !== null) return; if (!started) { const value = read(); if (value === null) return; remaining = value; } started = true; input.disabled = true; endAt = Date.now() + remaining; status.textContent = "专注中"; interval = setInterval(tick, 250); tick(); }); document.querySelector("#pause") .addEventListener("click", () => { if (interval === null) return; tick(); if (interval === null) return; stop(); status.textContent = "已暂停"; render(); }); document.querySelector("#reset") .addEventListener("click", reset); input.addEventListener("change", () => { if (!started) reset(); }); document.addEventListener("visibilitychange", () => { if ( document.visibilityState === "visible" && interval !== null ) { tick(); } }); render(); } function initNote() { const editor = document.querySelector("#note-editor"); const status = document.querySelector("#note-status"); const count = document.querySelector("#note-count"); const button = document.querySelector("#save-note"); const key = "efficiency-toolbox.note.v1"; let timeout = null; let dirty = false; let composing = false; let blocked = false; function updateCount() { count.textContent = Array.from(editor.value).length + " 个字符"; } function save() { clearTimeout(timeout); timeout = null; if (blocked || !dirty) return; try { localStorage.setItem(key, editor.value); dirty = false; status.textContent = "已保存 · " + new Date().toLocaleTimeString(); } catch (error) { status.textContent = "保存失败,文字仍在输入框,请复制备份或重试。"; console.error(error); } } function schedule() { dirty = true; updateCount(); clearTimeout(timeout); timeout = null; status.textContent = "有修改尚未保存……"; if (!composing) { timeout = setTimeout(save, 500); } } try { editor.value = localStorage.getItem(key) ?? ""; status.textContent = "便签已就绪。"; } catch (error) { blocked = true; editor.readOnly = true; button.disabled = true; status.textContent = "读取便签失败,已暂停编辑。"; console.error(error); } editor.addEventListener("input", schedule); editor.addEventListener("compositionstart", () => { composing = true; clearTimeout(timeout); timeout = null; }); editor.addEventListener("compositionend", () => { composing = false; schedule(); }); editor.addEventListener("blur", save); button.addEventListener("click", save); document.querySelector("#tool-nav") .addEventListener("click", save); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { save(); } }); window.addEventListener("pagehide", save); updateCount(); } initNavigation(); initTimer(); initTasks(); initNote();这份代码的任务核心和模拟 DOM 集成检查已通过,包括保存失败后恢复复选框、保留新增输入、模块切换、计时暂停与数据恢复。真实浏览器的布局和发布环境,还需要按后面的步骤验收。
先理解两种不同的保存策略。
任务操作采用:
计算候选状态 ↓ 保存成功 ↓ 提交内部状态 ↓ 更新界面保存失败时,任务修改不提交,界面恢复旧状态。
便签则保留用户已经输入的草稿:
用户输入 ↓ 草稿留在输入框 ↓ 尝试保存 ↓ 失败时显示提示,保留草稿供重试这两种策略对应不同操作。
任务修改通常是一项明确命令,可以拒绝本次提交。便签是连续输入,直接撤回全部草稿会损失用户刚写的内容,因此需要保留,并清楚标记尚未保存。
第二个需要理解的是模块加载。
HTML 中使用:
<script type="module" src="./app.js"></script>app.js再导入:
import { createTaskStore } from "./task-store.js";它建立了依赖关系:
index.html ├── styles.css └── app.js └── task-store.jsJavaScript 模块让导出的能力和文件依赖更明确。浏览器加载模块时还会检查请求、来源和 MIME 类型等条件,因此这次应通过 HTTP 服务运行,直接双击file:///文件可能遇到模块加载问题。参考:MDN JavaScript 模块
如果电脑已经安装 Python 3,在四个文件所在目录打开终端,运行:
python -m http.server 8000 --bind 127.0.0.1打开:
本地效率工具箱
确认首页出现v1.0.0,再打开 F12 的 Network 面板,刷新页面。
检查这些文件是否加载成功:
index.html styles.css app.js task-store.js如果task-store.js没有成功加载,app.js的模块依赖就无法满足,后面的初始化也可能不执行。
还有一个容易踩坑的地方:文件路径。
本例使用:
<link rel="stylesheet" href="./styles.css">而不是:
<link rel="stylesheet" href="/styles.css">./styles.css相对于当前页面目录解析。
/styles.css从网站根目录解析。
对于部署在子目录的项目,这个区别很重要。例如项目首页位于:
https://你的用户名.github.io/efficiency-toolbox/正确样式地址应当是:
https://你的用户名.github.io/efficiency-toolbox/styles.css如果误用根路径,浏览器可能去请求:
https://你的用户名.github.io/styles.css本地服务器根目录测试时,两种写法可能都正常,所以这种错误经常到线上才暴露。
本地验收完成后,可以使用 GitHub Pages 发布。
GitHub Pages 能托管这类静态文件。GitHub Free 的这条路径使用公开仓库,因此上传的项目源文件也会公开。参考:GitHub Pages 创建指南
操作步骤如下:
- 创建一个公开仓库,名称例如
efficiency-toolbox。 - 将四个文件上传到仓库根目录,提交修改。
- 打开仓库的Settings → Pages。
- 在Build and deployment中,将Source选择为Deploy from a branch。
- 选择代码所在分支,例如
main。 - 文件夹选择
/(root),保存配置。 - 查看部署结果,成功后使用 Pages 设置页提供的站点链接访问。
分支发布支持选择仓库根目录或/docs目录。本例文件放在根目录,因此选择/(root)。参考:GitHub Pages 发布来源设置
上传的是四个项目源文件。个人任务和便签的备份应保留在发布目录之外,不要作为网站素材一起上传。
网页部署成功后,最容易产生误解的是:
为什么本地添加的任务,线上没有出现?
因为这两个地址属于不同来源:
本地: http://127.0.0.1:8000 线上: https://你的用户名.github.iolocalStorage 按来源隔离。线上页面不会自动读取本地服务器来源下的数据。参考:MDN localStorage
这不是发布时把数据删除了。
在同一浏览器里回到原本的本地地址,原存储未被清理的话,仍有机会读到原任务。
如果需要迁移,可以先在本地页面的 Console 中读取备份:
(() => { return JSON.stringify({ format: "efficiency-toolbox-backup-v1", taskPayload: localStorage.getItem( "efficiency-toolbox.tasks.v1" ), notePayload: localStorage.getItem( "efficiency-toolbox.note.v1" ) }, null, 2); })();这里保存的是原始字符串,方便保留任务版本信息。
迁移属于另一项数据操作,应先备份线上已有内容,验证备份格式,再决定是否写入。本文最终版还没有提供导入界面,所以不要把“有备份”直接理解成“已经自动迁移”。
还要注意:localStorage 不按 URL 路径隔离。
同一个用户名下的多个 GitHub Pages 项目,如果位于相同来源,又使用相同存储名称,可能访问同一份数据。本例沿用了前几课的名称以保持兼容;以后制作其他工具,应为它们设置独立名称。
接下来做线上验收。
部署状态成功,证明文件已经发布;功能验收通过,才证明这个版本可以使用。
至少检查以下内容:
| 检查项 | 操作与预期 |
|---|---|
| 资源加载 | 四个文件均成功加载,控制台没有相关模块错误 |
| 版本识别 | 页面显示本次发布的版本号 |
| 导航 | 切换工具,不清空便签、不重置正在运行的计时 |
| 任务提交 | 添加、完成和删除后,刷新可以恢复 |
| 搜索筛选 | 不修改完整任务,只改变显示结果 |
| 操作定位 | 同名任务和筛选结果中的任务能独立删除 |
| 便签保存 | 保存后刷新,文字和换行恢复 |
| 空数据 | 删除全部任务或清空便签后,刷新保持为空 |
| 窄屏 | 输入框、任务文字和删除按钮能正常使用 |
| 手机访问 | 使用线上地址访问,不使用电脑的127.0.0.1地址 |
故障测试还应继续保留第九课的内存存储测试。
特别检查:
保存失败 → 任务状态不提交 保存失败 → 新任务输入不清空 读取损坏数据 → 暂停编辑,不覆盖原内容这些条件很难靠“随便点几下”稳定验证。
最后谈版本更新和回退。
这个项目里至少存在两种版本:
程序版本:v1.0.0 任务数据格式:version: 1它们不是同一个概念。
修改颜色、布局或错误提示,可以升级程序版本,同时继续使用原来的数据格式。
如果以后将任务字段改成新的结构,就需要考虑数据迁移,以及旧版本代码是否还能读取新数据。
建议每次发布记录:
发布版本:v1.0.1 改动: 修复筛选后删除任务的定位问题。 验证: 同名任务、筛选删除、刷新恢复均通过。 数据格式: 继续使用 version: 1。 对应提交: 填写实际 Git 提交编号。版本号帮助用户识别页面,提交编号帮助你准确找到源代码。
如果新版本出错,可以从已验证的历史提交恢复相应文件,再提交一次修复并重新部署。恢复时要检查 HTML、CSS 和 JavaScript 的依赖关系,不能只凭感觉回退其中一个文件。
回退代码不会自动还原用户数据。
如果新版本已经写入了旧代码无法读取的数据结构,回退后的页面仍然可能报错。所以改变数据格式之前,需要准备迁移和恢复方案。
也不要只根据页面版本文字判断更新成功。浏览器或部署缓存可能让部分文件暂时保持旧内容,应结合 Network 请求、实际文件内容和功能检查确认。
这个版本仍有明确的能力边界:
- 多个标签页同时编辑,可能覆盖彼此的旧状态。
- 番茄钟刷新后重置,后台提示不保证准时。
- 浏览器数据被清理后,需要依赖额外备份恢复。
- 电脑与手机之间没有自动同步。
- 便签字符数按 Unicode 码点计算,并非严格的中文字数。
后续进阶可以围绕这些问题展开:数据导入导出、存储版本迁移、多标签页冲突、IndexedDB,以及带登录和同步的后端。
这十课完成的是一个可以运行、解释和验证的小项目。继续深入时,最有价值的做法,是为每个新增能力明确数据规则、失败行为和验收方法。