如何在微信小程序裏實現跨頁面通信?

作者 | Danny 動靜科技軟件總監

我們在處理業務需求的時候,常常會遇到一些情況:

在二級或者三級頁面進行某些操作或者變更後,需要將結果通知到上級頁面去。比如:

  • 選擇了某些配置項,點擊保存後,外部頁面能夠立即變更
  • 在頭像上傳頁面,上傳完畢後,外部頁面的頭像能夠立即顯示爲新頭像

這個時候就涉及到如何在頁面之間通信的問題了。跨頁面通信,其實就是一個程序內部的事件通知機制問題,在其他平臺或者 OS 上都一些相應的實現,比如:

  • iOS SDK 自帶的 NotificationCenter
  • Android 平臺著名的第三方庫 EventBus

目前,微信小程序官方 SDK 還沒有提供 Event API 來幫助開發者實現頁面間通信。所以,今天知曉程序邀請到「雲夢聲音嚮導」小程序的開發者 Danny,教大家如何實現這樣一個簡單的小工具。

想和本文作者直接交流?現在就加入知曉程序的開發交流羣吧。關注微信號 zxcx0101,點擊菜單欄的「加羣交流」即可。

Quick and Dirty

我們知道,在小程序裏面一個頁面的變化,是通過調用 setData 函數來實現的。

所以,想做到在二級頁面裏讓一級頁面產生變化,最 quick 也最 dirty 的做法就是,把一級頁面的 this 傳入到二級頁面去。這樣,我們在二級頁面調用 page1.setData(…) 就可以立即引發外部的變化。

但是這並不是一個好的方案,不僅產生了頁面的耦合,而且也並不能處理複雜的數據邏輯。因爲,二級頁面不併清楚也不應該關心一級頁面想怎麼處理當前數據。

所以,二級頁面只應該把變更後的數據通知給一級頁面即可,至於一級頁面是想刷新界面,還是想本地存儲或者發起網絡通信,都與其他頁面沒有關係了。

簡單的 Callback

如果只是想把數據通知給外部頁面,那應該怎麼做呢?現在,我們來考慮另一個方案。

如果需要產生一個通知,就需要用到 callback 機制。它需要做到:

  • 瞭解數據變化的頁面,註冊一個 callback 函數到一個公共的地方(所有頁面可以訪問的地方)
  • 而數據變更者在變更數據後,將新的數據放入同一個公共的地方
  • 在放入數據時,同時調用這個 callback 函數,讓 callback 函數實現者接收到這個變化

那這個「公共的地方」在哪裏呢? 第一反應就是全局邏輯頁面 app.js 裏面。

小程序提供了一個叫做 getApp() 的 API,它可以在 page 初始化時,通過 var app = getApp() 來獲取 app 實例,從而實現全局的數據共享。

而且,微信也很貼心的在新建項目之後的 quick start 代碼裏,留了一個 globalData 字段,暗示開發者這裏是可以用來存儲全局數據的。

我們首先對全局邏輯代碼 app.js 動刀子,增加 addListener 方法。

//app.js
App({
  addListener: function (callback) {
    this.callback = callback;
  },
  setChangedData: function (data) {
    this.data = data;
    if (this.callback != null) {
      this.callback(data);
    }
  }
})

然後,我們在其他頁面合適的位置調用 addListener

//page1.js
var app = getApp()
Page({
  onLoad: function () {
    app.addListener(function (changedData) {
      that.setData({
        data: changedData
      });
    });
  }
})

//page2.js
var app = getApp()
Page({
  onBtnPress: function () {
    app.setChangedData('page2-data');
  }
})

一個基本合格的方案

以上就是跨頁面通信的最基本原理,但這也是一個很 dirty 的方案。

因爲,上面的代碼只能支持一種 Event 的通知,而且也不能針對這個 Event 添加多個監聽者(比如有多個頁面需要同時知道某數據變更)。

一個基本合格的 Event 管理器應該具備怎樣的能力?

  • 支持多種 Event 的通知
  • 支持對某一 Event 可以添加多個監聽者
  • 支持對某一 Event 可以移除某一監聽者
  • 將 Event 的存儲和管理放在一個單獨模塊中,可以被所有文件全局引用

根據以上的描述,我們來設計一個新的 Event 模塊,它應該具有如下三個函數:

  • on 函數:用來向管理器中添加一個 Event 的 Callback,且每一個 Event 必須有全局唯一的 EventName,函數內部通過一個數組來保存同一 Event 的多個 Callback
  • remove 函數:用來向管理器移除一個 Event 的 Callback
  • emit 函數:用來觸發一個 Event

我們現在就來動手構建這樣一個新的模塊。

首先,在小程序的 utils 目錄中,新建一個 event.js 文件。

//event.js
var events = {};

function on(name, callback) {
  var callbacks = events[name];
  addToCallbacks(callbacks, callback);
}

function remove(name, callback) {
  var callbacks = events[name];
  removeFromCallbacks(callbacks, callback);
}

function emit(name, data) {
  var callbacks = events[name];
  emitToEveryCallback(callbacks, data);
}
exports.on = on;
exports.remove = remove;
exports.emit = emit;

之後在頁面中,我們就可以使用這個 Event 模塊。

首先,我們在二級頁面中觸發事件。

//page2.js
var event = require('../../utils/event.js');
Page({
  onBtnPress: function () {
    event.emit('DataChanged', 'page2-data');
  }
});

//然後,我們在一級頁面中在 onLoad() 中監聽事件、在 onUnload() 中取消監聽:
//page1.js
var event = require('../../utils/event.js');
Page({
  onLoad: function () {
    var that = this;
    event.on('DataChanged', function (changedData) {
      that.setData({
        data: changedData
      });
    });
  }, 
  onUnload: function () {
    event.remove('DataChanged', ...);
  }
});

咦,似乎哪裏不對?

remove 需要接受兩個參數,第一個是 EventName,第二個是 Callback,但是我們的 Callback 以匿名函數的方式寫在了 event.on(...) 的調用語句裏面。

好吧,那我們不得不修改一下語句的調用方式:

//page1.js
var event = require('../../utils/event.js');
Page({
  onDataChanged: function (changedData) {
    this.setData({
      data: changedData
    })
  }, 
  onLoad: function () {
    event.on('DataChanged', this.onDataChanged);
  },
  onUnload: function () {
    event.remove('DataChanged', this.onDataChanged);
  }
});

這樣就 OK 了麼?NO NO NO NO!

熟悉 Javascript this 這個大坑的朋友們一定會知道,在 onDataChanged() 這個函數中調用的 this,並不是我們 Page 中的那個 this,所以根本不可能調用到 this.setData(....),於是我們用 bind 大法稍微調整一下:

onLoad: function () {
  event.on('DataChanged', this.onDataChanged.bind(this));
}
onUnload: function () {
  event.remove('DataChanged', this.onDataChanged.bind(this));
}

現在 OK 了麼?NO NO NO NO!如果大夥敲代碼試試,就會發現依然還是不行!

因爲 this.onDataChanged.bind(this) 會產生一個新的匿名函數,即 bind 的返回值是一個函數。

那麼,在 onLoad 和 onUnload 裏面,各自調用了 bind 大法,從而產生了各自的匿名函數。

也就是說 event.remove(...) 塞進去的那個函數,並不是 event.on(...) 塞進去的那個函數。這樣就造成了 remove 時無法正確匹配。

removeFromCallbacks 的僞代碼大致如下:

function removeFromCallbacks(callbacks, callback) {
  var newCallbacks = [];
  for (var item in callbacks) {
    if (item != callback) {
      newCallbacks.push(item);
    }
  }
  return newCallbacks;
}

我們會發現 remove 傳入的 callback,永遠無法在 callbacks 數組中被匹配到,從而也就無法正確移除了。

最終的代碼實現

當 EventName + Callback 無法唯一決定需要移除的監聽者時,那麼自然想到的就是再增加一個 key 值,我們可以用 Page 自身的某個特性來做 key,比如 pageName ,新的 remove 原型如下:

function remove(eventName, pageName, callback);

pageName 是一個字符串,如果開發者不能做到全局內 page name 唯一的話(比如開發者一不小心寫錯了),那就可能會出現後來監聽者沖掉前面監聽者的情況,從而造成無法收到通知的 bug。

所以,這裏看起來還是用 page 的 this 做 key 比較靠譜,修改後的函數原型如下:

function on(name, self, callback);
function remove(name, self, callback);
//讓我們來看看內部具體怎麼實現。以下是一個完整的 on 函數實現:

function on(name, self, callback) {
  var tuple = [self, callback];
  var callbacks = events[name];
  if (Array.isArray(callbacks)) {
    callbacks.push(tuple);
  }
  else {
    events[name] = [tuple];
  }
}

第二行我們將 self(即 page 的 this)和 callback 合併成一個 tuple;第三行從 events 容器中,取出該 EventName 下的監聽者數組 callbacks。

如果該數組存在,則將 tuple 加入數組;如果不存在,則新建一個數組。

remove 的完整實現:

function remove(name, self) {
  var callbacks = events[name];
  if (Array.isArray(callbacks)) {
    events[name] = callbacks.filter((tuple) = & gt; {
      return tuple[0] != self;
    });
  }
}

第二行從 events 容器中,取出該 EventName 下的監聽者數組 callbacks。

如果 callbacks 不存在,則直接返回;如果存在,則調用 callbacks.filter(fn) 方法。

filter 方法的含義是通過 fn 來決定是否過濾掉 callbacks 中的每一個項。fn 返回 true 則保留,fn 返回 false 則過濾掉。

所以,我們調用 callbacks.filter(fn) 後,callbacks 中的每一個 tuple 都會被依次判定。

fn 的定義爲:

(tuple) = & gt; {
  return tuple[0] != self;
}

tuple 中的第一個元素 self 和 remove 傳入的 self 相比較。

如果不相等則返回 true 被保留,如果相等則返回 false 被過濾掉。

callbacks.filter(fn) 會返回一個新的數組,然後重新寫入 events[name],最終達到移除 callbacks 中某一項的邏輯。

最後再來看看 emit 的實現:

function emit(name, data) {
  var callbacks = events[name];
  if (Array.isArray(callbacks)) {
    callbacks.map((tuple) = & gt; {
      var self = tuple[0];
      var callback = tuple[1];
      callback.call(self, data);
    });
  }
}

第二行從 events 容器中,取出該 EventName 下的監聽者數組 callbacks。

如果 callbacks 不存在,則直接返回;如果存在,則調用 callbacks.map(fn) 方法。

和 filter 的用法類似,map 函數的作用相當於 for 循環,依次取出 callbacks 中的每一個項,然後對其執行 fn(tuple)。

從其名字就可以看出 map 就是映射變換的意思,將 item 變換爲另外一種東西,這個映射關係就是 fn

fn 的定義爲:

 

(tuple) = & gt; {
  var self = tuple[0];
  var callback = tuple[1];
  callback.call(self, data);
}

對傳入的 tuple,分別取出 self 和 callback,然後調用 Javascript 的 call 大法:

fn.call(this, args)

從而最終實現調用到監聽者的目的。

講到這裏就基本上差不多了,因爲 Event 模塊持有了 Page 的 this,所以一定要在 Page 的 Unload 函數中調用 event.remove(…),不然會造成內存泄露。

文章來源:https://github.com/danneyyang/weapp-event/blob/master/README.md

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章