发布/订阅模式 实例分析

1,091 阅读5分钟

事件监听模式是一种广泛应用于异步编程的模式,是回调函数的事件化,又称发布/订阅模式。

简单示例
//使用node的events模块

const EventEmitter = require('events');
const myEE = new EventEmitter();

myEE.on('foo', () => {console.log('foo')});

myEE.emit('foo')
// foo

events还有其他的方法,有兴趣的可以去看看,很有意思,传送门

自己实现一个简单发布/订阅模式

就两个方法:on和emit


class Event {
    constructor () {
        this._list = {};
    }
    // 订阅
    on (name, fn) {
        if (!this._list[name]) {
            this._list[name] = [];
        }
        this._list[name].push(fn);
    }
    // 发布
    emit (name, ...args) {
        const item = this._list[name];
        if (!item) {
            return;
        }
        for (let i = 0; i < item.length; i += 1) {
            item[i](...args);
        }
    }
}

const ev = new Event()
ev.on('foo', (ms) => {console.log(ms)})
ev.emit('foo', '我是谁')
// 我是谁

上面的代码应该难不倒各位看官们,我就不细讲了。接下来我来聊聊有哪些应用场景可以用到这种模式

第一种:调用者无需关心订阅事件,只需要约定好发布事件含义即可。

有场景如下:

统计当前页面浏览时间,获取开始时间和结束时间,然后上报,我的思路如下:

一般写法

let startTime,endTime

function getStartTime () {
    startTime = new Date()
}

function getEndTime () {
    endTime = new Date()
}

function timeDuration () {
    upload({ startTime,endTime })
}
  • 优点:结构简单易懂
  • 缺点:startTime,startEnd变量污染全局,功能单一等等

封装一下

function upload () {
    let startTime,startEnd
    
    return {
        start () {
           startTime = new Date() 
        },
        end () {
            endTime = new Date()
            upload({ startTime,endTime })
        }
    }
}

let obj = upload()
obj.start() // 定义startTime
obj.end() // 上报

后来想想能不能只有一个方法暴露,然后只要两步即可,改造一下

function upload () {
    let startTime = new Date()
    
    return functin () {
        upload({ startTime, new Date() })
    }
}

let fn = upload() // 定义startTime
fn() // 上报

// 后来发现不适合应用于vue框架,感觉怪怪的:一个事件的返回值为方法存于其data变量

这样看起来很不错,实现多次使用,不污染全局变量,但是也是得有"一个变量"来接受upload函数返回值,而且只能实现一对一,"一个变量"的start只能为当前时间点服务,这时我渐渐的清晰我想要的是可以多次注册相同事件然后统一次上报(允许1对多)。

而上述的要求刚好契合发布/订阅的特点,代码如下

function upload () {
    // 上面的Event类
    let ev = new Event();
    // 暴露两个api
    return {
        strat (name) {
            let startTime = new Date()
            ev.on(name, () => {
                upload(startTime, new Date())
            })
        },
        end(name) {
            ev.emit(name)
        }
    }
}

let obj = upload()
obj.start('foo')
obj.start('foo')
obj.start('foo2')
obj.end('foo') // 两次上报
obj.end('foo2') // 一次上报

然后约定好 start是开始时间 end是结束时间并上报。

特点:支持多次订阅时间,一次上报。想要多次不同时间上报,那么就订阅不同事件名即可

第二种:钩子机制,导出内部数据状态供外部调用者使用

约定好钩子的事件名,把订阅的权利释放到外部,内部通过"发布"将运行中的中间值或者状态传出去,最典型的例子其实就是vue生命周期

这里我就简单实现一个定时器的生命周期

function setTimeoutLifecycle () {
    // 上面的Event类
    const ev = new Event()
    function run () {
        // 上面的Event类
        ev.emit('start', { data: 'start' })
        setTimeout(() => {
            console.log('执行完成')
            ev.emit('end', { data: 'end' })
        }, 2000)
    }
    
    ev.run = run
    return ev
}

const h = setTimeoutLifecycle()

h.on('start', (res) => {
    console.log('准备开始了')
    cosole.log(res)
})

h.on('end', (res) => {
    console.log('呦,这就结束啦')
    cosole.log(res)
})

// 或者
// const obj = {
//     start (res) {
//         console.log('准备开始了')
//         cosole.log(res)
//     },
//     end (res) {
//         console.log('呦,这就结束啦')
//         cosole.log(res)
//     }
// }

// Object.keys(obj).forEach(key => h.on(key, obj[key]))

h.run()

// 准备开始了
// { data: 'start' }
// 加载完成
// 呦,这就结束啦
// { data: 'end' }

第三种:解决“雪崩”问题,就是高并发,高I/O下服务器“爆炸”

在数据查询的时候,服务器做I/O操作,一瞬间调用某一个接口重复查询相同数据,以下以读取文件模拟

function readFile (key, fn) {
  fs.readFile(path.resolve(__dirname + '/index.txt'), (err, data) => {
    if (!err) { fn(data, key ) }
  })
}

for (let i = 0; i < 1000; i++) {
  readFile(i, function (data, key) {
    console.log(data.toString())
    console.log(key)
  })
}

以上模拟了1000个用户来读取index.txt文件,要执行1000次I/O,但其实都是访问同一个文件而且数据是一样的,就很浪费,能不能第一个读取之后后面的用户共享第一个用户读取的数据?这样I/O只要一次就可以了

答案当然是可以的,"状态锁"配合我们的发布/订阅就能实现

let state = 'end' // 所谓状态锁,就是一个变量控制当前次I/O是否结束
let ev = new Event()
function readFile (key, fn) {
  ev.once('read',fn) // readFile I/O结束之前的用户都存起来
  if (state === 'end') {
    state = 'padding'
    fs.readFile(path.resolve(__dirname + '/index.txt'), (err, data) => {
      if (!err) {ev.emit('read', data, key)}
      state = 'end'
    })
  }
}

for (let i = 0; i < 1000; i++) {
  readFile(i, function (data, key) {
    console.log(data.toString())
    console.log(key)
  })
}

其中 once,只执行一次就注销掉。

//上面的Event加个方法

class Event {
    constructor () {
        this._list = {};
    }
    // 订阅
    on (name, fn) {
        if (!this._list[name]) {
            this._list[name] = [];
        }
        this._list[name].push(fn);
    }
    // 订阅一次
    once (name, fn) {
        fn._id = 'once';
        this.on(name, fn);
    }
    
    // 发布
    emit (name, ...args) {
        const item = this._list[name];
        if (!item) {
            return;
        }
        for (let i = 0; i < item.length; i += 1) {
            item[i](...args);
        }
        // 注销
        this._list[name] = this._list[name].filter(fn => !fn.id === 'once');
    }
}

上面两个可以运行试一下,key代表的是用户。这样就解决“雪崩”问题,其中下一次“状态锁”开启由当次的I/O所花费的时间决定。

说到上面的状态锁,扩展一下顺便简单模拟一下 promise实现

主要原理:then的调用中不同参数放入不同的列表,实际哪个列表执行取决于,Promise参数中的resolve或者reject函数执行,然后状态锁来控制当前的执行状态。

function Promise (fn) {
    this.state = 'pedding' // 控制当前执行状态
    this.value = ''
    this.resolveFN = []
    this.rejectFN = []
    
    // 执行对应列表
    function resolve (v) {
        if (this.state === 'pedding') {
            this.value = v
            this.resolveFN.forEach(fn => fn())
            this.state = 'end'
        }
    }
    
    function reject (v) {
        if (this.state === 'pedding') {
            this.value = v
            this.resolveFN.forEach(fn => fn())
            this.state = 'error'
        }
    }
    
    fn(resolve.bind(this), reject.bind(this))
    
}

// 调用then则不同参数放入对应列表
Promise.prototype.then = function (fn1, fn2) {
    if (this.state === 'pedding') {
        fn1 && this.resolveFN.push(() => fn1(this.value))
        fn2 && this.rejectFN.push(() => fn2(this.value))   
    }
}

具体模拟可以看我之前的这一篇:ES6-promise模拟实现

好了到这里就完结了,主要是我碰到的针对发布/订阅模式的三种应用场景介绍,如果有更多的应用场景欢迎留言,之中有错误也欢迎指出,谢谢!!!