作者:苏梓铭
背景介绍
目前,古茗前端团队内部统一采用了 React 技术栈,所有新项目和基础建设均基于 React 框架开发。然而,许多老旧的 Vue 应用仍在使用和运行,因此我们需要进行项目迁移,统一技术栈,减少开发认知负担,接轨现有基建,增强可维护性,以便更好地满足业务新需求。
特别地,以笔者负责的学院业务域为例,其业务具有这么一个特点:存在众多外部链接跳转场景(例如,发布给加盟商的考试待办链接、张贴在店内设备上的资料二维码等)。在迁移老项目的过程中,我们也要格外关注外链治理。
本文主要讲述笔者治理老项目存量外链投放问题的解决思路。
现状及目标分析
在迁移老项目的过程中,我们发现了许多问题:
外链更新的跳转问题
我们对外投放的链接通常具有很强的时效性,例如给加盟商发送的考试链接等,而且这些外链是静态、无法直接修改的,因此我们通常会采用重定向逻辑,将旧路由映射到新路由,让老外链也能跳转到新页面。
在老项目的导航守卫里,已经积累了大量的重定向逻辑,并且每个模块都有一套自己单独的重定向策略,如下图所示:
因此,当老项目迁移完成下线后,已经投放出去的老链接将会无法访问(因为所有的重定向逻辑都在老项目内完成)
其次,外链投放还存在一定的不确定性,有的甚至是产研团队自己都不知道的外链投放,这就导致我们有时对页面路由进行改造后,会得到其他业务域的业务反馈。
路由传参的心智负担
在分析页面迁移时,我们注意到新旧页面的路由经常通过 URL 传递大量参数。缺乏统一规范来维护这些参数,使得页面的维护变得复杂。
目标分析
针对上述问题现状,我们可以总结出如下目标:
- 老项目路由重定向:将重定向逻辑做保留,实现对历史外链的兼容
- 操作可感知:用户的访问应当有完善的链路可以被感知到,用户访问了老项目路由时,我们需要能够记录日志,当用户访问 404 时我们需要知道是哪个页面报了 404,并且自动及时地告警同步给业务 Owner 排查。
- 统一技术栈/统一规范:我们应当全链路接入前端基建,使用最新的统一技术栈,针对路由跳转等场景需要有一个统一的技术规范
明确了几个基本目标后,我们就可以着手进行代码结构设计了
核心代码设计与实现
路由重定向
如何转发------Nginx, NodeJS OR JavaScript?
在设计重定向转发方案之前,我们首先考虑的是直接使用 Nginx 或 Node 做转发,但由于目前两个 H5 项目都使用 Hash 路由,而我们知道 Hash 路由 #
后面的参数是不会发送给服务器的,因此 Nginx 和 Node 甚至都无法读取到路由本身,更不用提做重定向了,因此我们确定了最终解决方案还是交由客户端处理,Nginx 层只做映射路径的转发(将老项目 /
的路径直接转发到 /college
下即可)
API 调用方式设计
设计一个简洁明了的 API 调用方式同样是技术方案的重要一环。基于配置化的思想,我们需要把原有的导航守卫中 if else 的糅杂一团的重定向逻辑,改造成维护一个路由跳转表。即通过配置固定格式的原 URL 和目标 URL 来声明重定向,调用方式大致如下:
typescript
const redirector = new Redirector()
.register('/knowledge/learnList', '/pages/material-list/index')
.register('/knowledge/detail/:id', '/pages/material-detail/index?id=:id');
这样一来我们注册重定向就变得非常简单。注册完成之后,我们就可以通过调用redirector.run
方法来执行一次重定向,接下来是具体的代码实现。
重定向路由跳转表设计
名词解析
在设计跳转表之前,我们需要明确,路由传参有两种类型的方式,一种是 params
,一种是 query
。其中 params
是作为路由路径的一部分存在的,比如 /pending/list/1234567
中的 1234567
就代表着 id
这个参数;而 query
就是在 ?
之后的键值对参数了,例如 /pages/list/index?id=1234567
这种形式。
上文的 API 设计中,仅做了 params
类型的传参映射,而对于页面的其他参数是没有处理的,这里我们要求,新老页面的出入参要保持相同,这样就可以直接共用一套 query
参数,减轻我们的开发成本
如何转换路由正则匹配式
原先的 Vue2 老项目实现重定向是利用 vue-router
自带的实现。经过调研,我们选择使用 path-to-regex
库做路由解析,它是 Vue-Router 等众多知名路由库的底层依赖库,正如其名,它能够将路由转化成正则表达式去匹配链接,并且支持动态路由参数等许多功能,官方示例如下:
实现 Redirector 类
path-to-regex
具有非常简单易用的 API,对于我们检测和提取路由参数的需求来说已经完全足够,我们可以通过调用 match
方法获取到解析路由的正则表达式,并据此实现一个 Redirector
类:
typescript
import { history } from '@tarojs/router';
import Taro from '@tarojs/taro';
import { match, MatchFunction } from 'path-to-regexp';
type RegisterPath = `/${string}`;
interface RouteObject {
matchFn: MatchFunction;
target: string;
}
class Redirector {
// 本地注册的路由跳转配置
private routes: RouteObject[] = [];
register(oldPath: RegisterPath, newPath: RegisterPath): Redirector {
this.routes.push({
matchFn: match(oldPath),
target: newPath,
});
// 链式调用
return this;
}
redirect(path: string, query = ''): void {
this.routes.some(({ matchFn, target }) => {
const result = matchFn(path);
if (result) {
// 替换路由参数
Object.entries(result.params).forEach(([key, value]) => {
target = target.replace(`:${key}`, value);
});
// 处理 query,Taro 路由的需要直接拼在参数上
target = `${target}${target.includes('?') ? '&' : '?'}${query}`;
// 执行重定向
Taro.redirectTo({ url: target });
return true;
}
return false;
});
}
run(): void {
const [path, query] = location.hash.slice(1).split('?');
this.redirect(path, query);
}
}
// 直接注册重定向后导出实例即可
const redirector = new Redirector()
.register('/knowledge/learnList', '/pages/material-list/index')
.register('/knowledge/detail/:id', '/pages/material-detail/index?id=:id');
export default redirector;
其基本思路就是通过 match
方法生成的正则表达式,匹配摘出原 URL 中的路由参数,并拼接和替换到新的 URL 上
何时执行------完整的一次重定向链路
本次迁移的 C 端 H5 微应用同时由两个项目组成,分别是使用 Vue2 技术栈的老项目和使用 Taro React 的新项目。两个项目通过 Nginx 转发到同一个域名的不同路由下:老项目映射在
/
根目录,而新项目则是映射在/college
目录下,所处同一个域,从而可以共享一些本地数据如localStorage
、sessionStorage
等。
(1)用户访问,Nginx 层转发
以用户外链跳转到老项目路由为例:
bash
https://host/#/knowledge/learnList?rankId=aaa&ListName=bbb
由于老页面会下线,访问服务时 Nginx 正常匹配其他路由,兜底匹配根路由 /
直接转发到 /college
上:
ruby
https://host/college/#/pages/material-list/index?rankId=aaa&ListName=bbb
因此用户在 Nginx 层被第一次重定向到:
bash
https://host/college/#/knowledge/learnList?rankId=aaa&ListName=bbb
(2)执行重定向
重定向后,我们需要在项目内解析路由参数后拼接跳转。这里 Taro 无法匹配到页面,但是 app.tsx
中仍然会执行代码逻辑,因此我们在新项目的 app.tsx
中执行重定向
tsx
// app.tsx
import redirector from "@/utils"
function App({ children }){
useMount(() => {
redirector.run()
})
}
需要监听路由变化吗?
我们可以通过 history.listen
方法监听路由的变化,并且在每次路由改变时都去匹配一次重定向:
typescript
history.listen(({ location }) => {
const { pathname, search } = location;
redirector.redirect(pathname, search);
});
但实际上我们并不需要做到这么全面,我们完全可以保证在学院新项目里不会出现老项目的路由,因此如果用户已经进入了应用里,就不会通过路由跳转进入到老项目了。如果监听了路由变更反而会带来额外的性能开销。
操作感知
如果没有触发重定向或是重定向失败,都会导致发生 404 错误,我们会上报一次错误日志。而只要触发了重定向,我们就会上报一次重定向记录
404 感知
typescript
function App({ children }) {
usePageNotFound((e) => {
const tag = 'PAGE_NOT_FOUND';
const log = `path=${e.path}`;
Slardar.logger()?.error(log, tag);
Taro.redirectTo({
url: `/pages/404/index?from=${e.path}`,
});
})
}
具体操作为:在 App.tsx
下新增 usePageNotFound
hook,调用 Taro 原生路由回调,通过数据平台上报 error 日志,并在 404 页面展示提示和链接,引导用户回到学院应用和反馈
我们可以在数据中心查看上报的日志信息等
相关文章:古茗是如何做前端数据中心的 - 掘金
由于 404 错误一般不会偶现,只要出现就代表页面路由或是外链投放存在问题。我们通过配置错误告警策略,来及时通知开发查看日志,发现并解决路由问题。
重定向感知
在执行重定向的时候上报执行日志,提交原路由和目标路由的完整信息
diff
class Redirector {
private routes: RouteObject[] = [];
register(oldPath: RegisterPath, newPath: RegisterPath): Redirector {}
redirect(path: string, query = ''): void {
this.routes.some(({ matchFn, target }) => {
const result = matchFn(path);
if (result) {
Object.entries(result.params).forEach(([key, value]) => {
target = target.replace(`:${key}`, value);
});
target = `${target}${target.includes('?') ? '&' : '?'}${query}`;
+ const tag = 'PAGE_NOT_FOUND';
+ const log = `from=${path}&to=${target}`;
+ Slardar.logger()?.info(log, tag);
Taro.redirectTo({ url: target });
return true;
}
return false;
});
}
run(): void {
const [path, query] = location.hash.slice(1).split('?');
this.redirect(path, query);
}
}
本次我们把新迁移的页面路由和已有的重定向路由全部加入到配置表中,并且在重定向发生时上报访问的链接和跳转的链接,收集 30 日的数据后再进行一次整理,观察线上流量分布,逐渐下掉没有使用的重定向,减少维护成本
统一规范------路由跳转方法
由于老项目的迁移过程中涉及到了大量的路由传参,经常会出现链接里挂了一大堆参数,实际页面里却根本没有用到的情况,我们很难分辨哪些路由参数是有效的,哪些路由参数又是可以舍弃的。因此,我们在本业务域内的移动端项目内做了如下统一规范:
- 针对页面维护 TS 入参类型定义;
- 路由跳转和获取入参使用公共方法,传入页面入参的类型定义作为泛型参数,规范页面跳转
具体调用方式如下:
- 跳转页面使用
formatUrlParams
格式化query
参数
typescript
import { BaseAssignmentParams } from "@/pages/assignment/index/index"
const handleClick = (assignment: CourseAssignmentItem) => {
const baseQuery = formatUrlParams<AssignmentParams>({
trainingId: trainingDetail.id,
semesterId: trainingDetail.semesterId,
assignmentId: assignment.id,
});
Taro.navigateTo({
url: `/pages/assignment/index/index?${baseQuery}`,
});
};
- 承接页面使用
getUrlParams
反序列化路由参数
typescript
export interface AssignmentParams {
/** 培训 id */
trainingId: string;
/** 学期 id */
semesterId: string;
/** 任务 id */
assignmentId: string;
}
const Assignment: React.FC = () => {
const { semesterId, trainingId, assignmentId } = getUrlParams<AssignmentParams>();
}
export default Assignment;
如此一来,就在两个页面成功通过类型系统架起了一道桥梁,如果页面新增或是删改了入参,都可以通过静态类型分析在开发阶段就得到提示,修改路由出入参时的心智负担明显减小,项目可维护性得到了显著提高。
完整流程图
总结
本文介绍了笔者在做业务项目迁移的时候处理外链逻辑以及对项目内的页面出入参和跳转场景进行统一化治理的思路,希望能给同样被历史项目和技术债折磨的同学们提供一些可行性建议,也欢迎大家在评论区一起交流,共同进步
最后
📚 小茗文章推荐:
关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~