Vue3从入门到工程实践:响应式、组件通信与路由全链路

本文系统梳理Vue3从项目初始化(Vite)、响应式原理(Proxy vs defineProperty)、组合式API与选项式API选型,到组件通信六种方式、路由(Vue Router)与状态管理(Pinia)、组合式函数封装、TypeScript集成及性能优化等核心工程实践。重点解析ref/reactive使用陷阱、v-model多参数绑定、provide/inject适用场景、副作用清理与内存泄漏防控,并结合真实踩坑案例提供可落地的调试与排查方法。

前阵子带几个刚入行的同学做项目,发现一个挺普遍的现象:网上讲 Vue3 的文章一抓一大把,但真正能从零把环境跑通、把响应式讲明白、把组件通信和路由串起来的系统性内容反而不多。很多人卡在第一步——装完脚手架就不知道该写什么;或者能照着抄代码,但一旦报错就完全不知所措。所以我把这几年在项目里反复踩过、也反复讲给新人听的内容整理成这一篇,从创建工程一直写到组合式函数、性能优化和报错排查。这里不会堆砌 API 手册,重点讲的是 为什么这么写 、 什么场景该选哪个 、 哪里最容易翻车 。不管你是刚接触前端框架的新手,还是从旧版本迁移过来的老手,都能从里面找到能直接抄走的部分。

1. 动手之前先想清楚:版本、生态与心智模型

我见过太多人一上来就闷头敲代码,结果写到一半发现用的是两套互不兼容的写法,代码越写越乱。Vue3 这一代最大的变化不是多几个 API,而是整套组织代码的思路变了。所以在动手之前,先花十分钟把这几个前提想明白,后面能少走很多弯路。

1.1 选项式 API 与组合式 API 到底该怎么选

选项式 API(Options API)就是那种 data 、 methods 、 computed 、 mounted 分块罗列的写法。它的好处是结构固定,新手一眼能看懂哪块是数据、哪块是方法。但项目一变大,同一个业务逻辑会被拆散到四五个选项块里——比如一个"商品筛选"功能,它的状态在 data 、派生值在 computed 、请求调用在 methods 、初始化在 mounted ,你改需求的时候得在文件里来回跳。

组合式 API(Composition API)把同一个逻辑的所有代码放在一起,配上组合式函数还能整体抽走复用。我的实际判断标准很简单:

  • 页面级组件、逻辑简单、只有两三个字段的,选项式写着更快;
  • 有复用需求、状态超过五个、涉及多个异步请求的,一律用组合式;
  • 一个项目里两种写法混用是允许的,但 同一个组件内不要混着写 ,维护成本会指数上升。

这里有个容易误解的点: <script setup> 不是必须的。它只是一种编译期语法糖,写起来更短,但调试时栈信息会被编译器改写,出问题时行号可能跟源码对不上。我在排查一些诡异的渲染问题时,会临时把它改回 setup() 函数写法来定位。这是很实用的一招,很多人不知道。

还有一点提醒: <script setup> 里定义的顶层变量默认全部暴露给模板,这意味着你写个 const temp = ... 也会被塞进模板作用域。虽然没有性能问题,但组件多了之后容易命名冲突,建议临时变量统一加前缀。

1.2 项目初始化:为什么我更愿意用 Vite

老项目里那套基于打包器的脚手架,改一行代码要等三四秒才刷新,热更新体验很差。现在的构建工具走的是原生模块按需编译路线,启动基本是秒级,热更新通常在一百毫秒以内。我实测过一个中型项目,冷启动从二十多秒降到一秒多,这个差距在频繁调试的时候非常影响状态。

初始化命令很简单:

# 交互式选择框架与语言
npm create vite@latest my-app

# 或者直接一步到位
npm create vite@latest my-app -- --template vue

如果要用 TypeScript,把模板换成 vue-ts 就行。命令行里的 -- 是必须的,它告诉包管理器后面的参数要传给被调用的工具,而不是自己吞掉。这个细节坑过不少人,不加这个符号,你会看到工具启动了交互式提问,而不是按你的参数走。

装完依赖后执行 npm run dev ,默认端口是 5173。我建议在 vite.config.js 里顺手配置几个东西:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  server: {
    port: 5173,
    open: true,
    proxy: {
      '/api': {
        target: 'http://127.0.0.1:8080',
        changeOrigin: true,
        rewrite: path => path.replace(/^\/api/, '')
      }
    }
  }
})

alias 配了之后,任何层级都能直接 import xxx from '@/utils/xxx' ,不用写一串 ../../../ 。 server.proxy 是本地联调必备,前端跑在 5173,后端跑在 8080,跨域问题直接在开发服务器层解决掉,不用麻烦后端加响应头。这里有个坑: rewrite 如果写错,接口会 404,但控制台不会报错,只是返回空白。排查方法是在浏览器网络面板里看实际请求的完整路径,一眼就能看出前缀有没有被正确剥掉。

1.3 目录结构该按什么逻辑分层

我不太建议一上来就套用复杂的分层规范。但有一条经验值得参考: 按业务域分目录,而不是按文件类型分目录 。也就是说,不要建一个巨大的 components 目录塞进三四十个组件,而是让每个业务模块自己管理自己的组件、接口和组合式函数。

src/
├── api/            # 接口定义,按模块拆分
├── assets/         # 静态资源
├── components/     # 全局通用组件(按钮、弹窗、表格)
├── composables/    # 组合式函数
├── router/         # 路由表
├── stores/         # 状态仓库
├── views/          # 页面级组件
│   └── order/
│       ├── index.vue
│       ├── components/
│       └── composables/
└── main.js

判断标准是:删掉这个模块,会不会影响其他模块?不会,就应该内聚到自己的目录里。我见过把业务组件全丢进 components 的项目,后来没人敢删任何东西,因为不知道谁在用。

2. 模板语法与响应式内核:把数据流捋顺

模板语法看着简单,但它是出错最多的地方,原因在于模板里写的东西和实际执行的 JavaScript 并不是一一对应。理解这层转换关系,很多"明明代码没错但页面不动"的问题就能自己定位了。

2.1 插值、指令与条件渲染的实际用法

模板里的双大括号只做文本插值,遇到对象会直接调用 toString ,所以渲染出来是 [object Object] 。想看结构得用 JSON.stringify ,或者干脆在调试工具里看。这是新手最常见的困惑之一。

指令部分有几个值得单独说的:

  • v-if 是真正的条件渲染,条件为假时元素从 DOM 树里移除;
  • v-show 只是切换 display 样式,元素始终存在;
  • v-for 必须带 key ,而且 key 不能重复。

v-if 和 v-show 怎么选?我的经验是:频繁切换的用 v-show ,比如折叠面板、Tab 内容区;条件稳定、切换次数少的用 v-if ,比如权限控制下的按钮、加载完成的骨架屏。因为 v-if 每次切换都要创建销毁组件,如果组件里有请求或者定时器,反复切换会带来额外的开销。

v-for 的 key 是个高频事故点。用数组下标当 key ,在列表中间插入或删除元素时,组件会复用错位,表现出来就是输入框里的内容跑到了别的行、动画错乱。正确做法是用数据的唯一标识:

<template>
  <ul>
    <li v-for="item in list" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

还有一个版本相关的细节必须提醒: Vue3 里 v-if 的优先级高于 v-for ,而旧版本正好相反。这意味着你不能在同一个元素上写 v-if 去判断 v-for 里的循环变量,那个变量此时还不存在,会直接报错。正确写法是套一层 template :

<template v-for="item in list" :key="item.id">
  <li v-if="item.visible">{{ item.name }}</li>
</template>

从旧版本迁移过来的人如果没注意到这条,会看到一堆"变量未定义"的报错,而且很难联想到是优先级变了。

2.2 ref 与 reactive 的选择标准与 .value 的坑

这两个 API 是新手最容易被劝退的地方。用一句话概括: ref 可以包装任何值, reactive 只能包装对象和数组。既然 ref 更通用,是不是全部用 ref 就行?理论上是,实际项目里我也是这么做的—— 统一使用 ref ,只在明确需要一组关联字段时才用 reactive 。

原因很实在: reactive 有两个逃不掉的限制。第一,整体替换会丢失响应式:

let state = reactive({ count: 0 })
state = reactive({ count: 1 })  // 响应式断掉,模板不再更新

第二,解构赋值也会丢失响应式:

const state = reactive({ a: 1, b: 2 })
const { a } = state      // a 变成普通数字,改了页面不动

要解决解构问题得用 toRefs 包一层,多了一步心智负担。而 ref 在模板里会自动解包,只有是在脚本里操作时才需要写 .value 。这个差异导致了一个经典 bug:模板里写 count 正常,脚本里写 count++ 不生效,必须是 count.value++ 。我在新人的代码里见过至少十次这个问题。

为了方便排查,我建议养成一个习惯: 凡是名字前面带 use 的组合式函数返回值,统一用 ref 返回 ,调用方拿到就是 .value ,行为一致,不会出现"这个到底要不要加 value"的犹豫。

另外两个进阶 API 值得知道: shallowRef 只对 .value 本身做响应式,不深入内部属性,适合承载大对象或者第三方实例; markRaw 直接标记某个对象永不代理,适合放图表实例、地图实例这类不需要响应式的重型对象。用对了能省下可观的性能开销。

2.3 响应式底层:Proxy 相比 defineProperty 补上了什么

理解原理不是为了面试,而是为了在遇到"为什么这个改动没触发更新"的时候,能立刻判断出原因。

旧方案是在对象初始化时遍历所有属性,给每个属性装一对存取器。这带来三个硬伤:新增属性监听不到、删除属性监听不到、数组下标赋值和长度修改监听不到。所以旧版本才需要 $set 、 $delete 这些额外 API 来打补丁。

新方案用一个代理对象整体接管目标对象,所有读写、删除、 in 判断、遍历操作都会先经过代理。用生活类比:旧方案像给每个房间单独装门铃,新盖的房间没装就没反应;新方案是在整栋楼门口设一个管家,谁进谁出都要登记,房间怎么改都逃不掉。

这个变化带来的直接好处:

场景 旧方案 新方案
对象新增属性 需要额外 API 自动响应
删除属性 需要额外 API 自动响应
数组下标赋值 不响应 自动响应
数组长度修改 不响应 自动响应
Map / Set 不支持 支持
代理层级 初始化时全量递归 访问时才递归

最后一条是性能上的关键差异:新方案是懒递归,只有当某个嵌套属性真正被访问到时,才会为那一层创建代理。对于深层嵌套但只用到少数字段的数据结构,初始化开销小很多。

不过这里也有代价,而且是个坑: 代理是懒创建的,所以如果你把一个响应式对象传给第三方库,库内部存了引用并在之后修改它,改动会正常触发更新;但如果第三方库把这个对象深拷贝走,那改的就是副本,页面不会动。 我遇到过一次图表库不刷新的问题,排查半天发现是它内部做了深拷贝,最后改成每次数据更新时重建实例才解决。

3. 组件化实战:从单个 SFC 到可复用的模块

单文件组件(SFC)是这个框架最有辨识度的设计,一个 .vue 文件里同时放模板、逻辑和样式。把这套机制吃透,才算真正开始写 Vue3。

3.1 单文件组件三段式与 script setup 的编译收益

标准的组件结构是 template 、 script 、 style 三块。 style 上加 scoped 会给元素加上唯一属性选择器,实现样式隔离。但要注意, scoped 对子组件的根元素是"放行"的,而且穿透到子组件内部需要 :deep() :

<style scoped>
/* 想改子组件内部元素的样式 */
:deep(.inner-class) {
  color: red;
}
</style>

这个 :deep() 用得很频繁,尤其是接第三方组件库的时候,几乎所有定制样式都得靠它。但滥用会破坏隔离性,我的原则是:能在自己的组件里通过 props 控制样式的,就不用穿透。

<script setup> 的编译收益值得单独说。它会把顶层绑定直接编译成渲染函数的局部变量,不需要经过 setup() 返回对象再挂到实例上这一步。实测下来,组件实例上的属性数量明显减少,内存占用和初始化时间都有改善。另外, <script setup> 里定义的组件可以直接在模板里用,不用再注册。这个便利性提升很大。

需要和普通 <script> 并存的场景有两个:定义组件名( defineOptions 也能做,但有些工具链还依赖普通 script),以及使用 export 导出一些常量。这时候可以写两个 script 块, <script setup> 会自动和普通 script 合并。

3.2 组件通信的六种方式与选型对照

组件通信是新手最容易乱用的地方,很多人一遇到传值就上全局状态,结果整个项目耦合得一塌糊涂。我把常用方式整理成一张表,按"关系距离"来选就行。

方式 适用关系 是否响应式 典型场景
props / emits 父子 是 数据下发、事件上报
v-model 父子双向 是 表单控件封装
模板 ref 父子(父调子方法) 否 触发表单校验、播放器控制
provide / inject 跨层级 是 主题、语言、表单上下文
全局状态仓库 任意 是 登录信息、购物车
事件总线 任意 是 已不推荐,用仓库替代

props 有一条必须记住的规则: 单向数据流 。子组件不能直接改 props,否则控制台会有警告。想改就通过 emit 通知父组件,由父组件改。这个约束看着麻烦,实际是防止数据流变成一团乱麻。

emits 建议用数组或对象显式声明。用对象声明还能做参数校验:

const emit = defineEmits({
  change: payload => typeof payload === 'number',
  submit: null
})

不声明也能跑,但声明之后有两个好处:一是文档性质,别人看组件头部就知道会抛什么事件;二是能在开发环境捕获拼写错误。

模板 ref 的用法在新版本里有点变化,需要额外注意:

<template>
  <ChildComp ref="childRef" />
</template>

<script setup>
import { ref, onMounted } from 'vue'
import ChildComp from './ChildComp.vue'

const childRef = ref(null)

onMounted(() => {
  childRef.value.doSomething()
})
</script>

关键点:必须等挂载完成之后才能拿到实例,在 setup 顶层直接访问是 null 。另外,子组件如果用 <script setup> ,默认是 封闭 的,父组件拿到的实例上什么都访问不到。想让父组件调用方法,必须显式暴露:

defineExpose({ doSomething, reset })

我踩过这个坑:明明写了方法,父组件调用却报 is not a function ,排查了很久才发现是漏了 defineExpose 。

3.3 v-model 的进化:多参数绑定与自定义修饰符

表单封装是业务里最高频的组件需求。旧版本一个组件只能有一个 v-model ,多字段就得写一堆 :value 加 @input 。新版本支持具名绑定:

<template>
  <UserForm v-model:name="form.name" v-model:age="form.age" />
</template>

子组件这边接:

const props = defineProps(['name', 'age'])
const emit = defineEmits(['update:name', 'update:age'])

写起来还是有点啰嗦。如果版本在 3.4 以上,可以直接用 defineModel ,一行搞定:

const name = defineModel('name')
const age = defineModel('age')
// 直接 name.value = 'xxx' 就会同步给父组件

这个 API 把双向绑定的样板代码全消掉了,实测下来表单组件的代码量能减三分之一。如果团队版本还停留在 3.2 或 3.3,那就老老实实写 props + emit 。

自定义修饰符是个冷门但好用的能力。想让父组件写 v-model.trim 而组件内部处理,可以通过 modelModifiers 拿到:

const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) }
})

实际场景比如做一个金额输入组件,父组件写 v-model.number ,子组件内部就知道要做数值转换和边界处理,逻辑收在组件里,调用方只写一个修饰符,很干净。

4. 生命周期、计算属性与侦听器:副作用要管住

这一块是新旧写法差异最大的部分。选项式里钩子函数是"到了点自动执行",组合式里钩子是"在 setup 里主动注册"。理解这个转变,才能把副作用放在正确的位置。

4.1 生命周期钩子的对照表与落位场景

先给对照表,迁移项目的时候照着改就行:

选项式钩子 组合式注册函数 常见用途
beforeCreate / created setup 本体 初始化状态、调用组合式函数
beforeMount onBeforeMount 极少用,DOM 还没生成
mounted onMounted 访问 DOM、初始化图表、发首屏请求
beforeUpdate onBeforeUpdate 更新前读一次旧 DOM
updated onUpdated 极少用,容易形成死循环
beforeUnmount onBeforeUnmount 清除定时器、解绑事件
unmounted onUnmounted 销毁第三方实例
activated / deactivated onActivated / onDeactivated 缓存组件进入/离开
errorCaptured onErrorCaptured 子组件错误兜底

几条实战经验:

第一, 请求数据尽量放在 onMounted 而不是 setup 顶层 。虽然在 setup 顶层发也能跑,但服务端渲染场景下会导致重复请求。统一放 onMounted 更保险。

第二, updated 要慎用。在这个钩子里改状态,很容易触发无限更新循环,控制台会甩出一堆警告。真需要在更新后做点什么,用 nextTick 配合 watch 更可控。

第三, onUnmounted 里一定要清理资源。我在一个项目里见过定时器没清的组件,页面来回切换十几次之后浏览器直接卡死,排查下来就是每次进入都新建一个定时器,从来没清过。

script setup 下的注册位置也有讲究:必须在同步执行阶段调用,不能放在 setTimeout 或者 await 之后:

// 错误写法,挂载后的代码才执行,钩子不会生效
setTimeout(() => {
  onMounted(() => {})
}, 0)

// 正确写法
onMounted(() => {})

这个错误尤其隐蔽,因为控制台只会给一条警告,不仔细看就漏掉了。

4.2 computed 与 watch 的边界:缓存、副作用与回调时机

这三者的区别,我用一句判断准则概括: 要一个由其他数据推导出来的值,用 computed ;要在数据变化时做一件事,用 watch ;要自动追踪依赖执行副作用,用 watchEffect 。

computed 的核心特性是缓存。只要依赖没变,重复访问拿的是缓存结果,不会重新计算。这意味着不要在 computed 里写请求、写日志、改其他状态——它不是"计算完就扔"的函数,而是有生命周期的派生值。

const list = ref([])
const keyword = ref('')

const filtered = computed(() => {
  return list.value.filter(i => i.name.includes(keyword.value))
})

上面这段,模板里访问十次 filtered ,实际过滤只执行一次,除非 list 或 keyword 变了。如果换成普通函数,每次渲染都会重算。

watch 有三个参数值得记牢:

watch(source, (newVal, oldVal) => {...}, {
  immediate: true,   // 立即执行一次
  deep: true,        // 深度监听对象内部
  flush: 'post'      // 回调时机:pre / post / sync
})

flush 是最容易被忽略的一个。默认 pre ,在 DOM 更新前执行; post 在 DOM 更新后执行。什么时候需要 post ?当你需要在回调里读更新后的 DOM 尺寸时。比如监听列表变化后重新计算滚动高度,用默认值会拿到旧尺寸。

deep 也有代价。深层对象监听会递归遍历所有属性,数据量大时开销明显。如果只需要监听某个字段,直接写成 getter 函数更省:

watch(() => state.user.name, () => {...})

watchEffect 会自动收集依赖,写起来最省事,但依赖关系不直观。我的建议是:能用 watch 显式写依赖就用 watch ,团队协作时别人看代码能一眼知道"这个副作用是被什么触发的"。

4.3 副作用清理与内存泄漏的实测案例

搜索框实时请求是最经典的场景。用户快速输入,如果每个字符都发一次请求,前面那些请求的结果可能晚于后面的返回,导致最终显示的内容和输入框对不上。这就是竞态条件。

解决方式是在监听回调里做清理:

watch(keyword, async (val, oldVal, onCleanup) => {
  let cancelled = false
  onCleanup(() => { cancelled = true })

  const res = await fetchSearch(val)
  if (!cancelled) {
    result.value = res
  }
})

onCleanup 会在下一次回调执行前被调用,把上一次的请求标记为作废。这样既能避免竞态,也能在组件卸载时自动取消。

如果不想手写这套逻辑,常见的做法是配合防抖。防抖我做了一个简单的实现:

function useDebounce(fn, delay = 300) {
  let timer = null
  const run = (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), delay)
  }
  onUnmounted(() => clearTimeout(timer))
  return run
}

注意最后那行清理,卸载时把待执行的定时器干掉,否则回调可能在组件已经销毁之后执行,报出"无法在已卸载组件上操作"的警告。

还有一类内存泄漏很隐蔽:事件监听。给 window 加滚动监听之后忘记移除,组件卸载了监听还在,回调里引用的组件状态无法回收。规范做法是成对出现:

onMounted(() => window.addEventListener('resize', handler))
onUnmounted(() => window.removeEventListener('resize', handler))

如果监听很多,可以写一个组合式函数统一管理,一次注册,卸载时批量清理。

5. 路由与状态管理:让多页面跑通

单页应用的核心是路由和状态。这两块配不好,项目结构会很别扭。

5.1 路由基础、动态参数与懒加载

初始化路由表:

import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/', component: () => import('@/views/Home.vue') },
  { path: '/order/:id', component: () => import('@/views/Order.vue'), props: true }
]

export default createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior: () => ({ top: 0 })
})

props: true 这行很实用,它把路由参数直接当 props 传给组件,组件内部就不用 useRoute() 去取了,复用性更好、测试也更容易。

路由懒加载用动态 import 实现,构建时会自动拆包。我一般建议除首屏页面外全部懒加载,这样首包体积能明显下降。判断哪些放首屏:用户进来第一眼要看到的、以及点击后立刻跳转的,其余都拆出去。

两个取参数的 API 容易混: useRouter() 拿的是路由器实例,用来做跳转; useRoute() 拿的是当前路由对象,用来读参数。名字差一个字母,行为完全不同,我见过有人拿 useRoute() 去调 push ,报错信息很不直观。

路由守卫做权限控制:

router.beforeEach((to, from, next) => {
  const token = getToken()
  if (to.meta.requiresAuth && !token) {
    next({ path: '/login', query: { redirect: to.fullPath } })
  } else {
    next()
  }
})

注意 next 必须被调用,否则导航会一直挂起,页面白屏。这是排查路由问题时第一个要检查的地方。

5.2 Pinia 的两种写法与 storeToRefs

状态仓库现在主流是 Pinia,两种写法都能用。选项式写法结构清晰:

export const useUserStore = defineStore('user', {
  state: () => ({ name: '', token: '' }),
  getters: {
    isLogin: state => !!state.token
  },
  actions: {
    async login(payload) {
      const res = await apiLogin(payload)
      this.token = res.token
      this.name = res.name
    }
  }
})

组合式写法和组件里的写法完全一致,更贴近日常习惯:

export const useUserStore = defineStore('user', () => {
  const name = ref('')
  const token = ref('')
  const isLogin = computed(() => !!token.value)

  async function login(payload) {
    const res = await apiLogin(payload)
    token.value = res.token
    name.value = res.name
  }

  return { name, token, isLogin, login }
})

我偏向组合式,因为它能把各种组合式函数直接用进来,逻辑复用没有障碍。

有个必须注意的地方:从仓库里解构出来的状态会丢失响应式:

const store = useUserStore()
const { name } = store          // 不响应
const { name } = storeToRefs(store)  // 响应,但只有状态和方法之间要区分

storeToRefs 只处理状态和 getter,方法还是直接从 store 上取。所以正确写法通常是:

const store = useUserStore()
const { name, isLogin } = storeToRefs(store)
const { login } = store

这套写法我一开始也觉得别扭,用几次就习惯了,而且能避免大量"明明数据改了页面不动"的问题。

5.3 provide/inject 适合什么场景

跨层级传值不一定非要上全局状态。如果是某个子树内共享的数据,用 provide/inject 更合适,因为它有天然的作用域边界,子树销毁就自动回收,不会污染全局。

典型场景是表单上下文。一个 FormItem 组件需要知道自己在不在 Form 里面、表单的方向和禁用状态:

// Form.vue
const formContext = reactive({ disabled: false, size: 'medium' })
provide('formContext', formContext)

// FormItem.vue
const ctx = inject('formContext', { disabled: false, size: 'medium' })

注意第二个参数是默认值,防止脱离 Form 单独使用时崩溃。这点很重要,组件库里的组件都会被单独使用,没有默认值就是一个必然踩的坑。

inject 拿到的如果是响应式对象,改它能同步到所有使用方。但要注意,注入方不应该随便改提供方的数据,这跟 props 的原则是一致的。稳妥做法是同时 provide 一个修改方法,让修改行为可控。

6. 工程化能力进阶:组合式函数、TypeScript 与性能

能跑通业务代码只是入场券,真正拉开差距的是代码组织能力和性能意识。

6.1 用组合式函数把逻辑抽出来

组合式函数(Composable)本质就是一个以 use 开头的函数,内部可以使用所有响应式 API 和生命周期钩子,返回响应式状态和方法。它是这套框架最有价值的复用机制。

一个典型的需求:多个页面都有"加载中、数据、错误"三态请求逻辑。如果不抽,每个页面都要写一遍,改起来到处改。

// composables/useRequest.js
import { ref, onUnmounted, unref } from 'vue'

export function useRequest(fn, options = {}) {
  const { immediate = false } = options
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  let cancelled = false

  async function run(...args) {
    loading.value = true
    error.value = null
    cancelled = false
    try {
      const res = await fn(...args)
      if (!cancelled) data.value = res
    } catch (e) {
      if (!cancelled) error.value = e
    } finally {
      if (!cancelled) loading.value = false
    }
  }

  if (immediate) run()

  onUnmounted(() => { cancelled = true })

  return { data, loading, error, run }
}

页面里用起来就一行:

const { data, loading, error, run } = useRequest(fetchOrderList, { immediate: true })

cancelled 那个变量的作用是在组件卸载后阻止状态更新,避免警告。这个小细节是我在频繁切换页面时才发现的,之前只是偶发警告,没当回事,直到日志里堆了几千条才去改。

写组合式函数有两条约定:一是命名以 use 开头,让调用方知道它是组合式函数;二是返回值统一用对象,不用数组,因为数组要按顺序取值,加一个新字段就会破坏所有调用方。

6.2 TypeScript 接入的三个关键点

如果项目用 TS,有三个地方值得提前踩明白。

第一个是 props 的类型声明。用类型标注比运行时校验更简洁:

interface Props {
  title: string
  count?: number
  list: Array<{ id: number; name: string }>
}
const props = withDefaults(defineProps<Props>(), {
  count: 0
})

注意 withDefaults 是必需的,因为类型声明本身没法给默认值。另外,导入接口时如果写在 <script setup> 里,编译工具可能处理不了,稳妥做法是把接口定义在同文件内的普通 <script> 块中,或者用 import type 。

第二个是事件类型:

const emit = defineEmits<{
  change: [value: number]
  submit: [payload: { id: number }]
}>()

这是较新版本的语法,能看到每个事件的参数类型,比数组声明清晰得多。

第三个是模板 ref 的类型。 ref(null) 推不出具体类型,调用方法时会提示可能为 null。常见写法是:

const formRef = ref<InstanceType<typeof MyForm> | null>(null)

这样既有类型提示,又保留了 null 的可能性,调用前做判空即可。

6.3 几个真能提性能的手段

第一, 长列表不要一次渲染完 。几千条数据全渲染,首屏会明显卡顿。方案要么是虚拟列表(只渲染可视区域),要么分页。我做过一个对比测试,五百条带图标的列表用虚拟列表后,滚动帧率从明显掉帧恢复到基本稳定。

第二, 大对象用 shallowRef 。比如从接口拿到一个几百个字段的配置对象,只做整体替换、不改内部字段,用 shallowRef 能省掉深度代理的开销。

第三, 组件按需异步加载 :

import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))

配合 <Suspense> 可以做加载占位。不过 Suspense 目前还是实验性能力,生产项目用之前要评估,简单场景用 v-if 加骨架屏更稳。

第四, <KeepAlive> 缓存页面。列表页跳详情再返回,如果没有缓存,列表会重新请求、滚动位置丢失。加上 KeepAlive 之后体验好很多,但要注意配合 onActivated 重新拉数据,否则用户返回后看到的是旧数据。这个取舍需要按业务定:对时效敏感的数据在 onActivated 里刷新,不敏感的就不刷。

7. 报错排查实录与踩坑清单

这部分是我觉得最有价值的地方,因为大部分内容在文档里查不到,只能在项目里撞出来。

7.1 常见报错速查表

现象 大概率原因 处理方式
改了数据页面不动 解构 reactive、整体替换对象 用 toRefs 、改用 ref 或 Object.assign
脚本里改值不生效 ref 漏写 .value 补 .value ,或用 storeToRefs
父组件调用子组件方法报错 子组件没 defineExpose 显式暴露方法
路由跳转白屏 守卫里没调用 next 每个分支都要调
组件样式不生效 scoped 阻隔 用 :deep() 穿透
列表输入内容错位 key 用了下标 改成唯一 id
生命周期不执行 写在异步回调里 移到同步执行阶段
深度监听不触发 新增属性不在初始对象上 补 deep 或改成 getter
接口 404 但控制台无报错 代理 rewrite 配置有误 看网络面板实际路径
打包后白屏 资源路径不对 配 base 或改相对路径

7.2 我踩过并且印象最深的几个坑

第一个是整体替换 reactive 对象。当时的代码是从接口拿到数据后直接赋值:

let formData = reactive({})
const res = await fetchDetail()
formData = res.data   // 响应式直接断掉

页面表现是:控制台打印 formData 有值,但模板就是不更新。排查了两小时,最后才想明白是重新赋值把代理对象换成了普通对象。正确做法是用 Object.assign 逐字段合并,或者干脆用 ref 包一层。

第二个是 watch 监听数组 push 不触发。这个跟数据层级有关,有些情况下浅监听就够了,有些情况下必须加 deep 。判断方法很简单:在回调里加一句 console.log ,如果压根没打印就是没触发,加上 deep 再试。当然更推荐的做法是直接监听 list.value.length 这类基础字段,开销更小。

第三个是为第三方组件库写自定义指令,结果指令的 updated 钩子在父子组件更新顺序上出现问题,拿到的 DOM 尺寸是旧的。解决办法是用 nextTick 包一层,等本轮更新完成后再读尺寸。这类问题非常隐蔽,因为大部分时候能正常工作,只有特定交互顺序下才出错。

第四个是循环依赖。两个模块互相 import,其中一个拿到的是 undefined 。这种报错信息通常是"不能读取 undefined 的属性",跟循环依赖毫无关系,非常难查。后来我形成一个习惯:看到莫名奇妙的 undefined 报错,先去检查一下是不是循环引用了。

7.3 调试手段:开发者工具与源码定位

浏览器里的开发者工具插件是必装的。它能直接看到每个组件的 props、状态、事件,还能在面板里临时改值看效果,比在代码里加 console.log 高效得多。

生产环境的报错定位,我一般开着构建的 sourcemap,出问题的报错能直接映射回源码行号。虽然会增加打包体积,但配合错误监控,定位效率提升很大。上线前把 sourcemap 文件单独上传到监控平台,不随代码发布出去,这样既能定位问题,又不暴露源码。

源码级的调试,浏览器开发者面板的断点功能足够用。想看清响应式依赖收集过程,可以在 effect 相关的位置打断点,走一遍流程。虽然平时用不上,但遇到很诡异的"更新了但没渲染"问题,走一遍能快速定位是哪一环断的。

另外提醒一句:开发环境下的性能面板和警告一定要重视。我把控制台的 Vue 警告当作错误来对待,团队里也这么约定。因为警告往往预示着某处逻辑有问题,积累多了会变成难查的 bug。这条纪律执行下来,项目的线上问题明显少了。

最后分享一个我用了很久的小习惯:新建项目时,先写一个最小的"能跑通"的垂直切片——一个页面、一个接口、一次状态更新、一个路由跳转,全部打通之后再往上堆功能。这样任何环境配置、依赖版本、路径别名的问题都会在第一小时内暴露,而不是写了三天才发现基础配置有坑。比起一上来就铺开目录结构,这个做法在实际项目里省下的时间远超想象。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值