主题
生命周期组合式API与计算属性-computed(Lesson24 1/3)
本页对应 Lesson24,共 6 段视频,约 1 小时 54 分。
学习目标
- 能通过代码示例复现并解释:直接给
reactive创建的引用类型变量重新赋值后,视图失去响应式更新的原因。 - 能说明
ref在模板与脚本中使用方式的差异,并验证ref的.value为对象时其内部数据被代理为Proxy。 - 能使用
Proxy的get、set手写一个简化版reactive,并实现修改数据后手动更新视图。 - 能使用
Object.defineProperty为简化版ref实现value的读取与赋值拦截,并与reactive结合处理对象类型数据。 - 能独立完成待办任务案例,包括添加、去重、勾选状态切换、按 ID 更新、删除、全选/反选、清除已完成及已完成/全部数量统计。
- 能定位并修复待办案例中因直接替换数组引用、循环删除导致下标变化等引发的响应式或逻辑错误。
知识点
为什么生命周期要换成组合式 API
选项式 API 里,生命周期写成 beforeMount、mounted、beforeUpdate、updated 等选项;组合式 API 里,统一改成从 vue 引入 onBeforeMount、onMounted 这类钩子,并在 setup 中注册。这样做的原因是:组合式 API 把“状态、逻辑、副作用”放在一起,生命周期也要跟着逻辑走,而不是散落在组件选项里。
怎么想:先明确“我要在哪个时机做什么”。例如挂载前拿不到 DOM,挂载后才能拿 DOM;更新前数据已变、DOM 还没变;更新后 DOM 才变;卸载前还能访问实例,卸载后不要再依赖 DOM。
怎么做:在 <script setup> 中直接调用 onXxx(() => {})。注意注册要发生在 setup 同步执行阶段,不要塞进异步回调里。
易错点:组合式 API 没有 onBeforeCreate/onCreated 这种同名钩子,初始化逻辑直接写在 setup 顶层即可。笔记里 console.log("立即调用。可以替换之前的beforeCreate以及created"); 就是这个意思。
App.vue:挂载与更新的对照实验
为什么需要:只看文档很难记住“数据变了 DOM 是否已变”。用 count 和 <h3> 做对照,能直观看到钩子时机。
怎么想:在 onBeforeMount 打印 count.value 和 document.querySelector("h3");在 onMounted 再打印一次;点击按钮改 count,在 onBeforeUpdate/onUpdated 对比 count.value 与 h3.innerText。
怎么做:保留下面核心代码。笔记注释给出的调试结果是:onBeforeMount 时 h3 为 null;onMounted 能拿到 生命周期组合API--->100;更新前 count 已是 101,DOM 仍是 100;更新后 DOM 变成 101。转写稿里反复强调用 console.log 对照数据与视图,这里正是同一套调试思路:不要猜,打印出来看。
vue
<!-- src/App.vue -->
<template>
<h3>生命周期组合API--->{{count}}</h3>
<button @click="count++">更改count</button>
<hr/>
<button @click="isRender=!isRender">销毁子组件</button>
<Child v-if="isRender"></Child>
</template>
<script lang="ts" setup>
import {onBeforeMount,onUpdated,onBeforeUpdate,onMounted,ref} from "vue";
import Child from "./components/Child.vue";
const count = ref(100);
const isRender = ref(true);
console.log("立即调用。可以替换之前的beforeCreate以及created");
onBeforeMount(function () {
console.log("onBeforeMount->count", count.value); // 100
console.log("onBeforeMount->h3", document.querySelector("h3")); // null
});
onMounted(function () {
console.log("onMounted->count", count.value); // 100
console.log("onMounted->h3", (document.querySelector("h3") as HTMLHeadingElement).innerText);
});
onBeforeUpdate(function(){
console.log("onBeforeUpdate->count", count.value); // 101
console.log("onBeforeUpdate->h3", (document.querySelector("h3") as HTMLHeadingElement).innerText); // 100
});
onUpdated(function(){
console.log("onUpdated->count", count.value); // 101
console.log("onUpdated->h3", (document.querySelector("h3") as HTMLHeadingElement).innerText); // 101
});
</script>易错点:onUpdated 里不要再随意改响应式状态,否则可能再次触发更新;更新钩子不是“只触发一次”,任何引起组件更新的响应式变化都可能进入。
Child.vue:卸载阶段能做什么、不能做什么
为什么需要:子组件用 v-if 控制销毁时,定时器、事件监听、订阅都要在卸载阶段清理。否则组件没了,逻辑还在跑。
怎么想:onBeforeUnmount 时实例还保有功能,适合做清理前确认;onUnmounted 表示实例已卸载,此时不要再依赖子组件 DOM。笔记注释里 onUnmounted 仍写 document.querySelector("h4") 得到 Child,这里要特别小心:卸载后 DOM 已移除,实际调试应以控制台为准,不要把它当成可继续操作 DOM 的时机。
怎么做:在 Child 中注册卸载钩子,配合父组件的“销毁子组件”按钮观察。
vue
<!-- src/components/Child.vue -->
<template>
<div>
<h4>Child</h4>
</div>
</template>
<script setup lang="ts">
import {onBeforeUnmount, onUnmounted} from "vue";
let userName = "zhangpeiyue";
onBeforeUnmount(function(){
console.log("onBeforeUnmount->userName", userName); // zhangpeiyue
console.log("onBeforeUnmount->h4", document.querySelector("h4"));
});
onUnmounted(function(){
console.log("onUnmounted->userName", userName); // zhangpeiyue
console.log("onUnmounted->h4", document.querySelector("h4"));
});
</script>易错点:清理逻辑优先放 onBeforeUnmount/onUnmounted;不要在 onUnmounted 里假设 DOM 还在。组合式生命周期钩子要成对理解:挂载前/后、更新前/后、卸载前/后,核心就是“数据状态”和“DOM 状态”的先后差异。
先看问题:为什么需要计算属性
模板里要显示“今年zhangsan,1岁了”,最直接的办法是拼接:{{"今年"+userName+","+age+"岁了"}}。这样能显示,但表达式一长,模板可读性就差;如果多处使用,还要重复写。用函数 getInfo() 也能算,但笔记里特意注释了:函数依赖的是 userName 和 age,可只要组件重新渲染,比如点了 count++,模板调用了 getInfo(),它就会再执行一次,哪怕结果没变。计算属性 computed 解决的就是“由已有响应式数据派生新值,并且依赖不变就复用上次结果”的问题。转写稿里做 Todo 作业时也提到,“已完成多少个、全部多少个”最好用计算属性,正是这个思路:不要手动维护派生值,让它根据源数据算出来。
只读计算属性:缓存从哪来
为什么需要:像 getInfoCom 这种只读派生值,只关心 userName、age,不希望被无关的 count 变化带着重复计算。
怎么想:先把 computed 从 Vue 引入;给它一个回调函数;把返回结果赋给常量;模板里直接渲染这个常量。计算属性初次渲染会执行,之后只有依赖变化才重新执行。
怎么做:笔记中写作:
ts
import { ref, computed } from "vue";
const userName = ref("zhangsan");
const age = ref(1);
const getInfoCom = computed(function () {
console.log("getInfoCom");
return "今年" + userName.value + "," + age.value + "岁了";
});模板中直接写 {{ getInfoCom }},不要写成 {{ getInfoCom() }}。
易错点:计算属性在模板里是自动解包的,不要加括号;在脚本里读取它仍是 ref 风格,要写 getInfoCom.value;只读计算属性不能直接赋值;computed 忘记 import 会报错。
调试:用 console.log 看缓存
笔记里在 getInfo 和 getInfoCom 中都放了 console.log,这就是最直接的调试手段。按本节代码观察:初次渲染时,getInfoCom 会执行并打印;模板里连续写了四个 {{ getInfoCom }},因为缓存,后续复用结果,不会因为写了四次就打印四次。点击 count++ 时,组件会重新渲染,函数方案 getInfo() 会再次打印;但 getInfoCom 依赖的是 userName 和 age,不依赖 count,所以不应重新打印。点击“更改age”或“更改userName”后,依赖变了,getInfoCom 才重新执行。转写稿里反复强调“响应式”(不是“小应史”)和“引用地址”(不是“盈地”),这里也是同一个判断:依赖有没有真正变化。调试时不要只看页面结果,结合 console.log 看谁执行、谁没执行,缓存现象就很清楚。
读写计算属性:get 与 set 的桥梁
为什么需要:有时不仅要“读派生值”,还要“改派生值”,再反推源数据。比如 countCom 显示 count * 2,外部给 countCom 赋 300,希望 count 变成 150。
怎么想:只读计算属性接收函数;读写计算属性接收对象,里面写 get 和 set。get 的返回值就是计算属性的值;set 接收新值,在里面对源数据赋值。转写稿里口述的“盖/赛”,对应的就是 get/set。
怎么做:笔记中:
ts
const countCom = computed({
get() {
return count.value * 2;
},
set(v: number) {
count.value = v / 2;
}
});模板中 {{ countCom }} 显示派生值;点击 countCom = 300 后,set 收到 300,把 count.value 改成 150,于是 countCom 重新计算为 300。
易错点:set 里必须写回源数据,不能只改形参 v;set 参数类型要写清楚;不要在 get 里修改依赖;只读计算属性直接赋值不会生效;模板里可直接 countCom = 300,脚本里则要写 countCom.value = 300。
完整示例代码
vue
<template>
<h3>计算属性-只读</h3>
<p @click="count++">count--->{{count}}</p>
<p>userName--->{{userName}}</p>
<p>age--->{{age}}</p>
<p>方案一:函数-->{{getInfo()}}</p>
<p>方案二:拼接-->{{"今年"+userName+","+age+"岁了"}}</p>
<p>方案三:计算属性-->{{getInfoCom}}</p>
<p>方案三:计算属性-->{{getInfoCom}}</p>
<p>方案三:计算属性-->{{getInfoCom}}</p>
<p>方案三:计算属性-->{{getInfoCom}}</p>
<button @click="age++">点我更改age</button>
<button @click="userName+='!'">点我更改userName</button>
<hr/>
<h3>计算属性-读写</h3>
<p @click="count+=2">count-->{{count}}</p>
<p>countCom->{{countCom}}</p>
<button @click="countCom=300">修改计算属性的值</button>
</template>
<script setup lang="ts">
import {ref,computed} from "vue";
const count = ref(100);
const age = ref(1);
const userName = ref("zhangsan");
// getInfo函数的返回结果,只受userName,以及age的影响,
// 但是更新了数据count,由于模板中调用了该函数,所以该函数依然会被执行,哪怕结果没有任何变化。
const getInfo = function(){
console.log("getInfo");
return "今年"+userName.value+","+age.value+"岁了"
}
// 计算属性:
// 1- 引入组合API->computed
// 2- computed接收一个回调函数
// 3- 将computed函数的返回结果赋值给常量,变量
// 4- 模板中可以直接渲染计算属性。
// 5- 计算属性拥有缓存功能,只有所依赖的数据不发生改变,计算属性对应的函数不会再次执行。
// 6- 模板中如果使用了计算属性,那么在初次渲染时,会执行。
// 创建一个只读的计算属性(接收函数)
const getInfoCom = computed(function(){
console.log("getInfoCom")
return "今年"+userName.value+","+age.value+"岁了"
})
// 创建一个读写的计算属性(对象)
const countCom = computed({
// 返回值即是计算属性的值
get(){
return count.value*2;
},
// 当设置计算属性值时调用。接收的参数是修改的值
set(v:number){
count.value = v/2;
}
})
</script>课堂画面






代码示例
vue
<!-- src/App.vue -->
<template>
<h3>生命周期组合API--->{{count}}</h3>
<button @click="count++">更改count</button>
<hr/>
<button @click="isRender=!isRender">销毁子组件</button>
<Child v-if="isRender"></Child>
</template>
<script lang="ts" setup>
import {onBeforeMount, onUpdated, onBeforeUpdate, onMounted, ref} from "vue";
// 直接将组件引用即可使用
import Child from "./components/Child.vue";
const count = ref(100);
const isRender = ref(true);
console.log("立即调用。可以替换之前的beforeCreate以及created");
// 当这个钩子被调用时,组件已经完成了其响应式状态的设置,但还没有创建 DOM 节点。它即将首次执行 DOM 渲染过程
onBeforeMount(function () {
console.group("********onBeforeMount:注册一个钩子,在组件被挂载之前被调用。**************")
console.log("onBeforeMount->count", count.value); // onBeforeMount->count 100
console.log("onBeforeMount->h3", document.querySelector("h3")); // onBeforeMount->h3 null
console.groupEnd();
});
onMounted(function () {
console.group("********onMounted:注册一个回调函数,在组件挂载完成后执行。**************")
console.log("onMounted->count", count.value); // onMounted->count 100
console.log("onMounted->h3", (document.querySelector("h3") as HTMLHeadingElement).innerText);
// onMounted->h3 生命周期组合API--->100
console.groupEnd();
});
onBeforeUpdate(function () {
console.group("********onBeforeUpdate:注册一个钩子,在组件即将因为响应式状态变更而更新其 DOM 树之前调用。**************")
console.log("onBeforeUpdate->count", count.value); // onBeforeUpdate->count 101
console.log("onBeforeUpdate->h3", (document.querySelector("h3") as HTMLHeadingElement).innerText);
// onBeforeUpdate->h3 生命周期组合API--->100 (DOM 还是旧值)
console.groupEnd();
})
onUpdated(function () {
console.group("********onUpdated:注册一个回调函数,在组件因为响应式状态变更而更新其 DOM 树之后调用。**************")
console.log("onUpdated->count", count.value); // onUpdated->count 101
console.log("onUpdated->h3", (document.querySelector("h3") as HTMLHeadingElement).innerText);
// onUpdated->h3 生命周期组合API--->101
console.groupEnd();
})
</script>要点解释
script setup里顶层代码本身就相当于beforeCreate + created,所以console.log("立即调用...")写在 setup 顶部即可,不需要再注册钩子。- 钩子必须从 vue 中 import(
onMounted等),且只能在setup顶层同步调用,写在异步回调或普通函数里不会生效。 - 易错点:
onBeforeMount阶段模板还没渲染成真实 DOM,document.querySelector("h3")拿到的是null。 - 易错点:
onBeforeUpdate里count.value已是新值 101,但 DOM 里仍是旧值 100;只有onUpdated里两者才一致。这正是"数据更新 → DOM 更新"的时机差。 - TS 下要取
innerText需要断言as HTMLHeadingElement,否则querySelector返回Element | null会报类型错误。
vue
<!-- src/components/Child.vue -->
<template>
<div>
<h4>Child</h4>
</div>
</template>
<script setup lang="ts">
import {onBeforeUnmount, onBeforeUpdate, onUnmounted} from "vue";
let userName = "zhangpeiyue";
onBeforeUnmount(function () {
// 当这个钩子被调用时,组件实例依然还保有全部的功能。
console.group("********onBeforeUnmount:注册一个钩子,在组件实例被卸载之前调用。**************")
console.log("onBeforeUnmount->userName", userName); // zhangpeiyue
console.log("onBeforeUnmount->h4", document.querySelector("h4")); // 仍能取到 <h4>Child</h4>
console.groupEnd();
})
onUnmounted(function () {
console.group("********onUnmounted:注册一个回调函数,在组件实例被卸载之后调用。**************")
console.log("onUnmounted->userName", userName); // zhangpeiyue(普通变量仍可读)
console.log("onUnmounted->h4", document.querySelector("h4")); // 节点已被移除,通常为 null
console.groupEnd();
})
</script>
<style scoped>
</style>要点解释
- 卸载类钩子写在子组件里,触发时机由父组件
v-if="isRender"的切换控制,v-show不会触发卸载。 - 易错点:
onBeforeUnmount里组件实例与 DOM 都还在,适合做定时器清理、事件解绑;onUnmounted里 DOM 已被移除,此时再querySelector一般拿不到节点。 - 易错点:
let userName = "zhangpeiyue"是普通变量,不是ref,所以两个钩子里都能读到值,但改变它不会触发视图更新。 - 在
script setup中组件实例是隐式的,没有this,清理工作必须靠闭包变量保存定时器/监听器引用。
vue
<!-- src/App.vue 计算属性 computed -->
<template>
<h3>计算属性-只读</h3>
<p @click="count++">count--->{{count}}</p>
<p>userName--->{{userName}}</p>
<p>age--->{{age}}</p>
<p>方案一:函数-->{{getInfo()}}</p>
<p>方案二:拼接-->{{"今年"+userName+","+age+"岁了"}}</p>
<p>方案三:计算属性-->{{getInfoCom}}</p>
<p>方案三:计算属性-->{{getInfoCom}}</p>
<p>方案三:计算属性-->{{getInfoCom}}</p>
<p>方案三:计算属性-->{{getInfoCom}}</p>
<button @click="age++">点我更改age</button>
<button @click="userName+='!'">点我更改userName</button>
<hr/>
<h3>计算属性-读写</h3>
<p @click="count+=2">count-->{{count}}</p>
<p>countCom->{{countCom}}</p>
<button @click="countCom=300">修改计算属性的值</button>
</template>
<script setup lang="ts">
import {ref, computed} from "vue";
const count = ref(100);
const age = ref(1);
const userName = ref("zhangsan");
// getInfo 函数的返回结果只受 userName、age 影响,
// 但更新了 count 之后,因为模板中调用了该函数,函数依然会被执行,哪怕结果没有任何变化。
const getInfo = function () {
console.log("getInfo");
return "今年" + userName.value + "," + age.value + "岁了"
}
// 计算属性:
// 1- 引入组合API -> computed
// 2- computed 接收一个回调函数
// 3- 将 computed 的返回结果赋值给常量/变量
// 4- 模板中可以直接渲染计算属性
// 5- 计算属性拥有缓存功能,只有所依赖的数据不发生改变,计算属性对应的函数不会再次执行
// 6- 模板中如果使用了计算属性,那么在初次渲染时,会执行
// 创建一个只读的计算属性(接收函数)
const getInfoCom = computed(function () {
console.log("getInfoCom")
return "今年" + userName.value + "," + age.value + "岁了"
})
// 创建一个读写的计算属性(接收对象)
const countCom = computed({
// 返回值即是计算属性的值
get() {
return count.value * 2;
},
// 当设置计算属性值时调用。接收的参数是修改的值
set(v: number) {
count.value = v / 2;
}
})
</script>要点解释
getInfo()是普通函数:模板里每次重新渲染都会执行一次,即使count与结果无关,控制台会不断打印getInfo;而getInfoCom因为缓存,count变化时不会重复执行。- 计算属性首次渲染一定执行一次(所以
getInfoCom至少打印一次),之后只有userName或age变化才会重新计算。 - 易错点:在
script setup中访问ref必须写.value(userName.value),而模板里会自动解包,写{{userName}}即可,两者不要混。 - 易错点:只读计算属性(传函数)不能被赋值,
getInfoCom = xxx会报错;需要写入时必须用对象形式computed({get, set})。 - 易错点:
countCom = 300触发set(300),内部执行count.value = 150,所以页面显示count-->150、countCom-->300;set里必须真正改到依赖的响应式数据,否则视图不会变。
常见错误
- 现象:在
onBeforeMount里document.querySelector("h3")得到null。原因:此时组件尚未完成 DOM 挂载。修法:把 DOM 读取放到onMounted;需要操作节点时优先用模板ref。 - 现象:写了
onBeforeCreate/onCreated报错或没有效果。原因:组合式 API 没有这两个钩子,setup顶层执行阶段已替代初始化逻辑。修法:初始化逻辑直接写在<script setup>顶层,确需挂载前逻辑用onBeforeMount。 - 现象:在
onUpdated中直接修改count,页面卡死或控制台疯狂输出。原因:更新钩子中修改响应式依赖会再次触发更新,形成循环。修法:不要在onUpdated中改依赖状态;派生状态用computed,副作用用watch或加明确条件。 - 现象:对
computed(fn)生成的只读计算属性赋值,报Write operation failed: computed value is readonly。原因:函数形式computed只提供 getter。修法:需要写入时改用computed({ get(){...}, set(v){...} })。 - 现象:模板中调用
getInfo(),点击无关的count++也会反复执行getInfo。原因:普通函数在每次渲染时都会调用,没有缓存。修法:由响应式依赖派生出的结果改用computed。 - 现象:脚本里写
console.log(getInfoCom)得到ComputedRefImpl对象,不是最终值。原因:模板会自动解包,脚本中不会。修法:脚本中访问计算属性用getInfoCom.value。 - 现象:在
computedgetter 里发请求、改状态或写console副作用,导致结果不稳定。原因:计算属性应保持纯派生,依赖变化时才重新求值。修法:getter 只返回计算结果,副作用放到watch、onMounted或事件函数中。 - 现象:生命周期钩子写在
setTimeout、异步回调或条件分支里不执行。原因:组合式生命周期钩子需要在setup同步执行阶段注册到当前组件实例。修法:把onMounted、onUnmounted等注册语句放到<script setup>顶层同步位置。
练习
- 在
App.vue同时注册onBeforeMount、onMounted、onBeforeUpdate、onUpdated,分别打印count.value和h3文本;点击count++与切换Child,记录输出顺序并标注此时 DOM 是否可读。 - 在
Child.vue注册onBeforeUnmount和onUnmounted,父组件用v-if切换子组件;在钩子中打印状态并清理一个setInterval,验证卸载前后执行差异。 - 保留笔记中的
getInfo()与getInfoCom,在两者内部加console.count;点击无关的count++5 次,比较执行次数并说明缓存差异。 - 实现读写计算属性
countCom:get返回count * 2,set(v)把count设为v / 2;点击按钮执行countCom = 300,检查count与页面结果。 - 故障修复题:在
onUpdated中直接count.value++导致页面卡死,给出两种修复方案并验证:一种加条件限制,一种改用watch/computed处理派生逻辑。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 有点慢 | 03:01 |
| 2 | ref以及reactive需要注意的地方 | 20:00 |
| 3 | reactive复习 | 07:53 |
| 4 | ref实现原理 | 29:03 |
| 5 | 问题 | 03:49 |
| 6 | 作业 | 50:16 |
小结
- 组合式生命周期钩子必须在
<script setup>同步阶段注册,异步回调中注册通常无效。 - DOM 读取放在
onMounted/onUpdated更稳妥,onBeforeMount阶段不要假设节点已存在。 onUpdated中修改响应式依赖是高危操作,容易触发循环更新,应优先用computed或watch拆分逻辑。computed适合由响应式依赖派生出的值,具备缓存;普通函数在模板中每次渲染都会执行。- 只读计算属性用
computed(fn),需要写入必须用computed({ get, set }),并在set中修改依赖源。 computedgetter 应保持纯函数,副作用、异步请求、事件监听清理不要混入 getter。- 脚本中访问
computed/ref要加.value,模板中会自动解包。 - 卸载类钩子适合做定时器、事件监听、订阅等清理,避免组件销毁后仍持有资源。