Vue3设计与实现之响应系统——基本实现

书接上篇,了解了Vue是怎么渲染到页面之后,来学习一个非常重要的概念:响应系统

什么是响应系统

MVVM&响应式数据&副作用函数

  • vue是一个MVVM框架,那什么是MVVM呢,MVVM实际上是一种架构,Model-View-ViewModel
    • Model:数据层,可能是固定的死数据,更多的是来自服务器,从网络上请求下来的数据
    • View:视图层,前端开发中,通常就是DOM层,主要的作用是给用户展示各种信息
    • View-Model:视图模型层 ,视图模型层是View和Model沟通的桥梁。主要的功能有两个,一是Data Binding(数据绑定) ,将Model的改变实时的反应到View中。二是DOM Listener(DOM监听),监听DOM发生一些事件(点击、滚动、触摸等),并在需要的情况下改变对应的Data
  • 响应式数据在vue2中是data中return的数据,vue3中是ref、reactive、computed包装的数据,具体的实现vue2基于Object.defineProperty,vue3是Proxy,从页面表现来讲,修改数据页面元素也跟着改变的这部分数据就是响应式数据
  • 副作用函数,直接或间接影响其他函数的执行

实现一个响应系统

vue3是基于Proxy实现响应式数据,Proxy是一个代理对象,可以拦截对象属性的操作,如读取(get)、设置(set)等

  • 参数:target handler
    • target是需要被代理的对象
    • handler是对被代理的对象的拦截操作
  • handler
    • set 是个函数,接收三个参数,分别是target(被代理的对象)、key(写入的属性)、val(修改后的值)、receiver(代理对象)
    • get 是个函数,接收两个参数,分别是target(被代理的对象)、key(读取的属性)、receiver(代理对象)
  • 简单举例
js 复制代码
const obj = { a: 1 }
const proxyObj = new Proxy(obj, {
  // 拦截读取操作
  get(target, key, receiver) {
    console.log(target === obj) // true
    console.log(proxyObj === receiver) // true
    return target[key]
  },
  // 拦截写入操作
  set(target, key, val, receiver) {
    target[key] = val
    return true
  }
})
proxyObj.a

上述代码中,obj是被代理的对象,proxyObj是代理对象,get和set中的target其实就是obj,receiver则是proxyObj

1. 简易版

期望达到的效果:实现一个响应式数据,修改后页面显示也跟着改变

实现思路:get拦截读取操作,把依赖存下来(存储到桶【bucket】里),set拦截设置操作,执行依赖

  • ① 创建一个响应式对象
  • ② 监听响应式对象的get和set
  • ③ 调用副作用函数读取元素属性,并存储副作用函数
  • ④ 修改数据执行副作用函数
js 复制代码
const bucket = new Set()
const data = {a: 1}

const handler = {
  // 拦截读取操作 存储副作用函数
  get(target, key) {
    bucket.add(effect)
    return target[key]
  },
  // 拦截写入操作 执行副作用函数
  set(target, key, value) {
    target[key] = value
    bucket.forEach(fn => fn())
    return true
  }
}
const obj = new Proxy(data, handler)
// 副作用函数
function effect() {
  document.documentElement.innerText = obj.a
}
effect()

setTimeout(() => {
  obj.a = '响应式'
}, 2000)

2.完善版

  • 副作用函数应该是个匿名函数
    上述示例中传入的effect函数是写死的,实际场景中需要灵活处理函数,所以这里通过匿名函数来增加灵活性
js 复制代码
let activeEffect
function effect(fn) {
  activeEffect = fn
  fn()
}

effect(() => { document.documentElement.innerText = obj.a })

const handler = {
  // 拦截读取操作 存储副作用函数
  get(target, key) {
    if (activeEffect) {
      bucket.add(activeEffect)
    }
    return target[key]
  },
  // 拦截写入操作 执行副作用函数
  set(target, key, value) {
    target[key] = value
    bucket.forEach(fn => fn())
    return true
  }
}
  • 桶的数据结构
    • 理想的数据结构应该是根据被读取的对象obj字段名key 来收集当前key的所有依赖effectFn ,在vue3中创建响应式数据通过ref、reactive、computed创建的实际就是一个代理对象
    • 所以首先需要以被存储的对象作为key,key对应的value又需要通过对象的key来区分,对象的每个key对应不同的依赖
      • 第一层:对象作为key,可以想到Map、WeakMap数据结构,通过WeakMap实现
      • 第二层:对象的key需要与effectFn形成一对多的关系,通过Map实现
      • 第三层:effectFn是收集的依赖,一个key可以产生多个依赖,通过Set实现
  • 具体实现
js 复制代码
const bucket = new WeakMap()
let activeEffect
function effect(fn) {
  activeEffect = fn
  fn()
}

const track = (target, key) => {
  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 就是一个与当前副作用函数存在联系的依赖集合
  deps.add(activeEffect)
}

const trigger = (target, key, value) => {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  if (!effects) return
  effects.forEach(fn => fn())
}

const handler = {
  // 拦截读取操作 存储副作用函数
  get(target, key) {
    track(target, key)
    return target[key]
  },
  // 拦截写入操作 执行副作用函数
  set(target, key, value) {
    target[key] = value
    trigger(target, key, value)
  }
}

const data = { a: 1 }
const obj = new Proxy(data, handler)
effect(() => { document.documentElement.innerText = obj.a })
obj.a = 2

细节

1. 分支切换与cleanup

  • 问题1:现在有一个三元表达式如下所示,执行拦截操作的时候因为ok的值为true所以会分别收集ok和text的依赖,当ok的值变为false时理想情况时只剩下ok的依赖,但上述实现的effectFn函数还做不到这一点,它还是保存着ok和text的依赖,所以当text的值发生改变时,effectFn函数还是会被执行一遍,这个时候无论text的值怎么变化页面始终显示的都是not,所以并不需要再执行effectFn函数
js 复制代码
const handler = {
  // 拦截读取操作 存储副作用函数
  get(target, key) {
    console.log(key, 'get');
    track(target, key)
    return target[key]
  },
  // 拦截写入操作 执行副作用函数
  set(target, key, value) {
    console.log(key, 'set');
    target[key] = value
    trigger(target, key, value)
  }
}

const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, handler)
effect(function effectFn() {
  document.body.innerText = obj.ok ? obj.text : 'not'
})
obj.ok = false
obj.text = 'hello world1212' // 这句代码不应该再触发ok的get
  • 思路:每次副作用函数执行时,先将与之相关的依赖删除,执行完毕后会重新建立联系
js 复制代码
const cleanup = (effectFn) => {
  for(let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

const bucket = new WeakMap()
let activeEffect
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn) // 新增 删除之前相关的依赖
    activeEffect = fn
    fn()
  }
  effectFn.deps = []
  effectFn()
}

const track = (target, key) => {
  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)
  activeEffect.deps.push(deps) // 新增 添加与当前副作用函数存在联系的依赖集合
}

const trigger = (target, key, value) => {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  if (!effects) return
  effects.forEach(fn => fn())
}
  • 问题2:上面的代码会触发死循环,分两种情况分析

- 1、没有修改ok的值时 - (1)、cleanup删除上次的依赖 - (2)、把活跃副作用函数设置成当前的effectFn - (3)、执行effectFn,也就是document.body.innerText = obj.ok ? obj.text : 'not' - (4)、执行effectFn会触发ok和text的读取操作,也就是track函数,track函数会把依赖添加到bucket中,这时候bucket中的值如下图所示 - 2、修改ok的值 - (1)、首先先正常执行上述修改之前的流程 - (2)、修改ok的值,触发ok的设置操作,进入到trigger函数,trigger执行与ok相关的所有副作用函数,也就是effects.forEach(fn => fn()),这时候effects还是上述bucket中与ok相关的副作用函数 - (3)、执行副作用函数时,先通过cleanup删除上次的依赖 ,也就是把上次的副作用函数effectFn删除了,再把活跃副作用函数设置成当前的effectFn,执行副作用函数,触发了ok的读取操作,进入到track函数,track函数把effectFn添加到bucket中 ,effects这时候又变成了上图中与ok相关的副作用函数,进而再次触发fn() ,又会按上述的过程执行,导致了死循环 - 根本原因:forEach 遍历 Set 集合 时,如果一个值已经被访问过了,但该值被删除并重新添加到集合, 如果此时 forEach 遍历没有结束,那么该值会重新被访问

  • 思路:不要通过原来的Set执行副作用函数,创建一个新的Set存储这次需要执行的副作用函数
js 复制代码
const trigger = (target, key, value) => {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  if (!effects) return
  // effects.forEach(fn => fn()) // 删除
  const effectsToRun = new Set(effects) // 新增 这次需要执行的副作用函数
  effectsToRun.forEach(effectFn => effectFn()) // 新增 只执行这次需要执行的
}

2. 嵌套的effect

  • 组件之间是可以嵌套的,上述实现的effect并不能实现,先看一段测试代码,这段代码期望实现的效果是修改 obj.foo 时会触发 effectFn1,由于 effectFn2 嵌套在 effectFn1 里,所以会间接触发 effectFn2 执行,而当修改 obj.bar 时,只会触发 effectFn2 执行
js 复制代码
// 原始数据
const data = { foo: true, bar: true }
// 代理对象
const obj = new Proxy(data, handler)
// 全局变量
let temp1, temp2
effect(function effectFn1() {
  console.log('effectFn1', '执行');
  effect(function effectFn2() {
    console.log('effectFn2', '执行');
    // 在 effectFn2 中读取 obj.bar 属性
    temp2 = obj.bar
  })
  // 在 effectFn1 中读取 obj.foo 属性
  temp1 = obj.foo
})

obj.foo = false

// 打印结果
// 'effectFn1 执行'
// 'effectFn2 执行'
// 'effectFn2 执行'
  • 问题:effect执行时设置的activeEffect只有一个,内层的副作用函数会覆盖外层的副作用函数,所以导致最后执行的是内层的副作用函数
  • 思路:创建一个副作用函数栈effectStack,副作用函数执行时压入栈中,执行完毕弹出,activeEffect始终指向栈顶
js 复制代码
let activeEffect
let effectStack = [] // 新增 创建副作用栈
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn) // 新增 执行前压入
    fn()
    effectStack.pop() // 新增 执行完弹出
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.deps = []
  effectFn()
}

3. 无限递归

  • 自增操作obj.foo++实际上是obj.foo = obj.foo + 1,会触发读取+设置
js 复制代码
const data = { foo: 1 }
const obj = new Proxy(data, handler)
effect(() => obj.foo++)
  • 问题:上述代码会导致控制台报错,原因是执行obj.foo++会先触发obj.foo的track操作,把副作用函数存入bucket中,接着加一后执行obj.foo的trigger操作,取出副作用函数并执行,但此时副作用函数还没执行完,就要开始下一次的执行,会导致无限递归调用自己,所以产生了栈溢出
  • 思路:trigger执行前加一个守卫,如果trigger触发执行的副作用函数与正在执行的副作用函数相同,则不执行
js 复制代码
const trigger = (target, key) => {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  if (!effects) return
  const effectsToRun = new Set()
  effects.forEach(effectFn => { // 新增
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
}
相关推荐
掘金一周2 分钟前
金石焕新程 >> 瓜分万元现金大奖征文活动即将回归 | 掘金一周 4.3
前端·人工智能·后端
三翼鸟数字化技术团队20 分钟前
Vue自定义指令最佳实践教程
前端·vue.js
Jasmin Tin Wei1 小时前
蓝桥杯 web 学海无涯(axios、ecahrts)版本二
前端·蓝桥杯
圈圈编码1 小时前
Spring Task 定时任务
java·前端·spring
转转技术团队1 小时前
代码变更暗藏危机?代码影响范围分析为你保驾护航
前端·javascript·node.js
Spark2381 小时前
关于vue3整合tiptap的slash菜单的ts支持
vue.js
Mintopia1 小时前
Node.js高级实战:自定义流与Pipeline的高效数据处理 ——从字母生成器到文件管道的深度解析
前端·javascript·node.js
Mintopia1 小时前
Three.js深度解析:InstancedBufferGeometry实现动态星空特效 ——高效渲染十万粒子的底层奥秘
前端·javascript·three.js
北凉温华1 小时前
强大的 Vue 标签输入组件:基于 Element Plus 的 ElTagInput 详解
前端
随笔记1 小时前
Flex布局下,label标签设置宽度依旧对不齐,完美解决(flex-shrink属性)
javascript·css·vue.js