装饰器介绍

初识装饰器,还是在 babel-preset-stage-0 提案法则中被支持,学过 Java 的想必能联想到注解。正如大家所知,装饰器修饰类是最简单的写法,直接按照高阶函数的方式来书写处理函数即可,关于对类体内部字段和方法的修饰,就没有那么简单了。关于装饰器的更多介绍和使用方式,见阮老师的 ES6 文档

问题起始

这篇文章的启发源自对防抖函数的封装探索,防抖函数的实现原理(debounce)无可厚非,使用告诫函数对防抖函数的二次处理时最容易也是防抖实现的核心:

export function debounce (func) {
let timer = null
return function() {
if (timer) {
clearTimeout(timer)
}
let argumentsCopy = arguments
timer = setTimeout(() => {
func.apply(this, argumentsCopy)
timer = null
}, 300)
}
}

实现思路很简单也很主流,使用闭包的方式使返回的函数在每次调用的时候都能够访问到外层声明的 timer,以此来实现每一个 debounce 都能够拥有自己的计时器。我们还可以加一层闭包来传入 duration 参数或者直接跟到 func 后面,使用方式也很简单,直接讲函数扔给 debounce,然后就当事情从未发生过一样使用。

那么问题来了,高阶函数太不方便了,我们都使用过 mobx,他们对 action 的实现就非常的简便,直接模仿对类的修饰,直接使用装饰器就可以搞定,而且最重要的是,还支持箭头函数。需求来了,得想办法实现呀~~~

使用装饰器改造 debounce

export function debounce (target, name, descriptor) {
let timer = null
let oldValue = descriptor.value
return {
...descriptor,
value: function() {
if (timer) {
clearTimeout(timer)
}
let argumentsCopy = arguments
timer = setTimeout(() => {
oldValue.apply(this, argumentsCopy)
timer = null
}, 300)
}
}
}

装饰器写法很简单,它接受三个参数:

  1. target:当前类的原型
  2. name:当前方法的名字
  3. descriptor:承载着装饰的内容
    descriptor.value 就是被修饰的方法,官方大致的思路很明确,就是利用常规的 hook 方法,对 value 进行 hook 改造,达到装饰的目的。
    使用的时候只需要:
class A {
@debounce
click () {
console.log("点击间隔不超过 300 ms,连续点击只会触发最后一次哦)
}
}

事情没那么简单

我们发现,因为我们在开发中经常使用给 this 实例(注意不是原型)上声明箭头函数的方式来以最便捷的方式绑定上下文 this 的时候,发现装饰器完全没有用, 让人挠头的时刻来了。

经过各种调研,发现官方对尖头函数的支持根本没有做具体说明,只讲了对类、类内函数、属性的修饰,箭头函数怎么办呢?转换思维后发现,箭头函数本身也是以属性的方式存在于类体内,于是乎又开始调研装饰属性的用法:

// decorator 外部可以包装一个函数,函数可以带参数
function Decorator(type){
/**
* 这里是真正的 decorator
* @target 装饰的属性所述的类的原型,注意,不是实例后的类。如果装饰的是 Car 的某个属性,这个 target 的值就是 Car.prototype
* @name 装饰的属性的 key
* @descriptor 装饰的对象的描述对象
*/
return function (target, name, descriptor){
// 以此可以获取实例化的时候此属性的默认值
let v = descriptor.initializer && descriptor.initializer.call(this);
// 返回一个新的描述对象,或者直接修改 descriptor 也可以
return {
enumerable: true,
configurable: true,
get: function() {
return v;
},
set: function(c) {
v = c;
}
}
}
}
__ 引自 https://juejin.im/post/59f1c484f265da431c6f8940

以下是关于装饰器的一些规则:

  1. 通过 descriptor.value 的修改直接给改成不同的值,适用于方法的装饰器。
  2. 通过 descriptor.getdescriptor.set 修改逻辑,适用于访问器的装饰器。
  3. 通过 descriptor.initializer 修改属性值,适用于属性的装饰器。
  4. 修改 configurablewritableenumerable 控制属性本身的特性,常见的就是修改为只读

以上是我所调研到关于属性装饰的一种方式,同下面常规 hack 访问器的方式相同,我们使同样使用了以上第二种 hack 访问器的方式来解决问题,先通过执行调用 initializer 来获取属性默认值(此时是不存在 value 的),然后配合 getter 和 setter 来装饰这个属性本身。

这里需要注意的是,最好把 v 的取值放到 get 函数里,这样在调用 call 函数的时候才能绑定到正确的运行时 this,而非编译时的 this(装饰器是编译时对代码的一种更改,因此它的调用者相当于浏览器中的 window,React 中则是和严格模式一致,为 undefined)。
因此,相对正确装饰属性的操作如下:

// decorator 外部可以包装一个函数,函数可以带参数
function Decorator(type){
return function (target, name, descriptor){
// 返回一个新的描述对象,或者直接修改 descriptor 也可以
return {
enumerable: true,
configurable: true,
get: function() {
// 以此可以获取实例化的时候此属性的默认值
let v = descriptor.initializer && descriptor.initializer.call(this);
return v;
}
}
}
}

我们再来看看常规操作对象属性的 getter 和 setter 的案例:

/**
* 为一个对象的属性变动添加监听事件
*/
function bindData(target, event){
for(var key in target) {
if(target.hasOwnProperty(key)) {
(function(){ // 使用 let 同样代替立即执行函数,同样可以避免 var 缺乏块作用域带来的生命周期污染
var v = target[key]; // 取值
Object.defineProperty(target, key, {
get: function() {
return v;
},
set: function(_value) {
v = _value;
event.call(this)
}
})
})()
}
}
}

一般在 hack set 或 get 方法的时候,都是成对定义这两个属性的,以便保全最基本的 get 和 set 功能。这里可以看出,我们在对属性操作的时候,发现装饰器方法的签名和 defineProperty 一致。
主要是通过修改一个属性的描述器来修改属性本身,这个和 Object.defineProperty Api 一致。

拓展

{
value: 属性的值,默认为 undefined,
writable: 属性是否可写,默认值为 true,
configurable: 属性是否可配置,默认值为 true,
enumerable: 属性是否可以在 for-in 中或 Object.keys 被枚举出来, 默认值为 true,
get: 一旦目标对象访问该属性,就会调用这个方法,并返回改方法的运算结果,默认为 undefined,
set: 一旦目标对象访问该属性,就会调用这个方法,默认为 undefined
}

需要注意的是,在 MDN 文档中,其中几个为 true 的值均为 false, 这并不矛盾,文档中所描述的是在一个 Object 中,一个属性被定义之后的默认值,可以使用 Object.getOwnPropertyDescriptor(object, key) 来查看描述器中的值,而本文中介绍的是,使用 defineProperty 和复写 descriptor 时不给予值时的默认值,因此在修改 descriptor 的时候一定要尽可能覆盖到这三个属性。

装饰属性的另一种方式

通过 descriptor.initializer 修改属性值,适用于属性的装饰器。
很简单,就是通过重写 initializer 函数来重新计算:

// decorator 外部可以包装一个函数,函数可以带参数
function Decorator(type){
return function (target, name, descriptor){
// 返回一个新的描述对象,或者直接修改 descriptor 也可以
return {
enumerable: true,
configurable: true,
initializer: function() {
// 以此可以获取实例化的时候此属性的默认值
let v = descriptor.initializer && descriptor.initializer.call(this);
return v;
}
}
}
}

将对应的属性替换为我们的箭头函数,debounce 函数就变成如下了:

export const niceDebounce = function (params = {}) {
return function (target, name, descriptor) {
let timer = null
const { delay = 300, immediate = false } = params

// high order function
if (!descriptor || (arguments.length === 1 && typeof target === 'function')) {
return createDebounce(target)
}

function createDebounce (fn) {
return function debounce () {
if (immediate && !timer) {
fn.apply(this, arguments)
}
if (timer) {
clearTimeout(timer)
}

let argumentsCopy = arguments
let that = this

timer = setTimeout(function () {
if (!immediate) {
fn.apply(that, argumentsCopy)
}
timer = null
}, delay)
}
}

// 修饰类内的箭头函数
if (descriptor.initializer) {
return {
enumerable: false,
configurable: true,
get: function () {
return createDebounce(descriptor.initializer.call(this))
}
}
}

return descriptor
}
}

修饰箭头函数

这里我们示范了使用 get 访问器来处理修饰箭头函数的问题,不能同时和处理数据属性的选项同时设置,比如 writable。同样我们也可以将它原地换成 initializer 就可以了,同时要添加 writable 选项,否则该属性(此处为箭头函数)就再也不可更改了。
需要注意的是:

  1. 作为 getter,它是属性每次访问都会被执行的函数
  2. initializer 作为初始化的函数,它只会在创建装饰器的时候会初始化执行一次,将属性值设置为该函数的返回值

getter 的缺点就是会反复创建新的函数,只需要注意保持 debounce 函数的等幂性就好了。

小疑问:这里为什么使用 createDebounce(descriptor.initializer()).bind(this) 就不行呢?
经调研,不论是 第一种还是第二种方式,我们这样绑定的 this 在 decorator 中都是组件,也就是属性的调用者——组件实例,而在 fn 也就是我们的箭头函数在被执行的过程中 this 就变成了 descriptor 本身,费解 …
换而言之,就是在修饰类的属性方面,只有通过 descriptor.initializer.call() 获取属性值的时候,才能将我们想要的 this 绑定到返回值中,而拿着没有绑定 this 上下文的属性值却无法再次绑定 this。答案只能隐藏在 initializer 这个函数中 …

关于 initializer

initializer:  ƒ initializer() {
var _this4 = this;
return function (clearUntreated, item, index) {
console.log('clickContact', _this4);
};
}

经过打印,我们的箭头函数
() => {console.log('clickContact', this)}
decorator 作为 babel-stage-0 的特性,在解释前经过 babel 转译,我们的 initializer 变成了如上的 es5 函数。其实,这和 babel 转译箭头函数是一致的:


因此,我们可以判断的是,我们的箭头函数在被 decorator 处理的时候,被安排到了 initializer 这个 function 中,而作为箭头函数的定义时 this 特性,它在解释运行之前,被 babel 转译成了这个样子,因而在后续解释执行的时候,this 永远地被安排成了 initializer 的运行时上下文了,这也就可以理解为什么只有在执行 descriptor。initializer.call(this) 才会绑定到正确的 this,此时不绑定,返回的 fn (这里是闭包)永远持有着取值时的 descriptor 上下文。

接下来我们看看 bind 为何不行

(function initializer() {
var _this = this;

return function () {
console.log('this of first bind', _this);
console.log('this of second bind', this);
};
}).bind({a: "outer this"})().bind({b: "inner this"})();
// output
this of first bind: {a: "outer this"}
this of second bind: {b: "inner this"}

我们将 babel 转译后的代码,并调用 bind 之后,似乎明白了什么 ……

  1. babel 的转译,将所有语法糖、ES6、7 特性转为了 ES5 代码,这样,我们箭头函数(作为定义时指定 this ,其实是被 babel 在转译时指定了 this)的 this 已经确定了下来,被修改为了 _this
  2. bind 之后的 this,是生成的 ES5 函数里的运行时 this,此时这个 this 与我们无关(因为我们硬代码里的 this 全都被替换掉了)

*可以得出一个小结论:使用箭头函数指定定义时 this 后,就已经无法再去使用 bind 重新指定 this 了。*
其实是可以的,只是 bind 作为 v8 引擎的 API,是在解释执行的时候运行的。而我们在定义函数时所使用的所有 this 会被 babel 转译为箭头函数所在的上下文(这里就完全取决于外部了(可以是运行时,也可以是定义时),不由箭头函数本身决定了),而引擎在执行 bind 函数的时候,才会给转译后的 function 的 this 重定向,这是没有意义的。

修饰类内函数

// 修饰类内函数
if(descriptor.value) {
return {
enumerable: false,
configurable: true,
writable: true,
value: createDebounce(descriptor.value)
}
}

因为 value 是 descriptor 的数据属性,因此会在构建类体的时候执行且被执行一次,此时 value 的值就是我们使用 descriptor.value 创建好的 debounce 函数。进一步,我们在运行过程中使用组件实例 this 调用 debounce 函数的时候,由于是利用了 function 的运行时上下文特性,自然能够在 debounce 中获取到正确的 this 上下文,此时再通过 fn.apply 或者 fn.call 这样的语言 API 来重新给 fn 绑定正确的上下文 this。(需要注意的是,由于装饰器是在 class 构建的过程中进行操作的,因此在执行 createDebounce 的时候,其 scope 内的上下文为 undefined)

进一步思考问题
我们在上面介绍箭头函数的时候发现,返回的 debounce 函数内部根本不需要重新给 fn 绑定 this。而这里却不可以,返回的 debounce 严重依赖调用它的 this,而此时我们讨论的是类内函数,它需要正确的运行时上下文呀!

// 修饰类内函数
if(descriptor.value) {
return {
enumerable: false,
configurable: true,
writable: true,
value: function () {
return createDebounce(descriptor.value.bind(this)(...arguments)
}
}
}

为了能让 debounce 中的 fn 执行时拥有正确的 this,我们只好在 value 的定义上,利用函数主动创造运行时环境,但由于 value 的值本身就应该是 debounce 函数,因此我们在这里重写为 function,相当于是用该 function 去替换 debounce 函数。为了保持一致性,应该被要求 value 方法的运行结果和改动之前完全一致(也就是 debounce 的执行结果),所以我们在返回值里创建好 debounce 后一定不要忘记执行。

总结

一路下来,对装饰器、箭头函数、普通函数的 this 的使用和定义有了更加全面的理解,『门儿清』不易啊 …

源代码

donation