PWA入門:手把手教你製作一個PWA應用

MudOnTire發表於2019-06-07

image

簡介

Web前端的同學是否想過學習app開發,以彌補自己移動端能力的不足?但在面對一眾的選擇時很多同學略感迷茫,是學習ios還是android開發?是學習原生開發、混合開發(比如:Ionic),還是使用react native或者flutter這樣的跨平臺框架?而app開發的學習週期長、學習成本高也讓一部分人望而卻步。得益於前端技術的飛速發展、瀏覽器效能的不斷提高,使用網頁技術開發出接近原生體驗的應用得以變為現實,PWA就在這樣的背景下應運而生。可以用自己熟悉的HTML、CSS、Javascript開發出媲美原生app的網站,不僅擁有接近原生app的流暢程度,並且具備一些原生app才有的特性,比如:a. 可以在主屏上安裝應用圖示,b. 離線狀態下訪問,c. 獲取訊息通知,等等。。PWA的出現讓大家看到了希望!

對比原生應用

那PWA和原生應用相比到底有何競爭力呢?我們分別看一下原生應用和PWA的特點:

原生應用:

  • 使用原生SDK和開發工具開發
  • 需要考慮跨平臺,不同系統往往需要獨立開發
  • 需要釋出到應用商店才能下載使用
  • 可以安裝到手機主屏,生成應用圖示
  • 直接執行於作業系統上,訪問系統資源方便
  • 可以離線使用
  • 可以獲取訊息通知

PWA應用:

  • 使用HTML,CSS,JS開發
  • 無需考慮跨平臺,只需要考慮瀏覽器相容性
  • 通過url訪問,無需釋出到應用商店
  • 可以安裝到手機主屏,生成應用圖示
  • 執行於瀏覽器中,可訪問系統資源
  • 可以離線使用
  • 可以獲取訊息通知

可以發現PWA具備了原生應用的主要能力,但是開發流程卻比原生應用更加簡潔:a. html/css/js的群眾基礎更好,開發效率更高;b. 省去了為不同系統開發獨立版本的大量成本;c. 省去了上架到應用市場的繁瑣流程;d. 無需前往應用商店下載,使用者使用起來也更加方便。但是值得注意的是,PWA還是相對比較新的技術,實現規範還有很多調整的空間,部分瀏覽器對PWA的支援也還不完善,但是PWA是一個趨勢,所以現在學習正合適!

本文將通過一個簡單的列子(一個簡單的郵編查詢app)向大家展示PWA的開發流程,專案參考:Traversy Media - Build a PWA With Vue & Ionic4。完成後的效果是 這樣的

建立專案

專案使用Vue + Ionic的組合進行開發。本文主要關注PWA的搭建,因此vue、ionic等技術不做過多描述。使用VSCode的同學,建議安裝Vetur外掛增加開發效率。

1. 首先全域性安裝 @vue/cli

npm install -g @vue/cli

2. 初始化vue專案:

vue create vue-ionic-pwa

3. 因為ionic的路由依賴於vue-router,所以接下來安裝 vue-router

vue add router

4. 安裝 @ionic/vue

npm install @ionic/vue

5. 在 src/main.js 中新增對ionic的引用:

...
import Ionic from '@ionic/vue'
import '@ionic/core/css/ionic.bundle.css'

Vue.use(Ionic)
...

6. 在 src/router.js 中使用 IonicVueRouter 替換預設的vue router:

import Vue from 'vue'
import { IonicVueRouter } from '@ionic/vue';
import Home from './views/Home.vue'

Vue.use(IonicVueRouter)

export default new IonicVueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    }
  ]
})

7. 將 src/App.vue 內容修改為:

<template>
  <div id="app">
    <ion-app>
      <ion-vue-router/>
    </ion-app>
  </div>
</template>

8. 將 src/views/Home.vue 內容修改為:

<template>
  <div class="ion-page">
    <ion-header>
      <ion-toolbar>
        <ion-title>
          ZipInfo
        </ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content class="ion-padding">My App</ion-content>
  </div>
</template>

<script>
export default {
  name: 'home',
  components: {}
}
</script>

最後,我們執行yarn serve看下效果:

1

App功能實現

App主要有三部分組成:1. 搜尋元件,用於輸入郵編並查詢,2. 展示元件,用於展示查詢到的郵編資訊,3. 清除按鈕,用於清除查詢到的郵編資訊

1. 搜尋元件

我們在 src/components 下面新建 ZipSearch.vue 檔案作為郵編搜尋元件,主要邏輯為當使用者輸入一串字元,按下搜尋按鈕,如果輸入合法則觸發get-zip事件,如果不合法則給出提示。

ZipSearch.vue

<template>
  <ion-grid>
    <form @submit="onSubmit">
      <ion-col>
        <ion-item>
          <ion-label>ZipCode:</ion-label>
          <ion-input
            :value="zip"
            @input="zip = $event.target.value"
            name="zip"
            placeholder="Enter US ZipCode"
          />
        </ion-item>
      </ion-col>
      <ion-col>
        <ion-button type="submit" color="primary" expand="block">Find</ion-button>
      </ion-col>
    </form>
  </ion-grid>
</template>

<script>
export default {
  name: "ZipSearch",
  data() {
    return {
      zip: ""
    };
  },
  methods: {
    onSubmit(e) {
      e.preventDefault();
      const zipRegex = /(^\d{5}$)|(^\d{5}-\d{4}$)/;
      const isValid = zipRegex.test(this.zip);
      if (!isValid) {
        this.showAlert();
      } else {
        this.$emit("get-zip", this.zip);
      }
      this.zip = "";
    },
    showAlert() {
      return this.$ionic.alertController
        .create({
          header: "Enter zipcode",
          message: "Please enter a valid US ZipCode",
          buttons: ["OK"]
        })
        .then(a => a.present());
    }
  }
};
</script>

src/views/Home.vue 中引入 ZipSearch 元件,當Home接收到get-zip事件時呼叫 https://www.zippopotam.us 的介面,獲取郵編對應的資訊:

...
    <ion-content class="ion-padding">
      <ZipSearch v-on:get-zip="getZipInfo"/>
    </ion-content>
...

<script>
import ZipSearch from "../components/ZipSearch";

export default {
  name: "home",
  components: {
    ZipSearch
  },
  data() {
    return {
      info: null
    };
  },
  methods: {
    async getZipInfo(zip) {
      const res = await fetch(`https://api.zippopotam.us/us/${zip}`);
      if (res.status == 404) {
        this.showAlert();
      }
      this.info = await res.json();
    },
    showAlert() {
      return this.$ionic.alertController
        .create({
          header: "Not Valid",
          message: "Please enter a valid US ZipCode",
          buttons: ["OK"]
        })
        .then(a => a.present());
    }
  }
};
</script>

我們先看一下搜尋元件的效果:

search

輸入郵編格式錯誤:

invalid input

2. 資訊展示和清除元件

獲取到郵編資訊後我們需要一個展示郵編資訊的元件和一個清除資訊的按鈕,在 src/components 下面新建 ZipInfo.vueClearInfo.vue

ZipInfo.vue

<template>
  <ion-card v-if="info">
    <ion-card-header>
      <ion-card-subtitle>{{info['post code']}}</ion-card-subtitle>
      <ion-card-title>{{info['places'][0]['place name']}}</ion-card-title>
    </ion-card-header>
    <ion-card-content>
      <ion-list>
        <ion-item>
          <ion-label>
            <strong>State:</strong>
            {{info['places'][0]['state']}} ({{info['places'][0]['state abbreviation']}})
          </ion-label>
        </ion-item>
        <ion-item>
          <ion-label>
            <strong>Latitude:</strong>
            {{info['places'][0]['latitude']}}
          </ion-label>
        </ion-item>
        <ion-item>
          <ion-label>
            <strong>Longitude:</strong>
            {{info['places'][0]['longitude']}}
          </ion-label>
        </ion-item>
      </ion-list>
    </ion-card-content>
  </ion-card>
</template>

<script>
export default {
  name: "ZipInfo",
  props: ["info"]
};
</script>

ClearInfo.vue

<template>
  <ion-button color="light" expand="block" v-if="info" @click="$emit('clear-info')">Clear</ion-button>
</template>

<script>
export default {
  name: "ClearInfo",
  props: ["info"]
};
</script>

接著在Home中引入ZipInfoClearInfo元件:

src/views/Home.vue

...
    <ion-content class="ion-padding">
      <ZipSearch v-on:get-zip="getZipInfo"/>
      <ZipInfo v-bind:info="info"/>
      <ClearInfo v-bind:info="info" v-on:clear-info="clearInfo"/>
    </ion-content>
...

import ZipInfo from "../components/ZipInfo";
import ClearInfo from "../components/ClearInfo";

export default {
  name: "home",
  components: {
    ZipSearch, ZipInfo
  },
  methods:{
    ...
    clearInfo(){
      this.info = null;
    }
  }
}

到此,app的主體就完成了,效果如下:

app

實現PWA

我們使用現成的 @vue/pwa 外掛來給我們的app增加PWA的能力。

安裝 @vue/pwa

vue add @vue/pwa

安裝完成後專案中增加了 public/manifest.jsonregisterServiceWorker.js兩個檔案。其中 public/manifest.json 檔案內容如下:

{
  "name": "vue-ionic-pwa",
  "short_name": "vue-ionic-pwa",
  "icons": [
    {
      "src": "./img/icons/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "./img/icons/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "./index.html",
  "display": "standalone",
  "background_color": "#000000",
  "theme_color": "#4DBA87"
}

manifest.json中主要包含app的基本資訊,比如名稱(name)、圖示(icons)、顯示方式(display)等等,是web app能被以類似原生的方式安裝、展示的必要配置。更多的配置項可參考 MDN Web App Manifest

在Chrome瀏覽器控制檯中也可看到app的manifest配置:

manifest

registerServiceWorker.js用於註冊service worker。service worker通俗來講就是在瀏覽器後臺獨立於網頁執行的一段指令碼,service worker可以完成一些特殊的功能,比如:訊息推送、後臺同步、攔截和處理網路請求、管理網路快取等。Service worker之於pwa的意義在於能夠為使用者提供離線體驗,即掉線狀態下使用者依舊能夠訪問網站並獲取已被快取的資料。使用service worker需要HTTPS,並且考慮 瀏覽器相容性

registerServiceWorker.js

import { register } from 'register-service-worker'

if (process.env.NODE_ENV === 'production') {
  register(`${process.env.BASE_URL}service-worker.js`, {
    ready () {
      console.log(
        'App is being served from cache by a service worker.\n' +
        'For more details, visit https://goo.gl/AFskqB'
      )
    },
    registered () {
      console.log('Service worker has been registered.')
    },
    cached () {
      console.log('Content has been cached for offline use.')
    },
    updatefound () {
      console.log('New content is downloading.')
    },
    updated () {
      console.log('New content is available; please refresh.')
    },
    offline () {
      console.log('No internet connection found. App is running in offline mode.')
    },
    error (error) {
      console.error('Error during service worker registration:', error)
    }
  })
}

在Chrome瀏覽器控制檯中也可看到service worker的狀態:

service worker

當然,只註冊service worker還不夠,我們還希望控制service worker的行為,通過在 vue.config.js 中增加相關的配置我們可以設定service worker檔案的名稱、快取邏輯等等。

vue.config.js

module.exports = {
  pwa: {
    workboxPluginMode: 'GenerateSW',
    workboxOptions: {
      navigateFallback: '/index.html', 
      runtimeCaching: [
        {
          urlPattern: new RegExp('^https://api.zippopotam.us/us/'),
          handler: 'networkFirst',
          options: {
            networkTimeoutSeconds: 20,
            cacheName: 'api-cache',
            cacheableResponse: {
              statuses: [0, 200]
            }
          }
        }
      ]
    }
  }
}

更多配置請參考:@vue/cli-plugin-pwaworkbox-webpack-plugin。由於@vue/cli-plugin-pwa生成的service worker只在生產環境生效,所以建議將專案build之後部署到生產環境測試。本文示例使用 github pages進行部署和展示。

到此,將普通web app轉成PWA的工作基本完成,我們部署到線上看下效果:

檔案已被快取用於離線訪問:

app

查詢一個郵編試試,可以發現請求被快取了下來:

api cache

我們接著關掉網路,再查詢剛剛的那個郵編,發現在網路請求失敗之後立即切換用本地快取的資料:

offline

好了,一個簡單的PWA就已經制作完成了。當然PWA的功能遠不止本文所展示的,比如推送、安裝到手機,後續有機會再跟大家分享,謝謝?。

本文demo地址:https://github.com/MudOnTire/...

相關文章