1. 为什么本地图片在 Vue 3 里这么容易"翻车"
1.1 从一段最常见的报错说起
我见过太多人第一次在 Vue 3 项目里引一张本地图片,控制台直接给你来一句Failed to load resource: 404,或者干脆页面上一片空白,<img>的src明明白白写着./assets/logo.png,路径看着一点毛病没有,可它就是不出来。更让人抓狂的是,同一张图放在public目录下能显示,挪到src/assets里就失效;开发环境跑得好好的,npm run build之后丢到服务器上又是满屏裂图。
这类问题的根源,其实不在 Vue 本身,而在你项目底层的构建工具。现在用 Vue 3 起项目,绝大多数人走的是 Vite 这条路(npm create vue@latest默认就是它),少部分老项目还在用 Vue CLI 的 webpack。这两套东西对静态资源的处理逻辑完全不一样,Vue 只是把模板编译了一下,真正决定"这张图打包后叫什么名字、放在哪个目录、引用路径长什么样"的,是 Vite 或 webpack。
所以想彻底搞明白本地图片和静态资源怎么加载,得先把构建工具的这套规则吃透。这篇东西我打算按我自己实际项目的思路来捋一遍:先讲清楚资源处理的底层模型,再把几种加载姿势挨个拆开对比,然后给一个能直接抄的完整实操,最后聊聊"怎么判断资源到底加载完了"这个被很多人忽略的细节。不管你是刚上手 Vue 3 的新人,还是用了两三年但一直靠"试试看哪个路径能跑"的老手,应该都能捞到点东西。
1.2 Vite 的资源处理模型到底做了什么
先给结论:Vite 在处理静态资源时,把资源分成了两大类,一类是会被构建流程接管、参与哈希重命名的,一类是原样拷贝、不参与构建的。你后面遇到的所有路径问题,基本都能归因到"你把资源放错了类别"。
Vite 基于 Rollup 和 esbuild,开发阶段它用原生 ESM 直接给你提供文件,不做打包;生产构建时才真正走一遍 Rollup。对于src目录下的资源,Vite 的规则是这样的:
- 你在 JS/TS 里
import一张图片,Vite 会在构建时把它当成一个模块来处理,返回一个最终的 URL 字符串。小文件(默认小于 4KB)会被转成 base64 内联进代码里,大文件则被拷贝到输出目录的assets文件夹下,并且带上内容哈希,比如logo-a1b2c3d4.png。 - 你在 CSS 里写
url('./bg.png'),Vite 同样会解析这个相对路径,走一样的内联或哈希流程。 - 你在 Vue 的
<template>里直接写死的相对路径,比如<img src="./assets/logo.png">,Vite 的 Vue 插件也能识别并处理。
这个"带哈希"的操作就是一切麻烦的起点,也是它最大的价值。哈希是为了缓存控制——内容变了哈希就变,浏览器一定会拉新文件;内容没变哈希不变,浏览器直接用缓存。这是生产环境性能优化的标准做法,你不能因为路径写不对就把它关掉,正确的做法是学会跟它配合。
而public目录完全是另一套逻辑。放进去的文件会被原封不动拷贝到输出目录根部,文件名不变、路径不变,你写/logo.png引用的就是它。听起来很方便对吧?代价是它失去了哈希缓存的能力,也失去了构建期的路径校验——你写错了,构建不会报错,只有运行时才 404。
理解了这个二分法,后面所有具体写法就都是它的推论了。
1.3 三种资源存放位置的取舍逻辑
实际项目里,静态资源其实有三个常见的落点,我把它们的适用场景整理成一张表,你以后往哪儿放直接对照着看:
| 存放位置 | 引用方式 | 是否参与构建 | 是否有哈希 | 适用场景 |
|---|---|---|---|---|
src/assets | import或相对路径 | 是 | 是 | 组件内使用的图片、图标、字体,随代码一起走版本 |
public | 绝对路径/xxx.png | 否 | 否 | favicon.ico、robots.txt、需要固定 URL 的文件 |
| 远程 CDN / 对象存储 | 完整 URL | 否 | 由对方决定 | 体积大、更新频繁、多项目共用的资源 |
这里有个我踩过的坑值得单独拎出来说:public目录下的文件,你没法用import去引它。有人图省事,把图丢进public,然后在组件里写import logo from '/logo.png',心想这样总该行了吧。结果 Vite 会把它当成一个 URL 模块处理,行为跟你想的完全不一样,路径会错乱。记住一句话:public里的东西只用绝对路径字符串引用,src里的东西才用import或相对路径引用,两者别混着来。
还有一个容易被忽略的点:public下的资源不参与构建,意味着它不会被压缩、不会被 tree-shaking、也不会有任何构建期校验。所以我个人的习惯是,能放src/assets的绝不放public,public只留给那些"必须是固定路径"的文件,比如网站图标、某些第三方验证文件、以及需要被外部系统按固定 URL 访问的内容。
2. 静态资源加载的五种正确姿势
2.1 public 目录:最省事但最受限
先从最简单的说起。你把图片丢进项目根目录的public文件夹,然后这样引用:
<img src="/logo.png" alt="logo" />或者在建了public/images子目录的情况下:
<img src="/images/logo.png" alt="logo" />注意这里用的是以斜杠开头的绝对路径,且不能带public这三个字。public只在源码目录里存在,构建完之后它的内容被平铺到输出目录根部,所以public/images/logo.png对应的运行时路径就是/images/logo.png。
它的好处是直白,不需要import,路径一眼能看懂,图片名也不会被改。适合的场景比如favicon.ico(浏览器默认就去根目录找)、一些给外部爬虫或第三方平台读取的静态文件。
但它的短板很明显。第一,没有哈希,浏览器缓存策略只能靠服务端配置或者手动加查询参数;第二,路径写错了构建期不报错,上线后才暴露;第三,如果项目部署在子路径下(比如https://example.com/myapp/),你写死的/logo.png会指向根域名的/logo.png而不是子路径下的,直接 404。
处理子路径部署的问题,得配合一个环境变量。Vite 会在构建时把vite.config.js里的base配置注入成一个可读的环境变量:
const logoUrl = `${import.meta.env.BASE_URL}logo.png`BASE_URL在开发时是/,如果你把base设成了/myapp/,构建后它就自动变成/myapp/。这样写就不用担心部署路径变化了。这个细节很多人不知道,一旦遇到"本地好好的,测试环境裂图",八成就是这儿。
2.2 import 导入:让构建工具帮你算哈希
我日常最推荐的写法是在 JS 或 TS 里import:
import logoUrl from '@/assets/logo.png' // 在 setup 里直接用 const imgSrc = logoUrl<template> <img :src="imgSrc" alt="logo" /> </template>这样 Vite 会在构建时把图片处理掉,返回最终 URL,同时享受哈希缓存、体积压缩、构建期校验三件套。路径写错了,构建直接报错,不会等到线上才发现。@这个别名通常在vite.config.js里配成指向src,如果没配就得用相对路径。
这里有个关键的认知点:import得到的是一个字符串 URL,不是一个图片对象。有人会问"导入之后怎么拿到图片的宽高",答案是拿不到,import只是给了你一个路径,浏览器还没开始加载呢。想要宽高得等<img>真正 load 之后才能读到naturalWidth、naturalHeight。这个区分很重要,后面讲"判断资源是否加载完成"的时候还会用到。
对于小图标这类文件,我一般会配一个内联阈值。Vite 默认是 4KB,你可以在vite.config.js里调整:
// vite.config.js export default defineConfig({ build: { assetsInlineLimit: 8192 // 8KB 以下的资源转 base64,减少请求数 } })调这个值的思路是:小文件内联能省一次 HTTP 请求,但有代价——base64 会让文件体积膨胀约 33%,而且内联进 JS 之后就失去了独立的缓存能力,内容一变整个 JS 的哈希都跟着变。所以图标字体、小 logo 内联划算,大图千万别内联。8KB 到 10KB 是我实测下来比较舒服的区间。
2.3 new URL + import.meta.url:动态路径的救命稻草
现在来一个真实场景。你要做一个图片展示组件,路径是根据数据动态拼出来的:
const imageName = props.name // 比如 'banner' const src = `@/assets/${imageName}.png` // 这样写是错的,Vite 处理不了这种字符串模板拼接的路径,Vite 在构建时根本没法静态分析,它不知道你最终会拼出什么文件,所以不会处理,运行时自然就 404。这是新手最容易掉进去的坑之一。
正确的解法是用new URL配合import.meta.url:
const imageName = props.name const src = new URL(`../assets/${imageName}.png`, import.meta.url).href这套写法的原理是:import.meta.url表示当前模块自身的 URL,new URL(相对路径, 基准)会把两者解析成一个绝对 URL。Vite 会识别这种模式,在构建时把匹配到的资源一起打包并重写路径。它支持../这种相对形式的拼接,但不支持完全动态的片段,比如new URL(someVariable, import.meta.url)这种整体变量是不行的,因为构建期没法确定范围。
import.meta.glob也是同一个思路的延伸,我们下一节说。这里先提醒一句:new URL的第一个参数必须是能静态分析的字符串模板,里面可以有一个变量,但路径的目录部分得写死,不然构建工具帮不了你。
2.4 import.meta.glob:批量导入的批量武器
如果你要一次性引入一整个目录的图片,比如做图标选择器、图库、或者根据配置渲染一堆素材,逐个import会写到吐。这时候用import.meta.glob:
// 默认懒加载,返回的是 () => Promise 的函数 const modules = import.meta.glob('@/assets/icons/*.png') // 想要直接拿到 URL 字符串,加 eager const icons = import.meta.glob('@/assets/icons/*.png', { eager: true, query: '?url', import: 'default' })eager: true表示立即导入而不是懒加载,query: '?url'告诉 Vite 把资源当 URL 处理,import: 'default'取默认导出。这几行下来,你就能得到一个键是文件路径、值是最终 URL 的对象,随便遍历。
注意:这里的参数写法存在版本差异。Vite 4 及更早版本用的是
as: 'url',Vite 5 开始改成了query: '?url'配合import: 'default',as已经废弃。如果你的项目是从老版本升上来的,看到as别慌,但新项目一律用新写法。
import.meta.glob还有一个很实用的变体是?raw,直接把文件内容当字符串读进来:
const svgContent = import.meta.glob('@/assets/icons/*.svg', { eager: true, query: '?raw', import: 'default' })这个在做 SVG 内联渲染(比如需要动态改颜色)的时候特别香,省了引入额外的 SVG 处理插件。
2.5 CSS 与内联样式里的资源引用
CSS 里的资源引用分两种情况,很容易混淆。
第一种是写在外部.css或.scss文件里:
.banner { background-image: url('../assets/banner.png'); }这个相对路径是相对于当前的 CSS 文件位置,Vite 会正常解析并处理,享受哈希和压缩。写对了就没事。
第二种是写在 Vue 组件的内联样式里:
<div :style="{ backgroundImage: `url(${bannerUrl})` }"></div>这种情况你必须自己import好bannerUrl再拼进去,不能指望 Vite 帮你解析字符串里的相对路径。这里有个坑:不要用@别名去拼内联样式的 URL,因为@别名只在构建工具的静态分析阶段有效,运行时浏览器不认识它。要么用import进来,要么用new URL动态解析。
<script setup> import bannerUrl from '@/assets/banner.png' </script> <template> <div :style="{ backgroundImage: `url(${bannerUrl})` }"></div> </template>顺带提一句字体文件。@font-face里引字体跟引图片是一个逻辑,放在src/assets/fonts下用相对路径引就行,注意字体文件通常体积不小,别用 base64 内联,让它们走独立的哈希文件,配合font-display: swap避免文字闪烁。
3. 完整实操:搭一个能跑通所有场景的示例工程
3.1 工程初始化与目录规划
光说理论没用,我带你走一遍完整流程。先起项目:
npm create vue@latest my-assets-demo cd my-assets-demo npm install创建时勾上 TypeScript 和 Router 都行,跟资源加载没关系。然后规划一下目录,我习惯这样分:
my-assets-demo/ ├── public/ │ ├── favicon.ico # 固定路径,不参与构建 │ └── robots.txt ├── src/ │ ├── assets/ │ │ ├── images/ # 参与构建的图片 │ │ │ ├── logo.png │ │ │ └── banner.png │ │ ├── icons/ # 批量图标,走 glob │ │ │ ├── home.svg │ │ │ └── user.svg │ │ └── fonts/ │ └── utils/ │ └── assets.ts # 通用资源工具 └── vite.config.ts这个划分的逻辑是:public只放"必须固定路径"的,其余全进src/assets按类型分子目录。这样找文件不费劲,构建规则也清晰。
顺手把vite.config.ts配好别名和构建参数:
import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], base: '/', // 部署到子路径时改成 '/myapp/' resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, build: { assetsInlineLimit: 8192, // 把图片单独归类输出,方便排查 rollupOptions: { output: { assetFileNames: 'assets/[ext]/[name]-[hash][extname]' } } } })assetFileNames这一项是我强烈建议加上的。默认所有资源都堆在assets一个目录下,图片一多就乱成一锅粥。按扩展名分类输出之后,构建产物一眼能看清,排查问题时省事很多。
3.2 写一个通用的资源加载工具函数
与其在每个组件里重复new URL和import.meta.glob,不如封装一个工具文件src/utils/assets.ts:
// 动态解析单张图片的 URL export function resolveAsset(path: string): string { return new URL(`../assets/${path}`, import.meta.url).href } // 批量导入某个目录下的图片 export function loadImages(): Record<string, string> { const modules = import.meta.glob('../assets/images/*.{png,jpg,webp}', { eager: true, query: '?url', import: 'default' }) const result: Record<string, string> = {} for (const [key, url] of Object.entries(modules)) { const name = key.split('/').pop()?.replace(/\.[^.]+$/, '') ?? '' result[name] = url as string } return result }resolveAsset处理单个动态路径,loadImages把整个目录变成"文件名不带扩展名 → URL"的映射,组件里用起来就很舒服。这里把文件名去掉扩展名做键,是为了调用方便,你想加个容错判断(比如找不到就返回空字符串)也很容易。
提示:
resolveAsset里的../assets/是相对utils目录的。如果你把工具放到别的层级,这个相对路径要跟着改。这也是new URL不如字符串路径直观的地方,写的时候多留意一眼。
3.3 组件里落地:图片轮播与图标切换
工具写好了,来两个真实组件。先做图片轮播,用loadImages把所有 banner 拉进来:
<script setup lang="ts"> import { ref, computed } from 'vue' import { loadImages } from '@/utils/assets' const images = loadImages() const imageList = Object.values(images) const current = ref(0) const currentSrc = computed(() => imageList[current.value]) function next() { current.value = (current.value + 1) % imageList.length } </script> <template> <div class="carousel"> <img :src="currentSrc" alt="banner" @click="next" /> <p>{{ current + 1 }} / {{ imageList.length }}</p> </div> </template>这个组件的关键点是:所有图片在模块初始化时就已经被打包成 URL 了,切换时只是换src,浏览器会走缓存,秒切没有延迟。如果你的图特别多、体积特别大,可以去掉eager: true改成按需加载,那时候返回的是函数,调用后返回 Promise,用法就变成异步的了。
再做一个图标切换组件,用?raw把 SVG 内容读进来内联渲染:
<script setup lang="ts"> const svgModules = import.meta.glob('@/assets/icons/*.svg', { eager: true, query: '?raw', import: 'default' }) as Record<string, string> const iconMap: Record<string, string> = {} for (const [key, content] of Object.entries(svgModules)) { const name = key.split('/').pop()?.replace('.svg', '') ?? '' iconMap[name] = content } defineProps<{ name: string }>() </script> <template> <span class="icon" v-html="iconMap[name]"></span> </template>内联 SVG 的好处是能用 CSS 控制fill和color,比<img src>引 SVG 灵活得多。代价是这些 SVG 内容会进 JS 包,图标上百个的话要掂量一下体积。
3.4 打包验证与环境变量适配
写完跑一遍构建:
npm run build打开dist/assets/看一眼,你应该能看到图片被按扩展名分类、带上了哈希。再npm run preview跑一下生产版本,确认路径都对。这一步千万别省,很多问题只在生产构建里才冒出来。
如果你的项目要部署到子路径,比如https://example.com/myapp/,把base改成/myapp/再构建。这时候import得到的路径会自动带上/myapp/前缀,new URL解析出来的也是对的,唯一要注意的是public里的资源,必须用import.meta.env.BASE_URL拼,不能写死斜杠。
我一般会再建一个简单的检查脚本,构建后扫一遍产物的 HTML,看看有没有残留的错误路径。虽然土,但比上线后用户截图来问强得多。
4. 判断静态资源是否加载完成:不只是一句 onload
4.1 complete 属性和 decode 方法
"资源加载完了吗"这个问题,在图片场景下比想象中复杂。因为你import一张图拿到的只是个 URL,图片真正加载要等浏览器发起请求、解码、渲染。有几个 API 可以帮你判断状态。
第一个是img.complete,这是个同步只读布尔值,表示图片是否已经加载完成(含加载成功和加载失败两种情况)。用法:
const img = document.querySelector('img') if (img.complete) { // 已经加载完或失败了 } else { // 还在加载 }但complete有个坑:它无法区分"加载成功"和"加载失败"。失败了它也是true。所以光看它不够,得配合naturalWidth:
if (img.complete && img.naturalWidth > 0) { // 确实加载成功了 }naturalWidth为 0 说明图片根本没解码出内容,基本就是失败了。
第二个是img.decode(),它返回一个 Promise,在图片解码完成、可以安全渲染时 resolve,失败时 reject:
try { await img.decode() console.log('图片可以无闪烁渲染了') } catch (e) { console.error('解码失败', e) }decode()比单纯监听load事件更精确的地方在于,它保证的是"解码完成",而不只是"数据下载完成"。对于要在 canvas 上绘制或者需要立刻显示的场景,用decode()能避免下载完但还没解码导致的短暂空白闪烁。
4.2 封装一个 Promise 版的资源预加载器
实际项目里我很少零散用这些 API,都是封装成一个预加载工具。核心逻辑是这样:
export interface PreloadResult { src: string ok: boolean width?: number height?: number } export function preloadImage(src: string, timeout = 10000): Promise<PreloadResult> { return new Promise((resolve) => { const img = new Image() const timer = setTimeout(() => { img.src = '' resolve({ src, ok: false }) }, timeout) img.onload = () => { clearTimeout(timer) resolve({ src, ok: true, width: img.naturalWidth, height: img.naturalHeight }) } img.onerror = () => { clearTimeout(timer) resolve({ src, ok: false }) } img.src = src }) }几个设计上的考量值得说说。第一,不在失败时 reject,而是统一 resolve 一个带ok标记的对象。因为批量预加载时,一张图失败不应该炸掉整个流程,用 resolve 更符合"收集结果"的语义。第二,加了超时,图片请求卡住的情况虽然少见但确实存在,没有超时保护会一直挂着。第三,超时时把src置空,主动中断请求,避免内存泄漏。
用起来就是:
const results = await Promise.all( imageList.map((src) => preloadImage(src)) ) const failed = results.filter((r) => !r.ok) console.log(`失败 ${failed.length} 张`)4.3 批量资源的并发控制与超时处理
如果一次预加载几十上百张图,直接Promise.all开一堆并发请求是有问题的。浏览器的并发连接数有限制(同域一般 6 个左右),开太多反而互相拖慢,而且服务端可能限流。我一般的做法是控制并发数:
export async function preloadBatch( sources: string[], concurrency = 4 ): Promise<PreloadResult[]> { const results: PreloadResult[] = [] let index = 0 async function worker() { while (index < sources.length) { const current = index++ results[current] = await preloadImage(sources[current]) } } const workers = Array.from({ length: Math.min(concurrency, sources.length) }, worker) await Promise.all(workers) return results }这个"多个 worker 共享一个索引"的写法是我很喜欢的并发池模式,比各种花哨的调度器简单得多,而且不会漏掉任何一个任务。并发数我一般设 4 到 6 之间,实测下来在这个区间浏览器能跑到比较理想的总吞吐。
注意:并发数不是越大越好。我试过设 10,结果在移动端老设备上内存飙升,反而变慢。这个值跟图片体积、设备性能都有关,4 是个稳妥的默认值。
还有一个细节,如果你要判断的是整页所有资源(包括 CSS、JS、字体)而不是单张图,可以用performanceAPI:
const resources = performance.getEntriesByType('resource') const images = resources.filter((r) => r.initiatorType === 'img')initiatorType能区分资源是由什么触发的,img、link、script、css都能识别。做性能监控的时候这个很有用,能看清到底哪类资源拖慢了加载。
5. 常见问题速查与踩坑实录
5.1 路径类问题的排查思路
路径问题占了这类故障的一大半,我整理了一张对照表,遇到问题直接查:
| 现象 | 大概率原因 | 解决方向 |
|---|---|---|
| 开发环境正常,构建后裂图 | 用了@别名拼动态路径 | 改用new URL或import |
图片路径里出现了@或~ | 别名没被构建工具解析 | 换成相对路径或import |
public里的图能显示,src里的不行 | 混用了两种引用规则 | src下必须用import或相对路径 |
| 子路径部署后全部 404 | base没配,且写死了绝对路径 | 配base+ 用BASE_URL |
| 字符串模板拼出的路径失效 | 构建期无法静态分析 | 用import.meta.glob或new URL |
补充一个我踩过的坑:new URL里的相对路径是相对当前模块文件的,不是相对src目录。所以同样一段字符串模板写在不同层级的文件里,基准不一样,得仔细核对。有一次我把工具函数挪了个目录,忘了改../的层数,找了半小时才发现。
5.2 打包与部署相关的隐藏问题
上线之后才暴露的问题,往往跟构建配置和部署方式有关。几个高频的:
base路径配置。部署到子路径时必须配base,否则所有绝对路径引用都会指向域名根目录。配了之后import的资源会自动带上前缀,public资源需要你手动用BASE_URL。
资源文件被服务器拦截。有些服务器配置会拦截特定扩展名的请求,或者 MIME 类型没配好,导致图片返回了但浏览器不认。表现是控制台报net::ERR_...或者图片勉强加载但显示异常。这种情况查一下服务器的Content-Type配置。
缓存配置不当。带哈希的资源应该配长缓存,public里没哈希的资源得配短缓存或者用查询参数。如果你的public资源更新后用户看不到新版本,八成是缓存问题,加个版本号查询参数能治标:
const url = `${import.meta.env.BASE_URL}logo.png?v=${APP_VERSION}`CDN 没同步。用了 CDN 的话,构建产物的哈希文件名变了,但 CDN 缓存里还是老文件,导致 404。这个得配合 CDN 的刷新机制,通常 CI 里会自动处理。
5.3 性能与体验相关的优化技巧
最后聊几个提升体验的实操点,都是我实际项目里验证过有用的。
首屏大图用preload提示浏览器。如果某张图是首屏关键资源,可以在index.html里加一行:
<link rel="preload" as="image" href="/assets/banner.png" />这样浏览器会在解析到图片标签之前就开始下载,能明显改善 LCP(最大内容绘制)。注意href得是构建后的真实路径,所以通常配合构建插件自动注入。
用响应式图片减少移动端流量。同一张图给不同屏幕尺寸准备不同分辨率的版本,用srcset让浏览器自己选:
<img src="banner-800.webp" srcset="banner-400.webp 400w, banner-800.webp 800w, banner-1600.webp 1600w" sizes="(max-width: 600px) 400px, 800px" alt="banner" />这套东西对移动端加载速度提升很明显,但要求你在构建时生成多套尺寸,可以用vite-imagetools之类的插件自动化。
懒加载非首屏图片。原生loading="lazy"就能搞定大部分场景:
<img src="..." loading="lazy" decoding="async" alt="..." />decoding="async"让图片解码不阻塞主线程,配合懒加载效果更好。不过懒加载有个副作用——用户快速滚动时可能看到图片"渐显"的过程,如果你的设计对这点敏感,就只在长列表里用。
用 VS Code 的 Vue 3 snippets 提速。这类插件能帮你快速敲出v-img、script setup之类的模板,虽然跟资源加载没直接关系,但日常写组件的时候确实省事。我配了几个自己常用的代码片段,比如输入imgsetup自动展开成导入图片 + 绑定的完整结构,写 demo 的时候效率高不少。
说到底,Vue 3 里加载本地图片这件事,核心就两点:搞清楚资源在哪个目录、走不走构建,以及动态路径必须让构建工具能静态分析到。把这两条吃透,剩下那些具体写法都是推论。我在项目里踩的坑,绝大多数都能追溯到这两条上。
有一点想提醒刚开始做项目的朋友:别一遇到路径问题就去搜"Vue 图片不显示怎么办",然后随便抄一个能跑的方案。网上很多答案没区分 Vue 2 的 webpack 和 Vue 3 的 Vite,抄过来可能当时能跑,换个场景又崩。花半小时把构建工具的规则读一遍,后面能省下的调试时间远不止这半小时。
还有一个习惯我坚持了好几年:每建立一个新项目,先搭一个最小的资源加载 demo,把import、new URL、import.meta.glob、public这四种方式各跑一遍,确认全通再开始写业务。这十几分钟的成本,能避免后面大量"这图为什么不出来"的困惑,性价比高得离谱。