Vue的更新机制和优化
刀刀
6/16/2025
0 字
0 分钟
原理思考
Vue2
常见不更新原因
- 对于对象新增初始化没有的属性,或者删除
- 对于数组用了下标修改
- 把 DOM 用
innerHTML
操作了内容 - 在
mounted
里对数据赋值,来源数据变更没更新(vuex
、vuerouter
)
<template>
<div>{{ obj }}</div>
</template>
<script>
export default {
data() {
return {
obj: {
a: 1,
},
};
},
mounted() {
this.obj.a = 2; // 页面正常更新
setTimeout(() => {
this.obj.b = 234;
console.log(this.obj); // 控制台打印数据有更新,页面没更新
}, 1000);
},
};
</script>
<template>
<div>{{ arr }}</div>
</template>
<script>
export default {
data() {
return {
arr: [1, 2, 3],
};
},
mounted() {
this.arr.push(4); // 页面正常更新
setTimeout(() => {
this.arr[0] = 234;
console.log(this.arr); // 控制台打印数据有更新,页面没更新
}, 1000);
},
};
</script>
<template>
<div id="mydom">{{ arr }}</div>
</template>
<script>
export default {
data() {
return {
arr: [1, 2, 3],
};
},
mounted() {
document.getElementById("mydom").innerHTML = 999;
setTimeout(() => {
this.arr.push(4); // DOM不更新,内容还是999,因为DOM被操作了导致失去了对arr的依赖
}, 1000);
},
};
</script>
<template>
<header v-if="path !== '/login'">头部</header>
<!-- Vue路由切换实际上只是切换router-view组件的内容,header不变 -->
<router-view></router-view>
</template>
<script>
export default {
data() {
return {
path: "",
};
},
mounted() {
// mounted函数只执行一次,如果一开始进入登录页,header能够隐藏;如果一开始进入的是首页,后面才跳转到登录页,此时mounted不再执行,头部也不会隐藏
this.path = this.$router.path;
},
};
</script>
<template>
<div>{{ data }}</div>
</template>
<script>
import { params } from "@/store";
export default {
data() {
return {
data: params,
};
},
};
</script>
上方代码示例四中,Vue 路由切换实际上只是切换 router-view
组件的内容,整个组件的 mounted
生命周期函数只执行一次,如果一开始进入登录页,header
能够隐藏;如果一开始进入的是首页,后面才跳转到登录页,此时 mounted
不再执行,头部也不会隐藏.
上方代码示例五中,mounted
里对 data
赋值,来源数据变更没更新,原因在于 data
保存了 params
的值,而 Vue 的更新机制是引用才更新,而该组件没有 params
变量的依赖,因此 params
数据更新组件也不会更新。
解决方案
$set
- 对象、数组用整个替换的方式修改
forceUpdate
<template>
<div>{{ obj }}</div>
<div>{{ arr }}</div>
</template>
<script>
export default {
data() {
return {
obj: {
a: 1,
},
arr: [1, 2, 3],
};
},
mounted() {
this.$set(this.obj, "b", 234);
this.$set(this.arr, 0, 234);
},
};
</script>
<template>
<div>{{ obj }}</div>
<div>{{ arr }}</div>
</template>
<script>
export default {
data() {
return {
obj: {
a: 1,
},
arr: [1, 2, 3],
};
},
mounted() {
this.obj = { a: 1, b: 234 };
let _arr = this.arr;
_arr[0] = 234;
this.arr = _arr;
},
};
</script>
<template>
<div>{{ obj }}</div>
<div>{{ arr }}</div>
</template>
<script>
export default {
data() {
return {
obj: {
a: 1,
},
arr: [1, 2, 3]
}
},
mounted() {
this.obj.b = 2
this.arr.[2] = 5
this.$forceUpdate()
}
}
</script>
Vue3
常见不更新原因
- 使用了
shallowRef
- 用错了
ref
和reactive
<script setup>
const data = shallowRef({
a: 1,
b: {
c: 2,
},
});
data.value.a = 3; // 页面不更新
</script>
<script setup>
const obj = reactive({
a: 123,
});
</script>
shallowRef
只会代理第一层,其内部的数据不会有响应式,所以修改 data.value.a
不会触发更新。
reactive
会把整个对象变为 Proxy
对象,修改其中的属性可以触发更新;但是如果直接替换整个对象,会让它失去 Proxy
代理,从而失去响应式。
解决方案
- 只有明确内部属性不需要单独修改才使用
shallowRef
,如果想要修改shallowRef
则需要整个对象替换 reactive
不要整个对象替换,如果需要,则改用ref
或者替换赋值时用reactive
包裹让它重新具有响应式
背后原理
Vue2
对象
先来看看 Vue2 是怎么处理对象的。可以看到,在 new Vue 后,内部会把 data
内全部的数据循环遍历,给每一个属性都添加 getter
和 setter
,当数据被访问时,会触发 getter
,当数据被修改时,会触发 setter
,从而实现响应式。
而后面新增的变量没有被绑定 getter
和 setter
,所以没有响应式,不会触发更新。
代码实现
<script>
function Vue(options) {
this.$data = options.data;
this.init();
}
Vue.prototype.init = function () {
function defineReactive(obj) {
for (let key in obj) {
const value = obj[key];
Object.defineProperty(obj, key, {
get() {
// 依赖收集,并返回现在值
return value;
},
set(newVal) {
// 修改数据并页面更新
value = newVal;
},
});
if (typeof value === "object" && !(value instanceof Array)) {
// 递归对象,内部属性也要绑定响应式
defineReactive(value);
}
}
}
defineReactive(this.$data);
};
new Vue({
data: {
a: 123,
b: {
c: 456,
},
},
});
</script>
$set
的实现原理也是利用 Object.defineProperty
,给新增的属性绑定 getter
和 setter
。
数组
接下来看看 Vue2 对数组的处理,可以看到,Vue2 对数组做了特殊处理,只对 push
、pop
、shift
、unshift
、splice
、sort
、reverse
几个方法做了重写,先取出数组原型链,拷贝复制一份数组的原方法,调用上述几个方法时会先调用原型链的原方法,然后再调用 Vue2 自己写的方法。
代码实现
<script>
function Vue(options) {
this.$data = options.data;
this.init();
}
Vue.prototype.init = function () {
function decorateArr(arr) {
const originProto = Array.prototype;
const copyProto = Object.create(originProto);
copyProto.push = function (...args) {
originProto.push.apply(this, args);
};
arr.__proto__ = copyProto;
}
function defineReactive(obj) {
for (let key in obj) {
const value = obj[key];
Object.defineProperty(obj, key, {
get() {
// 依赖收集,并返回现在值
return value;
},
set(newVal) {
// 修改数据并页面更新
value = newVal;
},
});
if (typeof value === "object" && !(value instanceof Array)) {
// 递归对象,内部属性也要绑定响应式
defineReactive(value);
}
}
}
defineReactive(this.$data);
};
const obj = new Vue({
data: {
a: 123,
b: {
c: 456,
},
arr: [1, 2, 3],
},
});
obj.$data.arr.push(100);
</script>
上述示例代码中以数组的 push
方法为例,先拿到原型链,再拷贝一份,然后重写 push
方法,在重写的方法中调用原型链的 push
方法,再调用 Vue2 自己写的方法。最后把改造后的原型链赋值给数组的原型,实现数组方法改写。
Vue3
const newProxy = new Proxy(obj, {
get(target, key) {
return target[key];
},
set(target, key, value) {
target[key] = value;
},
});
newProxy.a = 123;
场景复现
场景
先看一个倒计时场景,代码如下:
<script setup>
let word = 'hello'
let time = ref(1000)
setInterval(() => {
time.value--
}, 1000)
setTimeout(() => {
word = 'world'
}, 3000)
onUpdate(() => {
console.log('update')
})
</script>
<template>
<div>{{ word }}</div>
<div>{{ time }}</div>
</template>
这个组件每次修改倒计时变量时,都会触发 Vue 的更新机制,从而触发 onUpdate
生命周期钩子。如果组件庞大,会频繁触发 diff
算法对比 DOM (更新的时候只需要修改发生变动的部分,性能消耗反而没那么大,主要是虚拟 DOM 对比寻找更新部分消耗更大)
以上方代码为例试验一下,延时器 3s 后把 word
字段修改为 world
,控制台没有打印,页面没有发生变化,因为 word
不是响应式;而定时器每隔 1s 就让 time
字段自减1,可以发现 3s 后 word
字段变成了 world
。说明它是每个组件都去比对一次查看是否有变化。
解决
把倒计时区域提取为组件,数据通过
props
传递给组件,这样更新和比对都只会触发组件本身,而不是整个页面。vue<script setup> const {time} = defineProps(['time']) const containerTime = ref(time) setInterval(() => { containerTime.value-- }, 1000) </script> <template> <div>{{ containerTime }}</div> </template>
vue<script setup> import timeCounter from './timeCounter.vue' let word = 'hello' let time = 1000 setTimeout(() => { word = 'world' }, 3000) setInterval(() => { time-=1 document.querySelector('.time-container').innerHTML = time }, 1000) onUpdated(() => { console.log('update') }) </script> <template> <div>{{ word }}</div> <div class="time-container">{{ time }}</div> </template>
现在触发更新后只会更新
timeCounter
组件,而不会触发整个页面的更新,从而减少不必要的性能消耗。⚠️ 注意
传递的时候不要传递
ref
,不然还是会触发整个页面的更新,因为ref
是响应式的,会触发整个页面的更新。绕开 Vue 体系,用原生 DOM 操作设置
innerHTML
,这样就不会触发 Vue 的更新机制(简单设置文字内容可以考虑)vue<script setup> let word = 'hello' let time = 1000 setTimeout(() => { word = 'world' }, 3000) onUpdated(() => { console.log('update') }) </script> <template> <div>{{ word }}</div> </template>
这样页面只会触发重绘,不会触发重排。但是这个方法不是特别推荐,除非像案例那样只是简单修改文字内容。
原理
Vue 的更新是以组件为最小统计的单位的
常说 Vue 的响应式原理是
get
和set
,set
部分在修改数据时会触发更新,而get
会有一个依赖收集的过程。从图可以看出,触发
get
后它会针对变量收集使用到它的组件并存储起来,后续该变量触发更新后,会遍历存储的组件,触发它们的更新。因此 Vue 的更新是以组件为最小统计单位的更新。拓展
React 页面更新不是通过
get
和set
,因此不会有组件依赖收集,触发更新后是整个组件树从头到尾都更新了,因此需要做很多额外的优化。更新的过程
所以在更新上 Vue 是根据新老虚拟 DOM 来比对哪里发生了变化,然后用最简单的策略更新,比如修改文字内容,只需要修改
textContent
就可以了,而修改样式只需要修改style
就可以了,不需要重新渲染整个 DOM。
更新策略的决定
下面来简单看一下虚拟 DOM 长什么样:
let obj = {
type: 'div',
attr: [],
text: '',
children: [
{
type: 'div',
attr: [],
text: _ctx.other, // _ctx表示Vue的上下文
children: []
}
]
}
<div>
<div>{{ other }}</div>
</div>
在比对时他会先看 key
和类型是否发生改变,如果改变那就必定要删除之前的,createElement
方法重新创建一个新 DOM 再加入。
如果类型和 key
没有改变,那就看该 DOM 是否写死(即没有使用变量),写死则跳过比对。
后面再看文字内容和属性是否发生改变,文字变化用 innerHTML
修改,属性发生变化用 setAttribute
修改。
总结
- Vue 的更新是以组件为最小统计单位的。Vue 的更新过程是先比对虚拟 DOM,然后根据变化决定更新策略。
- 如果有一些更新特别频繁的区域,可以把区域从页面中提取出来作为一个组件,尤其是页面比较庞大的时候。
- 尽量复用之前的 DOM,避免相似的结构,用
v-if
切换显影