likes
comments
collection
share

Vue.js 3.0响应式原理剖析

作者站长头像
站长
· 阅读数 43

响应式实现方式

响应式Vue.js 核心设计思想。它的本质是当数据变化后自动执行某个函数,从而触发视图的重新渲染。

响应式的实现基本都是靠 数据劫持。 在介绍 Vue.js 3.0 响应式实现之前,我们先来回顾一下 Vue.js 2.x 响应式实现的部分: 通过 Object.defineProperty 劫持数据的变化,在数据被访问的时候做 依赖收集,在数据被修改的时候做 派发更新

const dep = new Dep()
const val = obj[key]

Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
    if (Dep.target) { Dep.target -> watcher
      dep.depend() // 依赖收集
    }
    return val
  },
  set: function reactiveSetter (newVal) {
    const value = val
    if (newVal === value) {
      return
    }
    val = newVal
    dep.notify() // 派发更新
}

Vue.js 2.x 中,依赖就是 Watcher,有专门针对组件渲染的 render watcher。组件挂载过程中,首先会实例化 render watcher,并且让 Dep.target 指向 render watcher。 执行组件 render 函数的过程中,会访问模板中的数据,然后触发数据对应的 getter 劫持,此时会把 render watcher 作为 依赖收集 起来(dep.depend);当对数据修改的时候,会触发数据对应的 setter 劫持,此时会通知已收集的 render watcher派发更新(dep.notify),从而触发了视图的重新渲染。 Vue2.0 响应式细节猛戳这里

而到了 Vue.js 3.0,不仅使用 Proxy 重写了响应式部分,并独立维护和发布 reactivity 库。那么 ProxyObject.defineProperty有哪些区别呢?

Proxy VS Object.defineProperty

1、从 API 上来看,Proxy  劫持的是整个对象,那么对于对象属性的新增、删除、修改自然都可以劫持到;而 Object.defineProperty API 劫持的对象某一个属性的访问和修改,因此它不能监听对象属性新增和删除(提供 Vue.setVue.delete),并且不能修改数组的索引及长度来触发视图更新(重写数组的原型指向)。 具体实现细节猛戳这里

2、从兼容性上来看,Object.defineProperty 支持所有主流浏览器,并兼容 IE9+,而 Proxy支持现代主流浏览器,但唯独不支持 IE,浏览器的兼容性不够好,而且没有合适的 polyfill。(过去时

3、在初始化阶段Vue.js 2.x 内部把某个对象变成响应式的时候,如果遇到对象的某个属性的值仍然是对象的时候,会 递归 把子对象也变成响应式。到了 Vue.js 3.0,并不会在初始阶段递归响应式,而是在对象属性被访问的时候才递归执行下一步 reactive,这其实是一种 延时定义子对象 响应式的实现,在性能上会有较大的提升。

vue3.0响应式语法

下面我们通过一个简单的 demo 来看一下 Vue.js 3.0 响应式部分的实现细节。

<template>
  <div>
    <p>{{ state.count }}</p>
    <button @click="increase">increase</button>
  </div>
</template>
<script>
  import { reactive } from 'vue'
  export default {
    setup() {
      const state = reactive({
        count: 0
      })

      const increase = function() {
        state.count++
      }

      return {
        increase,
        state
      }
    }
  }
</script>

页面初始化显示为 0,当我们点击按钮的时候,state.count 的值开始增加。

这里我们引入了 reactive API,它可以把一个对象数据变成响应式。那么它内部到底是怎么实现的呢?我们接下来一探究竟。

reactive 简版实现

function reactive(target) {
  return createReactiveObject(target);
}

function createReactiveObject(target) {
  if (!isObject(target)) {
    console.warn(`value cannot be made reactive: ${String(target)}`);
    return target;
  }

  const proxy = new Proxy(target, {
     get: function get(target, key, receiver) {
       return Reflect.get(target, key, receiver);
     },
     set: function set(target, key, value, receiver) {
      return Reflect.set(target, key, value, receiver);
     }
  });
  
  return proxy;
}

仔细想想看,响应式的实现方式无非就是劫持数据,我们通过 Proxy 劫持了原对象,并返回了代理对象。由于 Proxy 劫持的是整个对象,所以我们可以检测到任何对对象的访问和修改,很好的弥补了 Object.defineProperty 的不足。我们可以在访问数据触发 get 函数时依赖收集。

依赖收集

 get: function get(target, key, receiver) {
   ...
   track(target, key) // 依赖收集
 }

整个 get 函数最核心的部分其实是执行 track 函数收集依赖,下面我们重点分析这个过程。 我们先来看一下 track 函数的实现:

let activeEffect // 当前激活的 effect
const targetMap = new WeakMap()

function track(target, key) {
  if (!activeEffect) return; // 不需要收集依赖
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect) // 收集当前激活的 effect 作为依赖
    activeEffect.deps.push(dep) // 当前激活的 effect 收集 dep 集合
  }
}

分析这个函数的实现前,我们先想一下要收集的依赖是什么。我们的目的是实现响应式,就是当数据变化的时候可以自动做一些事情,比如执行某些函数,所以我们收集的依赖就是数据变化后执行的 副作用函数 (后面会着重介绍)。

再来看实现,我们把 target 作为原始的数据,key 作为访问的属性。我们创建了全局的 targetMap 作为原始数据对象的 WeakMap,它的键是 target,值是 depsMap 作为依赖的 Map;这个 depsMap 的键是 key,值是 dep 对应的 set 集合,set 集合中存储的是依赖的副作用函数(此处用 set 可以实现副作用函数的去重)。为了方便理解,可以通过下图表示它们之间的关系:

Vue.js 3.0响应式原理剖析

WeakMap 中的对象都是弱引用,即垃圾回收机制不考虑 WeakMap 对该对象的引用。也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakMap 之中。

派发更新

派发通知发生在数据更新的阶段 ,由于我们用 Proxy 劫持了数据对象,所以当这个响应式对象属性更新的时候就会执行 set 函数。我们可以在修改数据触发 set 函数时做派发更新。

set: function set(target, key, value, receiver) {
   ...
   const oldValue = target[key]
   const result = Reflect.set(target, key, value, receiver) // 设置新值
   if (oldValue !== value) { // 新老值不一致
     trigger(target, key) // 派发更新
   }
   return result
 }

整个 set 函数最核心的部分就是执行 trigger 函数派发通知 ,我们先来看一下 trigger 函数的实现:

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key); // 获取 set 集合
  dep.forEach((effect) => {
    // if (effect !== activeEffect) {
      if (effect.scheduler) {
        effect.scheduler(); // 优先执行调度,computed 计算属性会用到
      } else {
        effect.run(); // 普通场景调用 run 方法
      }
    // }
  })
}

trigger 函数的实现也很简单,主要做了三件事情:

  1. 通过全局的 targetMap 拿到 target 对应的依赖集合 depsMap
  2. 根据 keydepsMap 中找到对应的 dep 集合。
  3. 遍历 dep 集合,如果副作用函数中有 scheduler 会优先执行,普通场景下会执行 run 方法。

所以每次 trigger 函数就是根据 targetkey ,从 depsMap 中找到相关的所有副作用函数遍历执行一遍。

总结: 在描述依赖收集和派发更新的过程中,我们都提到了一个词:副作用函数,那它又是什么呢?接下来我们来看一下副作用函数的庐山真面目。

副作用函数

介绍副作用函数前,我们先回顾一下响应式的原始需求,即我们修改了数据就能自动执行某个函数,举个简单的例子:

import { reactive } from 'vue'

const state = reactive({
  count: 0
})

function printCount() {
  console.log(state.count)
}
setTimeout(() => {
  state.count++
}, 1000)

printCount()

可以看到,这里我们定义了响应式对象 state,然后我们在 printCount 中访问了 state.count,我们希望 1 秒后更改 state.count 值的时候,能自动执行 printCount 。我们可以把 printCount 作为 count 的依赖收集起来,修改 state.count 时让 printCount 重新执行一下即可。

顺着这个思路,我们可以利用 高阶函数 的思想,对 printCount 做一层封装,如下

let activeEffect 

function wrapper(fn) {
  const wrapped = function(...args) {
    activeEffect = fn
    fn(...args)
  }
  return wrapped
}
const wrappedPrint = wrapper(printCount)
wrappedPrint()

这里 wrapper 本身也是一个函数,它接受 fn 作为参数,返回一个新的函数 wrapped。我们维护一个全局的 activeEffect,当 wrapped 执行的过程中,首先会把 activeEffect 指向 fn,然后执行 fn。执行 fn 的过程中会对 state.count 取值,此时会触发了 track 函数,由于此时 activeEffect 已经指向了 fn(即 printCount),因此 count 会把 printCount 作为依赖收集起来。

当我们去修改 state.count 值时,会触发 trigger 函数,count 会找到先前已经收集的依赖 printCount,然后重新执行该函数。

Vue.js 3.0 就是采用类似的做法,在它内部就有一个 effect 副作用函数,我们来看一下它的用法和实现。

import { reactive, effect } from Vue

const state = reactive({ name: 'vue3.0' })
effect(() => {
  console.log(state.name) // 上来会打印 'vue3.0' 1秒钟后再次打印 'name被修改了'
})

setTimeout(() => {
  state.name = 'name被修改了'
}, 1000)

特点: effect 接收函数作为参数,默认会执行该函数。当函数内部的响应式数据发生变化时, 会再次执行传入的回调函数。让我们看一下它的实现:

let activeEffect // 当前激活 activeEffect
function effect(fn, options = {}) { // options 支持传入 scheduler 选项,优先级更高
  ...
  const _effect = new ReactiveEffect(fn, options.scheduler);
  _effect.run(); // effect 函数初始化会执行 1 次
  ...
}

effect 函数主要做了2件事:

  1. 内部 new ReactiveEffect 得到 _effect 实例
  2. 调用 _effect.run 方法。

ReactiveEffect 函数的实现: 接收用户传入的 fnscheduler (调度) 作为参数,并且声明了 activedeps 实例属性,runstop 等实例方法。

const effectStack = []

class ReactiveEffect {
  active: boolean = true;
  deps = [];
  
  constructor(public fn, public scheduler) {}
  
  run() {
    if (!this.active) {
      return this.fn();
    }
    if (!effectStack.includes(this)) {
      try {
        effectStack.push(this)
        activeEffect = this;
        cleanup(this);
        return this.fn();
      } 
      finally {
        effectStack.pop()
        activeEffect = effectStack[effectStack.length-1]
      }
    }
  }
  
  stop() {
    if (this.active) {
      this.active = false;
    }
    cleanup(this);
  }
}

比较关键的在于 run 函数的实现。

  1. 首先会判断 _effect 的状态是不是 active,这其实是一种控制手段,允许在非 active 状态下直接执行原始函数 fn 并返回。当 activefalse 时,不会再做依赖收集,只会执行 fn 函数
  2. 接着判断 effectStack 中是否包含 _effect,如果没有就把 _effect 压入栈内。之前我们在 wrapper 高阶函数 中提到,只要设置 activeEffect = _effect 即可,那么这里为什么要设计一个 的结构呢?其实是考虑到 嵌套 effect 的场景:

effect 嵌套场景

import { reactive, effect } from 'vue' 

const state = reactive({ 
  num: 0, 
  num2: 0 
}) 

function printCount() { 
  effect(printCount2) 
  console.log('num:', state.num) 
} 

function printCount2() { 
  console.log('num2:', state.num2) 
} 
effect(printCount) 


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

我们每次执行 effect 函数时,如果仅仅把当前实例的 _effect 函数赋值给 activeEffect,那么针对这种嵌套场景,执行完 effect(printCount2) 后,activeEffect 还是 effect(printCount2) 返回的 _effect 函数,这样后续访问 state.num 的时候,依赖收集对应的 activeEffect 就不对了,此时我们修改 state.num 后执行的便不是 printCount 函数,而是 printCount2 函数,最终输出的结果如下:

Vue.js 3.0响应式原理剖析

而我们期望的结果应该如下:

Vue.js 3.0响应式原理剖析

因此针对嵌套 effect 的场景,我们不能简单地赋值 activeEffect,应该考虑到函数的执行本身就是一种 入栈出栈 操作。因此我们可以设计一个全局的 effectStack,这样每次执行 _effect.run 函数时就先把 _effect 入栈,然后 activeEffect 指向这个 _effect 实例,接着在 fn 执行完毕后出栈,把 activeEffect 指向 effectStack 最后一个元素,也就是最外层的 _effect 实例。

cleanup

这里还有一个细节,在执行 fn 函数前会执行 cleanup 函数清空 _effect 实例上存储的 deps 数组。这个 deps 是什么时候收集元素的呢。其实是在执行 track 函数的时候,除了收集当前激活的 activeEffect,还通过 activeEffect.deps.push(dep)dep 作为 activeEffect 的依赖,这样在 cleanup 的时候我们就可以找到当前activeEffect 对应的 dep 了,然后把 dep 从当前 activeEffect.deps 中删除。cleanup 函数的代码如下所示:

function cleanup(effect) {
  const deps = effect.deps;
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect);
  }
  deps.length = 0 // 把 effect.deps 清空
}

为什么需要 cleanup 呢?如果遇到这种场景:

<div id="app"></div>

<script>
  ...
  const state = reactive({ name: 'vue3.0', age: 2, flag: true })

  effect(() => {
    console.log('rerender')
    app.innerHTML = state.flag ? state.name : state.age
  })

  setTimeout(() => {
    state.flag = false
    setTimeout(() => {
      console.log("修改name, 原则上不更新")
      state.name = '滴滴'
    })
  }, 500)

假设没有 cleanup,在第一次渲染的时候,执行 effect 函数, 生成 _effect 实例指向 activeEffect。由于 state.flagtrue,此时会访问 state.name,所以 name、flag 会把 activeEffect 作为依赖收集起来。 然后 500 毫秒后,state.flag 变成了 false,由于修改了 state.flag 就会派发更新,找到之前收集的 activeEffect, 重新执行 activeEffect 的过程中,由于 state.flag 为假,此时会访问 state.ageage 会把 activeEffect 收集起来。当我们再次修改 state.name 的时候,由于修改了 state.name 就会派发通知,找到了 activeEffect 并执行,就又触发了 effect 函数的重新渲染。

但这个行为实际上并不符合预期,因为此时 state.flagfalse,视图的渲染只依赖于 state.age 的变动 所以对 state.name 的改动并不应该触发视图的重新渲染。

因此在 fn 执行之前,如果通过 cleanup 清理依赖,我们就可以删除之前 state.name 收集的依赖。这样当我们修改 state.name 时,由于已经没有依赖了就不会触发组件的重新渲染,符合预期。

Vue.js 3.0响应式原理剖析

stop

stop 函数会把 active 置为 false, 然后执行 cleanup 清空之前收集的依赖,基于此函数,我们可以暂停追踪。

理解 effect 函数是非常关键的,后面介绍的 computed组件渲染 都会用到它。

手动实现简版响应式

总结

本篇文章主要介绍了 Vue.js 3.0 响应式的实现原理,并对比了和 2.0 实现差异。依次介绍了什么时候依赖收集,什么时候派发更新,副作用函数的作用和设计原理。由于本篇文章重点在于阐明响应式核心流程,忽略了响应式 API 实现细节的介绍。因而在下一篇文章主要来介绍日常开发中常用的响应式 API 的实现细节。

转载自:https://juejin.cn/post/7142119932283600903
评论
请登录