【JS设计模式】异国战场——参与者模式

137 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

参与者【participator】
在特定的作用域中执行给定的函数,并将参数原封不动地传递。

提到 特定的作用域,我们就要想到 ABC函数,即 apply、bind、call 这三个函数。 call和apply方法可以使我们在特定作用域中执行某个函数,并传入参数。 call()、apply()bind() 的区别就在于: call、apply 是在指定的作用域中运行函数,而 bind 则是把函数绑定到指定的作用域上,注意,这里只是绑定到指定的作用域,并没有运行函数,绑定完之后还需要我们手动执行函数。

假设页面上有一个按钮,我们定时从后台获取数据,但是只有点击按钮时才会执行某个事件,并且给事件传入对应的参数, 应该如何实现呢?我们先实现第一步,监听按钮,点击时执行某个事件M。

// 封装兼容性方法
A.on = function (dom, type, fn) {
    if (document.addEventListener) {
        // 主流浏览器
        dom.addEventListener(type, fn, false)
    } else {
        // IE
        dom.attachEvent('on' + type, fn)
    }
}

经过上面的封装,我们只需要传入按钮的dom元素以及执行的事件类型 click 和执行的回调函数 M,就可以实现当我们点击按钮时就执行事件M了。

然后我们来实现下一步,给执行的事件A传入参数。这里我们先修改一下封装的兼容性方法on,给它添加一个data参数,用来接收外部传来的参数。

A.on = function (dom, type, fn, data) {
    if (document.addEventListener) {
        // 主流浏览器
        dom.addEventListener(type, fn, false)
    } else {
        // IE
        dom.attachEvent('on' + type, fn)
    }
}

然后问题就来了,事件监听器是不支持给回调函数传入参数的,也就是说下面这种写法是错误的。

addEventListener('click', fn(data), false) // 错误

这一点我们没办法改变,所以我们把视线转移到 addEventListener 的第二个参数回调函数上,我们把回调函数改成匿名函数的形式,然后在匿名函数中执行M事件,这不就可以传入参数了?

addEventListener('click', function (e) {
    M.call(dom, e, data)
}, false)

这里我们要使用 call 方法把作用域绑定回调用M事件的dom元素身上,然后传入一个默认的回调参数event事件对象,以及我们自己要传的参数data,这样下来,我们就可以实现我们的需求了。

const A = {}
const btn = document.getElementById('btn')
const M = function (e, data) {
    console.log('测试测试测试', data);
}
A.on = function (dom, type, fn, data) {
    if (document.addEventListener) {
        console.log('这是主流浏览器环境');
        // 事件绑定
        dom.addEventListener(type, function (e) {
            fn.call(dom, e, data)
        }, false)
    } else {
        console.log('这是IE浏览器环境');
        dom.attachEvent('on' + type, function (e) {
            fn.call(dom, e, data)
        })
    }
}
// 执行一下
A.on(btn, 'click', M, '我是个参数')
// 点击按钮将输出 =》 测试测试测试 我是个参数

但是问题又来了, addEventListener 的回调函数如果是个匿名函数的话,是无法通过 removeEventListener 去移除掉事件的。 无法移除事件就会导致点击按钮,执行多个绑定的事件,比如我们现在绑定了一个事件M了,如果没移除M又绑定了一个M2,那么点击按钮的时候就会即执行M也执行M2。这样效果并不是我们想要的,因此我们继续改进。

这时,我们的 bind 方法就登场了。bind 的实现思想是 让一个函数在一个作用域中执行。 但是 bind 有个硬伤,就是它的支持性不是很少,虽然现在普遍都已经在 Function 对象的原型上实现了 bind,但是还是有一小部分浏览器需要我们自己去实现该方法。

函数绑定 bind

我们只需要一个闭包就可以实现 bind

// 函数绑定 bind
function bind(fn, ctx /*ctx:作用域*/) {
    // 闭包返回新函数
    return function () {
        // 对fn装饰并返回
        return fn.apply(ctx, arguments)
    }
}

实现完,我们来测试一下效果。

// 函数绑定 bind
function bind(fn, ctx /*ctx:作用域*/) {
    // 闭包返回新函数
    return function () {
        // 对fn装饰并返回
        return fn.apply(ctx, arguments)
    }
}

const obj1 = {
    title: '我是Obj1的title'
}
const obj2 = {
    title: '我是Obj2的title'
}

function showTitle() {
    console.log(this.title);
}

bind(showTitle, obj1)() // 我是Obj1的title
bind(showTitle, obj2)() // 我是Obj2的title

实现了 bind 之后,我们就可以这样使用了。

// 函数绑定 bind
function bind(fn, ctx /*ctx:作用域*/, data /*参数*/) {
    return function () {
        return fn.apply(ctx, data)
    }
}

const A = {}
const btn = document.getElementById('btn')
const data = ['我是参数']

const M = function (data) {
    console.log('测试测试测试', data);
}
const bind_M = bind(M, btn, data)
A.on = function (dom, type, fn, data) {
    if (document.addEventListener) {
        dom.addEventListener(type, fn, false)
    } else {
        dom.attachEvent('on' + type, fn)
    }
}
A.on(btn, 'click', bind_M, data) // 执行事件绑定

上面代码中,我们要传入的参数data是个数组,因为apply的第二个参数要求是个参数数组。这里不细讲 apply、call 的区别了,主要是讲另一个问题。

那就是,现在我们只有一个参数data,那假如我们的参数个数是不确定的呢?这里我们先不考虑ES6中的...扩展符,而是采用函数柯里化来解决这个问题。

在这之前,我们要先知道一个知识点,那就是函数的形参, 在每个函数内部都有一个叫做 arguments 的内置数组,这个数组中存储当前函数和传入的所有参数,而且数组中元素的排列顺序就是我们的传参顺序。

function demo() {
    console.log(arguments)
}

demo('参数1', 2, '参数3')

上面的函数demo,虽然我们没有给它定义参数,但是我们在调用的时候硬给它传递了3个参数,这时候我们就可以在arguments数组中找到这3个参数的存在。

image.png

arguments的作用就了解到这里。接下来我们使用柯里化来对 bind 函数进行优化一下。

函数柯里化

柯里化是一种对函数的转换,它将可调用的多参数函数 f(a,b,c) 转换成可调用的 f(a)(b)(c) 这种形式,柯里化不会调用函数,它只是对函数进行转换。

function bind(fn, ctx /*ctx:作用域*/) {
    const slice = Array.prototype.slice // 保存数组原型上的slice方法,用来分割arguments
    const args = slice.call(arguments, 2) // 从第3个参数开始切割,因为前2个参数是fn和ctx
    return function () {
        const addArguments = slice.call(arguments) // 这里的arguments是返回的这个匿名函数的,是个空数组
        allArguments = addArguments.concat(args) // 数组拼接,拼接后的数组中保存的就是我们要传递的参数
        return fn.apply(ctx, allArguments)
    }
}

接下来我们尝试一下优化后的 bind

const P1 = function(name){
    console.log(name)
}
const P2 = function(name,age){
    console.log(name,age)
}
const P1_bind = bind(P1,this,'老六')
const P2_bind = bind(P2,this,'老六',18)

P1_bind() // 老六
P2_bind() // 老六,18

总结

参与者模式就是在给定作用域中执行给定的函数,并将参数原封不动的传递,就像特种兵在给定的任务地点执行任务,所以称之为异国战场。

这种模式其实并不属于一般定义的26种设计模式的范畴,它像是一种技巧型设计模式。

参与者模式通过函数绑定bind来实现指定作用域,通过函数柯里化来实现原封不动的传递函数。

参与者模式常用于一些异步逻辑的回调中,因为函数绑定的构造复杂,在执行时会消耗更多的内存,因此在执行速度上会稍慢一些。