Day 8|Vue3 响应式:告别手动操作 DOM,让数据驱动界面

今天的效果图如下:

前几天我们一直在"手写 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-if vs v-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 自动重新算并更新界面,你不用手动调"重算合计"的函数。

computed vs 普通函数:函数每次渲染都执行;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 派生数据(带缓存)vs watch/watchEffect 副作用
  • 组件通信:父传子 props、子报父 emit(动态数据展示组件的骨架)
  • 六个响应式坑:解构丢响应性、.value、整体重赋值、数组更新、key、shallow

动手任务

  1. 把面板里加一个"按价格升序/降序"切换(提示:给 shown 再 .sort,用 v-model 绑一个 asc 开关)。
  2. 让 ProductCard 组件内显示该商品的"已加购数量"(提示:把 cart 里的 qty 通过一个 props 传进去)。
  3. 加一个"清空购物车"按钮,点击后 cart.value = [],观察 KPI 如何自动归零。

明天预告(Day 9)

明天讲 Vue Router :今天的面板只有一屏,真实后台有商品页、订单页、用户页------怎么用路由做多页面跳转、URL 和组件如何对应、动态路由参数(如 /product/:id)怎么拿。学完就能把今天的面板扩展成一个"能跳转的多页后台"。

进度已记到 Day 8 已交付。下次直接说"更新今天的",我接着写 Day 9(Vue Router)。

相关推荐
码林鼠2 小时前
flutter状态管理入门教程-Riverpod
开发语言·javascript·flutter
互影派2 小时前
分享:Cocos 互动影游一套代码双端分发(Web H5 + Windows/Steam)实践
javascript
Amos_Web3 小时前
Markdonw语法
开发语言·前端·javascript
星光不问赶路人3 小时前
Vue 3 对象监听与快照:ref 和 reactive 的关键差异
前端·vue.js
全栈项目管理程序猿3 小时前
ArcGIS JS 基础教程(34):体元系列 - 等值面 VoxelIsosurface
前端·javascript
SensorFlow3 小时前
离页埋点:sendBeacon 返回 true,为什么还不能算接收成功
javascript
稻草人4783 小时前
Vue3.0项目创建步骤
vue.js
兔子零10243 小时前
架构师最危险的能力:用正确的技术,做出错误的架构决策
前端·javascript·后端
Bug收容所3 小时前
Vue+js Day2
前端·javascript·vue.js