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

背景

公司有一个项目需要在前端渲染地图然后生成图片,这个功能是同事已经基于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。当然这对我来说不重要了,反正我都是要重写这两个方法。 重写之后的代码如下:

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

相关推荐
Pedantic1 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘1 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆2 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师3 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆3 小时前
VSCode自动格式化三要素
前端
爱勇宝3 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen4 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user20585561518136 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode6 小时前
Redis 在生产项目的使用
前端·后端