Navigation API 完全指南:告别 History API,拥抱现代 SPA 路由

深入解析浏览器 Navigation API(导航接口),替代 History API 实现可拦截、可中断、类型安全的 SPA 路由。含完整代码示例、性能对比和实战避坑指南。

前端开发 2026-06-08 15 分钟

如果你正在开发单页应用(SPA),几乎 100% 会用到 history.pushState()popstate 事件。但 History API 存在近 20 年来从未被修复的根本设计缺陷——无法拦截导航、无法取消后退、状态管理混乱。2024 年底,Chrome 130 正式稳定了 Navigation API(导航接口),这是 W3C 为 SPA 路由量身打造的全新标准,目前已有超过 85% 的全球浏览器覆盖率。本文将从实际开发痛点出发,用完整可运行的代码带你掌握这个即将取代 History API 的现代方案。

🔧 一、History API 的三大硬伤

在理解 Navigation API 的价值之前,先看看我们每天都在忍受的问题。

1.1 无法拦截导航

当用户点击浏览器的「后退」按钮时,popstate 事件只通知、不拦截。你无法阻止这次导航,也无法在导航前弹出「确定离开?」的确认框。开发者只能用 beforeunload 这个全局事件来兜底,但它不区分是后退、跳转还是关闭标签页。

// ❌ 错误写法:History API 无法精确拦截后退
window.addEventListener('popstate', (e) => {
  // 到这里时,URL 已经变了,来不及了
  console.log('页面已经后退了,无法阻止', e.state);
});

// 这个全局事件太粗暴,关闭标签页也会触发
window.addEventListener('beforeunload', (e) => {
  e.preventDefault();
});
// ✅ 正确写法:Navigation API 可以拦截并取消
navigation.addEventListener('navigate', (e) => {
  if (hasUnsavedChanges()) {
    e.preventDefault(); // 直接阻止导航
    showConfirmDialog('有未保存的更改,确定离开?', () => {
      navigation.back(); // 用户确认后再导航
    });
  }
});

💡 提示:navigation.navigate 事件在 URL 变化之前触发,这意味着你可以在拦截器中做权限检查、表单验证、数据预加载等操作,而 History API 完全做不到。

1.2 状态管理是噩梦

History API 的 state 是一个挂载在 history.state 上的隐式全局对象。当多个组件同时操作路由状态时,后设置的会覆盖先设置的,而且没有任何事件通知你 state 被修改了。

// ❌ 错误写法:History API 的 state 容易被覆盖
// 组件 A 设置了状态
history.replaceState({ scrollY: 100 }, '');

// 组件 B 不知道 A 设了什么,直接覆盖了
history.replaceState({ formData: { name: 'test' } }, '');

// 结果:scrollY 丢失了,没有任何警告
console.log(history.state); // { formData: { name: 'test' } }
// ✅ 正确写法:Navigation API 的 entry 自带独立状态
const entry = navigation.currentEntry;
console.log(entry.getState()); // 当前页面的状态,只读且独立

// 更新状态不会覆盖,而是合并(实际是替换整个对象,但有事件通知)
navigation.updateCurrentEntry({ state: { scrollY: 100 } });

// 监听状态变化
navigation.addEventListener('currententrychange', () => {
  const newState = navigation.currentEntry.getState();
  console.log('状态已更新:', newState);
});

1.3 路由信息碎片化

History API 的 location 对象信息有限。你想知道「用户是从哪个页面来的」,只能自己维护一个栈。想获取当前页面的滚动位置恢复,也需要额外逻辑。更糟糕的是,popstate 事件只在浏览器前进/后退时触发,pushStatereplaceState 调用时没有任何事件通知。

这意味着你无法监听「所有类型的 URL 变化」,只能分别处理每种场景。对于复杂的 SPA 应用,这导致了大量的防御性代码和边界情况处理。

能力 History API Navigation API
拦截导航事件 ❌ 不支持 navigate 事件
取消后退/前进 ❌ 不支持 event.preventDefault()
独立页面状态 ⚠️ 易覆盖 entry.getState()
导航历史栈遍历 ❌ 不支持 navigation.entries()
导航完成通知 ❌ 无 navigatesuccess / navigateerror
同 URL hash 导航 ⚠️ 行为不一致 ✅ 完整支持
AbortSignal 支持 ❌ 无 event.signal
导航类型区分 ❌ 无法区分 push / replace / traverse / reload
跨文档通信 ❌ 无 info 参数

⚠️ **警告:**Navigation API 目前不支持 Safari(2026 年 6 月状态)。在生产环境中使用需要 polyfill 或 feature detection。文末会给出兼容方案。

🚀 二、Navigation API 核心用法实战

2.1 基本导航操作

Navigation API 的核心对象是全局的 navigation,它提供了与 History API 对应但更强大的方法:

// ✅ 基本导航操作对比

// --- 前进/后退(等价于 history.go)---
navigation.back();       // 后退一步
navigation.forward();    // 前进一步
navigation.go(-2);       // 后退两步

// --- 跳转到新页面(等价于 history.pushState)---
// navigate() 返回一个 NavigationResult 对象
const result = await navigation.navigate('/dashboard', {
  state: { userId: 42, from: '/login' },
  info: { transition: 'fade-in' }  // 额外信息,不持久化
});
console.log(result.navigationType); // 'push'

// --- 替换当前页面(等价于 history.replaceState)---
navigation.navigate('/dashboard', {
  state: { userId: 42 },
  history: 'replace'  // 关键参数:replace 模式
});

// --- reload 当前页面 ---
navigation.reload({ state: { refreshed: true } });

2.2 事件模型详解

Navigation API 的事件系统比 History API 丰富得多。理解这些事件的触发时机是正确使用它的关键:

// ✅ Navigation API 事件生命周期

// 1. navigate —— 任何导航发生时触发(最关键)
navigation.addEventListener('navigate', (e) => {
  console.log('导航类型:', e.navigationType);  // push | replace | traverse | reload
  console.log('目标 URL:', e.destination.url);
  console.log('是否 hash 变化:', e.hashChange);
  console.log('是否可以拦截:', e.canIntercept);
  console.log('AbortSignal:', e.signal);

  // 拦截导航
  if (e.canIntercept) {
    e.intercept({
      handler: async () => {
        console.log('拦截的 handler 开始执行');
        await renderPage(e.destination.url);
        console.log('handler 执行完成');
      }
    });
  }
});

// 2. currententrychange —— 当前条目改变时触发
// 与 navigate 的区别:只在 currentEntry 变化时触发,reload 时也会触发
navigation.addEventListener('currententrychange', (e) => {
  const entry = navigation.currentEntry;
  console.log('当前页面:', entry.url);
  console.log('页面状态:', entry.getState());
  console.log('导航类型:', e.navigationType);
});

// 3. navigatesuccess —— 导航成功完成时触发
navigation.addEventListener('navigatesuccess', (e) => {
  console.log('导航成功:', e.destination.url);
  // 适合:停止 loading 动画、记录页面访问统计
});

// 4. navigateerror —— 导航出错时触发
navigation.addEventListener('navigateerror', (e) => {
  console.error('导航失败:', e.error);
  // 适合:显示错误页面、记录错误日志
  showErrorPage(e.error);
});

📌 记住:navigate 事件和 currententrychange 事件的区别很重要。navigate 在所有导航时触发(包括同 URL 的 replace),而 currententrychange 只在当前条目实际变化时触发。对于状态更新,监听 currententrychange 更精确。

2.3 构建类型安全的路由器

下面是一个完整的、可以在生产中使用的 SPA 路由器实现:

// ✅ 基于 Navigation API 的 SPA 路由器
class ModernRouter {
  constructor() {
    this.routes = new Map();
    this.guards = [];
    this._init();
  }

  // 注册路由
  route(pattern, handler) {
    // 支持参数匹配,如 /users/:id
    const regex = new RegExp(
      '^' + pattern.replace(/:(\w+)/g, '(?<$1>[^/]+)') + '$'
    );
    this.routes.set(pattern, { regex, handler });
    return this;
  }

  // 注册全局守卫(类似 Vue Router 的 beforeEach)
  beforeEach(guard) {
    this.guards.push(guard);
    return this;
  }

  _init() {
    navigation.addEventListener('navigate', (e) => {
      // 跳过非同源导航、下载链接等
      if (!this._shouldIntercept(e)) return;

      const url = new URL(e.destination.url);
      const match = this._matchRoute(url.pathname);

      if (!match) {
        console.warn(`未匹配的路由: ${url.pathname}`);
        return;
      }

      e.intercept({
        // 核心:在拦截中执行路由逻辑
        handler: async () => {
          const context = {
            path: url.pathname,
            params: match.params,
            query: Object.fromEntries(url.searchParams),
            state: e.destination.getState(),
            signal: e.signal
          };

          // 执行前置守卫
          for (const guard of this.guards) {
            const result = await guard(context);
            if (result === false) {
              e.preventDefault(); // 守卫拒绝,取消导航
              return;
            }
            if (typeof result === 'string') {
              navigation.navigate(result); // 重定向
              return;
            }
          }

          // 执行路由处理器
          await match.handler(context);
        },

        // 启用滚动位置恢复
        scroll: 'restore'
      });
    });

    // 处理初始页面
    const url = new URL(location.href);
    const match = this._matchRoute(url.pathname);
    if (match) {
      match.handler({
        path: url.pathname,
        params: match.params,
        query: Object.fromEntries(url.searchParams),
        state: navigation.currentEntry.getState()
      });
    }
  }

  _shouldIntercept(e) {
    if (e.hashChange) return true;  // hash 变化也要拦截
    if (e.downloadRequest !== null) return false;
    if (e.navigationType === 'reload') return true;

    const url = new URL(e.destination.url);
    // 只拦截同源、非外链的导航
    return url.origin === location.origin;
  }

  _matchRoute(pathname) {
    for (const [pattern, { regex }] of this.routes) {
      const match = pathname.match(regex);
      if (match) {
        return { pattern, params: match.groups || {} };
      }
    }
    return null;
  }
}

使用这个路由器非常直观:

// ✅ 使用示例
const router = new ModernRouter();

// 全局守卫:未登录跳转登录页
router.beforeEach(async (ctx) => {
  const isLoggedIn = await checkAuth();
  if (!isLoggedIn && ctx.path !== '/login') {
    return '/login';
  }
  return true;
});

// 注册路由
router.route('/', async (ctx) => {
  document.getElementById('app').innerHTML = '<h1>首页</h1>';
});

router.route('/users/:id', async (ctx) => {
  const user = await fetchUser(ctx.params.id);
  document.getElementById('app').innerHTML = `
    <h1>${user.name}</h1>
    <p>ID: ${ctx.params.id}</p>
  `;
  // 保存滚动位置到页面状态
  navigation.updateCurrentEntry({
    state: { ...ctx.state, scrollTop: 0 }
  });
});

router.route('/dashboard', async (ctx) => {
  // 利用 signal 支持:用户快速切换页面时取消未完成的请求
  const data = await fetch('/api/dashboard', {
    signal: ctx.signal
  }).then(r => r.json());

  document.getElementById('app').innerHTML = renderDashboard(data);
});

2.4 加载状态与骨架屏模式

在 SPA 中,页面切换时用户会看到短暂的空白。传统做法是在每个组件内部管理 loading 状态,但 Navigation API 让你可以在路由层统一处理:

// ✅ 路由级统一 loading 状态管理
const loadingOverlay = document.getElementById('loading-overlay');

navigation.addEventListener('navigate', (e) => {
  if (!shouldIntercept(e)) return;

  e.intercept({
    async beforeStart() {
      // 在 handler 执行前显示 loading
      // 这个回调在 URL 更新之前执行
      loadingOverlay.classList.add('visible');
      loadingOverlay.setAttribute('aria-busy', 'true');
    },
    async handler() {
      const url = new URL(e.destination.url);
      const route = matchRoute(url.pathname);
      if (!route) return;

      // 如果有缓存,先显示缓存内容(类似 stale-while-revalidate)
      const cached = pageCache.get(url.pathname);
      if (cached) {
        renderToDOM(cached);
        loadingOverlay.classList.remove('visible');
      }

      // 在后台加载新数据
      try {
        const freshData = await route.loadData({
          signal: e.signal,
          state: e.destination.getState()
        });

        renderToDOM(freshData);
        pageCache.set(url.pathname, freshData);
      } catch (err) {
        if (err.name === 'AbortError') return;
        renderError(err);
      }
    },
    commit: 'after-transition'
  });
});

💡 提示:beforeStart 回调在 URL 变化之前执行,适合做 UI 准备(显示 loading、保存滚动位置)。commit: 'after-transition' 配合 View Transitions API 可以避免页面闪白。info 参数可以在 navigate 事件中传递过渡类型信息,比如 { info: { direction: 'forward' } }

2.5 导航历史栈操作

Navigation API 最强大的特性之一是能完整访问和操控导航历史栈:

// ✅ 历史栈操作

// 遍历所有历史条目
const entries = navigation.entries();
console.log(`历史栈中共有 ${entries.length} 个条目`);
entries.forEach((entry, i) => {
  const isActive = entry === navigation.currentEntry;
  console.log(
    `${isActive ? '👉' : '  '} [${i}] ${entry.url} ` +
    `| key: ${entry.key} | index: ${entry.index}`
  );
});

// 获取特定条目
const current = navigation.currentEntry;
const previous = current.index > 0 ? entries[current.index - 1] : null;

// 根据 key 定位条目(类似 History API 的 history.go 但更精确)
const targetEntry = entries.find(e => e.url.includes('/users'));
if (targetEntry) {
  // 回到特定页面,同时传递新状态
  navigation.traverseTo(targetEntry.key, {
    state: { scrollToSection: 'comments' }
  });
}

// 删除未来的历史记录(前进栈清空)
// 注意:这会触发 dispose 事件
navigation.navigate('/new-page', {
  history: 'replace', // replace 模式隐式清空前进栈
  state: { fresh: true }
});

💡 提示:navigation.entries() 返回的是当前导航会话的完整快照。在用户刷新页面后,栈会被清空,只有 currentEntry 保留。所以不要依赖 entries 做持久化状态。每个条目都有一个唯一的 key 属性,可以用 traverseTo(key) 精确跳转到历史中的任意位置。

💡 三、高级模式与避坑指南

3.1 AbortSignal:优雅取消异步操作

Navigation API 的每个拦截事件都携带一个 AbortSignal,当用户在请求还没完成时就跳走了,信号自动触发 abort。这是解决「竞态条件」的完美方案——在 History API 时代,开发者需要手动创建 AbortController 并在每次导航时取消之前的请求,代码非常繁琐。

// ✅ 利用 AbortSignal 取消未完成的请求
navigation.addEventListener('navigate', (e) => {
  e.intercept({
    handler: async () => {
      const signal = e.signal;

      // 并行加载多个资源,任何一个被取消都会自动终止
      const [user, posts, settings] = await Promise.all([
        fetch(`/api/user`, { signal }).then(r => r.json()),
        fetch(`/api/posts`, { signal }).then(r => r.json()),
        fetch(`/api/settings`, { signal }).then(r => r.json()),
      ]);

      renderPage({ user, posts, settings });
    }
  });
});

// AbortSignal 还可以用于取消其他异步操作
navigation.addEventListener('navigate', (e) => {
  e.intercept({
    handler: async () => {
      const controller = new AbortController();

      // 建立 WebSocket 连接,导航变化时自动断开
      const ws = new WebSocket('wss://api.example.com/live');
      e.signal.addEventListener('abort', () => ws.close());

      // 建立 SSE 连接
      const eventSource = new EventSource('/api/events');
      e.signal.addEventListener('abort', () => eventSource.close());

      // 设置超时,防止导航卡住
      const timeoutId = setTimeout(() => {
        controller.abort('Navigation timeout');
      }, 10000);

      e.signal.addEventListener('abort', () => clearTimeout(timeoutId));

      await loadData(controller.signal);
    }
  });
});

3.2 页面过渡动画

结合 View Transitions API,Navigation API 可以实现丝滑的页面切换。两者配合使用时,Navigation API 负责「拦截导航 + 加载数据」,View Transitions API 负责「执行动画过渡」:

// ✅ Navigation API + View Transitions 实现页面过渡
navigation.addEventListener('navigate', (e) => {
  // 只对 push/traverse 类型的导航启用过渡
  if (!['push', 'traverse'].includes(e.navigationType)) return;

  e.intercept({
    handler: async () => {
      // 使用 View Transitions API
      if (!document.startViewTransition) {
        await renderNewPage();
        return;
      }

      const transition = document.startViewTransition(async () => {
        await renderNewPage();
      });

      // 监听过渡状态
      try {
        await transition.finished;
        console.log('页面过渡完成');
      } catch (err) {
        console.warn('过渡被中断:', err);
      }
    }
  });
});

3.3 生产环境兼容方案

⚠️ **警告:**截至 2026 年 6 月,Safari 仍不支持 Navigation API。以下方案确保所有用户都能正常工作。如果你的用户群体中 Safari 占比较高(尤其是 iOS 设备),建议使用降级方案。

// ✅ Feature detection + 降级方案
function createRouter(options = {}) {
  // 检测 Navigation API 支持
  if ('navigation' in window) {
    return new ModernRouter(options);
  }
  // 降级到 History API 封装
  return new LegacyRouter(options);
}

// LegacyRouter 用 History API 模拟相同的接口
class LegacyRouter {
  constructor() {
    this.routes = new Map();
    this.guards = [];
    this._stateStack = [];
    this._init();
  }

  route(pattern, handler) {
    const regex = new RegExp(
      '^' + pattern.replace(/:(\w+)/g, '(?<$1>[^/]+)') + '$'
    );
    this.routes.set(pattern, { regex, handler });
    return this;
  }

  beforeEach(guard) {
    this.guards.push(guard);
    return this;
  }

  _init() {
    // 监听 popstate(后退/前进)
    window.addEventListener('popstate', (e) => {
      this._handleNavigation(location.pathname, e.state, 'traverse');
    });

    // 拦截所有链接点击
    document.addEventListener('click', (e) => {
      const link = e.target.closest('a[href]');
      if (!link) return;
      const url = new URL(link.href);
      if (url.origin !== location.origin) return;
      if (link.target === '_blank') return;

      e.preventDefault();
      this._navigateTo(url.pathname + url.search, 'push');
    });

    // 处理初始页面
    this._handleNavigation(location.pathname, history.state, 'reload');
  }

  _navigateTo(url, type) {
    const state = {};
    history.pushState(state, '', url);
    this._handleNavigation(new URL(url).pathname, state, type);
  }

  async _handleNavigation(pathname, state, type) {
    const match = this._matchRoute(pathname);
    if (!match) return;

    const context = {
      path: pathname,
      params: match.params,
      query: Object.fromEntries(new URL(location.href).searchParams),
      state: state || {},
      signal: AbortSignal.timeout(10000)
    };

    for (const guard of this.guards) {
      const result = await guard(context);
      if (result === false) return;
      if (typeof result === 'string') {
        this._navigateTo(result, 'push');
        return;
      }
    }

    await match.handler(context);
  }

  _matchRoute(pathname) {
    for (const [, { regex }] of this.routes) {
      const match = pathname.match(regex);
      if (match) return { params: match.groups || {} };
    }
    return null;
  }
}

3.4 与主流框架的集成

// ✅ 在 Vue 3 中集成 Navigation API
// composables/useNavigationRouter.js
import { ref, readonly } from 'vue';

export function useNavigationRouter() {
  const currentPath = ref(location.pathname);
  const currentState = ref(navigation.currentEntry?.getState() || {});

  if ('navigation' in window) {
    navigation.addEventListener('currententrychange', () => {
      currentPath.value = new URL(navigation.currentEntry.url).pathname;
      currentState.value = navigation.currentEntry.getState() || {};
    });
  }

  function push(path, state = {}) {
    if ('navigation' in window) {
      navigation.navigate(path, { state });
    } else {
      history.pushState(state, '', path);
      currentPath.value = path;
      currentState.value = state;
    }
  }

  return {
    currentPath: readonly(currentPath),
    currentState: readonly(currentState),
    push,
    back: () => navigation.back(),
    forward: () => navigation.forward()
  };
}

3.5 常见坑点总结

坑点 说明 解决方案
Safari 不支持 截至 2026 年 6 月仍缺失 用 feature detection + LegacyRouter 降级
e.intercept() 不支持异步守卫 guard 函数必须在 intercept 的 handler 内部调用 把守卫逻辑放在 handler 函数里
navigate 事件冒泡 子框架也会收到事件 e.canIntercept 检查或在事件源判断
hash 导航触发两次 hashChangenavigate 都会触发 在 handler 中用 e.hashChange 做标记
scroll: 'restore' 不可靠 某些场景下滚动位置恢复不准确 自行在 state 中存储 scrollY 并手动恢复
navigation.reload() 刷新后状态丢失 reload 后 state 被覆盖 在 reload 前将 state 存入 sessionStorage
info 参数不持久化 刷新页面后 info 丢失 需要持久化的数据放入 state 而不是 info
与框架路由冲突 Vue Router / React Router 也在监听导航 二选一,不要同时使用两套路由系统

✅ 四、最佳实践总结

经过以上分析,以下是 Navigation API 在生产环境中的使用建议:

  • ✅ **推荐做法:**使用 e.intercept()scroll: 'restore' 配合 history: 'auto' 恢复滚动位置
  • ✅ **推荐做法:**利用 AbortSignal 统一取消 fetch、WebSocket、SSE 等异步操作
  • ✅ **推荐做法:**在 navigate 事件中做权限检查,而不是在组件 mount 后
  • ✅ **推荐做法:**用 entry.getState() 替代全局状态管理简单的页面级状态
  • ✅ **推荐做法:**配合 View Transitions API 实现流畅的页面过渡动画
  • ✅ **推荐做法:**用 beforeStart 回调统一管理 loading 状态和骨架屏
  • ❌ **避免做法:**不要把大量数据塞入 state(会序列化),大块数据用 sessionStorage
  • ❌ **避免做法:**不要在 navigate 事件中调用 history.pushState()(会无限循环)
  • ❌ **避免做法:**不要同时使用 Navigation API 和框架自带的路由系统
  • ⚠️ 注意事项:navigation.currentEntry 在页面刚加载时可能为 null,需要做空值检查
  • ⚠️ **注意事项:**navigate 事件中的 e.destination.url 是完整的绝对 URL,不是相对路径
  • ⚠️ **注意事项:**在 Safari 环境下必须有降级方案,建议用 feature detection 而不是浏览器 UA 判断

⚡ **关键结论:**Navigation API 不是 History API 的小修小补,而是从根本上重新设计了浏览器导航模型。对于新项目,建议直接使用 Navigation API 并搭配 LegacyRouter 降级方案;对于已有项目,可以逐步迁移——两者可以共存。随着 Safari 的跟进(预计 2027 年),Navigation API 将成为 SPA 路由的唯一标准。

🔗 相关工具推荐

jsjson.com 的开发者工具箱中,以下工具可以辅助你的 SPA 开发工作:

📚 相关文章