前言
项目框架:Vue3 + TypeScript
有这样一个需求,系统默认只有最基础的几个路由,如登录、404等,其它路由需要在登录后动态添加。系统没有固定首页,登录完成后跳转至动态菜单的第一个菜单页。
分析
这一逻辑乍一看很简单,其实有很多小坑在里面。其中最容易踩的的坑是动态路由尚未渲染完成就已经触发路由跳转了,这时候肯定是404,因为路由并不存在;另一个容易踩的坑是路由重复加载,此时页面会显示空白,需要手动刷新才能正常显示。
首先想到的就是使用 Promise 函数解决,结果行不通。addRoute 是一个宏任务 和 resolve 是微任务,所以 Promise 结束的时候并不能代表动态路由已经添加完成。
其次又想到使用 async 函数来确保获取到登录成功结果的时候,路由已经添加完成,结果一番尝试后依然行不通。因为添加路由的操作不是异步的,没有返回 Promise 对象,因此这里的 await 是不会产生效果的。(PS:事后使用 Promise.all 解决了这一问题,后面的具体方法上会说。)
最后,想到了一个很笨的解决方法,轮询。实验过后,确定可以实现,但就如开头说的,这会显得很 low ,虽然它最终解决了问题。
实践
登录的操作都是一样的,所以单独拿出来只写一遍。表单就不做介绍了,就从点击登录表单校验通过后说起。
所有登录的代码放到一个页面会显得臃肿,所以具体登录的操作逻辑我把它抽离了出来。在 src/utils 目录下创建一个 auth.ts 文件。
auth.ts
ts复制代码<span>import</span> { useRouteListStore } <span>from</span> <span>\'@/store/router\'</span><span>const</span> routeListStore = useRouteListStore()<span>// 登录</span><span>export</span> <span>async</span> <span><span>function</span> <span>Login</span>(<span>data: { username: <span>string</span>; password: <span>string</span>; portal: <span>string</span>; corpCode: <span>string</span> }</span>) </span>{ <span>const</span> { username, password, portal, corpCode } = data <span>try</span> { <span>// 登录接口</span> <span>const</span> res = <span>await</span> getLogin({ username, password, portal, corpCode }) <span>// ...</span> <span>// 这里写保存用户信息及 token 的逻辑</span> <span>// ...</span> <span>// 添加路由操作,写在 pinia 中,后面会说</span> <span>await</span> routeListStore.updateRouteList() <span>return</span> res } <span>catch</span> (err) { <span>return</span> err }}
接下来要写添加路由的具体逻辑。在 src/store 目录下创建一个 router.ts 文件,添加内容如下:(PS:具体文件路径要结合具体的项目内容,以下路径及菜单格式仅作为示例)。
根据处理方式不同,有两种方案。
方案一:使用 async 函数
src/store/router.ts
ts复制代码<span>export</span> <span>const</span> useRouteListStore = defineStore(<span>\'routeList\'</span>, { <span>state</span>: <span><span>()</span> =></span> ({ <span>routeList</span>: [], <span>breadcrumb</span>: [], <span>getRouter</span>: <span>true</span> <span>// 是否需要重新加载路由</span> }), <span>actions</span>: { <span>// 更新菜单并追加路由</span> <span>async</span> updateRouteList() { <span>const</span> modules = <span>import</span>.meta.glob(<span>\'../views/**/*.vue\'</span>) <span>// 此为接口请求获取的菜单</span> <span>const</span> list = <span>await</span> getMenus() list.forEach(<span>(<span>e</span>) =></span> { e.route = e.path e.component = <span><span>()</span> =></span> <span>import</span>(<span>\'@/layout/index.vue\'</span>) e.redirect = <span>`<span>${e.path}</span>/<span>${e.children[<span>0</span>].path}</span>`</span> e.children.forEach(<span>(<span>item</span>) =></span> { item.route = <span>`<span>${e.path}</span>/<span>${item.path}</span>`</span> item.component = modules[<span>`../views<span>${item.component}</span>.vue`</span>] }) }) <span>await</span> addRouteList(list) <span>this</span>.getRouter = <span>false</span> <span>this</span>.routeList = list <span>return</span> <span>true</span> }, }})
接下来写动态添加路由的逻辑,使用 Promise.all 来确保 Pinia 中返回结果时,动态路由已经加载完成。在 src/router 创建 index.ts 文件,添加内容如下:
src/router/index.ts
ts复制代码<span>export</span> <span><span>function</span> <span>addRouteList</span>(<span>data: any[] = []</span>) </span>{ <span>return</span> <span>new</span> <span>Promise</span>(<span>(<span>resolve</span>) =></span> { <span>const</span> promises = [] data.forEach(<span>(<span>e</span>) =></span> promises.push(router.addRoute(e))) <span>Promise</span>.all(promises).then(<span><span>()</span> =></span> resolve(<span>true</span>)) })}
使用 async 函数之后,登录页的操作将会变得很简单。
login.vue
ts复制代码<span>import</span> { Login } <span>from</span> <span>\'@/utils/auth\'</span>const onSubmit = <span><span>()</span> =></span> { validate().<span>then</span>(<span><span>()</span> =></span> { Login(formState).<span>then</span>(<span><span>()</span> =></span> { router.push(routerStore.routeList[<span>0</span>].path) }).<span>catch</span>(err => { message.error(err.message) }) })}
方案二:使用轮询
轮询的方案相比于使用 async 函数要简单很多,因为它不需要确保登录后拿到结果的那一刻,路由是加载完成的。具体实现代码如下:
src/store/router.ts
ts复制代码<span>export</span> <span>const</span> useRouteListStore = defineStore(<span>\'routeList\'</span>, { <span>state</span>: <span><span>()</span> =></span> ({ <span>routeList</span>: [], <span>breadcrumb</span>: [], <span>getRouter</span>: <span>true</span> }), <span>actions</span>: { <span>// 更新菜单并追加路由</span> updateRouteList() { listMenus().then(<span>(<span>res</span>) =></span> { <span>const</span> list = res.data <span>if</span> (list === <span>null</span>) { <span>this</span>.getRouter = <span>false</span> router.push(<span>\'/404\'</span>) <span>return</span> } list.forEach(<span>(<span>e</span>) =></span> { e.route = e.path e.component = <span><span>()</span> =></span> <span>import</span>(<span>\'@/layout/index.vue\'</span>) e.children.forEach(<span>(<span>item</span>) =></span> { item.route = <span>`<span>${e.path}</span>/<span>${item.path}</span>`</span> item.component = modules[<span>`../views<span>${item.component}</span>.vue`</span>] }) }) addRouteList(list) <span>this</span>.getRouter = <span>false</span> <span>this</span>.routeList = list }) }})
src/router/index.ts
ts复制代码<span>export</span> <span><span>function</span> <span>addRouteList</span>(<span>data: any[] = []</span>) </span>{ data.forEach(<span>(<span>e</span>) =></span> { router.addRoute(e) })}
轮询的好处是逻辑简单,唯一麻烦的一点就是在登录后添加一个定时器去定期获取路由是否加载完成。之所以要加定时器是因为获取菜单是异步请求,而程序执行时很快的,所以要确保执行路由跳转命令时菜单是加载完成的。
login.vue
ts复制代码<span>import</span> { ref, onBeforeUnmount } <span>from</span> <span>\'vue\'</span><span>import</span> { useRouter } <span>from</span> <span>\'vue-router\'</span><span>import</span> { useRouteListStore } <span>from</span> <span>\'@/store/router\'</span><span>const</span> routerStore = useRouteListStore()<span>import</span> { Login } <span>from</span> <span>\'@/utils/auth\'</span><span>const</span> router = useRouter()<span>// 每0.5s判断一次菜单是否加载完成,最多判断30次,超过则说明网络环境极差</span><span>const</span> timer = ref(<span>null</span>)<span>const</span> onSubmit = <span><span>()</span> =></span> { validate().then(<span><span>()</span> =></span> { Login(formState).then(<span><span>()</span> =></span> { <span>let</span> i = <span>0</span> timer.value = setInterval(<span><span>()</span> =></span> { <span>if</span> (routerStore.routeList[<span>0</span>].path) { router.push(routerStore.routeList[<span>0</span>].path) } i++ <span>if</span> (i > <span>30</span>) { clearInterval(timer.value) timer.value = <span>null</span> i = <span>null</span> message.error(<span>\'当前网络环境较差!\'</span>) spinning.value = <span>false</span> } }, <span>500</span>) }) })}<span>// 不要忘记清除定时器</span>onBeforeUnmount(<span><span>()</span> =></span> { clearInterval(timer.value) timer.value = <span>null</span>})
补充
以上代码只能保证系统初次登录后可以正常跳转页面,如果退出当前账号,重新登录或者更换账号登录,会出现路由重复加载的问题,也就是文章开头所说的另一个容易踩的坑。这个坑解决起来并不困难,只要注意到了,很容易就可以解决。
解决思路是添加路由前置守卫,同时在 Pinia 中添加一个字段判断当前路由是否需要重新加载即可。具体代码如下:
js复制代码<span>import</span> Cookies <span>from</span> <span>\'js-cookie\'</span><span>import</span> { useRouteListStore } <span>from</span> <span>\'@/store/router\'</span><span>// 前置守卫</span>router.beforeEach(<span>async</span> (to, <span>from</span>, next) => { <span>const</span> token = Cookies.get(<span>\'token\'</span>) <span>if</span> (!token) { next({ <span>path</span>: <span>\'/login\'</span> }) } <span>else</span> { <span>const</span> routerStore = useRouteListStore() routerStore.addBreadcrumb(to) <span>// 判断菜单是否存在且是否需要重新加载</span> <span>if</span> (routerStore.routeList.length === <span>0</span> && routerStore.getRouter) { <span>await</span> routerStore.updateRouteList() next({ <span>path</span>: to.path, <span>query</span>: to.query }) } <span>else</span> { next() } }})
如对本文有疑问或不同看法,欢迎在评论区指出。