設計模式之Plugin模式

早兩年所在公司有一個需求:針對視頻播放作一些視頻的播放事件上報,好比javascript

  • 視頻天然緩衝上報(因爲網絡形成的天然卡頓),一旦進入緩衝狀態則上報一次事件
  • 視頻拖拽緩衝上報
  • 心跳上報,在播放開始後的第15秒、第45秒、第60秒分別上報一次,而後穩定在每2分鐘上報一次。當播放器暫停時,中止計時與上報,繼續播放後接着計時與上報
  • 用戶主動拖拽進度條操做上報

從這個需求裏,咱們能夠引伸出一些關於設計的話題:java

該如何設計代碼以應對這種需求,以及未雨綢繆地應對產品下次相似需求?設計模式

剛從學校畢業一兩年的童鞋,最可能的作法應該是在播放頁直接取video的dom對象去監聽以上這一系列事件吧?很惋惜的是,這些事件video並未直接提供給咱們,而是須要咱們設置一些變量去統計。網絡

拿緩衝事件做比,video自己有一個timeupdate事件,只要視頻處於播放狀態,就會一直源源不斷的觸發這個事件。咱們能夠在每一次觸發時記錄一個當前時間戳,下次觸發時比較這個時間戳看是否超過1s(超過1s就是在緩衝)。此外,咱們還要區別是天然播放形成的緩衝事件仍是手動拖拽形成的緩衝事件,因此須要結合拖拽事件一塊兒分析並區分。架構

因此,這些較爲複雜的事件監聽代碼會強耦合到咱們的業務代碼裏。app

畢業兩三年的童鞋,應該會意識到這個問題,並將這一系列操做抽取出去造成一個獨立的模塊或類。dom

好比:ide

EventReport.registe(videoDom);
複製代碼

而後將這些監聽點的代碼放到EeventReport模塊裏,又或者更進一步再抽幾個模塊用於分離上報事件。從我這些年見過不一樣公司的業務代碼裏,基本上後者居多。函數

針對於完成需求,作到這些夠了嗎?ui

的確是夠了,哪怕產品再提一些相關需求,只要改改這些模塊,反正不會影響業務代碼。

但這不是本文須要拎出來講的重點,我要提的是設計模式

Plug-In模式

插件模式是一個應用很廣泛的軟件架構模式。經典例子如 Eclipse、jQuery、VS Code 等插件機制。

插件一般是對一個應用(host application,宿主應用)總體而言,經過插件的擴展爲一個應用添加新的功能或 features。一個插件至關於一個 component,插件內部能夠包含許多緊密相關的對象。

爲何要提到這個模式?由於我認爲上面的這一系列需求(以及產品腦洞大開的後續相關需求),都屬於能夠脫離業務代碼存在的獨立個體!

  • 首先,提供支持插件的"微內核"能夠單獨發佈成第三方庫,只要是針對videoDom元素均可以監聽(如下簡稱微內核
  • 其次,不一樣的事件有不一樣的上報插件,針對產品的需求能夠增長插件的類目
  • 最後,微內核能夠隨時卸載,插件也能夠隨時卸載。

實際上是一個很是簡單的微內核實現,沒有生命週期控制(只能註冊與卸載),代碼不過百來行,可是它能將代碼理得很是順暢,簡潔易讀易維護。

貼出微內核代碼以下:

var __EVENTS = ['play','timeupdate','ended','error','loadstart','loadedmetadata','playing','pause','seeking','seeked','waiting'];

  var VideoMonitor = function() {
    throw new TypeError('請使用monitor方法進行監測');
  };

  var seekingStart = 0;

  VideoMonitor.prototype = {
    constructor: VideoMonitor,
    init:function(videoDom, videoInfo){
      this.videoDom = videoDom;
      this.videoInfo = videoInfo;
      this.lastTimeupdate = 0;
      this.seekTime = -1;
      this.suuid = STK.$.getsUUID();
      this.firstBuffer = true;
      this.seekTimeout = null;
      this.bindContext();
      this.bindEvents();
    },
    destroy:function(){
      this.unbindEvents();
      setTimeout(()=>{
        this.videoDom = null;
        this.videoInfo = null;
      });
    },
    bind:function(fn, ctx) {
      return function (a) {
        var l = arguments.length;
        return l ? l > 1 ? fn.apply(ctx, arguments) : fn.call(ctx, a) : fn.call(ctx);
      };
    },
    bindContext:function(){
      this.onEventHandler = this.bind(this.onEventHandler,this);
    },
    bindEvents:function(){
      let playerDom = this.videoDom;
      for(var event in __EVENTS){
        playerDom.addEventListener(__EVENTS[event], this.onEventHandler, false);
      }
    },
    unbindEvents:function(){
      let playerDom = this.videoDom;
      for(var event in __EVENTS){
        playerDom.removeEventListener(__EVENTS[event], this.onEventHandler, false);
      }
    },
    onEventHandler:function(e){
      //觸發自身回調事件
      if(this[e.type] && typeof this[e.type] === 'function'){
        this[e.type].call(this,e);
      }
      //觸發外部註冊的句柄回調
      this.fireHandler(e);
    },
    fireHandler:function(e,data){
      for(var i = 0,len = handlerArray.length;i<len;i++){
        if(handlerArray[i][e.type] && typeof handlerArray[i][e.type] === 'function'){
          handlerArray[i][e.type](e,$.extend(this.videoInfo,data,{suuid:this.suuid}));
        }
      }
    },
    play:function(e){
      this.lastTimeupdate = +new Date();
      this.startHeartBeatCount();
    },
    playing(){
      this.lastTimeupdate = +new Date();
    },
    pause:function(){
      this.lastTimeupdate = +new Date();
      this.stopHeartBeatCount();
    },
    seeking(e){
      this.lastTimeupdate = +new Date();
      if (seekingStart == 0) {
        seekingStart = this.lastTimeupdate;
      }
      if (this.seekTime == -1 && e.target.currentTime != 0) {
        this.seekTime = e.target.currentTime;
      }
    },
    seeked(e){
      var self = this;
      var player = e.target;
      var td = 0;
      if (seekingStart > 0) {
        td = new Date().getTime() - seekingStart;
      }
      // 拖拽結束後上報drag時間
      this.lastTimeupdate = +new Date();
      if (player.currentTime != 0 && player.currentTime != this.videoInfo.info.duration && seekingStart > 0) {
        if (this.seekTimeout) {
            clearTimeout(this.seekTimeout);
            this.seekTimeout = null;
        }
        this.seekTimeout = setTimeout(
          e => {
              self.fireHandler({type:'drag',target:self.videoDom});
              this.seekTime = -1;
              seekingStart = 0; // 只有上報了才置0
          }, 
          1000
        );
      }   
    },
    timeupdate(e){
      var self = this;
      // 獲取兩次timeupdate事件間隔,用於卡頓判斷
      var now = +new Date();
      if (this.lastTimeupdate !== 0) {
        var d = now - this.lastTimeupdate;
        // 時間間隔超過1s,認爲是在緩衝中
        if (d >= 1000) {
          self.fireHandler({type:'buffer',target:self.videoDom},{firstBuffer:self.firstBuffer});
          self.firstBuffer = false;//第一次緩衝已經發生過了
        }
      }
      this.lastTimeupdate = now;
    },

    //收集觀看時長並每秒通知一次
    currentCount:0,
    timer:null,
    startHeartBeatCount:function(){
      var self = this;
      self.timer = setTimeout(function(){
        self.currentCount++;
        self.fireHandler({type:'count',target:self.videoDom},{count:self.currentCount});
        self.startHeartBeatCount();
      },1000);
    },
    stopHeartBeatCount:function(){
      clearTimeout(this.timer);
      this.timer = null;
    }
  };

  VideoMonitor.prototype.init.prototype = VideoMonitor.prototype;

  var MonitorArray = [], handlerArray = [];

  VideoMonitor.monitor = function(videoDom, videoInfo ) {
    var monitor = new VideoMonitor.prototype.init(videoDom,videoInfo);
    MonitorArray.push({
      dom:videoDom,
      instance:monitor
    });
    return monitor;
  };

  VideoMonitor.listen = function(handler) {
    handlerArray.push(handler);
  };

  VideoMonitor.destroy = function(videoDom) {
    var monitor = findInstance(videoDom);
    removeInstance(videoDom);
    monitor && monitor.destroy();
  };

  function findInstance(videoDom){
    for(var index in MonitorArray){
      if(MonitorArray[index].dom === videoDom)
        return MonitorArray[index].instance;
    }
    return null;
  }

  function removeInstance(videoDom){
    for(var index in MonitorArray){
      if(MonitorArray[index].dom === videoDom)
        MonitorArray.splice(index,1);
    }
  }
複製代碼

總結一下以上微內核代碼,總共有四方面內容:

  • 經過monitor 監控videoDom元素並保存實例引用
  • 經過listen 註冊不一樣的上報插件
  • 微內核會監聽videoDom的全部事件並轉發到插件裏
  • 微內核會分析videoDom的事件並整合出一些便於上報的合成事件,如
    • count 視頻播放計時器,每秒通知一次,暫停或拖拽時會暫停計時,便於外部handler進行視頻觀看時長的上報;
    • buffer 天然緩衝通知,因爲網絡問題形成的天然卡頓結束
    • drag 用戶拖拽通知,因爲用戶拖拽形成的卡頓結束

貼一個心跳的上報插件代碼

class HBStatHandler{
    ended(e,videoInfo){
      H._debug.log('HBStatHandler ended-----');
      var data = $.extend(base,{
        cf:videoInfo.info.clip_type,
        vts:videoInfo.info.duration,
        pay:videoInfo.info.paymark,
        ct:e.target.currentTime,
                suuid:videoInfo.suuid,
        idx:++idx,
        ht:2
      });
      stk.create(data,url);
    }

    count(e,videoInfo){
      var data = $.extend(base,{
        cf:videoInfo.info.clip_type,
        vts:videoInfo.info.duration,
        pay:videoInfo.info.paymark,
                suuid:videoInfo.suuid,
        ct:e.target.currentTime
      });
      //15秒上報
      if(videoInfo.count === 15){
        H._debug.log('HBStatHandler 15秒上報');
        data.idx = ++idx;
        data.ht = 3;
        stk.create(data,url);
        return;
      }
      //45秒上報
      if(videoInfo.count === 45){
        H._debug.log('HBStatHandler 45秒上報');
        data.idx = ++idx;
        data.ht = 4;
        stk.create(data,url);
        return;
      }
      //60秒上報
      if(videoInfo.count === 60){
        H._debug.log('HBStatHandler 60秒上報');
        data.idx = ++idx;
        data.ht = 5;
        stk.create(data,url);
        return;
      }
      //60秒後每2分鐘上報一次
      if(((videoInfo.count-60)/60)%2==0){
        H._debug.log('HBStatHandler 每2分鐘上報一次 videoInfo.count='+videoInfo.count);
        data.idx = ++idx;
        data.ht = 6;
        stk.create(data,url);
        return;
      }
    }
  }

複製代碼

微內核監聽videoDom

VideoMonitor.monitor(videoDom); // 第二個參數爲視頻附加屬性,上報時使用
複製代碼

微內核註冊插件

VideoMonitor.listen(new BufferStatHandler());
複製代碼

從心跳上報的插件代碼你們能夠很明顯看到,對於插件而言,只要實現相應的與videoDom事件同名的方法,好比play, timeupdate, ended, error, playing, pause, seeking等,就能夠被觸發到。除此以外,還有諸多合成事件,好比count, buffer, drag等,後期還能夠擴展更多合成事件。

微內核只負責整合、派發事件,上報相關的事情全權交由插件去解決,基本上百分百符合"開閉原則"。

業務代碼(播放視頻)——微內核(整合派發事件)——插件(上報不一樣事件),三方徹底解耦。

插件模式的使用雖然代碼量(註冊、監聽,派發)比強耦合稍多一些,但它簡潔明瞭、清晰易懂、輕鬆插撥、隨意擴展。

視頻播放的事件監聽上報並非一件很是難的事情,可是因爲需求很雜,很容易寫成一團亂麻。正所謂前人栽樹,後人乘涼,相比起不少被後人罵***的代碼,這段代碼應該會減輕後人維護的難度了。

PS: 代碼是2017年年初寫的,沒有使用class語法或TypeScript而是用的原型鏈。用了相似於jQuery裏的禁用構建函數處理方式。能夠寫得更簡潔一些的。

一句話總結:設計模式帶來的好處不少,可是須要根據不一樣的場景靈活判斷該使用何種模式,這也是不少人學完一遍設計模式以後卻發現一臉蒙逼所面對的問題。

相關文章
相關標籤/搜索