尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3实战指南:从组合式API到工程化部署的核心语法与避坑技巧

Vue3实战指南:从组合式API到工程化部署的核心语法与避坑技巧 最近后台收到一堆和 Vue3 相关的搜索词后台管理系统、商城、可视化大屏、扫码枪、海康视频插件、OnlyOffice 集成、从 Vue2 往 Vue3 迁移……各种业务形态都有。我其实特别理解这些搜索因为 Vue3 出来这几年真正让开发者焦虑的并不是语法好不好看而是“我到底能不能用一个新项目把它完整落地”。组合式 API、响应式原理、组件通信、Pinia、Vite 初始化、Nginx 部署再叠加各种第三方库的兼容问题一整套走下来确实要磨掉一层皮。这篇不是官方文档的阉割版也不打算重复写一个 Hello World 就收尾。我想把 Vue3 核心语法和实战技巧揉在一起所有高频语法点拆开来讲重点说清楚每个设计背后的原因再带着你走一遍常见的项目工程化流程。比较适合刚学完 Vue2 准备升级的开发者也适合已经在写 Vue3 项目、但经常在组件通信和工程配置上卡壳的人。1. 核心语法设计Composition API 为什么值得重构你的写法1.1 setup 的真实角色从分散到聚合Vue2 里写一个业务模块逻辑是按照 data、computed、methods、watch 分的同一个业务需求里的变量和函数会被拆到四个不同的位置。小项目还好项目一旦到后台管理这种体量改一个下单流程要在文件里来回跳心智负担非常大。Vue3 的 setup 之所以重要是因为它改变了代码的组织方式。你不再必须按选项分类而是按“业务意图”去组合。比如订单模块的请求参数、计算价格的方法、订单状态的监听代码就可以挨着写在一起。执行时机方面setup 在组件实例创建之前就执行比 beforeCreate 还早所以它里面取不到 this也没有办法通过 this 去访问 data、computed。处理方式是使用 ref、reactive 建立响应式状态。实际开发中我更推荐直接用 script setup 语法糖。它可以少写一个 export default在编译阶段自动把顶层变量暴露给模板使用。一个普通组件可以简化成script setup langts import { ref, computed } from vue const count ref(0) const double computed(() count.value * 2) /script template div{{ count }} * 2 {{ double }}/div /templatescript setup 里不需要再写 returnimport 进来的组件也直接可用模板里能用到的变量在编译期就确定了类型推导也更好一些。新项目统一用这种写法能省掉不少模板代码也更容易保证团队成员写出来的组件结构是一致的。1.2 ref、reactive 与 computed响应式三件套的选型逻辑ref 的本质是把值装进一个带 value 属性的包装对象reactive 则是用 Proxy 对对象进行深层代理。两者最终都会进入依赖收集和派发更新的响应式流程区别只在使用体验上。特性refreactive适用类型基本类型、对象均可对象、数组、Map 等访问方式脚本里要 .value直接取值模板使用自动解包直接使用解构丢失否会丢失响应性TS 类型提示好在泛型推断上容易漏我现在的习惯是能 ref 就 ref连对象也尽量用 ref。原因有两个第一是模板和逻辑里写法统一不用反复思考第二是解构时不容易出问题。如果一定要用 reactive 并且想把属性解构出来要用 toRefs 转一层否则解构出来的变量就是普通值。computed 是建立在依赖追踪之上的惰性计算。它只会在依赖变化时重算并且会缓存结果。和直接写在函数里的区别是computed 结果可以像响应式状态一样在模板中被订阅只要依赖的 ref 或 reactive 变了视图就会更新。有一个容易踩的坑在 computed 的 getter 里不要做副作用操作也不要在模板里写特别复杂的表达式所有计算逻辑都应该收进 computed。如果在开发工具里看到 computed 反复触发但依赖没变优先排查是否在 getter 里改了别的响应式状态。还有一点很多人问 Vue3.4 之后有没有更简洁的模板引用方式。新版本有 useTemplateRef但日常绑定 DOM 或组件实例用 ref 已经足够如果不是特别敏感的性能场景不需要一上来就升级到最新 API。1.3 watch 与 watchEffect按需监听的取舍watch 和 watchEffect 都用来处理副作用逻辑但适用场景不同。watch 需要显式指定要监听的源在依赖变化时执行回调适合“某个状态变化后要做什么”的场景watchEffect 则不需要手动指定源它会自动跟踪回调函数里访问到的所有响应式状态适合“随状态自动重新执行”的场景。举个例子团队列表接口要重新拉取并且用户切换团队时马上触发watch(currentTeamId, async (newId) { const res await fetchTeamList(newId) teamList.value res.data }, { immediate: true })如果是登录页里根据多个字段组合判断按钮是否可用watchEffect 更好用watchEffect(() { const canSubmit username.value.length 3 password.value.length 6 submitBtnDisabled.value !canSubmit })watchEffect 有一点要注意回调里的代码如果依赖异步结果它不会自动追踪异步之后的状态。需要异步之后再建立的依赖我一般手动用一个 watch 去监听。清理副作用也很重要。比如搜索框防抖用户在等待期间切换了团队或课程上一次的 setTimeout 还占着内存。可以把清理逻辑放到 onInvalidate 回调里时间一到就会把上一次的未完成请求标记掉避免重复执行。2. 组件通信与复用defineProps、defineEmits 与 defineComponent 的实战2.1 defineProps声明、默认值与响应式赋值组件通信是项目开发里最高频的场景之一。Vue3 里接收外层传入的数据标准做法是 defineProps。interface UserInfo { id: number name: string role?: string } const props defineProps{ user: UserInfo editable?: boolean }()如果不使用 TypeScript可以换成对象写法const props defineProps({ user: { type: Object, required: true }, editable: { type: Boolean, default: true } })需要默认值时推荐用 withDefaultswithDefaults(defineProps{ user: UserInfo; editable?: boolean }(), { editable: true })很多人在工作和面试里都会问“props 怎么赋值给 data”。直接写const localName props.name的问题在于localName 只是在 setup 执行时读了一次之后 props 变化它不会跟着变。正确做法要分场景如果只是需要派生显示值用 computed 包一层如果需要改不要直接改 props要么通知父组件更新要么用一个 ref 接收再用 watch 或 watchEffect 同步。用 watch 同步时记得打开 immediate: true否则首次渲染时本地值会拿不到默认。我遇到过不少次这种问题接口返回后 props 更新但本地 ref 因为没开 immediate 导致表格默认筛选条件一直为空。这种问题在控制台很难一眼看出来排查成本很高。2.2 defineEmits 与 v-model从零实现一个可控组件子组件在修改自己内部“状态”时不能直接写 props而是要通过 emit 通知父组件。在 script setup 里我们使用 defineEmits 声明。script setup langts const emit defineEmits{ (e: update:modelValue, value: string): void (e: save): void }() function handleInput(e: Event) { emit(update:modelValue, (e.target as HTMLInputElement).value) } /script这样组件的 v-model 就能正常支持UserInput v-modelusername /Vue 3.4 之后推荐用 defineModel 来简化const model defineModelstring(username, { default: })defineModel 展开后其实就是 props 加 emit 的封装但写起来直观很多。在管理员管理、商品筛选这类表单组件中体验提升非常明显。如果项目里还在用 Vue3.3 或更早版本再用回 defineEmits 就好。2.3 defineComponent 报错“is not defined”的真相搜索热词里有一条“vue3 vite5 总是报 definecomponent is not defined”遇到的人很多。先说明一下defineComponent 在 Vue3 里的作用是让 TypeScript 对 props、emits、setup 返回对象的类型推断更准确并且在“选项式写法”下提供更友好的类型提示。它并不是编写组件必须的 APIscript setup 模式下不写也能正常跑。那为什么会报 is not defined最常见的三种情况你手动写了 defineComponent但没有 import。例如某段代码里使用defineComponent({ ... })文件顶部却没写import { defineComponent } from vue。这种问题在开启严格 ESLint 时更明显编辑器可能自动修复了也可能直接标红。你使用了 unplugin-auto-import但没配置自动导入 Vue API。unplugin-auto-import 需要声明imports: [vue]否则代码里出现的 defineComponent、ref、computed 都不会被自动注入。某个第三方组件或 d.ts 类型声明把 defineComponent 指到了别处。比如自定义 uni-app 或者某库存根目录下有个同名全局变量或者 tsconfig 里的 paths 配置把 vue 指向了另一个别名库这种最隐蔽排查起来也比较费劲。排查顺序还是建议先看 import 行再看 eslint 和自动导入插件的配置最后看 tsconfig 里的路径映射。绝大多数情况下是前两种把 import 补上或者在自动导入里加一行配置就能解决。3. 生态整合与进阶玩法Pinia、可视化大屏与第三方库接入3.1 Pinia 状态管理从 Vuex 平滑迁移全局状态除了组件通信还有一部分业务数据需要跨页面共享。Vue3 官方推荐的是 Pinia。对比 VuexPinia 去掉了 mutations在 store 里直接调用 action 就能修改 state也支持 setup store 的写法用起来更像普通 Composition API。import { defineStore } from pinia import { ref } from vue export const useUserStore defineStore(user, () { const token ref() const userInfo refUserInfo | null(null) async function login(username: string, password: string) { const res await apiLogin(username, password) token.value res.token userInfo.value res.userInfo } function logout() { token.value userInfo.value null } return { token, userInfo, login, logout } })在页面里直接const userStore useUserStore()从 store 里取状态时如果用解构语法记得用 storeToRefs否则取出来的是普通值不再具备响应性import { storeToRefs } from pinia const { token, userInfo } storeToRefs(userStore)很多从 Vue2 迁过来的项目Vuex 和 Pinia 会同时存在一段时间。如果团队想渐进式迁移可以考虑新模块的状态用 Pinia 管理老模块继续用 Vuex两个库可以共存不需要一次性重写所有 store。不过我还是倾向于尽早统一下来否则团队里每个人写状态的思维方式都不一样协作成本更高。3.2 ECharts、Mapbox GL 与 EventSource 的响应式接入做后台管理系统和可视化大屏基本绕不开 ECharts。在 Vue3 中使用 ECharts 有几个核心步骤初始化实例、setOption、监听容器尺寸变化调用 resize、组件卸载时 dispose。import * as echarts from echarts/core import { BarChart, LineChart } from echarts/charts import { GridComponent, TooltipComponent } from echarts/components import { CanvasRenderer } from echarts/renderers echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, CanvasRenderer])按需引入能明显减小打包体积。我见过一个项目全量引入 echarts首屏包体直接多出接近 1MB。可视化大屏还好后台页面如果图表很多强烈建议按需。容器适配方面如果用了 flex 布局且宽度会变化监听 resize 时要加防抖不然拖动侧边栏时图表会疯狂重绘。还可以在容器上绑定 ResizeObserver只在可见区域变化时调整尺寸。Mapbox GL 的接入其实不复杂初始化方式与 ECharts 类似难点主要在 token 管理、地图样式加载和事件绑定。在 Vue3 里要注意地图实例不要塞进 reactive 或 ref 里mapbox 内部对象很大放进响应式系统反而会触发大量无意义的依赖收集。我的做法是直接用一个普通模块变量保存 map 实例。EventSource 是处理服务端推送的好东西。Vue3 里封装一个统一的事件流 hooks 很实用服务端有数据变更时推给前端前端自动更新列表。要小心的点是 EventSource 断线重连策略要自己实现后端一般也会加心跳。如果项目里用 SSE 且要兼容老浏览器eventsource-polyfill 可以自动降级处理。还有人问过 Vue3 和 OnlyOffice 怎么接。本质上是在页面里嵌入一个 iframe指向 OnlyOffice 的编辑页 URL然后用 token 打开文档进行在线协作。走到这步你很容易碰到“vue3 嵌套 iframe 点击事件失效”的问题后面专门会讲。3.3 长列表性能优化vue-virtual-scroller 与懒加载后台管理系统里最让人头疼的是表格和列表数据量一大DOM 节点一多页面就开始卡。此时不需要立刻上重型架构先用懒加载再上虚拟滚动。懒加载的思路是列表滚动到接近底部时再去取下一页数据可以用 IntersectionObserver 监听一个 sentinel 元素。Vue3 中可以直接封装成一个指令const vLoadMore { mounted(el, binding) { const observer new IntersectionObserver((entries) { if (entries[0].isIntersecting) { binding.value() } }, { rootMargin: 120px }) observer.observe(el) el._observer observer }, unmounted(el) { el._observer?.disconnect() } }虚拟滚动则是只渲染视口内的行用 absolute 定位加 translateY 模拟纵向位置。vue-virtual-scroller 在 Vue3 下支持还可以能直接用在树形表格或卡片列表上。不过虚拟列表对滚动容器的要求比较高用了之后行内做弹层或浮层时容易定位错位需要额外处理。如果只是表格更简单的方向是服务端分页。前端一次性渲染几十万行虚拟滚动再快也救不了样式和操作体验数据侧的分页才是根治方案。4. 工程化落地从 Vite 初始化到 Nginx 部署4.1 Vite Vue3 TS 项目初始化与目录规划新项目基本都是 Vite 起步几分钟就能把骨架搭出来。npm create vitelatest my-admin -- --template vue-ts cd my-admin npm install npm run dev如果 Node 版本太低或者本机是 Windows 7 那种老环境Vite 的新版本不一定跑得起来。Win7 环境最多能装到 Node 16/18 的某些小版本Vite 4 之前还能将就Vite 5 之后就比较吃力了。这种老环境里的项目要么用 Vite 4 Vue 3.3要么换成偏兼容性的构建方式看团队取舍。目录规划方面我习惯按业务模块组织而不是按文件类型堆src/ api/ components/ views/ dashboard/ user/ order/ stores/ composables/ utils/ layouts/ router/ types/每个业务模块的页面、组件、状态、接口都离得很近团队并行开发时冲突会少很多。后台管理、商城、鲜花商城 App 这类项目体量都不小提前把目录定清楚能省下后面重构的时间。4.2 开发环境代理、环境变量与 axios 封装前后端分离开发时跨域是逃不开的问题。Vite 开发服务器可以在 vite.config.ts 里配代理export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })我自己习惯把 baseURL 写成相对路径 /api接口路径本身不带域名。开发环境走代理生产环境在 Nginx 里做一次转发代码里不用判断环境。环境变量方面Vite 用 import.meta.env只有以 VITE_ 开头的变量才会暴露给前端const baseURL import.meta.env.VITE_API_BASE_URL || /apiaxios 封装时要注意请求拦截器里加 token响应拦截器里统一处理 401 跳转登录和服务端返回的 errorMsg。Vue3 项目里拦截器经常会把 loading 状态做成全局的但注意不要每个请求都转 loading否则并发请求一多转圈会反复闪烁。4.3 SPA 的 Nginx 部署与 history 路由 fallback部署这一块最常见的问题页面刷新后 404。原因在于项目用了 history 模式路由而 Nginx 只按路径去找对应文件找不到就返回 404。正确方案是配置 try_files 把不存在的路径回退到 index.html。server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } gzip on; gzip_types text/plain text/css application/javascript application/json; }部署前记得npm run build如果不放心可以先本地起一个静态服务器验证比如npx serve dist确认构建产物能跑通再上服务器。静态资源缓存策略有讲究。index.html 不设强缓存保证每次发布后浏览器能拿到最新的入口文件带 hash 的 js/css 可以设置较长的强缓存时间这样版本更新的同时还能吃满缓存加速。不知道你们有没有遇到过“发完版本用户还在用旧页面”的情况多半就是 index.html 被缓存了。4.4 Vue2 迁 Vue3写法差异与团队协作注意点很多团队从 Vue2 迁移过来搜索词里也有 uniapp Vue2 转 Vue3、若依 Vue3 TS 报错、jeecgboot 迁移 antd4 相关的问题。主要差异可以归纳成这张表维度Vue2Vue3数据定义data() 返回对象ref / reactive计算属性computed: {}computed(() ...)监听watch: {}watch(source, cb)方法methods: {}setup 中的普通函数生命周期beforeDestroyonBeforeUnmount事件this.$emitdefineEmits emit全局 APIVue.use / Vue.prototypeapp.use / app.config.globalProperties如果是从 uniapp 的 Vue2 转 Vue3还要额外注意uni-app 的 Vue3 版本本质上是编译到小程序和 H5两者在小程序端某些写法仍然不兼容比如不能在模板里使用复杂表达式。若依这种老框架升级到 Vue3 时常遇到的报错大多来源于全局 api 和 webpack 别名在 Vite 下失效排查时先看 tsconfig 和 vite resolve.alias 是否一致。不管用什么脚手架团队迁移节奏我建议是先统一语法规范再动工程配置。改代码风格和改构建链路同时进行会让人分不清报错到底来自哪一边。jeecgboot 这种老平台在迁 antd4 的时候组件库升级往往比 Vue 版本升级更让人头疼要单独拉一轮回归测试。5. 高频疑难杂症六个真实翻车现场与排查过程5.1 嵌套 iframe 点击事件失效一次诡异的“点击穿透”有段时间群里老有人问 iframe 嵌到页面里之后点击 iframe 区域外层 div 的 click 事件不触发。这个问题本质是 iframe 是一个独立窗口鼠标事件在进入 iframe 后父页面的监听器就接收不到了。如果只是想在外层做个遮罩或提示可以先把 iframe 的 pointer-events 设为 none事件自然就会被父级接收iframe { pointer-events: none; }页面里需要用户和 iframe 交互时等鼠标移动进来再打开 pointer-events。还有一种思路是监听 window 的 blur 事件iframe 获得焦点会导致父页面 window 失焦反过来能推断用户点击了 iframe。OnlyOffice 或第三方地图嵌入场景可以考虑用这类事件桥接做点击检测。简单场景用 pointer-events 即可复杂场景建议靠后端配合用 postMessage 做双向通信不要和浏览器底层事件隔离机制硬刚。我们在做 OnlyOffice 集成时就是用 postMessage 传递文档保存状态父页面再根据消息刷新业务数据。5.2 Edge 浏览器里“最小化按钮关不掉”是怎么回事这个热词很有意思“vue3 项目在 Edge 浏览器中有时候无法关闭浏览器右上角的最小化按钮”。我推测更准确的说法是点击浏览器右上角关闭按钮时窗口不关闭或者关闭行为不生效。这个问题和 Vue3 本身关系不大更多是浏览器层面的行为。比较常见的原因是页面里注册了window.onbeforeunload或者某个全局拦截浏览器弹出了“确定要离开此页面吗”的提示框然后又被页面逻辑自动关闭导致看起来像“关不掉”。另外如果页面里监听了 keydown 事件且阻止了默认行为某些版本 Edge 会把快捷键也一起拦截。排查路径建议这样先用无痕模式跑一遍排除扩展程序干扰再关掉浏览器硬件加速看是不是渲染进程崩溃导致的界面卡死最后检查页面里 window 事件监听有没有 unbind。和 Chrome 相比Edge 在关闭页面时会多做一些同步校验某些第三方插件会在这里卡住。如果确认是页面内代码导致的一个常规手段是在 beforeunload 里限制只允许浏览器自带的确认弹窗而不是自己弹窗口。如果在 Electron 壳里跑 Vue3问题就更偏 Electron 的 close 事件拦截逻辑了需要看主进程里 close 事件的 preventDefault 和 destroy 的配合。5.3 扫码枪输入丢失焦点把硬件输入变成业务输入扫码枪本质上是键盘设备扫码成功等于快速输入一串字符再带一个回车。它最大的坑在于如果页面当前焦点在一个输入框里扫码内容会直接灌进输入框没有焦点时字符又会跑到一些意想不到的地方。最直接的做法是把扫码输出重定向到一个全局隐藏输入框始终让这个输入框持有焦点。但移动端或者多标签页场景下这个方法不太好用更好的方案是拦截 window 上的 keydown 事件组装成一段字符串再交给业务处理连续输入且时间间隔极短时判定为一次扫码。let buffer let lastTime 0 window.addEventListener(keydown, (e) { const now Date.now() if (now - lastTime 30) { buffer e.key } else { buffer e.key } lastTime now if (e.key Enter buffer.length 6) { handleScanResult(buffer) buffer } })注意移动端扫码枪基本不走键盘输入走的是蓝牙 HID 或摄像头 SDK这部分的逻辑要再封装一层适配。5.4 自定义 v-ellipsis 指令让 el-tooltip 只在溢出时出现表格里内容一长通常要加省略号然后 hover 时显示完整内容。最笨的办法是每个格子都写一遍判断后来我用一条自定义指令把这件事收敛了。思路是给元素绑定一个 tooltip 实例渲染完成后检查 scrollWidth 是否大于 clientWidth如果大于就绑定 el-tooltip 的内容否则移除内容。const vEllipsis { mounted(el: HTMLElement, binding) { function update() { const isOverflow el.scrollWidth el.clientWidth if (isOverflow binding.value) { el.setAttribute(data-tip, binding.value) } else { el.removeAttribute(data-tip) } } const observer new ResizeObserver(update) observer.observe(el) el._resizeObserver observer update() }, unmounted(el: HTMLElement) { el._resizeObserver?.disconnect() } }这样无论表格宽度怎么变只要内容放得下tooltip 就不会多此一举地弹出来内容溢出了才显示完整信息。配合 hidden overflow 样式视觉效果就非常干净。在后台管理系统所有 table 列里统一用这个指令可以省掉大量重复代码。5.5 元素容器拖拽自适应与容器碰撞检测后台管理大屏经常遇到“拖拽面板自适应”的需求面板之间互相拖动改变大小或者面板在容器内自由拖动拖完后还能按比例缩放适配不同屏幕。有两个方向拖拽改尺寸用 SplitPane 组件或 CSS grid resize 事件监听拖拽移动位置用自定义 drag 指令记录偏移量再换算成百分比坐标百分比坐标换算在可视化大屏里尤其重要。大屏项目通常要适配 1920x1080 和其他各种分辨率不能写死像素值。拖拽结束后把当前元素的 left/top 转成相对父容器宽高的百分比保存到后端或 localStorage下次进入时再按比例渲染回来。我做这类需求时踩过的坑是拖拽事件里如果直接改 style.left在拖动过程中频繁触发回流会出现明显卡顿。改成用 transform: translate 移动性能会好很多拖拽结束后再把最终位置落回 left/top 并清除 transform。到这里Vue3 核心语法和实战技巧就说完了。我最想强调的还是开头那句话Vue3 不难难的是放下 Vue2 时期的惯性思维。如果你是从 Vue2 过来的人第一次写 script setup 可能会觉得有点乱这很正常多写几个模块就会喜欢上这种“按业务组织代码”的感觉。真正遇到问题的时候建议优先看官方文档里的迁移指南然后对照这份排查逻辑走import 是否正确、响应式是否丢失、事件绑定是否被浏览器拦截、工程配置是否和构建代码匹配。把这几点养成本能反应Vue3 项目基本上不会让你深夜崩溃。以上这些坑都是我基于自己项目的真实经历整理的不同项目环境不一样如果你们遇到更特殊的解法欢迎一起交流。
返回列表