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

June 18, 2024

最近在过 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 的数据来。

// 文件夹结构
./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

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

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 这块儿,将首次进入的页面记录下来,传递给后面的子组件进行渲染或使用。

// /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 获取数据)

<!-- /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

<!-- /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流打开笔记详情效果,实现原理也类似)

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