实现切换路由时当前布局不变并加载其他路由

最近在过 Next.js 文档 ,看到拦截路由这一章,感觉到惊喜(暂时没想到什么词可以形容了),这种设计在交互和代码上都着实可以称赞。同时联想了一下 Vue.js 如何实现。

举一个例子

路由定义

  • /photo : 图片列表
  • /photo/xxx : 具体某张图片的详情

在页面 /photo 单击图片时,路由发生变化,进入图片详情 /photo/123, 可以通过模态框( Modal )展示内容。(官方称这种行为拦截路由,屏蔽 URL)

此时,如果使用 /photo/123 直接进入图片详情,直接展示的是详情页面,而不是模态框。

此设计优势,参考 Next.js 官方

  • 可通过URL分享
  • 刷新时可保留上下文,而不是关闭 Modal
  • 路由后退时关闭 Modal ,而不是真正的后退路由
  • 后退后可以向前导航打开 Modal

实现效果如图,点击图片,路由已跳转,并且是已模态框形式展现;此时刷新,进入详情页。

Next.js 实现

对 Next.js 不熟的同学,需要去看文档过下基础了。

Intercepting Routes 拦截路由主要是可以实现切换路由时,原页面布局不进行改变,同时可以加载新路由。

Next.js 实现起来并不复杂,官方已经提供模板写法。

通过 Parallel Routes 平行路由来实现。

实现一下步骤:

  1. /app/layout.jsx 布局文件下放入插槽 modal
  2. 新建文件 /app/@modal/(.)photo/[id]/page.js 为 SPA 形式跳转路由的模态框展示代码
  3. 文件 /app/photo/[id]/page.js 为新开页面进去的代码
  4. 以上两者的路由是一样的,跳转路由方式不一样展示效果不一样

自己实现的文件结构

完整代码:intercepting-routes-nextjs

Vue.js 实现

大致思路:在最外层需要一个 layout 组件来控制图片是以什么形态展示;图片列表页需作为父页面因为需要在跳转路由后仍然展示,所以需要在结构上作为父组件页面;而图片详情有两种形态展示,需要一个 Container 来进行分发,至于判断条件是从 layout 的数据来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 文件夹结构
./src
├── App.vue
├── constant.js
├── data.js
├── main.js
├── pages
│ ├── Layout.vue
│ ├── PhotoInfo
│ │ ├── PhotoInfoContainer.vue
│ │ ├── PhotoInfoModal.vue
│ │ └── PhotoInfoPage.vue
│ ├── PhotoList
│ │ ├── PhotoList.vue
│ │ └── PhotoListContainer.vue
│ └── composables
│ └── useLayout.js
├── router
│ └── index.js
└── style.css

首先是需要配置一下路由信息,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const routes = [
{
path: '/',
component: () => import('../pages/Layout.vue'),
children: [
{
// 图片列表
path: '/',
name: 'PHOTO_LIST',
component: () => import('../pages/PhotoList/PhotoListContainer.vue'),
children: [
// 图片详情
{
name: 'PHOTO_INFO',
path: '/photo/:id',
component: () => import('../pages/PhotoInfo/PhotoInfoContainer.vue'),
},
],
},
],
},
];

看结构可以看出最外层是一个 Layout 组件,其次是PhotoListContainer,最后 PhotoInfoContainer,这个嵌套路由的关系与我们开头所说的关系一致。

核心代码就是 useLayout.js 这块儿,将首次进入的页面记录下来,传递给后面的子组件进行渲染或使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// /src/constant.js
import { markRaw } from "vue";
import PhotoList from "./pages/PhotoList/PhotoList.vue";

export const PAGE = {
PHOTO_LIST: 'PHOTO_LIST',
PHOTO_INFO: 'PHOTO_INFO'
};

export const COMPONENT_CONFIG = {
[PAGE.PHOTO_LIST]: markRaw(PhotoList),
}

// /src/pages/composables/useLayout.js
import { inject, provide, ref, watchEffect, watch } from 'vue';
import { COMPONENT_CONFIG } from '../../constant.js';
import { useRoute } from 'vue-router';

const LAYOUT_SYMBOL = Symbol('layout');

export function useLayout() {
const route = useRoute();
const cmp = ref(COMPONENT_CONFIG[route.name]);
let mountedList = !!cmp.value;
provide(LAYOUT_SYMBOL, {
components: cmp,
});

// 防止直接访问图片详情页后返回图片列表不显示内容;以及直接图片详情返回列表页面,再打开图片出现的兼容问题
watch(() => route.path, () => {
if (!mountedList) {
cmp.value = COMPONENT_CONFIG[route.name];
mountedList = true
}
})
}

export function useLayoutData() {
const data = inject(LAYOUT_SYMBOL, {});
return data;
}

接下来是 PhotoList 的两个组件, PhotoListContainer 这个组件在路由中进行引用,根据 useLayout 传下来的数据进行判断是否要显示图片列表。(备注:这里使用 useLayoutData 获取数据)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- /src/pages/PhotoList/PhotoListContainer.vue -->
<template>
<div class="PhotoListContainer">
<component :is="components" />

<router-view />
</div>
</template>

<script setup>
import { useLayoutData } from '../composables/useLayout'
const { components } = useLayoutData()
</script>

接下来是图片详情的三个组件,首先是图片详情的 Container ,用来做分发使用,数据从 layout 传下来,会看 useLayout 的逻辑可以看出,只有首次打开页面为图片列表页 components 才会有数据,此处也就可以判断需要加载 Modal 还是 Page

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- /src/pages/PhotoInfo/PhotoInfoContainer.vue -->
<template>
<div class="PhotoInfoContainer">
<component :is="InfoCmp" />
</div>
</template>

<script lang="ts" setup>
import { useLayoutData } from '~/pages/composables/useLayout'
import { computed, defineAsyncComponent } from 'vue'

const { components } = useLayoutData()

const InfoCmp = computed(() => {
if (components?.value) {
return defineAsyncComponent(() => import('./PhotoInfoModal.vue'))
} else {
return defineAsyncComponent(() => import('./PhotoInfoPage.vue'))
}
})
</script>

核心实现代码已结束,另外 PhotoList.vuePhotoInfoModalPhotoInfoPage 没有贴上来,感兴趣可以到仓库查看。

完整代码: intercepting-routes

总结

Next.js 实现起来有官方实践推荐,支持较好,跟随文档即可实现。

Vue.js 实现起来有点黑魔法的感觉,相关逻辑需要自行实现,不过也是可以实现类似功能,并且具备相同的特性。(小红书官网的feed流打开笔记详情效果,实现原理也类似)

  • 通过路由嵌套的形式,将列表页作为父路由,详情页作为子路由,实现路由页面可以共存,并且切换时还可以保留上下文
  • 需在子路由实现以什么形式加载,判断和展示逻辑都需手动实现。