大家好,我是晨霜,好久不见。本篇会分享如何拦截项目中所有的路由跳转,以及介绍一下笔者封装的相关轮子,希望对你有所帮助。
背景需求
假设有这么一种需求,有两个项目 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
变量,那么这样做会有什么问题呢?
-
改动量太大,大项目动辄成百上千个 URL,难免会有漏改的地方,并且需要兼顾之前的各种写法,比如 URL 分别使用 单引号、双引号、反引号 包裹。
-
需要后端配合一起改动,上面说到,B 系统 URL 有相当一部分为接口下发,如果 A 系统后端无法感知当前环境(笔者实际需求就是),还需要在每个接口都传一遍当前 A 系统环境,改起来基本不现实。
-
写起来不够优雅,每次写 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.target
为button
,但同样会发生跳转,因此并不能简单的判断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 找我内推。 祝大家都能找到心仪的工作。