service worker 运行在独立的线程,可以拦截和处理网络请求,以实现离线缓存、推送通知和后台同步等功能。具体可以看MDN的介绍
// 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([
'/'
])),
)
})
// sw.js
// 拦截响应数据
self.addEventListener('fetch', (e) => {
const response = requestHandler(e) // 经过requestHandler处理后,返回缓存的内容或服务器响应的数据
try {
e.respondWith(response) // 将数据返回给主线程相应的请求
} catch (err) {
console.error(err, e.request, response);
}
})
// 处理请求
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
}
// 拦截请求,返回新请求地址
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
}
// 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)
}
})
// 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)
})
// sw.js更新的时候,需要页面重新加载才会执行新的ServiceWorker脚本
let refreshing = false
navigator.serviceWorker?.addEventListener('controllerchange', () => {
if (refreshing) return
refreshing = true
location.reload()
})
// 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') || ''
})
})
}
/**
* 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())
}]
}])
}
})