广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何基于History API构建带路由守卫的JavaScript路由系统?

单页应用最明显的特征之一,是页面切换时浏览器并不会整页刷新,地址栏却会随之变化。这个能力并非前端框架自创,而是建立在浏览器原生的History接口之上。History API允许脚本在不触发请求的前提下修改URL,同时配合popstate事件捕捉用户的后退、前进操作。路由守卫则是在这套基础上增加了一层控制逻辑,让开发者可以在进入目标页面前做登录校验、权限判断或数据预加载。理解了History API的状态管理和事件触发机制,就能更从容地实现一套带守卫的路由系统。

如何基于History API构建带路由守卫的JavaScript路由系统?

History API的核心机制:状态、URL与事件

History API中最常用的两个方法是pushState和replaceState。它们都接受三个参数:状态对象、标题和URL。状态对象会与当前历史记录关联,浏览器不会主动读取或序列化它,但开发者可以在popstate事件里通过event.state取回。标题参数目前多数浏览器忽略,一般传空字符串即可。URL参数必须是同源地址,否则浏览器会抛出异常。

pushState会向历史栈中新增一条记录,地址栏立即变化,但不会触发页面重新加载,也不会引起任何网络请求。replaceState则只替换当前记录,不增加新的栈帧。两者的共同点是都不会触发popstate事件,popstate只在用户点击浏览器后退或前进按钮,或者调用history.back、history.forward、history.go时才会触发。这意味着自己调用pushState后,必须手动执行对应的视图更新逻辑,不能被popstate接管。

还有一个容易被忽略的细节:history.state只在popstate发生或页面重新加载时才有值,如果页面初次加载,history.state通常为null。为了恢复用户刷新前的状态,可以用replaceState在初始化时设置state,或者把必要信息编码到URL参数中。另一个常见误解是hash变化会触发popstate,实际上hash变化触发的是hashchange事件,只有通过history.pushState改变hash部分时才不触发hashchange,这也是Hash路由和History路由在事件模型上的一个区别。

// 新增两条历史记录,均不会触发popstate
history.pushState({ page: 'home' }, '', '/home');
history.pushState({ page: 'list' }, '', '/list?tab=hot');
// 替换当前记录,地址变为/profile,历史栈长度不变
history.replaceState({ page: 'profile' }, '', '/profile');
window.addEventListener('popstate', function (event) {
  // event.state为pushState时保存的状态对象
  console.log(event.state);
});

从零实现一个支持守卫的路由器

先定义路由配置。每个路由可以包含path、component和一个可选的beforeEnter函数。path支持静态路径和带参数的动态路径,例如/user/:id。为了匹配动态参数,可以把path中的参数段替换为正则捕获组,再与当前路径比对。实现一个简易匹配函数:

function matchRoute(pathname, routes) {
  for (let i = 0; i < routes.length; i++) {
    const route = routes[i];
    const paramNames = [];
    const regexString = route.path.replace(/:([^\/]+)/g, function (_, name) {
      paramNames.push(name);
      return '([^\\/]+)';
    });
    const regex = new RegExp('^' + regexString + '$');
    const match = pathname.match(regex);
    if (match) {
      return {
        route: route,
        params: paramNames.reduce(function (acc, name, index) {
          acc[name] = decodeURIComponent(match[index + 1]);
          return acc;
        }, {})
      };
    }
  }
  return null;
}

接下来实现路由器主体。路由器初始化时监听popstate或hashchange事件,并立即执行一次handleLocation,以便首屏渲染。push方法负责主动跳转,它在调用history.pushState后手动触发handleLocation,因为pushState不会产生popstate事件。replace方法类似,但用replaceState替换当前记录,避免产生多余历史栈。

class Router {
  constructor(options) {
    this.routes = options.routes || [];
    this.mode = options.mode || 'history';
    this.currentRoute = null;
    this.beforeGuards = [];
    this.afterGuards = [];
    this.init();
  }
  init() {
    if (this.mode === 'history') {
      window.addEventListener('popstate', () => {
        this.handleLocation();
      });
    } else {
      window.addEventListener('hashchange', () => {
        this.handleLocation();
      });
    }
    this.handleLocation();
  }
  beforeEach(guard) {
    this.beforeGuards.push(guard);
    return this;
  }
  afterEach(guard) {
    this.afterGuards.push(guard);
    return this;
  }
  push(path) {
    if (this.mode === 'history') {
      history.pushState({ path: path }, '', path);
    } else {
      window.location.hash = path;
    }
    this.handleLocation();
  }
  replace(path) {
    if (this.mode === 'history') {
      history.replaceState({ path: path }, '', path);
    } else {
      const url = window.location.href.replace(/#.*$/, '') + '#' + path;
      window.location.replace(url);
    }
    this.handleLocation();
  }
  handleLocation() {
    const pathname = this.mode === 'history'
      ? window.location.pathname
      : window.location.hash.slice(1) || '/';
    const match = matchRoute(pathname, this.routes);
    if (!match) {
      this.renderNotFound();
      return;
    }
    const to = { path: pathname, params: match.params, route: match.route };
    const from = this.currentRoute || { path: null, params: {}, route: null };
    this.confirmNavigation(to, from, match.route);
  }
  confirmNavigation(to, from, route) {
    const guards = this.beforeGuards.map(function (guard) {
      return function () {
        return Promise.resolve(guard(to, from));
      };
    });
    const routeGuard = route.beforeEnter
      ? [function () { return Promise.resolve(route.beforeEnter(to, from)); }]
      : [];
    const queue = guards.concat(routeGuard);
    runQueue(queue, function (guard, next) {
      guard().then(function (result) {
        if (result === false) {
          throw new Error('Navigation aborted');
        }
        if (typeof result === 'string' || result instanceof Object) {
          this.push(typeof result === 'string' ? result : result.path);
          return;
        }
        next();
      }.bind(this)).catch(function (error) {
        console.error('Navigation guard failed:', error);
      });
    }.bind(this), () => {
      this.currentRoute = to;
      to.route.component(to.params);
      this.afterGuards.forEach(function (guard) {
        guard(to, from);
      });
    });
  }
  renderNotFound() {
    const container = document.getElementById('app');
    container.innerHTML = '<p>404 Not Found</p>';
  }
}

在confirmNavigation中,所有注册的全局前置守卫和当前路由的独享守卫被放入队列。runQueue会按顺序执行每个守卫,每个守卫可以返回布尔值、路由地址或Promise。返回false会中断导航,返回字符串或路由对象会触发重定向。队列执行完毕后,更新currentRoute并渲染组件,最后执行后置守卫。这个流程模拟了主流前端路由库的导航解析过程。

路由守卫的执行顺序与异步处理

路由守卫的核心价值在于导航生命周期中插入可中断的逻辑。常见场景包括:未登录用户跳转到需要授权的页面时重定向到登录页;用户编辑表单未保存时离开前弹窗确认;进入详情页前根据参数请求数据。所有这些需求都依赖守卫能够异步决定导航是否继续。

执行顺序的设计会影响业务代码的可预测性。以本文实现的简化路由器为例,执行顺序是先依次运行通过beforeEach注册的全局前置守卫,再运行路由配置中的beforeEnter独享守卫,最后才更新视图并执行afterEach后置守卫。全局守卫按注册顺序执行,适合放置通用逻辑;独享守卫只对单个路由生效,适合写与该页面强相关的判断。如果前置守卫中返回了新的地址,重定向会重新开始一轮导航,而不是继续执行后续守卫。

处理异步守卫时,通常把返回值统一包装为Promise,然后用队列递归调用。下面是一个简单的顺序执行辅助函数:

function runQueue(queue, iterator, callback) {
  const step = function (index) {
    if (index >= queue.length) {
      callback();
      return;
    }
    iterator(queue[index], function () {
      step(index + 1);
    });
  };
  step(0);
}

这个函数接收守卫数组、迭代器和最终回调。迭代器内部负责调用守卫并判断结果,只有守卫通过时才调用next进入下一个。若某个守卫返回false或Promise被reject,next不会被触发,导航流程就此中断。实际项目中,导航取消还应该恢复地址栏到原本状态,避免URL已经改变但视图没变的不一致。可以在catch中调用history.replaceState回退。

异步错误捕获同样重要。如果守卫内部抛出异常,或者异步请求失败,必须阻止导航并给出提示。把每个守卫的返回值用Promise.resolve包装后,可以统一用.then和.catch处理,避免同步异常与异步异常混在一起。上面的Router类在catch中记录了错误,但没有回滚URL,这在实际使用中需要补齐。

History模式与Hash模式的权衡

路由库通常提供两种模式:Hash模式和History模式。Hash模式把路径放在井号后面,例如ipipp.com/#/user/1。由于井号之后的内容不会发送给服务器,因此即使服务器只提供index.html,刷新页面也不会404,部署成本很低。History模式使用真实路径,例如ipipp.com/user/1,地址更干净,也更利于SEO,但要求在服务器端配置回退规则,否则用户直接访问子路径或刷新时会得到404。

在Nginx中,常见的回退配置如下:

location / {
  try_files $uri $uri/ /index.html;
}

这段配置表示先按请求的URI查找真实文件,找不到则回退到index.html,由前端路由接管。Apache服务器则需要使用mod_rewrite规则,把非真实文件请求重写到入口文件。无论哪种服务器,核心思路都是保证所有前端路由地址都返回同一个HTML文档。

Hash模式虽然部署简单,但也有明显缺点。一方面URL中包含井号,分享和展示时不够美观;另一方面搜索引擎通常不会把hash后面的内容作为独立页面索引,SEO表现较差。History模式借助真实地址可以配合服务端渲染或预渲染获得更好的收录效果,但需要处理服务端404和重定向。现代浏览器对History API的支持已经很普遍,仅在IE9及以下环境中需要降级到Hash模式。

广告位 · 文章底部(后台-模块管理替换此区域)