用PWA来做一个天气应用

781 阅读5分钟

什么是PWA

Progressive Web App, 简称 PWA,是提升 Web App 的体验的一种新方法,能给用户原生应用的体验。

上面是Lavas给出的简单描述,PWA给我的个人感觉来说,就是将原生APP的体验搬到浏览器上,包括例如在桌面上生成icon,快速启动,可以离线使用,可以推送消息,总而言之,它需要具备原生APP的所有特点,并在此基础上更进一步。

PWA应用的技术

  • Service Worker
  • cacheStorage
  • Push Notification(本应用并未涉及)

应用演示

这个应用的想法源自于Your First Progressive Web App,在学习PWA的时候看到这个demo,不过里面的代码基本看不懂。。。所以就用了它的UI设计和ICON,自己开始慢慢摸索。

源码地址

在线浏览地址(因为没有对PC样式进行适配,所以请在手机端或chrome手机调试模式打开,chrome点击"添加到主屏幕"即可添加桌面ICON)

项目结构

  • images(存放图片)
  • fontSet.js(根据不同手机设置全局字体)
  • index.html(主页面)
  • main.js(主程序)
  • manifest.json(控制桌面启动程序(icon)的添加)
  • reset.css(清空默认样式)
  • skeleton(骨架屏,用于加载时过渡)
  • style.css(主样式)
  • sw.js(service worker进程)

缓存App Shell

首先,我们需要在主进程注册一个service worker

// 注册service worker
window.addEventListener('DOMContentLoaded', function() {
    SW.register();
})

const SW = {
    // 注册
    register() {
        // 检测serviceWorker是否可用
        if('serviceWorker' in navigator) {
            navigator.serviceWorker.register('./sw.js')
            .then(function() {
                console.log('Service Worker Registered');
            })
            .catch(function() {
                console.log('Service Worker failed');
            })
        }
    }
}

如果注册成功了,service worker就正式开始工作了,service worker的生命周期可以简单描述为

serviceWorker(第一次安装或者发生变化) -> install -> activite

所以此时就进入了第一步“install“,在install过程中缓存我们离线时需要的文件,这里包括像页面本身,页面的样式,还有主程序等,但是需要注意的是,千万不能把sw.js也缓存进去,不然你的应用就永远更新不了了

const CACHENAME = 'weather-' + 'v4';
const PATH = '/pwaTest';
const fileToCache = [
    PATH + '/',
    PATH + '/index.html',
    PATH + '/main.js',
    PATH + '/fontSet.js',
    PATH + '/skeleton.js',
    PATH + '/reset.css',
    PATH + '/style.css',
    PATH + '/images/icons/delete.svg',
    PATH + '/images/icons/plus.svg',
    PATH + '/images/partly-cloudy.png',
    PATH + '/images/wind.png',
    PATH + '/images/cloudy_s_sunny.png',
    PATH + '/images/cloudy.png',
    PATH + '/images/clear.png',
    PATH + '/images/rain.png',
    PATH + '/images/fog.png',
    PATH + '/images/icons/icon-32x32.png',
    PATH + '/images/icons/icon-128x128.png',
    PATH + '/images/icons/icon-144x144.png',
    PATH + '/images/icons/icon-152x152.png',
    PATH + '/images/icons/icon-192x192.png',
    PATH + '/images/icons/icon-256x256.png'
];

self.addEventListener('install', e => {
    console.log('Service Worker Install');
    e.waitUntil(
        caches.open(CACHENAME).then(function (cache) {
            self.skipWaiting();
            console.log('Service Worker Caching');
            return cache.addAll(fileToCache);
        })
    )
})

注:e.waitUntil()是等待一个Promise对象执行完毕后。

当install完毕后,进入activate进程,我们需要清理掉旧的缓存,不然浏览器还会使用旧缓存,并且旧缓存也占用着空间。

self.addEventListener('activate', function (event) {
    event.waitUntil(
        // 遍历 caches 里所有缓存的 keys 值
        caches.keys().then(function (cacheNames) {
            return Promise.all(
                cacheNames.map(function (NAME) {
                    if (NAME != CACHENAME) {
                        // 删除掉除了当前版本之外的缓存文件
                        return caches.delete(NAME);
                    }
                })
            );
        })
    );
});

fetch

最开始的时候我并不知道fetch的作用是什么,只是跟着示例代码敲,程序就能正常运行,我原以为service worker是类似与vue组件间的emit和on一样,通过message来传递数据。

但service worker并不是,简而言之,service worker是通过监听fetch事件来拦截所有的请求,并对其进行处理,这些请求包括服务器对服务器本地文件的请求(index.html,style.css,main.js),也包括了对外部接口的调用(GET,POST请求)

self.addEventListener("fetch", function(e) {
    // e是所有的请求,没调用一次请求,都会被fetch监听到
    e.respondWith(caches.match(e.request).then(function(response) {
          // 在caches中寻找response,如果有就返回response,如果没有,就继续fetch(即不在本地查找,调用接口去查找)
          return response || fetch(e.request);
    }));
});

至此,这个应用的初步框架已经搭建起来了。

离线功能

我们知道,PWA的一大特点就是可以离线使用,所以我们需要对我们的代码进行一些处理。

self.addEventListener('fetch', e => {
    e.respondWith(
        caches.match(e.request).then(function (res) {
            if (res) {
                if (e.request.url.indexOf(self.location.host) !== -1) {
                    // 同源
                    return res;
                } else {
                    // 离线状态
                    if (!navigator.onLine) {
                        return res;
                    } else {
                        return fetch(e.request).then((response) => {
                            let responeClone = response.clone();
                            let responeClone_2 = response.clone();
                            responeClone_2.json().then(data => {
                                caches.open(CACHENAME).then(cache => {
                                    cache.put(e.request, responeClone);
                                })
                            }).catch(e => {
                                console.log(e);
                            })
                            return response;
                        })
                    }
                }
            }
            // 远程js文件
            if (e.request.url.indexOf('https://pv.sohu.com/cityjson?ie=utf-8') !== -1) {
                return fetch(e.request);
            }
            return fetch(e.request).then((response) => {
                let responeClone = response.clone();
                let responeClone_2 = response.clone();
                responeClone_2.json().then(data => {
                    caches.open(CACHENAME).then(cache => {
                        cache.put(e.request, responeClone);
                    })
                }).catch(e => {
                    
                })
                return response;
            }).catch(e => {
                
            })
        })
    )
})

大体思路如下:

  • 无论在线/离线,App shell的部分(即同源)总是可以离线获取的,所以直接return res即可
  • 在线时,对于天气的情况,直接调用远程接口(不使用本地缓存),这样做的原因,是因为天气需要实时更新,每次访问时都应该是最新的天气情况,如果调用一次以后就直接去调用缓存的数据,那天气的情况就永远停留在第一次了
  • 离线时,直接或者已经缓存好的天气情况即可

骨架屏

当用户网络情况不佳时,页面信息的加载需要一些时间,但是如果直接留给用户一个大白屏,用户不知道应用是否还是正常工作,所以需要一个过渡,来缓解用户的焦躁,那我们就需要用到骨架屏了

skeleton.js

const Skeleton = {
    Render(key, type, row) {
        let rows = (function() {
            let temp = '';
            for (let i = 0; i < row; i ++) {
                temp += '<p class="item"></p>'
            }
            return temp;
        })();

        let model = (function() {
            let temp = '';
            switch (type) {
                case 'normal':
                    temp = `
                        <div class="card preload mg" id="${key}">
                            ${ rows }
                            <p class="item" style="width: 4rem"></p>
                        </div>
                    `
                    break;
                case 'title':
                    temp = `
                        <div class="card preload mg" id="${key}">
                            <p class="head"></p>
                            ${ rows }
                            <p class="item" style="width: 4rem"></p>
                        </div>
                    `
                    break;
                default:
                    break;
            }
            return document.createRange().createContextualFragment(temp);
        })();

        return model;
    }
}

export default Skeleton

main.js

    import skeleton from './skeleton.js'
    
    // 新建一个新的城市天气实例
    buildNewCity(city) {
        if (navigator.onLine) {
            // 骨架屏先行渲染
            let preModel = (function() {
                return skeleton.Render(city, 'title', 3);
            })();
            let container = document.getElementById('container');
            container.appendChild(preModel);
        }

        this.getInfoNow(city);
    }
    
    // 在线时才需要骨架屏
    if (navigator.onLine) {
        let container = document.getElementById(this.name);
        setTimeout(() => {
            container.classList.remove('preload');
            container.innerHTML = "";
            container.appendChild(model);

            // 为删除键绑定事件
            document.getElementById('delete_' + this.name).addEventListener('click', function() {
                WEATHERINFO.deleteCity(_this.name);
            })
        }, 200);
    } else {
        let card = document.createElement('div');
        card.classList = ['card ' + 'mg'];
        card.id = _this.name;
        card.appendChild(model);

        let container = document.getElementById('container');
        container.appendChild(card);

        // 为删除键绑定事件
        document.getElementById('delete_' + this.name).addEventListener('click', function() {
            WEATHERINFO.deleteCity(_this.name);
        })
    }

当用户添加城市时,先将骨架屏放到页面上,再进行fetch操作,当fetch完成后,再将fetch到的数据给对应的div中。

chrome slow 3G下的测试

这就是这个小应用的几个技术要点,可以猛戳这里观看演示,源码

如果文章对你有用的话,可以点个star哦