十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue 3 全局变量六种方案全解析:从 Vue.prototype 到 Pinia 的迁移指南

Vue 3 全局变量六种方案全解析:从 Vue.prototype 到 Pinia 的迁移指南 上个月把项目从 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写法一模一样。但如果使用组合式 APIsetup里没有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_URLhttps://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。
返回列表