postMessage 还能这样玩

在日常工作中,消息通信是一个很常见的场景。比如大家熟悉 B/S 结构,在该结构下,浏览器与服务器之间是基于 HTTP 协议进行消息通信:

然而除了 HTTP 协议之外,在一些对数据实时性要求较高的场景下,我们会使用 WebSocket 协议来完成消息通信:

对于这两种场景,相信大家都不会陌生。接下来,阿宝哥将介绍消息通信的另外一种场景,即父页面与 iframe 加载的子页面之间,如何进行消息通信。

为什么会突然写这个话题呢?其实是因为在近期项目中,阿宝哥需要实现父页面与 iframe 加载的子页面之间的消息通信。另外,刚好近期阿宝哥在写 源码分析 专题,所以就到 Github 上搜索 ???? 了一番,然后找到了一个不错的项目 ——  Postmate。

在阅读完 Postmate 源码之后,阿宝哥觉得该项目的一些设计思想挺值得借鉴的,所以就写了这篇文章来跟大家分享一下。阅读完本文之后,你将学到以下知识:

  • 消息系统中握手的作用及如何实现握手;

  • 消息模型的设计及如何实现消息验证来保证通信安全;

  • postMessage 的使用及如何利用它实现父子页面的消息通信;

  • 消息通信 API 的设计与实现。

好的,废话不多说,我们先来简单介绍一下 Postmate。

一、Postmate 简介

Postmate 是一个强大,简单,基于 Promise 的 postMessage 库。它允许父页面以最小的成本与跨域的子 iframe 进行通信。该库拥有以下特性:

  • 基于 Promise 的 API,可实现优雅而简单的通信;

  • 使用 消息验证 来保护双向 父 <-> 子 消息通信的安全;

  • 子对象公开父对象可以访问的可检索的模型对象;

  • 子对象可派发父对象已监听的事件;

  • 父对象可以调用子对象中的函数;

  • 零依赖。如果需要可以为 Promise API 提供自定义 polyfill 或抽象;

  • 轻量,大小约 1.6 KB(minified & gzipped)。

接下来阿宝哥将从如何进行握手、如何实现双向消息通信和如何断开连接,这三个方面来分析一下 Postmate 这个库。另外,在此期间还会穿插介绍 Postmate 项目中一些好的设计思路。

二、如何进行握手

TCP 建立连接的时候,需要进行三次握手。同样,当父页面与子页面通信的时候,Postmate 也是通过 “握手” 来确保双方能正常通信。因为 Postmate 通信的基础是基于 postMessage,所以在介绍如何握手之前,我们先来简单了解一下 postMessage API。

2.1 postMessage 简介

对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议、端口号以及主机时,这两个脚本才能相互通信。window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

2.1.1 postMessage() 语法
otherWindow.postMessage(message, targetOrigin, [transfer]);
  • otherWindow:其他窗口的一个引用,比如 iframe 的 contentWindow 属性、执行 window.open 返回的窗口对象等。

  • message:将要发送到其他 window 的数据,它将会被结构化克隆算法序列化。

  • targetOrigin:通过窗口的 origin 属性来指定哪些窗口能接收到消息事件,其值可以是字符串 "*"(表示无限制)或者一个 URI。

  • transfer(可选):是一串和 message 同时传递的 Transferable 对象。这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

发送方通过 postMessage API 来发送消息,而接收方可以通过监听 message 事件,来添加消息处理回调函数,具体使用方式如下:

window.addEventListener("message", receiveMessage, false);

function receiveMessage(event) {
  let origin = event.origin || event.originalEvent.origin; 
  if (origin !== "http://semlinker.com") return;
}
2.2 Postmate 握手的实现

在电信和微处理器系统中,术语握手(Handshake,亦称为交握)具有以下含义:

  • 在数据通信中,由硬件或软件管理的事件序列,在进行信息交换之前,需要对操作模式的状态互相达成协定。

  • 在接收站和发送站之间建立通信参数的过程。

对于通信系统来说,握手是在通信电路建立之后,信息传输开始之前。握手用于达成参数,如信息传输率,字母表,奇偶校验, 中断过程,和其他协议特性

而对于 Postmate 这个库来说,握手是为了确保父页面与 iframe 子页面之间可以正常的通信,对应的握手流程如下所示:

在 Postmate 中,握手消息是由父页面发起的,在父页面中要发起握手信息,首先需要创建 Postmate 对象:

const postmate = new Postmate({
  container: document.getElementById('some-div'), // iframe的容器
  url: 'http://child.com/page.html', // 包含postmate.js的iframe子页面地址
  name: 'my-iframe-name' // 用于设置iframe元素的name属性
});

在以上代码中,我们通过调用 Postmate 构造函数来创建 postmate 对象,在 Postmate 构造函数内部含有两个主要步骤:设置 Postmate 对象的内部属性和发送握手消息:

以上流程图对应的代码相对比较简单,这里阿宝哥就不贴详细的代码了。感兴趣的小伙伴可以阅读 src/postmate.js 文件中的相关内容。为了能够响应父页面的握手信息,我们需要在子页面中创建一个 Model 对象:

const model = new Postmate.Model({
  // Expose your model to the Parent. Property values may be functions, promises, or regular values
  height: () => document.height || document.body.offsetHeight
});

其中 Postmate.Model 构造函数的定义如下:

// src/postmate.js
Postmate.Model = class Model {
  constructor(model) {
    this.child = window;
    this.model = model;
    this.parent = this.child.parent;
    return this.sendHandshakeReply();
  }
}

在 Model 构造函数中,我们可以很清楚地看到调用 sendHandshakeReply 这个方法,这里我们只看核心的代码:

现在我们来总结一下父页面和子页面之间的握手流程:当子页面加载完成后,父页面会通过 postMessage API 向子页面发送 handshake 握手消息。在子页面接收到 handshake 握手消息之后,同样也会使用 postMessage API 往父页面回复 handshake-reply 消息。

另外,需要注意的是,为了保证子页面能收到 handshake 握手消息,在 sendHandshake 方法内部会启动一个定时器来执行发送操作:

// src/postmate.js
class Postmate {
  sendHandshake(url) {
    return new Postmate.Promise((resolve, reject) => {
      const loaded = () => {
        doSend();
        responseInterval = setInterval(doSend, 500);
      };

      if (this.frame.attachEvent) {
        this.frame.attachEvent("onload", loaded);
      } else {
        this.frame.addEventListener("load", loaded);
      }
      
      this.frame.src = url;
    });
  }
}

当然为了避免发送过多无效的握手信息,在 doSend 方法内部会限制最大的握手次数:

const doSend = () => {
  attempt++;
  this.child.postMessage(
    {
      postmate: "handshake",
      type: messageType,
      model: this.model,
    },
    childOrigin
  );
  // const maxHandshakeRequests = 5;
  if (attempt === maxHandshakeRequests) {
     clearInterval(responseInterval);
  }
};

在主应用和子应用双方完成握手之后,就可以进行双向消息通信了,下面我们来了解一下如何实现双向消息通信。

三、如何实现双向消息通信

在调用 PostmatePostmate.Model 构造函数之后,会返回一个 Promise 对象。而当 Promise 对象的状态从 pending 变为 resolved 之后,就会分别返回 ParentAPIChildAPI 对象:

Postmate

// src/postmate.js
class Postmate {
  constructor({
    container = typeof container !== "undefined" ? container : document.body,
    model, url, name, classListArray = [],
  }) {
    // 省略设置 Postmate 对象的内部属性
    return this.sendHandshake(url);
  }
  
  sendHandshake(url) {
    // 省略部分代码
    return new Postmate.Promise((resolve, reject) => {
      const reply = (e) => {
        if (!sanitize(e, childOrigin)) return false;
        if (e.data.postmate === "handshake-reply") {
          return resolve(new ParentAPI(this));
        }
        return reject("Failed handshake");
      };
    });
  }
}

ParentAPI

class ParentAPI{
  +get(property: any) // 获取子页面中Model对象上的property属性上的值
  +call(property: any, data: any) // 调用子页面中Model对象上的方法
  +on(eventName: any, callback: any) // 监听子页面派发的事件
  +destroy() // 移除事件监听并删除iframe
}

Postmate.Model

// src/postmate.js
Postmate.Model = class Model {
  constructor(model) {
    this.child = window;
    this.model = model;
    this.parent = this.child.parent;
    return this.sendHandshakeReply();
  }

  sendHandshakeReply() {
    // 省略部分代码
    return new Postmate.Promise((resolve, reject) => {
      const shake = (e) => {
        if (e.data.postmate === "handshake") {
          this.child.removeEventListener("message", shake, false);
          return resolve(new ChildAPI(this));
        }
        return reject("Handshake Reply Failed");
      };
      this.child.addEventListener("message", shake, false);
    });
  }
};

ChildAPI

class ChildAPI{
  +emit(name: any, data: any)
}
3.1 子页面 -> 父页面
3.1.1 子页面发送消息
const model = new Postmate.Model({
  // Expose your model to the Parent. Property values may be functions, promises, or regular values
  height: () => document.height || document.body.offsetHeight
});

model.then(childAPI => {
  childAPI.emit('some-event', 'Hello, World!');
});

在以上代码中,子页面可以通过 ChildAPI 对象提供的 emit 方法来发送消息,该方法的定义如下:

export class ChildAPI {
  emit(name, data) {
    this.parent.postMessage(
      {
        postmate: "emit",
        type: messageType,
        value: {
          name,
          data,
        },
      },
      this.parentOrigin
    );
  }
}
3.1.2 父页面监听消息
const postmate = new Postmate({
  container: document.getElementById('some-div'), // iframe的容器
  url: 'http://child.com/page.html', // 包含postmate.js的iframe子页面地址
  name: 'my-iframe-name' // 用于设置iframe元素的name属性
});

postmate.then(parentAPI => {
  parentAPI.on('some-event', data => console.log(data)); // Logs "Hello, World!"
});

在以上代码中,父页面可以通过 ParentAPI 对象提供的 on 方法来注册事件处理器,该方法的定义如下:

export class ParentAPI {
  constructor(info) {
    this.parent = info.parent;
    this.frame = info.frame;
    this.child = info.child;

    this.events = {};

    this.listener = (e) => {
      if (!sanitize(e, this.childOrigin)) return false;
   // 省略部分代码
      if (e.data.postmate === "emit") {
        if (name in this.events) {
          this.events[name].forEach((callback) => {
            callback.call(this, data);
          });
        }
      }
    };

    this.parent.addEventListener("message", this.listener, false);
  }

  on(eventName, callback) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(callback);
  }
}
3.2 消息验证

为了保证通信的安全,在消息处理时,Postmate 会对消息进行验证,对应的验证逻辑被封装到 sanitize 方法中:

const sanitize = (message, allowedOrigin) => {
  if (typeof allowedOrigin === "string" && message.origin !== allowedOrigin)
    return false;
  if (!message.data) return false;
  if (typeof message.data === "object" && !("postmate" in message.data))
    return false;
  if (message.data.type !== messageType) return false;
  if (!messageTypes[message.data.postmate]) return false;
  return true;
};

对应的验证规则如下:

  • 验证消息的来源是否合法;

  • 验证是否含有消息体;

  • 验证消息体中是否含有 postmate 属性;

  • 验证消息的类型是否为 "application/x-postmate-v1+json"

  • 验证消息体中的 postmate 对应的消息类型是否合法;

以下是 Postmate 支持的消息类型:

const messageTypes = {
  handshake: 1, 
  "handshake-reply": 1, 
  call: 1,
  emit: 1, 
  reply: 1, 
  request: 1,
};

其实要实现消息验证的提前,我们还需要定义标准的消息体模型:

{
   postmate: "emit", // 必填:"request" | "call" 等等
   type: messageType, // 必填:"application/x-postmate-v1+json"
   // 自定义属性
}

了解完子页面如何与父页面进行通信及如何进行消息验证之后,下面我们来看一下父页面如何与子页面进行消息通信。

3.3 父页面 -> 子页面
3.3.1 调用子页面模型对象上的方法

在页面中,通过 ParentAPI 对象提供的 call 方法,我们就可以调用子页面模型对象上的方法:

export class ParentAPI {
 call(property, data) {
    this.child.postMessage(
      {
        postmate: "call",
        type: messageType,
        property,
        data,
      },
      this.childOrigin
    );
  }
}

ChildAPI 对象中,会对 call 消息类型进行对应的处理,相应的处理逻辑如下所示:

export class ChildAPI {
  constructor(info) {
  // 省略部分代码
    this.child.addEventListener("message", (e) => {
      if (!sanitize(e, this.parentOrigin)) return;
      const { property, uid, data } = e.data;
      
      // 响应父页面发送的call消息类型,用于调用Model对象上的对应方法
      if (e.data.postmate === "call") {
        if (
          property in this.model &&
          typeof this.model[property] === "function"
        ) {
          this.model[property](data);
        }
        return;
      }
    });
  }
}

通过以上代码我们可知,call 消息只能用来调用子页面 Model 对象上的方法并不能获取方法调用的返回值。然而在一些场景下,我们是需要获取方法调用的返回值,接下来我们来看一下 ParentAPI 是如何实现这个功能。

3.3.2 调用子页面模型对象上的方法并获取返回值

若需要获取调用后的返回值,我们需要调用 ParentAPI 对象上提供的 get 方法:

export class ParentAPI {
 get(property) {
    return new Postmate.Promise((resolve) => {
      // 从响应中获取数据并移除监听
      const uid = generateNewMessageId();
      const transact = (e) => {
        if (e.data.uid === uid && e.data.postmate === "reply") {
          this.parent.removeEventListener("message", transact, false);
          resolve(e.data.value);
        }
      };
      
      // 监听来自子页面的响应消息
      this.parent.addEventListener("message", transact, false);

      // 向子页面发送请求
      this.child.postMessage(
        {
          postmate: "request",
          type: messageType,
          property,
          uid,
        },
        this.childOrigin
      );
    });
  }
}

对于父页面发送的 request 消息,在子页面中会通过 resolveValue 方法来获取返回结果,然后通过 postMessage 来返回结果:

// src/postmate.js
export class ChildAPI {
  constructor(info) {
    this.child.addEventListener("message", (e) => {
      if (!sanitize(e, this.parentOrigin)) return;
      const { property, uid, data } = e.data;
      
      // 响应父页面发送的request消息
      resolveValue(this.model, property).then((value) =>
        e.source.postMessage(
          {
            property,
            postmate: "reply",
            type: messageType,
            uid,
            value,
          },
          e.origin
        )
      );
    });
  }
}

以上代码中的 resolveValue 方法实现也很简单:

const resolveValue = (model, property) => {
  const unwrappedContext =
    typeof model[property] === "function" ? model[property]() : model[property];
  return Postmate.Promise.resolve(unwrappedContext);
};

此时,我们已经介绍了 Postmate 如何进行握手及如何实现双向消息通信,最后我们来介绍一下如何断开连接。

四、如何断开连接

当父页面与子页面完成消息通信之后,我们需要断开连接。这时我们可以调用 ParentAPI 对象上的 destroy 方法来断开连接。

// src/postmate.js
export class ParentAPI {
 destroy() {
    window.removeEventListener("message", this.listener, false);
    this.frame.parentNode.removeChild(this.frame);
  }
}

本文阿宝哥以 Postmate 这个库为例,介绍了如何基于 postMessage 来实现父页面和 iframe 子页面之间优雅的消息通信。如果你还意犹未尽的话,可以阅读阿宝哥之前写的与通信相关的文章:如何优雅的实现消息通信?你不知道的 WebSocket

关于奇舞周刊

《奇舞周刊》是360公司专业前端团队「奇舞团」运营的前端技术社区。关注公众号后,直接发送链接到后台即可给我们投稿。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在C#中SendMessage和PostMessage的参数传递 在C#中可以使用Window API提供的SendMessage和PostMessage来传递参数。两者的区别简单介绍下:返回值的不同,我们先看一下 MSDN 里的声明: LRESULT SendMessage( HWND hWnd, UINT Msg, WPARAM wParam, LPARAM lParam ); BOOL PostMessage( HWND hWnd, UINT Msg, WPARAM wParam, LPARAM lParam ); 其中 4 个参数的意义是一样的,返回值类型不同(其实从数据上看他们一样是一个 32 位的数,只是意义不一样),LRESULT 表示的是消息被处理后的返回值,BOOL 表示的是消息是不是 Post 成功。 2、PostMessage 是异步的,SendMessage 是同步的。 PostMessage 只把消息放入队列,不管消息是否被处理就返回,消息可能不被处理;而 SendMessage 等待消息被处理完了之后才返回,如果消息不被处理,发送消息的线程将一直被阻塞。 3、如果在同一个线程内,SendMessage 发送消息时,由 USER32.DLL 模块调用目标窗口的消息处理程序,并将结果返回。SendMessage 在同一线程中发送消息并不入线程消息队列。PostMessage 发送消息时,消息要先放入线程的消息队列,然后通过消息循环分派到目标窗口(DispatchMessage)。 如果在不同线程内,SendMessage 发送消息到目标窗口所属线程的消息队列,然后发送消息的线程在 USER32.DLL 模块内监视和等待消息处理,直到目标窗口处理完返回。SendMessage 在返回前还做了很多工作,比如,响应别的线程向它 SendMessage。Post 到别的线程时,最好用 PostThreadMessage 代替 PostMessagePostMessage 的 hWnd 参数可以是 NULL,等效于 PostThreadMessage + GetCurrentThreadId。Post WM_QUIT 时,应使用 PostQuitMessage 代替。 4、系统只整编(marshal)系统消息(0 到 WM_USER 之间的消息),发送用户消息(WM_USER 以上)到别的进程时,需要自己做整编。 用 PostMessage、SendNotifyMessage、SendMessageCallback 等异步函数发送系统消息时,参数里不可以使用指针,因为发送者并不等待消息的处理就返回,接受者还没处理指针就已经被释放了。 5、在 Windows 2000/XP 里,每个消息队列最多只能存放 10,000 个 Post 的消息,超过的还没被处理的将不会被处理,直接丢掉。这个值可以改得更大:[HKEY_LOCAL_MACHINE/SOFTWARE/ Microsoft/Windows NT/CurrentVersion/Windows] USERPostMessageLimit,最小可以是 4000。 PostMessage只负责将消息放到消息队列中,不确定何时及是否处理 SendMessage要等到受到消息处理的返回码(DWord类型)后才继续 PostMessage执行后马上返回 SendMessage必须等到消息被处理后才会返回。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值