Skip to content

生命周期组合式API与计算属性-computed(Lesson24 1/3) ​

本页对应 Lesson24,共 6 段视频,约 1 小时 54 分。

学习目标 ​

  1. 能通过代码示例复现并解释:直接给 reactive 创建的引用类型变量重新赋值后,视图失去响应式更新的原因。
  2. 能说明 ref 在模板与脚本中使用方式的差异,并验证 ref 的 .value 为对象时其内部数据被代理为 Proxy。
  3. 能使用 Proxy 的 get、set 手写一个简化版 reactive,并实现修改数据后手动更新视图。
  4. 能使用 Object.defineProperty 为简化版 ref 实现 value 的读取与赋值拦截,并与 reactive 结合处理对象类型数据。
  5. 能独立完成待办任务案例,包括添加、去重、勾选状态切换、按 ID 更新、删除、全选/反选、清除已完成及已完成/全部数量统计。
  6. 能定位并修复待办案例中因直接替换数组引用、循环删除导致下标变化等引发的响应式或逻辑错误。

知识点 ​

为什么生命周期要换成组合式 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>

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

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 里必须真正改到依赖的响应式数据,否则视图不会变。

常见错误 ​

  1. 现象:在 onBeforeMount 里 document.querySelector("h3") 得到 null。原因:此时组件尚未完成 DOM 挂载。修法:把 DOM 读取放到 onMounted;需要操作节点时优先用模板 ref。
  2. 现象:写了 onBeforeCreate / onCreated 报错或没有效果。原因:组合式 API 没有这两个钩子,setup 顶层执行阶段已替代初始化逻辑。修法:初始化逻辑直接写在 <script setup> 顶层,确需挂载前逻辑用 onBeforeMount。
  3. 现象:在 onUpdated 中直接修改 count,页面卡死或控制台疯狂输出。原因:更新钩子中修改响应式依赖会再次触发更新,形成循环。修法:不要在 onUpdated 中改依赖状态;派生状态用 computed,副作用用 watch 或加明确条件。
  4. 现象:对 computed(fn) 生成的只读计算属性赋值,报 Write operation failed: computed value is readonly。原因:函数形式 computed 只提供 getter。修法:需要写入时改用 computed({ get(){...}, set(v){...} })。
  5. 现象:模板中调用 getInfo(),点击无关的 count++ 也会反复执行 getInfo。原因:普通函数在每次渲染时都会调用,没有缓存。修法:由响应式依赖派生出的结果改用 computed。
  6. 现象:脚本里写 console.log(getInfoCom) 得到 ComputedRefImpl 对象,不是最终值。原因:模板会自动解包,脚本中不会。修法:脚本中访问计算属性用 getInfoCom.value。
  7. 现象:在 computed getter 里发请求、改状态或写 console 副作用,导致结果不稳定。原因:计算属性应保持纯派生,依赖变化时才重新求值。修法:getter 只返回计算结果,副作用放到 watch、onMounted 或事件函数中。
  8. 现象:生命周期钩子写在 setTimeout、异步回调或条件分支里不执行。原因:组合式生命周期钩子需要在 setup 同步执行阶段注册到当前组件实例。修法:把 onMounted、onUnmounted 等注册语句放到 <script setup> 顶层同步位置。

练习 ​

  1. 在 App.vue 同时注册 onBeforeMount、onMounted、onBeforeUpdate、onUpdated,分别打印 count.value 和 h3 文本;点击 count++ 与切换 Child,记录输出顺序并标注此时 DOM 是否可读。
  2. 在 Child.vue 注册 onBeforeUnmount 和 onUnmounted,父组件用 v-if 切换子组件;在钩子中打印状态并清理一个 setInterval,验证卸载前后执行差异。
  3. 保留笔记中的 getInfo() 与 getInfoCom,在两者内部加 console.count;点击无关的 count++ 5 次,比较执行次数并说明缓存差异。
  4. 实现读写计算属性 countCom:get 返回 count * 2,set(v) 把 count 设为 v / 2;点击按钮执行 countCom = 300,检查 count 与页面结果。
  5. 故障修复题:在 onUpdated 中直接 count.value++ 导致页面卡死,给出两种修复方案并验证:一种加条件限制,一种改用 watch/computed 处理派生逻辑。

原视频对照 ​

#视频时长
1有点慢03:01
2ref以及reactive需要注意的地方20:00
3reactive复习07:53
4ref实现原理29:03
5问题03:49
6作业50:16

小结 ​

  1. 组合式生命周期钩子必须在 <script setup> 同步阶段注册,异步回调中注册通常无效。
  2. DOM 读取放在 onMounted / onUpdated 更稳妥,onBeforeMount 阶段不要假设节点已存在。
  3. onUpdated 中修改响应式依赖是高危操作,容易触发循环更新,应优先用 computed 或 watch 拆分逻辑。
  4. computed 适合由响应式依赖派生出的值,具备缓存;普通函数在模板中每次渲染都会执行。
  5. 只读计算属性用 computed(fn),需要写入必须用 computed({ get, set }),并在 set 中修改依赖源。
  6. computed getter 应保持纯函数,副作用、异步请求、事件监听清理不要混入 getter。
  7. 脚本中访问 computed / ref 要加 .value,模板中会自动解包。
  8. 卸载类钩子适合做定时器、事件监听、订阅等清理,避免组件销毁后仍持有资源。