使用 Service Workers提升体验

有一个困扰 web 用户多年的难题——丢失网络连接。即使是世界上最好的 web app,如果下载不了它,也是非常糟糕的体验。如今虽然已经有很多种技术去尝试着解决这一问题。而随着离线页面的出现,一些问题已经得到了解决。但是,最重要的问题是,仍然没有一个好的统筹机制对资源缓存和自定义的网络请求进行控制。

之前的尝试 — AppCache — 看起来是个不错的方法,因为它可以很容易地指定需要离线缓存的资源。但是,它假定你使用时会遵循诸多规则,如果你不严格遵循这些规则,它会把你的APP搞得一团糟。关于APPCache的更多详情,请看Jake Archibald的文章: Application Cache is a Douchebag.

注意:  从Firefox44起,当使用 AppCache 来提供离线页面支持时,会提示一个警告消息,来建议开发者使用 Service workers 来实现离线页面。(bug 1204581.)

Service worker 最终要去解决这些问题。虽然 Service Worker 的语法比 AppCache 更加复杂,但是你可以使用 JavaScript 更加精细地控制 AppCache 的静默行为。有了它,你可以解决目前离线应用的问题,同时也可以做更多的事。 Service Worker 可以使你的应用先访问本地缓存资源,所以在离线状态时,在没有通过网络接收到更多的数据前,仍可以提供基本的功能(一般称之为 Offline First)。这是原生APP 本来就支持的功能,这也是相比于 web app,原生 app 更受青睐的主要原因。

。。。待续

注意事项:

  1. 注册sw.js 是不支持跨域的,这个也是防止XSS攻击等安全角度考虑,目前是不支持,至于以后是否支持,就不得而知了。这样就产生比较头疼的问题,如果你的静态资源是采用cdn的域名,那么主域名下的serviceworker就无法缓存cdn的内容了。但是还有曲线救国的方法。

如果你的js和css等是放在CDN下面的,你可以新建一个html文件,用来注册serviceworker,然后把这个文件的response的header设置成

"Content-Type", "application/x-javascript; charset=utf-8"

那么浏览器就会按照js来解析,但是不影响注册SW。示例:

<script>
    if ('serviceWorker' in navigator) {
        window.addEventListener('load', function() {
            setTimeout(function(){
                navigator.serviceWorker.register('./sw.htm', {
                    scope: './index'
                }).then(function(registration){
                    console.log('register service worker success', registration);

                    window.TES && TES.timeStamp('onload_sw');
                }).catch(function(registration){
                    console.log('register service worker fail', registration);

                    window.TES && TES.timeStamp('onload_no_sw');
                });
            }, 0);
        });
    }else{
        window.TES && TES.timeStamp('onload_no_sw');
    }
</script>

关于fetch

1. 如果服务器支持 CORS, 则在客户端设置相应的 `Access-Control-Allow-Origin` 即可得到数据。

let myHeaders = new Headers({
    'Access-Control-Allow-Origin': '*',
    'Content-Type': 'text/plain'
});
fetch(url, {
    method: 'GET',
    headers: myHeaders,
    mode: 'cors'
}) .then((res) => {
    // TODO 
}) 

服务端是否支持可以问下后端同事,如果是自己承担后端编码,则可以直接自己设置,比如如果是 PHPer, header 一下响应头即可。

header("Access-Control-Allow-Origin: *"); 

2. 如果服务器不支持 CORS, 则不用使用 Fetch Api 了。

`Fetch Api` 必须后台支持 `CORS`,。咱们可以试下,如果你设置了 `{mode: ‘ cors ‘}`(一般用于请求API),就会报错告诉你你请求的服务器不支持 `CORS`。大概会报下面的错误:

Response to preflight request doesn’t pass access control check: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

如果设置成 `{mode: ‘ no-cors ‘}` (一般用于请求图片等静态资源), 虽然不会报错,但是结果会 返回被标记了为 `opaque` 的数据,表明你没有权限访问。

这种情况下可以使用 JSONP

示例

{{ $http.headers.set("Content-Type", "application/x-javascript; charset=utf-8") }}

// 在主页面引用以下代码
//<script>
    // if ('serviceWorker' in navigator) {
    //     window.addEventListener('load', function() {
    //         setTimeout(function(){
    //             navigator.serviceWorker.register('./sw.htm', {
    //                 scope: './index'
    //             }).then(function(registration){
    //                 console.log('register service worker success', registration);

    //                 window.TES && TES.timeStamp('onload_sw');
    //             }).catch(function(registration){
    //                 console.log('register service worker fail', registration);

    //                 window.TES && TES.timeStamp('onload_no_sw');
    //             });
    //         }, 0);
    //     });
    // }else{
    //     window.TES && TES.timeStamp('onload_no_sw');
    // }
// </script>
if (!CacheStorage.prototype.match) {
    CacheStorage.prototype.match = function (request) {
        var matchRequestInCache = function (key) {
            return caches.open(key).then(function (cache) {
                return cache.match(request);
            });
        };

        var matchRequestInCaches = function (keys) {
            return matchRequestInCache(keys.shift()).then(function (res) {
                if (res) {
                    return res;
                } else {
                    if (keys.length) {
                        return matchRequestInCaches(keys);
                    }
                }
            })
        };

        if (!(request instanceof Request)) {
            request = new Request(request);
        }

        return caches.keys().then(function (keys) {
            return matchRequestInCaches(keys);
        });
    }
}

if (!Cache.prototype.addAll) {
    Cache.prototype.addAll = function (requests) {
        var cache = this;
        return Promise.all(requests.map(function (request) {
            if (!(request instanceof Request)) {
                request = new Request(request);
            }
            return fetch(request.clone()).then(function (res) {
                if (res && res.status === 200) {
                    return cache.put(request, res);
                }
            });
        }));
    }
    Cache.prototype.add = function (request) {
        return this.addAll([request]);
    }
}

var CACHE_NAME = 'tm/city-pavilion/v1';
var IMAGE_CACHE_NAME = CACHE_NAME + '/img';

var IMAGE_CACHE_SIZE = 80;
var ORIGIN_URL = '页面url';
var urlsToCache = [
    ORIGIN_URL,
    'https://g.alicdn.com/xxx'
];
/**
 * 这里我们 新增了一个 install 事件监听器,接着在事件上接了一个ExtendableEvent.waitUntil()  方法——这会确保Service Worker 不会在 waitUntil() 里面的代码执行完毕之前安装完成。
在 waitUntil()内,我们使用了 caches.open() 方法来创建了一个叫做 v1 的新的缓存,将会是我们的站点资源缓存的第一个版本。它返回了一个创建缓存的 promise,当它 resolved的时候,我们接着会调用在创建的缓存示例上的一个方法  addAll(),这个方法的参数是一个由一组相对于 origin 的 URL 组成的数组,这些 URL 就是你想缓存的资源的列表。
如果 promise 被 rejected,安装就会失败,这个 worker 不会做任何事情。这也是可以的,因为你可以修复你的代码,在下次注册发生的时候,又可以进行尝试。
当安装成功完成之后, service worker 就会激活。在第一次你的 service worker 注册/激活时,这并不会有什么不同。但是当  service worker 更新 (稍后查看 Updating your service worker 部分) 的时候 ,就不太一样了。
 localStorage 跟  service worker 的 cache 工作原理很类似,但是它是同步的,所以不允许在  service workers 内使用。
注意: IndexedDB 可以在  service worker 内做数据存储。
 */
this.addEventListener('install', function (event) {
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then(function (cache) {
                return cache.addAll(urlsToCache);
            })
    );
});

/**
 * 下一步是激活。当 service worker 安装完成后,会接收到一个激活事件(activate event)。 onactivate 主要用途是清理先前版本的service worker 脚本中使用的资源
 */
this.addEventListener('activate', function (event) {
    event.waitUntil(
        caches.keys().then(function (cacheNames) {
            return Promise.all(
                cacheNames.map(function (cacheName) {
                    if (cacheName !== CACHE_NAME && cacheName !== IMAGE_CACHE_NAME) {
                        return caches.delete(cacheName);
                    }
                })
            );
        })
    );
});

/**
 * 每次任何被 service worker 控制的资源被请求到时,都会触发 fetch 事件,这些资源包括了指定的 scope 内的文档,和这些文档内引用的其他任何资源(比如 index.html 发起了一个跨域的请求来嵌入一个图片,这个也会通过 service worker 。)你可以给 service worker 添加一个 fetch 的事件监听器,接着调用 event 上的 respondWith() 方法来劫持我们的 HTTP 响应,然后你用可以用自己的魔法来更新他们。
 */
var deletingImgs = [];
this.addEventListener('fetch', function (event) {
    //试验期间只作用域天猫超市每日鲜页面
    var isAssets = /https:\/\/(g\.alicdn\.com|g-assets\.daily\.taobao\.net)\/(\?\?)?(security|sd|js|tm|mui|hybrid|secdev|zebra-pages)\//.test(event.request.url);

    var isOrigin = function (url) {
        var urlObj = url ? new URL(url) : {};
        return (urlObj.origin + urlObj.pathname) === ORIGIN_URL;
    };

    var isAssetsFromOrigin = isAssets && isOrigin(event.request.referrer);

    var isImgFromOrigin = !isAssets && function () {
        var isImg = event.request.url.indexOf('gw.alicdn.com') !== -1 || event.request.url.indexOf('img.alicdn.com') !== -1;
        return isImg && isOrigin(event.request.referrer);
    }();

    var isOriginHtml = !isAssets && !isImgFromOrigin && isOrigin(event.request.url);

    if (isAssetsFromOrigin || isImgFromOrigin) {
        event.respondWith(
            // caches.match(event.request) 允许我们对网络请求的资源和 cache 里可获取的资源进行匹配,查看是否缓存中有相应的资源。这个匹配通过 url 和 vary header进行,就像正常的 http 请求一样。
            caches.match(event.request).then(function (response) {
                if (response) {
                    //response.clone().text().then(function(res){
                    //    console.log('%s response from cache: ', event.request.url, res.length);
                    //});
                    return response;
                }

                //var fetchRequest = new Request(event.request, {
                //    credentials: "omit",
                //    mode: 'cors'
                //});
                return fetch(event.request.url).then(function (response) {
                    if (response && response.status === 200) {
                        var responseToCache = response.clone();

                        if (isAssetsFromOrigin) {
                            caches.open(CACHE_NAME).then(function (cache) {
                                //var res2 = response.clone();
                                //res2.text().then(function(res){
                                //    console.log('%s to cache: ', event.request.url, res.length);
                                //});
                                cache.put(event.request, responseToCache);
                            });
                        } else if (isImgFromOrigin) {
                            caches.open(IMAGE_CACHE_NAME).then(function (cache) {
                                cache.keys().then(function (keys) {
                                    var normalItems = keys.filter(function (item) {
                                        return deletingImgs.indexOf(item.url) === -1;
                                    });
                                    //console.log('keys length is %d, normal keys length is %d', keys.length, normalItems.length);
                                    var imgsNeedDelCount = normalItems.length - IMAGE_CACHE_SIZE;
                                    var processor = [];
                                    if (imgsNeedDelCount) {
                                        for (var i = 0, j = imgsNeedDelCount; i < j; i++) {
                                            (function (index) {
                                                var firstNormalItem = normalItems[index];
                                                deletingImgs.push(firstNormalItem.url);
                                                processor.push(cache.delete(firstNormalItem).then(function () {
                                                    deletingImgs = deletingImgs.filter(function (item) {
                                                        return item !== firstNormalItem.url;
                                                    })
                                                }));
                                            })(i)
                                        }
                                    }
                                    return Promise.all(processor);
                                }).then(function () {
                                    cache.put(event.request, responseToCache);
                                });
                            });
                        }
                    }

                    return response;
                }).catch(function (ex) {
                    console.warn('request %s fail', event.request.url, ex);
                })
            }).catch(function (ex) {
                return caches.match(event.request).then(function (response) {
                    if (response) {
                        return response;
                    }
                    return fetch(event.request);
                })
            })
        )
    } else if (isOriginHtml) {
        var urlObj = event.request.url ? new URL(event.request.url) : {};
        var url = urlObj.origin + urlObj.pathname;
        event.respondWith(
            caches.match(url).then(function (response) {
                if (response) {
                    return response;
                }
                return fetch(event.request);
            })
        );
    }
});

 

参考文献:

  1.  mdn 使用  Service  Workers: https://developer.mozilla.org/zh-CN/docs/Web/API/Service_Worker_API/Using_Service_Workers
  2. fetch : https://fetch.spec.whatwg.org/#http-new-header-syntax
  3. 深入了解 Service Worker:https://zhuanlan.zhihu.com/p/27264234

未经允许不得转载:皓眸大前端 » 使用 Service Workers提升体验

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址