普通视图

发现新文章,点击刷新页面。
昨天以前首页

初探 Vue 3响应式系统(四):Watch

作者 Hyyy
2025年2月20日 22:27

无论Vue2还是 Vue3 ,watch 无论在使用还是面试中都是当之无愧的高频,它让我们能够监听响应式数据的变化,并在变化时执行相应的回调函数,今天和大家一起学习下watch的内部实现。

1. watch 的基本用法

在开始解析源码之前,我们先回顾一下 watch 的基本用法。

watch 可以监听一个或多个响应式数据,并在它们发生变化时执行回调函数。

import { ref, watch } from 'vue';

const count = ref(0);

watch(count, (newValue, oldValue) => {
  console.log(`从 ${oldValue}${newValue}`);
});

count.value++; // 输出: 从 0 到 1

2. watch 的核心实现

2.1 watch 函数的定义

watch 函数的定义如下:

function watch(source, cb, options) {
  // 处理 source 和 cb
  // 创建 effect
  // 返回停止监听的函数
}

watch 函数接收三个参数:

  • source:要监听的数据源,可以是一个响应式对象、ref、或者一个 getter 函数。
  • cb:回调函数,当 source 发生变化时执行。
  • options:可选的配置项,比如 immediatedeep 等。

2.2 处理 source


watch 函数首先需要处理 source,将其转换为一个 getter 函数。这是因为 Vue3 的响应式系统是基于 effect 的,而 effect 需要一个 getter 函数来追踪依赖。

function watch(source, cb, options) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else if (isRef(source)) {
    getter = () => source.value;
  } else if (isReactive(source)) {
    getter = () => source;
    options.deep = true; // 默认深度监听
  } else {
    getter = () => {};
  }

  // 其他逻辑
}

在这个代码片段中,我们根据 source 的类型来决定如何生成 getter 函数:

  • 如果 source 是一个函数,直接将其作为 getter
  • 如果 source 是一个 ref,则 getter 返回 ref.value
  • 如果 source 是一个 reactive 对象,则 getter 返回该对象,并默认启用深度监听。

2.3 初始化环境

首先,watch需要为后续的操作做好准备。这包括定义一个用于存储清理函数的变量和创建一个job函数,这个函数将在数据变化时被调用。

接下来,watch会创建一个ReactiveEffect实例。这个实例是Vue3响应式系统的核心,它负责追踪source的依赖,并在依赖变化时执行job函数。

function watch(source, cb, options) {
    // ...上面的getter逻辑

    let cleanup; // 用于存储清理函数

    // 定义一个函数,用于注册清理函数
    const onInvalidate = (fn) => {
      cleanup = fn;
    };

    // 定义 job 函数,它会在依赖变化时执行
    const job = () => {
      if (cleanup) {
        cleanup(); // 如果存在清理函数,则先执行清理函数
      }
      const newValue = effect.run(); // 执行 effect 来获取新的值
      cb(newValue, oldValue, onInvalidate); // 调用回调函数
      oldValue = newValue; // 更新旧值
    };
    // 创建一个 ReactiveEffect 实例,当getter中依赖的数据变化,就会执行job ⭐️
    const effect = new ReactiveEffect(getter, job);

    // 首次执行 effect 来获取初始值 
    let oldValue = effect.run();
}

2.4 处理 immediate 选项

watch 还支持 immediate 选项,当 immediatetrue 时,回调函数会在 watch 创建时立即执行一次。

最后,watch会根据immediate选项决定是否立即执行job函数。同时,它返回一个函数,允许我们手动停止对source的监听。

if (options.immediate) {
  job();
} else {
  oldValue = effect.run();
}

在这个代码片段中,我们根据 immediate 选项来决定是否立即执行 job 函数。

2.5 返回停止监听的函数

最后,watch 函数返回一个停止监听的函数,调用这个函数可以停止对 source 的监听。

return () => {
  effect.stop();
};

这个函数通过调用 effect.stop() 来停止 effect 的运行,从而停止对 source 的监听。

2.6 完整代码

function watch(source, cb, options) {
    let getter;
    if (typeof source === 'function') {
      getter = source;
    } else if (isRef(source)) {
      getter = () => source.value;
    } else if (isReactive(source)) {
      getter = () => source;
      options.deep = true; // 默认深度监听
    } else {
      getter = () => {};
    }
  
    let cleanup; // 用于存储清理函数

    // 定义一个函数,用于注册清理函数
    const onInvalidate = (fn) => {
      cleanup = fn;
    };

    // 定义 job 函数,它会在依赖变化时执行
    const job = () => {
      if (cleanup) {
        cleanup(); // 如果存在清理函数,则先执行清理函数
      }
      const newValue = effect.run(); // 执行 effect 来获取新的值
      cb(newValue, oldValue, onInvalidate); // 调用回调函数
      oldValue = newValue; // 更新旧值
    };
    // 创建一个 ReactiveEffect 实例 
    const effect = new ReactiveEffect(getter, job);

    // 首次执行 effect 来获取初始值 
    let oldValue = effect.run();
    
    // 如果选项中设置了 immediate 为 true,则立即执行 job ⭐️新增
    if (options.immediate) { 
      job();
    } else {
      // 否则,再次执行 effect 来确保 oldValue 是响应式数据的当前值
      oldValue = effect.run();
    }

    // 返回一个函数,调用它可以停止 effect,从而停止对 source 的监听 ⭐️新增
    return () => {
      effect.stop();
    };
}

3. 代码测试

watch部分就自动中就用我们文章最下方的附录代码

3.1 测试普通 ref

import { ref, watch } from 'vue';

function watch(){
    // 此处为我们的watch代码
    // ...
}
const count = ref(0);


watch(count, (newValue, oldValue) => {
  console.log(`从 ${oldValue}${newValue}`);
});

count.value++; // 输出: 从 0 到 1

在这个例子中,watch 监听了 count 的变化,并在 count 的值发生变化时打印出新旧值。

3.2 测试 reactive 对象

import { reactive, watch } from 'vue';

function watch(){
    // 此处为我们的watch代码
    // ...
}

const state = reactive({ count: 0 });

watch(() => state.count, (newValue, oldValue) => {
  console.log(`从 ${oldValue}${newValue}`);
});

state.count++; // 输出: 从 0 到 1

在这个例子中,watch 监听了 state.count 的变化,并在 state.count 的值发生变化时打印出新旧值。

3.3 使用 immediate 选项

import { ref, watch } from 'vue';

function watch(){
    // 此处为我们的watch代码
    // ...
}

const count = ref(0);

watch(count, (newValue, oldValue) => {
  console.log(`从 ${oldValue}${newValue}`);
}, { immediate: true });

// 输出: 从 undefined 到 0
count.value++; // 输出: 从 0 到 1

在这个例子中,watch 在创建时立即执行了一次回调函数,输出了 count 的初始值。

3.4 使用 onInvalidate 清理副作用

import { ref, watch } from 'vue';

function watch(){
    // 此处为我们的watch代码
    // ...
}

const count = ref(0);

watch(count, (newValue, oldValue, onInvalidate) => {
  let expired = false;
  onInvalidate(() => {
    expired = true;
  });

  setTimeout(() => {
    if (!expired) {
      console.log(`从 ${oldValue}${newValue}`);
    }
  }, 1000);
});

count.value++; // 1秒后输出: 从 0 到 1
count.value++; // 不会输出,因为上一次的副作用被清理了

在这个例子中,我们使用 onInvalidate 来清理上一次的副作用,确保只有最新的回调函数会执行。

3. 总结

对于watch的解析就到这里咯,有什么问题的话,感谢指正!

Vue 3 响应式系统(三):Computed

作者 Hyyy
2025年2月19日 22:39

一、计算属性的必要性

假设我们有如下模板:

{{ user.firstName + ' ' + user.lastName }}

当多处复用这个表达式时:

  • 维护成本:表达式修改需全局搜索替换
  • 性能浪费:每次渲染重复计算相同结果
  • 逻辑隔离:业务逻辑侵入视图层

所以我们需要computed这个能自动追踪依赖,且最好能够缓存计算结果的东西

const name = computed(() => user.firstName + ' ' + user.lastName)

我们来看一下computed的三个核心特性:

  1. 惰性执行:不访问时不计算,访问时执行
  2. 值缓存:依赖未变化时复用结果
  3. 依赖追踪:自动建立响应式关联

接下来,我们来分析一下computed是如何写出来的

二: 从零实现计算属性

1. 幼崽形态

// 第一阶段:直接返回函数结果
function computed(fn) {
  return fn()
}
let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName); // 张三

现存问题:

正常的computed是用.value来访问值的,我们的用.value访问不到

function computed(fn) {
  return fn()
}

let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 报错,因为fullName没有value属性

2. 支持 .value 访问

我们要在获取.value属性的时候进行拦截

// 第二阶段:value属性返回函数结果
function computed(fn) {
  return { 
    get value() { return fn() } 
  }
}
let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 控制台:张三

现存问题:

在依赖的数据变化后,计算属性没有随之变化

// 第二阶段:value属性返回函数结果
function computed(fn) {
  return { 
    get value() { return fn() } 
  }
}
let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 控制台:张三
user.lastName = '四'
console.log(fullName.value); // 控制台:张三(预期是变成了张四)

可以看到,在lastName属性更改后,我们的fullName依然是第一次计算的结果,并没有变成预想中的“张四”

3. ### 实现响应式更新

这里我们需要加入effect, 对effect不熟悉的小伙伴可以看下上篇文章 # Vue 3 响应式系统(二):Effect

// 接入effect
function computed(fn) {
  const effectFn = effect(fn)
  
  return {
    get value() {
      return effectFn() // 通过effect建立依赖关系
    }
  }
}
let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 控制台:张三
user.lastName = '四'
console.log(fullName.value); // 控制台:张四(因为当依赖的数据变化时,会重新执行传给computed的函数,也就重新计算了)

简单说下为什么加了effect就可以,effect自动追踪函数内部访问的响应式数据(如 user.firstNameuser.lastName),并建立依赖关系。当这些响应式数据变化时,effect 会自动重新执行传入的函数,从而触发更新。

我们传给comptuedfncomputed会传给effect,这样当effect中检测到我们computed传给他的fn中依赖数据变化的时候,就会重新执行我们计算属性传给他的fn,也就重新计算了。

现存问题:

computed在没有访问的时候应该是不执行的,现在还没有访问,就已经执行过一次fn

function computed(fn) {
  // 省略 如上
}
let user = reactive({
  // 省略 如上
})
let fullName = computed(() => (){
  console.log('computed执行了')
  return user.firstName + user.lastName
})
// 未访问计算属性fullName,控制台就已经打印'computed执行了'

4. 惰性执行优化

这里我们通过effect的调度器,让effect变为首次不执行。

// 加入lazy: true
function computed(fn) {
  const effectFn = effect(fn, { 
    lazy: true, // 🚨 延迟执行
  })
  
  return {
    get value() {
      return effectFn() // 通过effect建立依赖关系
    }
  }
}

let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => (){
  console.log('computed执行了')
  return user.firstName + user.lastName
})
// 未访问fullName,控制台不会打印

现存问题:

computed应该是缓存的,但我们的computed即使依赖的数据没变化,每次访问也都会重新计算。

// 加入lazy: true
function computed(fn) {
  const effectFn = effect(fn, { 
    lazy: true, // 延迟执行
  })
  
  return {
    get value() {
      return effectFn() // 通过effect建立依赖关系
    }
  }
}

let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => (){
  console.log('computed执行了')
  return user.firstName + user.lastName
})
// 测试访问 fullName
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"

在例子中我们可以看到,每次访问fullName.value都会执行这个log语句,代表我们传给computed的函数被执行,也就是说每次访问,都在重新计算,这当然是与我们对comptued带缓存这个认知不符的,所以要怎么加入呢?

5. 值缓存机制

缓存如何实现,需要加入一个叫做dirty的标志位,代表当前是否为脏数据,如果脏数据了,我们则重新计算。

scheduler(调度器):用于控制副作用函数的执行时机

function computed(fn) {
  let value
  let dirty = true // 🆕 脏检查标志

  const effectFn = effect(fn, {
    lazy: true,
    scheduler() { 
      dirty = true
    }
  })
  return {
    get value() {
      if (dirty) { // 如果为脏数据,才重新计算。
        value = effectFn()
        dirty = false 
      }
      return value
    }
  }
}

只要重新运行effect,我们就将dirty变为true,只要dirtytrue,我们在获取的时候就重新获取数据。

反之,如果依赖的数据没有变化,也就不会重新运行effectdirty也就不会变为truedirty不为true代表数据不需要重新请求,我们就直接返回老value就好了。

看看效果:

function computed(fn) {
  let value
  let dirty = true // 🆕 脏检查标志

  const effectFn = effect(fn, {
    lazy: true,
    scheduler() { 
      dirty = true
    }
  })

  return {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }
}
let fullName = computed(() => (){
  console.log('computed执行了')
  return user.firstName + user.lastName
})
// 测试访问 fullName
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"
console.log(fullName.value); // 输出: "张三";
console.log(fullName.value); // 输出: "张三";
user.lastName = '四'
console.log(fullName.value); // 输出: "张四";打印: "computed执行了"
console.log(fullName.value); // 输出: "张四"

现存问题:

当计算属性之间存在依赖链时,也就是computed A引用computed B时,我们的实现会出现computed B变化后,computed A不能及时更新的问题

function computed(fn) {
  // ...如上
}
// 响应式数据
const data = reactive({ 
  a: 1 
})

// 创建计算属性
const double = computed(() => {
  return data.a * 2
})

const quadruple = computed(() => {
  return double.value * 2 // 依赖 double
})

// 初始访问
console.log(quadruple.value) // 期望输出: 4 ; 实际输出4

// 修改依赖的数据
data.a = 2

// 再次访问
console.log(quadruple.value) // 期望输出: 8 ; 实际输出4

在这个例子中:

  1. double 依赖于 data.a
  2. quadruple 依赖于 double.value

data.a 发生变化时:

  • double 会被标记为 dirty(因为 effectscheduler 会触发)。
  • 但是,由于 computed 中没有手动调用 triggerquadruple 并不知道 double 已经发生了变化。
  • 因此,quadruple 不会重新计算,即使 double 的值已经更新。

所以,我们应该完善computed自身的响应式,如何做呢,就是加入tracktrigger

6. 支持嵌套计算属性

可能有同学要问了,我computed中已经将传来的回调给effect了,effect中就会调用track了,为什么还要在computed中再次加入tracktrigger呢?

那是因为,computed既是观察者,也是被观察者。

我们现在仅仅做到了computed当观察者的一面,他依赖的数据变化时他会重新计算,但并没有做好他当“被观察者”的一面,也就是说他自身变化的时候他不会去通知别人,所以我们要加入tracktrigger来完善computed自身的依赖触发

track 用于依赖收集,trigger 用于触发更新

// 完整版实现
function computed(fn) {
  let value
  let dirty = true
  const deps = new Set() // 🆕 新增依赖收集器
  const effectFn = effect(fn, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true
        // 触发所有依赖该计算属性的副作用
        trigger(thisObj, 'value') 
      }
    }
  })
  const thisObj = {
    get value() {
      // 收集当前正在执行的副作用
      track(thisObj, 'value') 
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }
  return thisObj
}

加入后我们重新测试:

function computed(fn) {
  // ...如上
}
// 响应式数据
const data = reactive({ 
  a: 1 
})

// 创建计算属性
const double = computed(() => {
  return data.a * 2
})

const quadruple = computed(() => {
  return double.value * 2 // 依赖 double
})

// 初始访问
console.log(quadruple.value) // 期望输出: 4 ; 实际输出4

// 修改依赖的数据
data.a = 2

// 再次访问
console.log(quadruple.value) // 期望输出: 8 ; 实际输出8

加入了tracktriggercomputed 就可以既当好“观察者”又可以当好“被观察者”啦

总结

computed实现的内部细节就已经完成啦~

附录:完整实现代码

// 生产级computed实现(简化版)
function computed(getter) {
  let value, dirty = true
  let effectFn = effect(getter, {
    lazy: true,
    scheduler: () => {
      if (!dirty) {
        dirty = true
        trigger(obj, 'value')
      }
    }
  })
  const obj = {
    _isRef: true,
    get value() {
      track(obj, 'value')
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }

  return obj
}
❌
❌