普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月8日首页

使用 Vite Mode 实现客户端与管理端的物理隔离

2026年3月8日 01:31

一、背景与目标

在我们的项目中,客户端管理端共享绝大部分的组件、工具和样式代码,但两者的登录入口业务路由完全不同。过去,我们将它们放在同一个Vue项目中,通过同一套路由混合管理。

目标

我们希望在不拆分代码仓库、保持公共代码高效复用的前提下,实现彻底的隔离:

  • 执行 pnpm dev:client 时,我们得到一个仅包含客户端路由和页面的纯净开发环境
  • 执行 pnpm dev:admin 时,我们得到一个仅包含管理端路由和页面的纯净开发环境
  • 在执行构建时,能产出两个互不包含对方代码的独立部署包。

实现这一目标的核心,便是利用 Vite 的 --mode 参数在构建时区分应用,并动态决定最终生效的路由配置。

二、核心机制:命令行参数驱动

整个方案的核心是利用 Vite 的 --mode 参数,在启动和构建时向应用注入一个“身份标识”。

  1. 定义启动与构建命令 (package.json)

    我们通过不同命令传递不同的 mode值。为了能在单个终端窗口同时启动或构建两个应用,我们使用 concurrently 工具。

    {
      "scripts": {
        // 1. 独立操作命令
        "dev:client": "vite --mode client",
        "dev:admin": "vite --mode admin",
        "build:client": "vite build --mode client",
        "build:admin": "vite build --mode admin",
    
        // 2. 核心效率工具:使用 concurrently 一键操作两端
        "dev:all": "concurrently "pnpm dev:client" "pnpm dev:admin"",
        "build:all": "concurrently "pnpm build:client" "pnpm build:admin""
      },
      "devDependencies": {
        "concurrently": "^9.1.2", // 需要安装此依赖
      }
    }
    
  2. 动态Vite配置 (vite.config.js)

    Vite 配置函数能接收到 mode 参数,我们据此动态设置所有差异化配置。

    import { defineConfig } from 'vite';
    
    export default defineConfig(({ mode }) => {
      const isAdmin = mode === 'admin';
      const appType = isAdmin ? 'admin' : 'client';
    
      return {
        // 核心:将应用类型注入为全局常量 APP_TYPE
        define: {
          APP_TYPE: JSON.stringify(appType) // 关键:必须用JSON.stringify
        },
        server: {
          port: isAdmin ? 3001 : 3000, // 为不同端分配不同端口,避免冲突
          open: true
        },
        build: {
          outDir: isAdmin ? 'dist-admin' : 'dist-client' // 构建输出到不同目录
        }
        // ... 其他公共配置
      };
    });
    

三、核心难点与解决方案

  1. 全局常量替换的“坑”:为什么必须用 JSON.stringify

    Vite 的 define配置本质是字符串级别的查找替换,并非 JavaScript 的变量赋值。理解“替换成什么文本”是关键。

    • 错误配置define: { APP_TYPE: 'admin' }

      • 替换过程:代码中 'console.log(APP_TYPE)'里的 APP_TYPE会被直接替换为文本 'admin'
      • 结果代码:'console.log(admin)'
      • 问题admin没有引号,会被 JavaScript 引擎当作一个变量名,导致报错 admin is not defined
    • 正确配置define: { APP_TYPE: JSON.stringify('admin') }

      • JSON.stringify('admin')的执行结果是字符串 '"admin"'(包含双引号)。
      • 替换过程:代码中的 APP_TYPE会被替换为文本 "admin"
      • 结果代码:console.log("admin")
      • 正确"admin"是一个字符串值,能正确打印。

    结论JSON.stringify的作用是把值转换成其对应的、有效的 JSON 字符串表示形式,确保替换后的代码语法正确。

  2. 路由动态配置:通过 APP_TYPE分离两套路由

    这是本方案的核心应用之一。在路由定义文件中,我们准备两套完全独立的路由数组,并通过 APP_TYPE 全局常量来决定最终使用哪一套。

    // src/router/index.js
    import { createRouter, createWebHistory } from 'vue-router'
    
    // 1. 定义客户端路由
    const clientRoutes = [
      { path: '/', component: () => import('@/views/client/Home.vue') },
      { path: '/profile', component: () => import('@/views/client/Profile.vue') }
    ]
    
    // 2. 定义管理员端路由
    const adminRoutes = [
      { path: '/admin', component: () => import('@/views/admin/Dashboard.vue') },
      { path: '/admin/users', component: () => import('@/views/admin/UserList.vue') }
    ]
    
    // 3. 根据 APP_TYPE 动态选择路由
    const routes = APP_TYPE === 'client' ? clientRoutes : adminRoutes
    
    export default createRouter({
      history: createWebHistory(),
      routes // 使用确定后的路由
    })
    
  3. TypeScript 支持:声明全局常量

    在项目文件中使用 APP_TYPE 时,TypeScript 会因找不到定义而报错。需在类型声明文件中声明此全局常量。

    // src/env.d.ts
    
    // 声明通过 define 注入的全局常量
    declare const APP_TYPE: 'client' | 'admin';
    

    此声明为 APP_TYPE 提供了类型支持,使其在代码中具备完整的类型提示与检查。

四、完整工作流程

  1. 安装依赖pnpm add -D concurrently

  2. 配置命令:按上文修改 package.json

  3. 配置Vite:按上文创建动态的 vite.config.js

  4. 声明类型:创建 src/env.d.ts文件声明 APP_TYPE

  5. 代码中区分逻辑:在任何需要区分两端的地方使用 APP_TYPE常量。

    // 例如在路由、组件、API配置中
    if (APP_TYPE === 'admin') {
      // 管理员端逻辑
    } else {
      // 客户端逻辑
    }
    
  6. 运行

    • pnpm dev:all一键启动两个端,分别访问 http://localhost:3000(client) 和 http://localhost:3001(admin)。
    • pnpm build:all一键构建两个端,产物分别输出到 dist-clientdist-admin 目录。

五、方案对比与更优方案

在理解了通过 define 配置全局常量的原理后,你会发现 Vite 本身就提供了更简洁的内置方案。

更优方案:直接使用 import.meta.env.MODE

Vite 会自动将 --mode参数的值注入到 import.meta.env.MODE这个内置环境变量中。这意味着你可以完全省略配置 define 和声明 .d.ts 文件的步骤,直接使用它。

在代码的任何地方,直接判断 import.meta.env.MODE即可。

// 路由配置中直接判断
const routes = import.meta.env.MODE === 'client' ? clientRoutes : adminRoutes;

// 在组件或逻辑中
if (import.meta.env.MODE === 'admin') {
  // 管理员端专属逻辑
}
昨天 — 2026年3月7日首页

Vue3 命令式弹窗原理和 provide/inject 隔离机制详解

2026年3月7日 00:53

Vue 3 命令式弹窗组件 这篇文章是 Vue3 命令式弹窗的实现,本文针对实现进行原理讲解。

核心问题

问题:通过命令式方法(如render函数)创建多个弹窗组件实例时,为什么每个实例调用provide()时数据不会互相污染?

关键代码与机制

1. 创建命令式弹窗的核心代码

export const useCommandComponent = (Component) => {
  const parentInstance = getCurrentInstance()
  
  // 创建新的 appContext,继承父级上下文
  const appContext = Object.create(parentInstance?.appContext)
  
  // 关键步骤:设置 appContext.provides 为父级的 provides
  if (appContext) {
    Reflect.set(appContext, 'provides', parentInstance?.provides)
  }
  
  const container = document.createElement('div')
  
  const CommandComponent = (options = {}) => {
    const vNode = createVNode(Component, options)
    vNode.appContext = appContext  // 我们设置的 appContext
    
    // 注意:render 函数没有传入 parentComponent
    // 这意味着命令式组件被当作"根组件"处理
    render(vNode, container)
    
    document.body.appendChild(container)
    return vNode
  }
  
  return CommandComponent
}

2. Vue 内部渲染逻辑

// Vue 内部的 render 函数简化逻辑
const render = (vnode, container) => {
  // 第5个参数是 parentComponent,对于命令式组件,这里传的是 null
  patch(null, vnode, container, null, null, null, namespace)
  //                                   ↑
  // 这个 null 就是 parent,意味着命令式组件没有父组件
}

3. 组件实例创建的关键逻辑

// Vue 源码:createComponentInstance
function createComponentInstance(vnode, parent, suspense) {
  const instance = {
    // 关键:命令式组件的 parent 是 null!
    parent: parent,  // 对于命令式组件,parent = null
    
    appContext: vnode.appContext,  // 我们设置的 appContext
    
    // 最关键的部分:provides 的初始化方式
    provides: parent 
      ? parent.provides  // 有 parent 时,直接继承(标准组件)
      : Object.create(vnode.appContext.provides)  // 无 parent 时,创建新对象
      //   ↑
      // 命令式组件走到这个分支
      // 创建一个以 vnode.appContext.provides 为原型的新空对象
  }
  return instance
}

为什么不会互相污染?

1. 实例创建过程

// 创建弹窗1时
const instance1 = createComponentInstance(vNode1, parent = null, suspense)
// 因为 parent = null,所以:
instance1.provides = Object.create(vnode.appContext.provides)
// 结果:instance1.provides 是一个新的空对象 {}
// 但这个对象的 __proto__ 指向 vnode.appContext.provides(即父组件的 provides)

// 创建弹窗2时
const instance2 = createComponentInstance(vNode2, parent = null, suspense)
// 同样因为 parent = null:
instance2.provides = Object.create(vnode.appContext.provides)
// 结果:instance2.provides 是另一个新的空对象 {}
// 注意:instance1.provides 和 instance2.provides 是两个不同的对象

2. 实例状态对比

// 弹窗1实例的状态
instance1 = {
  parent: null,  // 没有父组件
  provides: {},  // 全新的空对象1
  
  // 关键:这个空对象的原型指向父组件的 provides
  // provides.__proto__ === 父组件的provides
}

// 弹窗2实例的状态
instance2 = {
  parent: null,  // 同样没有父组件
  provides: {},  // 全新的空对象2
  
  // 注意:instance2.provides 和 instance1.provides 是不同的对象!
  // 但它们的原型都指向同一个父组件的 provides
  // provides.__proto__ === 父组件的provides
}

3. 调用 provide 时的行为

// 弹窗1调用 provide
provide('key1', 'value1')
// 实际执行:instance1.provides.key1 = 'value1'
// 结果:instance1.provides = { key1: 'value1' }

// 弹窗2调用 provide
provide('key2', 'value2')
// 实际执行:instance2.provides.key2 = 'value2'
// 结果:instance2.provides = { key2: 'value2' }

// 重要:这两个操作完全独立
// instance1.provides 和 instance2.provides 是两个不同的对象
// 所以不会互相影响

内存结构可视化

父组件
  │
  ├── provides: { parentKey: 'parentValue' }
  │
  ├── 弹窗1实例
  │     ├── parent: null
  │     ├── provides: { key1: 'value1' }  ← 这是自有属性
  │     │
  │     └── provides.__proto__
  │              ↓
  │         { parentKey: 'parentValue' } ← 父组件的 provides
  │
  └── 弹窗2实例
        ├── parent: null
        ├── provides: { key2: 'value2' }  ← 这是自有属性
        │
        └── provides.__proto__
                 ↓
            { parentKey: 'parentValue' } ← 父组件的 provides

关键点总结

1. 为什么 parent 是 null?

  • 命令式组件不是通过父组件模板渲染的
  • 而是通过 render()函数直接挂载到 DOM
  • Vue 内部将其视为独立的"根组件"
  • 所以 parent 参数为 null

2. 为什么 provides 是独立的对象?

  • parentnull时,Vue 会执行:

    provides: Object.create(vnode.appContext.provides)
    
  • 这创建了一个新的空对象,其原型指向父组件的 provides

  • 每个命令式组件实例都会执行这个操作

  • 所以每个实例都有自己独立的 provides对象

3. 如何实现数据共享?

  • 虽然每个实例的 provides 是独立的对象

  • 但这些对象的原型都指向同一个父组件的 provides

  • 当调用 inject()查找数据时:

    // 简化版的 inject 逻辑
    function inject(key) {
      // 对于命令式组件,instance.parent 为 null
      const provides = instance.parent == null
        // 走这个分支,刚好 appContext.provides 就是父组件的 provides
        ? instance.vnode.appContext.provides  
        : instance.parent.provides
    
      if (provides && key in provides) {
        return provides[key]
      }
    }
    
  • 所以所有命令式组件都能访问父组件提供的数据

4. 为什么不会互相污染?

  • 每个实例的 provides 是不同的对象
  • 调用 provide() 时,数据写入各自实例的 provides 对象
  • 实例A写入的数据在实例A的 provides 对象上
  • 实例B写入的数据在实例B的 provides 对象上
  • 它们之间没有直接联系,所以不会互相影响

实际示例

// 父组件提供配置
provide('appConfig', { theme: 'dark', version: '1.0' })

// 创建命令式弹窗
const showModal = useCommandComponent(Modal)

// 打开两个弹窗
const modal1 = showModal({ title: '弹窗1' })
const modal2 = showModal({ title: '弹窗2' })

// 在 Modal 组件内部:
setup() {
  // 两个弹窗都能获取到父组件的 appConfig
  const config = inject('appConfig')
  // config = { theme: 'dark', version: '1.0' }
  
  // 弹窗1 provide 数据
  provide('modalData', 'data from modal1')
  // 这个数据只在 modal1 内部有效
  // modal2 无法访问到
}
❌
❌