Nuxt Image 不是 img 的平替:一次本地 assets 图片 404 的排查

<NuxtImg> 很容易被当成一个更高级的 <img>。名字像,写法也像,迁移时看起来只是把标签换一下。

这次问题就出在这里:一批组件私有图片从 <img> 换成 <NuxtImg> 后,本地开发环境里图片突然打不开了。原始图片 URL 明明可以直接访问,但页面里经过 Nuxt Image 处理后变成了 404。

结论先放前面:<NuxtImg> 不是原生 <img> 的无脑替代。它是一个图片优化组件,会把 src 交给 provider 处理。默认 IPX provider 的本地图源目录是 public/;通过 Vite import 得到的组件私有资源 URL,例如 /_nuxt/components/foo/assets/icon.png,不是 IPX 的源图路径,直接传给 <NuxtImg> 很容易被改写成 /_ipx/.../_nuxt/... 并报 IPX_FILE_NOT_FOUND

现象

项目里有一些组件私有图片,原本是这样写的:

import topCoinSrc from './assets/top-coin.png'
<img :src="topCoinSrc" alt="" />

后来为了统一图片组件,把它改成了:

<NuxtImg :src="topCoinSrc" alt="" />

在 Vite dev 环境里,topCoinSrc 会被编译成类似这样的运行时 URL:

/_nuxt/components/home/RightIcons/assets/top-coin.png

这个 URL 直接访问是通的:

curl -sS -o /tmp/direct.png \
  -w '%{http_code} %{content_type} %{size_download}\n' \
  http://localhost:3000/_nuxt/components/home/RightIcons/assets/top-coin.png

# 200 image/png 3248

但页面里的 <NuxtImg> 最终请求变成了:

/_ipx/_/_nuxt/components/home/RightIcons/assets/top-coin.png

这条请求会失败:

curl -sS -o /tmp/ipx.json \
  -w '%{http_code} %{content_type} %{size_download}\n' \
  http://localhost:3000/_ipx/_/_nuxt/components/home/RightIcons/assets/top-coin.png

# 404 application/json 134

返回体里的关键信息是:

{
  "error": {
    "message": "[404] [IPX_FILE_NOT_FOUND] File not found: /_nuxt/components/home/RightIcons/assets/top-coin.png"
  }
}

同一个项目里,public/ 下的图片走 IPX 是正常的:

curl -sS -o /tmp/public.svg \
  -w '%{http_code} %{content_type} %{size_download}\n' \
  http://localhost:3000/_ipx/_/home/sidebar/v3/nav-home.svg

# 200 image/svg+xml 2370

这说明问题不在 Nuxt Image 本身坏了,而在图片来源不适合交给默认 IPX provider。

assetspublic 是两条链路

Nuxt 里本地图片大致有两类来源。

第一类是 public/。这里的文件会作为运行态静态资源暴露出来,例如:

public/home/sidebar/v3/nav-home.svg

页面里可以直接用:

<NuxtImg src="/home/sidebar/v3/nav-home.svg" />

Nuxt 的 public/ 目录说明里也明确提到,这个目录用来放需要通过应用根路径公开服务的静态资源。

第二类是 assets/ 或组件目录里的私有资源。它们会进入 Vite / bundler 管线:

import iconSrc from './assets/icon.png'

这种 import 得到的是一个被构建工具处理过的 URL。开发环境里可能长得像 /_nuxt/...,生产环境里可能带 hash,也可能被内联。它适合直接给原生 <img>、CSS background 或其他前端资源消费方使用。

这两类图片都叫“本地图片”,但处理链路不一样。public/ 是运行态可访问的静态文件;assets import 是构建产物 URL。

Nuxt Image 在这里做了什么

Nuxt Image 的 <NuxtImg> 文档里,基础示例用的是这样的路径:

<NuxtImg src="/nuxt-icon.png" />

这类路径默认来自 public/

Nuxt Image 的配置文档里有一个关键配置项 dir。它用于指定 IPX / IPX Static 的本地图源目录,默认值就是 public

也就是说,默认情况下:

<NuxtImg src="/home/sidebar/v3/nav-home.svg" />

会被 provider 处理成类似:

/_ipx/_/home/sidebar/v3/nav-home.svg

IPX 会去它认识的本地图源目录里找 home/sidebar/v3/nav-home.svg

但如果传进去的是 Vite import 产物:

/_nuxt/components/home/RightIcons/assets/top-coin.png

Nuxt Image 仍然会把它当成需要交给 provider 处理的 src,于是请求就会变成:

/_ipx/_/_nuxt/components/home/RightIcons/assets/top-coin.png

这时 IPX 会去源图目录里找 /_nuxt/components/... 这条路径。这个路径实际属于 Vite dev server 暴露出来的构建资源 URL,不在 IPX 的源图目录里,所以它找不到。

Nuxt Image providers 文档也把这条边界说得很清楚:需要被处理的本地图片应该放在项目 public 目录;assets 目录里的图片由项目 bundler 管理,不由 Nuxt Image 处理。

Astro 的 Image 为什么看起来不一样

这个问题容易和 Astro 的 <Image /> 混在一起。

Astro 的图片能力有一条很典型的写法:

---
import avatar from '../assets/avatar.png'
---

<Image src={avatar} alt="avatar" />

这里的 avatar 是 Astro 图片管线产出的 metadata。组件能拿到源文件、尺寸、格式等信息,再做构建期优化。

Nuxt / Vue 里的普通 Vite import 更接近:

import avatar from './avatar.png'

得到一个 URL 字符串。<NuxtImg> 看到的是这个 URL,不是 Astro 那种图片 metadata。它可以处理 URL,但不会自动把散落在组件目录里的 import 资源重新变成 IPX 源图。

所以不能把 Astro Image 的心智直接套到 Nuxt Image 上。

正确用法怎么选

如果图片在 public/ 下,并且希望 Nuxt Image 做尺寸、格式、质量或 provider 优化,用 <NuxtImg>

<NuxtImg src="/profile/badges/vip/ic_vip_level_9.png" alt="VIP9" />

如果图片来自远程 OSS / CDN,也可以考虑 <NuxtImg>,但要看 provider、domains 和远程优化规则是否已经配置好。没有配置时,它可能只是原样输出或走默认处理,不能默认等于“已经优化”。

如果图片是组件私有 import 资源,优先保留原生 <img>

import iconSrc from './assets/icon.png'
<img :src="iconSrc" alt="" />

这类图片通常是小图标、徽章碎片、装饰图。它们本来就应该交给 Vite 的资源管线处理,不需要再绕一层 IPX。

如果团队真的想让某个本地目录里的图片统一走 Nuxt Image,也可以配置 image.dir

export default defineNuxtConfig({
  image: {
    dir: 'assets/images',
  },
})

但这条路适合集中管理的内容图、营销图或文章图。引用时要按 image.dir 的源目录路径来写,而不是继续把组件私有 import URL 传进去。部署时还要确认这个目录能被运行时 IPX 读取,否则静态生成、serverless 或特定平台上仍然可能 404。

provider="none" 不是常规答案

Nuxt Image 里有一个 none provider,它本质上会原样返回 URL。理论上这样能让 import 出来的 /_nuxt/... URL 正常显示:

<NuxtImg provider="none" :src="iconSrc" alt="" />

但这不是一个好默认。

它保留了 <NuxtImg> 的组件名,却绕开了图片优化。review 的人看到这行代码时,容易误以为它仍在走 Nuxt Image 优化。团队后面继续迁移图片时,也会把“能显示”和“被优化”混成一件事。

除非项目已经明确要求所有图片标签都统一成 <NuxtImg>,并且团队接受 provider="none" 只是一个薄包装,否则组件私有资源直接用 <img> 更清楚。

排查这类问题的顺序

遇到 <NuxtImg> 后图片打不开,可以按这个顺序查:

  1. 打开 DevTools,看真实请求是不是走了 /_ipx/...
  2. 如果 URL 里出现 /_ipx/.../_nuxt/...,优先怀疑把 Vite import asset 传给了 IPX。
  3. 直接请求原始 /_nuxt/... URL,确认 bundler 资源本身能不能访问。
  4. 再请求 /_ipx/.../_nuxt/... URL,看是否出现 IPX_FILE_NOT_FOUND
  5. 检查 nuxt.config.ts 里的 image.dir、provider、domains。
  6. 按图片来源决策:组件私有资源改回 <img>;需要优化的稳定图片迁到 public/ 或明确的 image.dir 体系。

浏览器里也可以快速找出坏图:

Array.from(document.images)
  .filter((image) => !image.complete || image.naturalWidth === 0)
  .map((image) => ({
    src: image.getAttribute('src'),
    currentSrc: image.currentSrc,
    naturalWidth: image.naturalWidth,
    naturalHeight: image.naturalHeight,
  }))

我后面会怎么定规则

这次之后,我会把 Nuxt 项目里的图片先按来源分清楚,而不是批量把 <img> 换成 <NuxtImg>

小图标、组件私有装饰图、徽章碎片,继续用原生 <img> 或 CSS。它们的价值是稳定、贴近组件、路径清楚。

public/ 下的运行态图片、远程用户图片、内容图、需要响应式尺寸和格式优化的图片,再考虑 <NuxtImg><NuxtPicture>。这些图片才是 Nuxt Image 更擅长处理的对象。

Nuxt Image 仍然有价值,只是它解决的是“把图片交给 provider 优化”的问题,不是“替换所有 img 标签”的问题。