1. 全局变量的“全局”到底指什么
先聊一个很多人刚接触 Vue 时都会遇到的问题:项目刚搭起来的时候,组件之间传数据用 props 和 emit 就够了,父传子、子传父,清晰得很。但一旦项目过了十个页面、十几个组件的规模,你会发现有几个数据几乎每个页面都要用:登录后的用户信息、接口的请求地址、按钮的权限标识、主题色、甚至当前选择的是哪个设备。这时候如果还用 props 一层层往下穿,改一个字段要顺着组件树摸半天,不疯也快崩溃了。
这里说的“全局变量”,本质上就是要解决这种“到处都要用、到处都能改(或读)”的数据共享问题。但在 Vue 里,事情远不止“定义一个变量挂到 window 上”这么简单。我见过不少刚入门的朋友,图省事直接在 main.js 里写window.apiBaseUrl = '...',组件里用起来也确实方便,但问题是:这个变量没有任何响应式能力,页面不会因为它的变化而更新;测试环境里挂载了一堆全局变量,不利于模块化;而且团队协作时,全局命名空间被污染,排查问题极其痛苦。
所以,真正要搞清楚的不是“怎么定义一个对象挂到全局”,而是“在不同需求下,用 Vue 生态里哪种方案最合适”。我习惯把 Vue 的全局变量场景拆成三类:
- 编译期注入的配置类变量:比如接口地址、应用名称等,不同环境值不同,用环境变量管理。
- 运行时共享的响应式状态:比如用户信息、登录 token、购物车数据,跨组件实时联动,用 Pinia 或 Vuex。
- 挂在应用实例上的方法或组件:比如全局的弹窗方法、防抖函数、通用按钮组件,用
globalProperties或 provide/inject。
这三类如果混为一谈,就会出现“明明改了 store 里的值,页面却不刷新”这类奇奇怪怪的问题。下面我会分别拆开讲,每一步都给到可以直接抄的代码和踩坑记录。
2. 环境变量:把配置和代码分离的第一道关
2.1 不同环境下的全局配置管理
先说第一类——配置类变量。比如你的项目在开发环境请求http://localhost:8080,在测试环境请求http://test-api.example.com,正式环境请求https://api.example.com。这些地址如果直接写在组件里,上线前改一遍还能忍,但如果接口地址还分登录服务、文件服务、业务服务好几个域名,硬编码一定会出事故。
Vue 生态里标准的做法是用.env文件。Vue 2 和 Vue 3 略有区别,但核心逻辑一致。Vue 3 配合 Vite 时,变量名需要以VITE_开头,代码里通过import.meta.env访问;Vue 2 配合 Vue CLI 时,变量名以VUE_APP_开头,代码里通过process.env访问。
项目根目录下新建三个文件:
.env # 所有环境共享 .env.development # 开发环境 .env.production # 生产环境.env文件内容很简单,就是 key-value:
# .env.development VITE_APP_TITLE=本地开发环境 VITE_APP_API_BASE=/api VITE_APP_UPLOAD_URL=http://localhost:3000/upload组件里这么用:
const apiBase = import.meta.env.VITE_APP_API_BASE // 或者 Vue 2 / Vue CLI 项目用 process.env.VUE_APP_API_BASE这里有个很容易踩的坑:修改.env文件后,必须重启开发服务器。因为环境变量是在构建时就被静态替换进代码里的,你改了文件,跑着的vite或vue-cli-service serve并不会热更新这个变化。我第一次用的时候改完.env等了半天,页面始终拿到旧值,后来才发现重启后就好了,这点特别值得新手注意。
2.2 为什么不能把密钥和接口地址都塞进前端环境变量
很多初学者会问:那我把数据库密码、私密 key 也放在VITE_变量里不就行了?这里必须泼一盆冷水。凡是打包后会被浏览器加载的前端代码,里面所有的配置都属于公开信息。你在.env里写的任何值,最终都会出现在打包产物中,任何用户打开浏览器控制台都能看到。
所以我个人的经验是,环境变量只适合放“非敏感的公开配置”,比如接口基础路径、CDN 域名、页面标题、某个组件的默认开关等。真正的敏感操作一定要放到后端去做,后端通过环境变量读取自己的配置,前端只负责把用户的凭证发给后端,让后端再去调那些需要密钥的接口。这不是“不会写代码”的问题,这是安全边界意识。
另外一个实际场景:部署到服务器后,希望运维不重新打包就能改接口地址,这时候.env就不方便了。常见的做法是前端在index.html里加载一个config.js,里面暴露一个全局对象,比如window.APP_CONFIG = { apiBase: '/api' },然后前端代码读取这个对象。这样运维只需要改服务器上的config.js文件,刷新页面即可,不用重新构建。这个方法在生产环境里非常实用,尤其是前端和后端分离部署、多个环境共用一份构建产物的时候。
3. 全局响应式状态:从 Vuex 到 Pinia 的进化
3.1 状态管理到底在管什么
配置类的变量解决了“环境差异”,但真正的业务全局变量,比如“当前登录用户”“购物车数量”“通知未读数”,这些需要跨组件实时同步的数据,得交给 Vue 官方的状态管理库。
Vue 2 时代大家用得最多的是 Vuex,Vue 3 现在官方推荐的是 Pinia。两者的设计思路一脉相承,Pinia 更轻量,去掉了 mutations,写起来更接近普通 JavaScript 对象,对 TypeScript 的支持也更友好。如果你是新项目,直接用 Pinia 就好;老项目如果已经用了 Vuex 4(Vue 3 版),继续用也没问题,不必盲目迁移。
Pinia 的核心思想是“单一数据源”。什么意思呢?就是某个数据只有一份,所有组件都从 store 里读取,通过 action 或直接赋值修改,谁改了,所有用到这个数据的地方自动更新。这比你在每个组件里各自存一份副本、再用事件互相通知要可靠得多。
3.2 一个能直接抄的用户信息 store
下面定义一个非常常见的用户信息 store,对应的场景是:登录后存用户资料,任何页面随时读取,退出登录时清空。
// stores/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: null }), getters: { isLoggedIn: (state) => !!state.token, displayName: (state) => state.userInfo?.nickname || state.userInfo?.username || '未登录' }, actions: { login(token, userInfo) { this.token = token this.userInfo = userInfo localStorage.setItem('token', token) }, updateUserInfo(info) { this.userInfo = { ...this.userInfo, ...info } }, logout() { this.token = '' this.userInfo = null localStorage.removeItem('token') } } })组件里用起来是这样的:
<script setup> import { useUserStore } from '@/stores/user' const userStore = useUserStore() // 读取 console.log(userStore.isLoggedIn) console.log(userStore.displayName) // 修改 userStore.login('abc123', { username: '张三' }) </script>这里有个特别重要的“坑中坑”:Pinia 的 store 解构后会丢失响应式。新手最容易写出下面这样的代码:
const { token, userInfo } = userStore这样解构出来的token和userInfo就是普通字符串和对象,错过任何更新,页面永远不会变。正确做法是要解构也用storeToRefs:
import { storeToRefs } from 'pinia' const { token, userInfo } = storeToRefs(userStore)这个方法专门为了保留响应式而设计,用来解构 state 和 getters 是安全的;而 actions 直接解构也没问题,因为它本来就是普通函数。我第一次踩到“明明调用了 login,页面却还显示未登录”这个问题时,排查了整整一个下午,最后发现就是解构惹的祸。
3.3 多 store 拆分与跨 store 访问
项目一大,把所有状态塞进一个 store 会乱成一锅粥。我建议按业务域拆分,比如用户相关放stores/user.js,购物车相关放stores/cart.js,权限相关放stores/permission.js。在 Pinia 里,一个 store 可以访问另一个 store,这是它比 Vuex 更好用的地方之一:
// stores/cart.js import { defineStore } from 'pinia' import { useUserStore } from './user' export const useCartStore = defineStore('cart', { state: () => ({ items: [] }), actions: { checkout() { const userStore = useUserStore() if (!userStore.isLoggedIn) { throw new Error('请先登录') } // 继续处理下单逻辑 } } })像这样不同的模块各自管理自己的状态,需要联动时通过调用其他 store 的 getter 或 action 完成,代码结构清晰,维护成本低得多。
3.4 持久化:刷新页面后状态还在吗
store 默认是内存里的数据,刷新页面就没了。所以像 token、用户信息这类需要跨页面刷新保留的数据,要么像上面那样手动同步到localStorage,要么用持久化插件。如果要手动管理,注意在初始化 state 时读一次本地缓存,不要让 store 的初值永远是空。
如果项目里有很多需要持久化的状态,建议直接引入pinia-plugin-persistedstate。配置很简单,在定义 store 时加一个persist: true选项,插件会自动把 state 同步到 localStorage,刷新后自动恢复。实测用起来很稳,省去了一堆手动存取代码。
不过持久化也不是越多越好。只给真正需要的 store 开持久化,因为 localStorage 有容量限制(一般 5MB 左右),而且存太多数据会影响页面初始化的解析速度。像接口数据缓存这种东西,该走后端缓存走后端缓存,不要全压到前端存储里。
4. 全局方法与组件:能力注入,而不是数据注入
4.1 全局 properties 挂载方法
除了数据,有时候我们需要一个全局的方法或对象,比如统一的金额格式化函数、消息提示封装、全局的$http请求实例。这种需求用 store 也能做,但更直接的方式是挂到应用实例上。
Vue 3 里的写法:
// main.js import { createApp } from 'vue' import App from './App.vue' const app = createApp(App) app.config.globalProperties.$formatMoney = (value) => { return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(value) } app.mount('#app')组件里可以这样访问:
<script> export default { mounted() { // 选项式 API 中通过 this 访问 console.log(this.$formatMoney(12345.6)) } } </script>如果你用的是组合式 API(script setup),拿不到this,官方推荐的方式是引入一个单独的模块函数:
// utils/format.js export function formatMoney(value) { return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(value) }然后在组件里直接import { formatMoney } from '@/utils/format'使用。这种“普通函数 + import”的方式其实比globalProperties更适合组合式 API 的组件,因为依赖关系更清晰,而且能充分利用编辑器的智能提示。只有当你的方法在很多传统选项式组件里都要用时,globalProperties才更省事。
4.2 provide/inject:让子孙组件都能拿到
globalProperties适合“所有组件都可能用”的场景,但如果只是某一个深层子组件需要父级的数据,又不想一层层传 props,那更合适的是 provide/inject。这个 API 的大概意思是:父组件提供一个值,任意层级的子孙组件都可以注入使用。
<!-- 父组件 --> <script setup> import { provide, ref } from 'vue' const themeColor = ref('#409EFF') const changeTheme = (color) => { themeColor.value = color } provide('theme', { themeColor, changeTheme }) </script><!-- 任意深度的子组件 --> <script setup> import { inject } from 'vue' const { themeColor, changeTheme } = inject('theme') </script>在封装一些业务组件时会非常方便,比如一个UserProvider组件,它负责获取用户数据,然后所有子组件都能直接拿到用户信息,不需要再一层层传 props,也不需要引入 Pinia。如果你的全局数据只在一个组件树内共享,用 provide/inject 比建一个 store 更轻量、更内聚。
4.3 全局组件:懒注册和按需加载
最后说全局组件。把通用组件注册成全局,可以省去每个页面重复 import 的麻烦。Vue 3 的标准做法:
// main.js import { createApp } from 'vue' import BaseButton from '@/components/BaseButton.vue' import BaseTable from '@/components/BaseTable.vue' const app = createApp(App) app.component('BaseButton', BaseButton) app.component('BaseTable', BaseTable) app.mount('#app')但这里有一个性能上的警示:全局注册的组件会全部打包进主 bundle,哪怕某些页面根本没用它。所以全局组件一定要克制,只注册那些真正全站通用、且不会互相依赖的组件,比如基础的按钮、输入框、弹窗这些用得非常频繁的。
一个实际项目中我和团队成员总结的经验是:
- 被 3 个以上页面使用,且形态基本一致 → 全局注册。
- 只被某个业务模块使用,即使内部被多次复用 → 放在该模块的
components目录里局部注册。 - 体积较大、只在特定场景使用 → 用
defineAsyncComponent异步加载,配合路由懒加载,避免影响首屏性能。
// 异步注册大组件 app.component('BigReportTable', defineAsyncComponent(() => import('@/components/BigReportTable.vue') ))这样既保证了“随处可用”的便利,又不至于让首屏包变得过大。
5. 那些让人抓狂的“全局变量失效”场景
5.1 对象赋值了页面却不更新
这个问题在热搜词里反复出现,大概率是刚接触 Vue 的同学都会遇到:给 data 里的某个对象赋了新值,或者新增了一个属性,页面不刷新;但用this.$set或者重新赋值整个对象就可以了。原因要追溯到 Vue 2 的响应式原理——它通过Object.defineProperty给属性添加 getter/setter,只有初始化时存在的属性才具备响应式能力,后期新增或删除的属性,Vue 2 无法感知。Vue 3 改用 Proxy 后这个限制基本没有了,新增属性也能自动响应。
但有些团队还在维护 Vue 2 的老项目,这里给出排查顺序:
- 检查是不是直接新增了一个对象上原先不存在的属性,如果是,用
this.$set(obj, key, value)。 - 检查是不是对数组下标赋值或修改长度,如果是,用
splice方法替换。 - 检查是不是解构出的变量,解构后就失去了响应式连接,改用
storeToRefs或直接使用store.xxx。 - 最后再确认是不是修改了 store,但组件里用的是“浅拷贝副本”,而不是 store 本身。
我见过最离谱的一次,是有人把userInfo从 store 里取出来后,在一个很深的子组件里直接改的是副本对象的属性,然后又通过事件通知父组件“我要更新用户信息”,结果父组件里拿到的还是旧引用,一层层查下来,半天时间就耗在这了。这种问题靠代码 review 很难发现,最好的办法其实是定好规矩:全局状态的修改只允许通过 store 的 action 完成,任何组件不得直接修改 store state 的引用。
5.2 环境变量在打包后成了四不像
有朋友用 Vite 开发时,在代码里写了import.meta.env.VITE_APP_API_BASE,本地跑得好好的,打包部署到服务器后就懵了:接口 404,控制台显示请求打到服务器的根路径去了。检查了半天,发现是baseURL拼接的问题。
比如你在.env.production里写了:
VITE_APP_API_BASE=/api然后封装请求时:
const request = axios.create({ baseURL: import.meta.env.VITE_APP_API_BASE, // 结果是 /api timeout: 10000 })看起来没毛病。但如果后端网关要求你请求的完整路径是/api/v1/user/list,而你在请求拦截器里又拼接了一级路径,或者后端反代配置要求api不以斜杠结尾,就会出现 404 或 301 的诡异现象。
这个问题的核心是:环境变量只负责注入字符串,不做任何路径规范化。我建议在项目的请求模块里统一处理 baseURL 的拼接,比如封装一个getApiUrl(path)函数,负责去掉重复斜杠、拼接版本号等,不要在业务组件里到处拼接口地址。这样即使将来环境变量的值改了,只需要改一个地方。
另外,打包后布局异常或者静态资源 404,通常也和base配置有关。Vite 默认base是/,如果你的应用部署在子路径下,比如https://example.com/admin/,就需要在vite.config.js里设置base: '/admin/'。这个不设置对,打包后的 JS、CSS 路径全是根路径,自然找不到资源。环境变量也可参与进来,按环境分别配置,就能避免打包后大量找资源失败的问题。
5.3 全局变量用多了,命名冲突和调试难题
全局变量这个方案最隐蔽的坑,其实是“项目越写越大,全局命名空间越来越乱”。今天挂一个$util,明天挂一个$utils,后天又来一个$common,团队里的人各挂各的,最后根本分不清哪些是框架内置的、哪些是插件注入的、哪些是同事自定义的。
我给出的实操建议是:
- 全局 properties 上只挂真正“全项目都会用、且语义明确”的能力,控制在 5 个以内,且命名统一加前缀,比如
$app、$http、$auth。 - store 的命名按业务域划分,禁止出现一个
commonstore 塞所有东西。 - 环境变量统一维护在一个文档里,注明每个变量的职责、可选项、示例值;部署交接给运维时,这个文档比代码注释更重要。
- 定期清理:找一下 unused 的全局注册组件、没有引用的 Vuex module,删掉比留着好。
全局变量的本质是刻意制造的“公共空间”,用得好效率极高,用得烂就是把所有组件耦合在一起。我的体会是:全局的东西越少越好,每新增一个都要问自己三遍——真的需要全局吗?局部方案行不行?这种谨慎的态度会让项目在规模和版本迭代中走得更远。
5.4 面试题里最常问的几种全局方案
最后顺便聊聊 Vue 全局变量在面试里最常被问到的考点。因为 Vue 面试题中全局相关的内容出现频率其实很高,总结一下常见的追问思路:
- 如果面试官问“Vue 里怎么做全局变量”,参考答案分三层:环境变量、状态管理(Pinia/Vuex)、全局属性/组件。
- 如果问“为什么不用 window 对象存全局变量”,核心回答点是响应式缺失、命名污染、模块化破坏。
- 如果问“Vuex 和 Pinia 的区别”,核心回答点是 Pinia 去掉 mutations、更友好的 TS 支持、store 间互相调用更简单、体积更小。
- 如果问“provide/inject 和 vuex 的区别”,核心回答点是 provide/inject 作用于组件树局部,没有时间旅行调试和工具链支持,适合轻量场景。
把上面这些内容串起来,面试的“全局变量”这块基本就能应付了。不过说实话,比面试更重要的是你在真实项目里的体验。全局变量从来不是一道“选择题”,而是需要你结合项目体量、团队规模、部署环境同时想清楚的一套工程决策。真正的功夫,全藏在你项目那几万个文件里。