看看今天的效果图,是不是有点后台的意思了


昨天的商品面板只有"一屏"------所有东西堆在一个
#app里。但真实后台不是一页:左边一排菜单,点"商品"进商品页,点"订单"进订单页,点某个商品进它的详情页,地址栏还要变成/product/5。可前端只有一个 HTML 文件,传统那种"每个页面一个 .html、点链接整页刷新"的做法会把你昨天写的购物车、登录态、响应式数据全部冲没。今天讲 Vue Router ------单页应用(SPA)的"页面切换器":URL 和组件之间建一张对照表,地址一变,对应的组件就换进来,页面不刷新、状态不丢 。今天要串起 路由表、
router-view/router-link、动态路由参数、编程式导航、嵌套布局路由、路由守卫 ,并把它接到后台框架最刚需的"菜单跳转 + 详情跳转"上。文末同样有一个完整
index.html:一个用 Vue Router(hash 模式) 搭起来的 MiniMall 多页后台框架 ------侧边栏菜单跳转、概览/商品/订单三个页面、点商品进/product/:id详情页,复制保存即可打开。全程零构建、零后端。
一、先看清问题:后台为什么离不开"路由"
先想清楚传统多页网站怎么跳页:点一个 <a href="order.html">,浏览器整页卸载、重新请求 order.html。在后台里这意味着------你昨天做的购物车、当前登录用户、刚填了一半的表单,全随着整页刷新没了;每次跳页还要白屏重新加载,体验很"重"。
| 传统多页(多个 .html) | SPA + 路由(一个 HTML) |
|---|---|
| 跳页 = 整页刷新、重新请求 | 跳页 = 只换中间那块组件,页面不刷新 |
| 公共状态(登录、购物车)每次丢 | 状态留在内存里,跨页不丢 |
| 每次加载白屏一下 | 切换几乎瞬时 |
| 后端要渲染每个页面 | 后端只给数据,页面全在前端组装 |
一句话:路由 = 维护一张「URL → 组件」的对照表,地址变了就把对应组件渲染到出口,而不刷新整页。 这是所有后台、所有 SPA 的骨架。菜单点哪、地址栏写啥、中间显示哪个组件,全由它串起来。
二、把 Vue Router 跑起来:CDN + createRouter + createWebHashHistory
延续昨天的零构建方式,多引一个 vue-router 的 CDN 就能跑:
html
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/vue-router@4/dist/vue-router.global.prod.js"></script>
<div id="app">
<!-- 导航:点它 = 换地址 -->
<router-link to="/">概览</router-link>
<router-link to="/products">商品</router-link>
<!-- 出口:当前 URL 对应的组件显示在这里 -->
<router-view></router-view>
</div>
<script>
const { createApp } = Vue;
const { createRouter, createWebHashHistory } = VueRouter;
// 1. 定义几个"页面组件"(就是普通对象,有 template 即可)
const Home = { template: `<h2>概览页</h2>` };
const Products = { template: `<h2>商品页</h2>` };
// 2. 配一张路由表:路径 -> 组件
const router = createRouter({
history: createWebHashHistory(), // 用 # 模式,本地双击文件就能跑
routes: [
{ path: '/', component: Home },
{ path: '/products', component: Products },
],
});
// 3. 把路由装进应用
createApp({}).use(router).mount('#app');
</script>
createRouter:创建路由实例,核心是history和routes两样。history:决定地址怎么"记账"。createWebHashHistory()用#/products这种带#的地址------好处是纯静态文件双击就能跑、不依赖服务器配置 ,所以今天用它。真实部署用createWebHistory()(干净地址/products,需后端配回退)。.use(router):把路由装进 Vue 应用,router-link/router-view这些全局组件才生效。
为什么今天用 hash?因为我们要保持"复制
index.html双击就能跑"的一贯体验。createWebHistory的地址更好看,但直接双击打开file://会刷新 404,所以放在真实项目(Vite 起本地服务)里才用。
三、路由表 routes:地址和组件的对照清单
路由表是一个数组,每一项把"一个 URL 路径"绑到"一个组件"。这是整个后台的地图。
js
const routes = [
{ path: '/', component: Home }, // 首页
{ path: '/products', component: ProductList }, // 商品列表
{ path: '/orders', component: OrderList }, // 订单列表
{ path: '/users', component: UserList }, // 用户列表
// 重定向:访问老地址自动跳到新地址
{ path: '/mall', redirect: '/products' },
// 兜底:以上都不匹配 -> 跳 404 页
{ path: '/:catchAll(.*)', component: NotFound },
];
path:匹配的地址。component:匹配后要渲染的组件。redirect:访问 A 自动改到 B,常用于"默认页""老链接迁移"。- 兜底路由
/:catchAll(.*):Vue Router 4 的写法,把所有没匹配到的地址收进一个 404 页------后台必备,避免用户乱输地址白屏。
后台的菜单其实就是一串固定的
path:侧边栏每一项对应路由表里的一条。菜单、地址、组件三者一一对齐,后台的"页面感"就是这么造出来的。
四、router-link 与 router-view:导航的"入口"和"出口"
<router-link>:渲染成一个<a>,点它换地址但不刷新整页 (内部帮你拦截了默认跳转)。等价于"受路由管制的<a>"。<router-view>:一个"出口",当前 URL 匹配到的组件就渲染在它所在的位置。
html
<!-- 导航条 -->
<nav>
<router-link to="/">概览</router-link>
<router-link to="/products">商品</router-link>
<router-link to="/orders">订单</router-link>
</nav>
<!-- 出口:右侧内容区,URL 变这里跟着换 -->
<main>
<router-view></router-view>
</main>
router-link 自带"当前项高亮"------匹配的链接会被加上 router-link-active / router-link-exact-active 类,直接给侧边菜单做选中态:
html
<style>
.router-link-active { color:#2f6fed; font-weight:bold; border-left:3px solid #2f6fed; }
</style>
<!-- 还可以自定义高亮类名 -->
<router-link to="/products" active-class="is-on">商品</router-link>
后台侧边栏菜单为什么点谁谁高亮?就是
router-link-active在起作用。你不用自己写if (当前页==='商品')去加类,路由自动加。
五、动态路由参数 /product/:id:一个详情页吃下所有商品
商品有 1000 个,不可能配 1000 条路由。用 :id 占位,一条路由通吃:
js
const routes = [
{ path: '/products', component: ProductList },
{ path: '/product/:id', component: ProductDetail }, // :id 是参数
];
在详情组件里用 useRoute() 取出这个 id,再去拿对应数据:
js
const { useRoute } = VueRouter;
const ProductDetail = {
setup() {
const route = useRoute(); // 读"当前路由"的信息
const id = route.params.id; // 地址里的 id(字符串)
const product = computed(() =>
allProducts.find(p => p.id === Number(id)) // 按 id 找数据
);
return { product };
},
template: `
<div v-if="product">
<h2>{{ product.name }}</h2>
<p>¥{{ product.price }}</p>
</div>
<p v-else>商品不存在</p>`,
};
useRoute():拿"当前这一条路由",读params(路径参数)、query(?a=1查询串)、meta等。useRouter():拿"路由控制器",用来主动跳转(下一节)。注意两个名字差一个r,别搞混。
从列表点某商品 → 地址变
/product/3→ 详情组件用route.params.id拿到3→ 渲染 3 号商品。列表页只负责跳,详情页只负责按 id 取数,这就是"动态路由"的分工。
六、编程式导航:用代码跳页(router.push / replace / back)
不是所有跳转都来自点链接------"保存成功后自动回列表""登录后跳首页""点返回"这些要在逻辑里跳。用 useRouter():
js
const { useRouter } = VueRouter;
setup() {
const router = useRouter();
function saveAndBack() {
saveOrder().then(() => {
router.push('/orders'); // 跳订单页(留下一条历史记录,能后退)
});
}
function toDetail(id) {
router.push('/product/' + id); // 也能拼带参数的路径
}
function goBack() { router.back(); } // 相当于浏览器"后退"
return { saveAndBack, toDetail, goBack };
}
| 方法 | 作用 | 能否后退 |
|---|---|---|
router.push('/x') |
前进到新地址 | 能(入栈) |
router.replace('/x') |
替换当前地址 | 不能(不占历史) |
router.back() |
后退一步 | --- |
pushvsreplace:登录成功跳首页用replace(不让用户一后退又退回登录页);正常跳页用push。"商品列表点进详情"既可以用<router-link>,也可以在卡片@click里router.push('/product/'+id)------后者更适合"整张卡片可点"的场景,今天小项目就是这么做的。
七、嵌套路由 children:搭后台的"侧边栏 + 内容区"布局
后台几乎都长这样:外层一层固定的"侧边栏 + 顶栏",中间那块内容随路由换。用 children + 嵌套 router-view 实现------外层布局只渲染一次,切换时只换内层出口。
js
const Layout = {
template: `
<div class="layout">
<aside>
<router-link to="/">概览</router-link>
<router-link to="/products">商品</router-link>
</aside>
<main><router-view></router-view></main> <!-- 内层出口 -->
</div>`,
};
const routes = [
{ path: '/', component: Layout, children: [ // Layout 是父,子路由塞进它的内层出口
{ path: '', component: Home }, // 匹配 /
{ path: 'products', component: ProductList }, // 匹配 /products(注意子 path 不带前导斜杠)
{ path: 'orders', component: OrderList },
]},
];
关键点:外层布局组件里有自己的
<router-view>,父路由命中后,子路由命中谁,就渲染在外层这个<router-view>里。这样切页面时侧边栏/顶栏不重建,只换中间,体验顺、性能好。真实后台的整框架就是这么搭的。
八、路由守卫 beforeEach:进页面前先"查票"(接 Day 14 权限)
"没登录不许进后台""没权限的菜单点进去弹回登录"------这类"进页面前的拦截"叫路由守卫 。全局守卫 beforeEach 在每次跳转前跑一次:
js
router.beforeEach((to, from) => {
const isLogin = localStorage.getItem('token'); // 有没有登录态
// 没登录且要去的是"需要登录的页" -> 强制弹回登录
if (to.meta.needAuth && !isLogin) {
return '/login'; // 返回一个路径 = 重定向
}
return true; // 放行
});
配合路由表里的 meta(给每条路由挂自定义信息)声明"这页要不要登录":
js
{ path: '/products', component: ProductList, meta: { needAuth: true, title: '商品管理' } }
今天先认识守卫的门长什么样:"跳转前 → 读目标路由的 meta → 判断放行还是重定向"。第 14 天"权限路由(按角色动态渲染菜单)"会把它和登录、RBAC 串成完整一套。今天先把"页面按 URL 跳转"这条主线跑通。
九、meta 标题 + 监听路由变化
meta 除了给守卫用,还常挂页面标题。配合 watch 路由,可以在每次跳页时同步浏览器标题、埋点:
js
const { watch } = Vue;
const { useRoute } = VueRouter;
setup() {
const route = useRoute();
watch(() => route.fullPath, () => { // 地址每次变就触发
document.title = route.meta.title || 'MiniMall 后台';
// trackPageView(route.path); // 顺便打个页面访问埋点
}, { immediate: true });
return {};
}
这正好接上第 20 天"SEO/给页面加 title、meta"------SPA 的标题不能写死在一个 HTML 上,必须跟着路由动态改,套路就是监听 route + 读 meta。
十、新手六个坑 + 调试
js
// 坑1:useRoute / useRouter 名字看花,功能完全不同
const route = useRoute(); // 只读:params / query / meta(当前是哪条路由)
const router = useRouter(); // 操作:push / replace / back(主动跳)
// 坑2:useRoute/useRouter 只能在 setup() 里用,写在外面拿不到上下文
// 坑3:createWebHistory 双击 file:// 打开刷新会 404 ------ 本地演示用 createWebHashHistory
// 坑4:子路由 path 不要写前导斜杠
{ path: '/', component: Layout, children: [
{ path: 'products', component: List } // ✓ 拼出 /products
// { path: '/products' ... } // ✗ 变成根级 /products,脱离 Layout
]}
// 坑5:params 拿到的永远是字符串,比较/运算前先 Number(id)
// 坑6:同一条路由只改参数(/product/1 -> /product/2)组件会被复用,
// 里面 setup 只跑一次,要响应"id 变了"用 watch(() => route.params.id, ...)
// 坑7:router-link 会自动拦截默认跳转,别在 @click 里再手动 location.href 硬跳,那会整页刷新
调试 :Vue Devtools 里有专门的 Router 面板 ,能看当前路由、完整路由表、匹配到的 params/query/meta、历史栈------排查"点了没跳转/跳错页/参数拿不到"必备。浏览器地址栏本身也是最直观的调试器:手动改 #/product/3 回车,看中间组件换没换。
十一、完整小项目:MiniMall 多页后台框架(可直接复制)
下面是一份完整可运行的 index.html:用 **Vue Router(hash 模式)**搭的后台框架------侧边栏菜单跳转 + 概览/商品/订单三页 + 点商品进 /product/:id 详情页,全部套在"侧边栏+内容区"的嵌套布局里,router-link 自动高亮当前菜单。复制保存为 index.html 双击打开即可。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiniMall 后台管理</title>
<!-- 国内可用 CDN 镜像 -->
<script src="https://gcore.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<script src="https://gcore.jsdelivr.net/npm/vue-router@4/dist/vue-router.global.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f6fa; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 200px; background: #1e2329; color: #fff;
padding: 20px 0; position: fixed; height: 100vh;
}
.sidebar h2 { padding: 0 20px 20px; border-bottom: 1px solid #2d333a; font-size: 18px; }
.sidebar nav { margin-top: 10px; }
.sidebar a {
display: block; padding: 14px 20px; color: #b0b8c1;
text-decoration: none; transition: all 0.2s;
}
.sidebar a:hover { background: #2d333a; color: #fff; }
.sidebar a.router-link-active { background: #0d6efd; color: #fff; }
.content { margin-left: 200px; flex: 1; padding: 30px; }
.card {
background: #fff; border-radius: 8px; padding: 20px;
margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.card h3 { margin-bottom: 15px; color: #333; }
.product-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px;
}
.product-card {
background: #fff; border-radius: 8px; overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.06); cursor: pointer; transition: transform 0.2s;
}
.product-card:hover { transform: translateY(-4px); }
.product-card img { width: 100%; height: 150px; object-fit: cover; }
.product-card .info { padding: 12px; }
.product-card .info h4 { font-size: 14px; color: #333; margin-bottom: 8px; }
.product-card .info .price { color: #e74c3c; font-weight: bold; font-size: 16px; }
.product-card .info .stock { font-size: 12px; color: #999; margin-top: 4px; }
.detail { display: flex; gap: 30px; }
.detail img { width: 300px; height: 300px; object-fit: cover; border-radius: 8px; }
.detail .info { flex: 1; }
.detail .info h2 { margin-bottom: 10px; }
.detail .info .price { font-size: 24px; color: #e74c3c; font-weight: bold; margin: 15px 0; }
.detail .info .desc { color: #666; line-height: 1.6; margin-bottom: 20px; }
.btn {
padding: 10px 24px; background: #0d6efd; color: #fff;
border: none; border-radius: 6px; cursor: pointer; font-size: 14px;
}
.btn:hover { background: #0b5ed7; }
.btn:disabled { background: #ccc; cursor: not-allowed; }
.not-found { text-align: center; padding: 60px 0; color: #999; }
.back-link { color: #0d6efd; text-decoration: none; display: inline-block; margin-top: 15px; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 10px 0; }
td { padding: 10px 0; }
tr { border-bottom: 1px solid #eee; }
</style>
</head>
<body>
<div id="app"></div>
<script>
const { createApp, ref, computed } = Vue;
const { createRouter, createWebHashHistory, useRouter, useRoute } = VueRouter;
// ====== 模拟数据 ======
const products = [
{ id: 1, name: '机械键盘 RGB', price: 299, stock: 50, category: '数码', desc: '87键紧凑布局,青轴/红轴可选,全键无冲。' },
{ id: 2, name: '无线鼠标 静音版', price: 89, stock: 120, category: '数码', desc: '2.4G无线连接,人体工学设计,静音微动。' },
{ id: 3, name: '纯棉T恤 基础款', price: 59, stock: 200, category: '服饰', desc: '100%精梳棉,亲肤透气,多色可选。' },
{ id: 4, name: '帆布双肩包', price: 129, stock: 80, category: '箱包', desc: '大容量多隔层,防水面料,可装15寸笔记本。' },
{ id: 5, name: '保温杯 500ml', price: 69, stock: 0, category: '生活', desc: '304不锈钢内胆,6小时保温/保冷。' },
{ id: 6, name: '蓝牙音箱 便携', price: 159, stock: 45, category: '数码', desc: 'IPX6防水,12小时续航,双喇叭立体声。' },
];
const orders = [
{ id: 'ORD-1001', product: '机械键盘 RGB', amount: 299, status: '已发货' },
{ id: 'ORD-1002', product: '纯棉T恤 基础款', amount: 118, status: '待发货' },
{ id: 'ORD-1003', product: '保温杯 500ml', amount: 138, status: '已取消' },
{ id: 'ORD-1004', product: '蓝牙音箱 便携', amount: 159, status: '已发货' },
];
// ====== 页面组件(全部定义在 routes 之前)======
const Dashboard = {
template: `
<div>
<h1 style="margin-bottom:20px;">📊 概览</h1>
<div class="card">
<h3>今日数据</h3>
<div style="display:flex;gap:20px;flex-wrap:wrap;margin-top:15px;">
<div style="flex:1;min-width:150px;background:#f0f7ff;padding:20px;border-radius:8px;text-align:center;">
<div style="font-size:28px;font-weight:bold;color:#0d6efd;">{{ totalProducts }}</div>
<div style="color:#666;margin-top:5px;">商品总数</div>
</div>
<div style="flex:1;min-width:150px;background:#f0fff4;padding:20px;border-radius:8px;text-align:center;">
<div style="font-size:28px;font-weight:bold;color:#28a745;">{{ inStockCount }}</div>
<div style="color:#666;margin-top:5px;">有货商品</div>
</div>
<div style="flex:1;min-width:150px;background:#fff5f5;padding:20px;border-radius:8px;text-align:center;">
<div style="font-size:28px;font-weight:bold;color:#e74c3c;">{{ outOfStockCount }}</div>
<div style="color:#666;margin-top:5px;">已售罄</div>
</div>
<div style="flex:1;min-width:150px;background:#fff8f0;padding:20px;border-radius:8px;text-align:center;">
<div style="font-size:28px;font-weight:bold;color:#f39c12;">¥{{ totalPrice }}</div>
<div style="color:#666;margin-top:5px;">库存总价值</div>
</div>
</div>
</div>
<div class="card">
<h3>分类统计</h3>
<table style="width:100%;margin-top:15px;">
<tr style="border-bottom:2px solid #eee;">
<th>分类</th><th style="text-align:center;">商品数</th><th style="text-align:center;">库存</th>
</tr>
<tr v-for="c in categoryStats" :key="c.name">
<td>{{ c.name }}</td>
<td style="text-align:center;">{{ c.count }}</td>
<td style="text-align:center;">{{ c.stock }}</td>
</tr>
</table>
</div>
</div>`,
setup() {
const totalProducts = products.length;
const inStockCount = products.filter(p => p.stock > 0).length;
const outOfStockCount= products.length - inStockCount;
const totalPrice = products.reduce((s, p) => s + p.price * p.stock, 0);
const categoryStats = computed(() => {
const map = {};
products.forEach(p => {
if (!map[p.category]) map[p.category] = { name: p.category, count: 0, stock: 0 };
map[p.category].count++;
map[p.category].stock += p.stock;
});
return Object.values(map);
});
return { totalProducts, inStockCount, outOfStockCount, totalPrice, categoryStats };
}
};
const ProductList = {
template: `
<div>
<h1 style="margin-bottom:20px;">🛍️ 商品管理</h1>
<div class="card">
<input v-model="search" placeholder="搜索商品名或分类..."
style="width:100%;padding:10px 14px;border:1px solid #ddd;border-radius:6px;font-size:14px;outline:none;" />
</div>
<div class="product-grid">
<div class="product-card" v-for="p in filtered" :key="p.id" @click="goDetail(p.id)">
<img :src="'https://picsum.photos/seed/' + p.id + '/400/300'" :alt="p.name" />
<div class="info">
<h4>{{ p.name }}</h4>
<div class="price">¥{{ p.price }}</div>
<div class="stock">{{ p.stock > 0 ? '库存 ' + p.stock : '已售罄' }}</div>
</div>
</div>
<p v-if="filtered.length === 0" style="color:#999;text-align:center;padding:40px;grid-column:1/-1;">
没有找到匹配的商品
</p>
</div>
</div>`,
setup() {
const search = ref('');
const filtered = computed(() => {
const q = search.value.toLowerCase();
return products.filter(p =>
p.name.toLowerCase().includes(q) || p.category.toLowerCase().includes(q)
);
});
const router = useRouter();
const goDetail = id => router.push('/product/' + id);
return { search, filtered, goDetail };
}
};
const ProductDetail = {
template: `
<div>
<h1 style="margin-bottom:20px;">📦 商品详情</h1>
<div class="card detail" v-if="product">
<img :src="'https://picsum.photos/seed/' + product.id + '/400/300'" :alt="product.name" />
<div class="info">
<h2>{{ product.name }}</h2>
<div style="color:#999;">分类:{{ product.category }}</div>
<div class="price">¥{{ product.price }}</div>
<div class="desc">{{ product.desc }}</div>
<div style="margin-bottom:20px;color:#999;">库存:{{ product.stock > 0 ? product.stock : '已售罄' }}</div>
<button class="btn" :disabled="product.stock === 0"
@click="added = true; setTimeout(() => added = false, 1500);">
{{ product.stock > 0 ? '加入购物车' : '已售罄' }}
</button>
<span v-if="added" style="color:#28a745;margin-left:15px;">✓ 已加入</span>
</div>
</div>
<div class="not-found" v-else>
<h2>商品不存在</h2>
<router-link to="/products" class="back-link">← 返回商品列表</router-link>
</div>
</div>`,
setup() {
const route = useRoute();
const product = computed(() => products.find(p => p.id === Number(route.params.id)));
const added = ref(false);
return { product, added };
}
};
const OrdersPage = {
template: `
<div>
<h1 style="margin-bottom:20px;">📋 订单管理</h1>
<div class="card">
<table>
<tr style="border-bottom:2px solid #eee;">
<th>订单号</th><th style="text-align:center;">商品</th>
<th style="text-align:center;">金额</th><th style="text-align:center;">状态</th>
</tr>
<tr v-for="o in orders" :key="o.id">
<td>{{ o.id }}</td>
<td style="text-align:center;">{{ o.product }}</td>
<td style="text-align:center;">¥{{ o.amount }}</td>
<td style="text-align:center;">
<span :style="{
padding:'4px 12px',borderRadius:'12px',fontSize:'12px',color:'#fff',
background: o.status==='已发货'?'#28a745' : o.status==='待发货'?'#f39c12' : '#999'
}">{{ o.status }}</span>
</td>
</tr>
</table>
</div>
</div>`
};
const NotFound = {
template: `
<div class="not-found">
<h2>🚫 404 - 页面不存在</h2>
<router-link to="/dashboard" class="back-link">← 回到概览</router-link>
</div>`
};
// ====== 布局组件(必须在 routes 之前定义)======
const Layout = {
template: `
<div class="layout">
<aside class="sidebar">
<h2>MiniMall 后台</h2>
<nav>
<router-link to="/dashboard">概览</router-link>
<router-link to="/products">商品</router-link>
<router-link to="/orders">订单</router-link>
</nav>
</aside>
<main class="content">
<router-view></router-view>
</main>
</div>`
};
// ====== 路由配置 ======
const routes = [
{
path: '/',
component: Layout,
children: [
{ path: '', redirect: '/dashboard' },
{ path: 'dashboard', component: Dashboard },
{ path: 'products', component: ProductList },
{ path: 'product/:id', component: ProductDetail },
{ path: 'orders', component: OrdersPage },
{ path: ':pathMatch(.*)*', component: NotFound },
]
}
];
const router = createRouter({
history: createWebHashHistory(),
routes
});
// ====== 挂载 ======
const app = createApp({ template: '<router-view></router-view>' });
app.use(router);
app.mount('#app');
</script>
</body>
</html>
跑起来观察三件事:① 点左侧"商品/订单"------地址栏在
#/products、#/orders间变,中间内容换但侧边栏不重建、页面不刷新;② 点任一商品卡------地址变#/product/3,详情页从route.params.id拿到 3 号商品;③ 当前菜单项自动高亮(active-class)。这三件正是所有后台框架最核心的日常。
本篇覆盖要点清单
- 路由的本质:一张「URL → 组件」对照表,地址变就换组件、不刷新整页
- 零构建跑路由:CDN 引
vue-router+createRouter+createWebHashHistory - 路由表
routes:path/component/redirect/兜底/:catchAll(.*) - 导航两件套:
<router-link>(入口)+<router-view>(出口),自动高亮router-link-active - 动态路由
/product/:id+useRoute().params(值是字符串,用前Number) - 编程式导航:
useRouter().push/replace/back,push 能后退、replace 不能 - 嵌套路由
children+ 嵌套router-view:搭"侧边栏+内容区"后台布局 - 路由守卫
beforeEach+meta:进页前拦截(接 Day 14 权限路由) - 监听
route动态改标题(接 Day 20 SEO) - 六个坑:
useRoute/useRouter别混、只在setup里用、hash vs history、子 path 不带斜杠、params 是字符串、参数变化组件复用需 watch
动手任务
- 给概览页加一个"最近订单"表格(提示:复用
OrderList里的ORDERS,取ORDERS.slice(0,3))。 - 让详情页的"加入购物车"按钮把商品
id存进localStorage,再在概览页顶部显示"购物车件数"(提示:跨页面共享状态------你会自然发现"路由管跳转、但跨页共享数据还缺个东西",那正是明天的 Pinia)。 - 打开守卫:给
/orders挂meta:{needAuth:true},在beforeEach里判断localStorage.token不存在就return '/',体验"没登录被弹回"。
明天预告(Day 10)
明天讲 Pinia 状态管理 :今天你已经踩到痛点------购物车想在概览页、商品页、详情页都能读写,用 localStorage 传太笨、组件间传来传去更乱。Pinia 就是"放全局共享数据的地方":一处定义,处处读写,专门管全局用户信息、菜单、购物车状态。学完今天的路由 + 明天的 Pinia,一个能跳转、能共享状态的后台框架就成型了。