【专题】装饰者模式

2019年11月16日Web前端

在传统的面向对象语言中,经常使用继承的方式给对象添加方法。但是继承的方式往往会引起一些问题。一是父类与子类之间强耦合,二是破坏原有的封装性。而装饰者模式能够在不改变对象自身的基础上,动态地给对象添加属性。

简单实现js中的装饰者

动态的修改对象的属性很简单,这样就可以做到了,

let obj = {
    name: 'hello',
    age: 123,
};

obj.name = obj.name + ' world';

当然装饰者模式不会改变原对象的,我们在此基础之上做修改,

let first = {
    print: function() {
        console.log('first');
    }
};

let secondPrint = function() {
    console.log('second');
};

let thirdPrint = function() {
    console.log('third');
};

let print1 = first.print;

first.print = function() {
    print1();
    secondPrint();
}

let print2 = first.print;

first.print = function() {
    print2();
    thirdPrint();
}

first.print();      // 以此按行输出:first,second,third

我们通过保存原先函数引用的方式就可以重写之前的某个函数。其实这种方式很常见,像DOM0级事件绑定时,假如我们要给window对象绑定load回调事件,

// 假如某个js里有定义
window.onload = function() {
    console.log(1);
}

// 正在开发的js文件
let _onload = window.onload || function() {};

window.onload = function() {
    _onload();
    console.log(2);
}

由于我们不能确定window.onload是否在其他页面被定义,为了防止他被覆盖,于是先保存原先的onload事件,再将其放入新定义的onload中执行。

虽然达到了效果,但是这种方式有几个问题:

  • 中间变量 _onload 要维护,如果要装饰的函数变多,要维护的中间变量也会变多。
  • this丢失问题,当然这儿没有,因为执行环境指向的window。

考虑下以下代码:

let _document = document.getElementById;

document.getElementById = function(id) {
    console.log(1);

    return _document(id);
};

document.getElementById('btn');

由于调用全局的 _document 方法时,this是指向window的,而该方法需要指向document才能作用,所以出错了。

所以调用时需要改变下this指向,借助apply或call,

// ...
    return _document.apply(document, [id]);
// ...

AOP修饰函数

首先给出before和after两个方法,

Function.prototype.before = function(beforFn) {
    let _self = this;           // 原函数引用

    return function() {
        beforFn.apply(this, arguments);

        return _self.apply(this, arguments);
    };
}

Function.prototype.after = function(afterFn) {
    let _self = this;           // 原函数引用

    return function() {
        let res = _self.apply(this, arguments);

        afterFn.apply(this, arguments);

        return res;
    };
}

两函数都接收一个新功能函数,唯一区别则是该新功能是前置执行,还是后置执行。而apply的使用保证this指向不会被丢失。

当然直接给原型增加新方法会污染原型,将这两方法稍作修改:

const before = function(fn, beforeFn) {
    return function() {
        beforeFn.apply(this, arguments);
        
        return fn.apply(this, arguments);
    }
};

const after = function(fn, afterFn) {
    return function() {
        let res = afterFn.apply(this, arguments);
        
        fn.apply(this, arguments);

        return res;
    }
};

let a = before(function() {
    console.log(111);
}, function() {
    console.log(222);
});

a = before(a, function() {
    console.log(333);
});

a();        // 333 222 111

应用实例

动态修改参数

假如我们现在实现了一个简单的ajax请求,

let ajax = function(type, url, params) {
    // ... ajax处理
    console.log(params);
};

现在由于安全问题,现在需要在params增加个token参数,利用前面的Function.prototype.before装饰到ajax函数中的params参数上,

let getToken = function() {
    return 'token';
};

ajax = ajax.before(function(type, url, params) {
    params.token = getToken();
});

ajax('get', 'http://xx.com', { name: 'hello' });   // {name: "hello", token: "token"}

利用AOP方式给ajax函数动态地增加了参数,也保证了ajax的独立性,提高了ajax函数的复用性。

插件式表单验证

表单是web上必不可少的一部分,那么他的验证也是必不可少的。比如登录模块,至少需要检验用户名和密码是非空,

<body>
    <input type="text" id="username" />
    <input type="password" id="password" />
    <input type="button" id="btn" value="提交" />
</body>
<script>
    let oUser = document.getElementById('username'),
        oPass = document.getElementById('password'),
        oBtn = document.getElementById('btn');

    let submit = function() {
        if (oUser.value === '') {
            return console.log('用户名不能为空');
        }
        if (oPass.value === '') {
            return console.log('密码不能为空');
        }
        
        const params = {
            username: oUser.value,
            password: oPass.value,
        }

        ajax('...', params);        // 简略
    };
    oBtn.onclick = function() {
        submit();
    };
</script>

submit函数此处需要负责数据合法校验和提交ajax,造成函数臃肿,职责混乱,没有复用性。首先将校验部分代码分离,

const validate = function() {
    if (oUser.value === '') {
        console.log('用户名不能为空');
        return false;
    }
    if (oPass.value === '') {
        console.log('密码不能为空');
        return false;
    }
};

let submit = function() {
    if (validate() === false) {
        return;
    }
    
    // ....
};

代码已经有了改进,虽然校已经独立到validate函数中了,但是submit函数仍要计算validate函数的返回值,因为返回值表明了是否通过校验。

理好先后顺序,是优先validata后,再到submit,略微修改before函数:

Function.prototype.before = function(beforFn) {
    let _self = this;           // 原函数引用

    return function() {
        if (!beforFn.apply(this, arguments)) {
            // 验证失败,不通过
            return;
        }

        return _self.apply(this, arguments);
    };
}

const validate = function() {
    if (oUser.value === '') {
        console.log('用户名不能为空');
        return false;
    }
    if (oPass.value === '') {
        console.log('密码不能为空');
        return false;
    }
};

let submit = function() {
    const params = {
        username: oUser.value,
        password: oPass.value,
    };

    ajax('...', params);        // 简略
}

submit = submit.before(validate);

oBtn.onclick = function() {
    submit();
};

现在将校验和提交ajax完全拆分开了,不存在耦合关系,这样要增加其他校验规则,只需要动validate就行了。

当然,由于function也是对象,但装饰返回的是一个新函数,所以原函数上的属性就会丢失。

总结

装饰者模式在平时的开发过程中用的非常多,当然我们要注意下装饰太多的话,性能还是会有影响的。

参考《JavaScript设计模式与开发实践》