likes
comments
collection
share

装饰器,到底是什么?

作者站长头像
站长
· 阅读数 12

背景

我之前自己写项目的时候并未涉及或者说很少用到“装饰器”,入职美团之后,发现组内在大量使用这个,包括负责状态管理的 vuex-class,负责 vue 基础能力的 vue-property-decorator,在学习的过程中产生了好奇心,装饰器是什么?装饰器模式是什么?装饰器在常规项目中 work 的怎么样?在 Vue 项目中 work 的又怎么样?

那么接下来,我将从几个方面来介绍装饰器。

基础版(core-decorator)

介绍一下

首先,“装饰器”这个东西只是一个语法糖。就像我们知道 class 语法糖背后是 ES5 构造函数,装饰器本质上是一个函数,它接收三个参数:目标对象、属性名和属性描述符,然后返回一个新的属性描述符。

他的存在是为了让我们做某些“动作”的时候更加优雅,比如说我想要让一个属性在声明之后只读,我们可以使用 @readonly

import { readonly } from 'core-decorators';

class Meal {
  @readonly
  entree = 'steak';
}

var dinner = new Meal();
dinner.entree = 'salmon';
// Cannot assign to read only property 'entree' of [object Object]

那让我们自己写一个 readonly 怎么写呢?

function readonly(target, name, descriptor) {
 descriptor.writable = false;
 return descriptor;
}

class MyClass {
 @readonly
 myReadOnlyProperty = 42;
}

const obj = new MyClass();
obj.myReadOnlyProperty = 43; // 抛出错误

聊聊原理

我们刚才说到:“装饰器本质上是一个函数,它接收三个参数:目标对象、属性名和属性描述符,然后返回一个新的属性描述符。”

在上面的实现,第一个参数是 target,在 Class 前修饰时,target 为类本身,在 Class 中的方法时修饰时候,target 为 ClassName.prototype。

举一个🌰--这个是说在 Class 前修饰

// Case One
function classDecorator(target) {
    target.hasDecorator = true
  	return target
}

@classDecorator
class Button {
    // Button类的相关逻辑
}
/* 此时 target 为 Button */

第二个🌰--这个是说在 Class 中的函数前修饰

// Case Two
function funcDecorator(target, name, descriptor) {
    let originalMethod = descriptor.value
    descriptor.value = function() {
    console.log('我是Func的装饰器逻辑')
    return originalMethod.apply(this, arguments)
  }
  return descriptor
}

class Button {
    @funcDecorator
    onClick() { 
        console.log('我是Func的原有逻辑')
    }
}
/* 此时 target 为 Button.prototype */

刚刚我们说了第一个参数,而第二个参数 name,是我们修饰的目标属性属性名,在上面的例子中就是 onClick,这个也没什么好说的。

关键就在这个 descriptor 身上,它也是我们使用频率最高的一个参数,它的真面目就是“属性描述对象”(attributes object)。我们可能对这个名字不熟悉,但我们或多或少都应该了解过 “Object.defineProperty(obj, prop, descriptor)”。这里的“descriptor”和“Object.defineProperty”的第三个参数是一样的。

它是 JavaScript 提供的一个内部数据结构、一个对象,专门用来描述对象的属性。它由各种各样的属性描述符组成,这些描述符又分为数据描述符和存取描述符:

  • 数据描述符:包括 value(存放属性值,默认为默认为 undefined)、writable(表示属性值是否可改变,默认为true)、enumerable(表示属性是否可枚举,默认为 true)、configurable(属性是否可配置,默认为true)。
  • 存取描述符:包括 get 方法(访问属性时调用的方法,默认为 undefined),set(设置属性时调用的方法,默认为 undefined )

再来看看我们前面 readonly 的实现:

function readonly(target, name, descriptor) {
 descriptor.writable = false;
 return descriptor;
}

class MyClass {
 @readonly
 myReadOnlyProperty = 42;
}

const obj = new MyClass();
obj.myReadOnlyProperty = 43; // 抛出错误

现在应该清晰很多了,我们就是通过操作了 descriptor 中的数据描述符“writable”,来实现数据不可重写的效果。

Vue 版

React HOC

我们先抛开 Vue 不谈,来聊聊 React,我们知道 React 中有一个概念叫“高阶组件(HOC)”,这个概念是由“高阶函数”衍生而来。

首先看看高阶函数的概念:“JS 中高阶函数是指能够接受函数作为参数返回一个函数作为结果的函数。”也就是输入“函数”,输出“函数”。

高阶组件的概念与其类似,可以简单理解为输入“组件”,输出“组件”。它是在 hooks 推出之前实现逻辑复用的重要功能。

这里提供一个把高阶组件当装饰器的方法,避免了一层又一次的嵌套。

实现高阶组件

import React, { Component } from 'react'

const BorderHoc = WrappedComponent => class extends Component {
  render() {
    return <div style={{ border: 'solid 1px red' }}>
      <WrappedComponent />
    </div>
  }
}
export default borderHoc

使用高阶组件

import React, { Component } from 'react'
import BorderHoc from './BorderHoc'

// 用BorderHoc装饰目标组件
@BorderHoc 
class TargetComponent extends React.Component {
  render() {
    // 目标组件具体的业务逻辑
  }
}

// export出去的其实是一个被包裹后的组件
export default TargetComponent

Vue基操

经过刚才我们对于基础版本的了解,我想大家对 Vue 装饰器方面的实现也有了一定的猜想,在这个模块里面,我们以一个例子,即 @Watch 的实现来聊聊装饰器是怎么实现的。

  1. 在 vue-prototype-decorator 中找到 @Watch ,实现代码如下
import { WatchOptions } from 'vue'
import { createDecorator } from 'vue-class-component'

export function Watch(path: string, watchOptions: WatchOptions = {}) {
  return createDecorator((componentOptions, handler) => {
    // 逻辑细节
  })
}

我们注意到 Watch 返回了一个 createDecorator,而这个方法源于 vue-class-component

  1. 在 vue-class-component 中,找到这个函数方法
export function createDecorator (factory: (options: ComponentOptions<Vue>, key: string, index: number) => void): VueDecorator {
  return (target: Vue | typeof Vue, key?: any, index?: any) => {
    const Ctor = typeof target === 'function'
      ? target as DecoratedClass
      : target.constructor as DecoratedClass
    if (!Ctor.__decorators__) {
      Ctor.__decorators__ = []
    }
    if (typeof index !== 'number') {
      index = undefined
    }
    Ctor.__decorators__.push(options => factory(options, key, index))
  }
}

我们可以看到 createDecorator 实质上是一个高阶函数,然后再在生成的函数中,携带了装饰器所需的相关参数,从而实现了 @Watch 的功能。

runtime

装饰器的调用时机

装饰器在运行时进行调用,这调用意味着装饰器在代码执行过程中动态地应用于目标对象。

Q:当存在多个装饰器的时候,我们如何确定顺序?

eg:

function first() {
  console.log("first(): factory evaluated");
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log("first(): called");
  };
}
 
function second() {
  console.log("second(): factory evaluated");
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log("second(): called");
  };
}
 
class ExampleClass {
  @first()
  @second()
  method() {}
}

A:

first(): factory evaluated
second(): factory evaluated
second(): called
first(): called

解释:

当多个装饰器应用于单个声明时,它们的评估类似于数学中的函数组合。在此模型中,当复合函数fg时,得到的复合 ( f ∘ g )( x ) 等效于f ( g ( x ))。

因此,在 TypeScript 中的单个声明上评估多个装饰器时,将执行以下步骤:

  1. 每个装饰器的表达式都是从上到下计算的。
  2. 然后将结果作为函数从下到上调用。

TS

为啥装饰器在 TS 中配置,在 JS 中不需要配置

在 TypeScript 中,我们需要在 tsconfig.json 文件中添加配置来启用装饰器,主要是因为装饰器目前仍然是一个实验性的特性,尚未成为 ECMAScript(JavaScript)的正式标准。TypeScript 作为 JavaScript 的超集,需要确保生成的 JavaScript 代码符合 ECMAScript 标准。因此,TypeScript 需要显式地告知编译器启用实验性的装饰器特性。

在 JavaScript 中,如果你使用了支持装饰器的构建工具(如 Babel) ,则不需要额外的配置。这是因为这些构建工具会将装饰器语法转换为标准的 JavaScript 代码,使其在浏览器或其他 JavaScript 运行时环境中正常运行。然而,需要注意的是,即使在 JavaScript 中使用装饰器,你仍然需要为构建工具(如 Babel)配置相应的插件来支持装饰器语法。

总之,TypeScript 和 JavaScript 中的装饰器都需要相应的配置,以确保代码能够正确地转换和运行。在 TypeScript 中,需要修改 tsconfig.json 文件;而在 JavaScript 中,需要为构建工具配置相应的插件。

TS 配置

  • 开启 TS 实验性质功能
{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}
  • loadMeta配置

    • 配置
    • 使用
{
  "compilerOptions": {
    "emitDecoratorMetadata": true
  }
}
import "reflect-metadata";

function logType(target: any, key: string) {
  const type = Reflect.getMetadata("design:type", target, key);
  console.log(`${key} type: ${type.name}`);
}

class MyClass {
  @logType
  public myProperty: string;
}

在这个例子中,logType 装饰器使用 Reflect.getMetadata 函数获取 myProperty 属性的类型(string),并将其输出到控制台。

TS 转化 playGround

如果对你有帮助,能点个赞么qwq