a house in the woods

Hi, nice to meet you.

  1. 1. 需求背景及简述
  2. 2. 解决过程
    1. 2.1. 完整下载视频
    2. 2.2. 缓存视频
      1. 2.2.1. 视频不能缓存的原因
      2. 2.2.2. 缓存方案
      3. 2.2.3. 方案实现
  3. 3. 总结

需求背景及简述

视频配音,需要根据开始和结束时间分段播放视频,涉及视频跳至某一时刻的操作。另因需要在播放视频的时候同时进行录音,所以希望视频在播放的时候最好不要出现等待数据加载的情况,这样就要求在答题开始前完整下载整个视频。

解决过程

完整下载视频

以前有个误解,只要设置videopreload属性,它便会自动将整个视频下载下来。但事实并非如此,目前的方法是通过XHR请求视频资源。

这样出现了一个小问题,原来我们的视频资源是没有对页面所在的域名增加跨域允许的,此问题通过联系CDN服务商增加Access-Control-Allow-Origin响应头解决。

值得注意的是,我们经常看到Access-Control-Allow-Origin会设为*(允许所有域名),但如果是只对特定域名的话,除了域名以外还需要添上其协议,如www.abc.com需要改为https://www.abc.com

缓存视频

视频不能缓存的原因

视频是可以下载下来了,但却发现每次重新进入页面时,资源并没有被缓存,都是需要重新请求下载,这无疑会影响载入速度。尝试寻找原因,先看看视频的响应头是否有缓存控制相关的(是有的),实际在浏览器中也能正确地被缓存。

与图片,JS等资源可以正确地被缓存相比,那视频为什么没被webview缓存呢?
这里可以提出两个假设:

  1. webview默认只会缓存特定扩展名的资源
  2. webview默认对缓存资源的大小有限制

验证假设1,我们可以修改请求资源的url来改为其他扩展名,然后再代理到实际的文件。使用whistle可以配置如下的规则

1
cdn-resource.abc.com/acpf/data/upload/bt/2021/07/22/60f934011029c.png cdn-resource.abc.com/acpf/data/upload/bt/2021/07/22/60f934011029c.mp4

经试验,假设1不成立。

验证假设2,我们先将资源下载到本地,然后使用ffmpeg截取视频的头一秒作为新视频,示例命令如下:

1
ffmpeg -i 60f934011029c.mp4 -ss 00:00:00 -t 00:00:01 out.mp4

然后配置whistle如下规则
1
cdn-resource.abc.com/acpf/data/upload/bt/2021/07/22/60f934011029c.mp4 file://D:\MyDownloads\out.mp4 resCors://*

经试验,资源可被正确缓存,假设2成立

缓存方案

我们也许可以让本地开发调整相关配置来开放此限制,但这个问题并非必须寻求本地开发的协助,web端也有本地缓存的方案,所以不妨仅使用web端的能力来解决问题。

网上查找相关的方案,主要可以找到这几个技术ApplicationCacheservice worker, indexedDB

ApplicationCacheAndroid的文档和caniuse上都有说这是个废弃的API,并将会在新版本移除,推荐使用新标准的service worker

查阅caniuse,发现service worker至少在safari 11.3上才获得支持。注意service worker本身并不是缓存API cache本身,chrome43版本以后页面环境也可以使用cache API了,但service worker原生支持对请求的劫持,所以不妨折腾多一点,把service worker也引入进来。前面说到service workercache有兼容问题,所以需要找到一个fallback的方案。

这里就到indexedDB登场了,indexedDB不像localstorage只能储存字符串,还可以存储对象(例如存储blob对象),indexedDBsafari 8就已支持。

这两种离线存储的空间限制,从网上的资料上看,大概各个浏览器的实现都不太一样,但基本只是跟设备的物理储存挂钩。

方案实现

以下是service wokerindexedDB的实现代码

  1. service worker涉及页面service worker的注册和service workerworker主逻辑的编写。
    以下代码基本来源自create-react-app中的模板代码

注册service worker的逻辑,提供registerunregister两个方法。值得注意的是,navigator.serviceWorker.register的第二个参数,scope我们设为了/scope是相对于当前页面地址的路径,默认为空,即当前地址,表示它可控制以这个地址为前缀的页面。

如果是/开头表示直接相对于域名。例如scope/hello,域名为www.abc.com,那么则表示它能控制www.abc.com/hello为前缀的页面。所以这里设为/可以让我们控制这个域名下的所有页面。

但这里会有个问题,我原来没有想到的。servie worker只要注册了就会常驻在浏览器中的,所以即便在不是当时注册了它的页面下,只要在其scope的控制范围,它也会生效,这样就有可能不必要地在其他页面中出现。

另外有两个值得注意的点service worker需要放在与页面相同的域名下。scope的设置范围默认是不能高于service workerurl地址。

例如www.abc.com/resource/service-worker.js,它不能设为/, 至多只能为/resource。要解除这个限制,可以在返回service worker文件的响应头中添加service-worker-allowed: : /。现在是在nginx中添加的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
// serviceWorker.ts
type Config = {
onUpdate?: (r: ServiceWorkerRegistration) => any
onSuccess?: (r: ServiceWorkerRegistration) => any
}

const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
)

function registerValidSW(swUrl: string, config: Config) {
navigator.serviceWorker
.register(`${swUrl}`, { scope: '/' })
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing
if (installingWorker == null) {
return
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
)

// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration)
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.')
}
}
}
}
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration)
}
})
.catch(error => {
console.error('Error during service worker registration:', error)
})
}

function checkValidServiceWorker(swUrl: string, config: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' }
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type')
if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload()
})
})
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config)
}
})
.catch(() => {
console.log('No internet connection found. App is running in offline mode.')
})
}

export function register(config: Config) {
if ('serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.VUE_APP_SW_PUBLIC_PATH || '', window.location.href)
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return
}
const swUrl = `${process.env.VUE_APP_SW_PUBLIC_PATH}/service-worker.js`
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config)

// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
)
})
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config)
}
return true
}
}

export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister()
})
}
}

完成这次缓存任务的worker的逻辑代码。代码中引入workbox系列的工具库。

clientsClaim是通知注册此worker的页面,自己将开始控制页面

registerRoute是注册拦截请求的处理函数,处理函数的返回类型是Promise<response>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/// <reference lib="webworker" />
/* eslint-disable no-restricted-globals */

// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.

import { clientsClaim } from 'workbox-core'
import { registerRoute } from 'workbox-routing'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheFirst } from 'workbox-strategies'

declare const self: ServiceWorkerGlobalScope

clientsClaim()

// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA

// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ request }) => !!request.headers.get('app-cache'),
// Customize this strategy as needed, e.g., by changing to CacheFirst.
new CacheFirst({
cacheName: 'media',
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({
maxEntries: 100
})
]
})
)

// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})

  1. 下面是indexedDB的实现,indexedDB因为API比较冗余,所以一般会用Dexie.js这个库,不过因为我之前已经在其他项目写过indexedDBwrapper,所以这里我用自己的实现(地址)。

另外我还专门为这次的需求,封装了个DBCache类,也仿照workbox实现了maxEntries地址

使用indexedDB的时候,需要将XHRresponseType设为blobBlob可以通过URL.createObjectURL转为临时地址供video使用

总结

在解决问题的过程中,再次熟悉了service workerindexedDB的使用,是一次充实的学习经历。另:本文忽略了service workerindexedDBAPI的讲解,读者可以搜索相关的文档补足。

This article was last updated on days ago, and the information described in the article may have changed.