介绍下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.requestif (!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 paramsif (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.eventconst payload: string = e.data.payloadif (!event || !payload) return;handlerMap[event]?.(payload)
})
7、更新sw.js
// sw.js更新的时候,需要页面重新加载才会执行新的ServiceWorker脚本
let refreshing = false
navigator.serviceWorker?.addEventListener('controllerchange', () => {if (refreshing) returnrefreshing = truelocation.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) returnrefreshing = truelocation.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.eventconst payload: string = e.data.payloadif (!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 = nullself.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.requestif (!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 paramsif (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())}]}])}
})