同事求助:标签页切换后代码不执行了!!!

背景

公司有一个项目需要在前端渲染地图然后生成图片,这个功能是同事已经基于leaflet和modern-screenshot实现了的。但是因为生成数据量大时间长(经过优化后耗时还是很长)所以需要在页面隐藏之后也能够正常运行生成。

初步排查

如标题所示,代码在切换标签页或者页面被遮挡的一瞬间就不继续执行了,所以可以肯定的是,这是由浏览器的节能策略引起的问题。
先尝试关闭浏览器的性能模式和节能模式看是否有效(真这么简单就不会写这篇文章了),没有效果。
再仔细捋一下业务代码有没有什么可能导致卡顿的地方,也没有找到可疑点。

上调试工具

利用控制台的性能工具去分析是什么导致了卡顿。在开始运行生成代码之前先打开左上角性能工具的录制,然后运行代码切换几次标签页,点击停止就录制完成了。
同事求助:标签页切换后代码不执行了!!!
录制结果:
可以看到每次切换页面显示状态都有很明显的暂停截面
同事求助:标签页切换后代码不执行了!!!
放大截面,点开下面的事件日志查看页面隐藏后最后执行的是什么代码。
同事求助:标签页切换后代码不执行了!!!
是leaflet执行的一个requestAnimFrame函数。而在页面显示之后最先执行的也是这个函数。
这个requestAnimFrame函数是leaflet封装的requestAnimattionFrame api。在这贴一下mdn对这个api的描述吧:
同事求助:标签页切换后代码不执行了!!!
最后一句话已经写的很清楚了,页面看不见的时候是不会执行这个api回调的。

解决问题

我们需要找一个隐藏页面不会被限制的api来替代requestAnimationFrame。第一时间想到setTimeout,但是很遗憾,在页面隐藏之后会将setTimeout执行延迟到至少一秒。
查找资料后找到在Web Worker内执行的setTimeout就不会被限制。所以可以用一个Web Worker内执行的setTimeout来重写leaflet的requestAnimFeame方法。

参考:juejin.cn/post/689979…

将leaflet的仓库clone下来,切换到当前使用版本的提交,以免引起其他版本差异问题。
找到src/core/Util.js所在的requestAnimFrame函数。注册和取消又是用的另外两个requestFn和cancelFn函数,所以只需要重写requestFn和cancelFn这两个函数就行了。
目前我使用的版本是做了兼容处理的,但是在前两个月的一次提交里就已经去除了兼容,只会使用requestAnimationFrame。当然这对我来说不重要了,反正我都是要重写这两个方法。
同事求助:标签页切换后代码不执行了!!!
重写之后的代码如下:


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的完整代码如下:

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文件


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或者其他入口文件头部将这个文件引入执行,一定要先于其他依赖引入。

import '@/utils/workerTimeout.js'

刚刚修改的leaflet的代码可以把worker的代码删除了,只保留setTimeout调用就好了。

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等知识点。感兴趣的可以去看看相关的知识。

原文链接:https://juejin.cn/post/7346530887473053705 作者:能说一句爱我吗

(0)
上一篇 2024年3月16日 下午4:26
下一篇 2024年3月16日 下午4:36

相关推荐

发表回复

登录后才能评论