
这几年前端圈最常听到的一个词就是“框架疲劳”。React刚摸熟团队说统一切VueVue还没捂热业务又要求做小程序端等折腾完一套对应框架新项目又要求用TypeScript重写业务逻辑。最让人崩溃的不是学新API而是之前沉淀的路由配置、权限守卫、导航拦截这些“通用能力”因为跟框架绑得太死只能推倒重来。后来我在实际项目中逐渐沉淀出一套“标准化技能栈”——核心业务逻辑与路由机制保持框架无关再通过适配层映射到具体框架。这套思路让我在跨框架移植时只需要写一个薄薄的适配器而不是重写整个系统。今天把路由机制的部分拎出来详细讲讲怎么抽象、怎么适配、又会踩哪些坑。1. 为什么需要一套“标准化技能栈”1.1 框架疲劳与多端需求很多团队技术选型不是一成不变的。今天看官方文档觉得Vue上手快用了半年明天招聘市场React人才更多业务方希望统一到React生态后天产品说要出小程序端又要引入Taro或uni-app。每一次切换表面上只是换一套视图写法但真正痛苦的是路由配置、登录状态、权限控制、埋点逻辑、页面传参这些“业务骨架”全部散落在各框架的API调用里。React Router的useNavigate、Vue Router的router.push、小程序里的wx.navigateTo形式完全不同切换框架等于把整个应用的“神经”重新接一遍。这背后的根本问题是技能栈没有分层。很多人把“会框架”等同于“会写业务”于是框架就成了业务的宿主业务逻辑被框架的API绑架。一旦框架出现变化业务也就跟着骨折。标准化技能栈的核心就是做“关注点分离”把业务领域知识、路由规则、状态流转这些稳定不变的东西沉淀成核心层把跟框架打交道的方式收敛到适配层。这样框架只是核心层的“渲染器”和“执行器”不再承载业务决策。1.2 标准化不是“不用框架”而是“统一心智模型”别误会标准化技能栈不是让你自己造一个框架或者拒绝所有现成库。而是让团队在写代码时始终遵循一套与框架无关的思维模型定义路由时你面对的是路径、参数、守卫、元信息而不是某个框架的某个对象编写页面时你关注的是组件接收什么props、触发什么事件而不是怎么在模板里绑定路由。这样做还有一个额外好处新人培养成本下降。以前一个前端要同时掌握React Router和Vue Router还要理解两者在生命周期、正则匹配、路由守卫上的细节差异。有了标准化心智模型新人只需要理解“路由是一张配置表 一个导航控制器”剩下就是看适配层怎么把它翻译给具体框架。这不是“抽象过度”而是把真正值得沉淀的知识沉淀下来把框架差异变成一种机械的翻译工作。2. 跨框架移植的基本策略与边界设计2.1 分层架构核心层、适配层、展示层我常用的是三层结构。核心层也叫领域层是纯TypeScript模块只包含业务规则、路由表、状态机、工具函数。这里不允许出现任何import自React、Vue、小程序API的代码。它的输入输出都是普通对象或事件不关心外部世界长什么样。适配层是核心层与具体框架之间的桥梁。以路由为例适配层要做的就是把核心层解析出来的“目标页面描述”用框架的方式渲染出来把框架触发URL变化的回调转换成核心层能识别的“导航请求”。不同的框架需要不同的适配器但适配器的代码量通常很少因为逻辑都已经下沉到核心层了。展示层就是各个框架的页面组件。组件本身是框架强相关的但它在标准化技能栈里只承担最薄弱的“视图渲染”职责从核心层拿到当前路由对应的组件Key和参数然后渲染自己。组件里的业务判断、网络请求、权限逻辑应尽量上提到核心层或者通过自定义Hook/Composition函数从核心层取数据而不是散落在组件里。2.2 接口契约如何定义框架无关的组件与逻辑设计和实现跨框架移植最关键的是定义稳定的接口契约。组件层面我倾向于定义统一的ComponentSpecinterface ComponentSpecP Recordstring, unknown { key: string; loadProps?: () PromiseP; render: (props: P) unknown; // 具体框架的渲染入口 }每个页面都注册为一个ComponentSpec适配层拿到ComponentSpec后根据当前是React还是Vue调用对应的渲染函数。核心层不关心render具体返回的是ReactElement还是VNode。逻辑层面路由导航服务的接口也必须是框架无关的interface NavigationService { navigate(path: string, params?: Recordstring, unknown): void; replace(path: string, params?: Recordstring, unknown): void; back(delta?: number): void; onRouteChange(listener: (route: RouteState) void): () void; }有了这个接口业务代码在任何框架里调用navigate(/user/detail, { id: 123 })都是同一种写法不需要关心底层是history.pushState还是wx.navigateTo。这就像USB-C接口核心层是设备适配器是转接线框架就是不同的显示器转接线保证协议统一。接口契约一旦定义后续替换框架时只需要新写一套适配层核心层的单元测试甚至可以原样保留。3. 路由机制的抽象与适配3.1 路由的本质状态驱动的页面导航很多前端开发被框架路由说服了但路由的本质其实特别简单应用有一份“当前页面状态”的快照包含路径、参数、页面组件Key、历史栈信息。无论React Router还是Vue Router都是在维护这份状态并且让URL与页面状态双向映射。跨框架场景下我们不能直接依赖某个框架的Router对象因为每种Router的跳转方式、历史栈维护方式、监听机制都不一样。但如果抽出RouterCore只让它维护一个“路由状态机”事情就变得很简单。RouterCore接收路由表配置暴露navigate、replace、back等方法并维护一个订阅者列表。当路由状态变化时它会通知适配层。适配层负责两件事第一把新的路由状态同步到框架的Router比如修改URL、更新组件树第二把框架Router收到的新URL同步回RouterCore。这样设计的好处是核心层始终是唯一的决策者。比如权限守卫只需要在RouterCore里写一遍再也不用分别写React Router的onBeforeEach和Vue Router的beforeEach。因为无论哪个框架最终执行导航都必须先经过RouterCore的守卫管线否则适配层会拒绝执行。3.2 路由声明与框架适配器标准化技能栈里路由表是一份与框架无关的静态配置通常放在核心层里。一个典型的路由配置长这样// core/routes.ts import type { RouteRecord } from ./types; export const routes: RouteRecord[] [ { path: /home, name: home, componentKey: HomePage, meta: { title: 首页, requiresAuth: false } }, { path: /user/:id, name: userDetail, componentKey: UserDetailPage, meta: { title: 用户详情, requiresAuth: true } }, { path: /settings, name: settings, componentKey: SettingsPage, meta: { requiresAuth: true }, children: [ { path: profile, name: settingsProfile, componentKey: SettingsProfilePage } ] } ];有了这份声明任何框架的适配器都可以把它转换成自己框架的路由配置。React适配器可以遍历routes生成Route组件Vue适配器可以调router.addRoute小程序适配器可以生成页面JSON和路径映射。但注意适配器转换的只是“路由表”并不是“路由逻辑”。真正解析哪个路由匹配当前URL参数如何提取守卫如何执行这些还是在RouterCore统一完成。3.3 嵌套路由与参数传递的统一跨框架移植时嵌套路由最麻烦。React Router的嵌套写法是children组件Vue Router是children配置小程序则根本不支持嵌套路由只能通过设置subpackages或者用自定义导航栏模拟。如果核心层直接在路由配置里定义children适配层未必能完全复现嵌套渲染结构。我的做法是在核心层只把嵌套当成“路径拼接”和“路由父子关系”的抽象不强制适配层实现真实的组件嵌套。比如上面的settings/profile核心层会把它解析成完整路径/settings/profile组件Key为SettingsProfilePage。React Vue可以通过Outlet或RouterView实现嵌套布局小程序则可以把布局拆成单独组件在页面上手动引入。因为核心层已经把路由匹配到叶子节点适配层只需要根据路由表里的一组“布局Key”来决定外层框架怎么渲染比如layouts: [SettingsLayout]。这样嵌套路由在不同框架里可能UI层级一样但代码结构不同核心层不关心这些差异。参数传递也一样。核心层统一把URL参数和附加params合并成一个plain object传给目标组件。React Router的useParams、Vue Router的route.params、小程序页面的onLoad(options)全部被适配层转换成同一个对象。业务层看到的始终是props.id、props.params等不用理会框架的参数格式差异。4. 实操从零搭建一套可跨框架移植的路由层4.1 定义路由配置结构先定义一个超集类型的RouteRecord它包含了框架无关的所有信息// types.ts export interface RouteMeta { title?: string; requiresAuth?: boolean; [key: string]: unknown; } export interface RouteRecord { path: string; name: string; componentKey: string; children?: RouteRecord[]; meta?: RouteMeta; layoutKey?: string; // 可选指定外层布局组件 }再定义一个RouteState它代表某次导航完成后页面应该看到的状态export interface RouteState { path: string; name: string; componentKey: string; params: Recordstring, string | number; query: Recordstring, string | number; meta: RouteMeta; layoutKey?: string; }这里需要说明为什么params和query要分开。我记得第一次跨框架移植时React Router会把/users/123这种路径参数放在useParams里把?tab1这种查询参数放在useSearchParams里Vue Router统一放在route.params和route.query小程序则都在onLoad(options)里。核心层把它们分开保存适配层负责把框架解析出的值映射进去业务层拿到时就不会混乱。4.2 实现RouterCore核心类RouterCore不依赖任何框架内部维护一个当前路由堆栈用数组模拟以及一份路由表用字典存储完整路径到Record的映射。同时它提供一个中间件管线让权限校验、埋点等都走这套统一流程。// core/router-core.ts type Middleware (ctx: NavigationContext, next: () Promisevoid) Promisevoid; interface NavigationContext { to: { path: string; params: Recordstring, unknown }; from: RouteState | null; state: RouteState; redirect: (path: string, params?: Recordstring, unknown) void; } class RouterCore { private routesMap new Mapstring, RouteRecord(); private stack: RouteState[] []; private listeners new Set(state: RouteState) void(); private middlewares: Middleware[] []; registerRoutes(routes: RouteRecord[]) { this.flattenRoutes(routes, ); } private flattenRoutes(routes: RouteRecord[], basePath: string) { for (const route of routes) { const fullPath this.joinPath(basePath, route.path); this.routesMap.set(fullPath, route); if (route.children) { this.flattenRoutes(route.children, fullPath); } } } private joinPath(base: string, path: string) { if (!base) return path; return ${base.replace(/\/$/, )}/${path.replace(/^\//, )}; } async navigate(path: string, params: Recordstring, unknown {}) { const match this.matchRoute(path); if (!match) throw new Error(Route not found: ${path}); const ctx: NavigationContext { to: { path, params }, from: this.currentState(), state: { path, name: match.record.name, componentKey: match.record.componentKey, params: { ...match.params, ...params }, query: this.parseQuery(path), meta: match.record.meta ?? {}, layoutKey: match.record.layoutKey }, redirect: (newPath, newParams) { throw new RedirectSignal(newPath, newParams); } }; try { await this.runMiddlewares(ctx); } catch (error) { if (error instanceof RedirectSignal) { return this.navigate(error.path, error.params); } throw error; } this.stack.push(ctx.state); this.emit(ctx.state); } async replace(path: string, params: Recordstring, unknown {}) { const beforeState this.stack.pop() ?? null; await this.navigate(path, params); // 把replace语义修复为替换当前栈顶而不是新增 if (this.stack.length) { this.stack.splice(this.stack.length - 2, 1, this.stack[this.stack.length - 1]); } } back(delta 1) { if (this.stack.length delta) return; this.stack this.stack.slice(0, this.stack.length - delta); const state this.stack[this.stack.length - 1]; this.emit(state); } // 路径匹配、query解析、中间件等省略... private emit(state: RouteState) { this.listeners.forEach((fn) fn(state)); } private matchRoute(path: string) { // 把path按/拆分逐段匹配routesMap中的正则表达式 // 生产环境建议用路径到正则的预编译 for (const [pattern, record] of this.routesMap) { const keys: string[] []; const regex pathToRegex(pattern, keys); const result path.match(regex); if (result) { const params: Recordstring, string {}; keys.forEach((key, i) (params[key] result[i 1])); return { record, params }; } } return null; } }这里实现了一个简化版真实项目里你还需要处理通配符、可选参数、正则匹配优先级等。我的建议是不要自己造轮子直接用path-to-regexp库来转换路由模式它本身是框架无关的正好适合放在核心层。上面代码只是为了展示RouterCore的思想它做决策、记录状态、执行中间件完全不知道“渲染”为何物。4.3 实现框架适配器以React和Vue为例RouterCore只负责状态适配层要把状态变成UI。拿React举例写一个RouteRenderer组件// adapters/react/RouteRenderer.tsx import { useEffect, useState } from react; import { routerCore } from ../../core/router-core; import * as Pages from ./page-registry; export function RouteRenderer() { const [route, setRoute] useState(() routerCore.getCurrentState()); useEffect(() { return routerCore.subscribe((newRoute) { setRoute(newRoute); }); }, []); const PageComponent Pages[route.componentKey]; if (!PageComponent) return NotFound /; const Layout route.layoutKey ? Pages[route.layoutKey] : Fragment; return ( Layout PageComponent params{route.params} query{route.query} meta{route.meta} / /Layout ); }Vue适配器也类似可以写在组件里!-- adapters/vue/RouteRenderer.vue -- script setup import { ref, onMounted, onUnmounted } from vue; import { routerCore } from ../../core/router-core; const route ref(routerCore.getCurrentState()); let unsubscribe; onMounted(() { unsubscribe routerCore.subscribe((newRoute) { route.value newRoute; }); }); onUnmounted(() { unsubscribe unsubscribe(); }); /script template component :ispageRegistry[route.componentKey] :paramsroute.params :queryroute.query :metaroute.meta / /template可以看出适配层的职责非常清晰。还有一点需要处理浏览器URL的同步。React适配器里导航发生时RouterCore派发新状态你要用window.history.pushState更新地址栏反过来用户点击浏览器返回按钮时要监听popstate事件把反向导航请求转发给RouterCore。Vue适配器同理。小程序没有history API适配层就要自己实现一个内存栈并且把页面切换时传入的id作为路由参数用wx.navigateTo来跳转。路由守卫统一在中间件里写比如routerCore.use(async (ctx, next) { const requiresAuth ctx.state.meta.requiresAuth; if (requiresAuth !userStore.isLoggedIn) { ctx.redirect(/login, { from: ctx.state.path }); return; } await next(); });这一段逻辑在React、Vue、小程序下完全复用。5. 实际踩坑记录跨框架路由移植中常见的5个问题5.1 生命周期差异导致的路由守卫失效第一次做跨框架移植时我在React端用useEffect监听路由变化在Vue端用watch在小程序端用onShow结果权限守卫出现了“不同步”。后来查明白因为React Router的“导航完成”和Vue Router的“导航完成”触发的钩子顺序不同比如Vue的beforeEach在跳转前React Router的普通渲染在跳转后直接用框架的生命周期来执行守卫必然会出现时序差异。解决方案就是我在上面写的RouterCore中间件。所有导航动作必须统一走await routerCore.navigate()只有中间件全部通过后适配层才去更新UI。适配层只是执行RouterCore的“命令”不再自己判断“导航是否成功”。这就像所有车辆都从同一个收费站经过管你是React还是Vue不交费就不放行。实际项目中还要注意别在中间件里用异步等待太久否则用户会感觉页面长时间不跳转。5.2 动态路由正则不一致React Router支持:id语法Vue Router也支持/:id,但小程序的页面路径不允许冒号它用的是类似pages/detail/index?id1这种query传参。我当时在核心层写了/user/:idReact和Vue都正常小程序端适配时傻眼了路径里根本没有参数只有?id123。解决思路是在适配层做“路径模式转换”。核心层保存的是语义化路径小程序适配层在真正调用wx.navigateTo前把语义化路径转换成小程序的实际js路径并把/user/123改成/pages/user/index?id123。反过来小程序页面onLoad时读取query里的id再组合成语义路径/user/123丢给RouterCore。这个转换逻辑留在适配层核心层依然保持纯净。这里我踩过一个更细的坑在小程序里路径不能带协议或特殊字符要提前encodeURIComponent。5.3 导航拦截与全局事件管理业务里经常会做“关闭页面时确认”之类的拦截操作。React Router有useBlockerVue Router有beforeRouteLeave小程序则要靠自定义返回按钮和wx.enableAlertBeforeUnload三者行为都不同。如果你在每个框架都写一套拦截确认弹窗代码会很难维护。标准化做法是在核心层提供registerBlocker注册一个“离开确认”函数在RouterCore执行back或navigate切换离开当前页面前调用所有blocker并等待结果。如果用户取消了直接停止导航。React适配器不需要用框架的blocker而是统一在调用window.history.pushState前以及监听popstate时先问RouterCore是否能离开。Vue同样。小程序虽然无法拦截系统返回手势但用户可以自定义导航栏的返回按钮点击按钮时先走RouterCore的blocker通过后再wx.navigateBack。这个方法能生效的前提是所有页面跳转入口都统一经过RouterCore不能有人在组件里直接调history.pushState或者this.$router.push。为了让团队遵守我会在lint规则里禁用框架的导航API甚至用Wrapper对象作为唯一出口。5.4 参数序列化与类型丢失URL传参天然是字符串但业务层面可能需要数字或布尔值。React Router的useSearchParams拿到的是字符串Vue Router的query可序列化成字符串小程序更不用说。如果你在核心层将用户ID传给页面结果发现id被解析成了123那么用判断时就会出错。我的方案是在RouteMeta里定义参数类型描述比如paramsTyping: { id: number }。RouterCore匹配完路径后根据这个描述把字符串转为对应类型。更进一步如果参数是对象或数组可以通过JSON字符串传递然后在RouterCore里反序列化。但要注意URL长度限制所以复杂对象我建议放入全局状态管理如store路由只用路径参数传递一个实体ID。这样还能保持URL可分享。这个细节文档很少讲但实战中特别影响开发效率。5.5 多进程/多标签页同步问题浏览器多标签页下用户在一个标签页登录或切换页面另一个标签页的路由状态不会自动变化。React和Vue单页应用里history API无法跨标签页触发popstate。小程序单页模式没这个问题但如果是多实例WebView也可能遇到。跨框架路由层应该提供同步能力。我试过的方案是监听storage事件标签页A导航到新页面时在localStorage写一个lastPath标签页B的storage事件监听器读取到新路径后调用RouterCore的replace或navigate实现同步。加了防抖避免两个标签页互相触发循环跳动。后来在复杂项目里换用BroadcastChannel来做广播特定类型的消息比如ROUTE_CHANGEDtag标签页只处理消息不再次广播效率更高。唯一要注意的是同步路由时要判断当前页面是否允许离开否则会出现“A标签页离开时确认”与“B标签页强制同步跳转”的冲突需要由同一个blocker统一管理。6. 经验总结与继续扩展方向6.1 团队协作中的标准化约定技术实现只是标准化技能栈的一半另一半是团队约定。我见过不少项目路由层已经抽出来了但总有人图省事直接调用location.href切页于是路由状态就断了。要建立明确的红线业务代码禁止import框架路由API禁止直接操作history禁止在组件里手动修改url。所有导航行为必须从核心层的NavigationService接口走。为了让红线执行可以在代码审查中重点检查也可以写一个eslint插件把react-router-dom、vue-router、wx.navigateTo等标记为不允许直接使用除非该文件位于适配层目录。另一个约定是路由配置单独成文件页面组件的key命名有统一规则比如ModulePageName。这样适配层可以根据key自动拼接页面对应关系方便新框架接入。我习惯让路由表通过JSON文件承载这样后端也能生成或管理部分路由实现动态路由。标准化约定不是条条框框而是把经常变化的框架差异挡在核心层之外让大家在业务开发时不思考“这个框架要怎么跳转”只思考“我要去哪里”。6.2 从跨框架到跨端的延伸这套标准化技能栈不仅仅能解决React/Vue/小程序之间的框架切换还可以往跨端方向延伸。比如React Native、Flutter、鸿蒙的ArkUI等底层渲染模型更复杂但只要你的核心逻辑和路由机制足够干净适配层理论上都能写出来。我在个人实验里接过一个类React Native的场景核心层代码几乎没有改动只是新增了一个Adapter把路由状态映射成原生导航栈。后续还可以把状态管理也做类似标准化。比如把状态仓库抽象为StateStore接口React端用Zustand或ReduxVue端用Pinia小程序用某个自研Store但业务层的读写API保持统一。这样前端团队的技能栈不再是“某人只会Vue”、“某人只会React”而是大家都懂一套核心业务语言。新成员第一次接手项目时第一课不是学框架而是理解核心层的路由表、状态实体和业务流程框架只是展示层。这个心智模型我觉得是跨端时代前端团队最值得沉淀的资产。最后分享一点个人体会标准化技能栈不是一步到位的不要刚开始就打算抽象一套完美架构。我的路径是先在一个框架内把路由和业务逻辑抽到独立模块紧接着在第二个框架中强制复用这些模块逼自己找出“第一个框架里偷懒留下的强绑定代码”。等到三个框架都跑起来抽象边界自然就稳定了。这个过程很磨人但跨框架移植真正来临时你会庆幸自己的路由核心层早已成了团队的公共知识库。