发布检查单打开时,四个问题同时亮了红灯:dist 到底从哪里来,图片与脚本的 URL 为什么变了,Vue Router 的页面为什么一刷新就 404,应用放到域名子路径后为什么只剩一片空白?奇怪的是,切回 npm run dev,这些问题又像从未发生过一样。
上一章刚把购物车业务状态收进 Pinia,往前还有已经拆好的组件、组合式函数和 Vue Router。它们在开发服务器里能够一起工作,说明应用内部已经连了起来;但“功能能运行”还不是“页面已交付”。这最后一步要回答的是:从第一章保存下来的 App.vue,连同后来接入的模板、状态、路由和 Store,究竟怎样变成服务器能够发送、浏览器能够加载的生产文件。
问题也因此换了一副面孔:npm run build 生成的文件不再叫 App.vue,而是带哈希的 JavaScript 和 CSS;开发服务器替我们兜住的模块请求与路径规则,到了静态服务器上也不会凭空存在。这些现象很少是组件语法突然失效,而是应用已经从“开发环境中的源码”走到了“服务器上的构建产物”。
发布检查就沿着现有 Vite 应用的同一张模块图向前推进:先确认开发服务器怎样提供模块、热更新怎样接住修改,再核对环境变量、静态资源与代码拆分如何进入构建,最后检查 dist 和真实部署路径。遇到白屏或路由刷新失败,也先判断出问题的文件由谁读取、在哪个阶段生效,再沿模块请求找到断点。
先给这次收尾定下一条主线:开发时,Vite 围绕浏览器的模块请求按需提供并转换代码;构建时,Vite 从入口追踪完整模块图,生成适合部署的生产文件。 前十四章写出的组件、路由和 Store 都要经过这条链路,才能成为用户真正访问到的页面。
这四个名字其实已经陪我们走了十四章,只是此前镜头一直对准 Vue 代码。现在准备构建和发布,正好把 Vue、Vite、create-vue 与 @vitejs/plugin-vue 逐一归位。
create-vue 是项目脚手架。它按照你的选择写出目录、配置和依赖,任务完成后就退出,不会常驻在应用里。dist。@vitejs/plugin-vue 让 Vite 理解 Vue 单文件组件。没有它,Vite 只会把 .vue 当成一种陌生文件,浏览器也不可能直接执行其中的 <template> 和 <script setup>。回看第一章的起点,create-vue 只在创建目录时出现过一次;此后每次保存 App.vue,真正持续参与工作的都是 Vue、Vite 和 Vue 插件。可以把这套关系想成一家小厨房:create-vue 帮你把厨房搭好,Vue 是菜谱和烹饪方式,Vite 管备料与出餐,Vue 插件则告诉 Vite 怎样处理 .vue 这种特别的原料。它们合作得很紧,但不是同一个东西。
现代浏览器认识下面这种模块脚本:
<script type="module" src="/src/main.js"></script>type="module" 会让浏览器按照 ES 模块规则执行 main.js。当 main.js 里出现 import App from './App.vue',浏览器会继续请求 App.vue 对应的模块。问题也正出在这里:浏览器会执行 JavaScript,却不会编译 Vue 单文件组件,也不理解 import { createApp } from 'vue' 这种指向 npm 包的“裸导入”。
Vite 的开发服务器站在浏览器与本地文件之间。浏览器请求哪个模块,Vite 就解析并转换那个模块,把浏览器能执行的结果返回。对频繁修改的项目源码,这种工作大多按请求发生;对 vue 这类依赖,Vite还会进行依赖预处理和缓存,把包的模块格式与请求数量整理得更适合开发服务器使用。因此,准确说法是“开发阶段围绕原生 ESM 按需服务源码,并对依赖做必要的预处理”,不是“开发阶段任何东西都不打包”。

开发时最重要的是反馈快。你可能只打开首页,却在项目里放了几十个暂时不会访问的页面。Vite 没必要在服务器启动前把所有业务源码重新整理一遍;浏览器走到哪条导入链,相关源码才进入当前请求。
生产环境的目标不同。若把大量细碎源码模块原样部署,浏览器可能沿着嵌套导入产生很多网络往返,还会把未使用代码、开发辅助代码和不稳定的文件名一起带上。vite build 会从入口分析模块图,完成转换、摇树优化、压缩、资源处理和代码拆分,生成更适合缓存与传输的文件。
所以你在开发者工具中看到 /src/App.vue 一类请求,并不意味着线上也会部署 src/App.vue。线上通常加载的是 dist/assets/index-某段哈希.js。源码与产物之间有一条可解释的加工链,而不是把项目文件夹整体上传。
第一章创建项目时,Node.js 和 npm 已经替我们完成过安装依赖、启动 Vite 的工作。走到发布阶段仍要再确认一次版本,因为“旧环境还能运行现有开发服务器”不代表它一定能重新安装当前依赖并完成生产构建。
脚手架对 Node.js 有明确门槛。当前 create-vue 生成的项目要求 Node.js ^22.18.0 || >=24.12.0。换成直白的话,就是使用 22 系列时至少要到 22.18,或者使用 24 系列时至少到 24.12;更高的受支持版本也可以。Vite 工具本身的最低门槛稍宽,为 Node.js 20.19+ 或 22.12+,但一个项目能否正常安装,要同时满足模板和依赖的要求,所以这里按更严格的 create-vue 项目要求准备环境。
先检查终端真正调用的是哪个版本:
node -v
npm -v如果电脑里装过多个 Node.js,图形化终端、编辑器内置终端和系统终端可能得到不同结果。遇到“明明升级了,安装仍提示版本过低”,不要急着反复重装依赖,先在报错的那个终端里重新执行 node -v。
不要只看“Node 20”“Node 22”这样的大版本名。最低小版本同样会影响结果。包管理器给出的 engines 警告也不该直接忽略,因为安装勉强完成不代表开发服务器和构建能稳定运行。
本章主线仍然使用第一章以来的现有 Vite 应用,不需要为了学习构建再创建第三个项目。如果你想在不碰现有代码的前提下复现目录和命令,可以另外建立一个最小项目;这个副本只是观察 Vite 行为的实验场,后面的工程结论同样适用于课程主项目。
在准备存放项目的目录中执行:
npm create vue@latest@latest 的作用是让 npm 获取当前版本的 create-vue,避免命中旧缓存。命令会进入交互式向导。为了让这个可选实验把注意力放在 Vite 和 .vue 文件上,我们可以这样选择:
项目名称:vite-focus-lab
TypeScript:No
JSX:No
Vue Router:No
Pinia:No
单元测试:No
端到端测试:No
ESLint:Yes
代码格式化:Yes
其他实验功能:No这里选择 JavaScript,不是因为 TypeScript 不适合 Vue,而是为了减少与本课主题无关的类型配置。这个最小副本不勾选 Router 和 Pinia,也不表示前面接入它们的过程需要推倒重来;我们只是暂时缩小变量,观察 Vite 如何处理入口、组件和资源。脚手架的选项会随版本调整,看到比上面更多的提示并不奇怪;不确定的功能先选 No,项目仍然是完整可运行的。
创建结束后,终端还会明确告诉你接下来的命令:
cd vite-focus-lab
npm install
npm run devnpm install 根据 package.json 安装依赖并生成或更新锁文件;npm run dev 执行项目 scripts 中的 vite。通常终端会显示一个本地地址。端口常见为 5173,但若端口被占用,Vite 可能选择下一个可用端口,所以应以当前终端输出为准。
如果你已经建好了空目录,也可以把当前目录作为项目目标。执行前要确认里面没有需要保留的同名文件,因为脚手架会写入项目结构:
npm create vue@latest .第一章保存 App.vue 时,我们已经认识了 index.html、main.js 和根组件。后来组件被拆开,可复用逻辑进入组合式函数,Router 与 Pinia 也从 main.js 接入应用。现在回到工程层,把注意力放到这些模块共同依赖的根目录、资源目录和构建配置上。
下面的“专注学习台”沿用这套 Vite 骨架来整理本章示例。你的课程项目可能还多出 router/、stores/、views/ 和 composables/;它们不是另一套工程结构,只是继续挂在同一张模块图上的业务目录。这里列出的名字用于说明职责,不要求为了与示意图一致而重建或重命名现有项目。
vite-focus-lab/
├── public/
│ └── status-badge.svg
├── src/
│ ├── assets/
│ │ ├── base.css
│ │ └── focus-wave.svg
│ ├── components/
│ │ ├── BuildInspector.vue
│ │ └── FocusBoard.vue
│ ├── data/
│ │ └── tasks.js
│ ├── App.vue
│ └── main.js
├── .env
├── .env.production
├── .gitignore
├── index.html
├── package-lock.json
├── package.json
└── vite.config.js
Vite 默认把当前工作目录当作项目根目录,index.html 就位于这里。它不是过去那种只负责占位的模板文件,而是开发与构建的入口之一。Vite 会解析其中的模块脚本和资源引用,并在生产构建时生成对应的 dist/index.html。
package.json 记录依赖和命令。package-lock.json 记录一次确定的依赖解析结果,团队项目通常要提交它。vite.config.js 是工具配置,运行在 Node.js 一侧,不会作为普通前端模块原样发送到浏览器。
src 里的文件通过 import 或组件模板引用进入模块图。Vite 能追踪它们的依赖关系,在构建时改写 URL、添加内容哈希,小资源还可能被内联。public 里的文件不参与这套模块分析,而是开发时从站点根路径提供,构建时原样复制到输出目录根部。
这不是“图片该放哪”的审美问题,而是你是否希望资源进入构建管线。一般业务图片优先放 src/assets 并导入;robots.txt、必须保持固定文件名的图标,或者外部系统约定必须按固定路径访问的文件,才更适合 public。
先看 index.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="一个用于练习 Vite 与 Vue 工程化的专注学习台" />
<title>专注学习台</title>
再看 src/main.js:
import { createApp } from 'vue'
import App from './App.vue'
import './assets/base.css'
createApp(App).mount('#app')这条链路很短:HTML 提供 #app 挂载点并请求 main.js,main.js 导入根组件和全局样式,再把 Vue 应用挂到 #app。项目功能应继续拆到组件和普通模块里,不要把所有业务代码都堆进 main.js。
.vue 文件也不是浏览器里的字符串模板。Vue 插件会把 <template> 编译成渲染相关的 JavaScript,把 <script setup> 转换成组件逻辑,并处理 <style scoped> 的作用范围。浏览器最终拿到的是转换后的模块。知道这一点后,很多规则就不再神秘:模板中能写哪些表达式、静态资源为什么会改名、组件样式为什么能被热更新,都与编译和模块图有关。
接下来不换工程地基,只把现有应用的页面内容整理成一个便于观察构建行为的“专注学习台”。它会展示今日任务、完成比例和专注时长,还会按需加载一个“构建观察卡”。代码不追求业务复杂,而是让组件、普通模块、静态资源与动态导入怎样进入同一张模块图都能在一个页面里看见。
新建 src/data/tasks.js:
export const initialTasks = [
{ id: 1, title: '复习 ref 与 computed', done: true },
{ id: 2, title: '拆分第一个单文件组件', done: false },
{ id: 3, title: '检查生产构建目录', done: false },
]普通 .js 文件与 .vue 文件处在同一张模块图中。把初始数据单独放置后,组件只关心交互,数据也更容易被测试或替换。
新建 src/components/FocusBoard.vue:
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
seedTasks: {
type: Array,
required: true,
},
})
const tasks = ref(props.seedTasks.map((task) => ({ ...task })))
const newTitle = ref('')
这里使用 <script setup>。脚本顶层声明的 tasks、progress、addTask 等内容可以直接在模板中使用,不需要再写一个 return。<style scoped> 会在编译时给选择器和组件节点添加匹配标记,使这些样式主要作用在当前组件;它不是把组件放进 Shadow DOM。
新建 src/components/BuildInspector.vue:
<script setup>
defineProps({
runtime: {
type: Object,
required: true,
},
})
</script>
<template>
<aside class="inspector">
<p class="inspector__label">当前运行信息</p>
<dl>
<div>
新建 src/App.vue:
<script setup>
import { defineAsyncComponent, ref } from 'vue'
import FocusBoard from './components/FocusBoard.vue'
import { initialTasks } from './data/tasks.js'
import focusWaveUrl from './assets/focus-wave.svg'
const BuildInspector = defineAsyncComponent(
() => import('./components/BuildInspector.vue'),
)
const showInspector = ref(false)
注意 BuildInspector 与 FocusBoard 的导入方式不同。FocusBoard 是静态导入,进入首屏模块图;BuildInspector 使用 import(),为生产构建提供了一个明确的异步边界。代码拆分不是“每个组件必然生成一个文件”,构建器仍会根据依赖关系和优化策略安排 chunk,但动态导入表达了“这部分可以稍后加载”的意图。
新建 src/assets/base.css:
:root {
color: #263238;
background: #f7f2e8;
font-family:
Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
focus-wave.svg 放在 src/assets,并通过 JavaScript 导入。你可以自己画一个 SVG,也可以先用下面的最小版本:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 260">
<rect width="420" height="260" rx="30" fill="#fff8e7"/>
<path d="M35 172C90 40 142 236 206 102s105 83 179-32" fill="none" stroke="#ff6b6b" stroke-width="12" stroke-linecap="round"/>
<path d="M42 204c82-80 130 20 190-54s96 22 145-20" fill="none" stroke="#06d6a0" stroke-width="9" stroke-linecap="round"/>
<circle cx="96" cy="92" r="24" fill="#ffd166" stroke="#263238" stroke-width="6"/>
<path d="m286 66 18 18 38-42" fill="none" stroke="#263238" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>status-badge.svg 放在 public。它的文件名需要固定,因此页面用 import.meta.env.BASE_URL 拼出基础路径:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="27" fill="#b8f2e6" stroke="#263238" stroke-width="5"/>
<path d="m19 33 8 8 19-21" fill="none" stroke="#087f5b" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>保存后,页面会出现一张有彩色线条头图的“专注学习台”。你可以勾选任务,进度数字与进度条会立即变化;输入新任务并提交,清单会增加一项;修改专注分钟数,底部摘要会同步更新。点击“查看构建观察卡”后,异步组件出现,并显示当前模式、基础路径与是否处于开发环境。
在项目根目录运行:
npm run dev此时 Vite 先准备开发服务器。打开终端给出的地址,浏览器请求 index.html,再根据模块脚本请求 /src/main.js。main.js 又引出 Vue、App.vue 和 CSS,App.vue 继续引出组件、数据和图片。Vite 会维护这张模块关系图,并在文件被请求时完成必要的解析与转换。
打开浏览器开发者工具的 Network 面板,你会看到多个开发期模块请求,而不是一个已经压缩好的大文件。这恰好说明浏览器正在参与模块加载。请求地址里可能还会出现 Vite 的客户端模块,它负责与开发服务器保持连接,接收更新或错误信息。
保持页面打开,先勾选两项任务,再把 FocusBoard.vue 中按钮文字改一下并保存。理想情况下,你会看到文字很快更新,刚才勾选的状态仍在。这就是热模块替换:Vite 找到受影响的模块,Vue 的 HMR 集成接住组件更新,尽量只替换相关部分。

这里的“尽量”很重要。改模板文字和局部样式通常可以平滑更新;若你破坏了组件结构、改变模块导出、修改启动入口,或者更新无法被现有 HMR 边界接收,浏览器可能重新加载页面。语法错误也会先显示错误遮罩,修好并保存后再恢复。HMR 的目标是缩短反馈链路,不是让任何改动都永久保留运行状态。
.vue 需要编译,TypeScript 需要转译,npm 裸导入需要解析,CSS 与资源 URL 也要处理。Vite 的优势在于把很多源码处理放到请求路径上,并利用缓存避免重复劳动。依赖还可能被预打包,既处理模块格式兼容,也避免一个依赖内部几百个小模块变成几百次浏览器请求。
因此,当你在面试或讨论中回答“Vite 为什么启动快”,可以说得具体一些:它让浏览器用原生 ESM 驱动开发期的源码请求,按需转换频繁变化的源码,并对相对稳定的依赖做预处理和强缓存;更新时利用模块图与 HMR 边界缩小失效范围。这个解释比“因为它不打包”更接近真实行为,也能容纳 Vite 工具链未来的实现变化。
要让同一局域网的手机访问,可以把参数传给 Vite:
npm run dev -- --host想临时指定端口:
npm run dev -- --port 4300如果你希望端口被占用时直接报错,而不是悄悄换端口,可以在配置中启用 strictPort。这些参数位于 -- 后,是因为前面的 npm run 需要把后续参数继续传给 scripts 对应的命令。
很多项目要在本地、测试和生产环境使用不同的接口前缀或页面名称。Vite 会读取项目根目录的 .env 系列文件,并把允许暴露的值放到 import.meta.env。
文件 .env:
VITE_APP_TITLE=本地专注学习台
VITE_API_BASE=/api文件 .env.production:
VITE_APP_TITLE=专注学习台
VITE_API_BASE=/service在客户端源码中读取:
const title = import.meta.env.VITE_APP_TITLE
const apiBase = import.meta.env.VITE_API_BASE默认情况下,npm run dev 使用 development 模式,npm run build 使用 production 模式。Vite 会读取通用 .env,再用对应模式文件覆盖同名值。本机私有覆盖可以放进 .env.local 或 .env.production.local,这类文件通常应写进 .gitignore。

只有以 VITE_ 开头的自定义变量,才会默认进入客户端的 import.meta.env。这条规则是在减少误泄漏,但它绝不意味着 VITE_ 变量安全。只要值进入客户端包,访问页面的人就能通过下载的 JavaScript、Network 请求或运行时行为看到它。
下面这些值适合放到前端:公开 API 基础路径、站点标题、功能展示开关、公开的构建版本号。数据库密码、对象存储私钥、支付签名密钥、后台管理员令牌都不能放。若浏览器需要完成敏感操作,应请求你控制的后端,由后端在安全环境中使用密钥。
不要把密钥命名成 VITE_SECRET 后安慰自己“它在 env 文件里”。构建时,相关值会被替换进客户端代码。env 文件没有把前端变成服务端,前缀更不是加密。
Vite还提供几个内置值:
import.meta.env.MODE
import.meta.env.BASE_URL
import.meta.env.DEV
import.meta.env.PROD
import.meta.env.SSRDEV、PROD 和 SSR 是布尔值,自定义环境变量则按字符串读取。于是 VITE_FEATURE=false 得到的是字符串 'false',直接放进 if 仍会被当作真值。应显式转换:
const featureEnabled = import.meta.env.VITE_FEATURE === 'true'
const pageSize = Number(import.meta.env.VITE_PAGE_SIZE || 20)修改 env 文件后,最好重启开发服务器,因为它们在服务器启动时加载。还要区分 mode 与 NODE_ENV:vite build --mode staging 会读取 .env.staging,但它仍然是在执行生产构建。模式名用来选择配置集合,不等于自动切换成某一种构建算法。
你可以给 scripts 增加预发布构建:
{
"scripts": {
"build:staging": "vite build --mode staging"
}
}然后创建 .env.staging。无论有多少模式,都要坚持同一条边界:能进入浏览器的值一律按公开信息看待。
我们在示例里故意用了两种图片。
focus-wave.svg 位于 src/assets,代码写的是:
import focusWaveUrl from './assets/focus-wave.svg'导入得到的是一个最终可用的 URL。开发时它可能指向源码路径;构建时,Vite 会把它纳入资源图,根据内容和配置决定生成带哈希的文件,或者在足够小时内联为 data URL。组件模板中的相对资源引用、CSS 的 url() 也会受到相应处理。
status-badge.svg 位于 public。它不会被内容哈希重命名,而是原样复制到 dist 根部。我们没有把路径硬编码成 /status-badge.svg,而是写成:
const publicBadgeUrl = `${import.meta.env.BASE_URL}status-badge.svg`这样项目部署到 /focus-lab/ 子路径时,结果会是 /focus-lab/status-badge.svg。如果写死站点根路径,本地根目录开发可能正常,放到子目录后就容易 404。

如果代码能明确导入这张图片,我通常先放 src/assets。这样移动文件时导入会报错,删除未使用模块时资源也能随模块图一起退出,生产文件名还能用哈希支持长期缓存。
如果文件必须叫 robots.txt,或者某个平台会固定请求 /status-badge.svg,public 更合适。代价是 Vite 不会替你追踪“它是否还被使用”,也不会给内容变化生成新哈希。更新同名文件后,浏览器或 CDN 的旧缓存需要单独处理。
最后检查部署基础路径。凡是自己拼接的运行时 URL,Vite无法凭空猜出字符串含义。优先使用导入;确实要拼接 public 路径时,从 import.meta.env.BASE_URL 开始,不要到处散落写死的 /。
下面的写法对构建工具不够透明:
const imageUrl = new URL(imagePath, import.meta.url).hrefimagePath 完全来自运行时,构建阶段不知道可能涉及哪些文件,也就无法稳定地把它们纳入输出。若候选资源是已知集合,可以显式建立映射:
import calmUrl from './assets/calm.svg'
import activeUrl from './assets/active.svg'
const statusImages = {
calm: calmUrl,
active: activeUrl,
}代码多了几行,却让依赖关系清楚。将来某张图丢失,构建阶段就能更早暴露问题。
脚手架已经提供 vite.config.js。对我们的项目,可以写成:
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => ({
plugins: [vue()],
base: mode === 'production' ? '/focus-lab/' : '/',
resolve: {
alias: {
'@': fileURLToPath(new URL(
defineConfig 主要提供更好的编辑器提示。plugins: [vue()] 启用 Vue 单文件组件支持。resolve.alias 允许以后用 @/components/FocusBoard.vue 代替较长的相对路径。server 只管开发服务器;build 只管构建;base 同时影响开发与构建中的公共基础路径,不过这里根据模式给了不同值。
这个 base 假设生产站点部署在域名下的 /focus-lab/。如果站点直接位于域名根目录,应该改成 /。如果产物需要嵌入未知目录,也可以评估 ./ 相对基础路径,但这会影响路由和部分运行时 URL 的处理,不能见到子路径问题就机械套用。
base 必须和真实部署位置一致。本地开发成功,只说明根路径下的开发服务器能找到资源;它无法替你证明生产站点位于哪个子目录。部署后白屏时,先看 Network 面板中的 JS 和 CSS 是否 404。
你可以在 Vite 配置里导入 node:path、读取文件,前端组件却不能理所当然地使用 Node.js 内置模块。浏览器没有 fs 和 path。Vite也不会默认给前端偷偷补齐这些能力。看到“无法解析 fs”时,先问自己:这段代码是否本来就应该放到服务端或构建插件中?
配置求值时,.env 文件不会自动把所有变量塞进 process.env。若配置本身确实要读取某个模式的 env,可以使用 loadEnv:
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), 'VITE_')
return {
plugins: [vue()],
base: env.VITE_PUBLIC_BASE || '/',
}
})这里仍然只读取 VITE_ 前缀。不要再通过 define 把整个 process.env 塞进前端,那会把原本明确的暴露边界撕开。
插件可以参与模块解析、源码转换、开发服务器、HMR 与构建输出。Vue 插件就是最直接的例子。安装插件通常要做两件事:把包装进 devDependencies,再在 plugins 数组中调用它。
但插件越多,启动、构建和升级时的交互也越多。添加之前先确认 Vite 是否已经原生支持这个需求,再检查插件是否同时支持当前 Vite 版本、开发阶段和生产构建。只为了改一个文件名就引入长期无人维护的插件,往往比写几行清晰配置更麻烦。
先确认 package.json 至少有下面三条 scripts。实际依赖版本由脚手架生成,不需要照抄某个固定版本号:
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}执行生产构建:
npm run buildVite 默认从根目录 index.html 出发,追踪静态导入和动态导入,处理 Vue 组件、JavaScript、CSS 和资源,最后把结果写进 dist。每次输出的哈希和文件组合可能变化,不要把下面的示意名字写死到业务代码里:
dist/
├── assets/
│ ├── BuildInspector-a81c2f.js
│ ├── index-54d11a.css
│ └── index-b24e90.js
├── index.html
└── status-badge.svgBuildInspector 使用动态导入,所以它很可能形成异步 chunk;FocusBoard 是静态导入,通常会进入首屏相关 chunk。小型 focus-wave.svg 可能被内联,因此目录里不一定出现同名图片。资源是否拆成独立文件由内容大小、引用方式和构建配置共同决定,不应拿一次输出的文件数量当成永久契约。

文件名里的哈希来自内容。内容变化时,名字随之变化,浏览器可以长期缓存旧资源,同时通过新的 HTML 获取新地址。index.html 通常不适合使用同样长的强缓存,因为它承担“指向当前资源版本”的入口职责。
不要直接修改 dist/assets/index-某段哈希.js。它是生成物,变量可能已压缩,下一次构建还会覆盖。发现产物有问题,应回到 src 或构建配置修正,再重新运行 build。是否提交 dist 要看部署流程,多数应用由持续集成在发布时构建,而不是把本地产物长期混在源码提交中。
构建成功后运行:
npm run previewpreview 会从 dist 启动一个本地静态预览服务。因为示例生产 base 是 /focus-lab/,应按终端提示打开带这段路径的页面。检查任务交互、图片、异步观察卡与控制台错误。
preview 不是生产服务器,也不会替你解决 HTTPS、压缩、缓存策略、访问日志、安全头和进程守护。它回答的是“刚刚生成的文件能否按预期运行”,不回答“线上服务是否已经正确配置”。每次预览前先重新 build,否则你可能在看上一次留下的旧 dist。
动态导入能降低首屏立即加载的代码量,但每个 chunk 也会带来请求、调度和缓存管理成本。把每个十几行的小组件都改成异步组件,往往没有收益。更自然的边界通常是路由页面、体积较大的编辑器、图表、只在少数用户操作后出现的功能。
对我们的项目而言,“构建观察卡只有点击后才需要”是合理边界。Network 面板中,首次打开页面时不会立即请求它的生产 chunk;点击按钮后才会出现请求。再次打开时,浏览器通常可以复用缓存。
构建完成不等于部署完成。你真正要上传的是 dist 中的内容,并让静态服务器把站点路径映射到正确文件。
如果站点地址形如“域名根目录”,base 通常使用 /。如果站点放在域名下的 focus-lab 子目录,base 应使用 /focus-lab/。Vite会据此改写由 HTML、CSS 和模块导入追踪到的资源 URL。
最典型的错误是:本地地址在 / 下运行正常,生产 HTML 却仍请求 /assets/index-xxx.js。服务器实际文件位于 /focus-lab/assets/,于是 JS 404,页面只剩一个空的 #app。这时 Vue 根本还没启动,检查组件逻辑没有意义。
我们的 public 图片使用 BASE_URL 拼接,也是为了解决同一个问题。自己生成下载链接、图片链接或 fetch 路径时,要区分“站点资源路径”和“后端 API 路径”。API 是否跟随前端 base 取决于服务端路由,不能一概拼上同一个前缀。
前面的课程项目已经接入 Vue Router;本章为了单独观察构建行为,专注学习台这份最小示例没有再增加路由页面。把结论放回课程主项目:使用 history 模式时,用户直接访问 /focus-lab/report,静态服务器可能把它当成真实文件路径并返回 404。服务器需要在找不到静态文件时回退到应用的 index.html,再由前端路由解释 URL。
这种 404 与 Vite 构建错误不同:从首页点击导航可能正常,刷新子页面才失败。看到这种表现,应检查托管平台的 SPA rewrite 或 fallback,而不是在 Vue 路由里反复加同名页面。
新的 index.html 可能引用新哈希文件。若发布流程先删旧文件、隔很久才上传新文件,访问者可能在中间窗口拿到 HTML 却拿不到资源。更稳妥的发布方式是先让新产物完整可用,再切换入口,并根据平台能力保留一段时间的旧哈希资源。
这也解释了为什么不要在业务代码里猜哈希文件名。HTML 和模块关系会由构建结果维护,部署只负责把同一批产物完整交付。
工具报错时最怕“到处改一点试试”。Vite 项目有很清楚的链路:命令启动工具,HTML 请求入口,入口导入组件,组件再导入数据和资源。沿链路查,问题通常会很快缩小。
先看终端第一条有效错误,而不是最后一串调用栈。常见原因包括 Node 版本不满足、没有在项目根目录运行、依赖尚未安装、端口被占用,以及 vite.config.js 语法错误。
可以依次确认:
node -v
pwd
npm install
npm run devWindows 终端没有 pwd 时可以直接观察当前路径提示。判断项目根目录的简单办法是:当前目录能看到 package.json 与 index.html。
先开 Console 看运行时错误,再开 Network 看入口 JS、CSS 和图片是否成功返回。如果入口资源 404,优先查 base 和部署位置;如果提示无法解析模块,检查文件路径、扩展名与大小写。macOS 的某些默认文件系统对大小写不敏感,代码写 focusboard.vue 可能在本机凑巧工作,部署到区分大小写的 Linux 后才失败。
若 document.querySelector('#app') 得不到元素,检查 index.html 的挂载点与 main.js 中 .mount('#app') 是否一致。若错误指向某个组件,就顺着导入栈找到最早失败的模块。
依次检查变量是否以 VITE_ 开头、文件是否位于项目根目录、当前 mode 是否正确、修改后是否重启开发服务器。还要确认没有在客户端写 process.env.VITE_APP_TITLE;Vite 客户端使用的是 import.meta.env.VITE_APP_TITLE。
如果值能读到但逻辑不对,检查字符串转换。'false'、'0' 都是非空字符串,在 JavaScript 条件判断中为真。
先判断修改是否跨越了组件边界,是否改变了导出形状,或是否有循环依赖。偶尔整页刷新不是失败,它可能是当前更新无法安全局部接收时的正确退路。若每次改一个样式都刷新,再检查浏览器控制台中的 HMR 连接、代理或网络是否拦截了开发服务器的 WebSocket。
开发时只请求走到的模块,生产构建会分析更完整的入口图。一条暂时没点击到的动态导入、错误的文件名大小写或只在构建分支出现的代码,都可能到 build 才暴露。发布前必须主动运行:
npm run build
npm run preview不要把“开发页面能打开”当作生产构建已经验证。两套流程共享配置与插件,但优化目标和执行范围并不相同。
这句话省略得太多。项目源码主要经原生 ESM 按需提供,但仍要转换 .vue、处理 CSS 与资源,依赖也会做预处理。更稳妥的理解是:Vite避免在开发服务器启动前对整套频繁变化的业务源码做一次完整生产式打包。
短期看少写一行 import,长期却失去模块追踪、内容哈希和缺失检查。除非需要固定文件名、固定路径或不经源码引用,一般资源优先导入。
是否提交与是否暴露是两件事。构建机读取 env 后,VITE_ 值仍会进入客户端产物。真正的密钥只能在服务端使用。
preview 只用于本地检查构建结果,没有承担正式服务所需的完整运维职责。线上应使用托管平台、静态服务器或后端服务来提供 dist。
静态导入、动态导入、共享依赖和构建优化共同决定 chunk。需要延迟加载时显式使用合理的动态导入边界,不要依赖偶然的输出文件布局。
base 会影响构建生成的 JS、CSS、图片与相关 URL。设置错时通常不是少一张图,而是入口脚本都加载不到。运行时手写字符串还可能绕开自动改写,因此要单独检查。
启动开发服务器,打开 Network 面板,刷新页面。找到 main.js、App.vue、一个子组件和 Vite 客户端模块。记录它们的请求顺序,再点击“查看构建观察卡”,看看此时是否出现新的组件请求。
先在页面里新增任务并勾选几项,再依次修改 FocusBoard.vue 的按钮颜色、按钮文字和 tasks 初始化方式。观察哪些修改保留了页面状态,哪些触发了组件重建或整页刷新。
创建 .env.staging,把标题设为“专注学习台·预发布”,再在 scripts 中加入 build:staging。构建并预览,确认观察卡的 mode 和页面标题。
把生产 base 设为 /focus-lab/,构建后先从正确子路径预览。然后把 publicBadgeUrl 临时改成写死的 /status-badge.svg,重新构建并观察请求 URL。最后恢复正确写法。
先保留 BuildInspector 的 defineAsyncComponent 写法,构建一次并记录 dist/assets。再改为顶部静态 import,重新构建并比较输出与首次加载请求。
App.vue 到线上页面:完成整门课的闭环现在可以把镜头拉回第一章。那时我们保存 App.vue,看到页面随着状态变化,却只需要先记住 index.html → main.js → App.vue 这条短链路。随后,模板被解释为编译后的渲染逻辑,ref 与 reactive 建立响应式依赖;页面拆成组件后,静态导入形成模块关系,组合式函数把可复用逻辑带到不同组件;Vue Router 把 URL 接进应用,Pinia 再把跨组件业务状态收进 Store。前十四章讲的是应用内部怎样组织和运行,这一章补上了它怎样离开源码目录、成为服务器能够交付的文件。
因此,再看 npm run dev,它不只是“打开一个网址”的按钮:浏览器先拿到 index.html,沿原生 ESM 导入请求 main.js、根组件、路由、Store 和其他模块;Vite 解析路径、转换 .vue、处理依赖与资源,并通过 HMR 缩短每次保存后的反馈链路。我们在开发环境里看到的,是整张模块图被按需服务后的结果。
再看 npm run build,它也不只是“生成 dist”:构建从同一个入口追踪完整模块图,把静态导入与动态导入组织成生产 chunk,改写资源 URL,生成哈希文件,并按照 base 为真实部署位置准备路径。npm run preview 让我们在发布前检查这批产物;服务器的静态资源映射与 SPA 回退规则,则决定 Router 页面在直接访问和刷新时能否回到 index.html。
到这里,整门课形成了一条完整链路:在 App.vue 中声明界面,以响应式状态驱动模板,用组件和组合式函数组织逻辑,用 Router 表达页面结构,用 Pinia 管理共享业务状态,再让 Vite 把整张模块图构建为 dist,由服务器沿正确路径交付给用户。 遇到问题时,也可以沿“命令 → HTML → 入口模块 → 组件与状态 → 资源 URL → dist → 服务器路径”逐层倒查。能把这条链路讲清楚,Vue 就不再是一组散落的 API,而是一套从源码到线上页面彼此咬合的完整系统。