インタビュアー:Vue3レスポンシブ(Vue3主成分分析のためのレスポンシブシステムの実装)を記述できますか

この記事は、「新人クリエーションセレモニー」イベントに参加し、一緒にゴールドクリエーションの道を歩み始めました。

前に書く

Vue3が徐々に成熟するにつれ、Vue2と比較したVue3の利点はますます明白になっています。Vue3がVue2より優れているかどうか、アップグレードが必要かどうかについては、コミュニティでまだ多くの論争がありますが、Vue3は否定できません。徐々にポジティブになりました。、公式のデフォルトのVueバージョンになり始めました。その後、立ったり、座ったり、横になったりするフロントエンドのサークルで、Vue3を学ぶことはすでに避けられないハードルであり、私たちは皆、Vue3を多かれ少なかれ知っています。理由それが生まれた理由は、実際には「Vueが残した技術的負債を返済する」ためです。したがって、 Vue3は、アーキテクチャ設計と書き込みの最適化に関して多くの調整を行っています。したがって、Vue2の原則にすでに精通している場合でも、 Vue3の設計原理と設計アイデアを十分に理解する必要があります。Vue2とVue3を原理レベルから比較すると、より明確に理解できます。Vue2からVue3への進化のすべてのステップは、問題とその理由を解決することです。この最適化が行われます。これらの設計アイデアと最適化方法を学ぶことは、Vue3を理解または学習することだけではありません。それを学ぶことで得られる利益は、思考と設計レベルを向上させる可能性があります。その後のコーディングプロセスでは、基本的な実践を提供できます。設計しましょう。より合理的で効率的なアーキテクチャ

応答システムの実装

原生 API Proxy

MVVMフレームワークとして、Vueで最も重要なことはレスポンシブシステムの実装である必要があります。Vue2の実装で使用されるネイティブAPIはObject.definePropertyです。以前の記事で詳細な主成分分析を行いました。興味があるインタビュアー Vueレスポンシブを手で書くことはできますか?(Vue Responsiveness Principle [Full Version]) 、およびVue3では、Youdaはパフォーマンスの問題を測定した後にObject.definePropertyを放棄し、代わりにプロキシを基盤となるAPIとして使用して、応答システムのデータ変更を監視する機能を実装しました。応答システム、最初にプロキシとは何かを理解しましょう。プロキシを使用してデータ監視を実装するにはどうすればよいですか?

まず、API関連のドキュメントであるMDN-Proxyを見てください。はじめに、ProxyとObject.definePropertyは実際には類似しており、どちらもオブジェクトを受け取り、オブジェクトを監視しますが、2つの違いがあります。

オブジェクトキー値の完全な監視

Object.definePropertyは、指定されたキーに対応する値の変更のみを監視できますが、Proxyはオブジェクトのすべてのキーを直接監視できます。つまり、Proxyは、監視対象のオブジェクトを手動でトラバースし、各キーを監視し、次に、簡単な例を見てください(Object.defineProperty関連の例は、以前の記事を直接読んでいます。インタビュアー:Vueの応答性を手動で記述できますか?(Vueの応答性の原則[フルバージョン])

// 模拟数据更新
function update(){
  console.log('update view')
}

// proxy 
function observe_proxy(data){
  // 代理 data,并将生成的代理对象作为返回值返回 
  const res = new Proxy(data, {
    // 代理对象的getter
    get(target, key, receiver){
      // Reflect.get/set 为官方标准写法,直接将 get、set 的参数赋值即可
      // 不需要手动写 return target[key]
      return Reflect.get(target, key, receiver)
    },
    // 代理对象的setter
    set(target, key, value, receiver){
      // 模拟触发更新 
      update()
      return Reflect.set(target, key, value, receiver)
    }
  })
  return res
}

// 测试数据
const data = {
  name: 'yimwu',
  tel: '134xxx54567'
}
// 代理返回的对象
const dataProxy = observe_proxy(data)
console.log(dataProxy.name) // yimwu
dataProxy.name = 'YI' // 更新成功 --> 输出 update view
复制代码

可以看到当 observe 函数的实现方式很简单,直接通过 new Proxy(), 将对象传入即可代理对象的所有 key,但是细心的朋友可能注意到了,示例代码触发更新的方式和Object.defineProperty 的并不一样,这也是接下来要说的第二个不同点

触发更新

Object.defineProperty 触发更新的方式很简单,直接修改原对象,因为 Object.defineProperty 直接对原对象进行监听,而 Proxy 的实现思路不同,它通过代理的方式,将自己作为中间人的角色,所以对原对象所有的操作需要通过 Proxy 进行,才能够得到响应式的效果,例子如下,我们 通过修改 observe 返回的代理对象 dataProxy 触发更新,而不是修改原对象 data

const dataProxy = observe_proxy(data)
console.log(dataProxy.name) // yimwu
dataProxy.name = 'YI' // 更新成功 --> 输出 update view
复制代码

嵌套代理

使用 Object.defineProperty 实现嵌套监听的方式其实很简单粗暴,直接递归调用,通过给所有层级的所有key建立对于的 getter、setter,完成所有嵌套的监听,而使用 Proxy 略微有些差别,Proxy 对于嵌套赋值,会触发 get,例如 obj.a.b = 2, 会触发 obj.a 的 get,利用这个特性,可以在 get 中做判断,若获取的是 object 则返回 Proxy,而不是返回该值,通过这种方式能够对对象,以及对象内嵌套的子对象进行代理,例子如下

// 模拟数据更新
function update(){
  console.log('update view')
}
// proxy
function observe_proxy(data){
  const res = new Proxy(data, {
    get(target, key, receiver){
      // 若修改的为嵌套对象,如 obj.a.b = 2,则 obj.a 触发 get,
      // 返回嵌套代理对象,完成嵌套对象代理
      if(typeof target[key] === 'object'){
        return observe_proxy(target[key])
      }
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver){
      update()
      return Reflect.set(target, key, value, receiver)
    }
  })
  return res
}
// 测试数据
const data = {
  name: 'yimwu',
  tel: '134xxx54567',
  address: {
    firstAdd: 'guangzhou',
    second: 'shenzhen'
  },
}
// 
const dataProxy = observe_proxy(data)
dataProxy.address.firstAdd = 'jieyang' // 更新成功 --> 输出 update view
复制代码

可以看到,在开始监听对象的时候,并没有和采用和 Object.defineProperty 的实现方式一样,在监听开始时就递归完成所有 getter、setter 绑定,而是等到触发了某个嵌套对象,才进行监听,所以,从这一步上来说也是采用 Proxy 这种实现方式的一个性能优化的点。

依赖收集

前面介绍了 Vue3 中采用的 Proxy API,通过该 API,就能够实现对数据的变化进行特定的响应,但是,这在 Vue 的响应系统中,只是最基础的一部分,更加复杂的应该是与数据监听相配合的依赖收集,举个例子,在 Vue 中我们经常使用的计算属性 computed ,能够在依赖更新时更新,所谓的依赖,就是该 computed 值由什么值决定,const myComputed = computed(() => { return a+b }),则myComputed依赖a、b,即当 a、b变化时,myComputed 将随着变化

理解了什么是依赖之后,副作用函数就很清晰了,顾名思义,就是有副作用的函数,指的是 执行了该函数,会影响某些外部的变量,而副作用函数的绑定,恰恰就是为了 在所依赖的值变化时,重新执行副作用函数,重新对该变量进行影响

0.0 版本(基础实现)

当对数据进行监听时,我们为 Proxy 提供了 getter和setter 函数,那么,副作用函数自然也能够融合到这两个函数里面来,形成副作用函数绑定,如下图,我们在 getter 中将 effectFn 函数绑定到对应的对象上,然后在 setter 中执行该函数,也就能够实现简单的副作用函数绑定了

image.png

// 数据监听函数
function observe_proxy(data){
  // 监听对象
  const res = new Proxy(data, {
    get(target, key, receiver){
      if(typeof target[key] === 'object' && target[key] !== null){
        return observe_proxy(target[key])
      }
      // 提前到 fnList.add 前执行,避免监听行为被 fnList.add 干扰
      const res = Reflect.get(target, key, receiver)
      // =============== 新增 ===============
      // 将 effectFn 添加到 fnList 中
      fnList.add(activeEffect)
      // =============== 新增 ===============
      return res
    },
    set(target, key, value, receiver){
      // 提前到 fnList.forEach 前执行,避免 set 未生效而执行 effectFn
      let res = Reflect.set(target, key, value, receiver) 
      // =============== 新增 ===============
      // 执行 fnList 中所有副作用函数
      fnList.forEach(fn => fn())
      // =============== 新增 ===============
      return res
    }
  })
  return res
}

// 存储所有副作用函数的数组
// 这里采用 Set 替换 Array 实现自动去重
// const fnList = new Array()
const fnList = new Set()

// 存放需要添加到 funList 中的函数
let activeEffect

// 触发副作用函数 effectFn 绑定行为
function effect(effectFn){
  activeEffect = effectFn
  // 执行effectFn,触发 get
  effectFn()
}

// 测试数据
const data = {
  name: 'yimwu',
  tel: '134xxx54567',
  address: {
    firstAdd: 'guangzhou',
    second: 'shenzhen'
  },
}

// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:定义副作用函数
const effectFn = () => {
  console.log('My tel-num is: ', dataProxy.tel)
}
// 第三步:触发副作用函数绑定
effect(effectFn) // My tel-num is: 134xxx54567 ==> 触发 get, 副作用函数绑定成功

// 测试效果
setTimeout(() => {
  dataProxy.tel = '159000xxx54'  // My tel-num is: 159000xxx54 ==> 副作用函数成功执行
}, 2000);
复制代码

1.0版本(key 单独绑定副作用函数)

在基础版中,只是让我们能够初步体会到依赖绑定的行为,但是还存在很多问题,比如依赖函数并没有针对 data 的每个 key 单独绑定,下面引入一个数据结构解决这个问题,如图

image.png fnList 为一个 WeakMap,key 值为一个个的数据对象 data ,value 则是一个 Map,该 Map 中用 data 的 key值 作为 key,存储副作用函数的 Set 作为 value,这样就形成了针对每个数据对象的每个key的单独的副作用函数的 Set ,接下来对代码进行改造,支持新的数据结构

// 存储所有副作用函数的对象
const fnList = new WeakMap()
// 收集副作用函数
function track(target, key){
  if(!activeEffect) return
  // 依次获取data对应的Map以及Map中key所对应的Set,
  // 有则将 activeEffect 加入,没有则新建后加入
  let dataMap = fnList.get(target)
  if(!dataMap){
    dataMap = new Map()
    fnList.set(target, dataMap)
  }
  let fnSet = dataMap.get(key)
  if(!fnSet){
    fnSet = new Set()
    dataMap.set(key,fnSet)
  }
  fnSet.add(activeEffect)
}
// 执行副作用函数
function trigger(target, key){
  // 获取副作用函数集并执行
  const dataMap = fnList.get(target)
  if(!dataMap) return
  const effectFns = dataMap.get(key)
  effectFns && effectFns.forEach(effectFn=>effectFn())
}
function observe_proxy(data){
  const res = new Proxy(data, {
    get(target, key, receiver){
      if(typeof target[key] === 'object' && target[key] !== null){
        return observe_proxy(target[key])
      }
      // 提前到 fnList.add 前执行,避免监听行为被 fnList.add 干扰
      const res = Reflect.get(target, key, receiver)
      // 将 effectFn 添加到 fnList 中
      // fnList.add(activeEffect)
      track(data, key)  // 更新写法
      return res
    },
    set(target, key, value, receiver){
      // 提前到 fnList.forEach 前执行,避免 set 未生效而执行 effectFn
      let res = Reflect.set(target, key, value, receiver) 
      // 执行 fnList 中所有副作用函数
      // fnList.forEach(fn => fn())
      trigger(target,key)  // 更新写法
      return res
    }
  })
  return res
}

// 存放需要添加到 funList 中的函数
let activeEffect = null

// 触发 effectFn添加行为
function effect(effectFn){
  activeEffect = effectFn
  // 执行effectFn,触发 get
  effectFn()
  // 由于通过 set 触发副作用函数时也会导致 get 被触发,
  // 将 activeEffect 设为 null 避免重复添加副作用函数
  activeEffect = null
}

// 测试数据
const data = {
  name: 'yimwu',
  tel: '134xxx54567',
  address: {
    firstAdd: 'guangzhou',
    second: 'shenzhen'
  },
}

// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:定义副作用函数
const effectFn = () => {
  console.log('My tel-num is: ', dataProxy.tel)
}
// 第三步:触发副作用函数绑定
effect(effectFn)  // My tel-num is: 134xxx54567 ==> 首次触发,监听成功

// 测试效果
setTimeout(() => {
  dataProxy.tel = '159000xxx54' // My tel-num is: 159000xxx54 ==> 监听成功触发
}, 2000);

setTimeout(() => {
  dataProxy.name = 'YI' // 没监听,不输出
}, 4000);
复制代码

2.0 版本(副作用函数重置)

通过 1.0 版本的改造,加入了新的数据结构之后,实现了副作用函数针对key的单独绑定,而 1.0 版本依然存在问题。在许多使用场景中,一个副作用函数往往关联的 key 不止一个,也就是说首次触发副作用函数后,多个 key 会同时绑定一个副作用函数,当任何一个 key 被修改时,都将触发该副作用函数重新执行,例如以下场景,通过两个变量决定电灯是否打开,一个是电源开关 powerOn,一个是电灯开关 turnOn

// 测试数据
const data = {
  powerOn: true,
  turnOn : true
}

// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:定义副作用函数
const effectFn = () => {
  console.log('是否开启电源',  dataProxy.powerOn)
  console.log('是否开启灯光',  dataProxy.turnOn)
}
// 第三步:触发副作用函数绑定
effect(effectFn)
复制代码

执行后,powerOn、turnOn 同时绑定了 effectFn ,当任何一个状态发生变化时,effectFn 都将被触发,看似符合规则,没有任何问题,但是这并不符合场景设定,场景需求是两个变量共同决定电灯状态,并且如果电源关闭,无论电灯开关是否开启,电灯都为关闭状态,对上面的副作用进行一下修改,如下

// 测试数据
const data = {
  powerOn: true,
  turnOn : true
}
// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:定义副作用函数
// 表示若电源(powerOn)开启,则灯光状态由(turnOn)决定,
// 若电源(powerOn)关闭,无论灯光(turnOn)是否开起,状态都为关闭(false)
const effectFn = () => {
  console.log('是否开启灯光',  dataProxy.powerOn ? dataProxy.turnOn : false)
}
// 第三步:触发副作用函数绑定
effect(effectFn)

// 测试效果
setTimeout(() => {
  dataProxy.turnOn = false
}, 2000);

setTimeout(() => {
  dataProxy.turnOn = true
}, 3000);

setTimeout(() => {
  dataProxy.powerOn = false 
}, 4000);

// 将powerOn 置为 false 后,turnOn 的变化依然触发 副作用函数
setTimeout(() => {
  dataProxy.turnOn = false
}, 5000);

setTimeout(() => {
  dataProxy.turnOn = true
}, 6000);
复制代码

初始化时,powerOn 为 true,执行表达式时同时触发 powerOn、turnOn 的 get 并绑定副作用函数,而当我们将 powerOn 置为 false 后,无论 turnOn 的值如何变化,表达式恒等于 false,也就是说当我们将 powerOn 置为 false 后,再修改 turnOn 就不需要再触发副作用函数 effectFn了,这也是一个性能优化的手段,那该如何实现呢

最直接的方法是 在每次触发副作用函数时将该副作用函数从 所有 key 的依赖集合中清除掉,通过副作用函数触发的 get 重新形成绑定,这样一来,当 powerOn 为 false 时,将不再触发 turnOn 的 get ,因此 turnOn 的变化将不再触发 副作用函数。具体改动如下,在副作用函数上定义一个数组,用于触发绑定了该副作用函数的依赖集(Set),当触发副作用函数时调用 reset 函数,从所有依赖集中将副作用函数清除掉

image.png

// 触发 effectFn 添加行为
function effect(effectFn){
  const innerFn = () => {
    // 清除依赖
    reset(innerFn)
    activeEffect = innerFn
    effectFn()
  }
  // 存放依赖集的数组
  innerFn.deps = []
  // 间接执行effectFn,触发 get
  innerFn()
}
// 用于清除依赖的函数
function reset(effectFn){
  effectFn.deps && effectFn.deps.forEach(dep => {
    dep.delete(effectFn)
  })
  effectFn.deps = []
}

// 修改 track 函数
function track(target, key){
  if(!activeEffect) return
  // 依次获取data对应的Map以及Map中key所对应的Set,有则将 activeEffect 加入,没有则新建后加入
  let dataMap = fnList.get(target)
  if(!dataMap){
    dataMap = new Map()
    fnList.set(target, dataMap)
  }
  let fnSet = dataMap.get(key)
  if(!fnSet){
    fnSet = new Set()
    dataMap.set(key,fnSet)
  }
  fnSet.add(activeEffect)
  // ================ 新增 ================
  activeEffect.deps.push(fnSet)
  // ================ 新增 ================
}

// 测试数据
const data = {
  powerOn: true,
  turnOn : true
}
// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:定义副作用函数
// 表示若电源(powerOn)开启,则灯光状态由(turnOn)决定,
// 若电源(powerOn)关闭,无论灯光(turnOn)是否开起,状态都为关闭(false)
const effectFn = () => {
  console.log('是否开启灯光',  dataProxy.powerOn ? dataProxy.turnOn : false)
}
// 第三步:触发副作用函数绑定
effect(effectFn)

setTimeout(() => {
  dataProxy.turnOn = false
}, 2000);

setTimeout(() => {
  dataProxy.turnOn = true
}, 3000);

setTimeout(() => {
  dataProxy.powerOn = false
}, 4000);

// powerOn = false turnOn 变化不再触发副作用函数
setTimeout(() => {
  dataProxy.turnOn = false
}, 5000);

setTimeout(() => {
  dataProxy.turnOn = true
}, 6000);
复制代码

当运行上面版本的代码的时候会发现陷入无限循环,其实问题主要出现在 trigger 函数,原因如图所示,reset 函数将 Set 中的 effectFn 删除后,执行track时,track 又重新将 effectFn 加入到 Set 中,导致 ForEach 无限循环,所以最直接的办法是 forEach 一个新的 Set,这样执行过程中对原来的 Set 进行修改就不会影响循环的执行,修改如下

image.png

// 修改
function trigger(target, key){
  // 获取副作用函数集并执行
  const dataMap = fnList.get(target)
  if(!dataMap) return
  const effectFns = dataMap.get(key)
  const effectFnsTmp = new Set(effectFns)
  // =============== 修改 ===============
  effectFnsTmp.forEach(fn => fn())
  // effectFns && effectFns.forEach(effectFn=>effectFn())
  // =============== 修改 ===============
}
复制代码

3.0 版本(栈消除嵌套副作用函数bug)

2.0 版本已经基本上修复了大部分的坑了,而还有一个平时写程序经常会遇到的一个,那就是嵌套,我们先拿 2.0 版本的代码测试一下是否支持嵌套

// 测试数据
const data = {
  name: 'Yimwu',
  age: 18
}
// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:嵌套监听
effect(function effectFn1(){
  console.log('第一层开始')
  effect(function effectFn2(){
    // 触发副作用函数绑定
    console.log('第二层', dataProxy.name)
  })
  // 在第二层执行完成后,触发副作用函数绑定,验证是否能够正常绑定
  console.log('第一层结束', dataProxy.age)
})

// 测试效果
setTimeout(() => {
      dataProxy.age = 30 
      // 第二层 Yimwu ==> 输出错误,age 绑定了第二层的函数,
      // 而不是预想的第一层函数
}, 2000);
复制代码

很显然,2.0 版本并不支持嵌套,一旦发生嵌套,就会出现 activeEffect不匹配的问题,那么这个问题该如何解决呢

这里顺便说一下日常编程的一个小 Tip ,在平时我们遇到的问题中,有两个法宝可以使用,一个是队列,一个是栈,而顾名思义,队列,就是针对顺序进行的事情所定义的数据结构,它能够保证我们的程序按照我们所设想的顺序执行,而栈我们相对陌生,由于栈的特殊读取、输入规则(先进后出),栈最常被用于处理嵌套逻辑

从嵌套执行的 effect 可以看得出副作用函数错误绑定的原因主要是 全局 activeEffect 在进入第二层后,被第二层覆盖,当第二层结束后,activeEffect 没有恢复到第一层对应的 activeEffect,因此,我们使用栈结构,对每一层的 activeEffect 进行保存,通过出栈的方式保证 activeEffect 的对应关系是正确的,修改如下

image.png

// 修改 effect 函数
function effect(effectFn){
  const innerFn = () => {
    // 清除依赖
    reset(innerFn)
    activeEffect = innerFn
    // ==================== 新增 ====================
    // 入栈
    activeStack.push(innerFn)
    // ==================== 新增 ====================
    effectFn()

    // ==================== 新增 ====================
    // 出栈
    activeStack.pop()
    // 执行外层 activeEffect
    activeEffect = activeStack[activeStack.length-1]
    // ==================== 新增 ====================
  }
  // 存放依赖集的数组
  innerFn.deps = []
  // 间接执行effectFn,触发 get
  innerFn()
}
// 测试数据
const data = {
  name: 'Yimwu',
  age: 18
}
// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:嵌套监听
effect(function effectFn1(){
  console.log('第一层开始')
  effect(function effectFn2(){
    // 触发副作用函数绑定
    console.log('第二层', dataProxy.name)
  })
  // 在第二层执行完成后,触发副作用函数绑定,验证是否能够正常绑定
  console.log('第一层结束', dataProxy.age)
})

// 测试效果
setTimeout(() => {
  dataProxy.age = 30 
}, 2000);
// 输出:
// 第一层开始
// 第二层 Yimwu
// 第一层结束 30
复制代码

最后解决一个特殊情况下会出现的 Bug,当副作用函数中出现 自增、自减 操作时,副作用函数会重复调用,导致栈溢出,例子如下

// 测试数据
const data = {
  name: 'Yimwu',
  age: 18
}
// 定义副作用函数
const effectFn = () => {
  dataProxy.age++
}
// 触发副作用函数绑定
effect(effectFn)
复制代码

image.png

分析如下:

image.png

解决这个问题很简单,我们只需要在 trigger 中判断从 Set 中取出的 effectFn 与目前正在执行的 activeEffect 是否相同,相同则不执行,这样就能够避免无线循环,修改如下

// 修改副作用函数
function trigger(target, key){
  // 获取副作用函数集并执行
  const dataMap = fnList.get(target)
  if(!dataMap) return
  const effectFns = dataMap.get(key)

  // =================== 新增 ===================
  // 创建空 Set,筛选不等于 activeEffect 的副作用函数
  const effectFnsTmp = new Set()
  effectFns.forEach(fn => {
    if(fn !== activeEffect) effectFnsTmp.add(fn)
  })
  effectFnsTmp.forEach(fn => fn())
  // =================== 新增 ===================
  
  // const effectFnsTmp = new Set(effectFns)
  // effectFnsTmp.forEach(fn => fn())
  // effectFns && effectFns.forEach(effectFn=>effectFn())
}
复制代码

总结

多くの「バージョン反復」の後、Vueの応答システムの実装原理が包括的に分析されました。この分析と応答システムの調査を通じて、Vue応答システムがどのように実装されているかを明らかにするだけでなく、より重要な目的も可能です。 APIのAPI盲目的、Vueフレームワークをよりよく理解し、使用する理由を知ることです。同時に、実装プロセスで使用されるアーキテクチャ設計方法と導入されたデータ構造、ネストの問題のスタックやストレージデータ構造の解決などWeakMap=>Map => Set =>ストレージの問題を解決するも、Vue応答システムを学習する上で非常に大きなメリットです。シナリオは、フレームワークを理解することだけではありません。プログラミングの基本的な実践を学ぶための最良の方法です。

最後に書く

ブロガーは今後も良い記事を更新していきますので、ブロガーに注目してください!
記事があなたに役立つなら、好きで、集めて、フォローして、ブロガーと一緒に成長してください!❤❤❤

参考文献(謝辞)

「Vueの設計と実装」-HuoChunyang(HcySunYang)

おすすめ

転載: juejin.im/post/7084915514434306078