service-worker(从入门到放弃)

286 阅读7分钟

放弃是不可能放弃的,也就只能靠它混口饭吃。不使用点新东西,怎么制造Bug啊,没有Bug,怎么混饭吃啊。

灵魂发问,它是什么,有啥用

Service workers 本质上充当Web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步API。

上文咬文嚼字的官方定义,我假装视而不见。在我看来(不看推送通知和后台同步API,本篇文章不牵涉到它们),它大概有两点意思:

其一,它其实就是一个强有力的资源请求拦截器+代理服务器。因为sw(service-worker简写)注册安装好了之后,下次进入它控制的页面,它就开始拦截你的一些资源请求,伪造好像是从服务器返回给你的样子(从Network的Size中可以看到该资源是否来自 ServiceWorker)。

其二,它是一个 Web Worker,独立于JavaScript线程和UI线程。

sw的用处的话,如果不是因为PWA(它码生最主要的意义)而使用它的话,就只有缓存资源这个作用了。那它跟 HTTP 缓存有啥区别呢?如果用了HTTP缓存,它还有使用的意义吗?(可以看下这篇文章,简略了解一下 HTTP缓存

其实看他们的区别或意义,可以从离线应用/伪app和web网页对比去看,sw是pre-cache的,当你访问下一级页面时,cache已经存在了,那么页面加载就像app一样快速。而 HTTP 缓存,你没到那个页面,是不会给你缓存那个页面的资源的。在体验上,或许sw会优胜。

sw还可配置怎样去使用缓存:

  1. Cache only (只用Cache)
  2. Network only (只用网络)
  3. Cache, falling back to network (有Cache用Cache,无Cache使用Network,离线应用优先使用的方法)
  4. Cache & network race (Cache 与网络 谁快就使用谁,但如果存在Cache而网络较快的话,感觉会浪费了Cache数据)
  5. Network, falling back to cache (优先网络,网络失败了,使用Cache,需要等待网络失败才用Cache,等待时间过长)
  6. Cache then network (页面先使用Cache数据,等待网络数据回来后,更新页面数据和Cache数据)
  7. Generic fallback (有Cache用Cache,无则使用网络,网络也不行,则返回默认展示的数据)
  8. ServiceWorker-side templating(ssr类页面,使用模版html+数据整合为请求的页面返回)

灵活强大,可配置(结合)各种姿势,任君折腾(放过我吧)。

// 以下例子均来自 https://jakearchibald.com/2014/offline-cookbook

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // make sure promises are all promises
    promises = promises.map(p => Promise.resolve(p));
    // resolve this promise as soon as one resolves
    promises.forEach(p => p.then(resolve));
    // reject if all promises reject
    promises.reduce((a, b) => a.catch(() => b))
      .catch(() => reject(Error("All failed")));
  });
};

self.addEventListener('fetch', (event) => {
  // Cache only
  event.responseWith(cache.match(event.request));
  
  // Network only
  event.respondWith(fetch(event.request));
  
  // Cache, falling back to network
  event.respondWith(async function() {
    const response = await caches.match(event.request);
    return response || fetch(event.request);
  }());
  
  // Cache & network race 
  event.respondWith(
    promiseAny([
      caches.match(event.request),
      fetch(event.request)
    ])
  );
  
  //  Network, falling back to cache 
  event.respondWith(async function() {
    try {
      return await fetch(event.request);
    } catch (err) {
      return caches.match(event.request);
    }
  }());
  
  // Cache then network
 	// 这个需要做些配置,并不是简单地在 fetch 事件中做的
  
  // Generic fallback
  event.respondWith(async function() {
    // Try the cache
    const cachedResponse = await caches.match(event.request);
    if (cachedResponse) return cachedResponse;

    try {
      // Fall back to network
      return await fetch(event.request);
    } catch (err) {
      // If both fail, show a generic fallback:
      return caches.match('/offline.html');
      // However, in reality you'd have many different
      // fallbacks, depending on URL & headers.
      // Eg, a fallback silhouette image for avatars.
    }
  }());
  
  // ServiceWorker-side templating
  const requestURL = new URL(event.request);
  event.responseWith(async function() {
    const [template, data] = await Promise.all([
      caches.match('/article-template.html').then(r => r.text()),
      caches.match(requestURL.path + '.json').then(r => r.json()),
    ]);
    return new Response(renderTemplate(template, data), {
      headers: {'Content-Type': 'text/html'}
    })
  }());
  
})
// Cache then network
async function update() {
  // Start the network request as soon as possible.
  const networkPromise = fetch('/data.json');

  startSpinner();

  const cachedResponse = await caches.match('/data.json');
  if (cachedResponse) await displayUpdate(cachedResponse);

  try {
    const networkResponse = await networkPromise;
    const cache = await caches.open('mysite-dynamic');
    cache.put('/data.json', networkResponse.clone());
    await displayUpdate(networkResponse);
  } catch (err) {
    // Maybe report a lack of connectivity to the user.
  }

  stopSpinner();

  const networkResponse = await networkPromise;

}

async function displayUpdate(response) {
  const data = await response.json();
  updatePage(data);
}

那配置了 HTTP 缓存,还有使用它的意义吗?

这个就见仁见智吧,假如为了体验更好或者其他目的,而不在乎初始就下载资源的话,首选。假如,页面加载也还过得去,你又爱护头发的话,可以选择放过它,也放过自己。

入门首解,用什么姿势去用它

使用条件:网站支持HTTPS,且支持ServiceWorker。

然后,开始摆姿势:

  1. 注册(register)

    window.addEventListener('load', () => {
      if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('/sw.js').then(function(registration) {
          console.log('成功安装', registration.scope);
        }).catch(function(err) {
          console.log(err);
        });
      }
    })
    
  2. 安装(install, Cache.addAll

    const cacheName = 'my-site-cache-v1';
    this.addEventListener('install', function(event) {
      event.waitUntil(
        caches.open(cacheName).then(function(cache) {
          return cache.addAll([
            '/sw-test/',
            '/sw-test/index.html',
            '/sw-test/style.css',
            '/sw-test/app.js',
            '/sw-test/image-list.js',
            '/sw-test/star-wars-logo.jpg',
            '/sw-test/gallery/',
            '/sw-test/gallery/bountyHunters.jpg',
            '/sw-test/gallery/myLittleVader.jpg',
            '/sw-test/gallery/snowTroopers.jpg'
          ]);
        })
      );
    });
    

    cache.addAll 方法接受一个URL数组,检索它们,并将生成的response对象添加到给定的缓存中。 在检索期间创建的request对象成为存储的response操作的key。add / addAll 方法都不会缓存 Response.status 值不在200范围内的响应

    其中有个 skipWating 方法是跳过等待阶段,直接淘汰旧 ServiceWorker 尝试激活新的 ServiceWorker。

    // 这段代码是框架配置生成的,理解即可
    self.addEventListener("install", function (e) {
        e.waitUntil(caches.open(cacheName).then(function (c) {
            return setOfCachedUrls(c).then(function (t) {
                return Promise.all(Array.from(urlsToCacheKeys.values()).map(function (a) {
                    if (!t.has(a)) {
                        var e = new Request(a, {credentials: "same-origin"});
                        return fetch(e).then(function (e) {
                            if (!e.ok) throw new Error("Request for " + a + " returned a response with status " + e.status);
                            return cleanResponse(e).then(function (e) {
                                return c.put(a, e)
                            })
                        })
                    }
                }))
            })
        }).then(function () {
            return self.skipWaiting()
        }))
    })
    
  3. 激活(activate 事件)

    self.addEventListener("activate", function (e) {
        var t = new Set(urlsToCacheKeys.values());
        e.waitUntil(caches.open(cacheName).then(function (a) {
            return a.keys().then(function (e) {
                return Promise.all(e.map(function (e) {
                    if (!t.has(e.url)) return a.delete(e)
                }))
            })
        }).then(function () {
            return self.clients.claim()
        }))
    })
    

    cache.delete 方法查询request为key的 Cache 条目,如果找到,则删除该 Cache 条目并返回resolve为true的 Promise。 如果没有找到,则返回resolve为false的 Promise

    假如是首次安装sw,那么在安装成功后,就会激活。

    如果存在旧的sw,那么新的sw会进入waiting状态,直到旧sw影响的页面全部关闭后(只有一个页面的时候,刷新那个页面,新的sw并不会接管,仍然是旧的sw 劫持着),新的sw才会接管。

    在激活这里删除旧的 ServiceWorker 缓存,不好在安装阶段就删除 ServiceWorker 缓存,防止旧的 ServiceWorker 还起作用,导致拿不到对应的缓存,而报错。

  4. fetch事件

    self.addEventListener('fetch', (event) => {
      event.respondWith(async function() {
        const cache = await caches.open('mysite-dynamic');
        const cachedResponse = await cache.match(event.request);
        const networkResponsePromise = fetch(event.request);
    
        event.waitUntil(async function() {
          const networkResponse = await networkResponsePromise;
          await cache.put(event.request, networkResponse.clone());
        }());
    
        // Returned the cached response if we have one, otherwise return the network response.
        return cachedResponse || networkResponsePromise;
      }());
    });
    

    cache.put 方法 允许将键/值对添加到当前的 Cache 对象中。它将覆盖先前存储在匹配请求的cache中的任何键/值对。

    cache.match 方法, 返回一个 Promise 解析为(resolve to)与 Cache 对象中的第一个匹配请求相关联的Response 。如果没有找到匹配,Promise 解析为 undefined

  5. 更新

    Service Worker 控制着整个App的离线缓存,为了避免它缓存自己导致死锁无法升级,sw文件本身的缓存是交给 HTTP来缓存的。但 sw文件不是页面的脚本资源,即程序入口的执行点可能不是页面,而是它本身,它的更新是由浏览器触发的(所以可能在Network看不到该文件,且多次.register()同一个 ServiceWorker 不会出发更新),且sw的更新触发时机比较特殊:

    • 导航到作用域的一个页面

    • 更新 push (通知推送) 和 sync (后台同步API) 等功能事件,除非在前 24 小时内已进行更新检查。

    • 调用 .register()仅在 Service Worker 网址已发生变化时。

    当触发更新后,浏览器会检查sw文件的缓存设置。当需要访问服务器拿新文件的时候,浏览器将会在后台尝试重新下载sw文件(定义sw的文件),对比它们的字节(这个对比不是很清楚,建议读者深入去了解),如果有差异,就视为sw更新了。

    如果sw文件已经24小时或超过24小时没有更新,那么当触发更新时,会强制更新。意味着最坏的情况下,ServiceWorker会每天更新一次。

    也可以手动更新

    const version = '1.0.1';
    navigator.serviceWorker.register('/sw.js').then(reg => {
        if (localStorage.getItem('sw_version') !== version) {
            reg.update().then(() => localStorage.setItem('sw_version', version));
        }
    });
    

    最好还是不要缓存入口文件

  6. 卸载(unregister)

    if ('serviceWorker' in navigator) {
      const registrations = await navigator.serviceWorker.getRegistrations();
      const unregisterPromises = registrations.map(registration => registration.unregister());
      const allCaches = await caches.keys();
      const cacheDeletionPromises = allCaches.map(cache => caches.delete(cache));
      await Promise.all([...unregisterPromises, ...cacheDeletionPromises]);
    }
    
    

解惑

  1. 为啥使用Cache,而不是localStorage等?

    因为cache是异步的,贴合 Worker,localStorage是同步的。

注意点

  1. 入口文件不可以被缓存,否则版本更新会出现问题,版本更新了,但有可能会使用到ServiceWorker中的入口文件,从而导致版本仍然是旧版本。

参考

jakearchibald.com/2014/offlin…

juejin.cn/post/684490…

developer.mozilla.org/zh-CN/docs/…

developer.mozilla.org/zh-CN/docs/…

mp.weixin.qq.com/s/IhMyaCYrT…

harttle.land/2017/04/10/…