ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue前端小例子实战:Vite脚手架、组件通信与组合式函数

Vue前端小例子实战:Vite脚手架、组件通信与组合式函数

简介:这是一份面向 Vue 初学者的可运行小项目,以资产管理应用为具体场景,覆盖 Vue 实例创建、双向数据绑定、计算属性与侦听器、组件化开发、模板语法、Vue Router 路由管理以及工程化配置等 Vue 核心知识点,适合希望借助源码快速理解 Vue 运作机制并尝试动手修改扩展的开发者。压缩包共包含 39 个文件,整体仅 45KB,体积十分精简;其中 15 个 js 文件涉及入口、构建脚本和工具函数,9 个 vue 文件直观展示组件拆分与页面模板,7 个 png 可对照界面效果,另有 json、html、md 等用于依赖、入口与说明,目录结构接近标准 Vue 脚手架,便于按图索骥。目前已有 4211 人学习浏览,是较受欢迎的入门级示例。下载后可以拿到完整项目骨架,从入口启动、根组件挂载,到路由配置、组件之间 props 与事件交互,再到构建目录下的 webpack 配置均能一一对照学习;同时还能借鉴静态资源组织、代码规范与 Babel 编译等工程化细节,为后续独立开发 Vue 项目打下扎实基础。

1. VUE 前端小例子,练的是这三件事

搜“VUE前端小例子”的人,多半是刚把视频课追完、书翻了一半,手痒想自己写点东西。与其去抄几十个不痛不痒的片段,不如把这几个字当成一个明确目标:做出三五个能跑、能改、能拿出去给别人看的最小页面组件。这类小例子真正练的不是记 API,而是三件事——数据怎么绑定到视图、请求怎么进来、组件之间怎么说话。本文不假装有现成源码包,只把一线开发里最常用的一套 VUE 前端小例子方案完整拆开,从脚手架到踩坑,给你一份能照着敲的作业。

2. 从零搭一个 VUE 前端小例子:Vite 脚手架与第一个组件

2.1 为什么现在起手都用 Vite,而不是老的 Vue CLI

两三年前建项目,教程里还清一色是vue create。现在再从头搭 VUE 前端小例子,我一般直接上 Vite。原因很实在:Vite 开发服务器启动是秒级,改一行代码浏览器几乎无感刷新,而且它的依赖预构建机制让安装完依赖后的首次启动体验也快很多。Vue CLI 走的是 webpack 全量打包,项目稍大一点,保存一次等三五秒热更新是常事,对小例子这种频繁改、频繁看效果的需求来说,太磨人。

两者的差别说白了是构建引擎不同。Vite 在开发环境用原生 ES Module 按需加载,浏览器直接跑源码;生产构建才走 Rollup 打生产包。Vue CLI 则是 webpack 从头到尾负责打包。对新手来说,Vite 还能顺带把“模块是什么、依赖是什么”这件事在报错里展示得更直白。

对比项ViteVue CLI
开发启动秒级,按需编译全量编译,项目越大越慢
热更新模块级 HMR,改动哪块更新哪块页面级或全量刷新偏多
生产构建Rollup,产物更精简webpack,配置生态老但重
TypeScript开箱即用需要额外配置
新手友好度配置少,报错直观配置多,适合老项目维护

结论很简单:新开的 VUE 前端小例子,直接用 Vite。只有一种情况我会考虑 CLI,就是你在维护一个老项目,或者团队里全是 webpack 时代的配置习惯。

2.2 创建项目与目录结构:两条命令跑起来

打开终端,找一个干净的目录,执行下面的命令。用npm create vue创建的是官方推荐的 Vite + Vue 模板,它会交互式问你几个问题,新手建议先全部选 No,等跑通后再逐步加东西。

npm create vue@latest vue-demo

执行后终端会依次询问:项目名(上面已经给了 vue-demo,直接回车)、是否安装 TypeScript、是否引入 Vue Router、是否引入 Pinia、是否安装 ESLint 等。小例子阶段我建议 TypeScript 先不选,把精力放在 Vue 本身;Router 和 Pinia 只有在你要写多页或者复杂共享状态的小例子时才需要,后面可以随时补装,现在选 No 最省心。然后进入项目目录安装依赖并启动:

cd vue-demo npm install npm run dev

跑起来后浏览器访问终端里打印的那个本地地址,能看到默认欢迎页就算成功。这里重点看目录里这几个文件,因为大多数 VUE 前端小例子的改造都发生在这里:

文件/目录作用
src/main.js应用入口,负责创建应用实例并挂载根组件
src/App.vue根组件,所有小例子页面最终都挂到这里
src/components/放自定义组件的目录,小例子的主要战场
src/assets/静态资源
index.html页面壳,Vite 的入口 HTML
vite.config.jsVite 配置,后续配代理、配路径别名都在这里

2.3 改造默认组件:把第一段代码换成自己的计数按钮

VUE 前端小例子的第一步,通常是删掉默认欢迎模板,写一个最小组件。我把 App.vue 的内容整个替换成下面的代码,这就是一个完整的、可交互的第一件“小例子”:

<script setup> import { ref } from 'vue' const count = ref(0) function add() { count.value++ } </script> <template> <div> <p>当前计数:{{ count }}</p> <button @click="add">加一</button> </div> </template> <style scoped> button { padding: 8px 16px; font-size: 16px; cursor: pointer; } </style>

这段代码里ref(0)声明了一个响应式变量,count.value++修改它,模板里的{{ count }}会自动刷新。这里有一个新手最容易忽略的点:ref包裹的变量,在<script setup>里读取和修改必须带.value,但在模板里 Vue 会自动解包,所以模板里直接写count,不要多写.value。@click是事件绑定的简写,等价于v-on:click。

第一次把这套跑通后,你对 VUE 前端小例子的整体印象就有了:一个.vue文件 = 模板 + 脚本 + 样式,三块各管各的。接下来所有的例子,都是在这个文件结构上扩展。

3. 三个能直接抄进 VUE 前端小例子的组件:数据绑定、请求与组件通信

3.1 待办清单:v-model、v-for 与计算属性三件套

待办清单(Todo)几乎是所有 VUE 前端小例子里的常青树。它的价值在于横跨了三个最核心的知识点:v-model双向绑定、v-for列表渲染、computed派生数据。我在实际带人写的时候,总是推荐第一个完整例子写它,而不是写一些花哨的动画组件。

<script setup> import { ref, computed } from 'vue' const todos = ref([ { id: 1, title: '写一个 Todo 小例子', done: false }, { id: 2, title: '整理笔记', done: true } ]) const inputTitle = ref('') const addTodo = () => { const title = inputTitle.value.trim() if (!title) return todos.value.push({ id: Date.now(), title, done: false }) inputTitle.value = '' } const removeTodo = (id) => { todos.value = todos.value.filter((t) => t.id !== id) } const toggleTodo = (id) => { const todo = todos.value.find((t) => t.id === id) if (todo) todo.done = !todo.done } const remainingCount = computed(() => { return todos.value.filter((t) => !t.done).length }) </script> <template> <div> <h3>待办清单</h3> <input v-model="inputTitle" placeholder="输入新事项" @keyup.enter="addTodo" /> <button @click="addTodo">添加</button> <p>还剩 {{ remainingCount }} 项未完成</p> <ul> <li v-for="todo in todos" :key="todo.id"> <span :style="{ textDecoration: todo.done ? 'line-through' : 'none' }" @click="toggleTodo(todo.id)">{{ todo.title }}</span> <button @click="removeTodo(todo.id)">删除</button> </li> </ul> </div> </template>

关键点在于:v-model把输入框的值和响应式变量inputTitle绑起来,用户输入时变量自动更新,清空输入框时把变量置空字符串即可。v-for循环必须配:key,这里用todo.id而不是数组下标,后面第 4 章会重点说为什么。computed的好处是它只依赖todos,只要todos里任何一项变化,这个结果会自动重新计算,模板里永远拿到的是最新值。

这段代码里@keyup.enter是键盘事件修饰符,按下回车等于点添加按钮,这是个很实用的用户体验细节。id用Date.now()生成,在小例子里完全够用,但如果你做更复杂的数据管理,可以考虑crypto.randomUUID()或者引入一个简单的自增计数器。

3.2 天气卡片:生命周期里发起请求,处理加载与异常

VUE 前端小例子里第二类必练的是“请求数据”。很多教程教fetch就只教你发一个请求,但真实场景里你要管三件事:加载中的等待态、接口报错的提示态、组件销毁后还在回调里操作页面的隐患。下面这个天气卡片例子,把这三件事一起覆盖了:

<script setup> import { ref, onMounted, onUnmounted } from 'vue' const city = ref('杭州') const weather = ref(null) const loading = ref(false) const error = ref('') const fetchWeather = async () => { loading.value = true error.value = '' try { const res = await fetch(`https://api.example.com/weather?city=${city.value}`) if (!res.ok) throw new Error('请求失败') weather.value = await res.json() } catch (e) { error.value = e.message || '加载失败' } finally { loading.value = false } } let timer = null onMounted(() => { fetchWeather() timer = setInterval(fetchWeather, 60 * 1000) }) onUnmounted(() => { clearInterval(timer) }) </script> <template> <div> <h3>{{ city }} 天气</h3> <button @click="fetchWeather" :disabled="loading">刷新</button> <p v-if="loading">加载中...</p> <p v-else-if="error">{{ error }}</p> <div v-else-if="weather"> <p>温度:{{ weather.temperature }}°C</p> <p>天气:{{ weather.description }}</p> </div> </div> </template>

这里的onMounted是组件挂载完成后执行的生命周期钩子,适合放初始化请求。我额外写了一个setInterval定时刷新,并在onUnmounted(组件销毁前)清除定时器,否则组件被切走之后定时器还在跑,会一直发请求甚至报内存泄漏的警告。很多新手翻车就翻在这里:只写了onMounted,没写onUnmounted。

另外注意v-if / v-else-if / v-else的结构,把加载、错误、正常三种状态拆开渲染。这种写法比一股脑渲染数据要稳得多。fetch的地址我用的是api.example.com占位符,实际开发中替换成你自己的后端接口就好;如果你只是练手不想依赖后端,可以用本地 mock 数据,或者找那些公开的、不需要复杂鉴权的接口。

3.3 父传子与子传父:购物车数量联动总价

第三个必练例子是组件通信。VUE 前端小例子里最常见的父子结构就是购物车:父组件管列表和总价,子组件管单个商品的数量加减。这里的关键概念是单向数据流——父组件把数据通过 props 传给子组件,子组件不能直接改 props,只能通过事件把“我要改”告诉父组件,由父组件真正修改数据。

先看父组件 Cart.vue:

<script setup> import { ref, computed } from 'vue' import CartItem from './CartItem.vue' const cart = ref([ { id: 1, name: '无线键盘', price: 199, count: 1 }, { id: 2, name: '机械鼠标', price: 89, count: 2 } ]) const total = computed(() => cart.value.reduce((sum, item) => sum + item.price * item.count, 0) ) const changeCount = (id, delta) => { const item = cart.value.find((i) => i.id === id) if (item) item.count = Math.max(1, item.count + delta) } </script> <template> <div> <CartItem v-for="item in cart" :key="item.id" :item="item" @change="changeCount" /> <p>总计:¥{{ total }}</p> </div> </template>

再看子组件 CartItem.vue:

<script setup> const props = defineProps({ item: { type: Object, required: true } }) const emit = defineEmits(['change']) </script> <template> <div class="cart-item"> <span>{{ item.name }}</span> <button @click="emit('change', item.id, -1)">-</button> <span>{{ item.count }}</span> <button @click="emit('change', item.id, 1)">+</button> <span>¥{{ item.price * item.count }}</span> </div> </template>

父组件用:item="item"把商品对象传给子组件,子组件用defineProps声明接收;子组件内部的按钮通过emit('change', item.id, -1)把意图告诉父组件,父组件用@change="changeCount"监听并真正修改数据。defineProps和defineEmits都是<script setup>里的编译器宏,不需要额外 import,直接调用就行。

为什么要绕这么一圈?因为如果子组件直接改props.item.count,数据源就变得不可追踪,数据流混乱,项目稍微一复杂就全是玄学 bug。坚持单向数据流,数据永远有一个明确的修改入口,出问题的时候在父组件里打断点就能排查。这个购物车例子做完,父子通信这个坎基本就过了。

4. VUE 前端小例子最常踩的 5 个坑:现象、原因、解决

4.1 样式写了没生效,子组件里的 class 被无视

  • 现象:在父组件里给子组件标签加了一个 class,或者在父组件的<style scoped>里想改子组件内部的样式,刷新页面后发现样式完全没有命中。
  • 原因:scoped样式会为当前组件的元素加上一个 data 属性选择器,子组件内部元素的属性由子组件自己生成,父组件的 scoped 样式选不到子组件内部节点。这是 Vue 作用域样式机制的正常表现,不是 bug。
  • 解决:想改子组件内部样式,用:deep()穿透选择器。写法是.parent :deep(.child-inner) { color: red; }。当初我刚接触这个特性时也被“玄学”了一把,其实它设计的初衷就是让样式作用域清晰,不至于全局污染。如果非要在子组件根元素上加 class,子组件根节点会自动继承父组件传下来的 class,但内部元素只能靠:deep()。

4.2 v-for 用 index 做 key,删除列表项后内容错乱

  • 现象:列表渲染正常,删除中间某一项后,剩下的项内容对不上,勾选状态串位,甚至输入框里的内容跑到了别的行。
  • 原因:key是 Vue 做节点复用的依据。用index做 key 时,删除第一项后,原来第二项的 index 变成了 0,Vue 认为还是同一项,于是复用现有节点,但节点内绑定的数据还是老的。
  • 解决:每条数据给一个稳定且唯一的 id。上面的待办清单里:key="todo.id"就是正解。增删操作频繁的场景,永远不要用 index 当 key。数据生成时可以用Date.now()加自增后缀,或者crypto.randomUUID(),保证每条数据的 id 不重复。

4.3 修改数组某项的值,视图纹丝不动

  • 现象:list[0].title = '新标题'执行后,页面没有刷新,列表里还是旧文字。
  • 原因:Vue 3 的响应式基于 Proxy,理论上直接改数组下标也能被拦截,但如果你用的是ref且初始数组是普通数组,在嵌套层级较深或在某些边界写法下,直接赋值确实会触发不了视图更新;更常见的是整体重新赋值时引用没换导致视图不更新。
  • 解决:统一用“整体替换”或者“新数组赋值”的做法。例如要更新下标 0:list.value = list.value.map((item, index) => index === 0 ? { ...item, title: '新标题' } : item)。删除用filter生成新数组,添加用push或展开运算。养成“不直接改原数组,返回新数组”的习惯,既稳又方便调试。

4.4 接口返回正常,页面数据迟迟不来

  • 现象:浏览器网络面板里请求 200,响应 JSON 也完整,但页面上一直是空白或加载中。
  • 原因:最常见两种情况:一是请求在setup顶层就发了,但组件还没挂载完,或者接口返回后组件已经卸载;二是接口返回的字段名和模板里写的不一致,比如蹩脚后端给你返回temp,你模板里写temperature。
  • 解决:先在then回调里console.log(数据),确认字段名和结构;再检查请求是否在onMounted里发起。网络请求做好 loading 和 error 状态,错误提示直接渲染出来,页面空白时至少有个方向。这也是黑匣子最少见的场景——数据其实到了,只是你还没去看而已。

4.5 本地跑得好好的,部署到静态服务器刷新就 404

  • 现象:npm run build之后把 dist 目录扔到 Nginx 或静态托管,首页能打开,但一刷新某个子页面就是 404。
  • 原因:如果小例子用了 Vue Router,默认是 history 模式,路由路径是真实路径,静态服务器没有对应的物理文件,刷新时服务器找不到文件就返回 404。
  • 解决:开发阶段用 history 没问题,部署时让服务器把所有未知路径回退到 index.html。Nginx 加一行try_files $uri $uri/ /index.html;即可;如果是托管平台,找一下 SPA fallback 或 rewrite 配置。不想折腾的话,直接用 hash 模式路由也能绕开。这个坑几乎每个把例子部署上线的人都会碰到一次,早点知道能省不少时间。

5. 把 VUE 前端小例子再往前走一步:用 useTodo 把逻辑抽出来

小例子写多了,你会发现组件里的<script setup>越来越长,模板反而成了配角。这时候就该考虑把逻辑抽取成组合式函数(composable)。写个文件src/composables/useTodo.js,把第 3 章待办清单里的响应式数据和操作全部搬进去:

import { ref, computed } from 'vue' export function useTodo() { const todos = ref([]) const inputTitle = ref('') const remainingCount = computed(() => { return todos.value.filter((t) => !t.done).length }) const addTodo = () => { const title = inputTitle.value.trim() if (!title) return todos.value.push({ id: Date.now(), title, done: false }) inputTitle.value = '' } const removeTodo = (id) => { todos.value = todos.value.filter((t) => t.id !== id) } const toggleTodo = (id) => { const todo = todos.value.find((t) => t.id === id) if (todo) todo.done = !todo.done } return { todos, inputTitle, remainingCount, addTodo, removeTodo, toggleTodo } }

组件里原来的逻辑部分就可以压缩成三行:

<script setup> import { useTodo } from '../composables/useTodo' const { todos, inputTitle, remainingCount, addTodo, removeTodo, toggleTodo } = useTodo() </script>

模板部分保持不变。这样拆的好处是:同一个逻辑可以在多个小例子组件里复用,比如后面做一个“计划表”页面、做一个“习惯打卡”组件,直接 import 这个useTodo,再扩展它。这也是我自己的习惯——每个小例子跑通后,把里面可复用的逻辑丢进composables/目录,下次写新例子时先翻一遍自己的旧目录,能抄的绝不重写。这比收藏任何“项目源码包”都可靠,因为代码是你一行行敲过、踩过坑的。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进