打开“自在学”的课程详情页时,首屏最显眼的通常不是文字,而是一张课程封面。继续向下滚动,页面里还有推荐课程卡片、讲师头像、课程徽章和可下载的学习手册。它们看起来都只是“放一张图”,但浏览器真正要回答的问题远比 src 复杂:图片在加载前占多大空间?当前设备该下载哪个尺寸?它是否值得抢占首屏带宽?远程地址能不能被服务器访问?最终又是谁在裁剪、转码和缓存?
这正是图片性能最容易产生错觉的地方。页面使用了 next/image,不等于图片一定更小;写了 width 和 height,不等于最终显示尺寸就是这两个数字;配置了 remotePatterns,也不等于任意自定义 loader 都自动继承了同一套安全校验。真正可靠的做法,是把图片当成一条完整的交付链路,而不是一个 JSX 标签。
本章以“自在学课程页”为贯穿案例:课程封面来自 CMS,课程卡片需要响应式图片,本地徽章跟随代码发布,PDF 手册放在 public,并结合当前项目已经配置的全局图片 loader,逐步建立一套可验证的图片策略。
本章严格以当前课程项目使用的 Next.js 16.1.6 为版本基线。Next.js 16 已弃用 priority 图片属性,默认图片格式是 WebP 而不是 AVIF,并且这一版本还不能配置图片磁盘缓存的容量上限。正文中的 API、配置与迁移建议都按这个边界编写。
一张图片从代码走到屏幕,至少会经过下面六个环节:
public URL、CMS 远程 URL,还是 data: / blob: 地址;sizes、视口、设备像素比和网络状况选择 srcset 中的候选;Accept,缓存键是否区分宽度、质量和格式,更新图片后怎样失效。next/image 的价值,是让我们用一个组件表达这份契约:图片有什么语义、固有尺寸是什么、在布局里占多宽、何时加载,以及 URL 应怎样交给 loader。不过它无法替代一个不存在的图片处理服务。假如自定义 loader 只把 ?w=640&q=75 拼到原地址后面,而源站完全不认识这两个参数,那么浏览器收到的仍可能是原始大图。

可以把问题分成三个责任层:
Next.js 默认 loader 与默认优化器是一套配合好的实现;换成自定义 loader 后,URL 生成和图片处理就进入了自己的责任边界。以后遇到“为什么 Image 没有优化”的问题,先问“最终请求去了哪里、谁处理了它”,比反复调整 JSX 更有效。
“图片放哪里”不应只看哪个路径写起来最短,而要看谁维护它、什么时候变化、是否需要内容哈希,以及它是否由用户在运行时上传。
自在学课程页可以先列出这份资产清单:

如果徽章属于课程组件本身,可以放在组件附近并静态导入:
// app/courses/[slug]/CourseBadge.tsx
import Image from 'next/image'
import officialCourseBadge from './official-course-badge.png'
export function CourseBadge() {
return (
<Image
src={officialCourseBadge}
alt=""
width={32}
height={32}
className="size-8"
/>
)
构建工具可以从本地文件读取固有宽高;对受支持的非动画 JPG、PNG、WebP 和 AVIF,还能生成模糊占位所需的元数据。产物文件名通常带内容哈希,替换文件后 URL 会变化,很适合长期缓存。
这里的徽章旁边还有“官方课程”文字,所以图片本身是装饰,alt="" 比重复朗读更合适。width 和 height 可以覆盖静态元数据,但没有需要时直接保留导入结果即可。
public:需要稳定 URL 的原样文件放在 public/downloads/image-checklist.pdf 的文件,对外地址是 /downloads/image-checklist.pdf,URL 中没有 /public:
export function CourseDownloads() {
return (
<a href="/downloads/image-checklist.pdf" download>
下载课程图片排查清单
</a>
)
}public 适合浏览器必须按固定 URL 访问、并且无需框架导入处理的文件,例如下载附件、站点清单或某些第三方验证文件。它不是运行时文件系统:应用上线后由用户上传的内容,应进入 CMS、对象存储或专门的媒体服务。
课程封面由 CMS 管理时,页面接收的通常是绝对地址:
import Image from 'next/image'
type CourseCoverProps = {
title: string
coverUrl: string
}
export function CourseCover({ title, coverUrl }: CourseCoverProps) {
return (
<Image
src={coverUrl}
alt={`${title}课程封面`}
远程字符串不会在构建时被读取,所以必须手动提供与源图一致的宽高比,或者改用 fill。如果 CMS 允许编辑人员随意更换成不同宽高比的图片,上传流程就应统一裁剪规格,否则代码中写死的比例会与真实文件不一致。
一块不影响内容理解的彩色纹理,可以直接用 CSS;但课程封面、图表、讲师照片不能为了省事全部改成背景图。背景图没有原生 alt,也不具备 <img> 的加载和响应式语义。
export function CourseIntroDecoration() {
return (
<div
aria-hidden="true"
className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_top_right,_#bae6fd,_transparent_55%)]"
/>
)
}判断标准很简单:把图片隐藏后,如果用户仍能完整理解内容,它才更可能是装饰;如果信息缺失,就应该保留语义图片和准确的替代文本。
图片请求很快并不能自动消除布局偏移。浏览器第一次排版时,如果不知道图片要占多高,只能先把后文顶上来;文件加载完成后再腾出空间,用户刚准备点击的按钮就会突然下移。这类变化会计入 CLS。
width 和 height 的第一职责,是描述图片的固有宽高比,让浏览器在资源到达前预留空间。它们不是最终 CSS 显示尺寸:
<Image
src={course.coverUrl}
alt={`${course.title}课程封面`}
width={1600}
height={900}
className="h-auto w-full"
/>这里的图片可能在手机上只显示 343 像素宽,但 1600 / 900 仍告诉浏览器它是 16:9。w-full 决定渲染宽度,h-auto 保持比例。远程图片的数字应该来自 CMS 保存的真实元数据,而不是凭视觉猜测。
fill 解决未知显示尺寸,不解决未知容器尺寸卡片封面经常需要裁满一个响应式区域,这时可以使用 fill。下面先假定这个组件会放进下一节定义的一列、两列、三列课程网格:
import Image from 'next/image'
export function CourseCardCover({
title,
coverUrl,
}: {
title: string
coverUrl: string
}) {
return (
<div className="relative aspect-video overflow-hidden rounded-xl">
<Image
src={coverUrl}
alt={`${
fill 会让底层 <img> 绝对定位并填满父容器,因此父容器至少要满足两件事:
position: relative、fixed 或 absolute;aspect-ratio,或由网格布局确定的尺寸。如果父容器本身高度为零,fill 也没有空间可填。object-cover 会保持比例并裁掉边缘,适合统一卡片构图;object-contain 会完整显示图片,可能留下空白,适合 Logo、商品包装或不能裁切的教学图。旧版 objectFit 图片属性不应再使用,直接通过 CSS 的 object-fit 表达。

即使图片本身有比例,下面这些外部因素仍可能引发布局变化:
height: auto,导致固有比例失效;width / height 不一致。不要为了“看起来清楚”把所有远程图片都写成 width={1200} height={630}。这两个数字会参与浏览器的几何占位。源图不是该比例时,错误的元数据可能制造新的拉伸、裁切或布局偏移问题。
sizes 决定浏览器选哪一个候选响应式图片不是“服务器检测到手机就返回小图”。常见流程是:Next.js 根据配置生成一组 srcset 候选,浏览器再结合 sizes、当前视口宽度和设备像素比,选择自己认为最合适的一个。
假设课程列表使用下面的网格:
<div className="px-4">
<ul className="mx-auto grid max-w-[1200px] grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{courses.map((course) => (
<CourseCard key={course.slug} course={course} />
))}
</ul>
</div>布局含义是:
卡片图的 sizes 应尽量描述同一套规则:
<Image
src={course.coverUrl}
alt={`${course.title}课程封面`}
fill
sizes="(max-width: 639px) calc(100vw - 32px), (max-width: 1023px) calc((100vw - 56px) / 2), (max-width: 1231px) calc((100vw - 80px) / 3), 384px"
className="object-cover"
/>sizes 从左到右匹配,浏览器采用第一个为真的媒体条件;最后的 384px 是兜底槽位。两列布局要扣掉左右 32px 内边距和一个 24px 列间距,所以是 (100vw - 56px) / 2。三列尚未达到最大宽度时,还要扣掉左右内边距和两个列间距,所以是 (100vw - 80px) / 3;内容区达到 1200px 后,每列才稳定为 384px。

sizes 不是图片文件尺寸,而是布局槽位浏览器大致会用“槽位宽度 × DPR”估算所需资源。例如槽位为 384 CSS 像素、设备像素比为 2,浏览器可能倾向接近 768 像素宽的候选。它仍会结合候选集合和实现策略选择,因此不要把结果理解成绝对公式。
最常见的错误,是图片在桌面只占三分之一,却省略 sizes。对于使用 fill 或响应式 CSS 的图片,浏览器可能按 100vw 估算,最终下载远大于卡片实际需要的文件。另一个错误是复制一段与 CSS 断点不一致的 sizes:视觉布局改成两列了,图片规则却仍按三列选择。
可以把 CSS 与 sizes 放在同一个代码评审中检查:
srcset 模式没有 sizes、并使用明确 width / height 的图片,Next.js 常生成面向 1x、2x 密度的有限候选;提供 sizes 后,框架可以生成更完整的宽度描述符候选,让浏览器按实际槽位挑选。两者都不是越多越好,关键是候选 URL 对应的图片服务必须真的返回不同像素尺寸。
在浏览器控制台里可以直接查看当前选择结果:
const image = document.querySelector('[data-course-cover]')
if (image instanceof HTMLImageElement) {
console.table({
currentSrc: image.currentSrc,
clientWidth: image.clientWidth,
clientHeight: image.clientHeight,
devicePixelRatio: window.devicePixelRatio,
})
}currentSrc 比 JSX 里的 src 更有诊断价值,因为它显示浏览器最终选中的候选。
普通响应式图片解决的是“同一张图用多大分辨率”。但课程运营可能希望桌面封面突出右侧的代码窗口,手机封面则把讲师头像移到中央。这不是尺寸问题,而是艺术指导:不同媒体条件下应该使用不同裁图。
这时可以用 <picture> 组合多个来源,并通过 getImageProps() 复用 Next.js 的候选 URL 生成逻辑:
// app/courses/[slug]/ArtDirectedCourseCover.tsx
import { getImageProps } from 'next/image'
type ArtDirectedCourseCoverProps = {
title: string
desktopSrc: string
mobileSrc: string
}
export function ArtDirectedCourseCover({
title,
desktopSrc,
mobileSrc,
}: ArtDirectedCourseCoverProps) {
const {
props: {
这个例子保持两种裁图都是 2:1,布局占位比较简单。如果移动端和桌面端比例也不同,应让外层容器或媒体查询明确提供相应的 aspect-ratio,否则切换来源时仍可能出现几何不一致。示例把这张主封面视为已确认的首屏候选,因此在最终 <img> 上使用 eager 和高抓取优先级;浏览器从 <picture> 选择出的实际来源会继承这套加载意图。如果它不在首屏,应删掉这两个提示,恢复懒加载。
getImageProps() 返回底层 <img> 所需的属性,不会创建 Image 组件内部用于移除占位图的状态。因此不要在这种写法中直接使用 placeholder="blur",否则占位样式不会像普通 Image 那样自动退出。
如果只是同一构图在手机上下载小文件、桌面上下载大文件,不需要 <picture>。准确的 sizes 和正常 srcset 已经足够。只有主体位置、裁切范围或画面比例确实需要变化时,才增加艺术指导的维护成本。
Next.js 图片默认懒加载,这对折叠线以下的推荐课程卡片是合理的;对首屏最大的课程封面却可能太晚。图片是否该提高优先级,应该从 LCP 候选出发,而不是从组件名称是否叫 Hero 出发。
先回答三个问题:
Next.js 16 提供 preload 属性,并弃用了旧的 priority。当同一张课程封面在所有主要断点都明确是 LCP、而且需要尽早在 <head> 中发现时,可以这样写:
<div className="mx-auto max-w-[1184px] px-4">
<div className="relative aspect-[2/1] overflow-hidden rounded-3xl">
<Image
src={course.coverUrl}
alt={`${course.title}课程封面,画面展示课程编辑器和学习路线`}
fill
preload
sizes="(max-width: 1183px) calc(100vw - 32px), 1152px"
className="object-cover"
/>
</div>
</preload 会为图片加入预加载提示。不要再同时添加 loading 或 fetchPriority,否则同一资源出现相互重叠的加载指令,代码意图也会变得含糊。
如果图片在 HTML 中本来就会很早被发现,只是希望浏览器不要延迟它,可以考虑:
<Image
src={course.coverUrl}
alt={`${course.title}课程封面`}
width={1600}
height={900}
loading="eager"
fetchPriority="high"
sizes="(max-width: 1183px) calc(100vw - 32px), 1152px"
className="h-auto w-full"
/>loading="eager" 表示不要懒加载,fetchPriority="high" 是给浏览器的优先级提示。二者可以在证据充分时组合,但通常应从最少的提示开始;它们不保证图片一定成为第一个完成的请求,也不能补救过慢的图片服务。

可以按下面的决策表选择:
预加载不是免费的。预加载三张“也许会显示”的轮播图,可能挤占 CSS、字体和真正 LCP 图片的带宽;给所有卡片设置 fetchPriority="high",等于没有优先级。应该在真实页面的 Performance、Network 和 Web Vitals 数据里确认,而不是仅凭肉眼判断。
使用默认图片优化器时,远程图片 URL 会由服务器获取。此时允许规则不只是“消除报错”,还是服务器允许访问哪些地址的安全边界。remotePatterns 应尽可能约束协议、主机、端口、路径和查询参数:
// next.config.mjs:适用于使用 Next.js 默认图片 loader 的项目
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'media.edu-free.com',
port: '',
pathname: '/uploads/**',
search: '',
},
],
localPatterns: [
{
pathname: '/course-assets/**',
search: '',
},
],
这个例子表达的是:
media.edu-free.com;/uploads/ 下;search: '' 表示不允许查询字符串;
* 匹配一个路径段或一个子域段,** 用于路径尾部的多个段,或主机名前部的多个子域。不要把 ** 当成可以放在任意中间位置的正则表达式。
例如 /uploads/** 可以覆盖 /uploads/2026/course/cover.png;而 *.example.com 只表达一层子域。能写出固定主机时,不要为了省配置直接放开所有域名。
search 是精确匹配:
search: '':不允许任何查询字符串;search: '?v=2':只允许这一整段查询;search:允许任意查询参数,范围明显更宽。如果媒体服务依赖每次都变化的签名查询参数,通常无法用一条精确 search 覆盖。应评估由可信后端生成受控 URL、建立专门代理,或让上游媒体服务验证签名,而不是无意识地放开用户可控查询。
Next.js 16 的默认 loader 对带查询参数的本地图片要求更加明确。如果代码使用:
<Image
src="/course-assets/cover.png?v=2"
alt="Next.js 图片课程封面"
width={1600}
height={900}
/>就需要让 localPatterns.search 与查询规则相符。更常见、更利于长期缓存的方案,是让构建产物使用内容哈希,或把版本写进文件名,而不是给本地静态图片随意追加参数。
远程来源可能先返回重定向,再把优化器带到另一个地址。重定向目标不会重新经过同一轮 remotePatterns 匹配,因此安全敏感的项目可以把 maximumRedirects 设为 0,或保持一个小上限,并保证最终媒体服务本身可信。dangerouslyAllowLocalIP 默认应保持 false,否则图片优化端点可能成为访问内网资源的入口。
默认图片优化请求不会替你转发用户的 Cookie 或 Authorization。需要私有鉴权的图片不能假设 Image 会自动携带服务端身份;可以使用短期签名 URL、受控媒体代理,或者在确认风险后直接原样交付。
上面的 remotePatterns、localPatterns、重定向、本地 IP、响应体和优化 API 质量限制主要属于 Next.js 默认 loader / 默认优化器链路。项目一旦切换为自定义 loader,就必须重新确认哪些校验仍会执行,不能把这段配置当作外部图片服务的自动防火墙。
Next.js 图片链路有三种容易混在一起的形态:
默认情况下,Image 会生成指向 /_next/image 的候选 URL。服务器优化器校验来源、拉取文件、缩放、选择格式并缓存响应。静态导出没有可运行的默认优化端点,因此若仍希望使用图片候选,通常要配置外部 loader,或者选择 unoptimized。
当前项目的 next.config.mjs 包含:
const nextConfig = {
images: {
loader: 'custom',
loaderFile: './lib/utils/loader.ts',
remotePatterns: [
{
protocol: 'https',
hostname: 'cms.edu-free.com',
port: '',
pathname: '/uploads/**',
search: '',
},
{
protocol: 'https',
hostname: 'media.edu-free.com',
port:
而 lib/utils/loader.ts 的核心逻辑是:
export default function myImageLoader({
src,
width,
quality,
}: {
src: string
width: number
quality?: number
}) {
return `${src}${src.includes('?') ? '&' : '?'}w=${width || 0}&q=${
这段代码能证明的只有一件事:每个候选 URL 会追加宽度和质量参数。它不能单独证明 media.edu-free.com 会:
w;q 重新编码;Accept 输出 WebP 或 AVIF;还要注意,Next.js 默认 loader 中针对 remotePatterns 的来源校验,不应被假定为自定义 loader 的完整保护。自定义 loader 生成的是外部服务 URL,不再经过同一条默认 /_next/image 处理链路。即使前端函数做了域名判断,外部图片服务仍必须在服务端重新校验,因为攻击者可以绕过页面,直接请求它的公开接口。
当前函数还有一个容易忽略的默认值差异:组件没有传 quality 时,它会生成 q=100,而 Next.js 16 内置质量白名单的默认值是 75。如果团队希望统一使用 75,应同时确认组件、loader 和媒体服务的取值,不能只修改其中一层。
全局 loader 也会作用于本地静态 import 和 public 图片。它给 /_next/static/media/...png 或 /logo.png 追加 w、q,并不会让普通静态文件服务器凭空生成多个像素版本;在默认静态文件服务下,这些 URL 通常仍返回同一份原文件。静态 import 仍能提供固有尺寸、内容哈希和模糊占位元数据,但真正的多尺寸响应需要另有图片处理服务。
一个更严格的 URL 构造函数可以限制明显错误,但它仍不是最终安全边界:
// lib/utils/loader.ts
type ImageLoaderProps = {
src: string
width: number
quality?: number
}
const MEDIA_ORIGINS = new Set([
'https://cms.edu-free.com',
'https://media.edu-free.com',
])
const ALLOWED_QUALITIES = new Set([75])
export default function cmsImageLoader({
这段示例只是展示“如何让 URL 构造意图更明确”,不能替代上游校验,也不应直接用于覆盖现有实现而不验证兼容性。loader 代码可能进入客户端产物,因此不能把媒体服务密钥写进去。
任选同一张源图,请求 w=320 和 w=1280 两个 URL,然后检查:
Content-Length 或实际传输体积是否明显不同;Content-Type 是否与约定格式一致;如果两个 URL 返回完全相同的原文件,继续优化 sizes 只能改变 URL 字符串,不能节省字节。此时应修复媒体服务,或回到 Next.js 默认优化器,而不是给组件添加更多属性。
图片体积通常由像素数、编码格式、质量和内容复杂度共同决定。只写 quality={50} 不代表一定省一半体积;如果 loader 或上游忽略参数,响应根本不会变化。
Next.js 16 默认允许的质量值是 [75]。如果产品确实需要低、中、高三个档位,可以在使用默认优化器的项目中显式配置:
// next.config.mjs:默认图片优化器示例
const nextConfig = {
images: {
qualities: [60, 75, 85],
},
}
export default nextConfig使用默认 loader 时,组件传入的值不在列表中,URL 生成阶段会选择最接近的允许值;直接请求默认优化 API 的白名单外质量参数会得到错误响应。白名单的意义不只是视觉一致,也能防止公开优化端点被任意质量组合放大缓存数量和 CPU 成本。
当前项目使用自定义 loader,行为不同:开发环境可以对未列入 qualities 的组件值发出提示,但自定义函数仍会收到传入值,外部媒体接口也不属于 Next.js 默认优化 API。必须让 loader 约定和媒体服务的服务端白名单一致,不能只在前端 JavaScript 中维护一个集合。
Next.js 16 的内置图片优化器默认使用 WebP。若要同时允许 AVIF 和 WebP,可以配置:
const nextConfig = {
images: {
formats: ['image/avif', 'image/webp'],
qualities: [60, 75, 85],
},
}
export default nextConfig浏览器发送 Accept,优化器按配置顺序选择第一个兼容格式;没有兼容项或源图属于动画格式时,可能保留原格式。AVIF 往往文件更小,但首次编码成本更高,而且每增加一种格式,就会增加相应缓存变体。不要仅因为“更新”就无条件打开,应根据真实素材、流量和缓存命中率测试。
如果前面还有代理或 CDN,必须正确转发 Accept,并让缓存键或 Vary 行为区分格式。否则支持 AVIF 的浏览器可能拿到 WebP,或者不支持 AVIF 的客户端命中错误缓存。
Next.js 16 默认图片优化器的 minimumCacheTTL 为 14400 秒,也就是 4 小时。最终缓存时间取该值与上游图片 Cache-Control 中更大者。可以根据业务更新频率提高它:
const nextConfig = {
images: {
minimumCacheTTL: 86_400,
qualities: [60, 75, 85],
},
}
export default nextConfig但缓存越久,替换同一个 URL 后看到旧图的时间也可能越长。Next.js 默认图片缓存没有通用的主动失效 API,更稳妥的策略是修改图片 URL,例如让 CMS 上传新文件名、添加受控版本标识,或使用静态 import 的内容哈希。自托管环境排障时可以检查 .next/cache/images,但删除服务器目录不应成为日常内容发布流程。

不同链路的配置作用范围要分清:
Next.js 16.1.6 没有图片磁盘缓存容量上限的配置项,不要照搬更新版本文档中的相关字段。版本化文档比搜索结果中指向最新版的代码片段更可靠。
图片加载前留一块空白,有时会让页面显得生硬。placeholder="blur" 可以先展示低清预览,再过渡到正式图片。对于受支持的本地静态 import,Next.js 能在构建时提供相应元数据:
import Image from 'next/image'
import courseCover from './course-cover.jpg'
export function LocalCourseCover() {
return (
<Image
src={courseCover}
alt="Next.js 图片课程封面,画面展示响应式图片候选"
placeholder="blur"
className="h-auto w-full rounded-2xl"
/>
)
}远程字符串无法在构建时读取像素,需要自己提供一个很小的 blurDataURL:
<Image
src={course.coverUrl}
alt={`${course.title}课程封面`}
width={1600}
height={900}
placeholder="blur"
blurDataURL={course.blurDataUrl}
sizes="(max-width: 1183px) calc(100vw - 32px), 1152px"
className="h-auto w-full rounded-2xl"
/>最好由 CMS 上传流程为图片生成低分辨率预览,而不是在每次页面请求时临时计算。blurDataURL 应非常小;把完整图片转成巨大的 Base64 塞进 HTML,会增加文档体积,反而拖慢首屏。
模糊占位解决的是感知过渡,不会让最终主图变小,也不能代替宽高占位。低清预览过于清晰、持续过久或颜色反差太大,还可能让用户误以为页面没有完成加载。
位图优化流程并不适用于所有资源。SVG 本身是矢量文本,动画 GIF 也不能像普通静态照片那样随意转码而不丢失动画。
对于可控的本地 SVG,可以直接使用固定 URL:
<Image
src="/icons/play-course.svg"
alt="播放课程"
width={24}
height={24}
unoptimized
/>如果图标旁边已有“播放课程”文字,它就是装饰,应改成 alt=""。简单图标也可以写成经过审查的内联 SVG,从而使用 currentColor,但必须保留正确的可访问名称。
Next.js 并不内置“任意 .svg import 后直接当 React 组件”的能力。下面这种写法只有在项目额外配置了 SVGR 等 loader 后才成立:
// 只有项目已配置 SVGR 时才可用
import PlayIcon from './play.svg'
export function PlayButton() {
return <PlayIcon aria-hidden="true" />
}当前课程项目没有这项配置,因此不能把它写成默认用法。
如果确实让默认图片优化器代理 SVG,需要审慎评估 SVG 内嵌脚本和外部引用风险。官方建议同时使用下载型内容处置与严格 CSP:
const nextConfig = {
images: {
dangerouslyAllowSVG: true,
contentDispositionType: 'attachment',
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
}
export default nextConfig这不是“让所有外部 SVG 都安全”的开关。更稳妥的做法仍是只接受可信 SVG,上传时清洗内容,并避免把用户提供的任意矢量文件以内联方式执行。
unoptimized 会让 Image 原样使用 src,不经过默认缩放和格式转换:
<Image
src="/examples/course-editor-demo.gif"
alt="课程编辑器中拖动章节排序的动画演示"
width={960}
height={540}
unoptimized
/>它适合:
大动画通常更适合转成视频格式,并提供暂停、控制和减少动态效果方案。把十几 MB 的 GIF 放进课程正文,即使标记 unoptimized 也不会改善体验。
data: 和 blob: 地址也不会走常规远程优化流程。blob: 常用于用户本地预览,需要在组件卸载后调用 URL.revokeObjectURL() 释放;上传完成后,正式内容仍应换成受控媒体 URL。
public 是部署输入,不是运行时网盘public 目录的规则看似简单,却很容易在上线后踩坑。
目录结构:
public/
├── downloads/
│ └── image-delivery-checklist.pdf
├── icons/
│ └── play-course.svg
└── manifest.webmanifest对应 URL:
/downloads/image-delivery-checklist.pdf
/icons/play-course.svg
/manifest.webmanifest不要写成 /public/icons/play-course.svg。同时避免让 public 文件路径和 App Router 页面路径冲突,也不要占用 Next.js 框架保留的路径。
Next.js 无法确认 public 文件是否会被替换,因此默认响应通常使用:
Cache-Control: public, max-age=0如果文件需要长缓存,优先使用带版本或内容哈希的文件名,并在部署层配置明确的缓存规则。静态 import 更适合参与构建的图片,因为它天然产生内容哈希;下载手册如果需要稳定链接,可以把版本写入文件名,例如 image-delivery-checklist-v2.pdf。
public在本地开发中把上传文件写进 public/uploads 似乎能工作,但在无状态服务器、容器滚动部署或多实例环境里会出现:
自在学的课程封面应上传到 CMS 或对象存储,再把媒体 URL 和真实宽高写进课程数据。public 只保存随代码版本发布的静态输入。
alt 不是文件名,也不是 SEO 关键词容器。它的目标是:图片无法显示或用户使用屏幕阅读器时,页面意义是否仍然完整。
同一张图片在不同上下文中,替代文本可能不同:
不要写 alt="图片"、alt="课程图片" 或把文件名直接放进去。也不要重复紧邻的 <figcaption>。如果课程截图需要解释,可以组合语义结构:
<figure>
<Image
src={lesson.editorScreenshotUrl}
alt="课程编辑器左侧为章节树,右侧为正在编辑的图片章节"
width={1440}
height={900}
sizes="(max-width: 900px) calc(100vw - 32px), 868px"
className="h-auto w-full rounded-xl"
/>
<figcaption>
编辑器会把章节顺序与右侧正文预览保持同步。
</figcaption>
</figure>若图片承载复杂流程,正文还要提供等价说明;让读者“只能看图才能完成课程”,既不利于无障碍,也不利于搜索和复习。
现在把来源、几何尺寸、响应式和加载优先级放回同一个页面。假设课程详情页只有一张稳定的首屏主图,它在手机和桌面都是可信的 LCP 候选;推荐卡片位于首屏下方。
// app/courses/[slug]/CourseHero.tsx
import Image from 'next/image'
import officialBadge from './official-course-badge.png'
type CourseHeroProps = {
course: {
title: string
summary: string
coverUrl: string
coverAlt: string
}
}
export function CourseHero({ course }: CourseHeroProps
这里有几项需要结合业务确认:
preload 只因为它被确认是单一 LCP 候选,不是因为组件名叫 Hero;aspect-ratio,图片到达前空间已经存在;sizes 描述最大 1152px 的内容区,而不是无条件写 100vw;quality={75} 属于 Next.js 16 默认允许值,也要让上游媒体服务认可;coverAlt 应根据封面在当前页面提供的额外信息填写,不能机械复制课程标题;// components/courses/CourseCard.tsx
import Image from 'next/image'
import Link from 'next/link'
type CourseCardProps = {
course: {
slug: string
title: string
coverUrl: string
lessonCount: number
}
}
export function CourseCard({ course }: CourseCardProps) {
卡片封面没有增加 preload、loading="eager" 或高抓取优先级,继续使用默认懒加载。链接内已经有课程标题,封面没有额外信息时使用空 alt,避免链接名称重复。
export function ImageLessonResources() {
return (
<aside aria-labelledby="image-resources-title">
<h2 id="image-resources-title">本章资料</h2>
<a href="/downloads/image-delivery-checklist-v1.pdf" download>
下载图片交付检查表(PDF)
</a>
</aside>
)
}文件由部署版本管理,若内容发生变化就更新文件名。这样浏览器和 CDN 不必猜测同一个 URL 何时失效。
先列出每个资源的所有者和更新时间:跟代码发布的图片使用静态 import,需要固定 URL 的原样文件进入 public,运营和用户内容进入 CMS 或对象存储。
让 CMS 保存源图真实宽度、高度、格式和可选的低清预览。上传阶段统一限制文件类型、像素和体积。
为每个图片容器确定加载前就存在的宽高比。普通图片用正确的 width / height,裁切卡片用有稳定比例的父容器加 fill。
从真实 CSS 网格反推 sizes,并在手机、平板和桌面检查 currentSrc,而不是复制一条通用字符串。
图片优化不能只看“页面感觉挺快”。浏览器开发者工具可以把问题拆成可重复的证据。
在 Elements 面板选中图片,检查:
srcset 是否包含多个候选;sizes 是否与当前布局槽位一致;currentSrc 最终指向哪个 URL;loading="lazy";在 Network 面板打开图片请求,记录:
当前项目尤其要对比:
https://media.edu-free.com/uploads/example.png?w=320&q=75
https://media.edu-free.com/uploads/example.png?w=1280&q=75如果响应像素、体积和内容完全相同,说明 w 可能只是装饰参数。若媒体服务明确允许 q=60、q=85,而两者响应仍没有变化,质量参数可能未被处理;若服务端只允许 75,则其他质量应被明确拒绝。每次参数顺序变化都会 MISS 时,还要统一 URL 规范化和缓存键。
调试时可以临时勾选 Disable cache 查看首次请求,但最后一定要取消,再验证真实缓存行为。一直禁用缓存只能证明冷启动,无法证明生产访问是否高效。
现在为“自在学”的新课程列表设计图片交付。已知条件如下:
w 和 q”,没有测试报告。请完成以下设计:
public 或 CMS;sizes;getImageProps() 和 <picture> 实现两种主封面裁图;preload;remotePatterns 在当前自定义 loader 下能保护什么、不能替代什么;alt 还是描述性 alt,并解释上下文。完成这一章后,再看到一张图片,不应只问“该用 Image 还是 <img>”,而应沿着整条链路检查:
public 还是内容系统拥有;sizes 是否一致;Accept 是否穿过代理;next/image 最重要的能力,不是替开发者隐藏所有细节,而是把浏览器需要的布局、候选和加载意图组织起来。只有当 loader、媒体服务、缓存与内容流程也履行各自职责时,这份契约才真正成立。
下一章将进入表单和用户交互,继续完善课程页面中的报名、收藏与学习反馈流程。
只为经过确认的 LCP 候选选择 preload、eager 或 fetchPriority。折叠线下图片保持懒加载。
明确当前使用默认优化器还是自定义 loader。若为自定义服务,验证每个 w、q、格式和缓存参数确实被服务端执行。
最后检查替代文本、键盘链接名称、减少动态效果、失败状态,以及图片 URL 更新后的缓存失效路径。