Skip to content

响应式

副作用

副作用就是,当调用函数的时候会对外部产生影响,

js
// 全局变量
let val = 1

function effect() {
  val = 2 // 修改全局变量,产生副作用
}

响应式数据

副作用函数中读取了某个对象的属性,当对象属性值发生变化时,我们希望副作用函数自动重新执行,那么对象就是响应式数据。

js
const obj = { text: 'hello world' }
function effect() {
  // effect 函数的执行会读取 obj.text
  document.body.innerText = obj.text
}
obj.text = 'hello vue3' // 修改 obj.text 的值,同时希望副作用函数会重新执行

基本实现

拦截一个对象属性的读取和设置操作,读取时记录副作用函数,设置时执行副作用函数。在 ES2015 之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue.js 3 所采用的方式。

js
// 存储副作用函数的桶
const bucket = new Set()

// 原始数据
const data = { text: 'hello world' }

// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 effect 添加到存储副作用函数的桶中
    bucket.add(effect)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    bucket.forEach(fn => fn())
    // 返回 true 代表设置操作成功
    return true
  }
})

// 副作用函数
function effect() {
  document.body.innerText = obj.text
}
// 执行副作用函数,触发读取
effect()
// 1 秒后修改响应式数据
setTimeout(() => {
  obj.text = 'hello vue3'
}, 1000)

优化 - 注册副作用函数

上面代码硬编码了副作用函数的名字(effect),导致一旦副作用函数的名字不叫 effect,那么这段代码就不能正确地工作了。而我们希望的是,哪怕副作用函数是一个匿名函数,也能够被正确地收集到“桶”中。

js
// 用一个全局变量存储被注册的副作用函数
let activeEffect

// effect 函数用于注册副作用函数
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
  activeEffect = fn
  // 执行副作用函数
  fn()
}

// 一个匿名的副作用函数
effect(() => (document.body.innerText = obj.text))

然后修改 get/set 拦截器。

js
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    activeEffect && bucket.add(activeEffect) 
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    bucket.forEach(fn => fn())
    // 返回 true 代表设置操作成功
    return true
  }
})

优化 - 分组存储副作用函数

无论读取的是哪一个属性,都会把副作用函数收集到“桶”里;当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。

js
effect(function effectFn() {
  document.body.innerText = obj.text
})

在这段代码中存在三个角色:

  • 被操作(读取)的代理对象 obj;
  • 被操作(读取)的字段名 text;
  • 使用 effect 函数注册的副作用函数 effectFn。

二级分组存储。

txt
target
    └── key
        └── effectFn
        └── ...
    └── key
        └── ...
target
    └── key
        └── effectFn
        └── ...
    └── key
        └── ...

使用 WeakMap 代替 Set 作为桶的数据结构。WeakMap 存储 target -> Map,Map 存储 key -> Set,Set 存储副作用函数 effectFn。

为什么使用 WeakMap,而不是 Map?

WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。

js
// 存储副作用函数的桶
const bucket = new WeakMap()

然后修改 get/set 拦截器。

js
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 没有 activeEffect,直接 return
    if (!activeEffect) return target[key]
    // 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
    let depsMap = bucket.get(target)
    // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
    // 里面存储着所有与当前 key 相关联的副作用函数:effects
    let deps = depsMap.get(key)
    // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
    if (!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    // 最后将当前激活的副作用函数添加到“桶”里
    deps.add(activeEffect)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 根据 target 从桶中取得 depsMap,它是 key --> effects
    const depsMap = bucket.get(target)
    if (!depsMap) return
    // 根据 key 取得所有副作用函数 effects
    const effects = depsMap.get(key)
    // 执行副作用函数
    effects && effects.forEach(fn => fn())
  }
})

封装 get 中收集步骤为 track 函数。

js
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
  // 没有 activeEffect,直接 return
  if (!activeEffect) return
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
}

封装 set 中重新执行为 trigger 函数。

js
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}

然后修改 get/set 拦截器,并将创建代理封装为 reactive 函数。

js
function reactive(data) {
  new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
      // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
      track(target, key)
      // 返回属性值
      return target[key]
    },
    // 拦截设置操作
    set(target, key, newVal) {
      // 设置属性值
      target[key] = newVal
      // 把副作用函数从桶里取出并执行
      trigger(target, key)
    }
  })
}

优化 - 清除旧依赖

在一些条件执行过程中,有些值没有使用到,但其依赖函数依然会执行。

js
const obj = reactive({ text: 'hello world', ok: true })
effect(() => {
  console.log('执行')
  document.body.innerText = obj.ok ? obj.text : '张三'
})
obj.ok = false
obj.text = '张三'

以上代码在更改 obj.ok 属性后,副作用函数执行一次,此时 obj.text 在副作用函数中永远不会被访问,在我们 obj.text 后副作用函数也不应该执行,但是实际上当 obj.text 被修改后,副作用函数还是会执行一次。这时因为 obj.text 遗留了上次收集的依赖函数。

首次依赖收集:

obj
    └── ok
        └── effectFn
    └── text
        └── effectFn

副作用函数在执行后会重新收集依赖,所以我们要做的就是在副作用函数执行前清除掉对应的依赖。

在注册副作用函数的时候,添加一个 deps 数组,用于存储依赖追踪时使用的 set 结构。在执行前将遍历 deps 将当前副作用函数从 set 中删除,最后再清空 deps 数组。

js
function effect(fn) {
  // 包裹一层函数,用于做一些自定义操作
  function effectFn() {
    // 清空依赖
    for (const dep of effectFn.deps) {
      dep.delete(effectFn)
    }
    effectFn.deps.length = 0

    activeEffect = effectFn
    fn()
  }

  // 添加一个数组
  effectFn.deps = []
  effectFn()
}

添加依赖时,将 set 添加到 deps 中。

js
function track(target, key) {
  let depMap = bucket.get(target)
  if (!depMap) {
    bucket.set(target, (depMap = new Map()))
  }

  let deps = depMap.get(key)
  if (!deps) {
    depMap.set(key, (deps = new Set()))
  }

  deps.add(activeEffect)

  activeEffect.deps.push(deps) 
}

优化 - 嵌套依赖

当副作用函数发生嵌套时,例如:

js
const obj = reactive({ text: 'hello world', ok: true })
effect(() => {
  console.log('外层执行')
  effect(() => {
    console.log('内层执行')
    obj.ok
  })
  obj.text
})

我们期望当修改 ok 时, 内层执行 将被打印,当修改 text 时,打印 外层执行,但实际的情况是修改 text 打印的是 内层执行。这时因为外层副作用函数在执行期间 activeEffect 被内层副作用函数修改,并没有还原,当访问 obj.text 收集到的依赖是内层副作用函数。

这里需要引入栈结构来解决这个问题,当副作用函数开始执行时,压栈,执行完毕后弹出,正在执行的副作用函数始终与栈顶函数相同。

js
// 使用数组来模拟栈
const effectStack = [] 

// ...

function effect(fn) {
  function effectFn() {
    for (const dep of effectFn.deps) {
      dep.delete(effectFn)
    }
    effectFn.deps.length = 0

    // 使用数组模拟栈,子函数开始运行时,压入栈顶,运行结束后弹出,activeEffect 始终指向栈顶函数。
    activeEffect = effectFn 
    effectStack.push(effectFn) 
    fn()
    effectStack.pop(effectFn) 
    activeEffect = effectStack[effectStack.length - 1] 
  }
  effectFn.deps = []
  effectFn()
}

优化 - 死循环

目前代码在副作用函数内对同一个属性进行读取和设置会发生死循环。

js
const obj = reactive({ text: 'hello world', ok: true })
effect(() => {
  console.log('执行')
  obj.text = obj.text + '1'
})

以上代码一直打印 执行,最终导致栈溢出报错。因为在读取 obj.text 时,添加了依赖,在设置 obj.text 执行了依赖,注意此时副作用并没有执行完毕,又开始新一轮执行,如此循环下去。

解决这个问很简单,只要在触发执行时判断一下即可。

js
function trigger(target, key) {
  let depMap = bucket.get(target)
  if (!depMap) return

  let deps = depMap.get(key)
  if (!deps) return
  ;[...deps].forEach(fn => {
    // 如果此时fn正在执行则跳过
    if (fn === activeEffect) return
    fn()
  })
}

优化 - 增加调度器

在注册副作用函数时增加一个配置,把重新执行副作用函数时机交给外部处理。

js
function effect(fn, options = {}) {
  function effectFn() {
    for (const dep of effectFn.deps) {
      dep.delete(effectFn)
    }
    effectFn.deps.length = 0

    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop(effectFn)
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.options = options 
  effectFn.deps = []
  effectFn()
}

修改 trigger 调用副作用函数逻辑。

js
function trigger(target, key) {
  let depMap = bucket.get(target)
  if (!depMap) return

  let deps = depMap.get(key)
  if (!deps) return
  ;[...deps].forEach(fn => {
    // 如果此时fn正在执行则跳过
    if (fn === activeEffect) return

    if (fn.options.scheduler) {
      fn.options.scheduler(fn) 
    } else {
      fn()
    } 
  })
}

此时,在注册副作用函数时,就可以传入 scheduler 来自由决定副作用函数执行的时机。

js
const obj = reactive({ text: 'hello world', ok: true })
effect(
  () => {
    console.log('执行')
    obj.text = obj.text + '1'
  },
  {
    scheduler(fn) {
      // fn 就是注册的副作用函数
      fn()
    }
  }
)
obj.ok = false

提示

之后的 computed 和 watch 都是基于调度器实现的。

优化 - 触发队列

目前当我们修改一个响应式数据时,副作用函数就会执行一次,修改 5 次,那么副作用函数就会执行 5 次。

js
const obj = reactive({ text: 'hello world', ok: true })
effect(() => {
  console.log('执行')
  obj.text
})
obj.text = '1'
obj.text = '2'
obj.text = '3'
obj.text = '4'
obj.text = '5'

以上代码会连续打印 5 次 执行,这其实是没必要的,可以使用异步队列来优化它,让其在一定时间内修改响应式数据,副作用函数只执行一次。

一个异步队列 和 一个标识队列是否在运行中:

js
// 使用 set 结构自动去重
const jobQueue = new Set()

// 异步执行
const p = Promise.resolve()

// 一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob() {
  if (isFlushing) return

  // 异步更新已注册
  isFlushing = true

  // 异步执行队列中任务
  p.then(() => {
    jobQueue.forEach(fn => fn())
  }).finally(() => {
    // 执行完毕恢复标识
    isFlushing = false

    // 清空队列
    jobQueue.clear()
  })
}

使用调度器来将需要执行的副作用函数添加到队列中。

js
effect(
  () => {
    console.log('执行')
    obj.text
  },
  {
    scheduler(fn) {
      // 添加到队列
      jobQueue.add(fn)
      // 执行
      flushJob()
    }
  }
)

再次执行之前的代码,连续修改 5 次响应式数据后,控制台只会打印 1 次。

优化 - 副作用函数首次执行

目前副作用函数都是在注册时自动执行,有时候我们想手动调用执行,比如说 computed 属性,当不访问它时,不需要执行副作用函数。

在注册副作用函数增加 lazy 配置,并返回注册的副作用函数。

JS
function effect(fn, options = {}) {
  function effectFn() {
    for (const dep of effectFn.deps) {
      dep.delete(effectFn)
    }
    effectFn.deps.length = 0

    activeEffect = effectFn
    effectStack.push(effectFn)
    const result = fn()
    effectStack.pop(effectFn)
    activeEffect = effectStack[effectStack.length - 1]
    return result
  }
  effectFn.options = options
  effectFn.deps = []

  if (options.lazy) { 
    return effectFn 
  } 

  effectFn()
}

此时可以获取到注册的副作用函数,并手动执行它。

js
const obj = reactive({ text: 'hello world', ok: true })

effectFn = effect(() => console.log(obj.text), { lazy: true })

effectFn()

计算属性 computed

计算属性的特性:

  1. 访问才执行;
  2. 数据缓存;
  3. 依赖数据变化自动更新;

首先访问才执行,那么一定是用到了 getter 拦截器,在拦截器中执行副作用函数,其次数据缓存,每次执行结果都会被保存下来,最后依赖数据变化会自动刷新,数据变化后使用调度器处理,在调度器中标记数据需要更新。

js
function computed(getter) {
  // 获取注册的副作用函数
  const effectFn = effect(getter, {
    scheduler(fn) {
      // 调度器,数据变化了
      dirty = true
    },
    lazy: true
  })

  // 缓存结果
  let value

  // 依赖数据是否变化
  let dirty = true

  const obj = {
    get value() {
      // 数据变化时需要重新执行
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }

  // 返回拦截器结构数据
  return obj
}

一个基本的 computed 就是实现了,试一下。

js
const obj = reactive({ text: 'hello world', ok: true })

const comp = computed(() => obj.text + 1)

comp.value // 'hello world'1

这里其实还有一个问题,就是在副作用函数内访问 comp.value,当 obj.text 变化时,依赖的函数并没有执行。

js
effect(() => console.log(comp.value))

obj.text = '张三'

以上代码依赖计算属性的副作用函数并没有重新执行,原因在于计算属性返回的对象数据劫持并没有添加追踪和触发逻辑,这里我们手动添加一下即可。在 getter 时添加依赖,在调度器中触发依赖。

js
function computed(getter) {
  const effectFn = effect(getter, {
    scheduler(fn) {
      dirty = true
      trigger(obj, 'value') 
    },
    lazy: true
  })

  let value,
    dirty = true

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      track(obj, 'value') 
      return value
    }
  }

  return obj
}

观察属性 watch

观察属性特性:

  1. 当依赖数据更新时会触发回调,并给出新旧值;

首先给出两个变量保存前后结果,在调度器中执行回调,并传入新旧值。

js
function watch(getter, cb) {
  const effectFn = effect(getter, {
    scheduler() {
      // 数据更新,重新执行
      newV = effectFn()

      // 执行回调,并传入新旧值作为参数
      cb(newV, oldV)

      // 更新旧值
      oldV = newV
    },
    lazy: true
  })

  // 记录新旧值
  let newV, oldV

  // 执行一次,记录为旧值
  oldV = effectFn()
}

一个基本的观察属性就实现了,试一下:

js
const obj = reactive({ text: 'hello world', ok: true })

watch(() => obj.text, (newV, oldV) => console.log(newV, oldV)) // 张三, hello world

obj.value = '张三'

todo 待实现 deep 与 immediate

v2 与 v3 响应式比较

vue2 通过 Object.defineProperty 来实现,vue3 通过 Proxy 来实现。

js
const obj = { name: '张三' }

const v2 = {}
Object.defineProperty(v2, 'name', {
  get() {
    console.log('v2 get')
    return obj.name
  },
  set(value) {
    console.log('v2 set')
    obj.name = value
    return true
  }
})

const v3 = new Proxy(obj, {
  get(taget, key) {
    console.log('v3 get')
    return taget[key]
  },
  set(taget, key, value) {
    console.log('v3 set')
    taget[key] = value
    return true
  }
})
  • 代理形式:Object.defineProperty 只代理对象上的某个属性,需要遍历对象设置才能完整代理,而 Proxy 代理整个对象;

    js
    const obj = { name: '张三' }
    
    const v2 = {}
    for (const key in obj) {
      /* 一些判断 */
      Object.defineProperty(v2, key, {})
    }
    
    const v3 = new Proxy(obj, {})
  • 数据修改:Object.defineProperty 监听不到对象新增属性和数组修改,Proxy 可以监听到;

Object.defineProperty 监听不到对象新增属性其根本原因是没有设置对应的属性拦截处理,vue2 中可通过 Vue.set 或 this.$set 来新增属性拦截;

对于数组的情况 vue2 通过代理模式重写数组方法,共七个:

  1. push()
  2. pop()
  3. shift()
  4. unshift()
  5. splice()
  6. sort()
  7. reverse()