PWA:Service Worker实现离线访问、域名失效启用备用域名......

介绍下Service Worker

service worker 运行在独立的线程,可以拦截和处理网络请求,以实现离线缓存、推送通知和后台同步等功能。具体可以看MDN的介绍

怎么使用Service Worker

1、注册脚本

// useServiceWorker.ts
navigator.serviceWorker?.register('/sw.js').then(() => {
    console.log('Service Worker Registered')
}// sw.js
// 注册后触发
self.addEventListener('install', (e) => {
  self.skipWaiting()
  e.waitUntil(
    caches.open(storeName).then((cache) => cache.addAll([
      '/'
    ])),
  )
})

2、接管网络请求

// sw.js
// 拦截响应数据
self.addEventListener('fetch', (e) => {
  const response = requestHandler(e) // 经过requestHandler处理后,返回缓存的内容或服务器响应的数据
  try {
    e.respondWith(response) // 将数据返回给主线程相应的请求
  } catch (err) {
    console.error(err, e.request, response);
  }
})

3、利用接管网络请求的功能,实现本地数据持久化,为离线访问提供服务

// 处理请求
const requestHandler = async (e) => {
  return new Promise( async (resolve, reject) => {
    const newRequest = await fetchInterceptor(e)
    if (['image', 'document', 'script'].includes(e.request.destination) || e.request.url.slice(-4) === '.css' || e.request.url.slice(-3) === '.js' ) {
      // 匹配缓存中请求,命中则返回缓存内容,否则获取服务器内容
      resolve(await caches.match(e.request) ||  await getData(newRequest))
    } else if (newRequest.url !== e.request.url) {
      resolve( await fetch(newRequest) )
    } else {
      resolve( await fetch(newRequest) )
    }
  })
}

// 发起请求, 缓存请求成功的get请求
async function getData(request) {
  const response = await fetch(request)
  const cache = await caches.open(storeName)
  response.clone()?.status === 200 && cache.add(request, response.clone())
  return response
}

4、利用接管网络请求的功能,拿到请求报文后,将过期域名修改为备用域名,返回新的请求报文

// 拦截请求,返回新请求地址
async function fetchInterceptor(e) {
  let { url, method, headers, destination } = e.request
  if (
    !parkedDomain
    || url.indexOf(location.origin) === -1
    || ['image', 'document', 'script'].includes(destination)
    || e.request.url.slice(-4) === '.css'
    || e.request.url.slice(-3) === '.js'
  ) return e.request;

  url = url.replace(location.origin, parkedDomain)
  const init = { method, headers, mode: 'cors' }
  let params
  if (e.request.clone().body) {
    params = await e.request.clone().json()
    init.body = JSON.stringify(params)
  }
  const newRequest = new Request(url, init)
  return newRequest
}

5、主线程传数据到Service Worker

// useServiceWorker.ts
// 发送信息给Service Worker
navigator.serviceWorker?.controller?.postMessage({
  'parkedDomain': localStorage.getItem('parkedDomain') || ''
})
      
// sw.js
let currentClient = null // 通讯对象,后续需要这个对象传信息到主线程
// 接收主线程传来的信息
self.addEventListener("message", (e) => {
  if (!currentClient) {
    currentClient = await self.clients.get(e.source.id)
  }
})

6、Service Worker传数据到主线程

// sw.js
let currentClient = null // 通讯对象, 前面接收过主线程的数据,已经获取到通讯的对象
currentClient?.postMessage({ event, payload: JSON.stringify(payload) })

// useServiceWorker.ts
// 接收来自Service Worker的信息
navigator.serviceWorker?.addEventListener('message', (e) => {
  const event: 'setItem' | 'reload' | 'doLog' = e.data.event
  const payload: string = e.data.payload
  if (!event || !payload) return;
  handlerMap[event]?.(payload)
})

7、更新sw.js

// sw.js更新的时候,需要页面重新加载才会执行新的ServiceWorker脚本
let refreshing = false
navigator.serviceWorker?.addEventListener('controllerchange', () => {
  if (refreshing) return
  refreshing = true
  location.reload()
})

完整的代码如下

1、useServiceWorker.ts

// useServiceWorker.ts
if (!window.NativeBridge && process.env.NODE_ENV === 'production') {
  let refreshing = false
  // sw.js更新的时候,需要页面重新加载才会执行新的ServiceWorker脚本
  navigator.serviceWorker?.addEventListener('controllerchange', () => {
    if (refreshing) return
    refreshing = true
    location.reload()
  })
  navigator.serviceWorker?.register('/sw.js').then(() => {
    const handlerMap = {
      setItem: (payload: any) => {
        const obj = JSON.parse(payload)
        localStorage.setItem(obj.key, obj.value)
      },
      reload: () => {
        location.reload()
      },
      doLog: (data: any) => {
        // useMultiDoLog(JSON.parse(data))
      }
    }
    navigator.serviceWorker?.addEventListener('message', (e) => {
      const event: 'setItem' | 'reload' | 'doLog' = e.data.event
      const payload: string = e.data.payload
      if (!event || !payload) return;
      handlerMap[event]?.(payload)
    })
    
    navigator.serviceWorker?.controller?.postMessage({
      'parkedDomain': localStorage.getItem('parkedDomain') || '',
      'domainFileUrl': localStorage.getItem('domainFileUrl') || ''
    })
  })
}

2、sw.js

/** 
 * storeName在打包时自动更改,要调整注意修改ReplaceInFileWebpackPlugin插件配置 
*/
const storeName = 'video-store-SERVICEWORKER_NAME' 
/** 当前域名不可以则开启请求拦截,使用可用域名替换当前域名 */
let parkedDomain = ''
/** 可用域名列表文件 */
let domainFileUrl = ''
 /** 通讯对象 */
let currentClient = null

self.addEventListener('install', (e) => {
  self.skipWaiting()
  e.waitUntil(
    caches.open(storeName).then((cache) => cache.addAll([
      '/'
    ])),
  )
})
// 清除失效缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.filter((cacheName) => {
          return cacheName !== storeName
        }).map((cacheName) => {
          return caches.delete(cacheName)
        })
      )
    })
  )
})
// 拦截响应数据
self.addEventListener('fetch', (e) => {
  const response = requestHandler(e)
  try {
    e.respondWith(response)
  } catch (err) {
    console.error(err, e.request, response);
  }
})

// 拦截请求,返回新请求地址
async function fetchInterceptor(e) {
  let { url, method, headers, destination } = e.request
  if (
    !parkedDomain
    || url.indexOf(location.origin) === -1
    || ['image', 'document', 'script'].includes(destination)
    || e.request.url.slice(-4) === '.css'
    || e.request.url.slice(-3) === '.js'
  ) return e.request;

  url = url.replace(location.origin, parkedDomain)
  const init = { method, headers, mode: 'cors' }
  let params
  if (e.request.clone().body) {
    params = await e.request.clone().json()
    init.body = JSON.stringify(params)
  }
  const newRequest = new Request(url, init)
  return newRequest
}

// 处理请求
const requestHandler = async (e) => {
  return new Promise( async (resolve, reject) => {
    const newRequest = await fetchInterceptor(e)
    if (['image', 'document', 'script'].includes(e.request.destination) || e.request.url.slice(-4) === '.css' || e.request.url.slice(-3) === '.js' ) {
      // 匹配缓存中请求,命中则返回缓存内容,否则获取服务器内容
      resolve(await caches.match(e.request) ||  await getData(newRequest))
    } else if (newRequest.url !== e.request.url) {
      resolve( await fetch(newRequest) )
    } else {
      resolve( await fetch(newRequest) )
    }
  })
}

// 接收主线程传来的信息
self.addEventListener("message", async (e) => {
  if (!currentClient) {
    currentClient = await self.clients.get(e.source.id)
  }
  if('parkedDomain' in  e?.data) {
    e?.data?.parkedDomain && (parkedDomain = e?.data?.parkedDomain)
    e?.data?.domainFileUrl && (domainFileUrl = e?.data?.domainFileUrl)
    parkedDomainStrategy()
  }
})

// 发起请求, 缓存请求成功的get请求
async function getData(request) {
  const response = await fetch(request)
  const cache = await caches.open(storeName)
  response.clone()?.status === 200 && cache.add(request, response.clone())
  return response
}

// 备用域名策略
async function parkedDomainStrategy() {
  sendMessageToClient('doLog', { event: 'xxxx' }) // 可用域名上报日志
  const isValid = await verifyDomain(parkedDomain)
  if(isValid) {
    sendMessageToClient('doLog', { event: 'xxxx' }) // 可用域名上报日志
    parkedDomain && sendMessageToClient('reload', {})
  } else {
    failureDomainHandler()
  }
}
// 校验域名是否有效
function verifyDomain (domain) {
  return new Promise((resolve, reject) => {
    fetch(domain + '/xxxx.txt')
    .then(res => { 
      resolve(res.ok)
    })
    .catch(err => {
      resolve(false)
    })
  })
}
// 域名失效处理
function failureDomainHandler () {
  // 请求html文件失败,则域名不可用,使用可用域名去替换请求地址
  fetch(domainFileUrl)
    .then(stream => {
      stream.text().then( async (text) => {
        let domians = text.split('\n')
        for (const index in domians) {
          const isValid = await verifyDomain(domians[index])
          if (isValid) { 
            parkedDomain = domians[index]
            sendMessageToClient('doLog', {value:''}) // 可用域名上报日志
            sendMessageToClient('setItem', { key: 'parkedDomain', value: parkedDomain || '' })
            sendMessageToClient('reload', {})
            break 
          }
        }
      })
    })
}

// 发送消息到主线程
async function sendMessageToClient(event, payload) {
  currentClient?.postMessage({
    event,
    payload: JSON.stringify(payload)
  })
}

补充

由于文件会缓存到本地,所以每次更新代码都需要新建一个缓存空间存储更新后的代码,旧的缓存空间则作废
这里用到的是replace-in-file-webpack-plugin插件,在打包时,将SERVICEWORKER_NAME替换为当前的时间戳

// sw.js
const storeName = 'video-store-SERVICEWORKER_NAME' 

// vue.config.js
const ReplaceInFileWebpackPlugin = require('replace-in-file-webpack-plugin');
module.exports = defineConfig({
	configureWebpack: {
		 new ReplaceInFileWebpackPlugin([{
          dir: 'dist',
          test: /\.js$/,
          rules: [{
            search: /SERVICEWORKER_NAME/g,
            replace: JSON.stringify(new Date().getTime())
          }]
        }])
	}
})

你可能感兴趣的:(PWA,前端)