September 21, 2019

msn-cache实现与service worker生命周期简述

msn-cache是最近弄的一个小工具,用于处理memory,storage,netword数据,msn也就是这三个单词的首字母。获取缓存数据处理过程的优先级是memory => storage => network。至于怎样降级获取数据的详细处理,可以往下看一下

msn-cache 处理过程

image.png

在降级处理过程,主要有两个难点:

  1. cache 的算法处理
  2. storage数据处理

cache 的算法处理

目前支持的算法有LRUFIFO那么怎么验证这个算法是正确的?LRU算法相对比较复杂,这边是通过在leetcode提交验证,若通过leetcode的验证,则表明通过。leetcode 题目地址,但是leetcode暂时不支持ts语法,因此要把ts转换为js,再粘贴到leetcode运行。LRU的运行效率对比同语言还算不错。FIFO算法在leetcode没有找到相关题目,暂时只能手工测试,后续考虑加上单元测试。 image.png

简单使用例子:

1
2
3
4
5
6
7
8
9
10
11
12
import MCache from 'msn-cache'

const mc = new MCache({
  name: 'LRU', // FIFO
  capacity: 2,
  storage: 'sessionStorage'
})

// get value from cache
mc.get('key', () => { /* request function */ })
// put new key-value in cache
mc.put('key', value)

在实例化的时候,只需要指定cache算法的名称则可,而不同算法的具体实现对于实例调用mc.get,mc.put都是透明的。

算法说明:

storage处理

在工具的使用,可以选用localStorage,sessionStorage两种。由于这两种的存储方法不能存储对象类型的数据。在put data in storage的过程中,会执行JSON.stringify(data)方法对数据进行转换;而从storage提取数据的过程,会执行JSON.parse(string)方法转换:

1
2
3
4
5
6
let storage = 'sessionStorage'
let data = {
    foo: 'foo'
}
window[storage].setItem(JSON.stringify(data))
data = JSON.parse(window[storage].getItem())

因此,如果数据无法转换,则不能够使用,例如: JSON.parse(undefined),不能转换成功;不过程序目前已针对undefined的情况进行处理。

storage的注意点

localStorage相对sessionStorage很好理解,只要用户不删除,是一直存在的;但有时候缓存的数据会失效;而msn-cachelocalStorage能够拿到旧数据,就不会发出新的请求更新数据;针对这种情况,目前提供clearStorage用于清除存在storage的数据,用户可以选择需要的时候,对storage数据进行清理,具体API可以看API文档

sessionStorage 的“会话”适用范围

sessionStorage的定义是,在会话过程中,一直存在;那么怎么定义“会话”?

用户在初始状态: A;sessionStorage数据为:foo: 'foo'

  1. 用户在页面A按下刷新,属于同一个会话,此时能够拿到foo: 'foo'
  2. 用户点击页面的超链接B,该链接新开tab打开了,此时B页面,能够拿到foo: 'foo';原有属于A的session storage数据也带过去了;
  3. 在B页面,新增数据bar: 'bar';此时切换到A页面tab,A页面不能拿到bar: 'bar'
  4. 此时,用户在A页面,按住ctrl,点击超链接C,该链接从新tab打开,C页面不能拿到foo: 'foo'
  5. 此时,用户主动新开tab,同样打开A页面,命名为A2,A2页面不能获取foo: 'foo'

注意,页面之间跳转均属于同一个域名

由此,我们可以知道sessionStorage的规则:

  1. 在同一个tab刷新,属于同一个session,storage数据不会清除
  2. 用户正常操作(指不受组合键等处理新页面)打开新页面,新页面会把源页面的storage数据带过去;但新页面的storage数据不会同步到源页面
  3. 用户主动新开tab或者按住ctrl键等方式新开tab,不属于同一个session,相当于重新初始化

因此用户需要根据上述规则,需要根据情况是否对数据进行清除或初始化。

network

说了这么久,好像都没有跟network有关系?实际上msn-cache确实没有对network做缓存,只是把network请求当成最后一个找不到缓存的获取途径,使用的方法如下:

1
2
3
4
let mc = new MCache({/* ... */})
mc.get('key', () => {
    return fetch('/api/foo')
})

mc.get方法第二参数是一个方法,如果从storage也获取不到数据,就会执行该方法,获取该方法返回的值,作为mc.get的数据。从network数据缓存,我们延伸到service worker

service worker

service worker是什么?

是worker的一种,不能操作DOM,与主线程的通信是利用postMessage方法;能够拦截请求,配合cache storage,能够做到离线缓存。

在应用部署service worker,需要应用使用https,若在本地开发使用localhost则不需要

下面看一个简单的例子:页面中注册一个service worker,这个sw在安装成功之后,主动缓存/cat.svg;当页面请求/dog.svg的时候,返回/cat.svgcaches是指cacheStorage,与localStorage这些属于storage类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 例子1:
// index.js
navigator.serviceWorker.register('/sw.js').then(function(registration) {
  console.log('register successful.')
}, function(err) {
  console.log('register failed.', err)
});
setTimeout(() => {
    let img = new Image()
    img.src = '/dog.svg'
    img.onload = () => {
        document.getElementById('img').src = '/dog.svg'   
    }
}, 3000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// sw.js
self.addEventListener('install', event => {
    // sw 已安装
    console.log('sw installed')
    event.waitUntil(caches.open('v1').then(cache => {
        cache.add('/cat.svg')
    }))
})
self.addEventListener('activate', event => {
    // sw 已激活
    console.log('sw activated')
})

// 拦截fetch事件
self.addEventListener('fetch', event => {
    const url = new URL(event.request.url);

    // 拦截dog.svg请求,对这个请求返回cat.svg
    if (url.pathname == '/dog.svg') {
        event.respondWith(caches.match('/cat.svg'));
    }
});

clients.claim

从上述例子中,当初始值进入页面的时候,经过3秒之后(假定sw已经安装并激活完毕),会去请求/dog.svg,但是会发现,这个时候返回的图片,还是/dog.svg原图,并不是我们在fetch事件拦截,预计返回的/cat.svg;而当我们重新刷新页面的时候,请求/dog.svg才按照预计返回/cat.svg内容。

这是因为第一次进入页面,sw激活完毕的时候,并没有马上拿到应用的控制权,在请求/dog.svg中,请求并没有被拦截到,这是sw默认的处理方式;而如果第一次sw加载即马上控制页面,则需要调用clients.claim()方法。

1
2
3
4
5
6
self.addEventListener('activate', event => {
    // 主动获取控制权
    clients.claim()
    // sw 已激活
    console.log('sw activated')
})

注:我看到很多人添加 clients.claim() 作为样板文件,但我自己很少这么做。该事件只是在首次加载时非常重要,由于渐进式增强,即使没有 Service Worker,页面也能顺利运行。

service worker 生命周期

在主线程监听到sw的变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
navigator.serviceWorker.register('/sw.js').then(registration => {
    var serviceWorker;
    if (registration.installing) {
        console.log('init state is installing')
        serviceWorker = registration.installing;
    } else if (registration.installed) {
        serviceWorker = registration.installed;
        console.log('init state is installed')
    } else if (registration.waiting) {
        serviceWorker = registration.waiting;
        console.log('init state is waiting')
    } else if (registration.active) {
        serviceWorker = registration.active;
        console.log('init state is active')
    }
    serviceWorker.addEventListener('statechange', function (e) {
        console.log('状态变化为', e.target.state)
    });
}).catch(err => {
    console.log('register sw error', err)
})

在主线程中,监听首次加载sw的状态变化为: image.png

而在sw中,首次加载的主要事件变化是两个:

image.png

  1. installed sw已经安装成功,通常会在这个时候主动缓存资源,而且installed事件在一个sw中,只会触发一次
  2. activate sw 已经正常激活,准备好去处理fetch等事件

那么,如果有需要更新到新的sw,状态是怎么发生变化?例如sw.js资源发生变化,然后主动刷新页面,新的sw中的install事件会进行触发,而activate事件不会马上触发;那么再次刷新呢?还是不会触发新的sw的activate事件,而在主线程的log中,发现初始状态为waiting;无论刷新多少次,都是这样子...

事实上,如果sw对应的文件发生变化,旧的worker还是会继续控制浏览器;同时新的worker会进行加载,加载完毕后(sw线程的install事件触发)不会把原有的控制权抢过去,而是处于waiting,这个时候拿到的缓存,也是旧worker对应的缓存;fetch事件的拦截也是旧worker处理。

等待旧的worker控制的所有页面都被关闭(是的,你没看错,是控制的所有页面),待下次打开该页面的时候,新的worker才会接收新的控制权;这样子的处理逻辑,是保证只有一个worker控制资源。

picMadeByMatools.gif

图片来源:https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle

那么,如果真的有需求是需要获取到新的worker的时候,马上让新的worker控制所有缓存数据,怎么处理?

skipWaiting

使用skipWaiting()方法跳过等待;从上面的描述可以知道,新的worker安装完毕之后,就会进入等待状态,当所有由旧worker控制的页面退出则接受控制;这个阶段,新的worker一直处于waiting状态;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const cacheVersion = 'v2'
const urlCache = [
  '/cat.svg',
  '/dog.svg',
  '/cow.svg'
]
self.addEventListener('install', event => {
    // 跳过等待,安装完毕之后马上由新的worker控制
    self.skipWaiting()
    // 缓存新版本数据
    event.waitUntil(caches.open('v2')
        .then(cache => cache.addAll(urlCache)))
})
self.addEventListener('activate', event => {
  // 新的worker激活完成
    event.waitUntil(caches.keys().then(keys => {
        return Promise.all(keys.map(item => {
            // 删除旧版本数据
            if (cacheVersion !== item) {
                return caches.delete(item)
            }
        }))
  }))
})

参考文章