浏览器阻止返回实现记录


浏览器返回时提示未保存内容的实现记录

在一些编辑页面中,用户修改了内容但还没有保存,如果这时候点击浏览器返回、页面返回按钮、刷新或者关闭页面,就可能造成内容丢失

本文记录一种比较通用的实现方式:通过 history.pushState 增加一层“返回缓冲”,配合 popstatebeforeunload 实现未保存提示

一、实现思路

核心思路:

  1. 页面内容发生变化,标记为 dirty
  2. dirty = true 时,往浏览器历史记录里插入一条当前页面地址
  3. 用户点击浏览器返回时,先触发 popstate
  4. popstate 中弹出确认框
  5. 用户取消,则重新插入一条 history,继续留在当前页面
  6. 用户确认,则真正执行返回
  7. 用户刷新或关闭页面时,使用 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,否则下一次点击返回就可能直接离开页面


  目录