Help us understand the problem. What is going on with this article?

SharedWorkerを使ってWebSocketやSSEの接続を軽減するサンプル

参考

※この投稿は、↑のページを理解したくて、私が勉強してみたことをまとめた内容になります:bow:

やりたいこと

WebSocketを使うとサーバーとクライアントで双方向にデータを送信できます。
クライアントはサーバーとの接続を開きっぱなしにします。

なので例えばAさんが、WebSocketと通信するページを10タブ開いた場合は、Aさんだけでサーバーと10接続することになります。

computer_server1 (2).png

そこでSharedWorkerからWebSocketと接続することで、Aさんが10タブ開いたとしてもサーバーとの接続は1つだけにすることができます。

computer_server1 (1).png


今回私が最終的に作成したコードはこちらです

https://github.com/okumurakengo/shared-worker-sample

SharedWorkerとは

SharedWorkerはWeb Workerの一種です。
Web Workerとはメイン実行スレッドとは別に、バックグラウンドスレッドでスクリプト操作を実行できます。負荷の高い処理をメインスレッドで実行したくない場合に、Web Workerで処理を行うといった用途で使います。

SharedWorkerは複数ウィンドウ、複数タブからアクセスできる共有のWorkerです。
使い方もWeb Workerとは微妙に違います。

1. WebSocketサンプル

1-1. 簡単にWebSocketと通信

Nodejsのwsで簡単にWebSocketを使ってみます。
※まだSharedWorkerは出てきません。

yarn add ws
server.js
const http = require("http");
const fs = require("fs");
const WebSocket = require("ws");

// httpサーバー作成
const server = http.createServer();
server.on("request", async (req, res) => {
    try {
        res.write(await fs.promises.readFile(`${__dirname}${req.url === "/" ? "/index.html" : req.url}`));
    } catch(e) {
        console.log(e.message)
        res.writeHead(404)
    }
    res.end();
});
server.listen(8000);
console.log("http server listening ...");

// websockerサーバー作成
const wss = new WebSocket.Server({ port: 8001 });
wss.on("connection", ws => {
    console.log("Socket connected successfully");

    ws.on("message", message => {
        console.log(`Received ${message}`);

        for (client of wss.clients) {
            client.send(`${message} from server!`);
            client.send(`現在のクライアントとの接続数 : ${[...wss.clients].length}`);
        }
    });

    ws.on("close", () => {
        console.log("I lost a client");
    });
});

index.html
<!DOCTYPE html>
<meta charset="UTF-8">
<title>Document</title>
<script src="client.js" defer></script>

<input type="text" id="text" value="hello">
<input type="button" id="button" value="送信">
client.js
const ws = new WebSocket("ws://localhost:8001");

ws.addEventListener("open", e => {
    console.log("Socket Opne");
});

// サーバーからデータを受け取る
ws.addEventListener("message", e => {
    console.log(e.data);
});

// サーバーにデータを送る
button.addEventListener("click", e => {
    ws.send(text.value);
});
$ node server.js # サーバー起動
http server listening ...

この状態で http://localhost:8000 を開いて、送信ボタンを押すと、WebSocketと通信できました。
3つのウィンドウから開いた状態だと、WebSocketと接続している数が3つになることも確認できました。

Screen Shot 2019-11-23 at 22.37.43.png

1-2. SharedWorkerからWebSocketと通信

次に、
メインスレッドのjs -> SharedWorker -> サーバーとデータ送信する動作と、
サーバー -> SharedWorker -> メインスレッドのjsとデータ送信する動作に変更します。

client.jsを変更し、新しくworker.jsを作成します。

client.js
const worker = new SharedWorker("worker.js")
worker.port.start();

button.addEventListener("click", e => {
    console.log(`メインスクリプトからSharedWorkerにデータ送信 「${text.value}」`)
    worker.port.postMessage(text.value)
});

worker.port.addEventListener("message", e => {
    console.log(`メインスクリプトでSharedWorkerからのデータ受信 「${e.data}」`)
});
worker.js
const ws = new WebSocket("ws://localhost:8001");

ws.addEventListener("open", e => {
    console.log("Socket Opne");
});

// サーバーからデータを受け取る
ws.addEventListener("message", e => {
    console.log(`SharedWorkerでサーバーからデータ受信し、メインスクリプトへデータ送信 : 「${e.data}」`)
    // 開いている全ての複数ウィンドウ、複数タブへメッセージ送信
    for (const connection of connections) {
        connection.postMessage(e.data);
    }
});

let connections = [];
self.addEventListener("connect", e => {
    const port = e.ports[0];

    // タブやウィンドウを開くたびに接続を配列に保存します。
    // ※しかし、これだとウィンドウを閉じても配列に残り続けてしまったり、
    //  リロードすると前の接続が残ったまま新しい接続が追加されてしまうといった問題があります。
    //  Broadcast Channel API を使うと簡単に解決できるのでそちらを使った方が良いと思います。
    //  Broadcast Channel API を使ったサンプルは次に説明します。
    connections.push(port);

    port.addEventListener("message", function (e) {
        console.log(`SharedWorkerでメインスクリプトからデータ受信し、サーバーへデータ送信 : 「${e.data}」`)
        ws.send(e.data);
    });

    port.start(); // addEventListenerを使用する場合に必要、onmessageを使う場合は暗黙的に呼び出される
})

SharedWorkerからWebSockerに接続することで、複数のウィンドウを開いている場合でも、WebSocketとの接続は1つだけにすることができました。

Screen Shot 2019-11-23 at 23.32.01.png


※SharedWorker内でconsole.logを使用した場合は、普通に開発者ツールのコンソールには出てきません。

chrome://inspect/#workers を開き、そこから開けるコンソールに表示されます。

Screen Shot 2019-11-23 at 23.35.25.png

1-3. Broadcast Channel API を使ってSharedWorkerからメインスクリプトへデータを送信するようにする

先ほどの例ではタブやウィンドウを開くたびに、connections という配列にe.ports[0]が追加されていくようにしたのですが、
ブラウザを閉じても配列に残ったままになってしまう、
リロードした場合も、前の接続が残ったまま新しい接続が追加されてしまうといった問題があります。

woker.js
// ...

let connections = [];
self.addEventListener("connect", e => {
    const port = e.ports[0];

    connections.push(port);

// ...

SharedWorkerからBroadcast Channel API を使ってメインスクリプトへデータを送信するのが良いようです。

Broadcast Channel API - Web APIs | MDN

BroadcastChannelを使うことで、開いている全てのタブ、ウィンドウ、iframeにデータ送信できます。
※実際にデータを送信したウィンドウ(またはタブなど)では受信しません。自分以外の全てでデータを受信できます。

client.jsworker.jsBroadcastChannelを使うように変更します。

client.js
+ const bc = new BroadcastChannel("WebSocketChannel");
  const worker = new SharedWorker("worker.js")
  worker.port.start();

  button.addEventListener("click", e => {
      console.log(`メインスクリプトからSharedWorkerにデータ送信 「${text.value}」`)
      worker.port.postMessage(text.value)
  });


+ bc.addEventListener("message", e => {
- worker.port.addEventListener("message", e => {
      console.log(`メインスクリプトでSharedWorkerからのデータ受信 「${e.data}」`)
  });
worker.js
+ const bc = new BroadcastChannel("WebSocketChannel");
  const ws = new WebSocket("ws://localhost:8001");

  ws.addEventListener("open", e => {
      console.log("Socket Opne");
  });

  // サーバーからデータを受け取る
  ws.addEventListener("message", e => {
      console.log(`SharedWorkerでサーバーからデータ受信し、メインスクリプトへデータ送信 : 「${e.data}」`)
      // 開いている全ての複数ウィンドウ、複数タブへメッセージ送信
-     for (const connection of connections) {
-         connection.postMessage(e.data);
-     }
+     bc.postMessage(e.data);
  });


- let connections = [];
  self.addEventListener("connect", e => {
      const port = e.ports[0];


-     connections.push(port);

      port.addEventListener("message", function (e) {
          console.log(`SharedWorkerでメインスクリプトからデータ受信し、サーバーへデータ送信 : 「${e.data}」`)
          ws.send(e.data);
      });

      port.start(); // addEventListenerを使用する場合に必要、onmessageを使う場合は暗黙的に呼び出される
  })

こちらでも同じように動作することができました。

Screen Shot 2019-11-24 at 0.07.37.png

2. SSE (Server-Sent Event) でもやってみた

server.js
const http = require("http");
const fs = require("fs");

// httpサーバー作成
const server = http.createServer();

server.on("request", async (req, res) => {
  if (req.url === "/events") {

    res.writeHead(200, {
        "Content-Type": "text/event-stream", 
        "Cache-Control": "no-cache",
    });

    setInterval(() => {
        res.write(`data: ${JSON.stringify({ time: new Date().toLocaleTimeString() })}\n\n`)
        res.flushHeaders();
    }, 1000)

  } else {

    try {
        res.write(await fs.promises.readFile(`${__dirname}${req.url === "/" ? "/index.html" : req.url}`));
    } catch(e) {
        console.log(e.message)
        res.writeHead(404)
    }
    res.end();

  }
});
server.listen(8000);
console.log("http server listening ...");
index.html
<!DOCTYPE html>
<meta charset="UTF-8">
<title>Document</title>
<script src="event.js" defer></script>

<ul id="sample"></ul>
event.js
const bc = new BroadcastChannel("WebSocketChannel");
const worker = new SharedWorker("worker.js")
worker.port.start();

bc.addEventListener("message", e => {
    sample.appendChild(document.createElement("li")).textContent = e.data;
});
worker.js
const bc = new BroadcastChannel("WebSocketChannel");
const es = new EventSource("./events");

es.addEventListener("message", e => {
    const { time } = JSON.parse(e.data);
    bc.postMessage(`${time} from SharedWorker!`);
});

Screen Shot 2019-11-24 at 0.55.43.png

SSEでもSharedWorkerから接続して、複数タブを開いても接続は1つだけにすることができました。

最後まで読んでいただいてありがとうございました。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした