一次H5跨Webview通讯实战——WebviewBus的由来

2,265 阅读3分钟

前言

上次发表过《使用 window.postMessage 进行跨域实现数据交互的一次实战》之后,发现阅读量惨淡,或许大家对跨域数据交互的兴趣程度不高,又或许是我写的文章比较差。虽然文笔比较差,但还是会继续写的。

背景

这次的需求遇到的不是跨域通讯了,而是同域下的跨Webview通讯。之前跨域可以通过 window.postMessage 去解决,是因为当时的需求是通过 iframe 打开另一个页面,这时候可以获取到另一个页面窗口的引用,从而通过 postMessage 实现数据通讯。而这次,是两个 Webview 之间的数据交互,所以没办法拿到另一个页面的窗口对象引用,因此, postMessage 在这种场景下是不合适的。

但是,本次的场景两个 Webview 加载的都是同域下的页面。同域的页面可以共享的有什么呢?其实很容易想到的是通过 localStorage。本次需求也是通过 localStorage 去解决的,并且基于发布订阅的模式封装了一个跨 Webview 通讯的 EventEmitter,作为后续需求的技术储备,提升下次需求的开发效率。

框架先搭好:EventEmitter

一般来说,对于一个 EventEmitter 而言,会有$on$emit$un这几个方法,分别用于监听事件、触发事件、取消监听事件,然后内部维护一个 mapmap 中的每一个key都有一个数组维护当前事件的回调函数。

EventEmitter这个东西比较简单,相信很多人三两下就写完了。

class EventEmitter {
  constructor(key) {
    this.events = {};
  }

  $on(eventName, handler) {
    const events = this.events[eventName] || (this.events[eventName] = []);
    if (
      events.every(fn => fn !== handler)
    ) {
      events.push(handler);
    }
  }

  $emit(eventName, args = [], callself = true) {
    const events = this.events[eventName] || (this.events[eventName] = []);
    events.forEach(handler => handler.apply(null, args));
  }

  $un(eventName, handler) {
    if (!eventName) {
      this.events = {};
      return;
    }

    if (!handler) {
      this.events[eventName] = [];
      return;
    }

    const eventsArray = this.events[eventName];
    if (!Array.isArray(eventsArray)) {
      return;
    }

    let i = 0;
    while (i++ < eventsArray.length) {
      if (eventsArray[i] === handler) {
        break;
      }
    }

    if (i < eventsArray.length) {
      eventsArray.splice(i, 1);
    }
  }
}

这样一个 EventEmitter 使用起来是没什么问题的,但是我们现在需要的是把它跟 localStorage 结合起来,实现跨 Webview 的事件派发。

切入点: storage 事件

当同源页面中的其中一个页面修改了存储区域( localStorage 或 sessionStorage )的数据时,其他同源页面就会触发 storage 事件,而自身并不会触发这个事件

对于同源页面来讲,localStorage 是数据共享的,存储的数据可以相互访问。如果通过“轮询”的方式获取 localStorage 的值,那么也是一种两个同源页面实现通讯的一种手段。但是这不是很好的办法。而 storage 事件的触发可以在需要的时候告知其他页面进行相应的操作,比轮询的方式好太多了,并且兼容性也很好。

要想使用 storage 事件,那么必不可少的就是了解事件处理程序中的 event 对象究竟有什么东西,我们主要关注一下三个:

  1. key 设置或删除或修改的键,当调用clear时,keynull
  2. oldValue 对应key改变之前的旧值,如果是新增的话,就是null
  3. newValue 对应key改变之后的新值,如果是删除的话,就是null

因此,事件派发的逻辑就很清晰了:

  1. 监听 storage 事件的触发
  2. 依次执行对应事件的回调函数

$emit事件之前已经实现了,那么就来编写监听storage事件的方法。

_monitorStorageEvent() {
  this.storageEventHandler = event => {
    const { key, newValue } = event;
    const events = this.events[key] || (this.events[key] = []);
    events.forEach(handler => handler());
  };
  window.addEventListener('storage', this.storageEventHandler);
}

我们把key当做事件的名字,那么当 storage 对象的数据发生修改时,就可以知道什么事件触发了,然后再去一次执行对应的回调函数。

但是现在,我们没有通过A页面的$emit方法去派发事件,我们先添加一下:

$emit(eventName, args = [], callself = true) {
  if (callself) {
    const events = this.events[eventName] || (this.events[eventName] = []);
    events.forEach(handler => handler.apply(null, args));
  }

  localStorage.setItem(eventName, Date.now());
}

这时候我们是可以这样去使用的:

// B 页面监听 hello 事件
const bus = new EventEmitter();
bus.$on('hello', () => console.log('hello call'));

// A 页面 触发 hello 事件
const bus = new EventEmitter();
bus.$emit('hello');

// B 页面输出
hello call

现在,可以用了,但是却有几个问题需要解决一下:

  1. 命名冲突:同域之间的页面是共享 localStorage 的,我们直接通过 setItem(key, value) 修改的话,可能会不小心覆盖其他页面正在使用的有用的值。
  2. 事件没有过滤:不管是那个同域页面修改了 localStorage,都会被处理,其实我们根本就不关心除了事件以外的 storage 发生变化。
  3. 没法传参:我们调用setItem(key, value)的时候,value是一个当前时间的时间戳,没有地方传参数了。至于为什么是时间戳,后面会介绍。

完善:命名空间

增加命名空间,是为了解决 localStorage 中的 key 命名冲突以及解决事件没有过滤的问题。

处理方法很简单,只需要在$emit时自动加上命名空间前缀,在监听 storage 事件时根据命名空间过滤,然后除去命名空间前缀再遍历调用对应的事件处理程序即可。

const webviewBusContext = '@@WebviewBus_'; // 其实起什么都行,复杂点没那么容易冲突

// 监听时根据命名空间过滤
_monitorStorageEvent() {
  this.storageEventHandler = event => {
    const { key, newValue } = event;
    // 只处理以命名空间开头的 key
    if (key.startsWith(webviewBusContext)) {
      // 除去命名空间前缀
      const eventName = key.replace(webviewBusContext, '');
      const events = this.events[eventName] || (this.events[eventName] = []);
      events.forEach(handler => handler());
    }
  };
  window.addEventListener('storage', this.storageEventHandler);
}

// 派发时增加命名空间
$emit(eventName, args = [], callself = true) {
  if (callself) {
    const events = this.events[eventName] || (this.events[eventName] = []);
    events.forEach(handler => handler.apply(null, args));
  }
  const key = `${webviewBusContext}${eventName}`;
  localStorage.setItem(key, Date.now());
}

OK,这样子的话我们就可以在内部实现命名空间,避免了key的命名冲突,以及过滤掉那些我们不需要的key触发更新的事件。

继续完善:传参

之前在setItem(key, value)中,我们传入的value是一个当前时间的时间戳。如果我们需要传参,那么就需要占用这个参数,所以就不能直接传时间戳了,所以,一个很简单的传参方式就是:

$emit(eventName, args = [], callself = true) {
  if (callself) {
    const events = this.events[eventName] || (this.events[eventName] = []);
    events.forEach(handler => handler.apply(null, args));
  }
  const key = `${webviewBusContext}${eventName}`;
  localStorage.setItem(key, JSON.stringify(args));
}

_monitorStorageEvent() {
  this.storageEventHandler = event => {
    const { key, newValue } = event;
    if (key.startsWith(webviewBusContext)) {
      // 解析参数
      const args = JSON.parse(newValue);
      const eventName = key.replace(webviewBusContext, '');
      const events = this.events[eventName] || (this.events[eventName] = []);
      events.forEach(handler => handler.apply(null, args));
    }
  };
  window.addEventListener('storage', this.storageEventHandler);
}

这样看起来没什么问题,但是,如果我们这样搞的话:

// B 页面监听 hello 事件
const bus = new EventEmitter();
bus.$on('hello', () => console.log('hello call'));

// A 页面 触发 hello 事件
const bus = new EventEmitter();
bus.$emit('hello', [1, 2]);

// B 页面输出
hello call

// A 页面 再次触发 hello 事件
bus.$emit('hello', [1, 2]);

// B 页面输出
(无输出)

不知道你看出来了没有,理论上hello call是应该打印两次的,因为我们两次调用了$emit,但实际上没有。为什么呢?因为对于同一个key,storage 事件只在value发生变化时触发,也就是说触发的条件是newValue !== oldValue。之前的代码没有问题,是因为我们每次都传入一个时间戳,所以每次触发$emit事件,都是有效的。

所以,我们不能直接通过value传参,而是需要附加一个时间戳的标识,使得每次setItemvalue的值都不一样。

$emit(eventName, args = [], callself = true) {
  if (callself) {
    const events = this.events[eventName] || (this.events[eventName] = []);
    events.forEach(handler => handler.apply(null, args));
  }
  const key = `${webviewBusContext}${eventName}`;
  const value = JSON.stringify({
    // 增加时间戳
    timestamp: Date.now(),
    args
  });
  localStorage.setItem(key, value);
}

_monitorStorageEvent() {
  this.storageEventHandler = event => {
    const { key, newValue } = event;
    if (key.startsWith(webviewBusContext)) {
      // 解析参数
      let value;
      try {
        value = JSON.parse(newValue);
      } catch (error) {
        console.error('parse args error:', error);
        value = {};
      }
      const { args = [] } = value;
      const eventName = key.replace(webviewBusContext, '');
      const events = this.events[eventName] || (this.events[eventName] = []);
      events.forEach(handler => handler.apply(null, args));
    }
  };
  window.addEventListener('storage', this.storageEventHandler);
}

完成:WebviewBus

从基础框架、完善、继续完善,用于跨 Webview 的通讯机制终于完成了,我把他取名为 WebviewBus

const webviewBusContext = '@@WebviewBus_';

class WebviewBus {
  constructor(key) {
    this.events = {};

    this._monitorStorageEvent();
  }

  _monitorStorageEvent() {
    this.storageEventHandler = event => {
      const { key, newValue } = event;
      if (key.startsWith(webviewBusContext)) {
        this._handleEvents(key, newValue);
      }
    };
    window.addEventListener('storage', this.storageEventHandler);
  }

  _boardcast(eventName, args) {
    const key = `${webviewBusContext}${eventName}`;
    const value = JSON.stringify({
      timestamp: Date.now(),
      args
    });
    localStorage.setItem(key, value);
  }

  _handleEvents(eventName, value) {
    try {
      value = JSON.parse(value);
    } catch (error) {
      console.error('parse args error:', error);
      value = {};
    }
    const { args } = value;

    eventName = eventName.replace(webviewBusContext, '');

    const events = this.events[eventName] || (this.events[eventName] = []);
    events.forEach(handler => handler.apply(null, args));
  }

  $on(eventName, handler) {
    const events = this.events[eventName] || (this.events[eventName] = []);
    if (
      events.every(fn => fn !== handler)
    ) {
      events.push(handler);
    }
  }

  $emit(eventName, args = [], callself = true) {
    if (callself) {
      const events = this.events[eventName] || (this.events[eventName] = []);
      events.forEach(handler => handler.apply(null, args));
    }

    this._boardcast(eventName, args);
  }

  $un(eventName, handler) {
    if (!eventName) {
      this.events = {};
      return;
    }

    if (!handler) {
      this.events[eventName] = [];
      return;
    }

    const eventsArray = this.events[eventName];
    if (!Array.isArray(eventsArray)) {
      return;
    }

    let i = 0;
    while (i++ < eventsArray.length) {
      if (eventsArray[i] === handler) {
        break;
      }
    }

    if (i < eventsArray.length) {
      eventsArray.splice(i, 1);
    }
  }

  $destroy() {
    window.removeEventListener('storage', this.storageEventHandler);
  }
}

总结

之前工作中的需求,涉及了 iframe 的跨域通讯,使用了 postMessage + 监听 message 事件的方式;而这次的业务需求,则涉及了同域的跨页通讯,使用了 localStorage + storage event 的方式解决。

也总算是从工作中实践到一些不一样的东西:

  1. postMessage 虽好,但需要获取页面的 window 引用,适用于一些 iframe 和主页面的通讯,同域和跨域皆可。
  2. localStorage + storage event 不需要获取页面 window 的引用,但需要同域,适用于同域的不同页面之间的通讯。