Vue3.0 项目实战总结(一)

1,603 阅读11分钟

年初团队开始引入vue3版本,为后期ts的全栈使用,从语言层面(Typescript)作准备,但是由于团队小伙伴新人较多,对ts的接受需要一定的时间来作准备,顾先在小型项目中使用js版本的vue3,等大家相对熟悉新的api之后再逐渐介入ts,下面开始正文:

生命周期钩子

钩子函数

我们可以直接看生命周期图来认识都有哪些生命周期钩子(图片来自公众号《程序员成长指北》):

图片

全部生命周期钩子如图所示:

640_12

我们可以看到beforeCreatecreatedsetup替换了。其次,钩子命名都增加了on; Vue3.x还新增用于调试的钩子函数onRenderTriggeredonRenderTricked

下面我们简单使用几个钩子, 方便大家学习如何使用,Vue3.x中的钩子是需要从vue中导入的:

<template>
    <div>{{num}}</div>
</template>
<script>
    import {
        ref,
        defineComponent,
        onBeforeMount,
        onMounted,
        onBeforeUpdate,
        onUpdated,
        onBeforeUnmount,
        onUnmounted,
        onErrorCaptured,
        onRenderTracked,
        onRenderTriggered
    } from "vue";

    export default defineComponent({
        // beforeCreate和created是vue2的
        beforeCreate() {
            console.log("------beforeCreate-----");
        },
        created() {
            console.log("------created-----");
        },
        setup() {
            console.log("------setup-----");

            // vue3.x生命周期写在setup中
            onBeforeMount(() => {
                console.log("------onBeforeMount-----");
            });
            onMounted(() => {
                console.log("------onMounted-----");
            });
            onUpdated(() => {
                console.log('updated!')
            })
            onUnmounted(() => {
                console.log('unmounted!')
            })
            const num = ref(0)
            setInterval(() => {
                num.value++;
            }, 1000)
            // 调试哪些数据发生了变化
            onRenderTriggered((event) => {
                console.log("------onRenderTriggered-----", event);
            })

            return {
                num
            }
        },
    });
</script>

我们通过setInterval来改变数据,可以看到onUpdated函数和onRenderTriggered函数都被触发了!

image-20210708192547192

setup

执行顺序

export default defineComponent ({
    beforeCreate() {
        console.log("----beforeCreate----");
    },
    created() {
        console.log("----created----");
    },
    setup() {
        console.log("----setup----");
    },
})

结果是:
setup
beforeCreate
created

warning 由于在执行setup 时尚未创建组件实例,因此在 setup 选项中没有 this

setup 参数

setup接受两个参数:

  1. props: 组件传入的属性/参数
  2. context

setup中接受的props是响应式的,由于是响应式的, 所以不可以使用ES6解构,解构会消除它的响应式。

错误代码示例, 这段代码会让props不再支持响应式:

export default defineComponent ({
    setup(props, context) {
        const { name } = props
        console.log(name)
    },
})

如果要使用结构,则需要使用官方的toRefs,这个我们后续介绍。

setup第二个参数contextsetup中不能访问Vue2中最常用的this对象,所以context中就提供了this中最常用的三个属性:attrsslotemit,分别对应Vue2.x中的 $attr属性、slot插槽 和$emit发射事件,并且这几个属性都是自动同步最新的值,所以我们每次使用拿到的都是最新值。

reactive、ref与toRefs

在vue2.x中, 定义双向绑定的数据都是在data中, 但是Vue3 要使用reactiveref来进行双向绑定数据定义。

那么refreactive他们有什么区别呢?

<template>
    <div>{{obj.name}}-{{obj.count}}</div>
    <div>{{basetype}}</div>
    <div>{{baseTypeReactive}}</div>
    <div>{{objreactive.count}}</div>
</template>
<script>
    import {
        reactive,
        ref
    } from 'vue';
    export default {
        setup() {
            const obj = ref({
                count: 1,
                name: "张三"
            })
            const basetype = ref(2)
            setTimeout(() => {
                obj.value.count = obj.value.count + 1
                obj.value.name = "李四"
                basetype.value += 1
            }, 1000)

            const baseTypeReactive = reactive(6)
            const objreactive = reactive({
                count: 10
            })

            return {
                obj,
                basetype,
                baseTypeReactive,
                objreactive
            }
        },
    }
</script>

reactive

  • reactive 是 Vue3 中提供的实现响应式数据的方法。
  • 在 Vue2 中响应式数据是通过 defineProperty 来实现的,在 Vue3 中响应式数据是通过 ES6 的 Proxy 来实现的。
  • reactive 参数必须是对象 (json / arr),不能代理基本类型,例如字符串、数字、boolean等。
  • 本质: 就是将传入的数据包装成一个Proxy对象
  • 如果给 reactive 传递了其它对象(如Date对象)
    • 默认情况下,修改对象无法实现界面的数据绑定更新。
    • 如果需要更新,需要进行重新赋值。(即不允许直接操作数据,需要放个新的数据来替代原数据)

reactive 使用基本类型参数

基本类型(数字、字符串、布尔值)在 reactive 中无法被创建成 proxy 对象,也就无法实现监听,无法实现响应式。

<template>
    <div>
        <p>{{msg}}</p>
        <button @click="c">button</button>
    </div>
</template>

<script>
    import {
        reactive
    } from 'vue'
    export default {
        name: 'App',
        setup() {
            let msg = reactive(0)

            function c() {
                console.log(msg);
                msg++;
            }
            return {
                msg,
                c
            };
        }
    }
</script>

08utpj.png

点击 button ,我们期望的结果是数字从 0 变成 1,然而实际上界面上的数字并没有发生任何改变。

查看控制台,它的输出是这样的(我点了 3 次)

08uN1s.png

出现提示

value cannot be made reactive: 0

而输出的值确实发生了变化,只不过这种变化并没有反馈到界面上,也就是说并没有实现双向数据绑定。当然,如果是 ref 的话,就不存在这样的问题。而如果要使用 reactive ,我们需要将参数从 基本类型 转化为 对象。

<template>
    <div>
        <p>{{msg.num}}</p>
        <button @click="c">button</button>
    </div>
</template>

<script>
    import {
        reactive
    } from 'vue'
    export default {
        name: 'App',
        setup() {
            let msg = reactive({
                num: 0
            })

            function c() {
                console.log(msg);
                msg.num++;
            }
            return {
                msg,
                c
            };
        }
    }
</script>

将参数替换成了对象 {num: 0},此时,点击按钮界面就会产生改变(我点了 3 次)。

08u8AS.png

在控制台打印消息

08uGtg.png

可以看到,msg 成功被创建成了 proxy 对象,他通过劫持对象的 getset 方法实现了对象的双向数据绑定。

深层的、对象内部的变化也能被察觉到(注意下面代码中的 inner

<template>
<div>
  <p>{{msg.num.inner}}</p>
  <button @click="c">button</button>
</div>
</template>

<script>
import { reactive } from 'vue'
export default {
  name: 'App',
  setup() {
    let msg = reactive({
      num: {
        inner: 0
      }
    })
    function c() {
      console.log(msg);
      msg.num.inner ++;
    }
    return {
      msg,
      c
    };
  }
}
</script>

08uJhQ.png

数组变化当然也可以监听

<template>
    <div>
        <p>{{msg}}</p>
        <button @click="c">button</button>
    </div>
</template>

<script>
    import {
        reactive
    } from 'vue'
    export default {
        name: 'App',
        setup() {
            let msg = reactive([1, 2, 3])

            function c() {
                console.log(msg);
                msg[0] += 1;
                msg[1] = 5;
            }
            return {
                msg,
                c
            };
        }
    }
</script>

08uUcn.png

对象数组也可

<template>
    <div>
        <p>{{msg}}</p>
        <button @click="push">push</button>
        <button @click="change">change</button>
    </div>
</template>

<script>
    import {
        reactive
    } from 'vue'
    export default {
        name: 'App',
        setup() {
            let msg = reactive([{
                name: 'lilei',
                age: 12
            }])

            function change() {
                console.log(msg);
                msg[0].age += 1;
            }

            function push() {
                console.log(msg);
                msg.push({
                    name: 'zhaodafa',
                    age: 22
                })
            }
            return {
                msg,
                change,
                push
            };
        }
    }
</script>

特殊情况:reactive 中监听 Date 日期格式数据

如果参数不是数组、对象,而是稍微奇怪一点的数据类型,例如说 Date ,那么麻烦又来了。

<template>
    <div>
        <p>{{msg}}</p>
        <button @click="c">button</button>
    </div>
</template>

<script>
    import {
        reactive
    } from 'vue'
    export default {
        name: 'App',
        setup() {
            let msg = reactive(new Date())

            function c() {
                console.log(msg);
                msg.setDate(msg.getDate() + 1);
                console.log(msg);
            }
            return {
                msg,
                c
            };
        }
    }
</script>

08uaXq.png

08uwn0.png

这里我先打印了 msg 两次,可以看到,点击一次 button ,msg 的数据是存在变化的,但界面并未发生变化,同时我们发现在控制台里,msg 并未被识别成 proxy

就算我们把 Date 放在对象里,如下:

<template>
    <div>
        <p>{{msg.date}}</p>
        <button @click="c">button</button>
    </div>
</template>

<script>
    import {
        reactive
    } from 'vue'
    export default {
        name: 'App',
        setup() {
            // 注意此处改变
            let msg = reactive({
                date: new Date()
            });

            function c() {
                console.log(msg);
                msg.date.setDate(msg.date.getDate() + 1);
                console.log(msg);
            }
            return {
                msg,
                c
            };
        }
    }
</script>

也仍然不起效果。

08u0BV.png

08uB7T.png

显然,对于这种数据类型,我们需要做特殊处理。

处理方式就是重新赋值(而不是直接修改原来的值)。

<template>
    <div>
        <p>{{msg.date}}</p>
        <button @click="c">button</button>
    </div>
</template>

<script>
    import {
        reactive
    } from 'vue'
    export default {
        name: 'App',
        setup() {
            let msg = reactive({
                date: new Date()
            });

            function c() {
                console.log(msg);
                msg.date.setDate((msg.date.getDate() + 1));
                msg.date = new Date(msg.date);
                console.log(msg);
            }
            return {
                msg,
                c
            };
        }
    }
</script>

这里我采用了拷贝的方案重新赋值了 msg.date,界面成功发生了变化(日期 + 1)。

08urAU.png

ref

ref可以监听复杂对象也可以监听基础数据类型,如下:

<template>
    <!-- 直接取值,无需xxx.value -->
    <div>{{obj.name}}-{{obj.count}}</div>
    <div>{{basetype}}</div>
    <div>{{date}}</div>
</template>
<script>
    import {
        ref
    } from 'vue';
    export default {
        setup() {
            const obj = ref({
                count: 1,
                name: "张三"
            })
            const basetype = ref(2)
            const date = ref(new Date())
            setTimeout(() => {
                obj.value.count = obj.value.count + 1
                obj.value.name = "李四"
                basetype.value += 1
                date.value.setDate((date.value.getDate() + 1)); // 此处也可直接修改Date类型,不需要重新赋值
                // date.setDate((date.value.getDate() + 1));
                // date = new Date(date);
            }, 1000)


            return {
                obj,
                basetype,
                date
            }
        },
    }
</script>

ref 监听Date类型也可直接修改Date类型,不需要重新拷贝赋值

但是要注意ref监听的对象在setup方法中需要使用xxx.value来赋值和取值;在页面上可以直接取值

解构方法:toRefs与toRef

toRefs

  • toRefs可以看作toRef的语法糖,toRefs遍历传入对象的所有属性,使其都具备响应式。

  • 页面是通过user.name,user.age写很繁琐,但是又不能直接对user进行Es6的解构, 这样会消除它的响应式, 上面我们已经说过了,解决办法就是使用toRefs;与上面我们说props不能使用ES6直接解构的情况是一致的。

  • toRefs用于将一个reactive对象转化为属性全部为ref对象的普通对象。具体使用方式如下:

<template>
    <div class="homePage">
        <p>第 {{ year }} 年</p>
        <p>姓名: {{ nickname }}</p>
        <p>年龄: {{ age }}</p>
    </div>
</template>

<script>
    import {
        defineComponent,
        reactive,
        ref,
        toRefs
    } from "vue";
    export default defineComponent({
        setup() {
            const year = ref(0);
            const user = reactive({
                nickname: "xiaofan",
                age: 26,
                gender: "女"
            });
            setInterval(() => {
                year.value++
                user.age++
            }, 1000)
            return {
                year,
                // 使用reRefs
                ...toRefs(user)
            }
        },
    });
</script>

toRef

  • 用于解构常规(非响应式)对象,解构之后的变量具备响应式,跟随原对象值改变(但是修改现变量的值,不会反向改变原基础对象的值)
const obj1 = {
    count: 1,
    name: '张三1'
};
let name1 = toRef(obj1, 'name'); // 
<template>
	<div style="color: white">
		<div>obj1: {{ obj1 }}<br />name1:{{ name1 }}-----name1Cp:{{ name1Cp }}</div>
		<br />
		<div>
			obj2: {{ obj2 }} <br />
			name2:{{ name2 }}
		</div>
	</div>
</template>
<script>
import { ref, toRef, defineComponent } from 'vue';
export default defineComponent({
	setup() {
		const obj1 = {
			count: 1,
			name: '张三1'
		};
		let name1 = toRef(obj1, 'name'); // 解构之后具备响应式,跟随对象原值改变(但是修改name1,不会反向改变原对象)
		let { name: name1Cp } = obj1; // es6的解构是没有响应式的,虽然他的值也发生了变化

		const obj2 = ref({
			count: 1,
			name: '张三2'
		});
		let name2 = toRef(obj2, 'name'); // 不行的,只能解构基础对象,不能解构ref响应式对象

		setInterval(() => {
			obj1.count++;
			obj1.name = '李四' + obj1.count; // name1也会随之修改
			// name1 = '王五' + obj1.count;
			// console.log(name1, obj1); // name1值修改成功,但是页面无法感知
			// console.log(name1Cp); // 一直是:张三1

			obj2.value.count++;
			obj2.value.name = '李四' + obj2.value.count;
			name2 = '王五2';
		}, 1000);

		return {
			obj1,
			obj2,
			name1,
			name2,
			name1Cp
		};
	}
});
</script>

<style lang="less">
.demo {
	color: @fontsize-color;
	font-size: @fontsize-level1;
}
</style>

watch 与 watchEffect

watch 函数用来侦听特定的数据源,并在回调函数中执行副作用。默认情况是惰性的,也就是说仅在侦听的源数据变更时才执行回调。

watch(source, callback, [options])

参数说明:

  • source:可以支持string,Object,Function,Array; 用于指定要侦听的响应式变量
  • callback: 执行的回调函数
  • options:支持deep、immediate 和 flush 选项。

侦听reactive定义的数据

<template>
    <div>{{nickname}}</div>
</template>
<script>
    import {
        defineComponent,
        ref,
        reactive,
        toRefs,
        watch
    } from "vue";
    export default defineComponent({
        setup() {
            const state = reactive({
                nickname: "xiaofan",
                age: 20
            });

            setTimeout(() => {
                state.age++
            }, 1000)

            // 修改age值时会触发 watch的回调
            watch(
                () => state.age,
                (curAge, preAge) => {
                    console.log("新值:", curAge, "老值:", preAge);
                }
            );

            return {
                ...toRefs(state)
            }
        },
    });
</script>

侦听ref定义的数据

const year = ref(0)

setTimeout(() =>{
    year.value ++ 
},1000)

watch(year, (newVal, oldVal) =>{
    console.log("新值:", newVal, "老值:", oldVal);
})

侦听多个数据

上面两个例子中,我们分别使用了两个watch, 当我们需要侦听多个数据源时, 可以进行合并, 同时侦听多个数据:

watch([() => state.age, year], ([curAge, preAge], [newVal, oldVal]) => {
    console.log("新值:", curAge, "老值:", preAge);
    console.log("新值:", newVal, "老值:", oldVal);
});

侦听复杂的嵌套对象

我们实际开发中,复杂数据随处可见, 比如:

const state = reactive({
    room: {
    id: 100,
    attrs: {
        size: "140平方米",
        type:"三室两厅"
    },
    },
});
watch(() => state.room, (newType, oldType) => {
    console.log("新值:", newType, "老值:", oldType);
}, {deep:true});

在复杂数据访问中,如果不使用第三个参数deep:true, 是无法监听到数据变化的。

前面我们提到,默认情况下,watch是惰性的, 那什么情况下不是惰性的, 可以立即执行回调函数呢?其实使用也很简单, 给第三个参数中设置immediate: true即可。

stop 停止监听

我们在组件中创建的watch监听,会在组件被销毁时自动停止。如果在组件销毁之前我们想要停止掉某个监听, 可以调用watch()函数的返回值,操作如下:

const stopWatchRoom = watch(() => state.room, (newType, oldType) => {
    console.log("新值:", newType, "老值:", oldType);
}, {deep:true});

setTimeout(()=>{
    // 停止监听
    stopWatchRoom()
}, 3000)

还有一个监听函数watchEffect,在我看来watch已经能满足监听的需求,为什么还要有watchEffect呢?虽然我没有get到它的必要性,但是还是要介绍一下watchEffect,首先看看它的使用和watch究竟有何不同。

import { defineComponent, ref, reactive, toRefs, watchEffect } from "vue";
export default defineComponent({
  setup() {
    const state = reactive({ nickname: "xiaofan", age: 20 });
    let year = ref(0)

    setInterval(() =>{
        state.age++
        year.value++
    },1000)

    watchEffect(() => {
        console.log(state);
        console.log(year);
      }
    );

    return {
        ...toRefs(state)
    }
  },
});

执行结果首先打印一次stateyear值;然后每隔一秒,打印stateyear值。

从上面的代码可以看出, 并没有像watch一样需要先传入依赖,watchEffect会自动收集依赖, 只要指定一个回调函数。在组件初始化时, 会先执行一次来收集依赖, 然后当收集到的依赖中数据发生变化时, 就会再次执行回调函数。

所以总结对比如下:

  1. watchEffect 不需要手动传入依赖
  2. watchEffect 会先执行一次用来自动收集依赖
  3. watchEffect 无法获取到变化前的值, 只能获取变化后的值

Tips: 如果定义一个非响应式的值, watch和watchEffect是无法监听到值的变化的!!!

使用ref获取dom元素

先看下vue2中获取方式

<div ref="myRef"></div>
this.$refs.myRef

vue3用法: 获取单个dom

<template>
  <div ref="myRef">获取单个DOM元素</div>
</template>


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

export default {
  setup() {
    const myRef = ref(null);

    onMounted(() => {
      console.dir(myRef.value);
    });
    return {
      myRef
    };
  }
};
</script>

vue3获取多个dom

<template>
	<div :class="$style.demo">
		<div>获取多个DOM元素</div>
		<ul>
			<li v-for="(item, index) in arr" :key="index" :ref="setRef">
				{{ item }}
			</li>
		</ul>
	</div>
</template>

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

export default {
	setup() {
		const arr = ref([1, 2, 3]);

		// 存储dom数组
		const myRef = ref([]);
		const setRef = el => {
			myRef.value.push(el);
		};
		nextTick(() => {
			console.dir(myRef.value);
		});
		
		return {
			arr,
			setRef
		};
	}
};
</script>

控制台打印如下:
Proxy
[[Handler]]: Object
[[Target]]: Array(3)
    0: li
    1: li
    2: li
    length: 3

自定义 Hooks

在vue2 中可以抽出mixin 来实现共有逻辑功能,(但是其弊端在此处就不赘述了), vue3中可以将其封装成一个hook, 我们约定这些「自定义 Hook」以 use 作为前缀,和普通的函数加以区分。

useCount.js 实现:

import { ref, computed } from "vue";

export default function useCount(initValue = 1) {
    const count = ref(initValue);

    const increase = (delta) => {
        if (typeof delta !== "undefined") {
            count.value += delta;
        } else {
            count.value += 1;
        }
    };
    const multiple = computed(() => count.value * 2)

    const decrease = (delta) => {
        if (typeof delta !== "undefined") {
            count.value -= delta;
        } else {
            count.value -= 1;
        }
    };

    return {
        count,
        multiple,
        increase,
        decrease,
    };
}

接下来看一下在组件中使用useCount这个 hook:

<template>
    <div>
        <p>count: {{ count }}</p>
        <p>倍数: {{ multiple }}</p>
        <div>
            <button @click="increase()">加1</button>
            <button @click="decrease()">减一</button>
        </div>
    </div>

</template>
<script>
    import {
        defineComponent
    } from 'vue';
    import useCount from "../hooks/useCount";
    export default defineComponent({
        setup() {
            const {
                count,
                multiple,
                increase,
                decrease
            } = useCount(10);
            return {
                count,
                multiple,
                increase,
                decrease,
            };
        },
    });
</script>

代替vuex的状态管理组件pina

Pinia 类似 Vuex, 是用于 Vue 的状态管理库,Pinia 支持 Vue2 和 Vue3

本文只讲 Pinia 在 Vue3 中的使用, 在 Vue2 中使用略有差异,参考 官方文档

它非常的轻量, 仅有 1 KB;采用模块化设计,按需引入,易于学习。

下面我们直接上代码;

main.js

import { setupStore } from '@/store';
import App from './App.vue';

const app = createApp(App);
setupStore(app);  // 注册store

Src/store/index.js

import { createPinia } from 'pinia';

const store = createPinia();

export function setupStore(app) {
	app.use(store);
}

export { store };

Src/store/modules/app.js

import { defineStore } from 'pinia';
import { store } from '@/store';
import ehvApi from '@/api/ehv.js';
export const useAppStore = defineStore({
	id: 'app',  // 唯一id
  // 数据存储区
	state: () => ({
		currentStation: '',
		stationList: [],
		pageLoading: false
	}),
	getters: {
		getCurrentStation() {
			return this.currentStation;
		},
		getStationList() {
			return this.stationList;
		}
	},
  // 异步修改
	actions: {
		async queryStationList() {
			this.stationList = await ehvApi.stationList();
			return this.stationList;
		},
		setCurrentStation(val) {
			this.currentStation = val;
		}
	}
});

App.vue

<script setup>
import { useAppStore } from '@/store/modules/app.js';
const appStore = useAppStore();

// 调取action的接口请求
appStore.queryStationList();

// 获取store中信息
let selectOptions = computed(() => appStore.getStationList);
</script>

事件总线mitt.js

Vue2.x 使用 EventBus 进行组件通信,通过 new 一个 Vue 实例的方式,让它来充当事件总线,管理事件派发响应。 Vue3.x 由于源码的改动不在支持原有的写法,官方推荐使用mitt.js。Vue3 从实例中完全删除了 $on$off$once 方法。$emit 仍然是现有API的一部分,但是它目前用于触发由父组件以声明方式附加的事件。

比起 Vue 实例上的 EventBus,mitt.js 足够小,仅有200bytes,且支持全部事件的监听和批量移除,还可以跨框架使用,React 或者 Vue,甚至 jQuery 项目都能使用同一套库。

安装: npm install --save mitt

使用方式:

Utils/mitt.js

import mitt from 'mitt';
export default mitt();

A.vue

import emitter from '@/utils/mitt';
...  ...
// 触发事件
emitter.emit('changeStation', value);

B.vue

import emitter from '@/utils/mitt';
// 接收事件
emitter.on('changeStation', val => {
	// 接收的回调方法
});

其他用法,通过 on 方法添加事件,off 方法移除,clear 清空所有。

import mitt from 'mitt'

const emitter = mitt()

// listen to an event
emitter.on('foo', e => console.log('foo', e) )

// listen to all events
emitter.on('*', (type, e) => console.log(type, e) )

// fire an event
emitter.emit('foo', { a: 'b' })

// clearing all events
emitter.all.clear()

// working with handler references:
function onFoo() {}
emitter.on('foo', onFoo)   // listen
emitter.off('foo', onFoo)  // unlisten

拓展阅读:

Object.defineProperty vs Proxy

Vue2.x的时候就经常遇到一个问题,数据更新了啊,为何页面不更新呢?什么时候用$set更新,什么时候用$forceUpdate强制更新,你是否也一度陷入困境。后来的学习过程中开始接触源码,才知道一切的根源都是 Object.defineProperty

这里简单对比一下Object.defineProperty 与Proxy

  1. Object.defineProperty只能劫持对象的属性, 而Proxy是直接代理对象由于Object.defineProperty只能劫持对象属性,需要遍历对象的每一个属性,如果属性值也是对象,就需要递归进行深度遍历。但是Proxy直接代理对象, 不需要遍历操作
  2. Object.defineProperty对新增属性需要手动进行Observe,因为Object.defineProperty劫持的是对象的属性,所以新增属性时,需要重新遍历对象, 对其新增属性再次使用Object.defineProperty进行劫持。也就是Vue2.x中给数组和对象新增属性时,需要使用$set才能保证新增的属性也是响应式的, $set内部也是通过调用Object.defineProperty去处理的。

参考博文:

[1] vue3中reactive注意点(系列四) www.cnblogs.com/fsg6/p/1448…

[2] 公众号程序员成长指北- Vue3.0 新特性以及使用变更总结(实际工作用到的)