第一个 Vue 3 应用

要点

ref 响应式的即时收益:状态一变,界面自动跟着变,你从头到尾没有碰过 DOM。第一课只需要讲清三件事——状态在哪里、点击如何改变状态、界面为什么会更新。其余的(组件拆分、路由、状态库)都是这三件事长大之后的组织手段。

创建工程

环境按上一篇就位后,建一个只选 ESLint 的最小工程,然后把 src/App.vue 的内容整个替换成下面的代码——第一个应用不需要脚手架自带的演示页:

npm create vue@latest vue-todo   # 交互项只保留 ESLint,其余全否
cd vue-todo && npm install && npm run dev   # 打开 http://localhost:5173

核心概念:响应式链路三步

定义:ref 把一个值包成响应式引用(reactive reference),读写都要经过 .value,于是 Vue 有机会记录”谁在读它、谁改了它”。链路三步:

  1. 组件挂载时 setup 执行一次,声明 ref;
  2. 模板渲染时把用到的 ref 登记为依赖——这一步是自动的;
  3. 代码给 .value 赋值,Vue 只更新依赖它的那部分 DOM,组件函数不会重新执行。

最小示例,五行看懂整条链路:

<script setup>
import { ref } from 'vue'
const count = ref(0)   // 包一层:普通数字才有可能被追踪
</script>

<template>
  <button @click="count++">点了 {{ count }} 次</button>
<!-- 模板里不写 .value:顶层 ref 自动解包(unwrap) -->
</template>

点按钮:count++ 改的是 .value,按钮文字立刻变。没有 setState,没有 render,更新就发生了。

第一个应用:待办清单

同一个 App.vue,换成完整版。输入框(v-model 双向绑定)、列表(v-for)、点行切换完成(click 事件)、剩余计数(computed 派生):

<script setup>
import { ref, computed } from 'vue'

const todos = ref([
  { id: 0, text: '读官方互动教程', done: true },
  { id: 1, text: '跑通本篇待办应用', done: false },
])
const newText = ref('')

// 派生状态:todos 不变就不重算,依赖自动收集,不用手写依赖数组
const remaining = computed(() => todos.value.filter(t => !t.done).length)

function addTodo() {
  const text = newText.value.trim()
  if (!text) return                 // 空输入不加空行
  todos.value.push({ id: Date.now(), text, done: false })
  newText.value = ''                // v-model 让输入框同步清空
}
function toggle(todo) {
  todo.done = !todo.done            // ref 是深层的:嵌套属性直接改就有效
}
</script>

<template>
  <main>
    <h1>待办(剩 {{ remaining }} 件)</h1>
    <input v-model="newText" placeholder="记点什么" @keyup.enter="addTodo" />
    <button @click="addTodo">添加</button>
    <ul>
      <!-- :key 对齐节点与数据,增删行不串位 -->
      <li v-for="todo in todos" :key="todo.id"
          :class="{ done: todo.done }" @click="toggle(todo)">
        {{ todo.text }}
      </li>
    </ul>
  </main>
</template>

<style scoped>
.done { text-decoration: line-through; color: #999; }
</style>

可观察结果:输入”买牛奶”回车——新行出现、剩余计数加一、输入框清空;点任意一行——划线、计数减一。保存文件页面即热更新。打开 DevTools 的组件面板,能看到 todos 的实时值,改界面和改面板双向同步。

入口链路速读

页面从 index.html 进来,一路到组件,只有三跳:

<!-- index.html -->
<div id="app"></div>          <!-- 挂载点:整个应用长在这里 -->
<script type="module" src="/src/main.js"></script>
// src/main.js:创建应用、挂载,两行办完
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

日常改动九成发生在 App.vue 与后续拆出的子组件里;main.js 只在加插件(路由、Pinia)时再碰。 是单文件组件的语法糖:顶层声明的变量、函数自动暴露给模板,不用再写 return 与 components 注册——这就是模板能直接用 count 与 addTodo 的原因。

与 React 的心智对比

写过 React 再来写 Vue,差异集中在”状态变化之后发生什么”:

维度 React Vue 3
声明状态 const [n, setN] = useState(0) const n = ref(0)
更新状态 setN(n + 1),不可变风格,改对象要整份重建 n.value++,可变风格,深层属性直接改
更新之后 整个组件函数重新执行一遍 setup 不重跑,只更新用到该状态的模板部分
派生值 useMemo(fn, [deps]),依赖手写 computed(fn),依赖自动收集
副作用 useEffect(fn, [deps]),依赖手写,漏写是经典 bug watch / watchEffect,依赖自动收集

这不是”Vue 不用管依赖”,而是把收集依赖的活从人挪给了框架:React 要求你写依赖数组、保持不可变,换来的是”任意时刻函数重跑即视图”的模型;Vue 用 .value 的读写拦截换掉这套约束。两个模型各自的自洽逻辑,见《React Hooks 心智模型》与《React 渲染与重渲染》——对照着读,哪边卡壳都知道卡在哪一层。

第一次拆组件

界面里出现重复结构(比如列表行)时,把它拆成子组件,数据用 props 传入、操作用事件抛回。新建 src/components/TodoItem.vue:

<script setup>
defineProps(['todo'])                    // 输入:父组件传进来的数据
const emit = defineEmits(['toggle'])     // 输出:向父组件报告点击
</script>
<template>
  <li :class="{ done: todo.done }" @click="emit('toggle', todo)">
    {{ todo.text }}
  </li>
</template>

App.vue 里模板改成 <TodoItem v-for="todo in todos" :key="todo.id" :todo="todo" @toggle="toggle" />,toggle 函数不变。功能没变,但”一行长什么样”归子组件、”列表有什么”归父组件,边界出现了。什么时候拆、props 与 emits 的完整规则,下一篇展开。

最佳实践

第一个应用只保留一个能解释清楚的界面。反模式一:一上来就拆五个子组件、引 Pinia——拆分是为了复用与边界,没有复用需求时拆分只增加跳转成本。反模式二:绕过响应式去手动操作 DOM(document.querySelector 改文字)——那等于把框架最核心的服务停用,界面状态会和数据状态分叉。

常见问题

Q1: 模板里为什么不写 .value

顶层 ref 在模板中自动解包。<p>{{ count }}</p> 里 count 已经是数字。但嵌套在普通对象或数组里的 ref 不会解包——所以约定 ref 存顶层,用 reactive 或对象包起来时再小心。

Q2: 改了数据界面没反应

九成是绕开了响应式:script 里忘了 .value(count = 1 只是换了变量绑定),或解构了 reactive 对象(见下一篇语法要点)。DevTools 面板里能看到真实状态值,先确认数据到底变没变。

Q3: 为什么直接改 props 会收到警告

props 属于父组件,单向数据流(one-way data flow)要求子组件发事件让数据的主人去改。这条规则在语法要点篇展开。

练习与实践

练习一:把待办应用加一个”清除已完成”按钮(提示:过滤后整体替换 todos.value),记录点击前后 remaining 的变化。练习二:把 todos 初始化为空数组,体验从空列表到第一条的全过程。评估标准:能口头说出每次点击后”哪个状态变了、哪些 DOM 更新了、setup 有没有重新执行”。

延伸阅读

本篇用到的 ref、computed、v-model、props 各自的”为什么”与边界,下一篇《Vue 3 语法要点》逐个展开,查询具体 API 的签名与坑则留给系列末的参考字典。

版本核实(撰写时检索,2025-03):本文 API(ref、computed、v-model、v-for :key、script setup)均为 Vue 3 稳定特性,Vue 当前稳定线为 3.5.x;defineModel 等 3.4 起新特性在后续篇章标注版本。具体以官方文档与你的锁文件为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇