從 JavaScript 到 TypeScript 4 - 裝飾器和反射

隨着應用的龐大,項目中 JavaScript 的代碼也會愈來愈臃腫,這時候許多 JavaScript 的語言弊端就會愈發明顯,而 TypeScript 的出現,就是着力於解決 JavaScript 語言天生的弱勢:靜態類型。html

前端開發 QQ 羣:377786580前端

這篇文章首發於個人我的博客 《據說》,系列目錄:vue

在上一篇文章 《從 JavaScript 到 TypeScript 3 - 引入和編譯》 咱們簡單介紹了 TypeScript 的引入和編譯,在這篇文章中,咱們會討論 ECMAScript 的新特性,爲後續的內容作點鋪墊。node

前言

在瞭解裝飾器以前,咱們先看一段代碼:express

class User {
  name: string
  id: number

  constructor(name:string, id: number) {
    this.name = name
    this.id = id
  }

  changeName (newName: string) {
    this.name = newName
  }
}

這段代碼聲明瞭一個 Class 爲 UserUser 提供了一個實例方法 changeName() 用來修改字段 name 的值。npm

如今咱們要在修改 name 以前,先對 newName 作校驗,判斷若是 newName 的值爲空字符串,就拋出異常。json

按照咱們過去的作法,咱們會修改 changeName() 函數,或者提供一個 validaName() 方法:bash

class User {
  name: string
  id: number
  constructor(name:string, id: number) {
    this.name = name
    this.id = id
  }
  // 驗證 Name
  validateName (newName: string) {
    if (!newName){
      throw Error('name is invalid')
    }
  }
  changeName (newName: string) {
    // 若是 newName 爲空字符串,則會拋出異常
    this.validateName(newName)
    this.name = newName
  }
}

能夠看到,咱們新編寫的 validateName(),侵入到了 changeName() 的邏輯中。如此帶來一個弊端:函數

  1. 咱們不知道 changeName() 裏面可能還包含了什麼樣的隱性邏輯
  2. changeName() 被擴展後邏輯不清晰

而後咱們把調用時機從 changeName() 中抽出來,先調用 validateName(),再調用 changeName()ui

let user = new User('linkFly', 1)
if (user.validateName('tasaid')) {
  user.changeName('tasaid')
}

可是上面的問題 1 仍然沒有被解決,調用方代碼變的十分囉嗦。那麼有沒有更好的方式來表現這層邏輯呢?

裝飾器就用來解決這個問題:"無侵入式" 的加強。

裝飾器

顧名思義,"裝飾器" (也叫 "註解")就是對一個 類/方法/屬性/參數 的裝飾。它是對這一系列代碼的加強,而且經過自身描述了被裝飾的代碼可能存在的行爲改變。

簡單來講,裝飾器就是對代碼的描述。

因爲裝飾器是實驗性特性,因此要在 tsconfig.json 裏啓用這個實驗性特性:

{
    "compilerOptions": {
        // 支持裝飾器
        "experimentalDecorators": true,
    }
}

鋼鐵俠託尼·史塔克只是一個有血有肉的人,而他的盔甲讓他成爲了鋼鐵俠,盔甲就是對託尼·史塔克的裝飾(加強)。

咱們使用裝飾器修改一下上面的例子:

// 聲明一個裝飾器,第三個參數是 "成員的屬性描述符",若是代碼輸出目標版本(target)小於 ES5 返回值會被忽略。
const validate = function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  // 保存原來的方法
  let method = descriptor.value
  // 重寫原來的方法
  descriptor.value = (newValue: string) => {
    // 檢查是不是空字符串
    if (!newValue) {
      throw Error('name is invalid')
    } else {
      // 不然調用原來的方法
      method()
    }
  }
}

class User {
  name: string
  id: number
  constructor(name:string, id: number) {
    this.name = name
    this.id = id
  }

  // 調用裝飾器
  @validate
  changeName (newName: string) {
    this.name = newName
  }
}

這裏咱們能夠看到,changeName 的邏輯沒有任何改變,但其實它的行爲已經經過裝飾器 @validate 加強。

這就是裝飾器的做用。裝飾器能夠用很直觀的方式來描述代碼:

class User {
  name: string

  @validateString
  set name (@required name: string) {
    this.name = name 
  }
}

裝飾器工廠

裝飾器的執行時機以下:

// 這是一個裝飾器工廠,在外面使用 @god() 的時候就會調用這個工廠
function god(name: string) {
  console.log(`god(): evaluated ${name}`)
  // 這是裝飾器,在 User 生成以後會執行
  return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
      console.log('god(): called')
  }
}

class User {
  @god('test')
  test () { }
}

以上代碼輸出結果

god(): evaluated test
god(): called

咱們也能夠直接聲明一個裝飾器來使用(要注意和裝飾器工廠的區別):

function god(target, propertyKey: string, descriptor: PropertyDescriptor) {
  console.log("god(): called")
}


class User {
  // 注意這裏不是 @god(),沒有 ()
  @god
  test () { }
}

裝飾器全家族

裝飾器家族有 4 種裝飾形式,注意,裝飾器能裝飾在類、方法、屬性和參數上,但不能只裝飾在函數上!

類裝飾器

類裝飾器表達式會在運行時看成函數被調用,類的構造函數做爲其惟一的參數。

function sealed(constructor: Function) {
  Object.seal(constructor)
  Object.seal(constructor.prototype)
}

@sealed
class User { }

方法裝飾器

方法裝飾器表達式會在運行時看成函數被調用,傳入下列 3個參數

  1. 對於靜態成員來講是類的構造函數,對於實例成員是類的原型對象
  2. 成員的名字
  3. 成員的屬性描述符 {value: any, writable: boolean, enumerable: boolean, configurable: boolean}
function god(name: string) {
  return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
    // target: 對於靜態成員來講是類的構造函數,對於實例成員是類的原型對象
    // propertyKey: 成員的名字
    // descriptor: 成員的屬性描述符 {value: any, writable: boolean, enumerable: boolean, configurable: boolean}
  }
}

class User {
  @god('tasaid.com')
  sayHello () { }
}

訪問器裝飾器

和函數裝飾器同樣,只不過是裝飾於訪問器上的。

function god(name: string) {
  return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
    // target: 對於靜態成員來講是類的構造函數,對於實例成員是類的原型對象
    // propertyKey: 成員的名字
    // descriptor: 成員的屬性描述符 {value: any, writable: boolean, enumerable: boolean, configurable: boolean}
  }
}

class User {
  private _name: string
  // 裝飾在訪問器上
  @god('tasaid.com')
  get name () {
    return this._name
  }
}

屬性裝飾器

屬性裝飾器表達式會在運行時看成函數被調用,傳入下列 2個參數

  1. 對於靜態成員來講是類的構造函數,對於實例成員是類的原型對象
  2. 成員的名字
function god(target, propertyKey: string) {
  // target: 對於靜態成員來講是類的構造函數,對於實例成員是類的原型對象
  // propertyKey: 成員的名字
}

class User {
  @god
  name: string
}

參數裝飾器

參數裝飾器表達式會在運行時看成函數被調用,傳入下列 3個參數:

  1. 對於靜態成員來講是類的構造函數,對於實例成員是類的原型對象
  2. 成員的名字
  3. 參數在函數參數列表中的索引
const required = function (target, propertyKey: string, parameterIndex: number) {
  // target: 對於靜態成員來講是類的構造函數,對於實例成員是類的原型對象
  // propertyKey: 成員的名字
  // parameterIndex: 參數在函數參數列表中的索引
}

class User {
  private _name : string;
  set name(@required name : string) {
    this._name = name;
  }
}

例如上面 validate 的例子能夠用在參數裝飾器上

// 定義一個私有 key
const requiredMetadataKey = Symbol("required")

// 定義參數裝飾器,大概思路就是把要校驗的參數索引保存到成員中
const required = function (target, propertyKey: string, parameterIndex: number) {
  // 參數裝飾器只能拿到參數的索引
  if (!target[propertyKey][requiredMetadataKey]) {
    target[propertyKey][requiredMetadataKey] = {}
  } 
  // 把這個索引掛到屬性上
  target[propertyKey][requiredMetadataKey][parameterIndex] = true
}

// 定義一個方法裝飾器,從成員中獲取要校驗的參數進行校驗
const validateEmptyStr = function (target, propertyKey: string, descriptor: PropertyDescriptor) {
  // 保存原來的方法
  let method = descriptor.value
  // 重寫原來的方法
  descriptor.value = function () {
    let args = arguments
    // 看當作員裏面有沒有存的私有的對象
    if (target[propertyKey][requiredMetadataKey]) {
      // 檢查私有對象的 key
      Object.keys(target[propertyKey][requiredMetadataKey]).forEach(parameterIndex => {
        // 對應索引的參數進行校驗
        if (!args[parameterIndex]) throw Error(`arguments${parameterIndex} is invalid`)
      })
    }
  }
}

class User {
  name: string
  id: number
  constructor(name:string, id: number) {
    this.name = name
    this.id = id
  }

  // 方法裝飾器作校驗
  @validateEmptyStr
  changeName (@required newName: string) { // 參數裝飾器作描述
    this.name = newName
  }
}

clipboard.png

元數據反射

反射,就是在運行時動態獲取一個對象的一切信息:方法/屬性等等,特色在於動態類型反推導。在 TypeScript 中,反射的原理是經過設計階段對對象注入元數據信息,在運行階段讀取注入的元數據,從而獲得對象信息。

反射能夠獲取對象的:

  • 對象的類型
  • 成員/靜態屬性的信息(類型)
  • 方法的參數類型、返回類型
class User {
  name: string = 'linkFly'

  say (myName: string): string {
    return `hello, ${myName}`
  }
}

例如上面的例子,在 TypeScript 中能夠獲取到這些信息:

  • Class Name 爲 User
  • User 有一個屬性名爲 name,有一個方法 say()
  • 屬性 namestring 類型的,且值爲 linkFly
  • 方法 say() 接受一個 string 類型的參數,在 TypeScript 中,參數名是獲取不到的
  • 方法 say() 返回類型爲 string

TypeScript 結合自身靜態類型語言的特色,爲使用了裝飾器的代碼聲明注入了 3 組元數據:

  • design:type: 成員類型
  • design:paramtypes: 成員全部參數類型
  • design:returntype: 成員返回類型

因爲元數據反射也是實驗性 API,因此要在 tsconfig.json 裏啓用這個實驗性特性:

{
    "compilerOptions": {
        "target": "ES5",
        // 支持裝飾器
        "experimentalDecorators": true,
        // 裝飾器元數據
        "emitDecoratorMetadata": true
    }
}

而後安裝 reflect-metadata

npm i reflect-metadata --save

這樣在裝飾器中,就能夠訪問到由 TypeScript 注入的基本信息元數據:

import 'reflect-metadata'

let meta = function (target: any, propertyKey: string) {

  // 獲取成員類型
  let type = Reflect.getMetadata('design:type', target, propertyKey)
  // 獲取成員參數類型
  let paramtypes = Reflect.getMetadata('design:paramtypes', target, propertyKey)
  // 獲取成員返回類型
  let returntype = Reflect.getMetadata('design:returntype', target, propertyKey)
  // 獲取全部元數據 key (由 TypeScript 注入)
  let keys = Reflect.getMetadataKeys(target, propertyKey)


  console.log(keys) // [ 'design:returntype', 'design:paramtypes', 'design:type' ]
  // 成員類型
  console.log(type) // Function
  // 參數類型
  console.log(paramtypes) // [String]
  // 成員返回類型
  console.log(returntype) // String
}


class User {
  // 使用這個裝飾器就能夠反射出成員詳細信息
  @meta
  say (myName: string): string {
    return `hello, ${myName}`
  }
}

結語

Java 和 C# 因爲是強類型編譯型語言,因此反射就成了它們動態反推導數據類型的一個重要特性。

目前來講,JavaScript 由於其動態性,因此自己就包含了一些反射的特色:

  • 遍歷對象內全部屬性
  • 判斷數據類型

TypeScript 補充了基礎的類型元數據,只不過仍是有些地方不夠完善:在 TypeScript 中,參數名經過反射是獲取不到的。

爲何獲取不到呢?由於 JavaScript 本質上仍是解釋型語言,還迎合 Web 有一大特點:編譯和壓縮...

  • 編譯完了以後 Class Name 可能叫作 User_1
  • 壓縮完了以後參數 myName 可能叫 m
  • 運行時可能傳了 2 個,3 個,或者 N 個參數

angular 1.x 中使用的依賴注入,採用傳字符串那麼蹩腳的方式,也是對 JavaScript 反射機制的不完善作出的一種妥協。

在下一篇《從 JavaScript 到 TypeScript 5 - express 路由進化》 中,咱們將在 express 上,使用裝飾器和反射實現全新的路由表現。

 

TypeScript 中文網:https://tslang.cn/

TypeScript 視頻教程:《TypeScript 精通指南

相關文章
相關標籤/搜索