Vue Router 实战教程

Vue Router 实战教程

Vue Router 负责把浏览器 URL、权限检查、布局组件和具体业务页面连接起来。

讲解:

  • Router 如何在 Vue 应用中注册;
  • 路由表中的 pathcomponentchildrennamemeta
  • useRouter()useRoute()
  • router.push()queryparams
  • 全局路由守卫;
  • Layout 与嵌套路由;
  • 两层 router-view
  • keep-alive 与页面缓存;
  • onMounted()nextTick() 和页面销毁;
  • /bof/heat/twin 的完整真实调用链;
  • Three.js 数字孪生页面的生产环境注意事项。

1、Vue 应用入口 main.ts

Vue 项目一般从 main.tsmain.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 queryparams 的区别

查询参数形式:

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-alivenoCache

项目中常见:

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 把 routerouter 混淆

错误:

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 对象

SceneRendererCamera 等对象通常不需要深度响应式。

可以使用普通变量,或者使用:

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) 的作用是什么?
  • pathcomponentnamemeta 分别有什么作用?
  • 为什么页面路由常使用动态 import()
  • routerroute 有什么区别?
  • router.push()router.replace()router.back() 有什么区别?
  • 如何使用 query 携带炉次 ID?
  • queryparams 的区别是什么?
  • 路由守卫在什么时候执行?
  • tofromnext 分别表示什么?
  • 为什么父路由存在 children 时,父组件必须提供 router-view
  • 为什么当前项目中存在两层 router-view
  • meta.fullScreen 如何影响 Layout?
  • keep-alivenoCache 如何影响页面生命周期?
  • 为什么同一个 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
→ 负责建立父子路由关系
相关推荐
NutShell Wang1 小时前
拆解 GitHub gh-stack:堆叠 PR 工作流的设计取舍与工程实现
前端·git·开源·github·代码复审·开发者工具·vibe coding
名字还没想好☜1 小时前
React 用 IntersectionObserver 实现图片懒加载与无限滚动:封装一个 useInView Hook
前端·javascript·vue.js·react.js·react
带娃的IT创业者2 小时前
Puppeteer 深度解析:超越自动化测试的现代 Web 交互范式
前端·交互·puppeteer·可观测性·浏览器自动化·无头浏览器·web工程化
程序员爱钓鱼2 小时前
Rust 所有权 Ownership 详解:理解内存安全的核心机制
前端·后端·rust
Mh10 小时前
别再只会 `find` 了:Map 在前端业务里的真实用法
前端·javascript
陈随易10 小时前
FFmpeg 9.0 发布,代号 Lei,音视频处理再升级
前端·后端·程序员
爱丶狸11 小时前
Grafana_Zabbix_ImageRenderer_部署与前端操作手册
linux·前端·zabbix·grafana·kylin
用户0595401744614 小时前
把AI对话记忆存储测试从手工改成Playwright+pytest,覆盖率从20%提到96%,回归时间缩短90%
前端·css
kyriewen14 小时前
别再这样写TypeScript了——Code Review中最常见的8个反模式
前端·javascript·typescript