跳转到内容

Vue 的更新机制和优化

刀刀
0字
0分钟
2026/8/16

原理思考

Vue2

常见不更新原因

  1. 对于对象新增初始化没有的属性,或者删除
  2. 对于数组用了下标修改
  3. 把 DOM 用 innerHTML 操作了内容
  4. mounted 里对数据赋值,来源数据变更没更新(vuexvuerouter
vue
<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>
vue
<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>
vue
<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>
vue
<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>
vue
<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 数据更新组件也不会更新。

解决方案

  1. $set
  2. 对象、数组用整个替换的方式修改
  3. forceUpdate
vue
<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>
vue
<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>
vue
<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

常见不更新原因

  1. 使用了 shallowRef
  2. 用错了 refreactive
vue
<script setup>
const data = shallowRef({
  a: 1,
  b: {
    c: 2,
  },
})

data.value.a = 3 // 页面不更新
</script>
vue
<script setup>
const obj = reactive({
  a: 123,
})
</script>

shallowRef 只会代理第一层,其内部的数据不会有响应式,所以修改 data.value.a 不会触发更新。

reactive 会把整个对象变为 Proxy 对象,修改其中的属性可以触发更新;但是如果直接替换整个对象,会让它失去 Proxy 代理,从而失去响应式。

解决方案

  1. 只有明确内部属性不需要单独修改才使用 shallowRef,如果想要修改 shallowRef 则需要整个对象替换
  2. reactive 不要整个对象替换,如果需要,则改用 ref 或者替换赋值时用 reactive 包裹让它重新具有响应式

背后原理

Vue2

对象

对象背后原理

先看Vue2对对象的处理。在 new Vue 后,内部会把 data 内全部的数据循环遍历,给每一个属性都添加 gettersetter,当数据被访问时,会触发 getter,当数据被修改时,会触发 setter,从而实现响应式。

而后面新增的变量没有被绑定 gettersetter,所以没有响应式,不会触发更新。

代码实现

html
<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,给新增的属性绑定 gettersetter

数组

数组背后原理

接下来看Vue2对数组的处理,Vue2对数组做了特殊处理,只对 pushpopshiftunshiftsplicesortreverse 几个方法做了重写,先取出数组原型链,拷贝复制一份数组的原方法,调用上述几个方法时会先调用原型链的原方法,然后再调用Vue2自己写的方法。

代码实现

html
<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

js
const newProxy = new Proxy(obj, {
  get(target, key) {
    return target[key]
  },
  set(target, key, value) {
    target[key] = value
  },
})

newProxy.a = 123

场景复现

场景

倒计时场景,代码如下:

vue
<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 。说明它是每个组件都去比对一次查看是否有变化。

解决

  1. 把倒计时区域提取为组件,数据通过 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 是响应式的,会触发整个页面的更新。

  2. 绕开Vue体系,用原生DOM操作设置 innerHTML,这样就不会触发Vue的更新机制(简单设置文字内容可以考虑)

    App.vue
    vue
    <script setup>
    let word = 'hello'
    let time = 1000
    
    setTimeout(() => {
      word = 'world'
    }, 3000)
    
    onUpdated(() => {
      console.log('update')
    })
    </script>
    
    <template>
      <div>{{ word }}</div>
    </template>

    这样页面只会触发重绘,不会触发重排。但是这个方法不是特别推荐,除非像案例那样只是简单修改文字内容。

原理

  1. Vue的更新是以组件为最小统计的单位的

    常说Vue的响应式原理是 getsetset 部分在修改数据时会触发更新,而 get 会有一个依赖收集的过程。

    过程图

    从图可以看出,触发 get 后它会针对变量收集使用到它的组件并存储起来,后续该变量触发更新后,会遍历存储的组件,触发它们的更新。因此Vue的更新是以组件为最小统计单位的更新。

    拓展

    React页面更新不是通过 getset ,因此不会有组件依赖收集,触发更新后是整个组件树从头到尾都更新了,因此需要做很多额外的优化。
  2. 更新的过程

    更新的过程

    所以在更新上Vue是根据新老虚拟DOM来比对哪里发生了变化,然后用最简单的策略更新,比如修改文字内容,只需要修改 textContent 就可以了,而修改样式只需要修改 style 就可以了,不需要重新渲染整个DOM

更新策略的决定

更新策略的决定

下面来简单看一下虚拟DOM长什么样:

js
let obj = {
  type: 'div',
  attr: [],
  text: '',
  children: [
    {
      type: 'div',
      attr: [],
      text: _ctx.other, // _ctx表示Vue的上下文
      children: [],
    },
  ],
}
html
<div>
  <div>{{ other }}</div>
</div>

在比对时他会先看 key 和类型是否发生改变,如果改变那就必定要删除之前的,createElement 方法重新创建一个新DOM再加入。

如果类型和 key 没有改变,那就看该DOM是否写死(即没有使用变量),写死则跳过比对。

后面再看文字内容和属性是否发生改变,文字变化用 innerHTML 修改,属性发生变化用 setAttribute 修改。

总结

  1. Vue的更新是以组件为最小统计单位的。Vue的更新过程是先比对虚拟DOM,然后根据变化决定更新策略。
  2. 如果有一些更新特别频繁的区域,可以把区域从页面中提取出来作为一个组件,尤其是页面比较庞大的时候。
  3. 尽量复用之前的DOM,避免相似的结构,用 v-if 切换显影

贡献者

The avatar of contributor named as duyidao duyidao
The avatar of contributor named as v_duyilin v_duyilin
The avatar of contributor named as 刀刀 刀刀

页面历史

刀刀博客累计访客 人;文档累计访问量共