背景
公司有一个项目需要在前端渲染地图然后生成图片,这个功能是同事已经基于leaflet和modern-screenshot实现了的。但是因为生成数据量大时间长(经过优化后耗时还是很长)所以需要在页面隐藏之后也能够正常运行生成。
初步排查
如标题所示,代码在切换标签页或者页面被遮挡的一瞬间就不继续执行了,所以可以肯定的是,这是由浏览器的节能策略引起的问题。
先尝试关闭浏览器的性能模式和节能模式看是否有效(真这么简单就不会写这篇文章了),没有效果。
再仔细捋一下业务代码有没有什么可能导致卡顿的地方,也没有找到可疑点。
上调试工具
利用控制台的性能工具去分析是什么导致了卡顿。在开始运行生成代码之前先打开左上角性能工具的录制,然后运行代码切换几次标签页,点击停止就录制完成了。 录制结果: 可以看到每次切换页面显示状态都有很明显的暂停截面 放大截面,点开下面的事件日志查看页面隐藏后最后执行的是什么代码。 是leaflet执行的一个requestAnimFrame函数。而在页面显示之后最先执行的也是这个函数。
这个requestAnimFrame函数是leaflet封装的requestAnimattionFrame api。在这贴一下mdn对这个api的描述吧: 最后一句话已经写的很清楚了,页面看不见的时候是不会执行这个api回调的。
解决问题
我们需要找一个隐藏页面不会被限制的api来替代requestAnimationFrame。第一时间想到setTimeout,但是很遗憾,在页面隐藏之后会将setTimeout执行延迟到至少一秒。
查找资料后找到在Web Worker内执行的setTimeout就不会被限制。所以可以用一个Web Worker内执行的setTimeout来重写leaflet的requestAnimFeame方法。
将leaflet的仓库clone下来,切换到当前使用版本的提交,以免引起其他版本差异问题。
找到src/core/Util.js所在的requestAnimFrame函数。注册和取消又是用的另外两个requestFn和cancelFn函数,所以只需要重写requestFn和cancelFn这两个函数就行了。
目前我使用的版本是做了兼容处理的,但是在前两个月的一次提交里就已经去除了兼容,只会使用requestAnimationFrame。当然这对我来说不重要了,反正我都是要重写这两个方法。 重写之后的代码如下:
javascript
const createWorkerTimeoutUrl = () => {
const jsBlob = new Blob([`
...worker代码
`],
{
type: 'application/javascript'
})
return URL.createObjectURL(jsBlob)
}
const timeoutWorker = new Worker(createWorkerTimeoutUrl())
const deferFnMap = new Map()
timeoutWorker.onmessage = ((e) => {
const fn = deferFnMap.get(e.data)
deferFnMap.delete(e.data)
fn && fn()
})
let timeoutId = 0
export var requestFn = () => {
const id = ++timeoutId
timeoutWorker.postMessage(['ADD', id])
deferFnMap.set(id, fn)
return id
}
export var cancelFn = (id) => {
timeoutWorker.postMessage(['DEL', id])
deferFnMap.delete(id)
}
因为leaflet是打包成单js文件,为了方便就将worker的代码写在了模板字符串里面,再使用blob和URL.createObjectURL生成链接。
一般的worker代码都是引入另一个文件,但是这样使用字符串也是可以的。
worker的完整代码如下:
javascript
onmessage = (e) => {
const [type, id] = e.data
if (type === 'ADD') timeoutDefer(id)
else if (type === 'DEL') clearTimeoutDefer(id)
}
const timersIDMap = new Map()
let lastTime = 0
function timeoutDefer(id) {
const time = +new Date()
const timeToCall = Math.max(0, 16 - (time - lastTime))
lastTime = time + timeToCall
const nativeTimerID = setTimeout(() => {
timersIDMap.delete(id)
postMessage(id)
}, timeToCall)
timersIDMap.set(id, nativeTimerID)
}
function clearTimeoutDefer (id) {
const nativeTimerID = timersIDMap.get(id)
if (!nativeTimerID) return true
clearTimeout(nativeTimerID)
timersIDMap.delete(id)
}
原理很简单:
- 在worker内通过onmessage接收主线程的操作类型和操作id,创建对应的timeout或者删除对应的timeout。
- 等待timeout回调之后通过postMessage通知主线程完成了哪个id的延时任务并且从Map集合删除。(这里settimeout的延迟时间用的是leaflet原先的raf兼容模拟,也就是大概16ms执行一次,不用纠结到底是多少)
- 主线程在创建timeout将回调函数存到Map集合内,通过一个自增id来将回调函数和原生setTimeout的id关联。onmessage函数收到worker通知后取出对应id的回调函数执行。 代码改好之后就可以打包替换到项目内使用了。
打包替换
执行npm install和npm build。将打包好的代码放入项目内,把leaflet的引入改成手动打包的代码。 再次运行就可以在页面隐藏时继续执行了。
只是执行过程中偶尔还是会有些慢,因为页面上有其他短延时的setTimeout,上面说过setTimeout在页面隐藏时是会被限制在至少1秒的延时。
为了一劳永逸直接将全局的setTimeout替换为worker执行。
全局替换
在项目下新建一个workerTimeout.js文件
javascript
const createWorkerTimeoutUrl = () => {
const jsBlob = new Blob([`
onmessage = (e) => {
const { type, id, ms } = e.data
if (type === 'ADD') timeoutDefer(id, ms)
else if (type === 'DEL') clearTimeoutDefer(id)
}
const timersIDMap = new Map()
function timeoutDefer(id, ms) {
const nativeTimerID = setTimeout(() => {
postMessage(id)
timersIDMap.delete(id)
}, ms)
timersIDMap.set(id, nativeTimerID)
}
function clearTimeoutDefer (id) {
const nativeTimerID = timersIDMap.get(id)
if (!nativeTimerID) return
clearTimeout(nativeTimerID)
timersIDMap.delete(id)
}
`],
{
type: 'application/javascript'
})
return URL.createObjectURL(jsBlob)
}
const timeoutWorker = new Worker(createWorkerTimeoutUrl())
const deferFnMap = new Map()
timeoutWorker.onmessage = ((e) => {
const fn = deferFnMap.get(e.data)
deferFnMap.delete(e.data)
fn && fn()
})
let timeoutId = 0
const setFn = (fn, ms) => {
const id = ++timeoutId
timeoutWorker.postMessage({
type: 'ADD',
id: id,
ms: ms
})
deferFnMap.set(id, fn)
return id
}
const clearFn = (id) => {
timeoutWorker.postMessage({
type: 'DEL',
id: id
})
deferFnMap.delete(id)
}
export const setTimeoutSource = window.setTimeout
export const clearTimeoutSource = window.clearTimeout
window.setTimeout = setFn
window.clearTimeout = clearFn
原理还是一样的,只是延迟时间不是模拟帧数时间,而是从函数入参获取。主线程和worker传值方式从数组改为了对象传值,因为增加了一个延迟时间参数。
然后就是把原先的setTimeout和clearTImeout增加了后缀Source后导出出去,以防其他需要。 在main.js或者其他入口文件头部将这个文件引入执行,一定要先于其他依赖引入。
javascript
import '@/utils/workerTimeout.js'
刚刚修改的leaflet的代码可以把worker的代码删除了,只保留setTimeout调用就好了。
javascript
let lastTime = 0;
// fallback for IE 7-8
function timeoutDefer(fn) {
var time = +new Date(),
timeToCall = Math.max(0, 16 - (time - lastTime));
lastTime = time + timeToCall;
return window.setTimeout(fn, timeToCall);
}
export var requestFn = timeoutDefer;
export var cancelFn = function (id) { window.clearTimeout(id); };
总结
其实解决过程并没有这么轻松。中间还尝试过用浏览器的visibilitychange事件将待执行的raf回调取出立即执行,并且页面隐藏后就不注册raf回调,而是立即执行,然后运行一会就栈溢出了......
这次解决问题涉及到了性能工具、requestAnimationFrame、Web Worker、Blob等知识点。感兴趣的可以去看看相关的知识。