- 第三方脚本目前是绝大多数电商站 INP 变差的头号原因;单独禁用一个 TikTok Pixel 就能省下 38ms。
- Partytown 0.10.3(已迁移至
@qwik.dev/partytown)通过 Service Worker 加 Atomics 把脚本执行搬到 Web Worker,主线程零阻塞。
- Next.js
next/script 的 worker 策略底层就是 Partytown,但 App Router 目前不支持,需要手动集成。
dataLayer.push 用 requestIdleCallback 或 scheduler.postTask 包装,可把 GTM 触发从交互关键路径移出。
- 硬性预算「第三方 JS 压缩后 ≤ 200 KB」加 CI 门禁,是防止营销团队悄悄回退的唯一办法。
- Web.dev、DebugBear、SpeedCurve 都推荐先按「加载属性 → 懒加载 → 自托管 → Web Worker」四层递进优化,而不是一上来就 Partytown。
为什么第三方脚本会拖慢网站?
第三方脚本(third-party scripts)指的是页面从其他域名加载、且不由你直接控制的 JavaScript:Google Analytics、Google Tag Manager、Facebook Pixel、TikTok Pixel、LinkedIn Insight、Hotjar、Intercom、Segment、A/B 测试工具等。它们的问题不在于「多下载了几百 KB」,而在于以下几点。
- 阻塞主线程:脚本解析和执行会产生 Long Tasks(超过 50ms 的任务),直接推高 INP。2025 年 Web Almanac 数据显示,Top 1000 站点里只有 63% 通过 INP,而这些站点普遍加载更多第三方。
- 抢占带宽:多个第三方并行请求,会推迟真正的 LCP 资源(英雄图、字体)。这一点我在 LCP 优化完全指南 里详细讲过 fetchpriority 与预连接的关系。
- 连接开销:每个新域名意味着一次 DNS 查询、TCP 握手、TLS 握手,加起来动辄 100 到 300ms。
- 不可预测的更新:脚本内容由供应商随时更新,一次静默升级就可能带来 50KB 新代码。
- 持续消耗电量:分析和心跳类脚本长期占用 CPU,加速手机耗电,还会拉高设备温度。
Subito(意大利最大二手交易平台)关掉一个 GTM 里的 TikTok Pixel,INP 立刻从 208ms 降到 170ms。一个脚本,38ms。这是我引用最多的数字,因为它直观说明了一件事:第三方脚本的性价比通常很差。
第一步:审计现有第三方脚本
说实话,优化之前必须先看清楚。我在电商团队里推行的是每季度一次的第三方脚本审计,用三个工具交叉验证。
- Chrome DevTools → Performance 面板:录制一次冷启动,切到 Summary,按 URL 分组,一眼看到哪个域名占了主线程最多时间。
- Lighthouse → Diagnostics:重点看 Minimize main-thread work 与 Reduce the impact of third-party code 两项,Lighthouse 会直接列出 Top 5 元凶及其阻塞毫秒数。
- DebugBear / SpeedCurve 的第三方追踪:能按脚本粒度画出时间线,看到某个供应商在一次发版里悄悄新增了 40KB 代码。
审计的输出是一张表。
| 脚本 | 压缩后大小 | 主线程时间 | 业务价值 | 处置 |
| Google Tag Manager | 85 KB | 420 ms | 高(承载全部营销 Pixel) | Partytown 加 dataLayer 延迟 |
| Facebook Pixel | 62 KB | 180 ms | 中 | 交互后加载 |
| TikTok Pixel | 48 KB | 210 ms | 低 | 下线,与业务确认 |
| Intercom | 310 KB | 550 ms | 中 | 点击「联系客服」按钮再加载 |
| Hotjar | 96 KB | 140 ms | 低 | 只在 10% 流量抽样启用 |
审计过程里我经常砍掉一半以上的脚本。两个功能重叠的分析工具、上季度活动结束却没清掉的 Pixel、试用完没删的热力图,都是常见发现。
第二步:正确使用 async、defer 和 fetchpriority
在动用 Partytown 之前,先把简单的加载属性用对。这一步能白捡 30% 到 50% 的 INP 收益。
<!-- 错误:阻塞 HTML 解析、阻塞渲染 -->
<script src="https://analytics.example.com/tag.js"></script>
<!-- 好一点:async 不阻塞解析,但下载完立即执行,可能抢在 LCP 前 -->
<script async src="https://analytics.example.com/tag.js"></script>
<!-- 推荐:defer 保证在 DOMContentLoaded 前顺序执行 -->
<script defer src="https://analytics.example.com/tag.js"></script>
<!-- 最好:明确低优先级,把网络带宽让给 LCP 资源 -->
<script defer fetchpriority="low"
src="https://analytics.example.com/tag.js"></script>
fetchpriority="low" 是 2026 年所有 Chromium 内核浏览器都稳定支持的属性,配合 defer 使用时,浏览器会把这个请求排到 LCP 图片和关键字体后面。这一点在电商详情页尤其明显,LCP 图片能提前 200 到 400ms 落地。
第三步:交互后再加载(Lazy load on interaction)
聊天窗口、评论组件、社交分享、视频播放器,这类脚本在用户不点击的情况下永远用不到。让它们在真正需要时才下载:
// 只在用户点击「联系客服」时才加载 Intercom
const btn = document.querySelector('#contact-btn');
let loaded = false;
btn.addEventListener('pointerdown', () => {
if (loaded) return;
loaded = true;
const s = document.createElement('script');
s.src = 'https://widget.intercom.io/widget/APP_ID';
s.async = true;
s.onload = () => window.Intercom('boot', { app_id: 'APP_ID' });
document.head.appendChild(s);
});
用 pointerdown 而不是 click,能在用户手指按下时就开始下载,等到 click 触发时脚本往往已经就绪,用户几乎感觉不到延迟。
另一个技巧是「首次滚动 / 首次移动鼠标」触发加载:
function loadWhenIdle(src) {
const load = () => {
const s = document.createElement('script');
s.src = src;
s.async = true;
document.head.appendChild(s);
cleanup();
};
const cleanup = () => {
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach(e =>
window.removeEventListener(e, load, { passive: true })
);
};
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach(e =>
window.addEventListener(e, load, { once: true, passive: true })
);
// 兜底:5 秒后无论如何也加载
setTimeout(load, 5000);
}
loadWhenIdle('https://static.hotjar.com/c/hotjar-XXX.js');
第四步:用 Partytown 把脚本搬到 Web Worker
好,当加载属性和懒加载都用完,但 GTM 之类的脚本必须一开始就存在(否则漏埋点),就轮到 Partytown 出场了。Partytown 是 Builder.io 团队开源的库,通过 Service Worker 加 Atomics 实现同步 postMessage,把第三方脚本完全搬到 Web Worker 里执行,主线程只处理必要的 DOM 代理调用。
2026 年 8 月最新版本是 0.10.3,包名已经从 @builder.io/partytown 迁移到 @qwik.dev/partytown。安装:
npm install @qwik.dev/partytown
纯 HTML 站点的最小可用配置:
<head>
<script>
// Partytown 配置对象必须在 partytown.js 之前
partytown = {
forward: ['dataLayer.push', 'gtag'],
debug: false,
lib: '/~partytown/',
};
</script>
<script src="/~partytown/partytown.js"></script>
<!-- 关键:把要迁移的脚本 type 改成 text/partytown -->
<script type="text/partytown"
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"></script>
</head>
forward 数组告诉 Partytown:window.dataLayer.push 和 window.gtag 的调用要转发到 Worker 里执行。这是让 GTM 在 Worker 里正常工作的关键。你的业务代码依然在主线程调用 dataLayer.push({...}),Partytown 会把这次调用序列化并派发到 Worker。
Partytown 工作原理简述
Partytown 干的最巧妙的事情,是把 Web Worker 里的异步 DOM 访问「伪装」成同步。第三方脚本经常写 document.cookie、window.location.href 这种同步 API,Worker 天然只能异步 postMessage。Partytown 用 Service Worker 拦截同步 XHR 请求,让 Worker 「停在同步调用上」,等主线程处理完 DOM 操作再返回结果。
这套机制对 GTM、GA、Pixel 类脚本兼容度很高,对依赖 requestAnimationFrame、大量 DOM 事件绑定的脚本(比如某些聊天 UI)兼容性差。上线前一定要在灰度环境验证。我上一个项目就在 Intercom 迁移这一步吃过亏,最后决定把 Intercom 留在主线程用 lazy load 方案。
在 Next.js 里集成 Partytown(Pages 与 App Router)
如果你还在 Pages Router,Next.js 内置了 worker 策略:
// next.config.js
module.exports = {
experimental: { nextScriptWorkers: true },
};
// pages/_app.tsx
import Script from 'next/script';
export default function App({ Component, pageProps }) {
return (
<>
<Script
strategy="worker"
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"
/>
<Component {...pageProps} />
</>
);
}
启动 next dev,Next.js 会自动引导你安装 @builder.io/partytown 并生成 public/~partytown/ 静态文件。
但 App Router 目前不支持 strategy="worker"。用 App Router 的话必须手动集成,我通常这样做:
// app/layout.tsx
import { Partytown } from '@qwik.dev/partytown/react';
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html lang="zh">
<head>
<Partytown
debug={false}
forward={['dataLayer.push', 'gtag', 'fbq']}
/>
<Script
id="gtm-worker"
type="text/partytown"
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"
/>
</head>
<body>{children}</body>
</html>
);
}
还要在 next.config.js 里加一段把 Partytown 静态资源复制到 public/ 的构建脚本,可以用 partytown copylib CLI,配合 postinstall 钩子:
// package.json
{
"scripts": {
"postinstall": "partytown copylib public/~partytown"
}
}
Google Tag Manager 与 dataLayer 延迟策略
即使 GTM 已经在 Worker 里,业务代码里散落的 dataLayer.push() 依然会在用户交互的关键路径上触发。举个例子:用户点击「加入购物车」按钮,你在 handler 里立刻 dataLayer.push({ event: 'add_to_cart', ...}),这次 push 会同步经过 Partytown 的序列化、Worker 派发、GTM 规则匹配、Pixel 触发。即便都在 Worker,主线程的 postMessage 排队仍然会拖慢 INP。
正确的做法是把 dataLayer.push 从「用户交互」和「视觉反馈」之间移出:
// utils/analytics.ts
type DL = { event: string; [k: string]: unknown };
export function trackDeferred(payload: DL) {
const push = () => (window.dataLayer ||= []).push(payload);
// 优先用 scheduler.postTask(Chromium 已稳定),后备 requestIdleCallback
if ('scheduler' in window && 'postTask' in scheduler) {
// 'background' 优先级 = 让给用户交互
scheduler.postTask(push, { priority: 'background' });
} else if ('requestIdleCallback' in window) {
requestIdleCallback(push, { timeout: 2000 });
} else {
setTimeout(push, 0);
}
}
// 使用
btn.addEventListener('click', () => {
addToCart(sku); // 立即执行业务逻辑
showToast('已加入购物车'); // 立即视觉反馈
trackDeferred({ event: 'add_to_cart', sku }); // 埋点排队
});
关于 scheduler.yield() 与 scheduler.postTask() 的深入用法,可以看我之前写的 INP 优化完全指南。核心思路一致:把非关键工作明确降优先级,让用户交互和视觉反馈优先获得主线程。
技术手段再好,也挡不住营销总监下周说「再上一个客户成功平台的埋点」。硬性性能预算是唯一长期有效的防线。我们团队的预算长这样:
- 第三方 JS 总量:压缩后 ≤ 200 KB(超出即 CI 红灯)
- 第三方脚本数量:≤ 10 个域名
- 主线程被第三方阻塞时间(在慢速 4G 模拟下):≤ 800 ms
- 新增任何脚本前,必须提交「性能影响评估」:预估大小、主线程时间、Wall-clock 影响
- 季度审计:连续 60 天数据为 0 的 Pixel 自动下线
预算怎么执行?我们用 Lighthouse CI 加一个自定义的 third-party-web 检查脚本,跑在每次 PR 上:
// scripts/third-party-budget.mjs
import fs from 'node:fs/promises';
const BUDGET_KB = 200;
const report = JSON.parse(await fs.readFile('./lhci-report.json', 'utf8'));
const audit = report.audits['third-party-summary'];
const totalKB = audit.details.summary.wastedBytes / 1024;
if (totalKB > BUDGET_KB) {
console.error(`❌ 第三方 JS ${totalKB.toFixed(1)}KB 超过预算 ${BUDGET_KB}KB`);
console.error('明细:');
for (const row of audit.details.items) {
console.error(` ${row.entity} - ${(row.transferSize / 1024).toFixed(1)}KB`);
}
process.exit(1);
}
console.log(`✅ 第三方 JS ${totalKB.toFixed(1)}KB / ${BUDGET_KB}KB`);
预算落地后,任何人想加脚本都必须同时提出「移除什么」。这个「先减后加」的规矩比任何技术手段都有效。Google Web.dev 的第三方 JavaScript 指南 也强调这一点:预算不是数字,是流程。Partytown 官方仓库 里也有一份 Wiki 讲了预算集成的建议做法。
用 RUM 持续监控回归
Lab 数据(Lighthouse、WebPageTest)不能代替 RUM。真实用户设备千差万别,一个中端 Android 上的 GTM 主线程时间可能是 M2 Mac 的 5 倍。上线 Partytown 或懒加载后,务必用真实用户监控盯住 Core Web Vitals:
- Vercel Speed Insights / Cloudflare Web Analytics:免费、开箱即用,够看趋势。
- DebugBear / SpeedCurve / Calibre:能按第三方脚本粒度拆分,回归时立刻知道是哪个供应商。
- 自建:web-vitals 加 PerformanceObserver:如果你已经有数据平台,直接采 INP、LCP、CLS 的 P75,配合
Long Animation Frame API 抓具体触发者。
我们的报警规则:INP 或 LCP 的 P75 单日恶化 15% 以上,自动发钉钉给性能小组,同时给营销团队邮件抄送。透明化的数据是唯一能让「营销 vs 性能」这场持久战不至于反复失利的机制。CLS 层面同样重要,第三方注入的横幅、Cookie 弹窗经常引起累积布局偏移,处理方法参考 CLS 优化完全指南 里的容器占位技巧。
常见问题
Partytown 会破坏 Google Analytics 的数据吗?
不会破坏,但可能有少量差异:Worker 环境下 document.referrer、页面加载时机等信号会有细微偏差。上线前用 A/B 分流(10% 走 Partytown、90% 走原方案)对比 GA 事件量与转化率,通常差异在 1% 到 2% 以内,可接受。归因严格的场景(付费广告、SEM)建议保留原方案,只把非核心 Pixel 放 Worker。
async 和 defer 哪个更适合第三方脚本?
绝大多数第三方脚本用 defer 更好,因为它保证在 HTML 解析完成后、按脚本出现顺序执行,且不会打断 LCP。async 会在下载完立即执行,可能抢在 LCP 图片前,反而拖慢首屏。只有极少数需要「越早越好」的脚本(比如 A/B 测试的分组决策脚本)才应该用 async。
Next.js App Router 支持 next/script 的 worker 策略吗?
截至 2026 年 8 月,Next.js App Router 仍不支持 strategy="worker"。App Router 用户需要手动引入 @qwik.dev/partytown,在 app/layout.tsx 里渲染 <Partytown /> 组件,把要迁移的脚本 type 改为 text/partytown,并通过 partytown copylib CLI 把 Worker 静态资源复制到 public/~partytown/。
性能预算里第三方 JS 该设多少 KB 合理?
Web.dev 和 DebugBear 的经验值是 压缩后 150 到 250 KB。SME 站点低于 10 个脚本、总量 <200 KB 是健康线;电商大站可以放宽到 300 KB,但必须配合 Partytown。超过 15 个第三方域名基本一定拖慢 INP,是最需要清理的信号。
Google Tag Manager 为什么会拖慢网站?
空的 GTM 容器只有几 KB,几乎不影响性能。真正拖慢的是里面加载的 Pixel 和自定义 HTML 标签。每一个 Facebook Pixel、TikTok Pixel、LinkedIn Insight 可能占 50 到 200 KB,且会在页面加载或用户交互时触发主线程 Long Task。GTM 本身还会为每次 dataLayer.push 遍历规则匹配,规则越多,交互延迟越大。