Vue Router 实战教程
Vue Router 负责把浏览器 URL、权限检查、布局组件和具体业务页面连接起来。
讲解:
- Router 如何在 Vue 应用中注册;
- 路由表中的
path、component、children、name和meta; useRouter()与useRoute();router.push()、query和params;- 全局路由守卫;
- Layout 与嵌套路由;
- 两层
router-view; keep-alive与页面缓存;onMounted()、nextTick()和页面销毁;/bof/heat/twin的完整真实调用链;- Three.js 数字孪生页面的生产环境注意事项。
1、Vue 应用入口 main.ts
Vue 项目一般从 main.ts 或 main.js 启动。
典型代码如下:
typescript
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import store from './store';
import './permission';
import './styles/index.scss';
const app = createApp(App);
app.use(router);
app.use(store);
app.mount('#app');
这段代码可以拆成五步。
8.1 导入 Vue 的 createApp
typescript
import {
createApp
} from 'vue';
createApp() 用来创建 Vue 应用实例。
8.2 导入根组件
typescript
import App from './App.vue';
App.vue 是整个组件树的根组件。
8.3 导入 Router 和 Store
typescript
import router from './router';
import store from './store';
这里的:
typescript
'./router'
通常会自动解析到:
text
src/router/index.ts
这是模块目录解析的常见写法。
8.4 安装插件
typescript
app.use(router);
app.use(store);
app.use() 用于把插件安装到 Vue 应用。
安装 Router 后,项目才能正常使用:
typescript
router.push();
useRouter();
useRoute();
<router-view />
安装 Store 后,组件才能使用全局状态管理。
8.5 挂载应用
typescript
app.mount('#app');
它把 Vue 应用挂载到 HTML 中的:
html
<div id="app"></div>
最终关系是:
text
index.html
└─ #app
└─ Vue 应用
└─ App.vue
2、Vue Router 的基本组成
Vue Router 负责根据浏览器地址决定显示哪个 Vue 组件。
一个最小 Router 可以写成:
typescript
import {
createRouter,
createWebHistory
} from 'vue-router';
const routes = [
{
path: '/',
component: () =>
import('@/views/home/index.vue')
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
export default router;
核心部分包括:
text
createRouter
history
routes
path
component
9.1 path
typescript
path: '/bof/heat/twin'
表示要匹配的 URL 路径。
下面的 URL 都会匹配该路径:
text
/bof/heat/twin
/bof/heat/twin?heat_id=001
/bof/heat/twin?mode=realtime
因为查询参数不属于 path 本身。
9.2 component
typescript
component: Layout
或者:
typescript
component: () =>
import('@/views/bof/heat/twin.vue')
它表示匹配路由后应该渲染哪个组件。
9.3 name
typescript
name: 'BofHeatTwin'
路由名称可以用于:
- 按名称跳转;
- 判断当前页面模式;
- 缓存组件;
- 调试路由;
- 避免在业务代码中重复硬编码路径。
按名称跳转:
typescript
router.push({
name: 'BofHeatTwin',
query: {
heat_id: heatId
}
});
9.4 meta
typescript
meta: {
title: '3D 数字孪生大屏',
noCache: true,
fullScreen: true
}
meta 是路由的附加信息。
Router 本身不会自动理解所有自定义字段,它们通常由项目代码读取。
例如:
typescript
const isFullScreenRoute = computed(() => {
return route.meta.fullScreen === true;
});
这表示项目约定:
text
fullScreen = true
→ 使用全屏布局
同理:
text
noCache = true
→ 不缓存页面
activeMenu = '/bof/heat'
→ 左侧菜单保持炉次管理高亮
breadcrumb = false
→ 不显示面包屑
3、在组件中使用 Router
Vue 3 组合式 API 中常用:
typescript
import {
useRoute,
useRouter
} from 'vue-router';
然后:
typescript
const route = useRoute();
const router = useRouter();
这两个对象的作用不同。
10.1 useRouter():控制跳转
typescript
const router = useRouter();
它获得 Router 实例,主要负责主动导航。
例如:
typescript
router.push('/bof/heat/twin');
或者:
typescript
router.push({
path: '/bof/heat/twin',
query: {
heat_id: heatId
}
});
还可以返回上一页:
typescript
router.back();
替换当前历史记录:
typescript
router.replace({
path: '/login'
});
区别可以简单理解为:
text
router.push()
→ 新增一条浏览器历史记录
router.replace()
→ 替换当前历史记录
router.back()
→ 返回上一条历史记录
10.2 useRoute():读取当前路由
typescript
const route = useRoute();
它表示当前已经匹配到的路由信息。
常见读取方式:
typescript
route.path
route.name
route.query
route.params
route.meta
例如:
typescript
const heatId = String(
route.query.heat_id || ''
);
或者:
typescript
const isImmersiveMode = computed(() => {
return route.name ===
'BofHeatTwinImmersive';
});
可以记成一句话:
text
router 负责去哪里
route 负责现在在哪里
4、使用 query 传递页面参数
数字孪生入口使用:
typescript
router.push({
path: '/bof/heat/twin',
query: {
heat_id: heatId
}
});
假设:
typescript
heatId = 'BOF-20260803-001';
最终地址是:
text
/bof/heat/twin?heat_id=BOF-20260803-001
目标页面读取:
typescript
const route = useRoute();
const heatId = String(
route.query.heat_id || ''
);
11.1 多个查询参数
typescript
router.push({
path: '/bof/heat/twin',
query: {
heat_id: heatId,
mode: 'realtime',
full_process: '1'
}
});
最终地址类似:
text
/bof/heat/twin?heat_id=BOF-001&mode=realtime&full_process=1
读取:
typescript
const heatId = String(
route.query.heat_id || ''
);
const mode = String(
route.query.mode || ''
);
const isFullProcess =
route.query.full_process === '1';
需要注意:
URL 中的查询参数本质上是字符串表达,不要直接假设它是布尔值或数字。
例如:
typescript
const page = Number(
route.query.page || 1
);
11.2 query 与 params 的区别
查询参数形式:
text
/bof/heat/twin?heat_id=001
路由参数形式:
text
/bof/heat/001/twin
对应的路由定义可以写成:
typescript
{
path: '/bof/heat/:heatId/twin',
component: () =>
import('@/views/bof/heat/twin.vue')
}
读取:
typescript
const heatId = String(
route.params.heatId || ''
);
对比:
| 类型 | URL 示例 | 读取方式 | 常见用途 |
|---|---|---|---|
query |
?heat_id=001 |
route.query.heat_id |
筛选、模式、可选参数 |
params |
/heat/001 |
route.params.heatId |
资源 ID、路径核心部分 |
当前数字孪生项目使用的是 query。
5、路由守卫如何检查登录
当页面执行:
typescript
router.push({
path: '/bof/heat/twin'
});
Router 不一定立刻显示页面。
项目可以通过全局前置守卫拦截跳转:
typescript
router.beforeEach(
async (to, from, next) => {
if (getToken()) {
next();
} else {
next(
`/login?redirect=${to.fullPath}`
);
}
}
);
参数含义:
text
to
→ 即将进入的路由
from
→ 当前准备离开的路由
next
→ 决定导航下一步如何处理
12.1 允许跳转
typescript
next();
表示继续进入目标页面。
12.2 跳转到登录页
typescript
next(
`/login?redirect=${to.fullPath}`
);
表示当前访问被拦截,改为进入登录页。
登录地址可能是:
text
/login?redirect=/bof/heat/twin
登录成功后,系统可以读取 redirect,再返回原目标页面。
12.3 为什么 permission.ts 使用副作用导入
入口文件中:
typescript
import './permission';
没有接收导出值。
它的目的不是获得函数,而是执行文件中的:
typescript
router.beforeEach(...);
因此真实流程是:
text
main.ts 被执行
→ import './permission'
→ permission.ts 被执行
→ beforeEach 守卫被注册
→ 后续每次路由跳转都经过守卫
6、嵌套路由与 Layout
数字孪生路由可以写成:
typescript
{
path: '/bof/heat/twin',
component: Layout,
hidden: true,
children: [
{
path: '',
component: () =>
import(
'@/views/bof/heat/twin.vue'
),
name: 'BofHeatTwin',
meta: {
title: '3D 数字孪生大屏',
activeMenu: '/bof/heat',
noCache: true,
breadcrumb: false,
fullScreen: true
}
}
]
}
这里不是直接把 twin.vue 作为最外层组件,而是:
text
父组件:Layout
子组件:twin.vue
13.1 为什么子路由的 path 是空字符串
父路由:
typescript
path: '/bof/heat/twin'
子路由:
typescript
path: ''
合并后仍是:
text
/bof/heat/twin
如果子路径写成:
typescript
path: 'detail'
最终地址会变成:
text
/bof/heat/twin/detail
13.2 Layout 的作用
Layout 负责提供系统公共结构,例如:
- 左侧菜单;
- 顶部导航栏;
- 标签页;
- 主内容区域;
- 全屏页面切换;
- 面包屑;
- 页面过渡动画。
数字孪生页面设置:
typescript
meta: {
fullScreen: true
}
Layout 可以据此判断:
typescript
const isFullScreenRoute = computed(() => {
return route.meta.fullScreen === true;
});
模板:
vue
<template v-if="isFullScreenRoute">
<app-main />
</template>
<template v-else>
<navbar />
<tags-view />
<app-main />
</template>
因此:
text
普通后台页面
→ Navbar + TagsView + AppMain
数字孪生全屏页面
→ 只保留 AppMain
7、router-view 如何显示页面
router-view 是 Vue Router 提供的路由出口。
可以把它理解为:
Router 把当前匹配的组件放到这里。
根组件 App.vue 中通常有:
vue
<template>
<router-view />
</template>
它负责显示最外层路由组件。
数字孪生路由的最外层组件是:
text
Layout
因此第一层关系是:
text
App.vue
└─ router-view
└─ Layout
Layout 内部的 AppMain.vue 中还有一层:
vue
<router-view v-slot="{ Component, route }">
<component
:is="Component"
:key="route.path"
/>
</router-view>
它负责显示子路由组件:
text
twin.vue
完整组件树是:
text
App.vue
└─ 第一层 router-view
└─ Layout
└─ AppMain
└─ 第二层 router-view
└─ twin.vue
这就是嵌套路由必须有多层 router-view 的原因。
8、动态组件 <component :is="...">
在下面的代码中:
vue
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
router-view 通过插槽把当前匹配组件交给变量:
text
Component
然后:
vue
<component :is="Component" />
动态渲染它。
当当前子路由是数字孪生页面时,可以把它理解为:
vue
<twin />
当进入其他页面时,Component 会换成另一个页面组件。
9、keep-alive 与 noCache
项目中常见:
vue
<keep-alive
:include="cachedViews"
>
<component
:is="Component"
:key="route.path"
/>
</keep-alive>
keep-alive 用于缓存组件实例。
被缓存的页面离开后不会立即完全销毁,再次回来时可以保留:
- 表单输入;
- 滚动位置;
- 查询条件;
- 局部组件状态。
但 Three.js 页面是否应该缓存需要谨慎考虑。
数字孪生路由设置:
typescript
noCache: true
通常表示该页面不进入缓存名单。
原因是三维页面常常持有:
WebGLRenderer;Scene;Camera;OrbitControls;- 动画循环;
- GPU 纹理;
- 几何体和材质;
- Resize 事件;
- 定时器;
- WebSocket 连接。
如果缓存和销毁策略不清楚,容易出现:
- 多个动画循环同时运行;
- 重复监听窗口事件;
- WebGL 上下文长期占用;
- GPU 内存无法释放;
- 再次进入时场景状态错乱。
10、同一个 .vue 文件支持两种路由模式
项目中可以定义两个路由:
typescript
{
path: '/bof/heat/twin',
component: Layout,
children: [
{
path: '',
name: 'BofHeatTwin',
component: () =>
import(
'@/views/bof/heat/twin.vue'
)
}
]
}
以及:
typescript
{
path: '/bof/heat/twin/immersive',
component: Layout,
children: [
{
path: '',
name: 'BofHeatTwinImmersive',
component: () =>
import(
'@/views/bof/heat/twin.vue'
)
}
]
}
两条路由加载同一个:
text
twin.vue
组件内部通过路由名称判断模式:
typescript
const route = useRoute();
const isImmersiveMode = computed(() => {
return route.name ===
'BofHeatTwinImmersive';
});
然后模板可以条件渲染:
vue
<template>
<section
:class="{
immersive: isImmersiveMode
}"
>
<TwinHud
v-if="!isImmersiveMode"
/>
<div ref="twinCanvasRef"></div>
</section>
</template>
这种模式适合:
text
同一套数据和三维逻辑
+ 不同页面外观
+ 不同控制面板
+ 不同入口
但如果两种模式后续业务差异越来越大,应考虑把公共逻辑提取成组合式函数,而不是让一个 .vue 文件无限增长。
例如:
text
composables/
└─ useTwinScene.ts
11、组件创建后为什么执行 onMounted()
在 Vue 3 中:
typescript
onMounted(() => {
// 组件挂载完成后的逻辑
});
它会在组件的 DOM 已经完成初次渲染后执行。
数字孪生页面可能写成:
typescript
onMounted(() => {
heatId.value = String(
route.query.heat_id || ''
);
if (!heatId.value) {
goBack();
return;
}
window.addEventListener(
'resize',
handleResize
);
startHudTicker();
nextTick(() => {
initTwinScene();
updateTwinScene();
animateTwinScene();
});
void loadTwinData();
});
这段逻辑可以分成九步。
18.1 读取当前炉次
typescript
heatId.value = String(
route.query.heat_id || ''
);
从 URL 中取得:
text
heat_id
18.2 校验必要参数
typescript
if (!heatId.value) {
goBack();
return;
}
数字孪生页面依赖炉次 ID。
如果没有炉次 ID,就无法知道应该加载哪一炉的数据。
18.3 注册浏览器事件
typescript
window.addEventListener(
'resize',
handleResize
);
窗口尺寸变化时,需要更新:
- Canvas 宽高;
- Camera 宽高比;
- Camera 投影矩阵;
- Renderer 输出尺寸;
- HUD 布局。
18.4 启动 HUD 定时任务
typescript
startHudTicker();
可能用于更新:
- 当前时间;
- 数据新鲜度;
- 运行时长;
- 倒计时;
- 实时状态标签。
18.5 使用 nextTick()
typescript
nextTick(() => {
initTwinScene();
});
nextTick() 表示等待 Vue 完成本轮 DOM 更新。
Three.js Renderer 一般需要挂载到:
vue
<div ref="twinCanvasRef"></div>
组件中:
typescript
const twinCanvasRef =
ref<HTMLDivElement | null>(null);
初始化时:
typescript
const container =
twinCanvasRef.value;
if (!container) {
return;
}
container.appendChild(
renderer.domElement
);
如果 DOM 还没有完成创建:
typescript
twinCanvasRef.value
可能为 null。
所以 Three.js 初始化常常安排在:
text
onMounted
→ nextTick
→ initTwinScene
18.6 初始化三维场景
typescript
initTwinScene();
通常负责创建:
text
Scene
Camera
Renderer
OrbitControls
Light
Converter Model
Environment
Post-processing
18.7 首次同步场景状态
typescript
updateTwinScene();
在后端数据尚未返回时,可以先用默认值或初始状态渲染。
18.8 启动动画循环
typescript
animateTwinScene();
典型写法:
typescript
let animationFrameId = 0;
const animateTwinScene = () => {
animationFrameId =
requestAnimationFrame(
animateTwinScene
);
controls?.update();
renderer?.render(
scene,
camera
);
};
18.9 异步加载业务数据
typescript
void loadTwinData();
void 在这里常用于明确表示:
这个异步函数会启动,但当前代码不等待其返回值。
函数内部通常仍应处理异常:
typescript
const loadTwinData = async () => {
try {
const data = await getHeatDetail(
heatId.value
);
applyTwinData(data);
} catch (error) {
console.error(
'加载数字孪生数据失败',
error
);
}
};
12、离开 Three.js 页面时必须清理资源
只有 onMounted() 不够。
Three.js 页面还需要在组件离开时清理资源:
typescript
import {
onBeforeUnmount
} from 'vue';
示例:
typescript
onBeforeUnmount(() => {
window.removeEventListener(
'resize',
handleResize
);
stopHudTicker();
cancelAnimationFrame(
animationFrameId
);
controls?.dispose();
renderer?.dispose();
scene.traverse((object) => {
if (
object instanceof THREE.Mesh
) {
object.geometry?.dispose();
const materials =
Array.isArray(object.material)
? object.material
: [object.material];
materials.forEach((material) => {
material.dispose();
});
}
});
renderer?.domElement.remove();
});
这一部分非常重要。
如果只初始化、不销毁,多次进入页面后可能出现:
text
第一次进入
→ 1 个动画循环
第二次进入
→ 2 个动画循环
第三次进入
→ 3 个动画循环
最终表现为:
- 页面越来越卡;
- CPU 和 GPU 占用升高;
- resize 回调重复执行;
- 粒子和动画速度异常;
- 浏览器提示 WebGL 上下文过多。
13、数字孪生页面的完整真实调用链
根据本文中的项目结构,完整流程是:
text
src/views/bof/heat/index.vue
↓
用户点击数字孪生入口
↓
goTwin(heatId)
↓
router.push({
path: '/bof/heat/twin',
query: {
heat_id: heatId
}
})
↓
src/permission.ts
↓
router.beforeEach()
↓
检查 Token、用户信息和权限
↓
next()
↓
src/router/index.ts
↓
匹配 /bof/heat/twin
↓
加载父组件 Layout
↓
src/App.vue
↓
第一层 router-view 显示 Layout
↓
src/layout/index.vue
↓
读取 route.meta.fullScreen
↓
隐藏普通后台导航结构
↓
只显示 AppMain
↓
src/layout/components/AppMain.vue
↓
第二层 router-view 匹配子路由
↓
动态导入 twin.vue
↓
创建 twin.vue 组件实例
↓
执行 setup 代码
↓
渲染 template
↓
执行 onMounted()
↓
读取 route.query.heat_id
↓
nextTick()
↓
initTwinScene()
↓
updateTwinScene()
↓
animateTwinScene()
↓
loadTwinData()
↓
后端数据返回
↓
更新响应式状态
↓
同步 Three.js 场景和页面面板
14、使用 Mermaid 绘制页面加载流程
支持 Mermaid 的博客编辑器可以使用:
#mermaid-svg-pmNQ7PvO27ZfuL4e{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pmNQ7PvO27ZfuL4e .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pmNQ7PvO27ZfuL4e .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pmNQ7PvO27ZfuL4e .error-icon{fill:#552222;}#mermaid-svg-pmNQ7PvO27ZfuL4e .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pmNQ7PvO27ZfuL4e .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pmNQ7PvO27ZfuL4e .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pmNQ7PvO27ZfuL4e .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pmNQ7PvO27ZfuL4e .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pmNQ7PvO27ZfuL4e .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pmNQ7PvO27ZfuL4e .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pmNQ7PvO27ZfuL4e .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pmNQ7PvO27ZfuL4e .marker.cross{stroke:#333333;}#mermaid-svg-pmNQ7PvO27ZfuL4e svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pmNQ7PvO27ZfuL4e p{margin:0;}#mermaid-svg-pmNQ7PvO27ZfuL4e .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pmNQ7PvO27ZfuL4e .cluster-label text{fill:#333;}#mermaid-svg-pmNQ7PvO27ZfuL4e .cluster-label span{color:#333;}#mermaid-svg-pmNQ7PvO27ZfuL4e .cluster-label span p{background-color:transparent;}#mermaid-svg-pmNQ7PvO27ZfuL4e .label text,#mermaid-svg-pmNQ7PvO27ZfuL4e span{fill:#333;color:#333;}#mermaid-svg-pmNQ7PvO27ZfuL4e .node rect,#mermaid-svg-pmNQ7PvO27ZfuL4e .node circle,#mermaid-svg-pmNQ7PvO27ZfuL4e .node ellipse,#mermaid-svg-pmNQ7PvO27ZfuL4e .node polygon,#mermaid-svg-pmNQ7PvO27ZfuL4e .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pmNQ7PvO27ZfuL4e .rough-node .label text,#mermaid-svg-pmNQ7PvO27ZfuL4e .node .label text,#mermaid-svg-pmNQ7PvO27ZfuL4e .image-shape .label,#mermaid-svg-pmNQ7PvO27ZfuL4e .icon-shape .label{text-anchor:middle;}#mermaid-svg-pmNQ7PvO27ZfuL4e .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pmNQ7PvO27ZfuL4e .rough-node .label,#mermaid-svg-pmNQ7PvO27ZfuL4e .node .label,#mermaid-svg-pmNQ7PvO27ZfuL4e .image-shape .label,#mermaid-svg-pmNQ7PvO27ZfuL4e .icon-shape .label{text-align:center;}#mermaid-svg-pmNQ7PvO27ZfuL4e .node.clickable{cursor:pointer;}#mermaid-svg-pmNQ7PvO27ZfuL4e .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pmNQ7PvO27ZfuL4e .arrowheadPath{fill:#333333;}#mermaid-svg-pmNQ7PvO27ZfuL4e .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pmNQ7PvO27ZfuL4e .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pmNQ7PvO27ZfuL4e .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pmNQ7PvO27ZfuL4e .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pmNQ7PvO27ZfuL4e .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pmNQ7PvO27ZfuL4e .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pmNQ7PvO27ZfuL4e .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pmNQ7PvO27ZfuL4e .cluster text{fill:#333;}#mermaid-svg-pmNQ7PvO27ZfuL4e .cluster span{color:#333;}#mermaid-svg-pmNQ7PvO27ZfuL4e div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pmNQ7PvO27ZfuL4e .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pmNQ7PvO27ZfuL4e rect.text{fill:none;stroke-width:0;}#mermaid-svg-pmNQ7PvO27ZfuL4e .icon-shape,#mermaid-svg-pmNQ7PvO27ZfuL4e .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pmNQ7PvO27ZfuL4e .icon-shape p,#mermaid-svg-pmNQ7PvO27ZfuL4e .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pmNQ7PvO27ZfuL4e .icon-shape .label rect,#mermaid-svg-pmNQ7PvO27ZfuL4e .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pmNQ7PvO27ZfuL4e .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pmNQ7PvO27ZfuL4e .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pmNQ7PvO27ZfuL4e :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
用户点击数字孪生
router.push
全局路由守卫
是否允许访问
跳转登录页
匹配 /bof/heat/twin
加载 Layout
App.vue 第一层 router-view
Layout 判断 fullScreen
加载 AppMain
第二层 router-view
动态导入 twin.vue
twin.vue onMounted
读取 heat_id
nextTick
初始化 Three.js
启动动画循环
加载业务数据
更新页面和三维状态
15、页面跳转示例
22.1 在按钮点击时跳转
vue
<script setup lang="ts">
import {
useRouter
} from 'vue-router';
const router = useRouter();
const goTwin = (
heatId: string
) => {
router.push({
path: '/bof/heat/twin',
query: {
heat_id: heatId
}
});
};
</script>
<template>
<button
@click="goTwin('BOF-001')"
>
打开数字孪生
</button>
</template>
22.2 按路由名称跳转
typescript
router.push({
name: 'BofHeatTwin',
query: {
heat_id: heatId
}
});
优点是路由路径调整后,业务代码不一定需要同步修改路径字符串。
22.3 使用 <router-link>
vue
<router-link
:to="{
name: 'BofHeatTwin',
query: {
heat_id: heatId
}
}"
>
查看数字孪生
</router-link>
适合普通链接式导航。
router.push() 更适合在函数、校验或复杂交互之后跳转。
16、监听路由参数变化
有些项目会在同一个组件实例中切换查询参数,例如:
text
/bof/heat/twin?heat_id=001
↓
/bof/heat/twin?heat_id=002
如果组件没有重新创建,onMounted() 不会再次执行。
这时可以监听:
typescript
import {
watch
} from 'vue';
watch(
() => route.query.heat_id,
async (newHeatId) => {
if (!newHeatId) {
return;
}
heatId.value =
String(newHeatId);
await loadTwinData();
updateTwinScene();
}
);
也可以让路由组件的 key 包含完整地址:
vue
<component
:is="Component"
:key="route.fullPath"
/>
但这会强制重建组件,是否适合要根据项目状态管理和性能决定。
17、常见 Router 错误
25.1 只写跳转,不安装 Router
入口缺少:
typescript
app.use(router);
此时 Router 相关能力无法正常工作。
25.2 把 route 和 router 混淆
错误:
typescript
route.push('/home');
正确:
typescript
router.push('/home');
读取当前参数:
typescript
route.query.heat_id;
记忆方式:
text
router:动作
route:状态
25.3 页面依赖参数,但入口没有传参
目标页面要求:
typescript
route.query.heat_id
入口却只写:
typescript
router.push('/bof/heat/twin');
这会导致页面拿不到炉次 ID。
如果页面中有:
typescript
if (!heatId.value) {
goBack();
return;
}
用户就会立即被退回。
因此所有入口必须保持参数契约一致:
typescript
router.push({
path: '/bof/heat/twin',
query: {
heat_id: heatId
}
});
25.4 父路由有 children,但父组件没有 router-view
如果定义:
typescript
{
component: Layout,
children: [
{
component: TwinPage
}
]
}
那么 Layout 的组件树中必须存在子路由出口:
vue
<router-view />
否则 Router 匹配到了子组件,也没有位置可以显示它。
25.5 重复启动动画循环
不要在每次数据刷新时都调用:
typescript
animateTwinScene();
动画循环通常只启动一次。
数据变化时只更新状态:
typescript
updateTwinScene();
否则可能生成多个 requestAnimationFrame 链。
18、适合数字孪生项目的代码拆分
如果 twin.vue 逐渐增长到几千行,可以考虑拆分。
推荐结构:
text
src/views/bof/heat/twin/
├─ index.vue
├─ components/
│ ├─ TwinHeader.vue
│ ├─ TwinHud.vue
│ ├─ DevicePanel.vue
│ ├─ ProcessPanel.vue
│ └─ AlarmPanel.vue
├─ composables/
│ ├─ useTwinScene.ts
│ ├─ useTwinData.ts
│ ├─ useTwinAnimation.ts
│ └─ useTwinResize.ts
├─ scene/
│ ├─ createScene.ts
│ ├─ createConverter.ts
│ ├─ createLighting.ts
│ ├─ createParticles.ts
│ └─ disposeScene.ts
├─ types/
│ └─ twin.ts
└─ constants/
└─ twin.ts
职责划分:
text
index.vue
→ 负责页面组合和模式切换
useTwinData.ts
→ 负责接口请求和业务状态
useTwinScene.ts
→ 负责 Scene、Camera、Renderer
useTwinAnimation.ts
→ 负责动画循环
useTwinResize.ts
→ 负责尺寸变化
scene/createConverter.ts
→ 负责创建转炉模型层级
scene/disposeScene.ts
→ 负责资源释放
19、一个更完整的数字孪生页面骨架
vue
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch
} from 'vue';
import {
useRoute,
useRouter
} from 'vue-router';
import * as THREE from 'three';
import {
OrbitControls
} from 'three/examples/jsm/controls/OrbitControls.js';
import {
getHeatDetail
} from '@/api/heat';
const route = useRoute();
const router = useRouter();
const heatId = ref('');
const twinCanvasRef =
ref<HTMLDivElement | null>(null);
const isImmersiveMode = computed(() => {
return route.name ===
'BofHeatTwinImmersive';
});
let scene: THREE.Scene | null = null;
let camera:
THREE.PerspectiveCamera | null = null;
let renderer:
THREE.WebGLRenderer | null = null;
let controls:
OrbitControls | null = null;
let animationFrameId = 0;
const goBack = () => {
router.push({
path: '/bof/heat'
});
};
const initTwinScene = () => {
const container =
twinCanvasRef.value;
if (!container) {
return;
}
scene = new THREE.Scene();
camera =
new THREE.PerspectiveCamera(
45,
container.clientWidth /
container.clientHeight,
0.1,
1000
);
camera.position.set(8, 6, 10);
renderer =
new THREE.WebGLRenderer({
antialias: true
});
renderer.setSize(
container.clientWidth,
container.clientHeight
);
container.appendChild(
renderer.domElement
);
controls = new OrbitControls(
camera,
renderer.domElement
);
};
const updateTwinScene = () => {
// 根据当前响应式状态更新模型
};
const animateTwinScene = () => {
animationFrameId =
requestAnimationFrame(
animateTwinScene
);
controls?.update();
if (
renderer &&
scene &&
camera
) {
renderer.render(
scene,
camera
);
}
};
const loadTwinData = async () => {
try {
const data =
await getHeatDetail(
heatId.value
);
console.log(data);
updateTwinScene();
} catch (error) {
console.error(
'加载炉次数据失败',
error
);
}
};
const handleResize = () => {
const container =
twinCanvasRef.value;
if (
!container ||
!camera ||
!renderer
) {
return;
}
camera.aspect =
container.clientWidth /
container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(
container.clientWidth,
container.clientHeight
);
};
const disposeTwinScene = () => {
cancelAnimationFrame(
animationFrameId
);
controls?.dispose();
controls = null;
if (scene) {
scene.traverse((object) => {
if (
object instanceof THREE.Mesh
) {
object.geometry.dispose();
const materials =
Array.isArray(object.material)
? object.material
: [object.material];
materials.forEach(
(material) => {
material.dispose();
}
);
}
});
}
renderer?.dispose();
renderer?.domElement.remove();
renderer = null;
camera = null;
scene = null;
};
onMounted(() => {
heatId.value = String(
route.query.heat_id || ''
);
if (!heatId.value) {
goBack();
return;
}
window.addEventListener(
'resize',
handleResize
);
nextTick(() => {
initTwinScene();
updateTwinScene();
animateTwinScene();
});
void loadTwinData();
});
watch(
() => route.query.heat_id,
(newHeatId) => {
if (!newHeatId) {
return;
}
heatId.value =
String(newHeatId);
void loadTwinData();
}
);
onBeforeUnmount(() => {
window.removeEventListener(
'resize',
handleResize
);
disposeTwinScene();
});
</script>
<template>
<main
class="twin-page"
:class="{
'is-immersive':
isImmersiveMode
}"
>
<header v-if="!isImmersiveMode">
炉次:{{ heatId }}
</header>
<div
ref="twinCanvasRef"
class="twin-canvas"
></div>
</main>
</template>
<style scoped>
.twin-page {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.twin-canvas {
width: 100%;
height: 100%;
}
.is-immersive {
position: fixed;
inset: 0;
}
</style>
这个骨架把本文知识串联起来:
text
import npm 包
import Vue API
import Router API
import Three.js
import 接口模块
useRoute
useRouter
query
computed
onMounted
nextTick
watch
onBeforeUnmount
20、生产环境需要特别注意的事项
28.1 路由懒加载
大型三维页面应优先使用:
typescript
component: () =>
import('@/views/bof/heat/twin.vue')
避免把 Three.js 页面全部打进首屏主包。
28.2 资源销毁
离开页面时至少清理:
text
requestAnimationFrame
window 事件
定时器
OrbitControls
WebGLRenderer
Geometry
Material
Texture
WebSocket
轮询请求
28.3 路由参数校验
不要默认所有入口都正确传参。
应该显式校验:
typescript
if (!heatId.value) {
goBack();
return;
}
同时应统一入口函数,避免不同按钮各自拼装参数。
28.4 异步请求竞争
如果用户快速从炉次 001 切换到炉次 002,001 的旧请求可能更晚返回,反而覆盖 002 的数据。
可使用请求序号:
typescript
let requestVersion = 0;
const loadTwinData = async () => {
const currentVersion =
++requestVersion;
const data = await getHeatDetail(
heatId.value
);
if (
currentVersion !==
requestVersion
) {
return;
}
applyTwinData(data);
};
28.5 不要让响应式系统管理所有 Three.js 对象
Scene、Renderer、Camera 等对象通常不需要深度响应式。
可以使用普通变量,或者使用:
typescript
import {
shallowRef,
markRaw
} from 'vue';
例如:
typescript
const scene = shallowRef<
THREE.Scene | null
>(null);
scene.value = markRaw(
new THREE.Scene()
);
这样可以减少不必要的响应式代理开销。
28.6 页面尺寸不一定只由 window.resize 改变
侧边栏、父容器和面板折叠也会改变 Canvas 容器大小,但不一定触发浏览器窗口 resize。
更稳妥的方式是使用:
typescript
const resizeObserver =
new ResizeObserver(() => {
handleResize();
});
监听真实容器尺寸。
28.7 错误边界和加载状态
数字孪生页面需要区分:
text
页面代码加载中
业务数据加载中
三维模型加载中
实时连接建立中
数据请求失败
WebGL 不支持
模型资源失败
不要只显示一个无限旋转的 Loading。
21、建议的代码阅读顺序
初学者不要直接从几千行的 twin.vue 开始。
建议按照下面顺序阅读。
第一阶段:理解应用入口
text
src/main.ts
src/App.vue
重点:
typescript
createApp
app.use
app.mount
import
router-view
第二阶段:理解页面入口
text
src/views/bof/heat/index.vue
重点:
typescript
useRouter
router.push
path
query
第三阶段:理解路由表
text
src/router/index.ts
重点:
typescript
path
component
children
name
meta
动态 import
第四阶段:理解权限守卫
text
src/permission.ts
重点:
typescript
router.beforeEach
to
from
next
Token
redirect
第五阶段:理解布局嵌套
text
src/layout/index.vue
src/layout/components/AppMain.vue
重点:
vue
<router-view>
<component :is="Component">
<keep-alive>
第六阶段:理解页面生命周期
text
src/views/bof/heat/twin.vue
先只看:
typescript
useRoute
useRouter
computed
onMounted
nextTick
onBeforeUnmount
再进入:
text
initTwinScene
updateTwinScene
animateTwinScene
loadTwinData
disposeTwinScene
22、Vue Router 学习检查清单
完成本文后,应能够回答下面的问题:
- Vue Router 在
main.ts中如何注册? app.use(router)的作用是什么?path、component、name和meta分别有什么作用?- 为什么页面路由常使用动态
import()? router和route有什么区别?router.push()、router.replace()和router.back()有什么区别?- 如何使用
query携带炉次 ID? query和params的区别是什么?- 路由守卫在什么时候执行?
to、from和next分别表示什么?- 为什么父路由存在
children时,父组件必须提供router-view? - 为什么当前项目中存在两层
router-view? meta.fullScreen如何影响 Layout?keep-alive和noCache如何影响页面生命周期?- 为什么同一个
twin.vue可以支持普通模式和沉浸模式? - 为什么 Three.js 页面要在
onMounted()后初始化? - 为什么离开页面时必须清理动画、事件和 WebGL 资源?
23、总结
Vue Router 的核心任务是根据浏览器地址决定应该显示哪个 Vue 组件。
当前数字孪生页面的真实流程可以概括为:
text
用户点击炉次入口
→ router.push 携带 heat_id
→ permission.ts 的路由守卫检查权限
→ router/index.ts 匹配 /bof/heat/twin
→ App.vue 的第一层 router-view 显示 Layout
→ Layout 根据 meta.fullScreen 切换布局
→ AppMain 的第二层 router-view 加载 twin.vue
→ twin.vue 执行 onMounted
→ 读取 route.query.heat_id
→ nextTick 后初始化 Three.js
→ 启动动画并加载业务数据
→ 离开页面时清理事件、动画和 WebGL 资源
可以把最重要的概念记成:
text
router
→ 负责主动导航
route
→ 负责描述当前路由
router-view
→ 负责提供组件显示位置
beforeEach
→ 负责在进入页面前检查导航
meta
→ 负责保存项目自定义的路由信息
children
→ 负责建立父子路由关系