代码改变世界

渐进式web应用开发---Service Worker 与页面通信(七)

2019-08-11 17:12  龙恩0707  阅读(3451)  评论(0编辑  收藏  举报

阅读目录

一:页面窗口向 service worker 通信

Service Worker 没有直接操作页面DOM的权限。但是可以通过postMessage方法和web页面进行通信。让页面操作DOM。并且这种操作是双向的。

页面向service worker 发送消息,首先我们要获取当前控制页面的 service worker。可以使用 navigator.serviceWorker.controller 来获取这个service worker. 之后我们就可以使用 service worker 中的 postMessage() 方法,该方法接收的第一个参数为消息本身,该参数可以是任何值,可以是js对象,字符串、对象、数组、布尔型等。

比如如下代码是 页面向service worker 发送了一条简单对象的消息:

navigator.serviceWorker.controller.postMessage({
  'userName': 'kongzhi',
  'age': 31,
  'sex': 'men',
  'marriage': 'single'
});

消息一旦发布,service worker 就可以通过监听 message 事件来捕获它。如下代码:

self.addEventListener("message", function(event) {
  console.log(event.data);
});

在代码演示之前,我们来看下我们项目中的目录结构如下:

|----- service-worker-demo7
|  |--- node_modules        # 项目依赖的包
|  |--- public              # 存放静态资源文件
|  | |--- js
|  | | |--- main.js         # js 的入口文件
|  | | |--- store.js        # indexedDB存储
|  | | |--- myAccount.js    
|  | |--- styles
|  | |--- images
|  | |--- index.html        # html 文件
|  |--- package.json
|  |--- webpack.config.js
|  |--- sw.js

如上就是我们目前的项目架构,这篇文章的项目架构是基于上篇文章的架构的基础之上的,可以请移步查看上一篇文章

因此在入口文件 main.js 代码添加如下代码:

// 页面向 service worker 发送一条消息
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
  navigator.serviceWorker.controller.postMessage({
    'userName': 'kongzhi',
    'age': 31,
    'sex': 'men',
    'marriage': 'single'
  });
}

在我们的sw.js 里面,我们监听 message 消息即可;添加如下代码所示:

self.addEventListener("message", function(event) {
  console.log(event.data);
  console.log(event);
});

注意:当我们第一次刷新页面注册service worker的时候并没有发送消息,那是因为第一次刷新页面的时候并没有注册service worker,只有注册完成后,我们再刷新页面就可以打印消息出来了。因此我们上面加了 if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {} 这个来判断。
如上打印 console.log(event.data); 消息如下所示:

当我们打印 console.log(event); 的时候;如下图所示:

如上 打印 event 的时候,我们除了打印 event.data 可以获取到消息之外的数据,我们还可以拿到 event.source 里面包含了发送消息的窗口的相关信息。

窗口向service worker 通信的具体用途如下:

比如说我们网站有很多很多页面,是一个非常大型的网站,我们不可能对每个页面进行缓存,我们可以对用户访问的页面来进行缓存,那么这个时候我们可以通过 postMessage() 方法向用户发送一条消息,告诉用户该页面需要被缓存了。

因此我们对某个页面添加 js 代码如下:

navigator.serviceWorker.controller.postMessage("cache-current-page");

当用户访问该页面的时候,会发送一条消息到我们的 service worker 中,service worker 可以监听这些消息,并使用事件的 source 属性,判断需要缓存那个页面;具体判断代码如下:

self.addEventListener('message', function(event) {
  if (event.data === "cache-current-page") {
    var sourceUrl = event.source.url;
    if (event.source.visibilityState === 'visible') {
      // 缓存 sourceUrl 和相关的文件
    } else {
      // 将sourceUrl和相关的文件添加到队列中。稍后缓存
    }
  }
});

如上代码;在sw.js 中我们可以根据 sourceUrl 来 确定需要缓存那个页面,因为不同的页面,他们的 sourceUrl 是不相同的。从那个页面发送消息过来,那么就对应那个页面的url。并且代码里面根据页面的可见状态来判断对应请求缓存哪个页面。

二:service worker 向所有打开的窗口页面通信

在service worker 内,我们可以使用 service worker 的全局对象中的clients对象,获取 service worker作用域内所有当前打开的窗口。clients包含了一个 matchAll() 方法,我们可以使用这个方法获取service worker 作用域内所有当前打开的窗口。
matchAll() 返回一个promise对象。返回一个包含0个或多个 WindowClient 对象的数组。

为了有多个页面,因此我们需要在项目中的根目录添加一个新页面,比如叫 a.html. 因此目录结构变成如下:

|----- service-worker-demo7
|  |--- node_modules        # 项目依赖的包
|  |--- public              # 存放静态资源文件
|  | |--- js
|  | | |--- main.js         # js 的入口文件
|  | | |--- store.js        # indexedDB存储
|  | | |--- myAccount.js    
|  | |--- styles
|  | |--- images
|  | |--- index.html        # html 文件
|  |--- package.json
|  |--- webpack.config.js
|  |--- sw.js
|  |--- a.html

因此在 sw.js 代码中添加如下代码:

self.clients.matchAll().then(function(clients) {
  console.log(clients);
  clients.forEach(function(client) {
    console.log(client);
    if (client.url.includes('/a.html')) {
      // 首页
      client.postMessage('hello world' + client.id);
    }
  });
});

然后我们在 main.js 代码下 添加如下代码:

if ("serviceWorker" in navigator && navigator.serviceWorker) {
  navigator.serviceWorker.addEventListener("message", function(event) {
    console.log(event.data);
  })
}

如上如果运行正常的话,就可以在控制台中看到类似如下信息:hello world7f71806e-7699-45f3-8d5b-50fdc67b34fc

注意:但是把我们的代码放到 servcie worker 顶部是不行的,如果把代码放在事件之外的话,它只会在 service worker 脚本加载后,service worker 安装前以及任何客户端监听之前,它只会执行一次。因此我们需要放到 install 事件中,比如我之前缓存所有的页面中install 事件中,放在如下代码中即可:

// 监听 install 事件,把所有的资源文件缓存起来
self.addEventListener("install", function(event) {
  event.waitUntil(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.addAll(CACHE_URLS);
    }).then(function(){
      return self.clients.matchAll({includeUncontrolled: true});
    }).then(function(clients){
      console.log(clients);
      clients.forEach(function(client) {
        client.postMessage('hello world' + client.id);
      });
    })
  )
});

如上代码,我们打印 console.log(clients); 可以看到如下信息:

现在不管我们的页面是在线也好还是离线也好,都会执行代码。我们可以在service worker 安装并缓存所有的资源文件后,立即会向用户发送一条消息。

三:service worker 向特定的窗口通信

除了上面的 matchAll()方法之外,clients对象还有另一个方法。我们可以通过 get()方法获取单个客户端的对象。通过传递一个已知客户端的ID给get()方法,我们就可以得到一个promise。当其完成的时候我们就会得到 WindowClient对象,之后我们就可以使用该对象,给客户端发送消息。

比如我们之前的客户端的ID为 "87f07759-2e9e-4ecd-a9b2-3c64f843b9c7";

那么我们就可以使用该get()方法获取该ID,然后会返回一个Promise对象,如下代码所示:

self.clients.get("87f07759-2e9e-4ecd-a9b2-3c64f843b9c7").then(function(client) {
  client.postMessage("hello world");
});

有以下两种方式可以找到客户端的id, 第一种方式是使用 clients.matchAll()迭代所有的打开的客户端,通过WindowClient对象的id属性获取。如下代码所示:

self.clients.matchAll().then(function(clients) {
  clients.forEach(function(client){
    self.clients.get(client.id).then(function(client) {
      client.postMessage("Messaging using clients.matchAll()");
    })
  }) 
});

第二种方法是通过postMessage事件的source属性获取,如下代码所示:

self.addEventListener("message", function(event) {
  self.clients.get(event.source.id).then(function(client) {
    client.postMessage("Messaging using clients.get(event.source.id)");
  });
});

四:学习 MessageChannel 消息通道

我们前面的demo使用了 WindowClient 或 service worker 对象发送消息,并且只看到了 postMessage()只接收了第一个参数。
但是我们的postMessage方法可以接收第二个参数,我们可以使用该参数来保持双方之间的通信渠道打开。可以来回发送消息。

那么这种通信 是通过 MessageChannel 对象处理的。我们可以通过构造函数 MessageChannel() 可以创建一个消息通道,该实列会有2个属性,分别为 port1 和 port2; 如下代码所示:

var msg = new MessageChannel();
console.log(msg);

打印信息如下所示:

如上图我们可以看到,该对象有 onmessage 和 onmessageerror 两个属性是两个回调方法。我们可以使用 MessagePort.postMessage 方法发送消息的时候,我们就可以通过另一个端口的 onmessage 来监听该消息。

也就是说消息通道是有两个口子,那么这两个口子分别是 port1 和 port2。这两个口子可以相互发送消息,port1口子发送的消息,我们可以在port2口子中接收到消息。

比如如下代码:

var msg = new MessageChannel();
var p1 = msg.port1;
var p2 = msg.port2;

// 使用p1口子监听消息
p1.onmessage = function(msg) {
  console.log('接收到的消息:' + msg.data);
}

// 使用p2口子发送消息
p2.postMessage("hello world");

打印信息如下所示:

如上我们可以看到,MessageChannel对象有两个口子,分别为 port1 和 port2; 我们在port2上使用 postMessage 发送消息,我们可以在 port1上监听到该消息。

现在我们把该 MessageChannel 消息通道使用到我们的 service worker 当中来,当我们从窗口向service worker 通信时(或者反正都可以),我们可以在窗口中创建一个新的 MessageChannel 对象,并且通过 postMessage 将其中一个口子传递给 serviceworker, 当消息到达后,就可以在service worker 中访问端口了。如下:

首先我们在我们的 main.js(入口文件)添加如下代码:

var msgChan = new MessageChannel();
var p1 = msgChan.port1;

// 使用p1口子监听消息
p1.onmessage = function(msg) {
  console.log('接收到的消息:' + msg.data);
}

var msg = {
  name: 'kongzhi',
  age: 31,
  value: 2
};

if ("serviceWorker" in navigator && navigator.serviceWorker) {
  navigator.serviceWorker.controller.postMessage(msg, [msgChan.port2]);
}

然后在我们的 service worker.js 中添加如下代码:

// service worker 代码
self.addEventListener("message", function(event) {
  var data = event.data;
  var port = event.ports[0];
  if (data.name === 'kongzhi') {
    port.postMessage(data.value * 2);
  }
});

然后在页面上会打印如下信息:

如上代码我们可以看到,我们在main.js 代码中创建了一个新的 MessageChannel, 并且在port1中的口子上添加了事件监听器。如果收到任何消息就会打印出来,然后我们就会使用 navigator.serviceWorker.controller.postMessage 代码向 service worker发送一条消息。同时将 MessageChannel 第二个口子传递过去,这边使用了一个数组传递过去,以便我们在service worker中通过0或者多个端口进行通信。

在service worker.js 中,我们监听了message事件,当检测到该事件的时候,我们使用 event.data 获取到消息的内容,和页面的端口,并且检测该消息的 name 属性 等于 'kongzhi' 这个字符串的话,那么我们就使用第二个口子 port2发送一个消息过去,那么在main.js 中,我们使用第一个口子 port1 来监听该消息,然后就能接收到消息来了,最后打印信息了,如上所示。

如上demo我们演示了 使用 MessageChannel 来实现两个口子(port1, port2) 之间通信的问题。那么现在我们使用 MessageChannel 如何在页面和service worker 之间保持连续通信通道打开。

五:窗口之间的通信

通过以上一些知识点,我们现在再来看看如何在不同的窗口之间进行通信呢?现在我们可以通过使用上面的知识点来实现窗口之间发送消息。

比如我现在页面上有一个注销操作,当我们用户点击该操作时,该链接会把用户返回到首页,我们之前会在页面上增加一个 a 链接按钮,点击该注销按钮的时候,我们会发送一个ajax请求,请求成功后,我们会跳转到登录页面去。

现在我们需要使用service worker 来做同样的操作,唯一不同的是,假如我们的页面 打开了多个index.html页面,比如网址为:
http://localhost:8082/index.html 这样的,多个标签页都打开了该页面,如果我们点击注销按钮后,所有打开该页面都会被同时退出到登录页面去。也就是说,在支持service worker 的浏览器下,支持多个窗口同时退出。

首先我们需要在我们的 main.js 添加如下代码:

$(function(){
  if ("serviceWorker" in navigator && navigator.serviceWorker) {
    console.log(navigator.serviceWorker.controller);
    $('#logout').click(function(e) {
      e.preventDefault();
      navigator.serviceWorker.controller.postMessage({
        action: "logout"
      });
    });
    navigator.serviceWorker.addEventListener("message", function(event) {
      var data = event.data;
      if (data.action === "navigate") {
        window.location.href = data.url;
      }
    });
  }
});

如上代码,当我们点击 注销按钮 id 为 logout 的时候,我们会使用 service worker中的postMessage中的方法:
navigator.serviceWorker.controller.postMessage 发送一个消息过去。然后我们sw.js 代码中会监听该消息,比如如下代码:

// service worker 代码
self.addEventListener("message", function(event) {
  var data = event.data;
  if (data.action === 'logout') {
    self.clients.matchAll().then(function(clients) {
      clients.forEach(function(client) {
        console.log(client.url);
        if (client.url.includes("http://localhost:8082/index.html")) {
          client.postMessage({
            action: "navigate",
            url: 'http://www.baidu.com'
          })
        }
      })
    });
  }
});

然后会获取到 消息内容 event.data; 然后会判断该 action 是否等于 'logout' 这个字符串,如果相等的话,监听器就会获取当前打开的所有的 WindowClient, 逐个遍历,并且检查窗口是否包含 "http://localhost:8082/index.html", 如果包含的话,就向这个窗口发送消息,其中我们的键action包含了一个"navigate"字符串,可以随便取名字。

然后在我们的main.js 会有如下监听事件代码,如下所示:

navigator.serviceWorker.addEventListener("message", function(event) {
  var data = event.data;
  if (data.action === "navigate") {
    window.location.href = data.url;
  }
});

如果监听到该消息,就重置向到 登录页面去,我这边直接使用 百度 首页打比方。当然当我们点击注销按钮的时候,我们需要发送ajax请求,请求成功后,我们再使用如上的操作代码。如上代码,就可以使所有打开该页面,都会重置到登录页面去。

六:从sync事件向页面传递消息

 该功能是建立在前一篇 使用后台保证离线功能 的页面基础之上的,想看之前的页面,请点击这里
之前我们点击该按钮的时候,如下所示:
在页面上我们点击新增这个按钮的时候,我们会调用 如下代码:
var addStore = function(id, name, age) {
  var obj = {
    id: id,
    name: name,
    age: age
  };
  addToObjectStore("store", obj);
  renderHTMLFunc(obj);
  // 先判断浏览器支付支持sync事件
  if ("serviceWorker" in navigator && "SyncManager" in window) {
    navigator.serviceWorker.ready.then(function(registration) {
      registration.sync.register("sync-store").then(function() {
        console.log("后台同步已触发");
      }).catch(function(err){
        console.log('后台同步触发失败', err);
      })
    });
  } else {
    $.getJSON("http://localhost:8082/public/json/index.json", obj, function(data) {
      updateDisplay(data);
    });
  }
};
$("#submit").click(function(e) {
  addStore(1, 'kongzhi111', '28');
});

然后会调用 registration.sync.register("sync-store") 注册一个同步事件,然后会在我们的 sw.js 下会监听该事件;
如下代码:

self.addEventListener("sync", function(event) {
  if (event.tag === "sync-store") {
    console.log('sync-store')
    event.waitUntil(syncStores());
  }
});

如上我们调用了 syncStores 这个函数,我们来看下该函数的代码如下:

var syncStores = function() {
  return getStore().then(function(reservations) {
    console.log(reservations);
    return Promise.all(
      reservations.map(function(reservation){
        var reservationUrl = createStoreUrl(reservation);
        return fetch(reservationUrl).then(function(response) {
          return response.json();
        }).then(function(newResponse) {
          return updateInObjectStore("store", 1, newResponse).then(function(){

          })
        })
      })
    )
  });
};

如上代码,我们可以看到在我们的 最后一句代码 return updateInObjectStore("store", 1, newResponse).then(function() { }) 中,最后调用了 updateInObjectStore 更新 indexedDB数据库操作,但是我们如何把更新后的数据发送给DOM操作呢?我们之前学习了 postMessage() 这个,使页面能和service worker 进行通信操作,我们把该技术运用起来。

因此我们需要把上面的sw.js 中的 syncStores 函数 代码改成如下所示的:

// 新增的代码:
var postStoreDetails = function(data) {
  self.clients.matchAll({ includeUncontrolled: true }).then(function(clients) {
    clients.forEach(function(client) {
      client.postMessage({
        action: 'update-store',
        data: data
      })
    });
  });
};
var syncStores = function() {
  return getStore().then(function(reservations) {
    console.log(reservations);
    return Promise.all(
      reservations.map(function(reservation){
        var reservationUrl = createStoreUrl(reservation);
        return fetch(reservationUrl).then(function(response) {
          return response.json();
        }).then(function(newResponse) {
          return updateInObjectStore("store", 1, newResponse).then(function() {
            // 新增的代码如下:
            postStoreDetails(newResponse);

          })
        })
      })
    )
  });
};

如上我们在 updateInObjectStore 中的回调中添加了 postStoreDetails 这个函数代码,然后把新的对象传递给函数,该函数如上代码,会使用postMessage事件发送消息过去,然后我们需要在我们的 myAccount.js 中js操作页面去使用 message 事件去监听该消息,代码如下所示:

function updateDisplay(d) {
  console.log(d);
};

if ("serviceWorker" in navigator && navigator.serviceWorker) {
  navigator.serviceWorker.addEventListener("message", function(event) {
    var data = event.data;
    if (data.action === 'update-store') {
      console.log('函数终于被调用了');
      updateDisplay(data);
    }
  });
}

最后我们点击下该按钮,会打印如下信息了;如下图所示:

现在我们就可以拿到新增后或更新后的数据,在页面DOM上进行操作数据了。

查看github源码