Service Workerの基本とそれを使ってできること

Service Workerとは

ブラウザが Web ページとは別にバックグラウンドで実行するスクリプト
オフラインのアプリを実現・サポートするために作られたものです

ちなみに、ブラウザの対応状況はこんな感じ
http://caniuse.com/#search=service%20workers

特徴

  • DOM にアクセスできない
    • DOM を操作したい場合は、Service Worker がコントロールしているページ(js)と postMessage でメッセージのやり取りをして行う
  • リクエストをプロキシすることが可能
  • Service Worker はブラウザが必要に応じて起動・終了するので、変数の値を保持しておけない
    • Cache、IndexedDB 等で値を保存して、必要になった時に取り出すようにする
  • Promise を多用する
  • httpslocalhost 上でしか動作しない

ライフサイクル

Web ページとは全く異なるライフサイクルで動作する

ServiceWorkerLifeCycle.png

赤文字はその時に発火するイベント

登録

Service Worker を使うにはまず register()関数を呼びだして登録する
すでに登録されているかどうかはブラウザがチェックしてくれるので気にせず呼べばいい

navigator.serviceWorker.register('/service-worker.js');

登録時に重要なのがスコープ
スコープとは Service Worker がコントロールするページのこと
スコープは、service-worker.js が存在する階層が自動的に設定される
また、下記のように明示的に設定することも可能

navigator.serviceWorker.register('/service-worker.js', {scope: '/example'});

上記の場合、/example 配下のページが Service Worker にコントロールされる
つまり、「 http://www.example.com/example/page.html 」はコントロールされるが
http://www.example.com/index.html 」はコントロールされないということ

また、register()で登録し service-worker.js が更新されている場合はonupdatefoundイベントが発火する(初回登録時も)

インストール

Service Worker を新規インストール、もしくは、Service Worker が更新されていると installing 状態になる
この時、oninstall イベントも発火する
インストール時に何か処理させたい場合は、下記のように Service Worker 内で oninstall イベントを監視する

// service-worker.js
self.addEventListener('install', (event) => {
  // 何かの処理
});

更新

ブラウザが保持している Service Worker と、これからダウンロードしようとしている Service Worker に
1byteでも違いがあれば更新されたと判断される

ブラウザをコントロールしている Service Worker が存在しなければ installing 状態のあと
すぐに、active 状態になるが、コントロールしている Service Worker が存在する場合は
waiting 状態に移行する

ServiceWorker更新時.png

なぜすぐに active 状態にしないかというと、古い Service Worker が保持しているデータを
そのまま新しい Service Worker が使おうとした場合、不整合が起きる可能性があるからです
その後、安全な状態(ページが閉じられるなど)になると waiting 状態から active 状態に移行します

Tips

更新時に installing 状態からすぐに active 状態に移行させたい

Service Worker 更新時はデータの不整合等を防ぐために waiting 状態に移行し
安全な状態になるのを待つようになっている

すぐに active 状態にしても問題ない場合は、skipWaiting()を呼ぶことで active 状態にすることができる

// service-worker.js
self.addEventListener('install', (event) => {
  event.waitUntil(self.skipWaiting());
});

waitUntil()はこの関数が呼ばれたイベント終了のライフタイムをその処理が終わるまで待つ
(この関数はよく使うので覚えておくといいです)

active 状態になったらすぐにコントロールさせたい

Service Worker は active 状態になってもすぐにブラウザをコントロールしない
コントロールするのは次にページが表示された時

claim()を呼ぶことすぐにコントロールさせることができる

// service-worker.js
self.addEventListener('activate', (event) => {
  event.waitUntil(self.clients.claim());
});

ここまで、Service Worker の基本を説明してきました
ここからは Service Worker を使ってできることを説明していきます

サンプルコードはここにあるので見てください
動作は、Google Chrome 52.0.2743.116 m (64-bit)で確認済みです

Cache編

Cache API を使用することでオフライン状態でもリソースを取得できるようにします

動作サンプルはこちら
一度オンライン状態でページを取得し、その後オフライン状態で再読み込みしてみてください
オフライン状態でもリソースが取得できることが確認できるはずです

ページ

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Cache Test</title>
    <link rel="stylesheet" href="./styles/main.css">
</head>
<body>
    <h1>Service Worker Cache Test</h1>
    <img src="./images/image.jpg">
    <script src="./script/jquery-3.1.0.js"></script>
    <script src="./script/main.js"></script>
</body>
</html>

CSS、イメージ、スクリプトのリクエストをするだけの簡単なページです
main.js は Service Worker を登録するスクリプトです

Service Worker 登録スクリプト

// main.js
navigator.serviceWorker.register('./service-worker.js')
                       .catch(console.error.bind(console));

Service Worker の登録を行うスクリプトです
register で登録をしてエラーがあった時はコンソール表示するだけの簡単なものです

Service Worker

ちょっと長いです
いったん、全ソースを紹介して、その後細かく説明していきます

// service-worker.js
'use strict';

const CACHE_NAME = 'cache-v1';
const urlsToCache = [
    './',
    './styles/main.css',
    './images/image.jpg',
    './script/main.js'
];

self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME)
              .then((cache) => {
                  console.log('Opened cache');
                  
                  // 指定されたリソースをキャッシュに追加する
                  return cache.addAll(urlsToCache);
              })
    );
});

self.addEventListener('activate', (event) => {
    var cacheWhitelist = [CACHE_NAME];

    event.waitUntil(
        caches.keys().then((cacheNames) => {
            return Promise.all(
                cacheNames.map((cacheName) => {
                    // ホワイトリストにないキャッシュ(古いキャッシュ)は削除する
                    if (cacheWhitelist.indexOf(cacheName) === -1) {
                        return caches.delete(cacheName);
                    }
                })
            );
        })
    );
});

self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request)
              .then((response) => {
                  if (response) {
                      return response;
                  }
            
                  // 重要:リクエストを clone する。リクエストは Stream なので
                  // 一度しか処理できない。ここではキャッシュ用、fetch 用と2回
                  // 必要なので、リクエストは clone しないといけない
                  let fetchRequest = event.request.clone();
            
                  return fetch(fetchRequest)
                      .then((response) => {
                          if (!response || response.status !== 200 || response.type !== 'basic') {
                              return response;
                          }
                    
                          // 重要:レスポンスを clone する。レスポンスは Stream で
                          // ブラウザ用とキャッシュ用の2回必要。なので clone して
                          // 2つの Stream があるようにする
                          let responseToCache = response.clone();
                    
                          caches.open(CACHE_NAME)
                                .then((cache) => {
                                    cache.put(event.request, responseToCache);
                                });
                    
                          return response;
                      });
              })
    );
});

install時

const CACHE_NAME = 'cache-v1';
const urlsToCache = [
    './',
    './styles/main.css',
    './images/image.jpg',
    './script/main.js'
];

self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME)
              .then((cache) => {
                  // 指定されたリソースをキャッシュに追加する
                  return cache.addAll(urlsToCache);
              })
    );
});

CACHE_NAME はキャッシュに保存する時の名前です
Cacheキャッシュ名1.png
DevTools の Application タブを開くと左側に Cache という項目があり
↑で指定した名前で保存されていることがわかります

CacheStorage.open('キャッシュ名')でキャッシュを開き
urlsToCache で指定されているリソースを Cache.addAll でキャッシュに保存します

activate時

self.addEventListener('activate', (event) => {
    var cacheWhitelist = [CACHE_NAME];

    event.waitUntil(
        caches.keys().then((cacheNames) => {
            return Promise.all(
                cacheNames.map((cacheName) => {
                    // ホワイトリストにないキャッシュ(古いキャッシュ)は削除する
                    if (cacheWhitelist.indexOf(cacheName) === -1) {
                        return caches.delete(cacheName);
                    }
                })
            );
        })
    );
});

この activate 時の処理ですが、新規インストール時は何も処理されません

ここでは、キャッシュ名がcache-v2になった時の処理を説明します

  1. キャッシュ名が変わったので Service Worker が更新されたと判断され、install イベントが発火します
    そして、キャッシュ名cache-v2で保存されます
    Cacheキャッシュ名2.png
    このようにキャッシュが2つある状態になります
  2. 安全な状態になり、再度ページを開いた時に activate イベントが発火します
    CACHE_NAMEcache-v2になっているのでcacheWhitelistにはcache-v2が設定されます
    cache.keysによって保存されているキャッシュの名前が列挙され、ホワイトリストにないcache-v1(古いキャッシュ)が削除されます
    Cacheキャッシュ名3.png
    cache-v1が削除されていることがわかる

fetch時

self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request)
              .then((response) => {
                  if (response) {
                      return response;
                  }
            
                  // 重要:リクエストを clone する。リクエストは Stream なので
                  // 一度しか処理できない。ここではキャッシュ用、fetch 用と2回
                  // 必要なので、リクエストは clone しないといけない
                  let fetchRequest = event.request.clone();
            
                  return fetch(fetchRequest)
                      .then((response) => {
                          if (!response || response.status !== 200 || response.type !== 'basic') {
                              return response;
                          }
                    
                          // 重要:レスポンスを clone する。レスポンスは Stream で
                          // ブラウザ用とキャッシュ用の2回必要。なので clone して
                          // 2つの Stream があるようにする
                          let responseToCache = response.clone();
                    
                          caches.open(CACHE_NAME)
                                .then((cache) => {
                                    cache.put(event.request, responseToCache);
                                });
                    
                          return response;
                      });
              })
    );
);

Service Worker がブラウザをコントロールしている時にリソースのリクエストが発生すると
fetch イベントが発火します
fetch イベントで何もしなければ、普通にネットワーク経由でリクエストが処理されます

ここで行っていることは単純で、リクエストが来たリソースがキャッシュに保存されていればそれを返し、なければサーバーにリクエストを投げ、返って来たリソースをキャッシュに保存しつつ、クライアントに返しているだけです
install 時にキャッシュに保存していなくても、こうすることでキャッシュすることが可能です

重要なことは、リクエストとレスポンスはStreamなので使い回すことができないようです
フェッチしたり、キャッシュしたりする場合は clone する必要があるようです

Cacheについての説明はこんな感じです
実際に、オフライン状態でページを読み込んでみると CacheDevTools.png
Service Worker を使ってリソースを取得できてるのがわかります

Push Notification編

ネイティブアプリのようにプッシュ通知をブラウザに送る方法を説明します

プッシュ通知を試したい場合は、ここにあるリポジトリをクローンし
下記を参考に適宜修正してください
修正が必要なのは、manifest.json の gcm_sender_id と push.js の APIキー、エンドポイントなどです

ページ

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Push Test</title>
    <link rel="manifest" href="./manifest.json">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/primer/2.0.2/primer.css">
</head>
<body>
    <h1>Service Worker Push Test</h1>
    <dl class="form">
        <dt><label>Endpoint URL</label></dt>
        <dd><textarea id="subscription-endpoint" class="input-block"></textarea></dd>
        <dt><label>Auth</label></dt>
        <dd><textarea id="subscription-auth" class="input-block"></textarea></dd>
        <dt><label>Public Key</label></dt>
        <dd><textarea id="subscription-public-key" class="input-block"></textarea></dd>
    </dl>
    <script src="./script/main.js"></script>
</body>
</html>

ページは manifest.json の読み込み以外、大したことはしていません
プッシュ通知に必要な情報を表示するための要素があるぐらいです

manifest.json

{
  "name": "SW Push Notification",
  "short_name": "SW Push Notification",
  "icons": [{
    "src": "https://kanatapple.github.io/service-worker/push/images/image.jpg",
    "sizes": "192x192",
    "type": "image/jpg"
  }],
  "start_url": "./index.html",
  "display": "standalone",
  "gcm_sender_id": "621388437768"
}

プッシュ通知する際は必要なので用意します(詳細はここを参考に)
重要なのはgcm_sender_idです
gcm_sender_idには後述する GCM(Google Cloud Messaging)、FCM(Firebase Cloud Messaging)を使用するプロジェクトID(送信者ID)を指定します

Service Worker 登録スクリプト

今回のサンプルではメッセージをペイロードするために必要な情報を表示するようにしています
メッセージをペイロードする必要がない時は、エンドポイントだけでOKです

document.addEventListener('DOMContentLoaded', () => {
    let endpoint = document.querySelector('#subscription-endpoint');
    let key = document.querySelector('#subscription-public-key');
    let auth = document.querySelector('#subscription-auth');
    
    navigator.serviceWorker.register('./service-worker.js');
    navigator.serviceWorker.ready
             .then((registration) => {
                 return registration.pushManager.subscribe({userVisibleOnly: true});
             })
             .then((subscription) => {
                 var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
                 key.value = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
        
                 var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
                 auth.value = rawAuthSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
        
                 endpoint.value = subscription.endpoint;
                 console.log(`GCM EndPoint is: ${subscription.endpoint}`);
             })
             .catch(console.error.bind(console));
}, false);

重要なのは registration.pushManager.subscribe({userVisibleOnly: true})
subscribe することでプッシュ通知を購読するようになります

subscribe には {userVisibleOnly: true} を渡す必要があると記載があったんですが
false で渡しても、引数を渡さなくても通知されるので、ちょっと謎です・・
(何かわかったら追記します)

subscribe が終わったら、公開鍵、鍵生成に使用する乱数、エンドポイントを画面上に表示してます
これらの値をプッシュ通知の際に使用します

Service Worker

/*
    fetch以外は省略。リポジトリのコードを見てください
*/

self.addEventListener('push', (event) => {
    console.info('push', event);
    
    const message = event.data ? event.data.text() : '(・∀・)';
    
    event.waitUntil(
        self.registration.showNotification('Push Notification Title', {
            body: message,
            icon: 'https://kanatapple.github.io/service-worker/push/images/image.jpg',
            tag: 'push-notification-tag'
        })
    );
});

メッセージをペイロードしている場合は、event.data にデータが入っているので
text()を呼ぶとメッセージを取得できます

あとは、showNotificationに必要なデータを渡すと通知が表示されます

プッシュ通知.png

showNotificationシグネチャはこんな感じ

Promise<void> showNotification(DOMString title, optional NotificationOptions options);

dictionary NotificationOptions {
  NotificationDirection dir = "auto";
  DOMString lang = "";
  DOMString body = "";
  DOMString tag = "";
  USVString icon;
  USVString badge;
  USVString sound;
  VibratePattern vibrate;
  DOMTimeStamp timestamp;
  boolean renotify = false;
  boolean silent = false;
  boolean noscreen = false;
  boolean requireInteraction = false;
  boolean sticky = false;
  any data = null;
  sequence<NotificationAction> actions = [];
};

この辺を参考に
https://notifications.spec.whatwg.org/#dom-serviceworkerregistration-getnotificationsfilter
https://developer.mozilla.org/ja/docs/Web/API/ServiceWorkerRegistration/showNotification

プロジェクト登録

プッシュ通知を送信する場合は、Google Developers Console、Firebase Console に
プロジェクトを作成する必要があります
現在は Firebase の使用を推奨してるらしいですが、両方説明しときます

Google Developers Console

https://console.developers.google.com/

1.左上のプロジェクト作成を選択 Googleプロジェクト作成1.png

2.プロジェクト名を設定して、作成 Googleプロジェクト作成2.png

3.APIの有効化 GoogleAPI1.png
ライブラリから「Google Cloud Messaging」を探して

GoogleAPI2.png
有効化します

4.認証情報の作成 Google認証情報作成1.png
プロジェクトを使用するには認証情報が必要らしいので、「認証情報に進む」をクリック

Google認証情報作成2.png
APIを呼び出す場所で「ウェブブラウザ(Javascript)」を選択して

Google認証情報作成3.png
APIキーに適当な名前を付けて「APIキーを作成する」をクリック

完了

Firebase

https://console.firebase.google.com/

Firebase の場合、新規作成するか Google Developers Console に作成したプロジェクトをインポートすることができます
今回は新規作成を説明します

1.プロジェクト作成をクリック
Firebaseプロジェクト作成1.png

2.プロジェクト名を設定して、作成
Firebaseプロジェクト作成2.png

完了

Firebaseで作成すると、プロジェクトを作成すると
自動的にクラウドメッセージング用のAPIと送信者IDを作成してくれるので簡単です

通知を送信

それでは実際に通知を送信してみます
通知には送信者IDとAPIキーが必要なので、Google Developers Console、Firebase で調べます

Google Developers Console

送信者ID

Google送信者ID.png

APIキー

GoogleAPIキー.png

Firebase

送信者IDとAPIキー

Firebase情報.png

manifest.jsonの修正

gcm_sender_idに送信者IDを設定します

{
  "name": "SW Push Notification",
  "short_name": "SW Push Notification",
  "icons": [{
    "src": "https://kanatapple.github.io/service-worker/push/images/image.jpg",
    "sizes": "192x192",
    "type": "image/jpg"
  }],
  "start_url": "./index.html",
  "display": "standalone",
  "gcm_sender_id": "118577160855"
}

送信

今回、送信用のライブラリにweb-pushを使います
push.js を用意します

// push.js
'use strict';

const push = require('web-push');

const GCM_API_KEY = '********';
push.setGCMAPIKey(GCM_API_KEY);

const data = {
    'endpoint': '********',
    'userAuth': '********',
    'userPublicKey': '********'
};

push.sendNotification(data.endpoint, {
    payload:       'push test for service worker',
    userAuth:      data.userAuth,
    userPublicKey: data.userPublicKey,
})
    .then((result) => {
        console.log(result);
    })
    .catch((err) => {
        console.error('fail', err);
    });

GCM_API_KEYに上記で調べたAPIキーを記述します
endpointuserAuthuserPublicKeyにページを表示した時に表示される情報を設定します

設定が終了したら下記コマンドを実行します

node push.js

プッシュ通知.png

こんな感じで通知が来ます

Background Sync編

オフライン時にデータを保持しておいて、オンラインになった時にバックグラウンドでデータを送信する方法を説明します

デモ動画

今回はどういう仕組みで動くのかだけ説明します

ページ

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Sync Test</title>
</head>
<body>
    <h1>Service Worker Sync Test</h1>
    <button id="button">sync</button>
    <script src="./script/main.js"></script>
</body>
</html>

syncボタンがあるだけで、他は特殊なものはありません

Service Worker 登録スクリプト

navigator.serviceWorker.register('./service-worker.js');
navigator.serviceWorker.ready
         .then((registration) => {
             document.getElementById('button').addEventListener('click', () => {
                 // ここでIndexedDBなどにデータを保存しておく
                 // 保存が終わったら、↓を呼ぶ
                 registration.sync.register('sync-test')
                             .then(() => {
                                 console.log('sync registerd');
                             })
                             .catch(console.error.bind(console));
             }, false);
         })
         .catch(console.error.bind(console));

登録については他と一緒です

ボタンをクリックした時にサーバに送信したいデータを IndexedDB などに保存します
Service Worker はブラウザが必要に応じて終了させてしまうので、変数の値を保持しておけないためです
グローバル変数に入れておいてもダメということ)

データの保存が終わったらregistration.sync.registerを呼びます
引数にはタグ名を設定します(このタグ名を IndexedDB に保存するキーとかにしておくといい)

Service Worker

/*
    sync以外は省略。リポジトリのコードを見てください
*/

self.addEventListener('sync', (event) => {
    console.info('sync', event);
    
    // ここでIndexedDBからデータを取得して、サーバに送信する
});

sync が登録されて、Service Worker がサーバと Sync できる状態にあると判断した場合
この sync イベントが発火します
オンライン状態で sync が登録されたら、すぐに sync イベントが発火します
オフライン状態で sync が登録されたら、オンライン状態になった時に sync イベントが発火します

event.tag に sync 登録時に設定したタグ名が設定されています BackgroundSyncTag.png

このタグ名を使って IndexedDB に保存しておいたデータを取得しサーバにデータを送信します
こんな感じで、確実にデータを送信することができます


仕組みはこんな感じです
ここにサンプルがあるので、Developer Toolsを開きながら
オフラインの時にsyncボタン、オンラインの時にsyncボタンを押して挙動を確認してみてください

参考

http://www.html5rocks.com/ja/tutorials/service-worker/introduction/
https://blog.jxck.io/entries/2016-04-24/service-worker-tutorial.html

Generatorsについて

SymbolIteratorと順番に説明してきました
最後にGenerators(yield)について説明します

Generatorsとは

ジェネレータとはイテレータを強力にサポートするものです

例えば、イテレータを使って1から10まで順番に表示する場合

let array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
for (let n of array) {
    console.log(n);
} 

for文使ったり、関数化すればもっと簡潔に書けますが
イテレータを使うことが前提の場合こんな感じで書けます

ジェネレータを使うとスマートに書けます

function* gfn(from, to) {
    while(from <= to) {
        yield from++;
    }
}

var g = gfn(1, 10);
for (var n of g) {
    console.log(n);
}

上記のgfnがジェネレータ関数といい、gfnから得られるオブジェクトをジェネレータといいます

ジェネレータはIterableでもあり、Iteratorでもあります

g[Symbol.iterator]
/*
[Symbol.iterator]() { [native code] }
*/

g.next()
/*
Object {value: 1, done: false}
*/

ジェネレータ関数の書き方

function* gfn() {
    yield 1;
    yield 2;
    yield 3;
}

var g = gfn();
console.log(g.next()); // Object {value: 1, done: false}
console.log(g.next()); // Object {value: 2, done: false}
console.log(g.next()); // Object {value: 2, done: false}
console.log(g.next()); // Object {value: undefined, done: true}

ジェネレータ関数を実行すると、ジェネレータを返すだけで関数自体は実行されません
nextメソッドが呼ばれると、次のyieldまで処理が進み IteratorResultが返され処理が一時停止します
最後まで処理が進むとdoneがtrueになります

ジェネレータの内部状態の変更

nextメソッドに値を渡すと、ジェネレータが一時停止する直前のyieldの結果となります

function* gfn() {
    var counter = 0;
    while (true) {
        var reset = yield counter++;
        if (reset) {
            counter = 0;
        }

    }
}

var g = gfn();
console.log(g.next()); // Object {value: 0, done: false}
console.log(g.next()); // Object {value: 1, done: false}
console.log(g.next()); // Object {value: 2, done: false}
console.log(g.next(true)); // Object {value: 0, done: false}
                           // trueを渡したので、yieldが結果がtrueとなりresetされた
console.log(g.next()); // Object {value: 1, done: false}

yield*

yield*にはIterableなオブジェクトを渡すことが可能です
前回説明しましたが、String、Array、TypedArray、Map、SetなどがIterableなオブジェクトです

function* gfn() {
    yield 1;
    yield* [2, 3, 4];
    yield* 'abc';
}

var g = gfn();
console.log(g.next()); // Object {value: 1, done: false}
console.log(g.next()); // Object {value: 2, done: false}
console.log(g.next()); // Object {value: 3, done: false}
console.log(g.next()); // Object {value: 4, done: false}
console.log(g.next()); // Object {value: "a", done: false}
console.log(g.next()); // Object {value: "b", done: false}
console.log(g.next()); // Object {value: "c", done: false}
console.log(g.next()); // Object {value: undefined, done: true}

yield*に渡された値を反復し、その値によってyieldを呼び出すイメージです 下記のような感じ

function* gfn() {
    var array = [2, 3, 4];
    for (let n of array) {
        yield n;
    }
}

また、冒頭でも書きましたが、ジェネレータはIterableでもあるのでyield*に渡せます

function* g1() {
    yield 1;
    yield* g2();
    yield 5;
}

function* g2() {
    yield 2;
    yield 3;
    yield 4;
}

var g = g1();
console.log(g.next()); // Object {value: 1, done: false}
console.log(g.next()); // Object {value: 2, done: false}
console.log(g.next()); // Object {value: 3, done: false}
console.log(g.next()); // Object {value: 4, done: false}
console.log(g.next()); // Object {value: 5, done: false}
console.log(g.next()); // Object {value: undefined, done: true}

ジェネレータを使った非同期処理

ジェネレータを使うと非同期処理を同期的に記述することができます

function async(gfn) {
    var g = gfn();
    (function next() {
        var result = g.next();
        if (!result.done) {
            result.value.then(next);
        }
    })();
}

function sleep(msec) {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve();
        }, msec);
    });
}

async(function* (){
    console.log('start');
    yield sleep(1000);
    console.log('finish');
});

yieldにPromiseを渡すことで、Promiseがresolveされるまで処理が待機されます
ただ、上の例だとPromiseに限定した使い方しかできないので
coという便利なライブラリを使った方が確実です

まとめ

  • イテレータを強力にサポートするもの
  • Iterableでもあり、Iteratorでもある
  • functionのあとに*(アスタリスク)を記述する
  • yield、yield*を使う
  • 非同期処理を同期的に書ける

まだ自分でもそこまで把握できてない部分があるので、間違ってる箇所があればご指摘ください!
基本的な部分はおさえてあるかなって思ってます

Iteratorについて

前回は「Symbol」について説明しました
今回は「Iterator」についてです
Iterator」、「Iterable」の順番で説明します

Iterator

Iteratorとは・・

  • 次の要素(IteratorResult)へ1つずつアクセスする方法を備えたオブジェクト
  • nextメソッドを持っていて、nextメソッドがIteratorResultを返す
  • IteratorResultはvalueプロパティとdoneプロパティを持っているオブジェクト

文章だとよくわかりませんね汗

上記の定義をコードにするとこんな感じになります

var index = 0;
var array = [1, 2, 3];

var iterator = {}; // Iteratorはオブジェクト
iterator.next = function () { // nextメソッドを持っている
    // valueプロパティとdoneプロパティを持っているIteratorResult
    var iteratorResult =  { value: array[index++], done: index > array.length };
    return iteratorResult; // nextメソッドはIteratorResultを返す
};

コードにするとたったこれだけです
Iteratorは次の要素へのアクセスする方法を提供するだけで 実際の繰り返し処理は外部に任せます

IteratorResultの各プロパティですが

  • value: Iteratorから順番に取り出した値
  • done: Iteratorから値を取り出し終えたかどうか

Iterable

Iterableとは・・

  • 反復処理の挙動が定義されたオブジェクト
  • [Symbol.iterator]メソッドを実行するとIteratorを返すオブジェクト

これも文章だとさっぱりですねw
コードで表現してみます

var iterable = {}; // Iterableはオブジェクト
iterable[Symbol.iterator] = function() { // [Symbol.iterator]メソッドを持ってる
    return iterator; // Iteratorを返す
};

前回説明したSymbol.iteratorを使ってますね
それ以外は簡単なコードです

では実際にIteratorを使ってみます

実践

var obj = {};
obj[Symbol.iterator] = function() {
    var index = 0;
    var array = [1, 2, 3];
    var iterator = {};
    iterator.next = function () {
        var iteratorResult =  { value: array[index++], done: index > array.length };
        return iteratorResult;
    };

    return iterator;
};

var iterator = obj[Symbol.iterator]();
console.log(iterator.next()); // Object {value: 1, done: false}
console.log(iterator.next()); // Object {value: 2, done: false}
console.log(iterator.next()); // Object {value: 3, done: false}
console.log(iterator.next()); // Object {value: undefined, done: true}

/* whileを使った場合
var iterator = obj[Symbol.iterator]();
var iteratorResult;
while (true) {
    iteratorResult = iterator.next();
    if (iteratorResult.done) {
        break;
    }
    console.log(iteratorResult.value);
}
*/

上記を実際に実行してみると、順番に値を取り出すことができます
ただ、この書き方だと値の取り出し方が冗長でめんどくさいですね
そこでES2015で用意されたのがfor (var v of iterable)構文です

上記の繰り返し処理はこんな感じで書けます

for (let v of obj) {
    console.log(v);
}

スッキリしましたね
Iterableの定義さえ守っていれば、自作のオブジェクトもfor (var v of iterable)構文に渡せます

内部でwhileを使った場合の処理が行われています

  1. iteratorを取得
  2. nextメソッドを呼び出し、IteratorResultを取得
  3. doneが偽の場合繰り返し、真なら終了する

ビルトインのString、Array、TypedArray、Map、Setなども反復処理が可能なオブジェクトです

for (let v of [0, 1, 2]) console.log(v);
/*
0
1
2
*/

for (let v of 'abc') console.log(v);
/*
a
b
c
*/

まとめ

そして、ジェネレータ関数で作成されるジェネレータがIterableなオブジェクトです
詳しくは次の回

Symbolについて

ES2015(ES6)で追加されたgenerators(yield)
generatorsを使うにはSymbol、Iteratorを理解しておいた方がいいので順番に説明していきます
今回は「Symbol」についてです

概要

ES2015で追加された新しいプリミティブです
数値でも文字列でも真偽値でもない値ですが
文字列のようにオブジェクトのプロパティのキーとして使えます
またObject.keys、Object.getOwnPropertyNamesでも列挙できないという特徴を持っています

作り方

var s = Symbol();
var s = Symbol('foo'); // 引数に文字列を渡すことでSymbolの説明を追加することができる

念のためtypeof演算子で確認するとSymbolであることがわかります

typeof s; // "symbol"

またnew演算子付きで呼ぶとType Errorとなるので注意

var s = new Symbol(); // Type Error

使い方

Symbolはオブジェクトのプロパティのキーにすることができます

var obj = {};
var s = Symbol();
obj[s] = 'hoge';
console.log(obj[s]); // hoge

ちなみに、ES2015のComputed Property Namesを使えば下記のように書けます

var s = Symbol();
var obj = {
    [s]: 'hoge'
};
console.log(obj[s]); // hoge

シンボルは暗黙的に文字列変換されるわけではないので、下記の場合undefinedになります

console.log(obj[s.toString()]); // undefined

特徴

Symbolは毎回異なるシンボルが作成されます

Symbol('foo') === Symbol('foo'); // false

この特徴を活かして、ES2015は既存のコードに影響がでないように
機能を追加できるようにしたようです

例えば、

function test(obj, value) {
    obj._value = value;
}

上記のようなコードの場合、外部から勝手にobjのプロパティを上書きされて全く別のものになったり
キーがシンボルではないので普通に外部から参照できてしまいます

↓このようにすることで、外部からいじられる心配も、参照される心配もありません

var obj = {};
var _value = Symbol();
function test(obj, value) {
    obj[_value] = value;
}
test(obj, 123);

キーがシンボルなので同じキーを外部から作れないためです

ただし、Object.getOwnPropertySymbolsでシンボルを取得できてしまうので
完全に隠蔽できるわけではないです

console.log(obj[Object.getOwnPropertySymbols(obj)[0]]); // 123

登録

シンボルは毎回異なるシンボルが作成されますが
Symbol.forを使ってシンボルを登録すると共有することが可能です

Symbol.for

引数で与えられたキーでランタイム全体のシンボルレジストリ内に存在しているシンボルを検索し、見つかった場合それを返します。さもなければ、新しいシンボルがこのキーでグローバルシンボルレジストリ内に生成されます。

↓こんな感じでシンボルを共有できます

var s1 = Symbol.for('foo');  // ここでは新しく作成される
var s2 = Symbol.for('foo'); // 作成されているシンボルを参照
var s3 = Symbol('foo'); // これはグローバルシンボルレジストリに生成されてないシンボルの作成
s1 === s2; // true
s1 === s3; // false

Well-Known Symbols

ウェルノウンシンボルという特別なシンボルがあります
「特別なシンボル」と書くと、何かすごい機能があるように思えますが、ごく普通のシンボルです
JavaScriptの内部処理で参照されるシンボルです

Well-Known Symbols

このウェルノウンシンボルの中にSymbol.iteratorというシンボルがあります
IteratorはこのSymbol.iteratorを使うことで実現します

Iteratorについての説明は次の回