浏览器返回时提示未保存内容的实现记录
在一些编辑页面中,用户修改了内容但还没有保存,如果这时候点击浏览器返回、页面返回按钮、刷新或者关闭页面,就可能造成内容丢失
本文记录一种比较通用的实现方式:通过 history.pushState 增加一层“返回缓冲”,配合 popstate 和 beforeunload 实现未保存提示
一、实现思路
核心思路:
- 页面内容发生变化,标记为
dirty - 当
dirty = true时,往浏览器历史记录里插入一条当前页面地址 - 用户点击浏览器返回时,先触发
popstate - 在
popstate中弹出确认框 - 用户取消,则重新插入一条 history,继续留在当前页面
- 用户确认,则真正执行返回
- 用户刷新或关闭页面时,使用
beforeunload触发浏览器原生提示
简单来说,就是用一条额外的 history 记录挡住第一次返回
二、原生 HTML 示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>未保存返回提示</title>
</head>
<body>
<textarea id="editor" placeholder="输入一些内容"></textarea>
<button id="save">保存</button>
<button id="back">返回</button>
<script>
let isDirty = false;
let guardActive = false;
let allowLeave = false;
const editor = document.getElementById("editor");
const save = document.getElementById("save");
const back = document.getElementById("back");
function pushGuard() {
history.pushState({ unsavedGuard: true }, "", location.href);
guardActive = true;
}
function confirmLeave(callback) {
const ok = window.confirm("存在未保存的修改,确定要离开吗?");
if (ok) {
allowLeave = true;
guardActive = false;
callback();
} else {
pushGuard();
}
}
editor.addEventListener("input", () => {
isDirty = true;
if (!guardActive) {
pushGuard();
}
});
save.addEventListener("click", () => {
isDirty = false;
guardActive = false;
alert("保存成功");
});
back.addEventListener("click", () => {
if (!isDirty) {
history.back();
return;
}
confirmLeave(() => history.go(-2));
});
window.addEventListener("popstate", () => {
if (allowLeave || !isDirty) return;
guardActive = false;
confirmLeave(() => history.back());
});
window.addEventListener("beforeunload", (event) => {
if (!isDirty) return;
event.preventDefault();
event.returnValue = "";
});
</script>
</body>
</html>
三、Vue 示例
<script setup>
import { onMounted, onBeforeUnmount, ref } from "vue";
const content = ref("");
const isDirty = ref(false);
let guardActive = false;
let allowLeave = false;
function pushGuard() {
window.history.pushState({ unsavedGuard: true }, "", window.location.href);
guardActive = true;
}
function markDirty() {
isDirty.value = true;
if (!guardActive) {
pushGuard();
}
}
function save() {
isDirty.value = false;
guardActive = false;
alert("保存成功");
}
function confirmLeave(callback) {
const ok = window.confirm("存在未保存的修改,确定要离开吗?");
if (ok) {
allowLeave = true;
guardActive = false;
callback();
} else {
pushGuard();
}
}
function back() {
if (!isDirty.value) {
window.history.back();
return;
}
confirmLeave(() => window.history.go(-2));
}
function handlePopState() {
if (allowLeave || !isDirty.value) return;
guardActive = false;
confirmLeave(() => window.history.back());
}
function handleBeforeUnload(event) {
if (!isDirty.value) return;
event.preventDefault();
event.returnValue = "";
}
onMounted(() => {
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
});
onBeforeUnmount(() => {
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
});
</script>
<template>
<textarea v-model="content" @input="markDirty" />
<button @click="save">保存</button>
<button @click="back">返回</button>
</template>
四、React 示例
import { useCallback, useEffect, useRef, useState } from "react";
function useUnsavedNavigationGuard(hasUnsavedChanges: boolean) {
const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
const guardActiveRef = useRef(false);
const allowLeaveRef = useRef(false);
useEffect(() => {
hasUnsavedChangesRef.current = hasUnsavedChanges;
}, [hasUnsavedChanges]);
const pushGuard = useCallback(() => {
window.history.pushState({ unsavedGuard: true }, "", window.location.href);
guardActiveRef.current = true;
}, []);
const confirmLeave = useCallback(
(leave: () => void) => {
const ok = window.confirm("存在未保存的修改,确定要离开吗?");
if (ok) {
allowLeaveRef.current = true;
guardActiveRef.current = false;
leave();
} else {
pushGuard();
}
},
[pushGuard],
);
const requestBack = useCallback(() => {
if (!hasUnsavedChangesRef.current) {
window.history.back();
return;
}
confirmLeave(() => {
window.history.go(guardActiveRef.current ? -2 : -1);
});
}, [confirmLeave]);
useEffect(() => {
if (hasUnsavedChanges && !guardActiveRef.current) {
pushGuard();
}
}, [hasUnsavedChanges, pushGuard]);
useEffect(() => {
const handlePopState = () => {
if (allowLeaveRef.current || !hasUnsavedChangesRef.current) return;
guardActiveRef.current = false;
confirmLeave(() => window.history.back());
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!hasUnsavedChangesRef.current) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [confirmLeave]);
return requestBack;
}
export function EditorPage() {
const [value, setValue] = useState("");
const [savedValue, setSavedValue] = useState("");
const hasUnsavedChanges = value !== savedValue;
const requestBack = useUnsavedNavigationGuard(hasUnsavedChanges);
return (
<div>
<textarea
value={value}
onChange={(event) => setValue(event.target.value)}
/>
<button onClick={() => setSavedValue(value)}>保存</button>
<button onClick={requestBack}>返回</button>
</div>
);
}
五、几个注意点
1. beforeunload 不能自定义文案
刷新或关闭页面时,浏览器只允许显示原生提示,不能自定义弹窗内容
window.addEventListener("beforeunload", (event) => {
event.preventDefault();
event.returnValue = "";
});
2. popstate 适合处理浏览器返回
浏览器返回、前进会触发 popstate
但它不会天然阻止导航,所以需要提前插入一条 history guard
3. 保存成功后要清除 dirty 状态
保存成功后,应更新“已保存版本”的标记
例如:
savedValue = currentValue;
isDirty = false;
在复杂页面中,也可以用 JSON 签名来判断内容是否变化:
const signature = JSON.stringify(data);
4. 页面按钮和浏览器按钮最好走同一套逻辑
不要页面返回写一套,浏览器返回写一套
建议统一封装成:
requestBack();
这样逻辑更清楚,也不容易出现两次确认、退错层级的问题
六、小结
这种实现方式适合大多数表单、编辑器、流程画布、配置页面
关键点只有三个:
- 用
dirty判断是否有未保存内容 - 用
pushState + popstate拦截浏览器返回 - 用
beforeunload处理刷新和关闭页面
实现不复杂,但细节不少
尤其是取消返回后,需要重新插入 history guard,否则下一次点击返回就可能直接离开页面