如何优雅的拦截项目中所有的路由跳转?

1,795 阅读2分钟

大家好,我是晨霜,好久不见。本篇会分享如何拦截项目中所有的路由跳转,以及介绍一下笔者封装的相关轮子,希望对你有所帮助。

背景需求

假设有这么一种需求,有两个项目 A 和 B,A 系统有 uat 和 prod 两个环境,B 系统只有 prod 环境,并且 A 系统需要频繁的跳转到 B 系统,因此项目中的跳转方式千奇百怪,比如 a 标签,window.open, location.href 等等。但是突然有一天,产品和你说 B 系统也要增加一套 uat 环境,即 A(uat) 需要跳转至 B(uat),A(prod) 需要跳转至 B(prod),并且 A 系统中有相当一部分 B 系统 URL 由后端下发,这个时候你会怎么做?

初步想法,全局替换所有 B 系统 URL,改为全局变量动态拼接,举一个例子,B 系统原有 URL 为 'https://prod.bbb.com',那么全局替换项目中所有 B 系统链接,为 `https://${env}.bbb.com` ,即根据 A 系统环境不同,设置不同的 env 变量,那么这样做会有什么问题呢?

  1. 改动量太大,大项目动辄成百上千个 URL,难免会有漏改的地方,并且需要兼顾之前的各种写法,比如 URL 分别使用 单引号、双引号、反引号 包裹。

  2. 需要后端配合一起改动,上面说到,B 系统 URL 有相当一部分为接口下发,如果 A 系统后端无法感知当前环境(笔者实际需求就是),还需要在每个接口都传一遍当前 A 系统环境,改起来基本不现实。

  3. 写起来不够优雅,每次写 B 系统 URL 都需要使用变量进行拼接。

上面的需求是笔者编造出来供读者方便理解的。事实上,笔者实际遇到的需求比上述还要复杂,但是总结起来就是一个问题

如何优雅的拦截项目中所有的路由跳转?

解决方案

既然要拦截项目中所有的路由跳转,要么首先要明确都有哪些路由跳转方式,才能针对的进行处理,常见的有 a 标签、window.open、history.pushState、history.replaceState、hash、location.href、location.replace 等,一些不常见的跳转方式在此不再列出。以下是这些跳转方式拦截处理。

a 标签

直接在 document 上注册一个点击事件,在里面进行逻辑判断即可。

document.addEventListener("click", function (event) {
    const path = event.path || (event.composedPath && event.composedPath());

    if (event.defaultPrevented) {
      return;
    }

    for (const item of path) {
      if (item instanceof HTMLAnchorElement) {
        event.preventDefault();
        const to = item.href; // 在此处进行路由替换处理
        return window.open(to, item.target || "_self");
      }
    }
})

这里可能有以下几点需要注意。

  • 点击的元素并非 a 标签

    <a href="/some">
      <button>点我跳转</button>
    </a>
    

    此时点击 button, event.targetbutton,但同样会发生跳转,因此并不能简单的判断 event.target,还需要不断的向上判断 event.target.parentNode 是否为 a 标签。不过还好可以使用 event.path,该属性返回事件的路径,由于这并非是一个标准属性,后面还使用了标准属性 composedPath 做兜底。

    如果还是遇见了兼容性问题,可以考虑使用 Polyfill ,原理则是上述提到的 parentNode。

  • a 标签点击事件调用了 event.preventDefault()

    此时不应该再继续对该标签进行拦截,可以使用 event.defaultPrevented 进行判断。

  • a 标签默认行为为 _self,而 window.open 默认行为为 _blank

window.open

window.open 的处理要简单很多,直接复写 window.open 即可。

const originWindowOpen = window.open;
window.open = (url, target, features) => {
    const path = url.toString();
    if (path === undefined) {
      return null;
    }

    const to = path; // 在此处进行路由替换处理
    return originWindowOpen(to, target, features);
}

history

history 的处理也很简单,复写 pushState 和 replaceState 即可。

["pushState", "replaceState"].forEach((funcName) => {
    const originFunc = history[funcName];

    history[funcName] = (data, unused, url) => {
      if (url === undefined || url === null) {
        return;
      }

      const to = url.toString(); // 在此处进行路由替换处理
      return originFunc.call(history, data, unused, to);
    };
});

hash

hash 的修改方式比较多,如 location.hash = '#/some', location.href += '#/some', history.replaceState(undefined, undefined, "#/some"),但无论哪种,理论上都会触发 hashchange 事件,我们直接在此事件中操作 hash 即可。

let _nextHash = "";
window.addEventListener("hashchange", function (event) {
    const url = new URL(event.newURL);
    const { hash } = url;

    if (hash === _nextHash) {
      _nextHash = "";
      return;
    }

    const to = hash.slice(1); // 在此处进行路由替换处理

    _nextHash = `#${to}`;

    this.location.hash = _nextHash;
});

这里可能有一点需要注意,需要保存一下 _nextHash 变量,并在最开始判断,原因在于在 hashchange 函数中进行 hash 操作仍然会触发 hashchange,避免死循环。

location

location.href 和 location.replace 就要麻烦多了,由于 location 是一个只读属性(MDN),无法对其进行任何 override 操作,只能另辟蹊径,提供一个 fake location 供用户使用,并想办法替换用户的代码。

const $location = {
    set href(value) {
      const to = value;
      location.href = to; // 在此处进行路由替换处理
    },
    replace: (url) => {
      const to = url.toString(); // 在此处进行路由替换处理
      location.replace(to);
    },
}
window.$location = $location;

至于替换用户代码,第一反应还是使用 babel,以下是 babel plugin 代码。

function RouteInterceptorBabelPlugin() {
  return {
    name: "route-interceptor-babel-plugin",

    visitor: {
      // replace location.href to $location.href
      AssignmentExpression(path) {
        const { node } = path;
        const { left } = node;
        if (
          left.type === "MemberExpression" &&
          left.object.name === "location" &&
          left.property.name === "href"
        ) {
          left.object.name = "$location";
        }
      },
      // replace location.replace to $location.replace
      CallExpression(path) {
        const { node } = path;
        const { callee } = node;
        if (
          callee.type === "MemberExpression" &&
          callee.object.name === "location" &&
          callee.property.name === "replace"
        ) {
          callee.object.name = "$location";
        }
      },
    },
  };
}

module.exports = RouteInterceptorBabelPlugin;

至于 babel 插件,这里不再进行展开讲解。

route-interceptor 介绍

上面介绍了一些常见的路由跳转拦截方式,相信如果项目中遇见了一些不常见的跳转方式,也能轻松写出对应的拦截方式。如果不想 copy 上面的代码,也可以看看我的 route-interceptor。

NPM:www.npmjs.com/package/rou…

Github: github.com/suchangv/ro…

用法简介

安装

npm i route-interceptor

使用

import { create } from "route-interceptor";

const interceptor = create({
  way: ["a", "window.open", "history", "hash", "location"],
  intercept: (path) => {
    return path.replace("/bbb", "/ccc");
  },
});

interceptor.start();

需要注意的是,如果 way 中包含了 location,项目中需要使用 $location 对象,其 ts 声明为

interface Window {
  $location: Pick<Location, "href" | "replace">;
}

或者使用 babel 插件进行全局替换

// .babelrc.js
module.exports = {
  plugins: ["route-interceptor/plugins/babel"],
};

intercept: 返回一个新路由进行跳转,或者返回 false 取消跳转。

可以看到,用法非常简单,欢迎使用~

写在最后

笔者目前就职于字节跳动-抖音电商部门,目前电商团队在北京和上海都还有非常多的 hc,感兴趣的可以投递简历到 suchangv@bytedance.com 或者加我微信 suchangvv 找我内推。 祝大家都能找到心仪的工作。