之前接了一个数据看板项目,功能不复杂,但首屏一直很慢。查了一圈,罪魁祸首不是业务代码,而是散落在页面里的十几个第三方脚本:埋点、客服、图表 SDK、反馈组件……全部用普通的<script>标签堆在头部,互相不信任,加载顺序靠猜,页面渲染被堵得严严实实。后来把所有脚本统一换成 Next.js 的 Script 组件,加载策略重新梳理一遍,问题才算彻底解决。
如果你也在用 Next.js 做项目,或者正准备往 Next.js 迁移,这篇文章应该能帮你少踩几个坑。我会从“为什么需要 Script 组件”讲起,把三种加载策略、核心 API、路由模式差异、实际改造过程和常见问题全部过一遍,尽量用能直接落地的经验来说。
1. 为什么 Next.js 需要专门的 Script 组件:传统 script 标签的三个痛点
很多人第一次看到next/script都会觉得:不就是个 script 标签吗,自己写一行<script src="...">不完了?但在前后端同构的 Next.js 项目里,事情远没有那么简单。
1.1 手动管理加载时机的失控
传统<script>标签最麻烦的地方在于,它的阻塞行为和位置强相关。放到<head>里,浏览器必须等它下载并执行完,才开始渲染页面;放到<body>底部,虽然不阻塞首屏,但执行时机完全依赖 DOM 解析到什么位置。这在纯客户端项目里还能接受,但在 Next.js 这类 SSR/SSG 项目里,问题会被放大。
比如某个第三方监测脚本,需求是“尽快初始化,尽量早地捕获用户行为”,你会很自然地把它放到<head>。但 Next.js 在服务端渲染时会把整个 HTML 字符串拼好返回给浏览器,如果这个第三方脚本里有document相关的操作,服务端可没有document对象,轻则警告,重则直接导致构建或渲染失败。你可能会想“那我判断一下环境再执行”,但代码一旦散落到各个页面,每一处都要判断,维护成本立刻上来。
1.2 服务端渲染下的怪异表现
还有一个更隐蔽的问题:服务端渲染时,服务端拿到的 DOM 和客户端水合后的 DOM 必须尽量一致。如果你手动在某个组件里写了一个动态插入<script>的逻辑,服务端渲染的 HTML 里没有它,浏览器水合后突然多出一个脚本节点,React 的 hydration 虽然不一定会崩,但控制台会开始刷 warning。严重的会导致水合失败,整个页面变成纯客户端渲染,首屏性能优势全部丢失。
这种情况要处理,你得自己写useEffect来动态创建 script 标签,还要考虑组件卸载时要不要移除,多个页面会不会重复加载。说实话,我之前写过一套这样的“动态脚本管理器”,代码量不大,但边界情况非常多,而且每个项目都得重新复制一遍改改。
1.3 重复加载与加载顺序难以控制
第三个痛点是重复加载。同一个页面里,多个组件各自引入了同一个第三方 SDK,普通 script 标签没有去重机制,很容易出现同一个资源被请求两次的情况。虽然浏览器会走缓存,但多个同样代码块的执行和冗余的网络请求依然会造成浪费。更麻烦的是加载顺序:一旦第三方脚本之间有依赖——A 脚本要调用 B 脚本暴露的全局变量——用普通标签时,你只能靠“把 B 放在 A 前面”来保证,但这个前面要前面到什么程度?没人说得准。
Next.js Script 组件正是围绕这几个痛点设计的:它用统一的策略机制接管脚本加载时机,兼容服务端渲染,提供生命周期回调,并尝试在框架层面对重复加载做去重。本质上,它把你以前自己写的那些“动态加载脚本”的八股逻辑,全部收编成了声明式配置。
2. 三种策略怎么选:beforeInteractive、afterInteractive、lazyOnload 场景对照
Script 组件最核心的概念是strategy,一共三档:beforeInteractive、afterInteractive、lazyOnload。选错了,页面性能可能不升反降。我逐个拆开讲。
2.1 beforeInteractive:在首屏渲染前完成的关键脚本
beforeInteractive的意思是:脚本会在 Next.js 自身的水合脚本执行之前,被注入到初始 HTML 里。也就是说,浏览器拿到 HTML 后,会先下载并执行这些脚本,然后才继续解析页面、执行 React 水合逻辑。
适合用这个策略的脚本必须满足一个条件:如果不提前执行,页面功能会直接不可用,或者用户体验会明显受损。最典型的就是 Polyfill。比如你要兼容某个老浏览器上的新 API,但业务代码里已经用了这个 API,那就必须保证 API 在业务代码之前存在。还有少数场景,比如说需要在页面最早阶段劫持某些事件的监控脚本,用这个策略也合理。
但这里我要泼一盆冷水:beforeInteractive是三种策略里对性能威胁最大的一个。它本质上是把脚本重新放回了阻塞渲染的位置,如果塞了一个几百 KB 的第三方 SDK 进去,首屏会比原来更慢。很多项目里,这个策略被滥用了——有些开发者觉得“越早加载越好”,把所有埋点、客服脚本都塞进来,结果首屏跑出好几秒白屏。正确的心态应该是:默认不用,确认必须才用,用了之后还要反复测试它对 LCP 的影响。
在 Pages Router 时代,beforeInteractive策略有个硬性约束:脚本必须放在根布局的_document.tsx里,放在其他组件中会直接不生效。到了 App Router,情况又不一样,下面第 4 章会细说。
2.2 afterInteractive:页面水合完成后的默认选择
afterInteractive是 Script 组件的默认策略。字面意思是:在浏览器完成页面水合之后,才把脚本注入并执行。这个策略最大的好处是不干扰 React 的恢复过程,也不阻塞初次渲染,同时又能保证“脚本最终一定会执行”。
适合这个策略的脚本有:第三方 UI 组件库的初始化脚本、业务辅助 SDK、需要在用户交互前备好的工具库。这些脚本不需要赶在首屏之前,但也不能拖到用户操作之后——比如用户点了某个按钮,你才把处理点击事件的脚本加载出来,那就晚了。
实际用的时候,这个策略最省心。你只需要把 Script 组件放到对应的页面或布局里,框架会在水合完成后自动帮你在文档中补一个 script 标签,并且默认采用异步加载方式。大部分第三方脚本,只要不是首屏必须的,都可以先用这个策略,跑一段时间发现某个脚本要更早执行,再降级成beforeInteractive也不迟。
2.3 lazyOnload:真正不着急的脚本
lazyOnload的语义更靠后:它会等待页面主要资源都加载得差不多之后,利用浏览器的空闲时间(通常是load事件之后)再来加载和执行脚本。这是三种策略里对首屏最友好的一种。
适合它的场景非常明确:不影响核心功能的辅助型脚本。客服聊天挂件、反馈意见弹窗、埋点上报、行为分析采集——这些都属于“晚点出现也没关系”的功能。用户不太可能刚进页面 0.5 秒就打开客服聊天,也没人会在页面加载完成前提交反馈。
我曾经在一个项目里,把原来放在afterInteractive的埋点脚本挪到lazyOnload,视觉上完全无感,但核心页面的脚本执行总时长明显下降。原因很简单:afterInteractive虽然不阻塞渲染,但还是要在水合后立刻发生一次网络请求和脚本解析;lazyOnload把这次消耗挪到了浏览器空闲期,优化效果是实打实的。
三种策略选型时,我给团队定的口诀是:能晚就晚,不到万不得已不提前。把每个脚本先问一遍“如果它 3 秒后才加载,用户会察觉吗?”不会,就lazyOnload;会但首屏还能扛,就afterInteractive;真的必须最先执行,才用beforeInteractive。
3. 从配置到回调:Script 组件核心 API 使用要点
策略搞清楚了,接下来看代码怎么写。next/script的 API 并不复杂,但有些细节官方文档只写了一句话,实战里却是关键。
3.1 src + strategy 的基本用法与摆放位置
最基础的用法是引入外部脚本:
import Script from "next/script"; export default function DashboardPage() { return ( <div> <h1>数据看板</h1> <Script src="https://cdn.example.com/sdk.js" strategy="afterInteractive" /> </div> ); }注意,Script 组件可以放在页面组件里任意位置,不一定非要在布局或头部。因为它本质上不是直接渲染一个静态标签,而是通过框架层的调度,在正确的时机把脚本注入到 DOM。所以在页面主体里写一个<Script />,从代码组织上完全没问题,反而更符合“哪个页面用到,就在哪个页面声明”的直觉。
用的时候有几个点要留意。第一,src是必填项之一,如果你既没给src,又没有内联脚本内容,控制台会直接报错。第二,同一个页面里如果有两个相同src的 Script 组件,Next.js 会做去重处理,不会发出两个网络请求。第三,Script 组件的其他未知属性会被透传给底层的<script>标签,比如你可以额外传><Script src="https://cdn.example.com/sdk.js" onLoad={() => { // 此时可以安全地使用 window 上的全局变量 console.log("SDK loaded"); }} />
这里的价值在于,它帮你省掉了“轮询等待全局变量出现”的丑陋代码。以前常见这种做法:
let timer = setInterval(() => { if (window.SDK) { clearInterval(timer); init(); } }, 100);用了 onLoad,直接一个回调就解决问题。
onReady和onLoad的区别是:onReady在脚本加载完成之后每次都执行,而且即使脚本已经在别的页面加载过、切换到当前页面时不会再次发起网络请求,onReady依然会触发。在单页应用里做路由跳转时,这个特性非常有用,因为你不知道用户是从哪个页面跳过来的,到一个新页面后,脚本可能刚刚下载好,也可能早就在全局缓存里了。用onReady来初始化页面级功能,会比onLoad更可靠。
onError则用来捕获脚本加载失败的情况,可以在回调里做降级处理,比如提示用户、加载备用地资源等。
需要说明的是,这些回调都只在客户端执行,数据获取或静态生成阶段不会运行。所以不要在回调里写依赖服务端数据的逻辑,否则你会得到一堆“undefined is not a function”的诡异报错。
3.3 内联脚本怎么用,以及为什么不建议乱用
Script 组件也支持内联脚本,写法是直接把脚本内容作为 children 传进去,同时必须提供一个id:
<Script id="inline-config" strategy="lazyOnload"> {` window.someConfig = { enableLog: true, version: "1.0.0" }; `} </Script>id是必须的,框架要依托它来做去重和标识。内联脚本的好处是省一次网络请求,适合放一些小段配置,比如提前定义全局变量、设置第三方 SDK 的初始化参数。
但我个人不推荐在业务代码里大量使用内联脚本。首先是维护体验差,大段字符串形式的 JavaScript 代码在 TSX 文件里几乎享受不到任何语法高亮和类型检查,写错了只能靠运行时报错排查。其次,内联脚本一旦涉及变更,会直接影响初始 HTML 的 hash 或 diff,在浏览器强缓存策略下可能产生各种缓存不一致的诡异问题。还有一个限制:beforeInteractive策略不支持内联脚本。如果你脑子一热想把一个关键初始化逻辑塞进内联脚本并用beforeInteractive,直接会被官方限制挡回来。这种情况下更好的做法是:把内联内容提取成一个独立的.js文件,用src引入,再用beforeInteractive。
4. App Router 和 Pages Router 下 Script 组件摆放位置的区别
Next.js 从 13 版本开始主推 App Router,但线上依然有大量项目跑在 Pages Router 上。Script 组件在这两种路由模式下的用法有差异,实际迁移时最容易踩坑。
4.1 Pages Router:_document 与 _app 的分工
在 Pages Router 项目里,全局脚本通常放在两个特殊文件里:_app.tsx和_document.tsx。
这两个文件的分工是:_app.tsx负责初始化客户端应用,页面切换时它一直存活;_document.tsx只用于服务端渲染初始 HTML 的组装,不会被客户端重复执行。所以:
afterInteractive和lazyOnload策略的脚本,既可以放在_app.tsx,也可以放在任意页面组件里。放在_app.tsx里会全局生效,放在具体页面里就只在那个页面生效。beforeInteractive策略的脚本,官方要求必须放在_document.tsx中。原因也好理解:beforeInteractive需要把<script>直接渲染进初始 HTML 的<head>,而只有_document.tsx能控制初始 HTML 的结构。
我记得第一次在 Pages Router 项目里用 beforeInteractive 时,把它放在了_app.tsx里,页面运行后脚本压根没出现,控制台还给了警告。后来才意识到,这类脚本的“位置选择”不是随便放的,它和组件的渲染层级直接挂钩。
4.2 App Router:layout 中的新约束
App Router 的架构和 Pages Router 差别很大。它没有_document.tsx和_app.tsx这种概念,取而代之的是app/layout.tsx根布局。根布局负责渲染整个应用的外壳,包括<html>和<body>标签。
在 App Router 下,Script 组件的使用规则有一个明显变化:beforeInteractive策略不被支持。这不是配置问题,而是架构决定的——App Router 对初始 HTML 的生成方式做了重构,不再暴露一个全局的 document 组装层,所以“把脚本插到初始 HTML 的最前面”这个能力被移除了。如果强行使用,官方会报出策略不受支持的提示。
在 App Router 里,你只能在app/layout.tsx中放置afterInteractive或lazyOnload:
import Script from "next/script"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="zh-CN"> <body> {children} <Script src="https://cdn.example.com/analytics.js" strategy="afterInteractive" /> <Script src="https://cdn.example.com/support-widget.js" strategy="lazyOnload" /> </body> </html> ); }需要注意的是,App Router 下,Script 组件不要放在<head>标签内部。从设计意图上说,Script 组件是要在 body 区域声明的。
4.3 两种路由模式下迁移要注意什么
我有一个同事把项目从 Pages Router 迁到 App Router,脚本部分一开始没动,结果线上监控脚本一直没有上报。排查半天,就是因为迁移时保留了 Pages Router 文件结构,同时新建了 App Router 的 layout,beforeInteractive脚本在新结构里静默失效了。
所以迁移前你最好列一个脚本清单,逐个标记:
- 原本放在
_document.tsx里的beforeInteractive脚本,迁移后改为afterInteractive,并评估页面是否有可见变化。 - 原本放在
_app.tsx里的全局脚本,迁移后放到app/layout.tsx。 - 原本放在具体页面里的页面级脚本,迁移后放到对应的
app/xxx/page.tsx即可。
从beforeInteractive降级到afterInteractive时,要特别关注那些“必须在业务代码前执行”的脚本。如果它真的是关键 polyfill,App Router 下可能需要换个思路:通过next.config.js注入、或者在入口文件里显式 import,而不是依赖 Script 组件。
5. 实战记录:数据看板项目的脚本加载优化全过程
理论知识说得再多,不如完整看一个改造案例。下面这个项目是虚拟的,但改造思路来自我实际带过的一个内部数据看板系统,具备很强的参考价值。
5.1 项目背景与脚本困境
这个数据看板项目使用的是 Pages Router 架构,内有四个核心子页面:总览、流量分析、用户画像、实时监控。页面本身用了一套第三方的图表 SDK 来渲染曲线图和柱状图,同时还集成了三个辅助脚本:一个用户行为埋点脚本、一个反馈弹窗脚本、一个在线客服脚本。
最初的接入方式非常粗暴。开发者们在_app.tsx里用<head>下的普通 script 标签依次引入了这些脚本,还专门调整了先后顺序。结果上线后出现几个症状:
- 首屏加载时间很长,LCP 普遍超过 3 秒。
- 反馈弹窗的 CSS 偶尔一下子没生效,出现短暂裸样式。
- 埋点数据缺失,部分用户前几个操作没有被记录。
- 图表 SDK 报“全局对象不存在”的错误,排查后才发现是脚本加载顺序在某些网络环境下变了。
这类问题几乎全是脚本加载策略混乱导致的。首屏变长是因为所有脚本都同步阻塞在头部;埋点数据缺失是因为埋点脚本加载太晚,用户早期操作没被捕获;图表报错则是普通 script 标签的执行顺序在不同网络情况下不可控。
5.2 脚本策略落地与代码改造
我对照脚本清单重新做了分类。首先是图表 SDK,它的初始化需要时间,但页面核心就是图表,所以用户进入页面后要尽快可用。策略选afterInteractive。其次是埋点脚本,它不参与核心渲染,晚几秒无感,但又要保证不能太晚导致漏数据,放在加载事件之后完全可以接受,策略选lazyOnload。反馈弹窗和在线客服类似,都属于低频交互功能,晚加载无感,全部lazyOnload。
改造后的_app.tsx大概是这个样子:
import Script from "next/script"; import type { AppProps } from "next/app"; export default function App({ Component, pageProps }: AppProps) { return ( <> <Script src="https://cdn.example.com/chart-sdk.js" strategy="afterInteractive" onReady={() => { // 部分图表初始化依赖 SDK 全局对象,确保可用 window.ChartSDK = window.ChartSDK || {}; }} /> <Script src="https://cdn.example.com/analytics.js" strategy="lazyOnload" /> <Script src="https://cdn.example.com/support-widget.js" strategy="lazyOnload" /> <Script src="https://cdn.example.com/feedback-widget.js" strategy="lazyOnload" /> <Component {...pageProps} /> </> ); }这里面有个细节:afterInteractive的图表 SDK,我给它配了onReady而不是onLoad。原因是这个脚本可能已经在用户访问其他页面时加载过了,用户跳转到数据看板页时,脚本不会再次触发load事件,但图表组件依然需要确认它可用。onReady在这种跨页场景下更可靠。
同时,图表页里的组件不再自己动态添加script标签,而是直接等待全局变量出现,如果没出现就在界面上给出加载中的状态:
useEffect(() => { if (!window.ChartSDK) { // 显示 loading 状态,等待 onReady 后初始化 return; } initChart(); }, []);这个改动是关键。以前组件自己动态注入脚本时,会因为父页面已经加载过同一脚本而跳过加载逻辑,导致window.ChartSDK还是 undefined。现在由 Script 组件统一管理,生命周期回调保证了时序。
5.3 改造后的效果与经验反思
改造上线后,数据看板页面的脚本请求明显减少,同一 SDK 几乎不再出现重复加载。首屏的白屏时间虽然没有做严格的 A/B 对比,但从监控面板里能明显看到 LCP 降到了 2 秒以内,埋点数据的完整性也大幅提升,反馈弹窗的样式错乱问题消失。
这一段经历给我留下的最大教训是:脚本的加载策略应该被当作产品需求来对待,而不是“把标签补上去就行”。每个脚本都值得单独问一句:它真的需要现在加载吗?它是为了这个页面加载,还是为了整个应用?答案不同,策略完全不同。把思考固化到 Script 组件的策略字段里,后续接手的人扫一眼代码就知道为什么这样安排,比在项目文档里写一大段说明更直观。
6. 开发中容易踩的坑与排查方法
Script 组件虽然能解决很多问题,但它毕竟是对浏览器原生 script 机制的一层抽象,抽象就有代价,代价体现在各种边界情况上。下面这几个问题,都是我实际项目中遇到过、或者帮别人排查过的,值得收藏。
6.1 脚本顺序不对?不要依赖 DOM 插入顺序
即使是afterInteractive,多个 Script 组件之间的执行顺序也不能想当然。脚本的加载默认是异步的,两个脚本谁先下载完谁先执行,和它们在代码里出现的先后没有绝对关系。如果 A 脚本需要依赖 B 脚本暴露的全局对象,千万别假设“我写在前面它就先执行”。
我的经验是:有依赖关系的脚本,要么合并成一个加载入口;要么在 A 的onLoad/onReady回调里再动态加载 B。显式控制依赖一定比隐式暗示可靠。如果依赖链太长,或者要动态加载的脚本很多,建议自己封装一个小的 loader 函数,把所有加载逻辑集中管理。
6.2 onLoad 不触发:缓存、跨页与内联脚本
onLoad 不触发是排查频率最高的问题。常见原因有三个:
第一种,脚本已经被浏览器缓存。脚本加载完成后触发一次load,如果用户再次进入页面,脚本从内存缓存里读取,不一定再触发load事件。所以不要指望onLoad每次进入页面都会执行。
第二种,脚本在其他页面已经加载过了。Next.js 是单页应用,用户从 A 页面切到 B 页面,B 页面的脚本如果和 A 页面相同,不会重新加载,onLoad自然不触发。这种场景要用onReady替代。
第三种,内联脚本。内联脚本没有实际的网络加载过程,onLoad的触发时机和内联脚本的关系很不稳定,不要依赖它。给内联脚本做初始化逻辑,优先用onReady。
6.3 beforeInteractive 拖慢首屏怎么办
用beforeInteractive后发现首屏更慢了,第一步不是换策略,而是先确认这个脚本“提前执行”的价值是否大于性能损失。如果价值不大,直接降级到afterInteractive。如果确实必须提前,可以试试:
- 把脚本体积压缩到极致,只保留必要逻辑。
- 用更现代的文件拆分方式,只加载首屏真正用到的功能。
- 把部分逻辑延迟到脚本内部执行,不要全部在解析时同步跑。
还有个小技巧:beforeInteractive脚本最好做一次预连接优化,在 HTML 的<head>里加上<link rel="preconnect">到脚本所在的 CDN 域名,可以减少 DNS 和 TLS 握手带来的额外延迟。这个优化对任何策略都有效,但对beforeInteractive尤其值得做。
6.4 被 CSP 拦掉:第三方脚本加载失败的常见原因
如果你的站点配置了内容安全策略(CSP),第三方脚本被拦截是最常见的脚本加载失败原因。浏览器控制台会报类似“拒绝加载脚本,因为它违反了内容安全策略指令”的错误。
Script 组件本身不会绕过 CSP,也不应该绕过。正确的处理方式是在 CSP 的script-src指令中,显式加入该第三方脚本对应的域名白名单。
如果你的内联脚本也被 CSP 拦截,要么在script-src中加入'unsafe-inline'(不推荐,会大幅降低安全等级),要么给内联脚本计算 hash 并加到白名单里。Next.js 构建后会给内联脚本生成对应的 hash,配置 CSP 时可以从构建产物中提取。
6.5 开发环境表现和生产环境不一致
Script 组件在next dev下和生产构建next build && next start下的表现,偶尔会有差异。最典型的情况是:开发环境脚本一切正常,一到生产环境,某些onLoad回调不执行了,或者脚本重复加载了。
这通常和开发模式下的热更新机制有关。开发环境会为了调试方便而跳过一些缓存,脚本每次可能都会重新加载,所以onLoad触发得很勤快。生产环境则启用了更激进的缓存和资源优化,行为自然不同。
遇到这种问题,先别急着怀疑代码,用生产构建跑一遍,并打开浏览器 Network 面板观察脚本的加载次数和时序。另外,DevTools 里勾选“Disable cache”后,开发环境的表现也可能和生产更接近,可以用来复现一些问题。
6.6 扩展:如何直观验证策略是否生效
最后分享一个验证脚本策略是否生效的思路,很简单但非常实用。
打开项目页面,在浏览器的 Network 面板里查看脚本请求的发起时机。beforeInteractive的脚本会出现在请求列表的最前面,且在页面 HTML 的初始响应里就能找到对应的<script>标签(Chrome DevTools 的 “Initiator” 列会显示它的来源)。afterInteractive的脚本则出现在页面水合发生之后,初始 HTML 里没有对应的标签。lazyOnload的脚本一般要等所有主资源加载完成后才会出现在请求列表里。
如果发现某个标记为lazyOnload的脚本在页面加载早期就发起了请求,说明代码里可能还有别的地方手动引用了这个脚本,此时要全局搜索一下脚本的 URL。我之前排查过一次埋点脚本重复加载的问题,最后就发现是布局里既用了 Script 组件,又有人在某个组件里手动写了<script src>,双重引入导致行为完全失控。
排查这类问题时,全局搜索脚本 URL 是最快的方式,先把所有出现的地方都找出来,再逐个确认它们分别由谁负责。把散落的 script 标签统一收口到 Script 组件之后,策略才能被真正控制住。
Script 组件本质上不是什么炫技的技术,它是 Next.js 对“脚本加载”这个高频痛点给出的标准答案。花点时间把策略选型和生命周期回调搞懂,后面做性能优化会顺手很多。我个人的体会是,脚本管理这件事越早规范化越好,等项目长到一定规模再去收拾旧账,成本会高出好几倍。