Babel 7 Decorator 的神奇小問題

Hi 各位朋友們大家好,我是 Johnny,最近在調整優化我的開源套件script-custom-moduleopen in new window時踩到了一個很神奇的 Decorator 小坑,覺得有必要把這記錄下來。

背景故事

整個事件背景是:我在幫我的開源工具添加 Typescript loader 後,順便測試使用 Property Decorator 時,發現無論我怎麼在 Decorator 中使用 defineProperty 對 target 進行調整,最後都會作用到 prototype 中而不是我的 instance,參考如下範例

相關配置

  • @babel/standalone: v7.17.11
module.exports = {
  presets: [availablePresets.typescript],
  plugins: [
    [availablePlugins['proposal-decorators'], {
      version: 'legacy'
    }],
    availablePlugins['proposal-class-properties']
  ]
}

Property Decorator 範例

// 首先定義一個 decorator
function logDec(target, propName) {
  let value = target[propName];
  Object.defineProperty(target, propName, {
    get: () => value,
    set: (newVal) => {
      console.log('gocha');
      value = newVal;
    }
  })
}

// 套用到目標類上
class User {
  @logDec name: string;

  constructor(name: string) {
    this.name = name;
  }
}

在線上 TS Playground 中編譯的結果

"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
  // ...
};
var __metadata = (this && this.__metadata) || function (k, v) {
  // ...
};
function logDec(target, propName) {
    let value = target[propName];
    Object.defineProperty(target, propName, {
        get: () => value,
        set: (newVal) => {
            console.log('gocha');
            value = newVal;
        }
    });
}
class User {
    constructor(name) {
        this.name = name;
    }
}
__decorate([
    logDec,
    __metadata("design:type", String)
], User.prototype, "name", void 0);
const johnny = new User('johnny');
console.log(johnny);

在 Babel v7 編譯後

var _class, _descriptor;

function _initializerDefineProperty(target, property, descriptor, context) {
  if (!descriptor) return;
  Object.defineProperty(target, property, {
    enumerable: descriptor.enumerable,
    configurable: descriptor.configurable,
    writable: descriptor.writable,
    value: descriptor.initializer ? descriptor.initializer.call(context) : void 0
  });
}

function _defineProperty(obj, key, value) {
  // ...
}

function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) {
  var desc = {};
  Object.keys(descriptor).forEach(function (key) {
    desc[key] = descriptor[key];
  });
  desc.enumerable = !!desc.enumerable;
  desc.configurable = !!desc.configurable;
  if ('value' in desc || desc.initializer) {
    desc.writable = true;
  }
  // 這邊會判斷 decorator 回傳的 descriptor,若有回傳則使用回傳的,若沒有則取到目前為止的,預設是由 babel 提供
  desc = decorators.slice().reverse().reduce(function (desc, decorator) {
    return decorator(target, property, desc) || desc;
  }, desc);
  if (context && desc.initializer !== void 0) {
    desc.value = desc.initializer ? desc.initializer.call(context) : void 0;
    desc.initializer = undefined;
  }
  if (desc.initializer === void 0) {
    Object.defineProperty(target, property, desc);
    desc = null;
  }
  return desc;
}

function _initializerWarningHelper(descriptor, context) {
  throw new Error('Decorating class property failed. Please ensure that ' + 'proposal-class-properties is enabled and runs after the decorators transform.');
}

function logDec(target, propName) {
  let value = target[propName];
  Object.defineProperty(target, propName, {
    get: () => value,
    set: newVal => {
      console.log('gocha');
      value = newVal;
    }
  });
}

let User = (_class = class User {
  constructor(name) {
    // 這邊被 Babel 包了一層 _initializerDefineProperty
    // _descriptor 由 _applyDecoratedDescriptor 的 reduce 處理後獲得
    _initializerDefineProperty(this, "name", _descriptor, this);

    this.name = name;
  }

}, _descriptor = _applyDecoratedDescriptor(_class.prototype, "name", [logDec], {
  configurable: true,
  enumerable: true,
  writable: true,
  initializer: null
}), _class);
const johnny = new User('johnny');
console.log(johnny);

在一般 ts 本地編譯環境似乎這麼寫沒有問題,而 Babel v7 以後,似乎 Babel 對 class constructor 內部的一些 property 進行了改寫重組,導致原本 Decorator 的 getter/setter 無法正確被調用

我們在觀察 _initializerDefineProperty, _applyDecoratedDescriptor 的實現後發現,如果在我們的 decorator 函數中沒有返回任何 descriptor,那 Babel 就會自動協助產生一個 descriptor 來構建對應的 property,而我們的 decorator 將只作用在 prototype 上

以這個情況而言,就必須明確將我們的 descriptor 從 decorator 中返回,如此 Babel 在編譯時就會以我們返回的 descriptor 來進行構建,修改後如下

// 返回一個 descriptor
function logDec(target, propName) {
  let value = target[propName];

  const descriptor = {
    get: () => value,
    set: (newVal) => {
      console.log('gocha');
      value = newVal;
    }
  })

  return descriptor;
}

class User {
  @logDec name: string;

  constructor(name: string) {
    this.name = name;
  }
}

結論

只能說 decorator 這概念目前在 Javascript 中的實現還沒有一個定論,導致 Babel, Typescript 的一些實現不太一致,大家在使用的時候要多多注意環境,避免像我這樣無意間掉進這種可怕的無解陷阱當中,找了好久才找到一個相關的討論串open in new window

今天的分享就到這了,感謝大家收看,掰掰~

Last Updated:
Contributors: johnnywang