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())
}
相关推荐
Мартин.3 小时前
[Meachines] [Easy] Sea WonderCMS-XSS-RCE+System Monitor 命令注入
前端·xss
一 乐4 小时前
学籍管理平台|在线学籍管理平台系统|基于Springboot+VUE的在线学籍管理平台系统设计与实现(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·学习
昨天;明天。今天。4 小时前
案例-表白墙简单实现
前端·javascript·css
数云界4 小时前
如何在 DAX 中计算多个周期的移动平均线
java·服务器·前端
风清扬_jd4 小时前
Chromium 如何定义一个chrome.settingsPrivate接口给前端调用c++
前端·c++·chrome
安冬的码畜日常4 小时前
【玩转 JS 函数式编程_006】2.2 小试牛刀:用函数式编程(FP)实现事件只触发一次
开发语言·前端·javascript·函数式编程·tdd·fp·jasmine
ChinaDragonDreamer4 小时前
Vite:为什么选 Vite
前端
小御姐@stella4 小时前
Vue 之组件插槽Slot用法(组件间通信一种方式)
前端·javascript·vue.js
GISer_Jing4 小时前
【React】增量传输与渲染
前端·javascript·面试
eHackyd4 小时前
前端知识汇总(持续更新)
前端