preface

Last time, I published “A Real Combat of Using Window. postMessage to Realize cross-domain Data Interaction”, but I found that the reading volume was poor. Maybe people were not interested in cross-domain data interaction, or maybe the article I wrote was poor. Although the writing is poor, but will continue to write.

background

Instead of cross-domain communication, the requirement is cross-webView communication within the same domain. PostMessage can be used to solve the problem. At that time, the requirement was to open another page through iframe, and then the reference of another page window could be obtained, so as to realize data communication through postMessage. In this case, there is data interaction between two WebViews, so there is no way to get a reference to another page’s window object, so postMessage is not appropriate in this scenario.

However, in this scenario, both WebViews load pages in the same domain. What can pages in a domain share? The easy way to think about it is through localStorage. This demand was also solved through localStorage, and EventEmitter, which communicated across Webview, was encapsulated in the publishing-subscription mode as the technical reserve of subsequent demand to improve the development efficiency of the next demand.

Frame first: EventEmitter

For an EventEmitter, there are methods $on, $emit, and $UN, which are used to listen for events, trigger events, and cancel listening events, respectively, and maintain a map internally. Each key in the map has an array of callback functions for the current event.

EventEmitter is a simple thing, and I’m sure many of you can write it in a couple of clicks.

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); }}}Copy the code

Such an EventEmitter is fine to use, but what we need now is to combine it with localStorage for event dispatch across webViews.

Pointcut: Storage event

When one of the same-origin pages modifies data in a storage area (localStorage or sessionStorage), the other same-origin pages will trigger a storage event, but not themselves.

For same-origin pages, localStorage is data-sharing, storing data that can be accessed from one another. If the value of localStorage is obtained through “polling”, it is also a means of communication between two homologous pages. But that’s not a good idea. The triggering of storage events can inform other pages to carry out the corresponding operations when needed, much better than polling, and compatibility is also very good.

In order to use storage events, it is essential to understand what the event object in the event handler is. We will focus on three things:

  1. keySet or delete or modify keys when calledclearWhen,keyfornull.
  2. oldValueThe correspondingkeyChanging the old value, if it’s new, isnull.
  3. newValueThe correspondingkeyThe new value after the change, if you delete it, isnull.

Thus, the logic of event dispatch is clear:

  1. Listen for the triggering of storage events
  2. Execute the corresponding event callback function in turn

The $emit event has already been implemented, so write methods that listen for storage events.

_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);
}
Copy the code

We use key as the name of the event, so that when the storage object’s data is modified, we can know what event is triggered, and then execute the corresponding callback again.

Instead of sending events via the $emit method on page A, let’s add:

$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());
}
Copy the code

We can use it like this:

// the B page listens for hello events
const bus = new EventEmitter();
bus.$on('hello'.() = > console.log('hello call'));

// Page A triggers the Hello event
const bus = new EventEmitter();
bus.$emit('hello');

// B page output
hello call
Copy the code

Now, it’s ready to use, but there are a few problems that need to be solved:

  1. Naming conflicts: Pages between domains share localStorage and we pass directlysetItem(key, value)You might accidentally overwrite useful values that other pages are using.
  2. Events are not filtered: regardless of whether the local page changes localStorage, it will be handled. In fact, we don’t care about storage changes other than events at all.
  3. Unable to pass parameters: we callsetItem(key, value)The time,valueIs a timestamp of the current time, there is no place to pass the parameters. As for why it is a timestamp, I will explain it later.

Improved: namespace

The namespace is added to resolve key naming conflicts in localStorage and the problem that events are not filtered.

$emit = $emit = $emit = $emit = $emit = $emit = $emit = $emit = $emit = $emit

const webviewBusContext = '@@WebviewBus_'; // It can be anything

// filter by namespace when listening
_monitorStorageEvent() {
  this.storageEventHandler = event= > {
    const { key, newValue } = event;
    // Only handle keys that start with a namespace
    if (key.startsWith(webviewBusContext)) {
      // Remove the namespace prefix
      const eventName = key.replace(webviewBusContext, ' ');
      const events = this.events[eventName] || (this.events[eventName] = []);
      events.forEach(handler= >handler()); }};window.addEventListener('storage'.this.storageEventHandler);
}

// Add namespace for distribution
$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());
}
Copy the code

OK, this way we can implement namespaces internally, avoid key naming conflicts, and filter out events that trigger updates for keys that we don’t need.

Continue to improve: pass the reference

Previously in setItem(key, value), the value we passed was a timestamp of the current time. If we need to pass the parameter, we need to use this parameter, so we can’t pass the timestamp directly, so a very simple way to pass the parameter is:

$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)) {
      // Parse the parameters
      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);
}
Copy the code

This looks fine, but if we do it this way:

// the B page listens for hello events
const bus = new EventEmitter();
bus.$on('hello'.() = > console.log('hello call'));

// Page A triggers the Hello event
const bus = new EventEmitter();
bus.$emit('hello'[1.2]);

// B page output
hello call

// The A page triggers the Hello event again
bus.$emit('hello'[1.2]);

// B page output(No output)Copy the code

If you can see this, theoretically the Hello call should print twice, because we called $emit twice, but it didn’t. Why is that? A storage event is triggered only when the value of a key changes. = = oldValue. The previous code was fine because we passed a timestamp each time, so every time the $emit event was triggered, it was valid.

Therefore, instead of passing the parameter directly through value, we need to attach a timestamp to the parameter so that the value is different each time we setItem.

$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({
    // Add a timestamp
    timestamp: Date.now(),
    args
  });
  localStorage.setItem(key, value);
}

_monitorStorageEvent() {
  this.storageEventHandler = event= > {
    const { key, newValue } = event;
    if (key.startsWith(webviewBusContext)) {
      // Parse the parameters
      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);
}
Copy the code

Complete: WebviewBus

From the basic framework, refinement and refinement, the communication mechanism for cross-WebView is finally complete, which I call the 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); }}Copy the code

conclusion

The requirements in the previous work involved the cross-domain communication of iframe, using the way of postMessage + listening for Message events; The business requirement of this time involves cross-page communication in the same domain, which is solved by using localStorage + Storage Event.

Also finally from the work practice to some different things:

  1. PostMessage is good, but requires a reference to a page’s window, which is useful for some iframes and home page communication, both in and across domains.
  2. LocalStorage + Storage Event does not need to get the reference of page Window, but needs to be in the same domain, suitable for communication between different pages in the same domain.