外观
从卡顿 480ms 到 INP 120ms:Web Worker 长任务拆解实战
为什么一次点击会让 INP 飙到 480ms
INP(Interaction to Next Paint)衡量页面在用户点击、触摸或键盘输入后的整体响应速度。它不只看事件函数执行多久,还包含事件处理、浏览器渲染以及后续绘制。很多文档站首屏看起来不复杂,但点击搜索、目录过滤、代码高亮时仍会卡顿,原因通常是主线程被一个超过 50ms 的长任务占住。
在一次 VitePress 文档站性能排查中,用户点击“筛选文章”按钮后,主线程需要完成三件事:遍历上千篇文档索引、根据关键词计算匹配分数、把新列表交给 Vue3 重新渲染。Performance 面板显示单次输入耗时约 480ms,其中 JS 计算占 360ms,渲染提交占 90ms,浏览器几乎没有机会及时反馈。
优化目标不是让所有计算消失,而是把主线程从“什么都自己做”改成“只负责交互和渲染”。最终方案是:重计算迁移到 Web Worker,非紧急 UI 工作用 requestIdleCallback 分片,Vue 列表更新保持轻量。真机 Moto G 模拟下 INP 稳定降到约 120ms。
方案对比:setTimeout、requestIdleCallback 与 Web Worker
常见拆任务方案各有适用边界。setTimeout 能避免任务连续阻塞,但它仍运行在主线程,计算量大时会和点击、绘制抢时间;requestIdleCallback 适合日志上报、缓存写入、低优先级索引补充,不适合用户正在等待结果的关键路径;Web Worker 适合纯数据计算,可以真正把 CPU 密集任务移出主线程,但不能直接访问 DOM。
选型时可以按一个简单决策框架判断:用户正在等待结果的同步计算,优先 Worker;不影响当前反馈的延后工作,使用 requestIdleCallback;只需要把一个大任务切成多个宏任务、且计算本身很轻时,才用 setTimeout。不要把所有代码机械地包进 setTimeout,因为回调仍可能在输入事件后排队,INP 未必改善。
Web Worker 承载搜索索引计算
下面示例模拟文档站搜索。主线程只维护输入状态、加载状态和结果列表;Worker 接收可序列化的文档索引,执行过滤、分词、评分和截断。为了让示例可直接运行,索引在运行时生成;实际项目中可在构建阶段生成 JSON,再由 Worker fetch。
javascript
// search.worker.js
let docs = []
// 简单中文与英文混合分词:小写后按非单词字符切分
function tokenize(text) {
return String(text).toLowerCase().split(/[^\u4e00-\u9fa5a-z0-9]+/).filter(Boolean)
}
// Worker 只处理数据,不访问 DOM,避免阻塞主线程绘制
self.onmessage = (event) => {
const { type, payload, requestId } = event.data
if (type === 'SET_DOCS') {
docs = payload.map((doc) => ({
...doc,
tokens: tokenize(`${doc.title} ${doc.summary} ${doc.tags.join(' ')}`)
}))
self.postMessage({ type: 'READY', requestId })
return
}
if (type === 'SEARCH') {
const query = payload.query.trim().toLowerCase()
if (!query) {
self.postMessage({ type: 'RESULT', requestId, payload: [] })
return
}
const keywords = tokenize(query)
const result = docs
.map((doc) => {
let score = 0
for (const word of keywords) {
if (doc.title.toLowerCase().includes(word)) score += 8
if (doc.summary.toLowerCase().includes(word)) score += 3
score += doc.tokens.filter((token) => token.includes(word)).length
}
return {
title: doc.title,
summary: doc.summary,
link: doc.link,
score
}
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 30)
self.postMessage({ type: 'RESULT', requestId, payload: result })
}
}主线程封装一个 Promise 风格客户端,并通过 requestId 丢弃过期响应。这个细节对 INP 很关键:连续输入时,如果旧请求晚于新请求返回,界面会闪现错误结果,还会触发额外渲染。
javascript
// searchClient.js
let requestId = 0
const worker = new Worker(new URL('./search.worker.js', import.meta.url), { type: 'module' })
const pending = new Map()
worker.onmessage = (event) => {
const { type, requestId: id, payload } = event.data
if (type === 'RESULT' && pending.has(id)) {
pending.get(id)(payload)
pending.delete(id)
}
}
export function setDocs(docs) {
worker.postMessage({ type: 'SET_DOCS', payload: docs })
}
export function search(query) {
const id = ++requestId
return new Promise((resolve) => {
pending.set(id, resolve)
worker.postMessage({ type: 'SEARCH', requestId: id, payload: { query } })
})
}
export function getLatestRequestId() {
return requestId
}Vue3 组件保持事件处理轻量
输入事件里不要直接做重计算,也不要先写大量同步日志。组件应立即给出视觉反馈,再把查询交给 Worker。返回结果时再校验请求序号,避免竞态。
vue
<script setup>
import { onMounted, ref } from 'vue'
import { search, setDocs } from './searchClient'
const keyword = ref('')
const loading = ref(false)
const results = ref([])
let latestId = 0
async function onInput() {
const query = keyword.value
const currentId = ++latestId
loading.value = query.trim().length > 0
const list = await search(query)
// 连续输入时,只接受最后一次请求
if (currentId !== latestId) return
results.value = list
loading.value = false
}
onMounted(() => {
// 实际项目可替换为 fetch('/search-index.json')
setDocs([
{ title: 'INP 优化指南', summary: '拆解长任务,提升交互响应', link: '/inp', tags: ['性能', 'Web Vitals'] },
{ title: 'VitePress 搜索实战', summary: '构建本地文档索引', link: '/search', tags: ['VitePress', '搜索'] }
])
})
</script>
<template>
<label>
搜索文档
<input v-model="keyword" @input="onInput" placeholder="输入 INP、Worker..." />
</label>
<p v-if="loading">正在检索...</p>
<ul>
<li v-for="item in results" :key="item.link">
<a :href="item.link">{{ item.title }}</a>
<span>{{ item.summary }}</span>
</li>
</ul>
</template>如果结果很多,还应控制 Worker 返回数量,并在 UI 层配合分页或虚拟列表。INP 关注的是整次交互,计算移走之后,若一次性渲染上千个 DOM 节点,渲染阶段仍会成为新的长任务。
requestIdleCallback 处理低优先级工作
搜索过程中常有埋点、缓存最近关键词、预热相关索引等工作。它们不应该和当前输入的渲染竞争主线程。可以用 requestIdleCallback 分片执行,并设置 timeout 防止长期饿死。
javascript
// idleTasks.js
const tasks = []
let scheduled = false
function runTasks(deadline) {
// 浏览器空闲时执行;时间不够就让出主线程
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && tasks.length) {
const task = tasks.shift()
try {
task()
} catch (error) {
console.error(error)
}
}
scheduled = tasks.length > 0
if (scheduled) requestIdleCallback(runTasks, { timeout: 300 })
}
export function scheduleIdleTask(task) {
tasks.push(task)
if (!scheduled) {
scheduled = true
requestIdleCallback(runTasks, { timeout: 300 })
}
}调用时只放入低优先级、可中断、可重复执行也不会出错的逻辑。例如记录搜索词可以延迟,但提交订单、保存表单就不能放进空闲回调,否则用户离开页面时任务可能还没执行。
javascript
import { scheduleIdleTask } from './idleTasks'
function reportSearch(query, resultCount) {
scheduleIdleTask(() => {
navigator.sendBeacon('/analytics', JSON.stringify({ query, resultCount }))
})
}VitePress 中的构建与兼容配置
在 VitePress 中使用 Worker 时,推荐使用 new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }),这样 Vite 能正确产出、哈希和预加载 Worker 文件。不要把 Worker 路径写成运行时拼接字符串,例如 new Worker('/workers/search.js'),开发环境可能可用,构建后却容易因为 base 路径或资源哈希变化而 404。
如果需要兼容更老的浏览器,应判断 Worker 与 requestIdleCallback 能力,再提供主线程降级。降级路径也要限制数据量,而不是默默恢复成一个 400ms 长任务。
javascript
export function createScheduler(docs) {
if ('Worker' in window) {
return { mode: 'worker', search: (query) => workerSearch(docs, query) }
}
return {
mode: 'main-thread',
async search(query) {
// 降级时只扫描标题和标签,避免老设备完全卡死
return docs
.filter((doc) => `${doc.title} ${doc.tags.join(' ')}`.toLowerCase().includes(query.toLowerCase()))
.slice(0, 20)
}
}
}避坑总结:优化 INP 时最容易踩的 4 个坑
第一,不要在 Worker 里操作 DOM 或期待 Vue 响应式跨线程生效。常见错误是把组件实例、Ref 对象直接 postMessage 过去,浏览器会报数据不可克隆,或者响应式更新根本不触发。正确做法是只传普通数组、对象和原始值,Worker 返回纯数据,由主线程赋值给 ref 或 reactive。
第二,不要忽略过期请求。现象是输入“vue”后立刻改成“vitepress”,列表先显示 vitepress,过一会儿又跳回 vue。修正方式是为每次交互生成递增 id,响应回来后比较当前 id;Worker 侧也可以在新搜索开始时终止旧计算,或使用单独 Worker 池控制并发。
第三,不要把 requestIdleCallback 当成保证执行的定时器。典型问题是埋点放进空闲回调后,用户马上关闭页面,数据丢失。关键业务应使用同步提交的替代策略,或在 visibilitychange 为 hidden 时改用 navigator.sendBeacon;低优先级缓存才适合空闲执行。
第四,不要只看 JS 执行时间而忽略渲染成本。把计算搬进 Worker 后,Performance 里脚本任务变短了,但如果一次返回上千条结果并渲染复杂卡片,INP 仍可能超过 200ms。应限制返回条数、简化列表项结构、避免在渲染中执行格式化函数,并对超长列表使用虚拟列表或分页。
最终优化要以真实交互链路验证:输入后是否立即出现反馈、Worker 消息体积是否过大、结果渲染是否形成新的长任务。把重计算移出主线程,把低优先级任务让到空闲期,把 DOM 更新控制在短任务内,INP 从 480ms 降到 120ms 才是可复现的结果。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。