简介这是一份面向前端初学者的Vue3入门示例资源以经典HelloWorld形式演示Vue3相对Vue2的核心改进帮助学习者快速认识Composition API、setup函数以及基于Proxy的响应式系统。压缩包内共7个文件以2个js脚本和2个xml工程配置为主辅以1个html入口文件、1个gitignore及1个iml工程标识整体仅257KB轻量精简可直接复用。目前已有784人下载学习适合作为第一份Vue3实践素材。资源包含可直接在浏览器中运行测试的HTML页面与Vue3运行库无需事先搭建复杂脚手架即可观察响应式效果同时保留了项目级IDE配置便于导入WebStorm等工具继续扩展。通过这个示例读者可以掌握setup函数的基本写法、模板中的常用指令以及组件化的组织方式并为理解Teleport、Suspense等进阶特性打下基础。1. Vue3 HelloWorld 工程模板从脚手架到组件通信一次讲清很多人第一次接触 Vue3 HelloWorld 项目时最容易掉进两个坑一个是还在用 Vue2 的 Options API 写法另一个是跟着老教程配 Vue CLI结果启动一次要等十几秒。实际上 Vue3 生态早就把重心移到了 Vite 和script setup语法糖上一个 HelloWorld 跑通之后组件通信、响应式数据、构建部署这些真实项目里天天用的东西你基本都能无缝迁移过去。这篇文章要拆的这份 Vue3 HelloWorld 资源就是一套基于 Vite 的完整最小工程适合刚转 Vue3 的前端、要做课程设计的学生以及想快速搭一个干净模板的开发者。下面直接从脚手架创建开始把每一步的坑和参数都讲清楚。2. 从零到跑通Vite 脚手架与三个必调参数2.1 为什么选 Vite 而不是 Vue CLI启动速度和依赖预构建在 Vue3 刚发布那阵官方推荐的是 Vue CLI底层走的是 Webpack。Webpack 在开发服务器启动时要把整个项目从头编译一遍项目一超过几十个组件冷启动就是五到十秒起步改一行代码触发热更新也要等一两秒。Vite 的思路完全不同开发模式下它不对整个项目做打包编译而是利用浏览器原生的 ES Module 按需加载启动时只做依赖预构建所以一个 HelloWorld 工程基本是一秒内启动。这里有个容易被忽略的概念叫依赖预构建。Vite 启动时会把 node_modules 里那些 CommonJS 或 UMD 格式的依赖用 esbuild 预打包成 ESM 格式并缓存在node_modules/.vite目录。这意味着你第一次跑npm install后启动项目Vite 会做一次比较耗时的预构建之后再启动就快很多。如果你改了某个依赖的版本或者手动改了vite.config.js里的optimizeDeps.include你会发现启动时会重新预构建这是正常的不是报错。对于 Vue3 HelloWorld 这种规模的工程Vite 和 Vue CLI 的最终产物差别不大但开发体验差距巨大。我一般给新手推荐 Vite就是因为热更新反馈快能立刻看到自己改的代码在页面上变成什么样子这对理解响应式原理有直观帮助。2.2 创建 HelloWorld 工程命令与关键参数逐条拆解假设你已经装好了 Node.js 环境版本最好在 16 以上下一步就是创建工程。最常见的方式是直接使用 Vite 官方脚手架命令如下# 用 Vite 官方脚手架创建 Vue3 工程 npm create vitelatest vue3-helloworld -- --template vue # 进入工程目录 cd vue3-helloworld # 安装依赖 npm install # 启动开发服务器 npm run dev逐条拆解一下命令里的关键点。第一行的npm create vitelatest是 npm 创建脚手架的统一语法vue3-helloworld是你的项目目录名--template vue指定使用 Vue 模板注意这里有两个--第一个--是 npm 的参数分隔符第二个是让后面的参数原样传给 create-vite。如果漏掉-- --template vue脚手架会进入交互式问答模式问你选择原生 JS 还是 Vue、要不要 TS 等虽然也不麻烦但脚本化创建时不够干脆。第四行的npm install会生成 package-lock.json 文件这一步在网络不稳定时容易卡住常见做法是用npm install --registryhttps://registry.npmmirror.com切换镜像源或者换用pnpm install但混用包管理器容易把 node_modules 结构搞乱这点后面避坑章会展开。启动npm run dev之后终端会输出一个本地访问地址通常是http://localhost:5173/。这里有一个新手常见误区Vite 默认端口是 5173不是 Vue CLI 时代的 8080也不是老教程里常写的 3000。如果你的 5173 端口被占用Vite 会自动往后找 5174、5175终端打印的地址会跟着变以终端输出为准。2.3 工程目录速读HelloWorld 里每个文件在干什么脚手架创建完成后项目里会生成一批文件很多人一看到十几个文件就懵了。实际上核心文件就那几个我用下面的表格把职责列清楚文件路径作用在 HelloWorld 里你该关注什么index.htmlVite 的入口 HTMLscript typemodule src/src/main.js是挂载点src/main.js创建应用实例引入 App.vue调用createApp().mount(#app)src/App.vue根组件所有视图的顶层容器src/components/HelloWorld.vue示例子组件展示 props、响应式数据、事件绑定vite.config.jsVite 配置文件端口、代理、打包路径都在这里改package.json工程元信息scripts 里的 dev/build/preview 是最常用三个命令打开index.html你可能会奇怪为什么 Vue 单页应用的入口居然不是src/main.js而是index.html这正是 Vite 与传统 Webpack 工程的核心差异之一。Vite 在开发环境下直接把index.html当作页面入口浏览器解析到script typemodule标签时才去请求对应模块所以启动速度才快。src/main.js是 Vue3 应用的诞生点代码非常短// main.js 是 Vue3 应用的最小编排入口 import { createApp } from vue import App from ./App.vue createApp(App).mount(#app)这段代码的逻辑是从 vue 包中导入createApp函数传入根组件 App最后挂载到index.html里 id 为 app 的 DOM 节点上。对比 Vue2 的new Vue({ render: h h(App) }).$mount(#app)Vue3 用函数式创建应用的方式好处是多个应用实例互不干扰而且方便后续按需注册插件比如app.use(router)。注意main.js里没有默认全局引入 Vue因为 Vue3 的 SFC 转译会自动注入必要的运行时导入这也是为什么你在组件里写模板却不需要import Vue from vue的原因。3. 拆开 HelloWorldsetup 语法糖、ref/reactive 与模板渲染链路3.1 Options API 与 setup 语法糖同样的 HelloWorld 两种写法Vue3 初期兼容 Vue2 的 Options API也就是data、computed、methods、watch这些选项对象放在组件默认导出的对象里。但新项目里几乎看不到这种写法了因为组合式 API 配合script setup语法糖代码组织更紧凑。用一个最经典的计数器对比两种写法!-- 传统 Options API 写法Vue2 时代延续下来的风格 -- script export default { data() { return { count: 0 } }, methods: { increment() { this.count } } } /script template button clickincrement点击了 {{ count }} 次/button /template!-- 现在推荐的 script setup 写法Vue3 HelloWorld 的标准姿势 -- script setup import { ref } from vue // ref 把基本类型包装成响应式对象 const count ref(0) const increment () { count.value } /script template button clickincrement点击了 {{ count }} 次/button /template第二种写法里的逻辑区别值得细讲。script setup是单文件组件编译时的语法糖它把顶层变量和函数直接暴露给模板不需要return。这意味着你写在 setup 里的每一个ref、computed、普通函数模板里都能直接用。ref(0)返回的是一个 Proxy 对象访问它的值要用count.value但在模板里 Vue 会自动解包所以模板里直接写count而不是count.value。很多新手在这块会翻车在script setup里导入的组件不需要写components: { HelloWorld }注册直接HelloWorld /就能用。因为编译阶段 Vite 会把导入的变量自动视为组件引用。顺手提一句文件名大小写敏感import HelloWorld from ./components/HelloWorld.vue里的路径如果写错大小写本地 Windows 上可能能跑但 Linux 服务器上直接报错。3.2 计数器 Demoref.value 为什么模板里不用写点继续用计数器深入理解渲染链路。Vue3 的响应式核心是 Proxyref的功能是把内部值包裹成带有 getter 和 setter 的对象。修改count.value时dep 依赖收集器会通知所有依赖它的 effect 函数重新执行模板编译出来的渲染函数自然也被重新执行于是页面更新。有一个细节特别容易让新手困惑count.value和count.value看起来没啥区别但如果把计数逻辑拆到普通函数里比如写一个const logAndIncrement () { console.log(count.value); count.value }你会发现console.log打印的是自增前的值。这不是 bug而是 JS 本身的执行顺序问题跟 Vue 无关。排查这类问题时先回到原生 JS 语义别急着怀疑框架。对于对象和数组这类复杂结构ref内部其实会自动调用reactive来包裹深层数据。所以ref({ name: Vue3 })之后obj.name也是响应式的。但规范用法是对象用reactive、基本类型用ref这样代码语义更清晰。3.3 列表与条件渲染HelloWorld 里最常用的流程控制计数器只是起步HelloWorld 工程里通常还包含列表渲染和条件渲染这两个几乎每个页面都会用。下面这段是一个带状态切换的待办示例script setup import { ref, computed } from vue const todos ref([ { id: 1, text: 学习 Vite 脚手架, done: true }, { id: 2, text: 理解 ref 和 reactive, done: false }, { id: 3, text: 跑通第一个 HelloWorld, done: false } ]) // computed 会根据 done 状态自动衍生数据 const unfinished computed(() todos.value.filter(item !item.done)) const toggleDone (id) { const target todos.value.find(item item.id id) if (target) { target.done !target.done } } /script template ul li v-foritem in unfinished :keyitem.id {{ item.text }} button clicktoggleDone(item.id)标为完成/button /li /ul p v-ifunfinished.length 0所有任务都完成了/p p v-else还有 {{ unfinished.length }} 个任务未完成/p /template这里有三个重点参数。第一个是v-for的:key必须绑定稳定且唯一的标识用item.id而不是数组下标。如果拿下标做 key当列表头部插入一项时Vue 会认为每个位置的元素没变只是内容变了导致复用错误的 DOM出现复选框状态串位之类的诡异问题。第二个是v-if与v-else必须紧挨着中间不能插入其他元素否则 Vue 无法正确匹配分支。第三个是computed的缓存特性它只在todos.value变化时重新计算而不是每次渲染都重新 filter 一遍这也是它比普通函数调用高效的原因。3.4 样式绑定与事件处理模板里的两个顺手技巧HelloWorld 工程里还经常演示动态样式和事件对象这两个技巧在真实项目中几乎天天要用。先看动态样式绑定script setup import { ref } from vue const isActive ref(true) const activeColor ref(#42b983) const toggleActive () { isActive.value !isActive.value } /script template div classdemo-box :class{ active: isActive } :style{ color: isActive ? activeColor : #999 } clicktoggleActive($event) 点击切换状态当前颜色 {{ isActive ? activeColor : 灰色 }} /div /template:class绑定对象时对象键决定是否添加这个 class值决定条件这里isActive为 true 时 div 会同时拥有demo-box和active两个类。:style绑定的对象键对应 CSS 属性名注意驼峰命名background-color要写成backgroundColor。click事件处理方法里手动传入$event是为了拿到原生事件对象比如event.clientX做弹窗定位。如果是纯标记性点击不传$event也行Vue 默认会传入事件对象作为第一个参数。事件处理还有一个容易被忽略的细节内联语句里直接调用toggleActive()不传参时事件对象不会自动传进去收到的是 undefined。所以需要在模板里手动传$event示例里就是这么处理的。4. 组件通信props、emit 与 defineExpose 的配合4.1 props 单向数据流父组件给子组件传值的标准写法HelloWorld 工程一般会带一个子组件用来演示父组件向子组件传值。Vue3 中定义 props 的推荐写法是直接用defineProps宏!-- ChildComp.vue -- script setup // 用 defineProps 声明组件接收哪些属性 const props defineProps({ title: { type: String, default: 默认标题 }, count: { type: Number, required: true } }) // props 是只读的不能直接修改 console.log(props.title) /script template div classchild-box h3{{ title }}/h3 p收到的计数是{{ count }}/p /div /templatedefineProps是编译宏不需要从 vue 里 import直接在script setup里调用就行。传 props 的父组件写法!-- App.vue -- script setup import { ref } from vue import ChildComp from ./components/ChildComp.vue const appCount ref(3) /script template ChildComp title父组件传来的标题 :countappCount / /template这里有个单向数据流的概念要反复强调子组件不能直接修改props.count如果写了props.count 5Vue 会在控制台告警。正确做法是子组件把 props 的值拷贝到自己的本地 ref或者通过 emit 事件通知父组件去修改。实际上 Vue3 的底层并没有真正冻结 props 对象但直接修改会破坏单向数据流导致数据来源混乱这是设计约定不是技术限制。4.2 emit 自定义事件子组件把操作结果交回父组件子组件要改变父组件的数据标准姿势是触发自定义事件。还是用计数器场景!-- CounterChild.vue -- script setup const props defineProps({ start: { type: Number, default: 0 } }) // 声明组件能触发哪些自定义事件 const emit defineEmits([update:count]) const localCount props.start const plusOne () { // 第一个参数是事件名第二个是要传给父组件的值 emit(update:count, localCount 1) } /script template button clickplusOne子组件按钮当前 {{ localCount }}/button /template父组件监听方式!-- App.vue -- script setup import { ref } from vue import CounterChild from ./components/CounterChild.vue const total ref(10) const handleUpdate (newCount) { total.value newCount } /script template p父组件维护的数值{{ total }}/p CounterChild :starttotal update:counthandleUpdate / /templatedefineEmits的作用不只是声明还有一个容易被忽略的好处如果子组件发了一个未声明的事件名比如emit(foo)而defineEmits里没有列出foo在开发模式下 Vue 会给出警告。这在多人协作时能快速发现事件拼写错误。另外注意update:count这种带冒号的事件名是 Vue 提供的一种约定配合v-model:counttotal可以实现类似v-model的双向绑定效果本质上是:counttotal加update:counttotal $event的语法糖。4.3 defineExpose 与 ref拿到子组件内部方法的另一种路径props 和 emit 覆盖了大多数场景但有一种情况它们不好使父组件想在某个时机主动调用子组件内部的方法比如点一个按钮让子组件的弹窗打开。这时候可以用ref获取子组件实例并通过defineExpose控制暴露哪些内容给父组件。子组件侧代码!-- ModalChild.vue -- script setup import { ref } from vue const visible ref(false) const open () { visible.value true } const close () { visible.value false } // vue3 中 setup 里的变量默认不暴露给父组件需要显式声明 defineExpose({ open, close }) /script template div v-ifvisible classmodal-mask div classmodal-content p子组件的弹窗内容/p button clickclose关闭/button /div /div /template父组件侧获取实例!-- App.vue -- script setup import { ref } from vue import ModalChild from ./components/ModalChild.vue // 模板里 refmodalRef 的名字需要和这个变量一致 const modalRef ref(null) const callOpen () { if (modalRef.value) { modalRef.value.open() } } /script template button clickcallOpen打开子组件弹窗/button ModalChild refmodalRef / /template关键点在于refmodalRef和const modalRef ref(null)的名字必须保持一致否则拿不到组件实例。也不是每个组件实例的所有内部属性都能访问的因为script setup默认是关闭的父组件只能拿到defineExpose显式暴露的成员。如果你发现modalRef.value是 null最常见的原因是子组件还没挂载完成或者你在onMounted之前就去访问了。5. 避坑指南从 HelloWorld 到真实项目最常见的七个坑5.1 端口被占用后刷新失效现象启动npm run dev后终端提示 5173 被占用自动换到 5174但浏览器访问 5174 一直白屏控制台报Failed to fetch dynamically imported module。原因Vite 重启后换了端口但内存里的模块映射还引用旧端口或者浏览器缓存了旧的模块请求。解决先彻底停掉终端里跑着的 Vite 进程然后清一遍浏览器缓存再重新npm run dev。更稳的做法是在vite.config.js里固定端口// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, strictPort: true } })strictPort设为 true 后端口被占用时会直接报错而不是自动切换到下一个端口避免端口漂移带来的各种缓存不一致问题。5.2 安装了依赖但 import 就报错现象npm install正常完成但npm run dev后页面报Cannot find module vue或vitejs/plugin-vue is not defined。原因大概率是 package.json 里依赖版本不对或者 npm 用了旧缓存安装了一半的依赖。解决第一步rm -rf node_modules package-lock.json注意 Windows 下用rmdir /s node_modules。第二步重新安装并确认 package.json 里 Vue 版本是^3.x而不是^2.7因为 Vue2 的包名也叫 vue装上后代码里createApp不存在直接崩。我一般会用npm view vue version先确认最新的 3.x 主版本号再写进依赖。5.3 热更新失效改代码页面毫无反应现象改了模板或样式浏览器不自动刷新必须手动刷新才能看到变化。原因Vite 的文件监听机制在某些环境下失效比如 IDE 自动保存时写入的是软链接路径、WSL 跨文件系统访问、或者项目目录里有符号链接。解决如果项目在 WSL 或 Docker 挂载目录里运行把项目挪到本地文件系统再试。如果实在挪不了改 Vite 配置里的监听选项server: { watch: { usePolling: true, interval: 100 } }usePolling会强制 Vite 用轮询方式检测文件变化代价是 CPU 占用上升只作为开发阶段的临时方案。5.4 生产构建后打开是白屏现象npm run build成功npm run preview本地预览正常但把dist目录部署到服务器子目录后访问页面白屏控制台报资源 404。原因Vite 默认base是/即产物里的资源引用路径都是从域名根目录开始。部署在https://example.com/myapp/这种二级目录下时资源请求会跑到https://example.com/assets/...去。解决在vite.config.js里把base改为相对路径export default defineConfig({ base: ./ })base: ./会让产物里的路径变成相对路径这样部署在任何目录下都能正常加载。注意改了base之后preview本地预览也要用相对路径环境否则带入旧配置。5.5 依赖预构建缓存不更新现象改了 npm 某依赖的版本重新npm install后启动 Vite还是加载旧版本的依赖页面表现与依赖版本不符。原因Vite 的依赖预构建缓存存在node_modules/.vite它基于 package-lock.json 内容和依赖版本生成 hash但某些场景下 hash 计算不准比如用npm install升级了 patch 版本后锁文件缺失。解决强制清除缓存再启动# 删除预构建缓存 rm -rf node_modules/.vite # 重启开发服务器 npm run dev某些特殊依赖如在运行时动态 import 的模块还需要在optimizeDeps.include里显式声明Vite 才会在预构建时把它们打包进去。5.6 混用包管理器导致依赖版本不一致现象同事用pnpm install后项目能跑你用npm install后报错或者反过来。尤其在公司协作项目中这种问题很致命。原因pnpm和npm的依赖解析策略不同pnpm基于内容寻址的链接结构npm是平铺的 node_modules两者生成的 lock 文件互不兼容。混用后 node_modules 里会残留大量不属于当前包管理器策略的重复依赖。解决项目里统一只用一个包管理器。如果当前项目已经混用了彻底清场重装# 清掉所有依赖和锁文件windows 请用对应命令 rm -rf node_modules package-lock.json pnpm-lock.yaml yarn.lock # 选定一个包管理器重装推荐 pnpm 或 npm 二选一 npm install从那以后我每次接手新项目第一件事就是看仓库根目录有没有pnpm-lock.yaml或yarn.lock确认锁文件到底属于哪个包管理器再决定自己用哪个命令。5.7 子组件不渲染也没有报错现象页面没有报错模板也写了HelloWorld /但子组件内容不显示。原因最常见的是子组件导入路径写错了后缀./components/HelloWorld.vue写成了./components/HelloWorldVite 默认允许省略.vue后缀但省略后它按 ESM 去解析时可能匹配不到文件另一种可能是子组件根元素只有一个注释节点Vue 渲染结果为空。解决统一带.vue后缀导入并确认子组件模板根节点是真实 DOM 元素。如果用了Fragment或多个根节点确认没有把template的注释当渲染结果。检查技巧是临时在子组件里加一行静态文本如果还是不显示再往导入路径上排查。6. 进阶技巧从 HelloWorld 到多页面与生产构建的三步提升HelloWorld 跑通之后最自然的进阶路径是往里面加路由让页面从单组件变成多视图。# 装 vue-router注意当前安装的是 vue-router 4.x npm install vue-router4然后在src下建router/index.js// src/router/index.js import { createRouter, createWebHistory } from vue-router import HomeView from ../views/HomeView.vue const routes [ { path: /, name: home, component: HomeView }, { path: /about, name: about, component: () import(../views/AboutView.vue) } ] const router createRouter({ history: createWebHistory(), routes }) export default routercreateWebHistory()基于 HTML5 History APIURL 里没有#好看但部署到服务器时需要配置前端路由回退到index.html否则刷新页面会 404。不想配服务器回退就改createWebHashHistory()url 变成/#/about兼容性更好代价是略丑。然后在main.js里挂载路由// main.js import { createApp } from vue import App from ./App.vue import router from ./router/index.js createApp(App).use(router).mount(#app)第二步是区分环境变量。HelloWorld 阶段往往写死接口地址真实项目里要把开发环境和生产环境的 api 地址拆开。在项目根目录建.env.development和.env.production# .env.development 开发环境变量 VITE_API_BASE/api # .env.production 生产环境变量 VITE_API_BASEhttps://api.example.com组件里通过import.meta.env.VITE_API_BASE读取注意变量必须以VITE_开头Vite 才会把它暴露给客户端代码。改完环境变量要重启开发服务器才生效这是环境变量加载时机决定的不是 Vite 的 bug。第三步是生产构建的验证流程。npm run build之后除了看构建结果我固定会做三个自查动作第一npm run preview本地起一个生产环境预览服务器因为开发环境表现和构建产物不完全一致第二打开浏览器开发者工具的网络面板确认静态资源路径是相对路径还是绝对路径对应判断base配置是否有问题第三如果页面有路由把preview服务的 URL 手动改成/about刷新一次验证服务器是否配置了路由回退如果 404 就说明部署环境还缺一条 try_files 规则。关于环境变量有个常见误区.env.production在本地npm run build时生效但部署到服务器后又发现接口地址不对这种情况往往是构建时没把生产环境变量打包进去或者服务器上的部署目录和base配置不匹配。处理办法是回本地重新npm run build用vite preview验证后再上传别在服务器上直接改 dist 文件。还有个小技巧值得提醒构建产物里的assets文件夹如果文件名带了很长的 hash比如index.8f3d2c.js这是 Vite 基于内容生成的指纹作用是强制浏览器加载新版本而不是命中旧缓存。如果你发现线上改了代码但用户还是看到旧页面优先检查服务器是否设置了index.html的no-cache响应头而不是怀疑构建有问题。这套流程跑通之后Vue3 的工程化底座就算立住了。从那以后我每次新建 Vue3 工程都强制走一遍创建脚手架、确认端口、跑通组件通信、构建预览的完整流程哪怕只是写个测试页也绝不跳过构建这一步。踩过太多次开发环境好好的、生产环境白屏的坑这习惯帮我提前拦掉了至少一半的部署事故。希望这篇 Vue3 HelloWorld 的拆解能帮你在起步阶段少绕几个弯把基础工程搭得干净一点后面加路由、加状态管理、接后端接口的时候你会感谢自己没在起步阶段引入一堆看不懂的配置。本文还有配套的精品资源点击获取