今天的效果图如下:

前几天我们一直在"手写 DOM":商品列表要自己
map出 HTML 字符串、再innerHTML塞进去;改一个价格要手动找到那个节点、改文字、再同步合计。数据一变,界面就得你亲手去推------这就是原生 JS 做界面的累:数据和视图是两套东西,你得当"中间人"。今天正式进入 Vue3 。Vue 最核心的能力叫响应式 :你把数据声明好,界面用模板"描述"数据长什么样;之后只要数据变,界面自动跟着变 ,你再也不碰 DOM。今天要串起 响应式原理、
ref/reactive、模板与指令、v-model、v-for、computed、watch、组件与props/emit,并解释清楚"数据一变界面怎么就变了"这件事。文末同样有一个完整
index.html:一个用 Vue3 CDN 写的 MiniMall 商品数据动态展示面板------一个可复用的商品卡片组件、实时筛选、KPI 自动计算、增删改即时刷新,复制保存即可打开。全程零构建、零后端。
一、先看清问题:为什么需要"响应式"
回看 Day 5/6 的购物车代码,一次"加一件商品"你要做四件事:改 cart 数据 → 找到数量节点改文字 → 重算合计 → 更新合计节点。数据量一大、页面一复杂,这种"改完数据再手动改视图"的同步代码就会失控。
| 手写 DOM(前几天) | 响应式(Vue) |
|---|---|
| 数据、视图分开维护 | 视图声明式地绑定数据 |
| 改数据 → 手动找节点改视图 | 改数据 → 视图自动更新 |
| 容易漏改、改错节点 | 只描述"应该长什么样" |
| 代码量随交互平方级增长 | 代码量≈数据本身 |
一句话:响应式 = 让框架替你做"数据 → 视图"的同步。 你的工作从"操作 DOM"变成"管理数据"。这是现代前端效率跃升的根本原因,也是 React / Vue / Svelte 共同的底层思想。
二、把 Vue3 跑起来:CDN + createApp + 组合式 setup
今天用 CDN(内容分发网络,把 vue.js 挂到网上直接 <script> 引入)的零构建方式,复制就能跑:
html
<div id="app">{{ msg }}</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script>
const { createApp, ref, reactive, computed } = Vue; // 从全局 Vue 里取 API
createApp({
setup() { // 组合式 API 入口
const msg = ref('Hello Vue3'); // 一个响应式数据
return { msg }; // 暴露给模板
}
}).mount('#app'); // 挂载到 #app
</script>
createApp:创建一个应用实例,.mount('#app')把它"焊"到页面上某个元素里。setup():Vue3 推荐的写法(组合式 API),所有数据和方法都在这一个函数里定义,return出去的东西模板里就能用。它取代了 Vue2 把数据塞data、方法塞methods的"选项式"写法。- 模板 :
{``{ msg }}叫插值 ,把setup里return的msg显示出来------注意我们没写任何document.getElementById。
真实项目用 Vite 脚手架(Day 12 讲)拆成
.vue单文件组件;今天为了"复制即运行",全写在 HTML 里,但写法和真实 Vue3 组件完全一致,迁移只是换个文件后缀。
三、ref 与 reactive:给数据装上"响应式"
Vue3 用这两个函数把普通 JS 值变成"响应式数据"------普通值变了界面不动,响应式值变了界面才动。
js
import { ref, reactive } from 'vue';
// ref:包一个"基本类型"(数字/字符串/布尔),通过 .value 读写
const count = ref(0);
count.value++; // 代码里读写要带 .value
// 模板里直接用 {{ count }},Vue 自动解包,不用写 .value
// reactive:包一个"对象/数组",直接读写属性,不用 .value
const product = reactive({ name: '耳机', price: 299, stock: 10 });
product.price = 199; // 改属性,绑定它的视图立刻更新
什么时候用哪个:
- 单个值 (计数、输入框内容、开关)→
ref。 - 一组相关的字段 / 列表 / 表单对象 →
reactive。 - 拿不准就用
ref------它什么都能包(包括对象,.value里放对象即可),最不容易出错。
js
// ref 也能装对象/数组
const cart = ref([
{ sku: 'A1', name: '耳机', price: 299, qty: 1 },
{ sku: 'B2', name: '键盘', price: 199, qty: 2 },
]);
cart.value[0].qty++; // 数组内容改一样触发更新(下面第十节讲深拷贝坑)
响应式原理一句话 :Vue3 用 Proxy(ES6 的"对象代理")劫持数据的读写。读 的时候记录"哪个组件用了这个字段",写的时候通知"用过的组件重新渲染"。所以你只管写数据,谁用了它、怎么更新,Vue 记账自己干。理解到这个程度就够用了。
四、模板语法与指令:用"属性"描述界面
Vue 把常用的 DOM 操作做成了带 v- 前缀的指令,写在标签属性上。
html
<!-- 1. 文本插值 与 一次性绑定文本 -->
<p>{{ product.name }} · ¥{{ product.price }}</p>
<!-- 2. 属性绑定:v-bind:,简写为 : -->
<img :src="product.img" :alt="product.name" />
<div :class="{ danger: product.stock === 0 }"> <!-- 对象语法:条件加类 -->
<!-- 3. 事件绑定:v-on:,简写为 @ -->
<button @click="add">加入购物车</button>
<input @keyup.enter="submit" /> <!-- 事件修饰符:回车触发 -->
<!-- 4. 条件渲染 -->
<p v-if="product.stock > 0">有货 {{ product.stock }} 件</p>
<p v-else>已售罄</p>
<div v-show="isDetail">展开的详情</div> <!-- v-show 用 CSS 显示/隐藏 -->
<!-- 5. 渲染原始 HTML(内容可信时才用,防 XSS) -->
<div v-html="safeRichText"></div>
| 指令 | 作用 | 简写 |
|---|---|---|
{``{ }} |
插值文本 | --- |
v-bind:src |
把表达式的值绑到属性 | :src |
v-on:click |
监听事件 | @click |
v-if / v-else |
条件创建/销毁元素 | --- |
v-show |
条件显示(切 CSS) | --- |
v-ifvsv-show:要频繁切换显隐用v-show(只切display,性能好);这块内容平时根本不存在、条件很少变用v-if(真销毁 DOM,省内存)。
五、v-model:输入框双向绑定(接上 Day 5 的表单校验)
前几天给输入框取/设值是"读 input.value、写 input.value"两套操作。v-model 把它合成一个:数据 ⇄ 输入框双向同步。
html
<input v-model="keyword" placeholder="搜索商品" />
<p>你正在搜索:{{ keyword }}</p> <!-- 打字时这句实时变 -->
<input v-model="qty" type="number" min="1" />
<label><input type="checkbox" v-model="agree" /> 同意协议</label>
<select v-model="cat">
<option value="">全部</option>
<option value="数码">数码</option>
</select>
js
setup() {
const keyword = ref(''); // 用户在框里打字 -> keyword 自动更新
const agree = ref(false); // 勾选框 -> 布尔值
const cat = ref(''); // 下拉 -> 选中值
return { keyword, agree, cat };
}
配合 Day 6 的
computed,能做出"边打字边提示、不过就禁止提交"的实时校验:输入框绑v-model,错误信息用computed算出来,提交按钮:disabled="!valid"。今天的示例商品筛选就是这么做的。
六、v-for 列表渲染 + key:把数组变成一片卡片
渲染列表是响应式的最大爽点------Day 5/6 里手写 map + innerHTML,这里一个 v-for 搞定。
html
<!-- 遍历数组 -->
<ul>
<li v-for="item in cart" :key="item.sku">
{{ item.name }} × {{ item.qty }}
<button @click="remove(item.sku)">删除</button>
</li>
</ul>
<!-- 要下标用 (item, index);遍历对象用 (value, key) -->
<tr v-for="(p, i) in products" :key="p.id">{{ i + 1 }}. {{ p.name }}</tr>
js
remove(sku) {
// 只改数据,列表视图自动重排------不用自己删 <li>
cart.value = cart.value.filter(it => it.sku !== sku);
}
:key 必须给、且要唯一稳定:它告诉 Vue "这一行身份是谁"。Vue 靠 key 做 diff------删/插/换序时尽量复用已有 DOM 节点,又快又不会错位。
key用业务唯一值 (sku/id),别用数组下标i。下标会随增删变化,Vue 会误判身份,导致"删第一条结果最后一条消失"这类诡异 bug。这是 Vue 面试和实战都高频的坑。
七、computed 计算属性:派生数据自动重算
合计、总金额、过滤后的列表------凡是"由别的算出来的",用 computed,它只在依赖变化时才重算(带缓存)。
js
const subtotal = computed(() =>
cart.value.reduce((s, it) => s + it.price * it.qty, 0) // 依赖 cart
);
const countKinds = computed(() => cart.value.length); // 依赖 cart
模板里像普通值一样用:{``{ subtotal.toFixed(2) }}。只要 cart 一变,subtotal 自动重新算并更新界面,你不用手动调"重算合计"的函数。
computedvs 普通函数:函数每次渲染都执行;computed有缓存,依赖没变就直接用上次结果。computed必须是纯计算 (不能有请求、改数据等副作用),要"变了去做点事"用下一节的watch。
八、watch / watchEffect:数据变了,执行副作用
computed 用来"算新值",watch 用来"数据变了去干点别的事"------发请求、写日志、弹提示。
js
// watch:明确盯某个源,变了就触发回调(带新旧值)
watch(keyword, (val, old) => {
console.log(`搜索词:${old} -> ${val}`);
fetchSuggestions(val); // 搜索词变了,去请求联想
});
// watchEffect:自动追踪里面用到的依赖,一有变化就整体再跑一次
watchEffect(() => {
localStorage.setItem('kw', keyword.value); // keyword 变了就同步本地存储
});
选择口诀 :要"得到一个新的值显示出来" → computed;要"数据变化触发一段副作用逻辑" → watch。别用 watch 去算派生值,也别用 computed 去发请求。
九、组件与 props / emit:把界面拆成可复用的"动态展示组件"
Word 里今天的小项目叫"动态数据展示组件"------本质就是把一块界面封装成组件 ,数据从外面传进来(props),组件里的事往外面喊(emit)。
js
// 定义一个可复用的商品卡片组件
const ProductCard = {
props: ['item'], // 从父组件接收数据(只读)
emits: ['add'], // 声明会向父组件发出的事件
template: `
<div class="card">
<h4>{{ item.name }}</h4>
<p>¥{{ item.price }} · 库存 {{ item.stock }}</p>
<button :disabled="item.stock === 0" @click="$emit('add', item.sku)">
{{ item.stock === 0 ? '售罄' : '加入购物车' }}
</button>
</div>`,
};
// 父应用注册并使用:v-for 渲染一堆卡片,监听每张卡片的 add 事件
const App = {
components: { ProductCard },
setup() { /* 商品数据 + addToCart */ },
template: `
<ProductCard
v-for="item in products" :key="item.id" :item="item"
@add="addToCart" />`,
};
这就是"动态数据展示组件"的完整闭环:父组件持有数据 →
v-for把每条数据当props喂给子组件 → 子组件纯展示 + 用emit把用户操作抛回父组件 → 父组件改数据 → 响应式让整片卡片自动刷新。 这套父传子(props)、子报父(emit)的模式,是 Vue 组件通信的骨架,后面 Element Plus 封装的表格/表单组件也全建立在它之上。
十、响应式六个坑 + 调试
js
// 坑1:解构 / 解 .value 会丢掉响应性
const state = reactive({ n: 1 });
const { n } = state; // ✗ n 变普通数字,改了界面不动
state.n = 2; // state 上是响应式的,但解构出来的 n 不是
// 解构响应式对象用 toRef/toRefs:const { n } = toRefs(state);
// 坑2:ref 在 JS 里必须 .value,模板里必须不加 .value ------ 记反就报错
function bump() { count.value++; } // JS 里带 .value
// 模板:{{ count }} 不带
// 坑3:reactive 整体重新赋值会丢响应式
let form = reactive({ a: 1 });
form = { a: 2 }; // ✗ 直接换对象,响应式丢了
// 要整体重置改回 ref,或逐属性赋值
// 坑4:数组直接按下标改 / 改 length,Vue3 的 Proxy 能捕获,但别用深拷贝后的普通数组塞回来
list.value = [...list.value, x]; // ✓ 换新数组触发更新(Day6 的展开用法)
// 坑5:v-for 用 index 当 key(见第六节)
// 坑6:深层大对象性能吃紧时,用 shallowRef/shallowReactive 减少代理开销
调试 :装 Vue Devtools 浏览器插件,能实时看每个组件的 props/响应式数据、computed 的值、组件树、事件流------相当于 Vue 版 DevTools,调响应式 bug 必备。
十一、完整小项目:MiniMall 商品数据动态展示面板(可直接复制)
下面是一份完整可运行的 index.html:用 Vue3 CDN 写的商品动态面板------一个可复用的 ProductCard 组件、v-for 渲染、v-model 实时搜索、分类筛选、computed 算 KPI、加购后总数与金额自动刷新。复制保存为 index.html 双击打开即可。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MiniMall · 商品动态展示面板</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f6f8; color: #222; padding: 20px; }
h1 { font-size: 22px; margin-bottom: 16px; }
.toolbar { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.toolbar input, .toolbar select { padding: 8px 12px; border: 1px solid #d0d3d9; border-radius: 8px; font-size: 14px; }
.kpi { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; }
.kpi div { background: #fff; border-radius: 10px; padding: 12px 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.kpi b { display: block; font-size: 20px; color: #2f6fed; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; }
.card { background: #fff; border-radius: 12px; padding: 14px; box-shadow: 0 1px 6px rgba(0,0,0,.08); }
.card h4 { font-size: 15px; margin-bottom: 6px; }
.card .meta { color: #666; font-size: 13px; margin-bottom: 10px; }
.card button { width: 100%; padding: 8px; border: 0; border-radius: 8px; background: #2f6fed; color: #fff; cursor: pointer; }
.card button:disabled { background: #c8ccd3; cursor: not-allowed; }
.tag { font-size: 12px; color: #fff; background: #ff7a45; border-radius: 4px; padding: 1px 6px; margin-left: 6px; }
.empty { text-align: center; color: #999; padding: 40px; }
</style>
</head>
<body>
<div id="app">
<h1>MiniMall · 商品动态展示面板</h1>
<div class="toolbar">
<input v-model="kw" placeholder="搜索商品名" />
<select v-model="cat">
<option value="">全部分类</option>
<option v-for="c in categories" :key="c" value="c">{{ c }}</option>
</select>
<label style="align-self:center;"><input type="checkbox" v-model="onlyStock" /> 只看有货</label>
</div>
<div class="kpi">
<div>在展示<b>{{ shown.length }}</b></div>
<div>购物车件数<b>{{ totalQty }}</b></div>
<div>购物车金额<b>¥{{ totalAmount.toFixed(2) }}</b></div>
</div>
<div class="grid" v-if="shown.length">
<product-card v-for="p in shown" :key="p.id" :item="p" @add="addToCart"></product-card>
</div>
<p class="empty" v-else>没有符合条件的商品</p>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script>
const { createApp, ref, computed, watchEffect } = Vue;
// 可复用的商品卡片组件:props 收数据,emit 抛加购事件
const ProductCard = {
props: ['item'],
emits: ['add'],
template: `
<div class="card">
<h4>{{ item.name }}<span class="tag" v-if="item.hot">热销</span></h4>
<div class="meta">¥{{ item.price }} · {{ item.cat }} · 库存 {{ item.stock }}</div>
<button :disabled="item.stock === 0" @click="$emit('add', item)">
{{ item.stock === 0 ? '已售罄' : '加入购物车' }}
</button>
</div>`
};
createApp({
components: { ProductCard },
setup() {
const products = ref([
{ id: 1, name: '蓝牙耳机', cat: '数码', price: 299, stock: 12, hot: true },
{ id: 2, name: '机械键盘', cat: '数码', price: 199, stock: 0, hot: false },
{ id: 3, name: '保温杯', cat: '家居', price: 89, stock: 50, hot: true },
{ id: 4, name: '护眼台灯', cat: '家居', price: 129, stock: 20, hot: false },
{ id: 5, name: '跑步鞋', cat: '运动', price: 459, stock: 5, hot: true },
{ id: 6, name: '瑜伽垫', cat: '运动', price: 69, stock: 30, hot: false },
]);
const kw = ref(''), cat = ref(''), onlyStock = ref(false);
const cart = ref([]);
const categories = computed(() => [...new Set(products.value.map(p => p.cat))]);
// 筛选链:关键词 + 分类 + 只看有货(Day6 的声明式流水线,这里用 computed 驱动)
const shown = computed(() =>
products.value.filter(p =>
p.name.includes(kw.value.trim()) &&
(!cat.value || p.cat === cat.value) &&
(!onlyStock.value || p.stock > 0)
)
);
const totalQty = computed(() => cart.value.reduce((s, i) => s + i.qty, 0));
const totalAmount = computed(() => cart.value.reduce((s, i) => s + i.price * i.qty, 0));
function addToCart(item) {
if (item.stock === 0) return;
item.stock--; // 直接改响应式数据,界面自动刷新
const hit = cart.value.find(i => i.id === item.id);
if (hit) hit.qty++;
else cart.value.push({ ...item, qty: 1 });
}
watchEffect(() => localStorage.setItem('mm_cart', JSON.stringify(cart.value)));
return { products, kw, cat, onlyStock, categories, shown,
totalQty, totalAmount, addToCart };
}
}).mount('#app');
</script>
</body>
</html>
注意它是怎么"动"起来的:你在搜索框打字(
v-model="kw")→shown这个computed自动重算 →v-for的卡片列表自动增删;点"加入购物车"(组件emit('add'))→addToCart改cart/stock→ KPI 与"库存/售罄"自动刷新。全程没写一行 DOM 操作,这就是响应式的日常。
本篇覆盖要点清单
- 响应式的本质:框架替你做"数据→视图"同步,你只管数据
- 零构建跑 Vue3:CDN +
createApp().mount()+ 组合式setup() ref(基本类型,用.value)/reactive(对象,直接点属性)怎么选- 响应式原理一句话:Proxy 读时记账、写时通知重渲染
- 模板与指令:插值、
:绑属性、@绑事件、v-if/v-show v-model双向绑定,接上实时表单校验v-for+ 唯一稳定:key(别用下标)computed派生数据(带缓存)vswatch/watchEffect副作用- 组件通信:父传子
props、子报父emit(动态数据展示组件的骨架) - 六个响应式坑:解构丢响应性、
.value、整体重赋值、数组更新、key、shallow
动手任务
- 把面板里加一个"按价格升序/降序"切换(提示:给
shown再.sort,用v-model绑一个asc开关)。 - 让
ProductCard组件内显示该商品的"已加购数量"(提示:把cart里的qty通过一个props传进去)。 - 加一个"清空购物车"按钮,点击后
cart.value = [],观察 KPI 如何自动归零。
明天预告(Day 9)
明天讲 Vue Router :今天的面板只有一屏,真实后台有商品页、订单页、用户页------怎么用路由做多页面跳转、URL 和组件如何对应、动态路由参数(如 /product/:id)怎么拿。学完就能把今天的面板扩展成一个"能跳转的多页后台"。
进度已记到 Day 8 已交付。下次直接说"更新今天的",我接着写 Day 9(Vue Router)。