【翻译】Vue.js vs Next.js:模态框路由 —— 并排拆解

0 阅读3分钟

Vue.js vs Next.js:模态框路由 —— 并排拆解

原文链接:dev.to/heba_allah/…

原文作者:Heba Allah Hashim

文章头图

让我们并排拆解这两个框架,以便理解这种巨大的思维转变。


项目 1:Vue.js 做法(代码驱动逻辑)

步骤 1:初始化项目

npm create vite@latest vue-modal-app -- --template vue
cd vue-modal-app
npm install vue-router@4

步骤 2:配置路由路径(src/router.js

import { createRouter, createWebHistory } from 'vue-router'
import GalleryView from './views/GalleryView.vue'

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      component: GalleryView,
      children: [
        {
          path: 'photo/:id',
          component: () => import('./components/PhotoModal.vue'),
          props: true
        }
      ]
    }
  ]
})

步骤 3:把路由器接到应用上

更新应用入口文件 src/main.js

import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'

createApp(App).use(router).mount('#app')

替换根组件文件 src/App.vue

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

步骤 4:创建图库列表页(src/views/GalleryView.vue

<template>
  <div style="padding: 20px; font-family: sans-serif;">
    <h1>Vue Photo Gallery</h1>

    <div style="display: flex; gap: 20px;">
      <div 
        v-for="photoId in ['sunset', 'ocean', 'mountains']" 
        :key="photoId"
        @click="openPhoto(photoId)"
        style="width: 150px; height: 100px; background: #3b82f6; color: white; display: flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 8px;"
      >
        View {{ photoId }}
      </div>
    </div>

    <!-- 在网格上方渲染子级弹层组件 -->
    <router-view v-if="showModal" @close="closeModal" />
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()
const showModal = ref(false)

// 关键:若硬刷新时 URL 中仍有 ID,Watcher 会强制打开模态框
watch(
  () => route.params.id,
  (newId) => {
    showModal.value = !!newId
  },
  { immediate: true }
)

function openPhoto(id) {
  router.push(`/photo/${id}`)
}

function closeModal() {
  router.push('/')
}
</script>

步骤 5:创建弹层模态框组件(src/components/PhotoModal.vue

<template>
  <div style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999;">
    <div style="background: white; padding: 30px; border-radius: 12px; text-align: center; width: 300px;">
      <h2>Vue Modal Layout</h2>
      <p style="font-size: 24px; font-weight: bold; text-transform: uppercase; color: #3b82f6;">
        🌌 {{ id }}
      </p>
      <button @click="$emit('close')" style="margin-top: 20px; padding: 8px 16px; cursor: pointer;">Close Overlay</button>
    </div>
  </div>
</template>

<script setup>
defineProps({ id: String })
defineEmits(['close'])
</script>

通过 npm run dev 运行,即可查看正在运行的应用。


项目 2:Next.js 做法(文件夹驱动架构)

步骤 1:初始化项目

# 配置选项:TypeScript(是)、ESLint(否)、src/(否)、App Router(是)
npx create-next-app@latest next-modal-app
cd next-modal-app

步骤 2:创建文件文件夹

在编辑器的文件侧栏中,手动创建与上方 Hierarchy Map 完全一致的文件夹,以避免系统转义错误。

步骤 3:搭建主图库页(app/gallery/page.tsx

import Link from 'next/link';

export default function GalleryPage() {
  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h1>Next.js Photo Gallery</h1>

      <div style={{ display: 'flex', gap: '20px' }}>
        {['sunset', 'ocean', 'mountains'].map((photoId) => (
          <Link 
            key={photoId} 
            href={`/gallery/photo/${photoId}`}
            style={{ 
              width: '150px', 
              height: '100px', 
              background: '#ef4444', 
              color: 'white', 
              display: 'flex', 
              alignItems: 'center', 
              justifyContent: 'center',
              textDecoration: 'none', 
              borderRadius: '8px' 
            }}
          >
            View {photoId}
          </Link>
        ))}
      </div>
    </div>
  );
}

步骤 4:配置布局插槽(app/gallery/layout.tsx

export default function GalleryLayout({
  children,
  modal
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <div>
      {children}
      {modal}
    </div>
  );
}

步骤 5:创建插槽回退(app/gallery/@modal/default.tsx

export default function DefaultModal() {
  // 通过返回基线 null,修复未挂载插槽不匹配导致的崩溃
  return null;
}

步骤 6:创建拦截式弹层视图 (app/gallery/@modal/(.)photo/[id]/page.tsx)

'use client';
import { useRouter, useParams } from 'next/navigation';

export default function InterceptedPhotoPopup() {
  const router = useRouter();
  const params = useParams(); 

  return (
    <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999 }}>
      <div style={{ background: 'white', padding: '30px', borderRadius: '12px', textAlign: 'center', width: '300px' }}>
        <h2>Next.js Intercepted Popup</h2>
        <p style={{ fontSize: '24px', fontWeight: 'bold', textTransform: 'uppercase', color: '#ef4444' }}>
          📸 {params.id}
        </p>
        <button onClick={() => router.back()} style={{ marginTop: '20px', padding: '8px 16px', cursor: 'pointer' }}>
          Close Overlay
        </button>
      </div>
    </div>
  );
}

步骤 7:创建独立回退页 (app/gallery/photo/[id]/page.tsx)

import Link from 'next/link';

interface PageProps {
  params: Promise<{ id: string }>;
}

export default async function StandalonePhotoPage({ params }: PageProps) {
  // 现代 Next.js 规则:params 必须异步解包
  const { id } = await params;

  return (
    <div style={{ padding: '40px', fontFamily: 'sans-serif', textAlign: 'center' }}>
      <h1>Standalone Full-Screen View</h1>
      <div style={{ margin: '40px auto', width: '400px', height: '250px', background: '#ef4444', color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '32px', borderRadius: '12px' }}>
        🌌 {id.toUpperCase()}
      </div>
      <Link href="/gallery" style={{ color: '#ef4444' }}>← Back to Gallery List</Link>
    </div>
  );
}

通过 npm run dev 运行,并在浏览器中打开 http://localhost:3000/gallery