Files
Docker/uniapp/youjiala/src/md/page-routing.md
T
2026-10-13 10:47:39 +08:00

21 KiB
Raw Blame History

页面路由与短地址规范

适用范围:src/pages.json、src/manifest.json、src/router/routes.ts、src/router/h5-clean-url.ts、src/main.ts。 本文记录"为什么这么设计"和"新增页面要动哪几处",代码里的注释只解释局部行为,跨文件的约束以本文为准。 后端地址的 .env 分层见 environment.md,跳转失败与未捕获错误的兜底口径见 error-handling.md,权限判定见 permission.md。


1. 目标:H5 地址形态

需求是对齐旧 WebVue 系统的地址风格(https://uat.youjiala.com/dashboard),同时保住 uni-app 的 pages/ 目录结构。

阶段 地址 说明
旧 WebVue /login、/dashboard vue-router 4 + createWebHistory,可自由定义 path
本项目改造前 /#/pages/login/login hash 模式 + pages.json 的 path 字面即 URL
中间态(被否决) /login,但文件在 src/login.vue 页面文件被迫上移到 src 根,放弃 pages/ 约定
本项目现状 /login、/dashboard,文件仍在 pages/xxx/xxx.vue history 模式 + 运行时追加短地址路由记录

三端最终形态:

src/pages/login/login.vue            pages.json: pages/login/login           URL  /  和  /login
src/pages/dashboard/dashboard.vue    pages.json: pages/dashboard/dashboard   URL  /dashboard
小程序 / App 端:只认真实路径 pages/login/login、pages/dashboard/dashboard

2. uni-app 的路由模型(与 vue-router 的本质差异)

  • 路由表编译期静态注册:pages.json 的 pages 数组是唯一来源,没有 router.addRoute,没有嵌套路由,没有全局 beforeEach。

  • URL 由 path 字面决定:uni-h5-vite 的 generatePageRoute 生成

    // uni-h5-vite/dist/pagesJson.js 逐字摘录(引号为原样,非项目代码)
    const alias = isEntry ? `\n  alias:'/${path}',` : '';
    return `{ path:'/${isEntry ? '' : path}',${alias} ...`
    

    入口页被特殊映射为 /(真实路径记在 alias 与 meta.route),其余页面的 URL 就是 pages.json 里 path 的逐字拷贝。除入口页外,uni-app 没有任何"URL 重命名"配置项——这是整篇文档所有绕路的根源。

  • 导航 API 分端:uni.navigateTo / redirectTo / reLaunch / switchTab,而不是 router.push。

  • 守卫靠拦截器:uni.addInterceptor,不是路由中间件。

  • 产物里的路由表:window.__uniRoutes,由 uni-h5 的 initRouter(app) 在插件 install 阶段拿去 createRouter。


3. 改动清单

3.1 短地址改造(第一轮)

文件 改动
manifest.json h5.router.mode = "history"、h5.router.base = "/"
pages.json path 保持带目录(pages/login/login);注释指向本文的登记要求
router/routes.ts 新增。PAGE_PATHS 映射表 + toRealPath() / toCleanPath() / pageUrl()
router/h5-clean-url.ts 新增。往 window.__uniRoutes 追加短地址路由记录
main.ts #ifdef H5 引入并在模块体求值期调用 registerCleanUrls()
common/http.interceptor.ts loginPage = toRealPath("login");401 跳登录用 uni.reLaunch({ url: pageUrl("login") })

3.2 登录守卫 + 错误页(第二轮,见 §11)

文件 改动
router/routes.ts 扩展:错误页登记、CLEAN_PATH_OVERRIDES、AUTH_FREE_PAGES、PAGE_PERMISSIONS、resolvePageName() / errorPageUrl() / normalizePath()
router/nav-guard.ts 新增。setupNavGuard() / ensureCurrentPageAuthorized() / setupUnknownPathFallback() / goLoginWithRedirect()
composables/useAuthGuard.ts 新增。页面级 onShow 复核(补冷启动深链那半边)
components/error-panel/error-panel.vue 新增。401/403/404 共用外壳(移植旧 ErrorPanel.vue)
pages/error/page40{1,3,4}.vue 新增。三个错误页
pages.json 注册 3 个错误页
App.vue onLaunch 调 setupNavGuard()
main.ts H5 块内 registerCleanUrls() 之后调 setupUnknownPathFallback()
common/http.interceptor.ts 非 2xx 分支新增 statusCode === 403 → 跳 403 页
locale/lang/{zh-CN,en-US}.json 新增 ErrorPage 组 30 个键

history 开关的读取路径:编译层特征开关 __UNI_FEATURE_ROUTER_MODE__,取自 manifest.web || manifest.h5 的 router.mode。

3.3 权限评估层(第三轮,见 permission.md)

文件 改动
permission/permission-kernel.ts 新增。纯函数判定内核 + 预建索引(buildPermissionIndex / can / canAny / canOperate / actionsOf / grantedPermissions)
permission/permission-context.ts 新增。接 pinia 档案:PermissionContext / usePermission / usePagePermission / canVisitPage
permission/permission-kernel.spec.ts 新增。59 条断言的可执行校验(esbuild + node 跑,未引测试框架)
router/routes.ts PAGE_PERMISSIONS 值类型放宽为 PermissionNames(string | string[]),注释指向 permission.md
router/nav-guard.ts evaluate() 补第三步 canVisitPage(name) → error403

4. 短地址机制:追加克隆记录,而不是挂 alias

4.1 第一版为什么错(实测抓到的真实故障)

最初实现是给原路由记录挂 alias: ["/dashboard"](数组)。结果症状是割裂的:

  • 浏览器地址栏直接输入 /dashboard → 能进(vue-router 自己支持数组 alias);
  • 站内 uni.reLaunch({ url: "/dashboard" }) → 报 page \/dashboard` is not found`。

原因在 node_modules/@dcloudio/uni-h5/dist/uni-h5.es.js 两条硬约束:

// L1281  getRouteOptions(path, alias = false):uni 的导航层查表
return __uniRoutes.find(
  (route) => route.path === path || route.alias === path   // 字符串全等,数组永不命中
);

// L6010  入口页在导航层被强制改写回根路径
if (routeOptions.meta.isEntry) {
  url = url.replace(routeOptions.alias, "/");              // alias 是数组时这行也是错的
}

结论:uni 的导航层不读 vue-router 的 alias 能力,只认 path 精确匹配(alias 仅支持字符串全等);且带 meta.isEntry 的记录地址永远回落到 /,到不了 /login。

4.2 现方案

给每个页面追加一条克隆路由记录,path 用短地址,让 uni 的查表用 path 精确命中。

字段 处理 原因
path 换成短地址 /dashboard 让 getRouteOptions 第一条件命中
alias 删除 避免与真实路径互相干扰;也避开 isEntry 分支里的 url.replace(alias, "/")
meta.isEntry 置 undefined 否则地址栏被强改回 /
meta.route 保持真实路径 uni 的页面栈、导航栏样式等仍按真实路径查表
isCleanClone 标记 true 二次注册时跳过克隆体,防止无限繁殖

4.3 时序(为什么能赶上)

registerCleanUrls() 在 main.ts 模块体求值期调用,而 initRouter(app) 要到插件 install 阶段才用同一个数组引用去 createRouter,所以追加必然被读进去。

两个分支都要覆盖,因为 pages 虚拟模块与本模块的求值先后不由我们控制:

  • window.__uniRoutes 已赋值 → 直接 applyCleanUrls(数组);
  • 尚未赋值 → Object.defineProperty 拦一次 set,在赋值瞬间完成注册。

4.4 跨端隔离

h5-clean-url.ts 只被 #ifdef H5 引入,实测产物命中数:H5 有、mp-weixin 0、app 0。


5. API 契约(写页面时必须遵守)

router/routes.ts 是页面路径的唯一来源:

export const PAGE_PATHS = {
  login: "pages/login/login",
  dashboard: "pages/dashboard/dashboard",
  error401: "pages/error/page401",
  error403: "pages/error/page403",
  error404: "pages/error/page404",
} as const;
函数 用途 注意
pageUrl(name) 跳转用的 URL H5 返回短地址,其他端返回真实路径(条件编译)
toRealPath(name) 路径比较、日志 永远带前导斜杠的真实路径
toCleanPath(name) 短地址推导 优先取 CLEAN_PATH_OVERRIDES,否则取路径最后一段
resolvePageName(url) 任意 URL → 页面名 认不出返回 null,守卫据此判 404;"/" 视为入口页
errorPageUrl(name, from?) 错误页地址 内部用 normalizePath(from) 清洗后拼进 ?from=,不做预编码(见 §11.4)
currentRealPath() 当前页真实路径 页面栈为空时返回空串
isAuthFree(name) 是否免登录页 数据源是 AUTH_FREE_PAGES

硬约束:getCurrentPages() 返回的 route 永远是真实路径,不含运行时补的短地址。所以任何"当前是不是某页"的判断只能用 toRealPath(),用 pageUrl() 比较在 H5 上会恒不等(http.interceptor.ts 的 goLogin 就是按这个写的)。

小程序 / App 端导航只能用真实路径——短地址在那些端根本不存在。


6. 新增页面的完整动作

1. src/pages/album/album.vue          文件,目录名必须与文件名同名
2. src/pages.json                     追加 { "path": "pages/album/album" }
3. src/router/routes.ts 的 PAGE_PATHS  追加 album: "pages/album/album"
4. src/pages/album/album.vue           受登录保护的页面调一次 useAuthGuard()
5. src/router/routes.ts 的 PAGE_PERMISSIONS  受功能权限控制的页面才登记权限名,见 permission.md §6

第 2 步是绝对必须:pages.json 是编译期唯一注册入口,不写就没有这个页面。 第 3 步的必须程度见下一节。 第 4 步见 §11:站内跳转由全局拦截器兜住,但冷启动深链不经过任何导航 API,页面自己必须复核。

命名约定(第 1 步)不是审美问题,是功能要求:短地址由 toCleanPath() 取最后一段推出来,pages/album/album.vue → /album;若写成 pages/album/index.vue,短地址就成了 /index。

例外:错误页刻意写成 pages/error/page401.vue(末段是 page401),因为地址要与旧 WebVue 逐字一致的 /error/page401,这类情况用 CLEAN_PATH_OVERRIDES 指定短地址,而不是把文件改名为 error/error.vue。


7. 不登记 PAGE_PATHS 会怎样

页面不会坏,但会失去短地址,并且编译期被拦:

  1. /pages/album/album 真实路径照常可访问,深链、刷新、站内跳转都正常;

  2. 短地址 /album 打不进去,控制台输出一条告警(h5-clean-url.ts):

    [h5-clean-url] 页面未登记到 PAGE_PATHS(router/routes.ts),仅真实路径可访问: /pages/album/album
    
  3. 只要代码里用 pageUrl("album") / toRealPath("album"),PageName = keyof typeof PAGE_PATHS 会让 TS 在编译期直接报错——这是有意的强制手段,比运行时静默失效应更早暴露。

另外两种会被跳过的情况(都有独立告警,不会静默):

  • 末段重名冲突:pages/a/user.vue 与 pages/b/user.vue 都想占 /user,后到者被跳过并提示"短地址冲突,已跳过"。
  • tabBar 页面:明确不注册短地址(h5-clean-url.ts 的 isTabBar 分支)。tabBar 的路径判定依赖 route.path.slice(1),克隆记录会干扰 switchTab / preloadPage。将来把首页做成 tabBar 时,它只能使用真实路径。

8. 当前没有做的事(已知缺口)

  • 权限守卫已落地但登记表是空的:PAGE_PERMISSIONS 尚无条目,路由级 403 因此还没被任何页面真正触发(现在能跳 403 的只有请求层的 HTTP 403)。判定层本身已完成并可用,语义、登记流程与已知缺口见 permission.md。
  • history 模式的服务端兜底:/login、/dashboard 这类深链直接刷新依赖服务端把请求 rewrite 回 index.html。dev server(Vite)已实测返回 SPA 壳,但生产环境(如 uat.youjiala.com 的 Nginx)需要加 try_files $uri $uri/ /index.html; 一条,否则刷新会 404。这一条尚未在生产环境验证。

9. 验证记录

改造后跑过的验证,后续再动路由可比照执行:

  • 产物实锤:window.__uniRoutes 含 {path:"/",alias:"/pages/login/login"}、{path:"/pages/dashboard/dashboard"},以及追加的 {path:"/login"}、{path:"/dashboard"} 克隆记录;全程无 #。
  • 浏览器 8/8 用例全绿:/、/login、/dashboard 深链直达 + 整页刷新;001/123 登录后 reLaunch 落到 /dashboard;上一轮失败的"站内跳 /dashboard 报 page is not found"已消失。
  • HTTP 层:/、/login、/dashboard、/pages/dashboard/dashboard 均返回含 <div id="app"> 的 SPA 壳。
  • 三端构建:H5 / mp-weixin / app 均 EXIT=0;mp-weixin 的 app.json 中 pages = login, dashboard(根级 path 在小程序端合法)——注意该项属于中间态验证,现行结构为 pages/xxx/xxx。
  • 跨端隔离:h5-clean-url 特征串仅在 H5 产物出现。

复查命令(PowerShell):

# 三端构建
.\node_modules\.bin\uni.cmd build *> "$env:TEMP\_b1.log"; "H5_EXIT=$LASTEXITCODE"
.\node_modules\.bin\uni.cmd build -p mp-weixin *> "$env:TEMP\_b2.log"; "MP_EXIT=$LASTEXITCODE"
.\node_modules\.bin\uni.cmd build -p app *> "$env:TEMP\_b3.log"; "APP_EXIT=$LASTEXITCODE"

# 产物里的路由表
Select-String -Path ".\dist\build\h5\assets\*.js" -Pattern '__uniRoutes=\['

浏览器侧的深链 / 地址栏 / 控制台表现无法用构建日志判定,必须真机或自动化实测。


10. 可优化项(未实现,需确认后再动)

PAGE_PATHS 与 pages.json 目前是两处手工同步。可改为编译期自动生成,只保留"目录名 = 文件名"一条约定:

import pagesJson from "@/pages.json"; // uni-app 支持 JSON 导入

代价是失去 PageName 的字面量类型(跳转目标名不再是编译期校验),且要额外处理 subPackages。取舍未定,暂不动。


11. 登录守卫与 401/403/404(移植自旧 WebVue)

11.1 为什么是"两条链路"而不是一条

旧系统是 vue-router 的 router.beforeEach,一个钩子覆盖所有进入方式。uni-app 没有等价物:

  • uni.addInterceptor(api, { invoke }) 只作用在调用导航 API 的那一次跳转上(navigateTo / redirectTo / reLaunch / switchTab)。
  • 冷启动深链不经过任何导航 API:H5 地址栏直接输入、分享链接打开、小程序"编译进入指定页面",都是 uni 自己解析出页面实例后直接 onLoad/onShow,拦截器一次都不会触发。

所以:router/nav-guard.ts 的 setupNavGuard() 管站内跳转,composables/useAuthGuard.ts 的页面级 onShow 复核管冷启动,缺一漏一半。

取消导航的机制(源码依据,@dcloudio/uni-h5/dist/uni-h5.es.js):

invokeApi -> queue(interceptor.invoke, options)
queue: res === false 时返回 { then(){}, catch(){} } 这样的空 thenable

即 invoke 返回 false 会让原调用被静默丢弃,既不会执行跳转,也不会给调用方留下未捕获的 promise rejection——所以守卫拦下来之后不需要调用方配合处理异常。

11.2 判定顺序与放行表

evaluate(url) 的顺序就是语义:地址不存在(404)→ 未登录(401)→ 无权限(403)。

  • AUTH_FREE_PAGES = login + 三个错误页。错误页必须免登录:401 页就是给"没登录/登录失效"的人看的,拦掉它只会让用户看到一个登录框而不知道刚才为什么被踢。
  • 登录态只认请求层的 getJwtInfo()?.AccessToken(令牌唯一来源,与 stores/loginProfile 口径一致)。
  • 刻意不把已登录用户从 /login 弹回首页:403 页的"重新登录"按钮需要先能进入登录页。
  • 第三步(403)走 fail-open:本地档案缺失或权限清单为空时放行,只有"档案里有清单但目标页的权限名不在其中"才拦。理由与风险边界见 permission.md §5——真正的安全边界在后端每个接口的 [PermissionOperate]。
  • 跳错误页用 redirectTo(保住返回栈,"返回上一页"才有内容),失败再退化成 reLaunch。

11.3 错误页与外壳组件

真实路径 pages/error/page401 经 CLEAN_PATH_OVERRIDES 得到短地址 /error/page401,与旧 WebVue 及 WebSitePolicy.Web401/403/404 逐字一致。三个页面共用 components/error-panel/error-panel.vue(对应旧 ErrorPanel.vue),它只管排版与 tone 三色(blue / amber / violet),文案、图标、按钮由各错误页给:

页 tone 图标 主按钮 次按钮
401 blue lock-fill 去登录(带 redirect) 返回上一页
403 amber warning-fill 重新登录 返回首页
404 violet map 返回首页 返回上一页

旧版用 ant-design 图标和 <a>,这里换成 u-icon + u-button(u-button 没有 icon 属性,故按钮只有文字),尺寸单位从 px 换成 rpx,头图顶部用 var(--status-bar-height) 适配 navigationStyle: custom。按钮间距写在容器的 gap 上——插槽内容属于各页面的作用域,错误页自己的 scoped 样式选不到它。

11.4 ?from= / ?redirect= 不做预编码

第一版用 encodeURIComponent 包了参数值,实测地址栏出现 ?from=%252Fdashboard——uni 不会先解这一层,而 vue-router 拼 URL 用的是 encodeURI(不编码 /,但会把 % 编成 %25),于是被编码两次。现在只做 normalizePath() 清洗(剥 query/hash、补前导斜杠),参数值里只剩路径字符,地址栏呈现干净的 ?from=/dashboard。页面侧仍用 decodeQueryPath() 兜底解一次,兼容各端 query 解码时机的差异。

11.5 403 的两个触发点

  • 请求层:common/http.interceptor.ts 的非 2xx 分支里 statusCode === 403 → goForbidden() 跳 403 页并带 ?from=;已经在 403 页就不再跳,避免"错误页自己发的请求又 403"来回刷。
  • 导航层:evaluate() 的第三步 if (!canVisitPage(name)) return "error403",数据源是 PAGE_PERMISSIONS + PermissionContext(判定语义、双失败策略、登记流程全部见 permission.md)。该步排在 401 之后:未登录时档案通常也是空的,先给 401 才谈得上"去登录"。

goForbidden() 在请求层自己实现而没有复用 nav-guard 的 jumpToError:nav-guard 依赖请求层读登录态,反向 import 会形成循环依赖。canVisitPage() 由 nav-guard import permission/permission-context 单向调用,不构成环——permission-context 只 import routes.ts 的常量与 resolvePageName,不 import nav-guard。

11.6 词条与验证

  • 文案全部走语言包:locale/lang/{zh-CN,en-US}.json 的 ErrorPage 组 30 个键;组件内固定的"可能的原因""出错的地址:"也在里面(reasonsTitle / fromLabel)。
  • 实测(H5 dev + 自动化浏览器):未登录深链 /dashboard → 401 页且 from 显示正确;401 的"去登录"→ /login?redirect=/dashboard;001/123 登录后回跳 /dashboard;/no/such/page → 404 页;404 的"返回首页"放行;清态后直访 /error/page403 不被再拦、无死循环;window.__uniRoutes 共 10 条(5 真 + 5 短地址克隆),全程零 console error/warn。
  • 三端构建 EXIT=0;location.pathname 仅出现在 H5 产物(setupUnknownPathFallback 的 #ifdef H5 生效),page401 三端产物均有。

11.7 resolvePageName("") 曾把登录成功后置地址清成空串(已修)

第三轮接权限层做端到端时暴露的既有缺陷,与权限无关,但正是"空地址到底算不算一个页面"这条边界没定死:

无 ?redirect= 的普通登录
  -> login.vue onLoad 里 decodeQueryPath(undefined) -> ""
  -> resolvePageName("") -> normalizePath("") -> "/" -> 命中入口页分支 -> "login"(真值)
  -> if (name) 成立 -> redirectUrl.value = ""(把默认的 /dashboard 覆盖成空串)
  -> uni.reLaunch({ url: "" })  ->  fail: Missing required args "url"
  -> 用户点了登录、接口 200、令牌已写入,却仍然卡在登录页

"/" 作为 H5 入口页的路由 path 必须能被 resolvePageName 认出来(冷启动落在入口页时靠它反查页面名),所以不能动那一条;问题在于 normalizePath 把空串也归一成 "/",于是"我压根没打算跳"被误读成"我要跳入口页"。修复分两层:

  • routes.ts resolvePageName:开头对 !String(url || "").trim() 直接返回 null(源头掐断,空地址永远不算页面)。
  • login.vue onLoad:判定条件从 if (name) 收紧为 if (name && !isAuthFree(name))——手敲 ?redirect=/ 或 ?redirect=/error/page401 时不再把落地页设成登录页/错误页自己。

教训:""" 和 "/" 等价"这种归一化只该出现在真正需要它的地方。凡是把字符串地址解析成结构化结果的函数,都要先明确"空输入是错误还是默认值",否则默认值会顺着真值判断悄悄覆盖掉上游的初始化。