vue-cli 的 Router 插件(@vue/cli-plugin-router)完全指南:安装、生成器原理与 Vue 2/Vue 3 差异

原创2026-09-19 21:21:061,915 阅读
文章标签:前端开发工具构建工具

vue-cli 的 Router 插件(@vue/cli-plugin-router)完全指南:安装、生成器原理与 Vue 2/Vue 3 差异

导读

@vue/cli-plugin-router 是 vue-cli 官方提供的 Vue Router 集成插件:它既能在创建项目时按需接入路由,也支持在已创建的项目中通过一条命令快速补齐路由能力。本文将以该插件的源码与测试为证据,完整讲解 vue add router 的用法、交互式 prompt、Vue 2(vue-router 3)与 Vue 3(vue-router 4)两套模板的生成逻辑、history 模式与代码分割的实现方式,以及插件在 TypeScript 项目中的行为差异,帮助你彻底理解 vue-cli 路由集成的每一步。

一、插件定位与安装方式

@vue/cli-plugin-router 是 vue-cli 的路由插件,其 package.json(见 package.json)声明了自身的元信息:主入口为 index.js,核心依赖为 @vue/cli-shared-utils,并以 @vue/cli-service(^3.0.0 || ^4.0.0 || ^5.0.0-0)作为 peerDependency,即它必须运行在 vue-cli-service 提供的生成器环境中。

与 README 中描述一致,在已经创建好的项目中安装该插件只需一条命令:

vue add router

该命令会做两件事:安装插件包并执行其 generator,同时以交互式方式向你提问(见下文 prompt 小节)。值得注意的是,在 vue create 创建新项目的过程中,勾选 "Router" 特性时会直接内置该插件并调用同一套 generator,因此两套流程产出的代码结构完全一致。

二、交互式提问:historyMode

当插件被后期安装(late-install,即通过 vue add router 或 vue invoke 触发)时,prompts.js 会向用户提出一个问题:

const { chalk } = require('@vue/cli-shared-utils')

module.exports = [
  {
    name: 'historyMode',
    type: 'confirm',
    message: `Use history mode for router? ${chalk.yellow(`(Requires proper server setup for index fallback in production)`)}`,
    description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`
  }
]

关键参数说明:

参数 类型 含义 注意事项
historyMode boolean(confirm) 是否使用 HTML5 History 模式 使用后 URL 不再包含 #,但生产环境需要服务器配置 index fallback(如 nginx 的 try_files),否则刷新非根路径会 404

这个 historyMode 选项最终作为 options.historyMode 传入 generator,直接决定生成的路由配置代码(详见第三节)。测试 routerGenerator.spec.js 中的 history mode 用例验证了:当 historyMode: true 时,生成的 src/router/index.js 中会出现 history 相关代码。

三、生成器原理:一次生成,两套模板

插件的核心逻辑位于 generator/index.js,它根据 rootOptions.vueVersion 判断项目是 Vue 2 还是 Vue 3:

module.exports = (api, options = {}, rootOptions = {}) => {
  const isVue3 = (rootOptions.vueVersion === '3')

  api.injectImports(api.entryFile, `import router from './router'`)

  if (isVue3) {
    api.transformScript(api.entryFile, require('./injectUseRouter'))
    api.extendPackage({
      dependencies: {
        'vue-router': '^4.0.3'
      }
    })
  } else {
    api.injectRootOptions(api.entryFile, `router`)
    api.extendPackage({
      dependencies: {
        'vue-router': '^3.5.1'
      }
    })
  }

  api.render('./template', {
    historyMode: options.historyMode,
    doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
    hasTypeScript: api.hasPlugin('typescript')
  })

  if (isVue3) {
    api.render('./template-vue3', {
      historyMode: options.historyMode,
      doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
      hasTypeScript: api.hasPlugin('typescript')
    })
  }

  if (api.invoking) {
    if (api.hasPlugin('typescript')) {
      /* eslint-disable-next-line node/no-extraneous-require */
      const convertFiles = require('@vue/cli-plugin-typescript/generator/convert')
      convertFiles(api)
    }
  }
}

其执行流程可拆解为四步:

  1. 注入入口 import:向 api.entryFile(通常是 src/main.js)注入 import router from './router';
  2. 按版本分支处理:
    • Vue 3:使用 api.transformScript 配合 injectUseRouter.js 做 AST 转换,把 createApp(...) 改写为 createApp(...).use(router),并安装 vue-router@^4.0.3;
    • Vue 2:向根组件选项注入 router(api.injectRootOptions),并安装 vue-router@^3.5.1;
  3. 渲染模板:Vue 2 渲染 ./template,Vue 3 额外渲染 ./template-vue3,两者共享 historyMode、doesCompile、hasTypeScript 三个渲染变量;
  4. TypeScript 转换:若是在已有项目上调用(api.invoking 为真)且项目使用了 TypeScript,则复用 @vue/cli-plugin-typescript/generator/convert 将生成的 .js 文件转换为 .ts。

3.1 AST 转换注入 use(router)(Vue 3 专属)

在 Vue 3 中,路由不能再通过 Vue.use() 全局安装,而必须显式调用 app.use(router)。injectUseRouter.js 使用 jscodeshift 遍历入口文件的调用表达式:

const appRoots = root.find(j.CallExpression, (node) => {
  if (j.Identifier.check(node.callee) && node.callee.name === 'createApp') return true
  if (
    j.MemberExpression.check(node.callee) &&
    j.Identifier.check(node.callee.object) && node.callee.object.name === 'Vue' &&
    j.Identifier.check(node.callee.property) && node.callee.property.name === 'createApp'
  ) return true
})

appRoots.replaceWith(({ node: createAppCall }) => {
  return j.callExpression(
    j.memberExpression(createAppCall, j.identifier('use')),
    [j.identifier('router')]
  )
})

它同时兼容 createApp(App) 与 Vue.createApp(App) 两种写法,将其统一替换为 createApp(App).use(router)。测试 use with Vue 3 用例断言生成的 src/main.js 包含 .use(router)(见 routerGenerator.spec.js),正是对这一步骤的验证。

3.2 版本与依赖的对应关系

从源码可以确认插件的版本策略,这是 Vue 2 / Vue 3 项目的重要差异:

项目 Vue 版本 安装的 vue-router 路由实例化方式 入口注入方式
Vue 2 ^3.5.1 new VueRouter({ mode, routes }) + Vue.use(VueRouter) 根组件选项注入 router
Vue 3 ^4.0.3 createRouter({ history, routes }) createApp(App).use(router)(AST 改写)

四、生成的路由代码模板详解

4.1 Vue 2 模板(vue-router 3)

template/src/router/index.js 是 Vue 2 项目的路由入口模板,它使用 EJS 语法按渲染变量动态生成代码:

import Vue from 'vue'
<%_ if (hasTypeScript) { _%>
import VueRouter, { RouteConfig } from 'vue-router'
<%_ } else { _%>
import VueRouter from 'vue-router'
<%_ } _%>
import HomeView from '../views/HomeView.vue'

Vue.use(VueRouter)

<%_ if (hasTypeScript) { _%>
const routes: Array<RouteConfig> = [
<%_ } else { _%>
const routes = [
<%_ } _%>
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about',
    name: 'about',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    <%_ if (doesCompile) { _%>
    component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
    <%_ } else { _%>
    component: function () {
      return import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
    }
    <%_ } _%>
  }
]

const router = new VueRouter({
  <%_ if (historyMode) { _%>
  mode: 'history',
  base: process.env.BASE_URL,
  <%_ } _%>
  routes
})

export default router

几个值得注意的实现细节:

  • 路由级代码分割:/about 路由使用动态 import() 并带 webpackChunkName: "about" 注释,webpack 会为它单独生成 about.[hash].js 分块,访问该路由时才懒加载。注释明确说明了这一行为;
  • doesCompile 分支:当项目启用了 Babel 或 TypeScript(api.hasPlugin('babel') || api.hasPlugin('typescript'))时,直接使用箭头函数 () => import(...);否则退回普通函数 function () { return import(...) },避免在无编译能力的项目中因箭头函数转换问题出错。测试 use with Babel 用例验证了启用 Babel 后生成 component: () => import(见 routerGenerator.spec.js);
  • historyMode 分支:开启后为 new VueRouter 添加 mode: 'history' 和 base: process.env.BASE_URL,其中 base 取自 vue-cli 的 publicPath 配置(即 process.env.BASE_URL),保证在子路径部署时路由基准正确。

4.2 Vue 3 模板(vue-router 4)

template-vue3/src/router/index.js 面向 Vue 3 项目,API 完全不同:

import { createRouter<%
  if (historyMode) {
    %>, createWebHistory<%
  } else {
    %>, createWebHashHistory<%
  }

  if (hasTypeScript) {
    %>, RouteRecordRaw<%
  }
  %> } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const routes<% if (hasTypeScript) { %>: Array<RouteRecordRaw><% } %> = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about',
    name: 'about',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    <%_ if (doesCompile) { _%>
    component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
    <%_ } else { _%>
    component: function () {
      return import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
    }
    <%_ } _%>
  }
]

const router = createRouter({
  <%_ if (historyMode) { _%>
  history: createWebHistory(process.env.BASE_URL),
  <%_ } else { _%>
  history: createWebHashHistory(),
  <%_ } _%>
  routes
})

export default router

与 Vue 2 模板的关键差异:

  • 实例化方式:new VueRouter({...}) 变为 createRouter({...});
  • history 对象化:mode: 'history' 字符串配置改为传入 history 实例——history 模式用 createWebHistory(process.env.BASE_URL)(同样透传 BASE_URL),hash 模式用 createWebHashHistory();
  • TypeScript 类型:启用 TypeScript 时,从 vue-router 额外导入 RouteRecordRaw 类型,并将 routes 声明为 Array<RouteRecordRaw>。测试 Vue 3 + TypeScript 用例对此有明确断言(见 routerGenerator.spec.js)。

4.3 默认路由与视图组件

模板同时生成两个默认视图:src/views/HomeView.vue 和 src/views/AboutView.vue,分别对应 /(home)与 /about(about)两个路由。/ 使用静态导入,/about 使用懒加载——这一设计示范了 vue-router 推荐的按路由拆分代码的最佳实践。

4.4 App.vue 的模板替换

插件会基于 @vue/cli-service/generator/template/src/App.vue 进行扩展替换(见 template/src/App.vue 与 template-vue3/src/App.vue 的 front matter):

---
extend: '@vue/cli-service/generator/template/src/App.vue'
replace:
  - !!js/regexp /<template>[^]*?<\/template>/
  - !!js/regexp /\n<script>[^]*?<\/script>\n/
  - !!js/regexp /  margin-top[^]*?<\/style>/
---

替换后的模板内容包含:

<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </nav>
    <router-view/>
  </div>
</template>

其中 <router-link> 用于声明式导航,<router-view/> 是路由组件的渲染出口。样式部分针对 nav 和 nav a.router-link-exact-active 做了高亮处理(激活路由链接为 #42b983),并且根据 rootOptions.cssPreprocessor 自动适配三种 CSS 写法:无预处理器时输出原生 CSS,使用 stylus 时输出缩进语法,其他预处理器(scss/less 等)输出嵌套语法。

需要注意 Vue 2 与 Vue 3 模板的一个细节差异:Vue 2 的 App.vue 保留 <div id="app"> 包裹,而 Vue 3 版本没有——测试 use with Vue 3 中断言 App.vue 不包含 <div id="app">(见 routerGenerator.spec.js)。

五、TypeScript 项目中的额外处理

在已有的 TypeScript 项目中通过 vue invoke 安装本插件时(api.invoking 为真),generator 会额外调用 @vue/cli-plugin-typescript/generator/convert 把新生成的 .js 文件转换成 .ts(见 generator/index.js),因此最终生成的路由文件是 src/router/index.ts。该文件在类型层面会有以下特征:

  • Vue 2 + TS:import VueRouter, { RouteConfig } from 'vue-router',const routes: Array<RouteConfig> = [...];
  • Vue 3 + TS:额外导入 RouteRecordRaw,const routes: Array<RouteRecordRaw> = [...]。

测试 Vue 3 + TypeScript 用例分别断言了这两点(见 routerGenerator.spec.js)。

六、测试覆盖:插件行为的验证依据

插件的完整行为都由 routerGenerator.spec.js 通过 @vue/cli-test-utils/generateWithPlugin 驱动真实 generator 验证,六个用例分别覆盖:

测试用例 验证点
base 生成 src/router/index.js(不含 history)、两个视图、App.vue 含 router-link、依赖含 vue-router
history mode historyMode: true 时路由文件包含 history 配置
use with Babel 有 Babel 时使用 component: () => import 箭头函数写法
use with Vue 3 使用 createRouter、createWebHashHistory()、入口含 .use(router)、App.vue 无 #app 包裹、vue-router 版本为 ^4
Vue 3 + History Mode 使用 createWebHistory(process.env.BASE_URL)
Vue 3 + TypeScript 生成 src/router/index.ts,包含 RouteRecordRaw 类型与 Array<RouteRecordRaw> 声明

这些用例与本文前述源码分析一一对应,可作为插件行为最直接的验证入口。

七、实战建议与常见问题

  1. history 模式的部署前提:选择 history 模式后,URL 不再带 #,但生产环境必须为所有非静态路径配置回退到 index.html(例如 nginx 的 try_files $uri $uri/ /index.html),否则直接访问 /about 这类深层链接会 404。这是 prompt 中黄色警告的由来;
  2. vue add router 与 vue invoke router 的区别:vue add 会先安装插件包再触发 generator;而 vue invoke 只对已安装的插件重新执行 generator,通常用于在已有项目中补装新特性,或修改选项后重新生成;
  3. 代码分割是默认行为:模板中 /about 路由的懒加载写法是刻意设计的,新增路由时建议保持"首屏静态导入 + 次级路由动态导入"的节奏,以控制首屏包体积;
  4. TypeScript 项目的版本差异:若在 Vue 3 + TS 项目中,路由文件为 .ts 且带有 RouteRecordRaw 类型约束,新增路由时会获得编译期类型检查。

结语

@vue/cli-plugin-router 虽然 README 仅有一行安装命令,但其背后是一套完整的、版本感知的代码生成体系:通过 generator 判断 Vue 大版本、通过 AST 改写入口文件、通过 EJS 模板按需渲染 history 模式与 TypeScript 类型。理解 generator/index.js、template 与 template-vue3 三者的协作关系,你就能在自定义脚手架或二次开发时轻松复刻这套路由集成能力。

登录后查看全文
vue-cli