1. 先聊清楚我为什么会在 Vue 里折腾装饰器
第一次在别人的 Vue 项目里看到@Component挂在类上面,我的反应是"这不是 Angular 或者后端的写法吗"。那时候我手上维护的是一个迭代了两年的中后台系统,选项式 API 写到后面,methods里塞了四十多个函数,computed和watch交错的顺序完全凭记忆,this的指向问题每隔两周就要排查一次。真正让我下决心研究注解式写法的,不是审美,是代码的可检索性:当你按Ctrl点进一个方法,希望直接跳到它的定义,而不是在一个 800 行的.vue文件里翻半天,这件事本身就已经值得换一种组织方式了。装饰器(很多人习惯叫它注解)在 Vue 里不是官方主推路线,但它确实给了一部分团队另一条组织代码的路径。
需要先把话说在前面:Vue 的注解写法,本质上是用类组件(Class Component)承载组件的选项,装饰器只是把data、computed、watch、props、生命周期这些选项,用更贴近 TypeScript 类型系统的语法糖表达出来。它并没有创造新的运行时能力,也不会让你的组件跑得更快。它的价值集中在三个地方:一是把成员逻辑聚合成类成员,让 IDE 的跳转、重命名、引用查找真正生效;二是让this的类型推导完整,this.orderList能直接推断出OrderItem[],而不是any;三是把横切逻辑(loading 状态、防抖、权限校验)抽成可复用的装饰器,减少在每个方法里重复写this.loading = true这种样板。适合参考这套方案的人很明确:手里有 Vue 2 存量项目、正在逐步补 TypeScript 的团队;从其他框架迁移过来、习惯类与依赖注入的开发者;以及需要在老项目里做渐进式改造、又不想整体重写成组合式 API 的人。
不适合的人也要说清楚。新开的 Vue 3 项目,我基本不会推荐这条路。组合式 API 加上<script setup>在类型推导、逻辑复用(组合函数)、Tree-shaking 上都更顺,而且官方文档、生态组件、社区问答全都是围绕它展开的。装饰器方案在 Vue 3 时代属于"能跑、但生态边缘"的位置:vue-class-component的维护节奏放缓,遇到诡异 bug 时能搜到的答案明显变少。所以我写这篇的定位是存量项目改造与技术视野补全,而不是劝你把整个项目迁过去。理解装饰器在 Vue 里怎么落地、编译链怎么配、坑在哪里,本身就是一件有价值的事——哪怕你最后决定不用,你在读别人代码、评估第三方库、写 Babel 插件时也会用上这些知识。
2. 编译链是这条路的第一道门槛
2.1 装饰器标准的两套阵营,选错了全盘皆输
装饰器在 JavaScript 里有两套互不兼容的提案:一套是 TypeScript 早年实现的experimentalDecorators(业内叫 legacy 装饰器),另一套是 TC39 推进的 Stage 3 标准装饰器。这两套的函数签名完全不一样。legacy 装饰器里,属性装饰器拿到的是(target, propertyKey),方法装饰器拿到的是(target, propertyKey, descriptor);Stage 3 装饰器拿到的是(value, context)两个参数,context里带kind、name、addInitializer等信息。你在网上抄到一个装饰器写法跑不通,八成就是抄了另一套标准的代码。
Vue 生态里的类组件库,包括vue-class-component和 Vue 2 时代广泛使用的vue-property-decorator,全部基于legacy 装饰器实现。这意味着你在tsconfig.json里必须打开experimentalDecorators,并且构建工具侧的编译行为要和它保持一致。近两年 TypeScript 5.x 对 Stage 3 装饰器的支持越来越完整,一些新项目默认不再开启 legacy 开关,这时候直接引入类组件库就会报"装饰器只能用于类的声明"之类的语法错误——这不是库的问题,是两套标准在打架。
注意:不要在同一个项目里混用两套装饰器标准。一旦你在
tsconfig.json开了experimentalDecorators,整个项目的装饰器都会走 legacy 语义,这时候任何依赖 Stage 3 装饰器的第三方库都可能出问题。反过来,如果你引了一个基于 Stage 3 的依赖,也不要随手把 legacy 打开。
2.2 tsconfig 逐项拆解,每个开关都有代价
我见过太多人配tsconfig.json是复制粘贴的,出问题就抓瞎。这里逐项说清楚,为什么这么配。
{ "compilerOptions": { "target": "ES2018", "module": "ESNext", "moduleResolution": "node", "experimentalDecorators": true, "emitDecoratorMetadata": true, "useDefineForClassFields": false, "strictPropertyInitialization": false, "strict": true } }experimentalDecorators: true是硬性前提,不开这个开关,TypeScript 会把@Component当成语法错误。emitDecoratorMetadata: true会让编译器在装饰器位置额外注入design:type、design:paramtypes、design:returntype这些元数据,配合reflect-metadata做运行时类型反射。Vue 的类组件库本身对元数据的依赖不算重,但如果你自己写依赖注入、参数校验类的装饰器,这个开关会省很多事。代价是编译产物体积略微增加,以及需要在外层入口import 'reflect-metadata',否则运行时读元数据会拿到undefined。
useDefineForClassFields: false是我认为最容易踩、后果最严重的一项。这个开关控制类字段的编译语义:设成true(ES2022 默认行为)时,类字段会用Object.defineProperty定义成实例的自有属性,并且在构造函数里先于一切执行;设成false时,字段赋值走普通赋值语义,可以被父类的 getter/setter 接管。Vue 的类组件库正是靠父类Vue上的代理来接管props、data的读写,一旦类字段变成自有属性,就会把这些代理"盖掉",表现就是:@Prop声明的属性在模板里永远是undefined,data里定义的响应式字段失去了响应性。我排查过这个问题的真实场景,改了三行配置才定位到原因,整个过程让人很不舒服。
strictPropertyInitialization: false是一个取舍。开着它,@Prop() keyword!: string这种写法,不加!非空断言就会报"属性未在构造函数中初始化"。理论上更严谨的做法是全用!或者declare,但类组件里字段是由框架在运行时注入的,编译器没法静态推断,所以实际项目里我更倾向于把这个开关关掉,靠!非空断言和运行时兜底默认值来保证安全。
2.3 Vite 和 Vue CLI 两条路,各自的动手方式
vue-cli的老项目走 webpack + babel 或 ts-loader,链路比较成熟。如果走 babel,需要装@babel/plugin-proposal-decorators并把legacy选项设为true,同时配合@babel/plugin-proposal-class-properties,注意两者的顺序:装饰器插件必须在类属性插件之前。
// babel.config.js module.exports = { presets: ['@vue/cli-plugin-babel/preset'], plugins: [ ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties', { loose: true }] ] }Vite 这边要绕一下。Vite 默认用 esbuild 编译 TS,而 esbuild 支持 legacy 装饰器的语法,但不支持emitDecoratorMetadata。也就是说,纯语法能过,元数据反射拿不到。如果你的代码只用到类组件库本身的装饰器,通常能跑;一旦你自己写了依赖design:type的装饰器,就必须换成 SWC 或 Babel 来处理 TS。
// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import swc from 'unplugin-swc' export default defineConfig({ plugins: [ vue(), swc.vite({ jsc: { parser: { syntax: 'typescript', decorators: true }, transform: { legacyDecorator: true, decoratorMetadata: true } } }) ] })这里unplugin-swc需要配合@swc/core一起装。SWC 的legacyDecorator和decoratorMetadata两个开关,语义上对应 TypeScript 的experimentalDecorators和emitDecoratorMetadata,两边必须同步打开或关闭,否则会出现"本地 tsc 编译通过、Vite 启动报错"的割裂现象。我的做法是在项目根目录放一份tsconfig.json作为单一事实来源,构建器的配置直接读它,避免两边漂移。
3. 核心写法拆解:把选项翻译成类成员
3.1 Vue 2 与 Vue 3 的库不是同一个
这点必须先掰开。Vue 2 时代的主流是vue-property-decorator,它依赖vue-class-component@7,导出Component、Prop、Watch、Emit、Ref、Inject、Provide、Model、PropSync这一整套。Vue 3 时代,vue-class-component@8把这套装饰器合并进了自己的包,vue-property-decorator在 Vue 3 项目里基本不能直接用。所以第一步是认清楚你项目里 Vue 的版本,再决定装哪个包。
# Vue 2 项目 npm i vue-class-component@7 vue-property-decorator -D # Vue 3 项目 npm i vue-class-component@8 -DVue 3 的写法里,基类还是Vue,但装饰器统一从vue-class-component引入:
import { Vue, Options, Prop, Watch, Emit } from 'vue-class-component'这个包名统一的设计其实挺方便,因为它顺带把Ref、Provide、Inject也一起导出了,Vue 3 的ref也能通过@Ref装饰器拿到类型安全的引用。
3.2 data 变成字段,computed 变成 getter
这是整个映射关系里最直观的部分。选项式里写在data()返回对象里的东西,在类组件里就是类字段;写在computed里的东西,就是get访问器;写在methods里的,就是普通方法。这个映射不是随便定的,背后是 JavaScript 类本身的语义:字段是实例状态,getter 是派生状态,方法是可以被替换的行为。
import { Vue, Options } from 'vue-class-component' interface OrderItem { id: number amount: number status: 'pending' | 'paid' | 'closed' } @Options({ name: 'OrderPanel' }) export default class OrderPanel extends Vue { // 对应 data() keyword = '' status = 'all' as 'all' | OrderItem['status'] orders: OrderItem[] = [] loading = false // 对应 computed get filteredOrders(): OrderItem[] { return this.orders.filter(item => { const matchStatus = this.status === 'all' || item.status === this.status const matchKeyword = !this.keyword || String(item.id).includes(this.keyword) return matchStatus && matchKeyword }) } get totalAmount(): number { return this.filteredOrders.reduce((sum, item) => sum + item.amount, 0) } // 对应 methods async fetchOrders(): Promise<void> { this.loading = true try { const res = await request.get('/api/orders') this.orders = res.data } finally { this.loading = false } } }这里有个细节值得说:get访问器在类组件里不能有 setter 依赖副作用。Vue 的计算属性是惰性求值加缓存,你如果在 getter 里做赋值、发请求、改别的状态,很容易触发循环更新。我见过有人在 getter 里this.count++,结果页面直接卡死。记住一条:getter 只做纯计算,任何副作用都放到watch或者方法里。
3.3 props、emit、watch 的装饰器参数要背下来
@Prop的参数和 Vue 的props选项是一一对应的,支持类型构造器、默认值、required、自定义校验器。
@Prop({ type: String, default: '' }) readonly keyword!: string @Prop({ type: Array, default: () => [] }) readonly columns!: ColumnConfig[] @Prop({ type: Number, default: 20, validator: (v: number) => v > 0 && v <= 200 }) readonly pageSize!: number数组和对象的默认值必须用工厂函数,这点和选项式 API 完全一致,因为如果直接写default: [],所有组件实例会共享同一个数组引用。我在一个老项目里遇到过这个问题:某个筛选面板的默认选中项,改了一个实例,其他实例全跟着变,排查了半天才发现是共享引用。
@Emit的行为需要特别留意:它会把被装饰方法的返回值作为事件参数发出去。也就是说方法必须return,不 return 就是undefined。而且事件名默认取方法名,如果方法名是handleChange,事件名就是handle-change(驼峰转横线),要换名字得显式传参。
@Emit('update:keyword') handleKeywordChange(val: string): string { return val }@Watch的第二个参数是配置对象,immediate和deep都支持。这里有个坑:immediate: true时,回调会在组件created之前触发,此时this上的 props 已经就绪,但data里依赖其他初始化逻辑的字段可能还没准备好。
@Watch('status', { immediate: true }) onStatusChange(val: string, oldVal: string): void { this.page = 1 this.fetchOrders() }3.4 自定义装饰器才是真正的收益点
类组件本身只是换了个写法,真正让这条路有吸引力的是你可以自己写装饰器,把重复逻辑抽走。legacy 装饰器的方法签名很简单:方法装饰器收(target, propertyKey, descriptor),你只要替换descriptor.value就能改变方法行为。
下面这个是@Loading,自动给异步方法套上加载状态:
// decorators/loading.ts export function Loading(stateKey = 'loading') { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor ) { const original = descriptor.value descriptor.value = async function (this: any, ...args: any[]) { this[stateKey] = true try { return await original.apply(this, args) } finally { this[stateKey] = false } } return descriptor } }用法就是把@Loading()挂在方法上,方法体里不用再写this.loading = true和finally里的复位。这个写法我用在十几个列表页上,省掉的不只是几行代码,更重要的是它保证了一定会复位——手写try/finally总有人漏掉。
再给一个防抖的:
export function Debounce(delay = 300) { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor ) { const original = descriptor.value let timer: ReturnType<typeof setTimeout> | null = null descriptor.value = function (this: any, ...args: any[]) { if (timer) clearTimeout(timer) timer = setTimeout(() => { original.apply(this, args) timer = null }, delay) } return descriptor } }注意:装饰器工厂返回的闭包里持有的
timer是所有组件实例共享的,因为装饰器在类定义阶段只执行一次。如果多个实例共用一个防抖方法,会互相打断。要按实例隔离,得把 timer 挂到this上,或者用WeakMap以实例为键来存。这个坑我在一个弹窗组件里踩过,页面上有两个弹窗同时打开,输入框互相把对方的计时器清掉,表现就是"打字打着打着搜索结果不出来了"。
4. 完整跑一遍:一个订单筛选组件的搭建过程
4.1 从零到能跑起来的具体步骤
假设这是一个 Vue 3 + TypeScript + Vite 的项目,我要把已有的选项式筛选组件改成类组件写法。整个过程分四步。
第一步装依赖,vue-class-component装到devDependencies,运行时只依赖 Vue 本身。第二步改tsconfig.json,重点是前面说的experimentalDecorators、useDefineForClassFields: false和strictPropertyInitialization: false三项,其余的按项目现状保留。第三步配vite.config.ts,引入unplugin-swc,把legacyDecorator和decoratorMetadata打开。第四步在入口文件加一行import 'reflect-metadata',如果不需要运行时元数据反射,这步可以省,但建议加上,代价很小。
这里我要提醒一个顺序问题:先改配置,再改组件。我见过有人先把.vue文件改成类写法,结果启动报错,回头怀疑是库版本不对,折腾半天才发现tsconfig里useDefineForClassFields还是默认值。
4.2 组件代码实现
<template> <div class="order-panel"> <input v-model="keyword" placeholder="搜索订单号" /> <select v-model="status"> <option value="all">全部</option> <option value="pending">待支付</option> <option value="paid">已支付</option> <option value="closed">已关闭</option> </select> <span class="summary">共 {{ filteredOrders.length }} 条,合计 {{ totalAmount }} 元</span> <ul> <li v-for="item in filteredOrders" :key="item.id"> #{{ item.id }} - {{ item.amount }} - {{ item.status }} </li> </ul> <button :disabled="loading" @click="reload">刷新</button> </div> </template> <script lang="ts"> import { Vue, Options, Prop, Watch, Emit } from 'vue-class-component' import { Loading } from '@/decorators/loading' import request from '@/utils/request' interface OrderItem { id: number amount: number status: 'pending' | 'paid' | 'closed' } @Options({ name: 'OrderPanel' }) export default class OrderPanel extends Vue { @Prop({ type: Number, default: 20 }) readonly pageSize!: number keyword = '' status: 'all' | OrderItem['status'] = 'all' orders: OrderItem[] = [] loading = false get filteredOrders(): OrderItem[] { return this.orders.filter(item => { const matchStatus = this.status === 'all' || item.status === this.status const matchKeyword = !this.keyword || String(item.id).includes(this.keyword) return matchStatus && matchKeyword }) } get totalAmount(): number { return this.filteredOrders.reduce((sum, item) => sum + item.amount, 0) } @Watch('status') onStatusChange(): void { this.$emit('filter-change', this.status) } @Loading('loading') async reload(): Promise<void> { const res = await request.get('/api/orders', { params: { size: this.pageSize } }) this.orders = res.data } mounted(): void { this.reload() } @Emit('close') handleClose(): boolean { return true } } </script>这段代码里,mounted作为类方法直接定义就行,无需额外的装饰器,类组件库会自动把同名的生命周期方法挂到对应钩子上。this.$emit依然可用,因为基类就是Vue实例本身。
4.3 参数与执行顺序的实测记录
装饰器的执行顺序值得单独说一下,因为它在自定义装饰器叠加时会直接影响结果。规则是这样的:多个装饰器作用于同一目标时,表达式从上到下求值,装饰器函数从下到上调用。也就是说,写在最下面的装饰器最先执行。这个顺序和 Python 的装饰器一致,但和大多数人的直觉相反。
我做过一个实测:给同一个方法叠@Loading()和@Debounce(300),如果@Debounce写在下面,防抖包在外层,那么用户连续点击时只有最后一次会进入@Loading,loading 状态只切换一次;如果@Loading写在下面,每次点击都会先置 loading 为 true,然后才被防抖拦截,视觉上就是"点一下闪一下,但请求没发"。所以顺序不是随便写的,得按业务期望倒推。
关于类字段的初始化顺序,也顺手记一下:父类字段先于子类字段初始化,字段初始化在构造函数里、super()调用之后执行。这意味着在data字段的初始值里引用this.props,在类组件里是拿不到的,因为字段初始化阶段 props 还没被 Vue 注入。要么给个安全的默认值,要么在created里赋值。这个问题我遇到过一次,某个字段初始值写成了this.pageSize * 2,结果永远是NaN,改成在created里赋值就好了。
5. 踩过的坑与排查实录
5.1 常见故障速查表
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 启动报"装饰器只能用于类声明" | 未开启 legacy 装饰器 | tsconfig开experimentalDecorators,构建器同步配置 |
props 在模板里始终undefined | 类字段被defineProperty覆盖 | useDefineForClassFields设为false |
| 页面上数据变了但视图不更新 | data字段被自有属性遮蔽,失去响应性 | 同上,检查字段声明方式 |
| 类里报"属性未初始化" | strictPropertyInitialization开启 | 加!断言或关闭该开关 |
@Emit事件发出去了但没参数 | 方法没有return | 显式return需要传出去的值 |
@Watch首次不触发 | immediate默认false | 显式配置{ immediate: true } |
| 两个实例的防抖互相干扰 | 装饰器闭包变量跨实例共享 | 状态挂实例或改用WeakMap |
循环引用导致Cannot access before initialization | 装饰器元数据在模块加载期求值 | 拆分文件,改为延迟引用 |
| 热更新后组件状态错乱 | 类组件对 HMR 支持有限 | 手动刷新页面,或降低对 HMR 的依赖 |
元数据反射拿到undefined | 未引入reflect-metadata | 入口文件首行引入 |
5.2 几个只有实际跑过才知道的细节
热更新的问题比想象中严重。类组件的热更新支持一直不算好,改了类里的方法,有时候新代码不生效,有时候整个组件实例被重建、滚动位置丢失、输入框清空。我的应对方式是:写类组件的时候,把复杂交互逻辑尽量往组合函数或者纯函数里挪,让类本身保持"薄",这样改动频率低,HMR 的干扰也就少。如果某个页面天天改,我会考虑这部分单独用<script setup>写,两种写法在同一个项目里是可以共存的——只是不要在同一个文件里混用。
循环依赖是类组件的隐形杀手。类组件在定义阶段就会执行装饰器,装饰器里如果引用了另一个组件的类型或者实例,就会在模块加载期触发求值。A 引 B、B 引 A,就会撞上Cannot access 'X' before initialization。选项式 API 里这个问题不明显,因为data()是函数,执行时机晚。解决办法有两条:一是把共享的类型定义拆到独立的types.ts里,类型文件不产生运行时依赖;二是装饰器参数里只传字符串、字面量,不要传组件引用。
@Ref拿到的引用在mounted之前是undefined。这个看起来是废话,但类组件里特别容易忘,因为@Ref声明的是类字段,你在字段初始化阶段访问它一定拿不到。我见过有人在created里访问@Ref声明的 DOM 引用,结果报空指针。
this的指向问题在箭头函数里会翻车。类组件里定义方法,不要用箭头函数,因为箭头函数绑定的是定义时的词法作用域,this不是组件实例。这个坑在选项式 API 里也存在,但类组件里更容易犯,因为写类的习惯让人下意识用箭头函数来处理回调。
性能上别期待有提升。类组件在运行时相对选项式多了一层包装,官方也没做过什么针对性优化。我做过简单的对比测试,在同样的列表渲染场景下,两种写法的帧率差异在误差范围内。选它的理由应该是可维护性和类型安全,不是性能。
6. 我的取舍与几句实在话
这几年我经手的项目里,装饰器写法主要出现在两类场景:一类是 Vue 2 时期就开始的中大型后台系统,代码量已经很大,逐步把选项式改成类写法,配合 TypeScript 补类型,改动风险可控;另一类是从其他技术栈转过来的团队,成员对类和依赖注入更熟,用这个过渡会比较顺。这两类场景的共同点是改造优先于重写。
如果现在让我给一个建议,我的判断依据是"这个组件会不会被反复改"。稳定的基础组件、纯展示组件,用哪种写法都行,装饰器写法反而显得啰嗦;而那种状态多、交互复杂、半年要改十几次的业务组件,我更倾向于用组合式 API 把状态和逻辑拆成独立的组合函数,因为拆出来的函数能被单测直接调用,而类组件的方法要挂载实例才能测,成本高不少。
还有一个实际感受:装饰器方案的真正难点不在语法,在链路配置的不确定性。TypeScript 版本升级、构建工具切换、Babel 插件顺序变动,任何一环动一下都可能让装饰器失效,而错误信息往往指向业务代码,容易误判。所以我现在的做法是,如果项目要用这条路,就把编译配置写进一个独立的config文件,加一条构建时的断言,跑测试时先验证装饰器是否能正常工作,把问题暴露在最早的位置,而不是等到某个页面白屏才发现。
最后分享一个小技巧。自定义装饰器写多了之后,我习惯在decorators目录下建一个index.ts,把所有装饰器集中导出,并且每个都配一行注释说明它依赖哪个状态字段。比如@Loading()默认读this.loading,那就在注释里写清楚。这么做的原因很朴素:装饰器把逻辑藏起来了,半年后回来改代码,光看方法定义根本不知道它被套了几层行为,一个集中的清单能省掉很多翻找时间。