Vue3响应式源码分析 - ref + ReactiveEffect篇

更新日期: 2022-07-01阅读: 983标签: 源码作者: 周小羊

vue3中,因为reactive创建的响应式对象是通过Proxy来实现的,所以传入数据不能为基础类型,所以 ref 对象是对reactive不支持的数据的一个补充。

在 ref 和 reactive 中还有一个重要的工作就是收集、触发依赖,那么依赖是什么呢?怎么收集触发?一起来看一下吧:

我们先来看一下 ref 的源码实现:

export function ref(value?: unknown) {
  return createRef(value, false)
}

export function shallowRef(value?: unknown) {
  return createRef(value, true)
}

const toReactive = (value) => isObject(value) ? reactive(value) : value;

function createRef(rawValue: unknown, shallow: boolean) {
  // 如果是ref则直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

class RefImpl<T> {
  private _value: T
  // 存放 raw 原始值
  private _rawValue: T

  // 存放依赖
  public dep?: Dep = undefined
  public readonly __v_isRef = true

  constructor(value: T, public readonly __v_isShallow: boolean) {
    // toRaw 拿到value的原始值
    this._rawValue = __v_isShallow ? value : toRaw(value)
    // 如果不是shallowRef,使用 reactive 转成响应式对象
    this._value = __v_isShallow ? value : toReactive(value)
  }

  // getter拦截器
  get value() {
    // 收集依赖
    trackRefValue(this)
    return this._value
  }

  // setter拦截器
  set value(newVal) {
    // 如果是需要深度响应的则获取 入参的raw
    newVal = this.__v_isShallow ? newVal : toRaw(newVal)
    // 新值与旧值是否改变
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      // 更新value 如果是深入创建并且是对象的话 还需要转化为reactive代理
      this._value = this.__v_isShallow ? newVal : toReactive(newVal)
      // 触发依赖
      triggerRefValue(this, newVal)
    }
  }
}

RefImpl 采用ES6类的写法,包含 get 、 set ,其实大家可以用 webpack 等打包工具打包成 ES5 的代码, 发现其实就是 Object.defineProperty 。

可以看到, shallowRef 和 ref 都调用了 createRef ,只是传入的参数不同。当使用 shallowRef 时,不会调用 toReactive 去将对象转换为响应式,由此可见,shallowRef对象只支持对value值的响应式,ref对象支持对value深度响应式,ref.value.a.b.c中的修改都能被拦截,举个:chestnut::

<template>
    <p>{{ refData.a }}</p>
    <p>{{ shallowRefData.a }}</p>
    <button @click="handleChange">change</button>
</template>


let refData = ref({
  a: 'ref'
})
let shallowRefData = shallowRef({
  a: 'shallowRef'
})

const handleChange = () => {
  refData.value.a = "ref1"
  shallowRefData.value.a = "shallowRef1"
}

当我们点击按钮修改数据后,界面上的 refData.a 的值会变为 ref1 ,而 shallowRefData.a 应该会不发生变化,但其实在这个例子里, shallowRefData.a 在视图上也会发生变化的:dog:,因为修改 refData.a 时候,触发了setter函数,内会去调用 triggerRefValue(this, newVal) 从而触发了 视图更新 ,所以shallow的最新数据也会被更新到了视图上 (把 refData.value.a = "ref1" 去掉它就不会变了)。

在 ref 里最关键的还是 trackRefValue 和 triggerRefValue ,负责收集触发依赖。

如何收集依赖:

function trackRefValue(ref) {
    // 判断是否需要收集依赖
    // shouldTrack 全局变量,代表当前是否需要 track 收集依赖
    // activeEffect 全局变量,代表当前的副作用对象 ReactiveEffect
    if (shouldTrack && activeEffect) {
        ref = toRaw(ref);
        {
            // 如果没有 dep 属性,则初始化 dep,dep 是一个 Set<ReactiveEffect>,存储副作用函数
            // trackEffects 收集依赖
            trackEffects(ref.dep || (ref.dep = createDep()), {
                target: ref,
                type: "get",
                key: 'value'
            });
        }
    }
}

为什么要判断 shouldTrack 和 activeEffect ,因为在Vue3中有些时候不需要收集依赖:

  • 当没有 effect 包裹时,比如定义了一个ref变量,但没有任何地方使用到,这时候就没有依赖,activeEffect 为 undefined,就不需要收集依赖了
  • 比如在数组的一些会改变自身长度的方法里,也不应该收集依赖,容易造成死循环,此时 shouldTrack 为 false

*依赖是什么?

ref.dep 用于储存 依赖 (副作用对象),ref 被修改时就会触发,那么依赖是什么呢?依赖就是 ReactiveEffect :

为什么要收集依赖(副作用对象),因为在Vue3中,一个响应式变量的变化,往往会触发一些副作用,比如视图更新、计算属性变化等等,需要在响应式变量变化时去触发其它一些副作用函数。

在我看来 ReactiveEffect 其实就和 Vue2 中的 Watcher 的作用差不多,我之前写的 《Vue源码学习-响应式原理》 里做过说明:


class ReactiveEffect {
    constructor(fn, scheduler = null, scope) {
        // 传入一个副作用函数
        this.fn = fn;
        this.scheduler = scheduler;
        this.active = true;
        // 存储 Dep 对象,如上面的 ref.dep
        // 用于在触发依赖后, ref.dep.delete(effect),双向删除依赖)
        this.deps = [];
        this.parent = undefined;
        recordEffectScope(this, scope);
    }
    run() {
        // 如果当前effect已经被stop
        if (!this.active) {
            return this.fn();
        }
        let parent = activeEffect;
        let lastShouldTrack = shouldTrack;
        while (parent) {
            if (parent === this) {
                return;
            }
            parent = parent.parent;
        }
        try {
            // 保存上一个 activeEffect
            this.parent = activeEffect;
            activeEffect = this;
            shouldTrack = true;
            // trackOpBit: 根据深度生成 trackOpBit
            trackOpBit = 1 << ++effectTrackDepth;
            // 如果不超过最大嵌套深度,使用优化方案
            if (effectTrackDepth <= maxMarkerBits) {
                // 标记所有的 dep 为 was
                initDepMarkers(this);
            }
            // 否则使用降级方案
            else {
                cleanupEffect(this);
            }
            // 执行过程中重新收集依赖标记新的 dep 为 new
            return this.fn();
        }
        finally {
            if (effectTrackDepth <= maxMarkerBits) {
                // 优化方案:删除失效的依赖
                finalizeDepMarkers(this);
            }
            // 嵌套深度自 + 重置操作的位数
            trackOpBit = 1 << --effectTrackDepth;
            // 恢复上一个 activeEffect
            activeEffect = this.parent;
            shouldTrack = lastShouldTrack;
            this.parent = undefined;
            if (this.deferStop) {
                this.stop();
            }
        }
    }
}

ReactiveEffect 是副作用对象,它就是被收集依赖的实际对象,一个响应式变量可以有多个依赖,其中最主要的就是 run 方法,里面有两套方案,当 effect 嵌套次数不超过最大嵌套次数的时候,使用优化方案,否则使用降级方案。

降级方案:

function cleanupEffect(effect) {
    const { deps } = effect;
    if (deps.length) {
        for (let i = 0; i < deps.length; i++) {
            // 从 ref.dep 中删除 ReactiveEffect
            deps[i].delete(effect);
        }
        deps.length = 0;
    }
}

这个很简单,删除全部依赖,然后重新收集。在各个 dep 中,删除该 ReactiveEffect 对象,然后执行 this.fn() (副作用函数) 时,当获取响应式变量触发 getter 时,又会重新收集依赖。之所以要先删除然后重新收集,是因为随着响应式变量的变化,收集到的依赖前后可能不一样。

const toggle = ref(false)
const visible = ref('show')
effect(() = {
  if (toggle.value) {
    console.log(visible.value)
  } else {
    console.log('xxxxxxxxxxx')
  }
})
toggle.value = true
  • 当 toggle 为 true 时,toggle、visible 都能收集到依赖
  • 当 toggle 为 false 时,只有visible 可以收集到依赖

优化方案:

全部删除,再重新收集,明显太消耗性能了,很多依赖其实是不需要被删除的,所以优化方案的做法是:

// 响应式变量上都有一个 dep 用来保存依赖
const createDep = (effects) => {
    const dep = new Set(effects);
    dep.w = 0;
    dep.n = 0;
    return dep;
};
  1. 执行副作用函数前,给 ReactiveEffect 依赖的响应式变量 ,加上 w(was的意思) 标记。
  2. 执行 this.fn(),track 重新收集依赖时,给 ReactiveEffect 的每个依赖,加上 n(new的意思) 标记。
  3. 最后,对有 w 但是没有 n 的依赖进行删除。

其实就是一个筛选的过程,我们现在来第一步,如何加上 was 标记:

// 在 ReactiveEffect 的 run 方法里
if (effectTrackDepth <= maxMarkerBits) {
    initDepMarkers(this);
}

const initDepMarkers = ({ deps }) => {
    if (deps.length) {
        for (let i = 0; i < deps.length; i++) {
            deps[i].w |= trackOpBit;
        }
    }
};

这里使用了位运算,快捷高效。trackOpBit是什么呢?代表当前嵌套深度 (effect可以嵌套) ,在Vue3中有一个全局变量 effectTrackDepth

// 全局变量 嵌套深度 
let effectTrackDepth = 0;

// 在 ReactiveEffect 的 run 方法里
// 每次执行 effect 副作用函数前,全局变量嵌套深度会自增1
trackOpBit = 1 << ++effectTrackDepth

// 执行完副作用函数后会自减
trackOpBit = 1 << --effectTrackDepth;

当深度为 1 时,trackOpBit为 2(二进制:00000010),这样执行 deps[i].w |= trackOpBit 时,操作的是第二位,所以第一位是用不到的。

为什么Vue3中嵌套深度最大是 30 ?

1 << 30
// 0100 0000 0000 0000 0000 0000 0000 0000
// 1073741824

1 << 31
// 1000 0000 0000 0000 0000 0000 0000 0000
// -2147483648 溢出

因为js中位运算是以32位带符号的整数进行运算的,最左边一位是符号位,所以可用的正数最多只能到30位。

可以看到,在执行副作用函数之前,使用 deps[i].w |= trackOpBit ,对依赖在不同深度是否被依赖( w )进行标记,然后执行 this.fn() ,重新收集依赖,上面说到收集依赖调用 trackRefValue 方法,该方法内会调用 trackEffects :

function trackEffects(dep, debuggerEventExtraInfo) {
    let shouldTrack = false;
    if (effectTrackDepth <= maxMarkerBits) {
        // 查看是否记录过当前依赖
        if (!newTracked(dep)) {
            dep.n |= trackOpBit;
            // 如果 w 在当前深度有值,说明effect之前已经收集过
            // 不是新增依赖,不需要再次收集
            shouldTrack = !wasTracked(dep);
        }
    }
    else {
        shouldTrack = !dep.has(activeEffect);
    }
    if (shouldTrack) {
        // dep添加当前正在使用的effect
        dep.add(activeEffect);
         // effect的deps也记录当前dep 双向引用
        activeEffect.deps.push(dep);
    }
}

可以看到再重新收集依赖的时候,使用 dep.n |= trackOpBit 对依赖在不同深度是否被依赖( n )进行标记,这里还用到两个工具函数:

const wasTracked = (dep) => (dep.w & trackOpBit) > 0;
const newTracked = (dep) => (dep.n & trackOpBit) > 0;

使用 wasTracked 和 newTracked,判断 dep 是否在当前深度被标记。比如判断依赖在深度 1 时 (trackOpBit第二位是1) 是否被标记,采用按位与:


最后,如果已经超过最大深度,因为采用降级方案,是全部删除然后重新收集的,所以肯定是最新的,所以只需要把 trackOpBit 恢复,恢复上一个 activeEffect:

finally {
    if (effectTrackDepth <= maxMarkerBits) {
        // 优化方案:删除失效的依赖
        finalizeDepMarkers(this);
    }
    trackOpBit = 1 << --effectTrackDepth;
    // 恢复上一个 activeEffect
    activeEffect = this.parent;
    shouldTrack = lastShouldTrack;
    this.parent = undefined;
    if (this.deferStop) {
        this.stop();
    }
}

如果没超过最大深度,就像之前说的把失效的依赖删除掉,然后更新一下deps的顺序:

const finalizeDepMarkers = (effect) => {
    const { deps } = effect;
    if (deps.length) {
        let ptr = 0;
        for (let i = 0; i < deps.length; i++) {
            const dep = deps[i];
            // 把有 w 没有 n 的删除
            if (wasTracked(dep) && !newTracked(dep)) {
                dep.delete(effect);
            }
            else {
                // 更新deps,因为有的可能会被删掉
                // 所以要把前面空的补上,用 ptr 单独控制下标 
                deps[ptr++] = dep;
            }
            // 与非,恢复到进入时的状态
            dep.w &= ~trackOpBit;
            dep.n &= ~trackOpBit;
        }
        deps.length = ptr;
    }
};

举个简单的:chestnut:,理解起来可能简单点,有两个组件,一个父组件,一个子组件,子组件接收父组件传递的 toggle 参数显示在界面上, toggle 还控制着 visible 的显示,点击按钮切换 toggle 的值:

// Parent
<script setup lang="ts">
const toggle = ref(true)
const visible = ref('show')

const handleChange = () => {
  toggle.value = false
}
</script>

<template>
  <div>
    <p v-if="toggle">{{ visible }}</p>
    <p v-else>xxxxxxxxxxx</p>
    <button @click="handleChange">change</button>
    <Child :toggle="toggle" />
  </div>
</template>
// Child
<script setup lang="ts">
const props = defineProps({
  toggle: {
    type: Boolean,
  },
});
</script>

<template>
  <p>{{ toggle }}</p>
</template>

第一次渲染,因为toggle 默认为 true,我们可以收集到 toggle 、 visible 的依赖,

  1. Parent 组件, 执行 run 方法中的 initDepMarkers 方法,首次进入,还未收集依赖, ReactiveEffect 中 deps 长度为0,跳过。
  2. 执行 run 方法中的 this.fn ,重新收集依赖,触发 trackEffects:

    • toggle 的 dep = {n: 2, w: 0} , shouldTrack 为 true,收集依赖。
    • visible 的 dep = {n: 2, w: 0} , shouldTrack 为 true,收集依赖。
  3. 进入 Child 组件,执行 run 方法中的 initDepMarkers 方法,首次进入,还为收集依赖,deps长度为0,跳过。
  4. 执行 run 方法中的 this.fn ,重新收集依赖,触发 trackEffects:

    • toggle 的 dep = {n: 4, w: 0} , shouldTrack 为 true,收集依赖。

这样首次进入页面的收集依赖就结束了,然后我们点击按钮,把 toggle 改为 false:

  1. Parent 组件: 执行 run 方法中的 initDepMarkers 方法,之前在 Parent 组件里收集到了两个变量的依赖,所以将他们 w 标记:

    dep = {n: 0, w: 2}
    dep = {n: 0, w: 2}
    
  2. 执行 run 方法中的 this.fn ,重新收集依赖,触发 trackEffects:

    • toggle 的 dep = {n: 2, w: 2} , shouldTrack 为 false, 不用 收集依赖。
    • visible 不显示了 ,所以没有重新收集到,还是 {n: 0, w: 2} 。
  3. 进入 Child 组件,执行 run 方法中的 initDepMarkers 方法,之前 收集过 toggle 依赖了,将 toggle 的 w 做标记,toggle 的 dep = {n: 0, w: 4} 。
  4. 执行 run 方法中的 this.fn ,重新收集依赖,触发 trackEffects:

    • toggle 的 dep = {n: 4, w: 4} , shouldTrack 为 false,不用收集依赖。

最后发现 visible 有 w 没有 n ,在 finalizeDepMarkers 中删除掉失效依赖。

如何触发依赖:

在一开始讲到的 ref 源码里,可以看到在 setter 时会调用 triggerRefValue 触发依赖:

function triggerRefValue(ref, newVal) {
    ref = toRaw(ref);
    if (ref.dep) {
        {
            triggerEffects(ref.dep, {
                target: ref,
                type: "set",
                key: 'value',
                newValue: newVal
            });
        }
    }
}

function triggerEffects(
  dep: Dep | ReactiveEffect[]
) {
  // 循环去取每个依赖的副作用对象 ReactiveEffect
  for (const effect of isArray(dep) ? dep : [...dep]) {
    // effect !== activeEffect 防止递归,造成死循环
    if (effect !== activeEffect || effect.allowRecurse) {
      // effect.scheduler可以先不管,ref 和 reactive 都没有
      if (effect.scheduler) {
        effect.scheduler()
      } else {
        // 执行 effect 的副作用函数
        effect.run()
      }
    }
  }
}

触发依赖最终的目的其实就是去执行 依赖 上 每个的副作用对象 的 副作用函数 ,这里的副作用函数可能是执行更新视图、watch数据监听、计算属性等。

我个人再看源码的时候还遇到了一个问题,不知道大家遇到没有(我看的代码版本算是比较新v3.2.37),一开始我也是上网看一些源码的解析文章,看到好多讲解 effect 这个函数的,先来看看这个方法的源码:

function effect(fn, options) {
    if (fn.effect) {
        fn = fn.effect.fn;
    }
    const _effect = new ReactiveEffect(fn);
    if (options) {
        extend(_effect, options);
        if (options.scope)
            recordEffectScope(_effect, options.scope);
    }
    if (!options || !options.lazy) {
        _effect.run();
    }
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    // 返回一个包装后的函数,执行收集依赖
    return runner;
}

这个函数看上去挺简单的,创建一个 ReactiveEffect 副作用对象,将用户传入的参数附加到对象上,然后调用 run 方法收集依赖,如果有 lazy 配置不会自动去收集依赖,用户主动执行 effect 包装后的函数,也能够正确的收集依赖。

但我找了一圈,发现源码里一个地方都没调用,于是我就在想是不是以前用到过,现在去掉了,去commit记录里找了一圈,还真找到了:


这次更新把 ReactiveEffect 改为用类来实现,避免不必要时也创建 effect runner ,节省了17%的内存等。

原来的 effect 方法包括了现在的 ReactiveEffect ,在视图更新渲染、watch等地方都直接引用了这个方法,但更新后都是直接 new ReactiveEffect ,然后去触发 run 方法,不走 effect 了,可以说现在的 ReactiveEffect 类就是之前的 effect 方法 。

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  const effect = createReactiveEffect(fn, options)
  return effect
}

let uid = 0

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  const effect = function reactiveEffect(): unknown {
    if (!effect.active) {
      return fn()
    }
    if (!effectStack.includes(effect)) {
      cleanup(effect)
      try {
        enableTracking()
        effectStack.push(effect)
        activeEffect = effect
        return fn()
} finally {
effectStack.pop() resetTracking() const n = effectStack.length activeEffect = n > 0 ? effectStack[n - 1] : undefined } } } as ReactiveEffect effect.id = uid++ effect.allowRecurse = !!options.allowRecurse effect._isEffect = true effect.active = true effect.raw = fn effect.deps = [] effect.options = options return effect }

结尾

我是周小羊,一个前端萌新,写文章是为了记录自己日常工作遇到的问题和学习的内容,提升自己,如果您觉得本文对你有用的话,麻烦点个赞鼓励一下哟~

来自:https://segmentfault.com/a/1190000042054691

链接: https://fly63.com/article/detial/11842

Js中的 forEach 源码

在日常 Coding 中,码农们肯定少不了对数组的操作,其中很常用的一个操作就是对数组进行遍历,查看数组中的元素,然后一顿操作猛如虎。今天暂且简单地说说在 JavaScript 中 forEach。

微信小程序代码源码案例大全

克隆项目代码到本地(git应该都要会哈,现在源码几乎都会放github上,会git才方便,不会的可以自学一下哦,不会的也没关系,gitHub上也提供直接下载的链接);打开微信开发者工具;

Node 集群源码初探

随着这些模块逐渐完善, Nodejs 在服务端的使用场景也越来越丰富,如果你仅仅是因为JS 这个后缀而注意到它的话, 那么我希望你能暂停脚步,好好了解一下这门年轻的语言,相信它会给你带来惊喜

Vue源码之实例方法

在 Vue 内部,有一段这样的代码:上面5个函数的作用是在Vue的原型上面挂载方法。initMixin 函数;可以看到在 initMixin 方法中,实现了一系列的初始化操作,包括生命周期流程以及响应式系统流程的启动

vue源码解析:nextTick

nextTick的使用:vue中dom的更像并不是实时的,当数据改变后,vue会把渲染watcher添加到异步队列,异步执行,同步代码执行完成后再统一修改dom,我们看下面的代码。

React源码解析之ReactDOM.render()

React更新的方式有三种:(1)ReactDOM.render() || hydrate(ReactDOMServer渲染)(2)setState(3)forceUpdate;接下来,我们就来看下ReactDOM.render()源码

React源码解析之ExpirationTime

在React中,为防止某个update因为优先级的原因一直被打断而未能执行。React会设置一个ExpirationTime,当时间到了ExpirationTime的时候,如果某个update还未执行的话,React将会强制执行该update,这就是ExpirationTime的作用。

扒开V8引擎的源码,我找到了你们想要的前端算法

算法对于前端工程师来说总有一层神秘色彩,这篇文章通过解读V8源码,带你探索 Array.prototype.sort 函数下的算法实现。来,先把你用过的和听说过的排序算法都列出来:

jQuery源码之extend的实现

extend是jQuery中一个比较核心的代码,如果有查看jQuery的源码的话,就会发现jQuery在多处调用了extend方法。作用:对任意对象进行扩;’扩展某个实例对象

vuex源码:state及strict属性

state也就是vuex里的值,也即是整个vuex的状态,而strict和state的设置有关,如果设置strict为true,那么不能直接修改state里的值,只能通过mutation来设置

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!