上个月把项目从 Vue 2 迁到 Vue 3,团队争论最多的不是组合式 API,而是以前到处可见的Vue.prototype.$xxx全都失效了。老项目里塞满了Vue.prototype.$api = ...、Vue.prototype.$config = ...,迁移第一天就炸了一半。有人提议“全部改 Pinia”,但很多常量根本不需要响应式,引入状态管理反而把简单问题复杂化。我用了一周时间把各种“Vue 全局变量”的方案重新梳理了一遍,不同方案对应不同场景,代码结构也清爽了不少。如果你现在也被“消息怎么在组件间共享”“token 怎么给拦截器用”“Socket 怎么全局唯一”这些问题困扰,这篇文章应该能一次讲透。
1. 从“到处 import”到“一处读取”:我为什么重新审视全局变量
1.1 最常见的三个使用场景
先说需求,别急着写代码。一个正常的前后端分离项目里,我必须从任何组件读到的东西,通常只有三类。
第一类是“登录后才有”的敏感数据,比如 token、用户信息、当前角色、菜单权限。这些数据的特点是:几乎每个接口、每个路由、每个按钮权限判断都会用到。如果每个组件各自localStorage.getItem('token')一遍,一旦 key 改名或缓存结构调整,全项目都要跟着改,迟早出事。
第二类是“环境相关”的配置项,比如 API 基础地址、上传地址、WebSocket 地址、APP 版本号、公司名称等。这类数据不一定响应式,但它们必须集中管理,不能散落在 20 个组件里。
第三类是“跨组件跨路由”的实时状态,比如购物车数量、未读消息数、当前在线状态。这一类表面上也能用全局变量解决,实际更接近“全局状态”,需要响应式和可追踪,应该交给 Pinia 或 Vuex 这类状态管理库。
1.2 全局变量和状态管理不是一回事
很多人在迁移时把全局变量和全局状态画等号,这是个误区。
全局变量的核心诉求是“处处可访问”,全局状态的核心诉求是“变更可追踪、数据可预测、组件可响应”。我举一个非常直观的例子:接口地址BASE_URL从开发环境到测试环境再到生产环境都不一样,它只是部署时注入的一个常量,组件不需要因为它变化而重新渲染,用普通模块导出一个只读常量就够了。但用户昵称和头像不同,它有登录、刷新、登出、修改资料等一系列动作,这些动作必须改变界面,所以它应该是响应式全局状态。
如果一上来就把所有东西塞进store,最后你会得到一个“状态仓库爆炸”:代码里到处都是store.state.foo、store.dispatch('bar'),很多数据根本没有状态流转的必要。我的建议是:常量、枚举、工具方法、运行时单例用“全局变量”方案;会变化且影响视图的数据用“状态管理”方案。两者是互补关系,不是替代关系。
2. 六种全局变量方案的取舍:每种我都踩过坑
2.1 模块级导出变量:最简单也最容易忽略
先说最简单的一种:直接建一个src/global/index.js,用export导出常量,组件里import使用。
// src/global/index.js export const APP_NAME = '智慧运维平台' export const API_BASE_URL = 'https://api.example.com' export const UPLOAD_URL = 'https://upload.example.com' export const SUPPORT_LANGUAGES = ['zh-CN', 'en-US']组件中使用:
import { APP_NAME, API_BASE_URL } from '@/global'这种方式的好处是零依赖、零初始化、类型推导友好、SSR 完全安全,缺点也很明显:它没有真正做到“无处不在”,每个组件还是要写import。但在真实项目里,import本身不麻烦,麻烦的是“你记不记得有这个常量”。只要路径固定、命名规范、文件职责单一,模块导出其实是性价比最高的全局变量方案。我目前所有项目的环境无关常量都走这一套。
2.2 Vue 2 的 Vue.prototype 与 Vue 3 的 app.config.globalProperties
这是从“老项目迁移”时最容易碰到的。Vue 2 里大家习惯这样:
// Vue 2 main.js import Vue from 'vue' Vue.prototype.$appName = '我的应用' // 组件中 console.log(this.$appName)Vue 3 里Vue.prototype不再存在,替代方案是app.config.globalProperties:
// Vue 3 main.js import { createApp } from 'vue' import App from './App.vue' const app = createApp(App) app.config.globalProperties.$appName = '我的应用' app.mount('#app')组件中如果使用选项式 API,写法一模一样。但如果使用组合式 API,setup里没有this,那就要绕一层:
import { getCurrentInstance } from 'vue' export default { setup() { const { proxy } = getCurrentInstance() console.log(proxy.$appName) } }这里有三个坑我全踩过。第一,globalProperties的挂载时机必须在app.mount()之前,否则部分依赖组件实例的插件可能访问不到。第二,globalProperties上的值如果是一个普通对象,它是不会触发组件更新的,这一点放到第 3 节详细说。第三,getCurrentInstance()只能在setup同步环境中调用,你在setTimeout、异步回调里取会得到null。很多面试题问“Vue 3 如何用全局变量”,标准答案就是globalProperties,但真实项目里它更适合挂“全局方法”,而不是挂“全局数据”。
2.3 Pinia / Vuex:真正意义上的全局状态
如果你的数据需要被许多组件反应式读取,还会在运行过程中被修改,那直接上 Pinia。Vue 3 项目我推荐 Pinia,它比 Vuex 少很多样板代码。
// src/store/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: null, roles: [] }), actions: { setAuth(payload) { this.token = payload.token this.userInfo = payload.user this.roles = payload.roles localStorage.setItem('token', payload.token) }, clearAuth() { this.token = '' this.userInfo = null this.roles = [] localStorage.removeItem('token') } } })注意,Pinia 和“全局变量”思路最大的区别是:它把数据变更收敛到 action 中,而不是让每个组件直接store.state.userInfo = xxx。这样做的好处是,你可以像看日志一样回溯数据变化,配合store.$subscribe还能做持久化、埋点、调试。缺点是对于“看一次就不会变的配置项”来说,把常量放进 Pinia 等于杀鸡用牛刀——不但没有变快,反而每次组件访问都要走一层代理,还容易让 store 越来越大,最后变成“上帝对象”。
我的分工习惯是:配置项、关键词列表、枚举值用模块导出;token、用户信息、权限、导航菜单、购物车这种“有生命周期的状态”用 Pinia;临时跨组件通信用provide/inject或事件总线。
2.4 Provide / Inject:给组件树传全局数据
provide/inject并不是真正的全局变量,它的作用范围是“以某个组件为根节点的整棵子树”。它的价值在于跨层级传递,比如从App.vue向所有子孙组件注入某个依赖,避免一层一层props透传。
// App.vue import { provide } from 'vue' import { globalConfig } from '@/global' export default { setup() { provide('globalConfig', globalConfig) } }子孙组件:
export default { setup() { const config = inject('globalConfig') console.log(config.API_BASE_URL) } }如果一个页面里只有少数组件需要这份配置,用它很合适。但如果你在根组件provide了全局配置,理论上所有组件都能inject,但实际上没人会保证“每个组件都记得 inject”。而且inject本身有响应式陷阱:如果传入的是普通对象,父级里替换了对象,子组件不会自动更新;传入ref或reactive则没有这个问题。所以它更适合“注入某个类实例、某个客户端单例、某个固定配置”,而不是“注入一份需要频繁变更的响应式数据”。
2.5 环境变量:不写死在代码里的全局配置
真正意义上的“环境级全局变量”应该用 Vite 的.env文件。以 Vite 项目为例:
# .env.development VITE_API_BASE_URL=/api VITE_APP_TITLE=智慧运维平台(开发) # .env.production VITE_API_BASE_URL=https://api.example.com VITE_APP_TITLE=智慧运维平台代码里通过import.meta.env.VITE_API_BASE_URL读取。
注意几个硬性规则:只有以VITE_开头的变量才会被暴露给客户端代码,其他变量一律不会出现在打包产物里。所以任何敏感密钥、数据库地址、私钥都不能放在以VITE_开头的变量中,打包后它们会被完整暴露在浏览器端。另外,环境变量是在构建时被“原地替换”的,而不是运行时的动态读取,这意味着你修改.env文件必须重新构建才能生效。
这个方案我基本固定用于“接口地址、应用标题、埋点开关”这一类部署期才需要改变的值。它和模块导出联用效果更好:模块里可以导出export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api',这样既保留了统一出口,又允许运维在构建时按环境替换。
2.6 直接挂到 window / globalThis:最暴力,但别乱用
有些项目图省事,直接在main.js里写window.appConfig = {...}。这样确实在任何地方都能访问,甚至不依赖 Vue 加载,但它带来的问题比解决的问题多。
首先,命名空间污染。window上既有浏览器原生属性,也混着第三方 SDK 留下的一堆变量,你在上面再挂一个appConfig,一旦第三方库也有同名变量,后执行的脚本会覆盖前面的。其次,不利于测试。单元测试时你会希望每个测试文件的环境是干净的,但window上的变量会跨用例残留,用例之间互相干扰。最后,SSR 场景下没有window,这份“全局变量”会在服务端直接报错。如果你的项目只在浏览器跑,短期用着还行,但只要考虑测试、服务端渲染、微前端隔离,这个方案会成为头号隐患。
我把这六种方案整理成了一张表,方便你对照选择。
| 方案 | 是否响应式 | 可访问范围 | 初始化时机 | 适合场景 |
|---|---|---|---|---|
| 模块导出变量 | 默认否 | 所有 import 它的模块 | 模块加载时 | 常量、枚举、配置项、工具方法 |
| globalProperties | 默认否 | 所有组件实例 | app.mount() 前 | 全局方法、格式化函数、第三方单例挂载 |
| Pinia/Vuex | 是 | 所有组件和普通 JS 模块 | createApp 时注册插件 | 用户信息、token、购物车等共享状态 |
| provide/inject | 由注入值决定 | 当前组件子树 | 父组件 setup 时 | 跨层级依赖注入、局部单例 |
| 环境变量 | 否 | import.meta.env | 构建时 | 按环境变化的接口地址、开关项 |
| window/globalThis | 否 | 所有 JS 作用域 | 脚本执行时 | 极少数第三方兜底方案,不推荐 |
3. 响应式全局变量的底层逻辑:ref、reactive 和 globalProperties 的区别
3.1 直接挂到 globalProperties 上真的响应式吗
很多人以为app.config.globalProperties.$count = 0之后,组件里this.$count++视图就会更新。实际不会。
原因在于globalProperties只是在组件实例的原型链上增加了一层属性代理,它本身不是响应式容器。Vue 的响应式系统跟踪的是“在setup返回的渲染上下文数据”和“组件数据对象”中发生的访问和修改,而不是组件实例上的任意属性。当你this.$count++时,this是组件实例,它只是一次普通的属性读写,没有经过依赖收集和派发更新的机制,所以视图不会知道这件事。
3.2 用 ref/reactive 包装后挂到 globalProperties
要让globalProperties上的值具备响应式,需要自己包装一层ref或reactive。在 Vue 3 中,模板渲染上下文访问全局属性时,如果属性值是一个ref,会像处理setup返回的ref一样自动解包。
// main.js import { createApp, ref } from 'vue' import App from './App.vue' const app = createApp(App) const globalCount = ref(0) app.config.globalProperties.$count = globalCount app.mount('#app')模板中使用:
<template> <div>{{ $count }}</div> </template>但如果你在<script setup>里想通过proxy.$count拿到这个值并修改,得到的会是一个ref对象,必须.value才能读写:
import { getCurrentInstance } from 'vue' const { proxy } = getCurrentInstance() proxy.$count.value++这种方式能做到“全局响应式”,但globalProperties最大的问题在于它没有具备完整的类型推导能力,而且在setup里访问绕来绕去,写起来并不舒服。如果是比较简单的“全局日期格式化”“全局金额转换”之类的函数,挂globalProperties没问题;如果是“全局用户信息”这种高频读写、需要类型提示的共享状态,我更推荐模块级reactive,或者直接上 Pinia。
3.3 模块级定义响应式状态
不想引入 Pinia 时,可以用一个模块维护全局单例响应式对象,然后组件直接import。
// src/global/userState.js import { reactive } from 'vue' export const userState = reactive({ token: '', userInfo: null, roles: [] }) export function setAuth(payload) { userState.token = payload.token userState.userInfo = payload.user userState.roles = payload.roles localStorage.setItem('token', payload.token) } export function clearAuth() { userState.token = '' userState.userInfo = null userState.roles = [] localStorage.removeItem('token') }这个方案的关键点在于:userState被reactive包裹后,组件模板中直接绑定userState.userInfo.name、userState.token等都是响应式的。任何模块import到的都是同一个代理对象,这就是所谓的“模块级全局响应式”单例。
它的缺点是没有官方 devtools 的时间旅行、没有$subscribe这类调试钩子,项目协作人多之后,大家容易绕过setAuth直接去改userState.token,数据变更变得难以追踪。所以这个方案更适合“小项目”或“刚开始搭建的中间层”,一旦状态逻辑复杂起来,就该换 Pinia。
3.4 嵌套对象和整对象替换的坑
模块级reactive有一个非常经典的坑:如果整个替换reactive对象,会导致之前所有import userState的地方引用的都是旧对象,视图当然不会更新。
// 错误示范 import { userState } from '@/global/userState' // 某个 API 回调里 const data = await fetchUser() userState = { token: data.token, userInfo: data.user, roles: data.roles } // 直接报错或者视图不更新,因为 userState 这个变量绑定被重新赋值了正确做法是Object.assign或逐字段更新:
Object.assign(userState, { token: data.token, userInfo: data.user, roles: data.roles })同样的问题也会出现在组件data上。我见过不少新同事习惯写this.form = res.data,然后表单不更新,就是因为form被整体替换了。对于reactive全局状态,逐属性赋值通常最安全。如果你很喜欢“整个替换”的写法,那就用ref来保存:
import { ref } from 'vue' export const userState = ref({ token: '', userInfo: null, roles: [] }) // 更新时可以整体替换 userState.value = { token: data.token, userInfo: data.user, roles: data.roles }这里userState是ref,模板中访问需要自动解包,直接在模板里写userState.userInfo.name也能正确渲染(Vue 3 模板会解包顶层的 ref),但在 JS 代码里记得加.value。
4. 实战接线:token、路由守卫和全局 WebSocket 如何共用一套变量
4.1 登录态和 token:从全局变量到 axios 拦截器
前端项目最典型的需求是:登录成功后保存 token,之后所有请求自动在请求头带上 token。我推荐用一个模块同时维护内存态和持久化,内存态保证拦截器读取快且统一,localStorage保证刷新页面后还能恢复登录态。
// src/global/auth.js import { ref } from 'vue' export const token = ref(localStorage.getItem('access_token') || '') export const userInfo = ref(JSON.parse(localStorage.getItem('user_info') || 'null')) export function setAuth({ access_token, user }) { token.value = access_token userInfo.value = user localStorage.setItem('access_token', access_token) localStorage.setItem('user_info', JSON.stringify(user)) } export function clearAuth() { token.value = '' userInfo.value = null localStorage.removeItem('access_token') localStorage.removeItem('user_info') }axios 拦截器怎么使用这份全局变量?直接在普通 JS 模块里import即可,不依赖组件实例:
// src/api/request.js import axios from 'axios' import { token, clearAuth } from '@/global/auth' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 15000 }) request.interceptors.request.use(config => { if (token.value) { config.headers.Authorization = `Bearer ${token.value}` } return config }) request.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { clearAuth() router.push('/login') } return Promise.reject(error) } )这里最重要的设计是:auth.js中的token是ref,拦截器通过token.value读取。因为模块本身是单例,token 的值在所有模块间是“活”的,登录页面调用setAuth后,拦截器下一次请求立刻能拿到最新 token,不需要额外通知。
4.2 路由守卫里判断“当前用户角色”
路由守卫最常见的坑是直接读localStorage,读出来是一个 JSON 字符串,判断角色时类型对不上。用全局变量模块则简单很多:
// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' import { userInfo } from '@/global/auth' router.beforeEach((to, from, next) => { if (to.meta.requiresAuth && !userInfo.value) { next('/login') return } const requiredRoles = to.meta.roles if (requiredRoles && !requiredRoles.includes(userInfo.value.role)) { next('/403') return } next() })这里userInfo.value就是当前登录用户对象,角色判断直接点属性即可。另一个细节是:刷新页面后userInfo初始值来自localStorage,所以即使刷新,路由守卫也能立即读到用户信息,不会白屏跳登录页。
4.3 全局 WebSocket 连接:让每个组件都能发消息
WebSocket 这类“连接型单例”特别适合放在全局模块中。比如项目需要实时接收系统通知,我希望所有页面都能监听,所有组件都能发消息。如果用组件生命周期去创建WebSocket,路由切换时连接断断续续,这种体验基本没法用。
// src/global/socket.js import { io } from 'socket.io-client' const socket = io(import.meta.env.VITE_WS_URL, { autoConnect: false, transports: ['websocket'] }) export function connectSocket() { socket.connect() } export function disconnectSocket() { socket.disconnect() } export function sendMsg(event, payload) { socket.emit(event, payload) } export function onMsg(event, callback) { socket.on(event, callback) } export function offMsg(event, callback) { socket.off(event, callback) }在App.vue或登录成功后调用connectSocket(),任何组件需要监听某个消息时,直接import { onMsg } from '@/global/socket'。由于这是普通模块导出,它天然是全局单例,不需要经过 Vue 的依赖注入系统。
不过要注意,WebSocket 连接是长连接,会在刷新页面时断开。有些项目会把连接句柄放到sessionStorage里做“跨页面重连”,但底层还是需要在新页面重新创建连接。比较稳妥的做法是:创建连接后保存一个全局status标记,同时监听close事件做自动重连,避免用户网络波动后静默掉线。另外,在测试场景或 HMR 热更新环境里,模块级单例可能导致重复连接,比较好的做法是在main.js中主动disconnectSocket或设置最大重连次数。
4.4 全局样式变量:不是 JS 的“显式全局变量”
很多人以为“全局变量”只包括 JS 数据,其实样式体系里的全局变量同样重要。SCSS 文件里的$primary-color、$header-height,通过 Vite 的css.preprocessorOptions配置可以做到“任意组件内直接使用,不用手动 import”:
// src/styles/variables.scss $primary-color: #1677ff; $danger-color: #ff4d4f; $header-height: 64px;Vite 配置:
// vite.config.js export default { css: { preprocessorOptions: { scss: { additionalData: `@use "@/styles/variables.scss" as *;` } } } }CSS 自定义属性则更偏向运行时全局变量,适合主题切换:
:root { --primary-color: #1677ff; --page-bg: #f5f6fa; }用法是color: var(--primary-color),它可以在任意组件样式中直接引用,甚至可以在 JS 里通过getComputedStyle(document.documentElement).getPropertyValue('--primary-color')读取。这种“全局变量”和 JS 全局变量配合使用时,要注意命名和覆盖策略,否则会出现第 5 节里提到的样式错乱问题。
5. 全局变量导致意难平事故:排查还原与规避手法
5.1 初始化顺序:App.vue 里访问 undefined
一个真实发生过的报错:在main.js中先app.use(router),再给app.config.globalProperties.$env = 'prod'赋值,结果所有路由组件里访问this.$env都是undefined。原因很明确,globalProperties的挂载晚于路由启动,路由守卫在跳转首个路由时,组件实例还没创建,但路由配置里的beforeEach已经执行了,这时拿到的globalProperties还是空的。
排查这种问题,不能只看调用栈,要理清“谁先执行、谁后执行”。我的规避手法很粗暴:凡是需要在路由守卫、axios 拦截器、Pinia 初始化里使用的全局数据,一律用模块级变量,绝不依赖globalProperties的挂载时机。模块级变量只要被 import,声明式代码就已经执行完毕,不存在“时机之后”的问题。
5.2 globalProperties 上的方法丢失 this 上下文
还有一次,我在globalProperties上挂了一个$hasPermission方法,内部用到this.$refs和组件数据。单独在模板中写$hasPermission('edit')没问题,但我在一个事件回调里写了:
const check = this.$hasPermission check('edit')结果this变成了undefined,直接报错。原因是方法被解构后,丢失了组件实例的绑定。这不是 Vue 的 bug,而是 JS 方法调用的基本规则。规避方法有三种:要么始终以this.$hasPermission()的形式调用;要么用箭头函数把方法挂上去,箭头函数没有自己的this,它捕获的是定义时所在的作用域;要么干脆让它成为纯函数,不依赖组件实例的任何状态。我的经验是,全局方法尽量做成纯函数,参数显式传入,不要把组件内部的 this 当作“全局接口”的一部分。
5.3 命名冲突:一个 mistake 导致“全局变量被篡改”
另一个项目里,有同事在window上挂了一个config对象,专门存前端全局配置。后来引入了一个第三方埋点 SDK,这个 SDK 也在window上定义了config,两个库互相覆盖,结果登录页偶发白屏,配置时灵时不灵。排查了很久才发现是命名冲突。解决方案也很简单:把所有要暴露到window的内容收敛到一个带唯一前缀的命名空间下。
window.__MY_APP__ = { config: { ... }, user: { ... } }但即使这样,它也只是一个“兜底方案”,能不用就不用。更优雅的做法是维护一个状态模块,通过import提供访问入口,这样即使第三方脚本也在全局上写了同名变量,也不会影响你的业务代码,因为你的数据封闭在自己的模块作用域里,不占全局对象。命名空间的全局性,天然不如模块作用域安全。
5.4 看起来和全局变量无关的样式错乱
有次项目打包上线后,高版本浏览器的页面布局出现异常,本地开发环境完全正常。所有人都在排查 CSS 冲突,最后发现是全局 SCSS 变量$danger-color在某个子组件里被局部变量覆盖,而这个局部变量的赋值顺序在不同环境下产生了差异,导致该组件的按钮背景色变成了透明。这个问题的本质,其实和 JS 全局变量一样:全局变量被局部作用域无意中覆盖,然后悄悄影响所有依赖它的地方。
我现在的习惯是:全局样式变量统一用语义化命名,并且放在独立的variables.scss,所有组件不允许重新声明同名变量。如果某个组件确实需要局部调整颜色,就定义自己的局部变量,取一个带组件前缀的名字,例如$button-danger-color,避免撞全局名。这样既能看到“可变的部分”,也不至于污染全局名称空间。
5.5 全局长连接不关,热更新后重复连接
全局 WebSocket 单例在开发环境热更新时尤其容易翻车。Vite 的热更新会重新执行模块代码,但不会自动断开旧的 Socket 连接,结果一个页面上同时存在多个连接,服务端频繁推送重复数据。
我排查过一次,打开 Network 面板发现同一个 WebSocket 地址建立了七八个连接。后来在模块顶层加了一个“连接去重”逻辑:
// src/global/socket.js let socket = null export function getSocket() { if (!socket) { socket = io(import.meta.env.VITE_WS_URL) } return socket }这样即使模块被重新执行,socket在模块作用域里也是闭包变量,只要应用实例没销毁,就能复用同一个连接。如果是刷新页面,浏览器会强制断开所有连接,服务端检测到心跳超时会自动清理,这个不用担心。这里真正要留意的是:全局单例不代表永久不销毁,在应用卸载、用户登出、测试用例清理时,记得调用disconnectSocket(),不然长连接会变成内存泄漏的温床。
全局变量这个主题,说起来简单,做起来全是细节。不同方案各有适用边界,没有“万金油”。我现在的默认组合是:常量用模块导出,登录态用模块级ref,复杂共享状态用 Pinia,跨组件传配置用provide/inject,环境相关配置放.env,不到万不得已不碰window。你在搬代码、重构项目时,先想清楚那句“数据无处不在”到底想解决什么问题,再决定把它放在哪个“全局”里,会让整个项目省掉很多玄学 bug。