Vue计算属性详解+模板引用与生命周期:解锁响应式与DOM操作全技巧

0 阅读21分钟

Vue计算属性详解+模板引用与生命周期:解锁响应式与DOM操作全技巧

在Vue响应式开发中,我们经常需要根据已有响应式数据,派生出新的状态——比如根据用户输入过滤列表、根据多个变量拼接名称、根据状态切换显示内容等。如果直接在模板中编写复杂逻辑,会让模板臃肿杂乱、难以维护;如果重复编写相同逻辑,又会导致代码冗余、性能损耗。Vue提供的computed(计算属性),正是为解决这些问题而生,它让响应式逻辑的编写更简洁、高效、可维护。今天就从计算属性的基础用法、核心特性、实战场景到避坑指南,再延伸到模板引用与生命周期钩子,全方位解锁Vue响应式开发与手动DOM操作的核心技巧,帮你彻底掌握这些高频开发工具。

为什么需要计算属性?模板逻辑的“减负神器”

在Vue模板中,我们可以直接编写简单的表达式,比如拼接字符串、判断布尔值、简单运算等,这能满足基础的展示需求。但当逻辑变得复杂时,模板表达式的弊端就会凸显出来。

痛点示例:臃肿的模板逻辑

假设我们有一个用户信息组件,需要根据用户的姓名、性别、年龄,动态显示一段描述文本,同时判断用户是否为成年,还要拼接用户的完整身份信息。如果直接在模板中编写逻辑,代码会是这样:

<script setup>
import { ref } from 'vue';

// 响应式用户数据
const user = ref({
  firstName: '张',
  lastName: '三',
  gender: '男',
  age: 22
});
</script>

<template>
  <div class="user-info">
    <!-- 模板中嵌套复杂逻辑,可读性差、难以维护 -->
    <p>用户描述:{{ user.firstName + user.lastName }},{{ user.gender === '男' ? '先生' : '女士' }},{{ user.age >= 18 ? '已成年' : '未成年' }}</p>
    <p>完整身份:{{ user.gender === '男' ? 'Mr.' : 'Ms.' }} {{ user.firstName + user.lastName }} ({{ user.age }}岁)</p>
  </div>
</template>

这段代码虽然能实现需求,但存在两个明显问题:一是模板逻辑过于复杂,可读性差,后续修改时需要反复梳理表达式;二是存在重复逻辑(比如拼接姓名、判断性别),如果多处需要使用,会导致代码冗余,且修改时需要多处同步修改,容易出错。

解决方案:用计算属性简化逻辑

计算属性的核心作用,就是将复杂的派生逻辑抽离出来,单独定义,让模板只负责展示,逻辑负责计算。上面的示例,用计算属性重构后,代码会变得简洁清晰:

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

const user = ref({
  firstName: '张',
  lastName: '三',
  gender: '男',
  age: 22
});

// 计算属性:拼接完整姓名
const fullName = computed(() => {
  return user.value.firstName + user.value.lastName;
});

// 计算属性:判断用户称谓
const honorific = computed(() => {
  return user.value.gender === '男' ? '先生' : '女士';
});

// 计算属性:判断是否成年
const isAdult = computed(() => {
  return user.value.age >= 18;
});

// 计算属性:完整用户描述
const userDesc = computed(() => {
  return `${fullName.value}${honorific.value}${isAdult.value ? '已成年' : '未成年'}`;
});

// 计算属性:完整身份信息
const userIdentity = computed(() => {
  const title = user.value.gender === '男' ? 'Mr.' : 'Ms.';
  return `${title} ${fullName.value} (${user.value.age}岁)`;
});
</script>

<template>
  <div class="user-info">
    <!-- 模板只负责展示,逻辑清晰易懂 -->
    <p>用户描述:{{ userDesc }}</p>
    <p>完整身份:{{ userIdentity }}</p>
  </div>
</template>

重构后的代码,将所有派生逻辑抽离为独立的计算属性,模板变得简洁干净,且每个计算属性职责单一,后续修改时只需修改对应计算属性,无需改动模板,可维护性大幅提升。这就是计算属性的核心价值:分离逻辑与展示,简化模板,减少冗余

计算属性基础用法:从定义到使用

Vue3中,计算属性通过computed()函数定义,该函数接收一个getter函数(核心逻辑),返回一个计算属性ref。和普通ref一样,我们可以通过.value访问其值,在模板中会自动解包,无需手动添加.value

1. 基础定义:只读计算属性

最常用的计算属性是“只读”的,即只定义getter函数,用于根据依赖的响应式数据派生新值。其基本语法如下:

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

// 源响应式数据
const num1 = ref(10);
const num2 = ref(20);

// 计算属性:派生新值(只读)
const sum = computed(() => {
  // getter函数:逻辑计算,返回派生值
  return num1.value + num2.value;
});
</script>

<template>
  <div>
    <p>num1: {{ num1 }}</p>
    <p>num2: {{ num2 }}</p>
    <p>两数之和:{{ sum }}</p> <!-- 模板中自动解包,无需.sum.value -->
  </div>
</template>

关键点说明:

  • 计算属性会自动追踪其依赖的响应式数据(如上例中的num1、num2),当依赖发生变化时,计算属性会自动重新计算;
  • 计算属性的值会被缓存,只要依赖没有变化,多次访问计算属性,都会直接返回缓存的结果,不会重复执行getter函数;
  • 只读计算属性不能直接修改,若尝试修改(如sum.value = 50),会触发运行时警告。

2. 实战场景1:列表过滤(结合v-for)

在列表渲染中,过滤功能是高频需求,结合计算属性使用,能让过滤逻辑更清晰,且无需手动触发过滤方法。比如我们有一个商品列表,需要根据搜索关键词过滤商品:

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

// 源响应式数据:商品列表、搜索关键词
const goods = ref([
  { id: 1, name: 'Vue实战教程', price: 99 },
  { id: 2, name: 'JavaScript进阶指南', price: 89 },
  { id: 3, name: '前端面试题库', price: 69 },
  { id: 4, name: 'Vue3组件封装实战', price: 129 }
]);
const searchKey = ref('');

// 计算属性:过滤商品列表
const filteredGoods = computed(() => {
  // 转换为小写,实现不区分大小写过滤
  const key = searchKey.value.toLowerCase();
  // 根据关键词过滤商品
  return goods.value.filter(good => {
    return good.name.toLowerCase().includes(key);
  });
});
</script>

<template>
  <div class="goods-list">
    <input 
      type="text" 
      v-model="searchKey" 
      placeholder="请输入商品名称搜索"
    />
    <ul>
      <!-- 渲染过滤后的列表 -->
      <li v-for="good in filteredGoods" :key="good.id" class="goods-item">
        商品名称:{{ good.name }} | 价格:¥{{ good.price }}
      </li>
    </ul>
  </div>
</template>

<style>
.goods-list {
  margin: 20px;
  padding: 15px;
  border: 1px solid #eee;
  border-radius: 4px;
}
.goods-item {
  margin: 8px 0;
  list-style: none;
  padding: 6px;
  background: #f8f9fa;
}
input {
  padding: 6px;
  width: 300px;
  margin-bottom: 10px;
}
</style>

这个示例中,搜索关键词searchKey变化时,filteredGoods会自动重新计算,过滤出包含关键词的商品,无需手动调用过滤方法,实现了“数据驱动视图”的响应式效果。

3. 实战场景2:多依赖派生状态

计算属性可以依赖多个响应式数据,甚至可以依赖其他计算属性,实现更复杂的派生逻辑。比如一个购物车组件,需要根据商品数量和单价,计算商品小计、购物车总价,还要判断是否满足满减条件:

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

// 源响应式数据:商品信息、数量
const product = ref({
  name: 'Vue实战教程',
  price: 99,
  discount: 0.9 // 折扣
});
const quantity = ref(2);
const fullReduction = ref(200); // 满200减50

// 计算属性:商品小计(单价×数量×折扣)
const subTotal = computed(() => {
  return (product.value.price * quantity.value * product.value.discount).toFixed(2);
});

// 计算属性:购物车总价(此处简化为单个商品小计)
const totalPrice = computed(() => {
  return parseFloat(subTotal.value); // 依赖其他计算属性
});

// 计算属性:是否满足满减条件
const isFullReduction = computed(() => {
  return totalPrice.value >= fullReduction.value; // 依赖其他计算属性
});

// 计算属性:最终支付金额
const finalPrice = computed(() => {
  return isFullReduction.value ? (totalPrice.value - 50).toFixed(2) : totalPrice.value.toFixed(2);
});
</script>

<template>
  <div class="cart-item">
    <h3>{{ product.name }}</h3>
    <p>单价:¥{{ product.price }}({{ product.discount * 10 }}折)</p>
    <p>数量:<button @click="quantity--" :disabled="quantity <= 1">-</button> {{ quantity }} <button @click="quantity++">+</button></p>
    <p>小计:¥{{ subTotal }}</p>
    <p>总价:¥{{ totalPrice }}</p>
    <p v-if="isFullReduction" style="color: #e53e3e;">满足满{{ fullReduction }}减50!</p>
    <p>最终支付:¥{{ finalPrice }}</p>
  </div>
</template>

这个示例中,finalPrice依赖isFullReduction,isFullReduction依赖totalPrice,totalPrice依赖subTotal,形成了依赖链。只要其中任何一个源数据(如quantity、product.price)发生变化,所有相关的计算属性都会自动重新计算,且每个计算属性都会缓存自己的结果,避免重复计算。

核心特性:计算属性缓存 vs 方法

很多初学者会有一个疑问:既然计算属性能实现的逻辑,用方法也能实现,为什么还要用计算属性?比如上面的求和逻辑,用方法编写如下:

<script setup>
import { ref } from 'vue';

const num1 = ref(10);
const num2 = ref(20);

// 方法:计算两数之和
function getSum() {
  return num1.value + num2.value;
}
</script>

<template>
  <p>两数之和:{{ getSum() }}</p>
</template>

从表面上看,计算属性和方法的效果完全一致,但二者的核心区别在于缓存机制,这也是计算属性的性能优势所在。

1. 计算属性缓存:依赖不变,结果不变

计算属性会缓存其计算结果,只有当它依赖的响应式数据发生变化时,才会重新执行getter函数,重新计算结果。如果依赖没有变化,无论多少次访问计算属性,都会直接返回缓存的结果,不会重复执行逻辑。

举个例子,假设我们有一个耗时的计算逻辑(比如循环一个巨大的数组):

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

const list = ref(new Array(100000).fill(1)); // 模拟巨大数组

// 计算属性:耗时计算(求和)
const listSum = computed(() => {
  console.log('计算属性重新计算'); // 仅在list变化时打印
  return list.value.reduce((total, item) => total + item, 0);
});

// 方法:耗时计算(求和)
function getListSum() {
  console.log('方法重新执行'); // 每次调用都会打印
  return list.value.reduce((total, item) => total + item, 0);
}
</script>

<template>
  <!-- 多次访问计算属性,仅第一次(或list变化时)执行逻辑 -->
  <p>计算属性求和:{{ listSum }}</p>
  <p>计算属性求和:{{ listSum }}</p>
  <p>计算属性求和:{{ listSum }}</p>

  <!-- 多次调用方法,每次都执行逻辑 -->
  <p>方法求和:{{ getListSum() }}</p>
  <p>方法求和:{{ getListSum() }}</p>
  <p>方法求和:{{ getListSum() }}</p>
</template>

运行这段代码会发现:计算属性的console.log只打印一次(或list变化时打印),而方法的console.log会打印三次。这就是缓存的作用——避免重复执行耗时逻辑,提升页面性能。

2. 方法:无缓存,每次调用都执行

方法没有缓存机制,每次调用方法,都会重新执行方法内部的逻辑。即使方法依赖的响应式数据没有变化,只要调用方法,就会重复计算。

3. 如何选择:计算属性 vs 方法

根据二者的特性,我们可以明确选择场景:

  • 使用计算属性:当逻辑依赖响应式数据,且需要多次访问结果,且逻辑耗时(如过滤、排序、复杂运算)时,优先使用计算属性,利用缓存提升性能;
  • 使用方法:当逻辑不依赖响应式数据(如生成随机数、格式化时间),或需要每次调用都重新执行逻辑(如点击按钮触发的计算)时,使用方法。

注意:如果计算属性的逻辑非常简单(如简单的加减乘除),缓存的性能优势不明显,此时使用计算属性或方法均可,主要看代码可读性。

进阶用法:可写计算属性

默认情况下,计算属性是只读的,只能通过修改其依赖的响应式数据来改变计算结果,不能直接修改计算属性的值。但在某些特殊场景中,我们需要“反向修改”计算属性,进而修改其依赖的源数据,这时就可以使用“可写计算属性”——通过同时提供getter和setter函数来实现。

1. 可写计算属性的定义

可写计算属性的语法的是,给computed()函数传递一个对象,该对象包含get( getter函数,用于计算派生值)和set(setter函数,用于反向修改源数据)两个方法:

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

// 源响应式数据: firstName 和 lastName
const firstName = ref('张');
const lastName = ref('三');

// 可写计算属性:fullName
const fullName = computed({
  // getter:根据firstName和lastName,计算完整姓名
  get() {
    return firstName.value + lastName.value;
  },
  // setter:修改fullName时,反向修改firstName和lastName
  set(newValue) {
    // 假设 newValue 是 "李四",拆分出 firstName 和 lastName
    const [newFirst, newLast] = newValue.split('');
    firstName.value = newFirst;
    lastName.value = newLast;
  }
});
</script>

<template>
  <div>
    <p>完整姓名:{{ fullName }}</p>
    <button @click="fullName = '李四'">修改为李四</button>
    <p> firstName:{{ firstName }} | lastName:{{ lastName }}</p>
  </div>
</template>

运行这段代码,点击按钮后,fullName被修改为“李四”,setter函数会被触发,将“李四”拆分为firstName(李)和lastName(四),进而修改源数据。此时,getter函数会重新计算,fullName的值也会同步更新。

2. 实战场景:表单双向绑定

可写计算属性常用于表单双向绑定场景,比如将一个输入框的值,同步到多个响应式数据中。例如,一个地址输入框,输入“省-市-区”格式的地址,自动拆分到province、city、district三个变量中:

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

// 源响应式数据:省、市、区
const province = ref('');
const city = ref('');
const district = ref('');

// 可写计算属性:完整地址
const fullAddress = computed({
  get() {
    // 拼接完整地址
    return [province.value, city.value, district.value].filter(Boolean).join('-');
  },
  set(newValue) {
    // 拆分地址,反向修改源数据
    const [prov, cit, dist] = newValue.split('-');
    province.value = prov || '';
    city.value = cit || '';
    district.value = dist || '';
  }
});
</script>

<template>
  <div class="address-form">
    <input 
      type="text" 
      v-model="fullAddress" 
      placeholder="请输入地址(格式:省-市-区)"
    />
    <div class="address-detail">
      <p>省:{{ province }}</p>
      <p>市:{{ city }}</p>
      <p>区:{{ district }}</p>
    </div>
  </template>

<style>
.address-form {
  margin: 20px;
  padding: 15px;
  border: 1px solid #eee;
  border-radius: 4px;
}
.address-detail {
  margin-top: 10px;
  padding: 10px;
  background: #f8f9fa;
}
input {
  padding: 6px;
  width: 300px;
}
</style>

这个示例中,输入“北京市-海淀区-中关村”后,fullAddress的setter函数会拆分地址,将省、市、区分别赋值给province、city、district;反之,修改province、city、district中的任何一个,fullAddress也会自动拼接更新,实现了双向同步。

高级特性:获取计算属性的上一个值(Vue3.4+)

Vue3.4及以上版本,支持在计算属性的getter函数中,通过第一个参数获取上一次计算的结果(previous),这在需要“保留上一个有效状态”的场景中非常实用。比如,我们需要一个计算属性,当count的值小于等于5时,返回count的值;当count大于5时,保留上一次小于等于5的值:

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

const count = ref(3);

// 计算属性:保留上一个有效值
const validCount = computed((previous) => {
  // previous 是上一次计算的结果(初始值为undefined)
  if (count.value <= 5) {
    return count.value; // 小于等于5,返回当前值
  }
  return previous; // 大于5,返回上一次的值
});
</script>

<template>
  <div>
    <p>当前count:{{ count }}</p>
    <p>有效count:{{ validCount }}</p>
    <button @click="count++">count+1</button>
  </div>
</template>

运行这段代码:

  • 初始时count=3,validCount=3;
  • 点击按钮,count依次变为4、5,validCount同步变为4、5;
  • count变为6时,validCount仍为5(上一次的值);
  • 继续点击,count变为7、8,validCount始终保持5。

这个特性也适用于可写计算属性,只需在getter函数中添加previous参数即可。

避坑指南:计算属性的常见错误与最佳实践

虽然计算属性用法简单,但在实际开发中,很多初学者会因为忽略其特性而踩坑。下面总结几个常见错误和最佳实践,帮你避开陷阱。

1. 错误1:getter函数有副作用

计算属性的getter函数应该只做“计算”,不应该有任何副作用——比如修改其他响应式数据、发送异步请求、操作DOM等。getter函数的职责是“根据依赖派生值”,副作用会导致代码逻辑混乱,且可能引发不可预期的问题。

<!-- 错误示例:getter函数有副作用(修改其他数据) -->
<script setup>
import { ref, computed } from 'vue';

const num = ref(10);
const doubleNum = ref(0);

// 错误:getter中修改doubleNum,产生副作用
const sum = computed(() => {
  doubleNum.value = num.value * 2; // 副作用:修改了其他响应式数据
  return num.value + doubleNum.value;
});
</script>

正确做法:将副作用逻辑抽离到方法中,或使用watch侦听器(后续会讲解),getter函数仅负责计算。

2. 错误2:直接修改计算属性的值

只读计算属性不能直接修改,若尝试修改,会触发运行时警告,且修改无效。即使是可写计算属性,也应该通过修改计算属性的值,间接修改其依赖的源数据,而不是直接修改依赖。

<!-- 错误示例:直接修改只读计算属性 -->
<script setup>
import { ref, computed } from 'vue';

const num1 = ref(10);
const num2 = ref(20);
const sum = computed(() => num1.value + num2.value);

// 错误:直接修改计算属性的值
function changeSum() {
  sum.value = 50; // 触发警告,修改无效
}
</script>

正确做法:修改计算属性依赖的源数据(num1、num2),让计算属性自动重新计算。

3. 错误3:依赖非响应式数据

计算属性只能追踪响应式数据(ref、reactive包裹的数据)的变化,如果依赖非响应式数据(如普通变量),计算属性不会自动重新计算。

<!-- 错误示例:依赖非响应式数据 -->
<script setup>
import { computed } from 'vue';

// 非响应式变量
let num = 10;

// 计算属性依赖非响应式数据,num变化时,计算属性不会更新
const doubleNum = computed(() => num * 2);

// 错误:修改非响应式数据,计算属性不会重新计算
function changeNum() {
  num = 20;
  console.log(doubleNum.value); // 输出20,而非40
}
</script>

正确做法:将非响应式数据改为响应式数据(用ref或reactive包裹)。

4. 最佳实践总结

  1. 单一职责:每个计算属性只负责一个逻辑,避免一个计算属性包含复杂的多步逻辑,提升可读性和可维护性;
  2. 无副作用:getter函数仅做计算,不修改其他数据、不发送请求、不操作DOM;
  3. 优先缓存:对于耗时逻辑、多次访问的派生值,优先使用计算属性,利用缓存提升性能;
  4. 慎用可写:可写计算属性仅在需要反向修改源数据的场景使用,大部分场景使用只读计算属性即可;
  5. 依赖响应式:确保计算属性的依赖都是响应式数据,否则无法触发自动更新。

延伸:模板引用与生命周期钩子——手动操作DOM的正确姿势

前面我们讲解的计算属性,核心是处理“响应式数据的派生”,全程由Vue自动管理DOM更新,无需我们手动干预。但在实际开发中,难免会遇到需要手动操作DOM的场景——比如获取DOM元素的尺寸、手动触发DOM事件、集成第三方DOM库(如图表、富文本)等。这时,Vue提供的模板引用生命周期钩子,就是我们手动操作DOM的“最佳搭档”。

一、模板引用:获取模板中的DOM元素

模板引用(Template Ref)的核心作用,是让我们在Vue组件中,通过ref属性获取模板中指定的DOM元素或子组件实例。它的使用非常简单,分为两步:

1. 模板中标记ref属性

在模板中的DOM元素上,添加ref属性,并指定一个唯一的名称(相当于给这个DOM元素起一个“ID”),用于后续访问:

<template>
  <!-- 给p标签添加ref,名称为pElementRef -->
  <p ref="pElementRef">Hello, Vue!</p>
  <!-- 给输入框添加ref,名称为inputRef -->
  <input ref="inputRef" type="text" placeholder="请输入内容" />
</template>
2. 脚本中声明同名ref

在的ref变量,并初始化为null。这个变量将用于存储对应的DOM元素:

<script setup>
import { ref } from 'vue';

// 声明与模板ref同名的ref变量,初始值为null
const pElementRef = ref(null);
const inputRef = ref(null);
</script>

⚠️ 注意:脚本执行时,DOM元素还未挂载到页面上,所以初始值必须设为null。只有当组件挂载完成后,这个ref变量的.value才会指向对应的DOM元素,此时才能进行DOM操作。

二、生命周期钩子:在合适的时机操作DOM

Vue组件从创建到销毁,会经历一系列固定的阶段,比如“初始化数据”“编译模板”“挂载DOM”“更新DOM”“销毁组件”等。这些阶段被称为组件生命周期,而生命周期钩子,就是允许我们在组件特定生命周期阶段执行自定义代码的函数。

对于手动操作DOM来说,最常用的生命周期钩子是onMounted——它会在组件完成初始渲染、DOM元素挂载到页面后触发,此时模板引用已经可以访问,是执行DOM操作的最佳时机。

1. 基本使用:onMounted钩子

首先导入onMounted钩子,然后注册一个回调函数,在回调函数中访问模板引用,执行DOM操作:

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

// 声明模板引用
const pElementRef = ref(null);
const inputRef = ref(null);

// 注册onMounted钩子,组件挂载后执行
onMounted(() => {
  // 此时DOM已挂载,pElementRef.value指向对应的p标签
  console.log('p标签DOM元素:', pElementRef.value);
  // 执行DOM操作:修改p标签的文本内容和样式
  pElementRef.value.textContent = '组件挂载完成,已修改文本';
  pElementRef.value.style.color = '#e53e3e';
  pElementRef.value.style.fontSize = '18px';

  // 操作input标签:自动聚焦
  inputRef.value.focus();
});
</script>

<template>
  <p ref="pElementRef">Hello, Vue!</p>
  <input ref="inputRef" type="text" placeholder="请输入内容" />
</template>

image.png

运行这段代码,组件挂载后会自动执行onMounted中的回调:p标签的文本和样式被修改,输入框自动聚焦,完美实现了手动DOM操作。

2. 其他常用生命周期钩子

除了onMounted,还有两个常用的生命周期钩子,可根据场景灵活使用:

  • onUpdated:组件更新(比如响应式数据变化导致DOM重新渲染)后触发。适合在DOM更新完成后,再次调整DOM(如重新计算DOM尺寸)。
  • onUnmounted:组件销毁前触发。适合清理手动创建的DOM资源(如移除事件监听、销毁第三方DOM库实例),避免内存泄漏。

示例:使用onUpdated和onUnmounted

<script setup>
import { ref, onMounted, onUpdated, onUnmounted } from 'vue';

const count = ref(0);
const pElementRef = ref(null);
let resizeListener = null;

onMounted(() => {
  // 挂载后添加窗口大小变化监听
  resizeListener = () => {
    console.log('窗口尺寸变化:', window.innerWidth);
  };
  window.addEventListener('resize', resizeListener);
});

onUpdated(() => {
  // 组件更新(count变化导致DOM更新)后,修改p标签颜色
  if (pElementRef.value) {
    pElementRef.value.style.color = count.value % 2 === 0 ? '#e53e3e' : '#48bb78';
  }
});

onUnmounted(() => {
  // 组件销毁前,移除事件监听,清理资源
  window.removeEventListener('resize', resizeListener);
});
</script>

<template>
  <p ref="pElementRef">count: {{ count }}</p>
  <button @click="count++">count+1</button>
</template>

image.png

三、模板引用的进阶用法

1. 引用子组件

模板引用不仅可以获取普通DOM元素,还可以获取子组件实例,从而调用子组件的方法或访问子组件的响应式数据。

示例:父组件引用子组件

<!-- 子组件 Child.vue -->
<script setup>
import { ref } from 'vue';

const childCount = ref(0);
// 子组件的方法
const incrementChild = () => {
  childCount.value++;
};
// 暴露子组件的方法和数据,供父组件访问
defineExpose({
  childCount,
  incrementChild
});
</script>

<template>
  <p>子组件count: {{ childCount }}</p>
</template>

<!-- 父组件 Parent.vue -->
<script setup>
import { ref, onMounted } from 'vue';
import Child from './Child.vue';

// 声明引用子组件的ref
const childRef = ref(null);

onMounted(() => {
  // 访问子组件的响应式数据
  console.log('子组件初始count:', childRef.value.childCount);
  // 调用子组件的方法
  childRef.value.incrementChild();
  console.log('调用子组件方法后count:', childRef.value.childCount);
});
</script>

<template>
  <Child ref="childRef" />
</template>

⚠️ 注意:子组件中必须通过defineExpose,将需要被父组件访问的方法和数据暴露出来,否则父组件无法通过模板引用访问。

2. 动态模板引用(v-for中使用)

当在v-for中使用模板引用时,需要将ref绑定为一个函数,用于收集所有遍历产生的DOM元素(或子组件):

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

// 用数组存储所有li标签的DOM元素
const listRefs = ref([]);
const list = ref(['Vue', 'React', 'Angular']);

// 绑定ref函数,收集DOM元素
const setListRef = (el) => {
  if (el) {
    listRefs.value.push(el);
  }
};

onMounted(() => {
  // 访问所有li标签的DOM元素
  console.log('所有li元素:', listRefs.value);
  // 给第一个li标签修改样式
  listRefs.value[0].style.color = '#e53e3e';
});
</script>

<template>
  <ul>
    <li v-for="item in list" :key="item" :ref="setListRef">
      {{ item }}
    </li>
  </ul>
</template>

四、模板引用与生命周期的注意事项

  • 模板引用只能在组件挂载后访问(onMounted及之后的钩子),在挂载前(如setup直接执行时)访问,其.value始终为null;
  • 生命周期钩子必须同步注册(不能在setTimeout、Promise.then等异步操作中注册),否则Vue无法关联到当前组件实例,钩子不会生效;
  • 模板引用不是响应式的,不能在模板中用它进行数据绑定(如{{ pElementRef.value.textContent }}),仅用于脚本中手动操作DOM;
  • 组件销毁时,务必清理手动创建的DOM资源(如事件监听、第三方实例),避免内存泄漏,这一步通常在onUnmounted中完成。

总结:Vue响应式与DOM操作的完整体系

本文我们从计算属性入手,讲解了其核心用法、缓存特性、进阶技巧与避坑指南,又延伸到模板引用与生命周期钩子,形成了Vue开发中“响应式数据处理+手动DOM操作”的完整体系:

  • 计算属性:负责响应式数据的派生,分离逻辑与展示,利用缓存提升性能,适用于无需手动操作DOM的场景;
  • 模板引用:负责获取模板中的DOM元素或子组件实例,是手动操作DOM的入口;
  • 生命周期钩子:负责在组件合适的阶段(如挂载后、更新后、销毁前)执行代码,确保DOM操作的时机正确,避免报错和内存泄漏。

常见应用场景补充:

  • 列表过滤、排序:结合v-for,实现响应式过滤排序,无需手动触发;
  • 多数据派生:根据多个响应式数据,生成新的状态(如拼接、运算、判断);
  • 表单双向绑定:通过可写计算属性,实现输入值与多个源数据的双向同步;
  • 复杂逻辑抽离:将模板中的复杂表达式,抽离为计算属性,简化模板;
  • 手动DOM操作:通过模板引用获取DOM元素,结合onMounted等钩子,实现聚焦、修改样式、集成第三方库等需求。

掌握这些知识点,能让你在Vue开发中灵活处理响应式数据与DOM操作,避开冗余代码和性能问题,尤其是在中大型项目中,合理运用计算属性、模板引用和生命周期钩子,能显著提升代码的可维护性和页面性能。后续我们还会讲解与计算属性、生命周期互补的“侦听器(watch)”,解锁更灵活的响应式开发技巧。