从零到一:一个“自动保存草稿”功能的实现
在现代Web应用中,用户体验至上。没有什么比用户在精心编辑长篇内容后,因意外关闭浏览器或网络问题而丢失所有心血更令人沮丧的了(一位站友就和我吐槽过在草稿箱功能上线前因为网络问题而丢失了所有的心血)。一个健壮的“草稿箱”功能,特别是具备“自动保存”能力,已然成为内容创作平台的标配。
本文将完整记录一次添加“自动保存草稿”功能的完整旅程。从最基础的手动保存开始,逐步迭代到一个无感、智能的自动保存系统,并分享整个过程中遇到的挑战与排错心得。
阶段一:奠定基石 - 手动保存草稿
任何复杂的功能都始于一个简单的MVP(最小可行产品)。我们的第一步是实现一个用户可以主动点击“存为草稿”按钮的功能。
1. 后端设计:一切始于状态
我的设计是在数据模型中引入一个“状态”字段,增加了一个ArticleStatus枚举,包含draft(草稿)、published(已发布)两个状态。
public class Article {
// ... 其他字段
private ArticleStatus status;
public enum ArticleStatus {
draft, // 草稿
published // 已发布
}
}
接着,我们创建一个专门处理草稿相关请求的API端点。
Controller 层 (DraftController.java)
@Controller
public class DraftController {
@Autowired
private ArticleService articleService;
@Autowired
private UserService userService;
// API: 保存新草稿
@PostMapping("/api/article/save-draft")
@ResponseBody
public ResponseEntity<?> saveDraft(@AuthenticationPrincipal Principal principal,
@RequestBody ArticlePublishRequestDTO requestDTO) {
User user = userService.getCurrentUserFromPrincipal(principal);
// ... 安全和数据校验 ...
ArticleDetailsDTO savedDraft = articleService.saveDraft(requestDTO, user.getId());
return ResponseEntity.ok(savedDraft);
}
// 页面: 显示用户的草稿列表
@GetMapping("/drafts")
public String showDraftsPage(Model model, @AuthenticationPrincipal Principal principal) {
User user = userService.getCurrentUserFromPrincipal(principal);
Page<ArticleExcerptDTO> drafts = articleService.getDraftsByUserId(user.getId(), 1, 10);
model.addAttribute("drafts", drafts);
return "drafts"; // 指向 drafts.html 模板
}
}
Service 层 (ArticleServiceImpl.java)
Service层负责将DTO转换为实体,设置状态为draft,并存入数据库。
@Override
@Transactional
public ArticleDetailsDTO saveDraft(ArticlePublishRequestDTO requestDTO, Long userId) {
Article article = new Article();
article.setTitle(requestDTO.getTitle());
article.setContent(requestDTO.getContent());
article.setUserId(userId);
article.setStatus(Article.ArticleStatus.draft); // 关键:设置为草稿状态
// ... 设置其他默认值 ...
articleMapper.insert(article);
return convertToDetailsDTO(article);
}
2. 前端实现:从点击开始
前端的改造同样直接:
- 在发布页面 (publish.html) 的发布按钮旁,增加一个“存为草稿”按钮。
- 使用 JavaScript 监听该按钮的点击事件,通过 fetch API 调用后端接口。
- 创建一个 drafts.html 页面,使用 Thymeleaf 的 th:each 循环渲染后端传来的草稿列表。
至此,一个基础但完整的手动草稿功能便完成了。用户可以主动保存和查看自己的草稿,避免了最糟糕的情况。但我们还能做得更好。
阶段二:体验飞跃 - 无感自动保存
自动保存的核心是让用户忘记保存的存在。它应该像一个可靠的助手,在用户专注于创作时,默默地在后台处理一切。
1. 后端进化:支持“创建”与“更新”
自动保存意味着第一次是“创建”草稿,后续都是对同一篇草稿进行“更新”。因此,我们需要让后端的保存接口更加智能。
我们通过在请求体 ArticlePublishRequestDTO 中增加一个可选的 articleId 字段来实现这一点。
@Data
public class ArticlePublishRequestDTO {
private Integer articleId; // 新增字段
private String title;
private String content;
// ...
}
接着,改造Controller层的保存方法,让它根据articleId是否存在来决定是调用saveDraft还是updateDraft。
@PostMapping("/api/article/save-draft")
@ResponseBody
public ResponseEntity<?> saveOrUpdateDraft(@RequestBody ArticlePublishRequestDTO requestDTO, ...) {
// ...
if (requestDTO.getArticleId() \!= null) {
// articleId 存在,执行更新逻辑
return ResponseEntity.ok(articleService.updateDraft(requestDTO, user.getId()));
} else {
// articleId 不存在,执行创建逻辑
return ResponseEntity.ok(articleService.saveDraft(requestDTO, user.getId()));
}
}
2. 前端架构:智能与优雅的结合
前端是自动保存的主战场,其设计的优劣直接决定了用户体验。我采用了 状态管理 + 事件防抖 的经典组合。
A. 状态管理变量
需要几个“旗帜”来追踪编辑器的实时状态:
isContentDirty
: 内容是否被修改?只有“脏”的内容才需要保存。isSaving
: 是否正在保存中?这是一个“锁”,防止在前一次保存完成前发起新的请求。currentDraftId
: 当前草稿的ID。初始为null,首次保存成功后,用后端返回的ID更新它。
B. “防抖” (Debounce) 技术
我们不希望用户每按一个键就触发一次保存。最佳时机是在用户输入停顿时。“防抖”正是为此而生。
实现起来很简单,利用 setTimeout 和 clearTimeout:
// 定义一个统一的内容变化处理器
const onContentChange = () => {
isContentDirty = true; // 标记内容已修改
// 清除上一个未执行的定时器
if (autoSaveTimer) {
clearTimeout(autoSaveTimer);
}
// 设置一个新的定时器,4秒后执行真正的保存函数
autoSaveTimer = setTimeout(autoSaveDraft, 4000);
};
// 将处理器绑定到输入事件
articleTitleInput.addEventListener('input', onContentChange);
editorInstance.on('change', onContentChange);
C. 异步通信核心 (autoSaveDraft 函数)
当用户的输入停顿超过4秒(也就是设定的定时器时间),这个核心函数被触发。
async function autoSaveDraft() {
// 1. 前置检查: 如果内容没变或正在保存,则退出
if (!isContentDirty || isSaving) return;
// 2. 上锁并更新UI
isSaving = true;
updateAutoSaveStatus('正在自动保存...');
// 3. 收集数据 (包含可能为null的currentDraftId)
const requestData = {
articleId: currentDraftId,
title: dom.articleTitleInput.value.trim() || "无标题草稿",
content: editorInstance.getMarkdown(),
};
try {
// 4. 发送请求
const response = await apiPost('/api/article/save-draft', requestData);
if (response.ok) {
const result = await response.json();
// 5. 关键:首次保存成功后,用返回的ID更新currentDraftId
if (!currentDraftId && result.articleId) {
currentDraftId = result.articleId;
}
// 6. 重置“脏”标记
isContentDirty \= false;
updateAutoSaveStatus('已保存');
} else {
// ... 错误处理
}
} catch (error) {
// ... 网络错误处理
} finally {
// 7. 无论成功失败,最后都要解锁
isSaving = false;
}
}
阶段三:穿越迷雾 - 漫长而宝贵的排错之旅
理论很丰满,现实很骨感。在集成了上述代码后,陷入了一个棘手的困境:功能时好时坏,且控制台频繁抛出 ReferenceError: csrfToken is not defined 错误。
这是排错过程的真实复盘:
-
初步诊断:网络问题? 打开浏览器开发者工具的“网络(Network)”面板,发现在报错时,根本没有任何请求被发出去。这几乎可以确定问题出在前端JavaScript代码中,它在fetch请求执行前就崩溃了。
-
深入分析:变量引用错误 ReferenceError 意味着代码在尝试使用一个未被声明的变量。我们检查了所有使用csrfToken和csrfHeader的地方,代码逻辑看起来没有问题。
-
大胆假设:HTML中没有令牌? 我们猜想是不是后端模板没有成功渲染出CSRF的meta标签。通过在浏览器中“查看网页源代码”,我们搜索_csrf,结果令人意外:meta标签一直都在!
-
柳暗花明:console.log大法 既然HTML中有,而JS中没有,问题一定出在JS获取DOM的环节。我们使用了最朴素也最强大的武器——console.log,在JS文件的不同位置打印变量,以定位脚本中断的精确位置。
结果发现,在DOMContentLoaded事件的最开始,document.querySelector('meta[name="_csrf"]')返回的就是null。 -
最终的“敌人”:浏览器缓存与脚本加载时机 怎么会这样?HTML里明明有,为何JS一加载就拿不到?经过反复的强制刷新(Ctrl+Shift+R)和代码结构调整,我们终于锁定了两个罪魁祸首:
- 强力的浏览器缓存:浏览器一直在加载旧的、有问题的JS文件(这是开发中最常见也最容易被忽略的问题)。
- 不规范的代码结构:最初,获取CSRF令牌的代码和使用它的代码分散在不同的函数甚至作用域中。这导致了变量作用域混乱,在某些执行路径下变量确实未被定义。
结论与最终方案
这次从零到一的开发经历,特别是漫长的排错过程,给我带来了宝贵的经验:
- 功能设计,用户先行:自动保存不是一个技术噱头,而是对用户创作心血的尊重。
- 前端逻辑,稳字当头:对于复杂交互,清晰的状态管理和成熟的设计模式(如防抖)是成功的关键。
- 排错过程,大胆假设,小心求证:从网络到前端,再到HTML源代码,层层递进的排查思路是解决疑难杂症的利器。
- 敬畏缓存,规范为王:始终注意浏览器缓存对开发的影响,并保持代码结构的清晰、统一(如将全局变量和配置在文件顶部集中声明),可以避免90%的“灵异事件”。
评论区
请登录后发表评论