《Vue.js设计与实现》第四章响应系统的作用与实现

一、响应式数据与副作用函数

  1. 什么是响应式数据?
  2. 什么是副作用函数?
  3. 副作用函数在vue中起什么作用?
  4. vue中的副作用函数有哪些?

以上是读本章节我的疑问。

什么是副作用函数,什么是响应式数据书中是这样说的,如下:

副作用函数指的是会产生副作用的函数,如下面的代码所示:

function effect() {
  document.body.innerText = 'hello vue3'
}

当 effect 函数执行时,它会设置 body 的文本内容,但除了 effect 函数之外的任何函数都可以读取或设置 body 的文本内容。也就是说,effect 函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用。副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用,如下面的代码所示:

// 全局变量
let val = 1

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

什么是响应式数据呢,结合副作用函数来说:

理解了什么是副作用函数,再来说说什么是响应式数据。假设在一个副作用函数中读取了某个对象的属性:

const obj = { text: 'hello world' }
function effect() {
  // effect 函数的执行会读取 obj.text
  document.body.innerText = obj.text
}

如上面的代码所示,副作用函数 effect 会设置 body 元素的 innerText 属性,其值为obj.text,当 obj.text 的值发生变化时,我们希望副作用函数 effect 会重新执行:

obj.text = 'hello vue3' // 修改 obj.text 的值,同时希望副作用函数会重新执行

这句代码修改了字段 obj.text 的值,我们希望当值变化后,副作用函数自动重新执行,如果能实现这个目标,那么对象 obj 就是响应式数据。但很明显,以上面的代码来看,我们还做不到这一点,因为 obj 是一个普通对象,当我们修改它的值时,除了值本身发生变化之外,不会有任何其他反应。

副作用函数在vue中起什么作用呢:

当某个状态改变时,副作用函数可以自动执行,使得视图可以随之更新。例如响应式数据改变页面对应的dom更新。在组件加载时,可以在onMounted函数中发送 API 请求,并将获取的数据存储在ref或者reactive响应式状态中,进而更新视图。或者在侦听器watch中我们写的回调函数也被认为是副作用函数,监听数据改变,数据变化回调函数从新执行。

vue中的副作用函数有哪些呢:

主要集中在以下几个方面:

  • watch侦听器
  • 生命周期钩子(如 mounted和onMounted) 
  • nextTick 函数
  • ……

二、响应式数据的基本实现

const obj = { text: 'hello world' }
function effect() {
  // effect 函数的执行会读取 obj.text
  document.body.innerText = obj.text
}

观察这段代码:

当副作用函数 effect 执行时,会触发字段 obj.text 的读取操作;

当修改 obj.text 的值时,会触发字段 obj.text 的设置操作。

如果我们能拦截一个对象的读取和设置操作,事情就变得简单了,当读取字段 obj.text 时,我们可以把副作用函数 effect 存储到一个“桶”里,如图所示。

 接着,当设置 obj.text 时,再把副作用函数 effect 从“桶”里取出并执行即可,如图所示。

接下来我们就根据如上思路,采用 Proxy 来实现:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script>
         // 存储副作用函数的桶
         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)
    </script>
</body>
</html>

首先,我们创建了一个用于存储副作用函数的桶 bucket,它是Set 类型。接着定义原始数据 data,obj 是原始数据的代理对象,我们分别设置了 get 和 set 拦截函数,用于拦截读取和设置操作。当读取属性时将副作用函数 effect 添加到桶里,即bucket.add(effect),然后返回属性值;当设置属性值时先更新原始数据,再将副作用函数从桶里取出并重新执行,这样我们就实现了响应式数据。但是目前的实现还存在很多缺陷,例如我们直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活。副作用函数的名字可以任意取,我们完全可以把副作用函数命名为myEffect,甚至是一个匿名函数,因此我们要想办法去掉这种硬编码的机制。这里大家只需要理解响应式数据的基本实现和工作原理即可。

三、设计一个完善的响应系统

先看代码:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <script>
    // 存储副作用函数的桶
    const bucket = new WeakMap()

    // 原始数据
    const data = { text: 'hello world' }
    // 对原始数据的代理
    const obj = new Proxy(data, {
      // 拦截读取操作
      get(target, key) {
        // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
        track(target, key)
        // 返回属性值
        return target[key]
      },
      // 拦截设置操作
      set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        trigger(target, key)
      }
    })


    // 用一个全局变量存储被注册的副作用函数
    let activeEffect
    // effect 函数用于注册副作用函数
    function effect(fn) {
      // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
      activeEffect = fn
      // 执行副作用函数
      fn()
    }
    effect(
      // 一个匿名的副作用函数
      () => {
        document.body.innerText = obj.text
      }
    )
    // 1 秒后修改响应式数据
    setTimeout(() => {

      obj.text = 'hello vue3'
      obj.notExist = 'hello vue3'
    }, 1000)

    function track(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)
    }

    function trigger(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())
    }
  </script>
</body>

</html>

对以上代码做一个解读:

定义了一个全局变量 activeEffect,初始值是 undefined,它的作用是存储被注册的副作用函数。接着重新定义了 effect 函数,它变成了一个用来注册副作用函数的函数,effect 函数接收一个参数 fn,即要注册的副作用函数。我们使用一个匿名的副作用函数作为 effect 函数的参数。当 effect 函数执行时,首先会把匿名的副作用函数 fn 赋值给全局变量 activeEffect。接着执行被注册的匿名副作用函数 fn,这将会触发响应式数据 obj.text 的读取操作,进而触发代理对象 Proxy 的 get 拦截函数。由于副作用函数已经存储到了 activeEffect 中,所以在 get 拦截函数内应该把 activeEffect 收集到“桶”中,这样响应系统就不依赖副作用函数的名字了。

可以看到,匿名副作用函数内部读取了字段 obj.text 的值,于是匿名副作用函数与字段 obj.text 之间会建立响应联系。如果obj.text 的值改变,那么读取了obj.text的值的函数会从新执行。

但如果在响应式数据 obj 上设置一个不存在的属性时,例如添加一个notExist属性,这时会触发Proxy的set方法并从新执行副作用函数,字段 obj.notExist 并没有与副作用建立响应联系,因此,定时器内语句的执行不应该触发匿名副作用函数重新执行。也就是在实际项目开发中我们在一个响应式数据中添加一个新字段,那么不应该导致页面的从新渲染和dom更新。为了解决这个问题,我们需要重新设计“桶”的数据结构。

不应该直接使用Set 数据结构作为存储副作用函数的“桶”​。导致上述问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到“桶”里;当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。解决方法很简单,只需要在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构,而不能简单地使用一个 Set 类型的数据作为“桶”了。

那应该设计怎样的数据结构呢?在回答这个问题之前,我们需要先仔细观察下面的代码: 

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

//在这段代码中存在三个角色:
//被操作(读取)的代理对象 obj; 
//被操作(读取)的字段名 text; 
//使用 effect 函数注册的副作用函数 effectFn。

//在Proxy的get方法中储存副作用函数时
//如果用 target 来表示一个代理对象所代理的原始对象
//用 key 来表示被操作的字段名
//用 effectFn 来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:

target
    └── key
        └── effectFn
//1.如果有两个副作用函数同时读取同一个对象的属性值:
effect(function effectFn1() {
  obj.text
})
effect(function effectFn2() {
  obj.text
})
//那么关系如下:
target
    └── text
        └── effectFn1
        └── effectFn2


//2.如果一个副作用函数中读取了同一个对象的两个不同属性:
effect(function effectFn() {
  obj.text1
  obj.text2
})
//那么关系如下:
target
    └── text1
        └── effectFn
    └── text2
        └── effectFn

//3.如果在不同的副作用函数中读取了两个不同对象的不同属性:
effect(function effectFn1() {
  obj1.text1
})
effect(function effectFn2() {
  obj2.text2
})
//那么关系如下:
target1
    └── text1
        └── effectFn1
target2
    └── text2
        └── effectFn2

总之,这其实就是一个树型数据结构。这个联系建立起来之后,就是被代理的响应式对象在设置某个字段时,只会从新触发被设置的这个字段对应的副作用函数,如果字段没被读取过,那么这个字段就不会收集到捅中,也就无论这个字段如何改变都不会触发任何副作用函数的执行。

解读一下track函数和trigger函数

track函数在get读取拦截时被执行,接收了get的参数,用于追踪,里面封装了把副作用函数收集到“桶”里的这部分逻辑。那是如何收集的呢?观察代码使用了WeakMap、Map 和Set:WeakMap 由 target --> Map 构成; Map 由 key --> Set 构成。其中 WeakMap 的键是原始对象 target,WeakMap 的值是一个 Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。它们的关系如图 所示。

trigger函数在set设置拦截是被执行,接收了set的参数,用于触发,里面封装了触发副作用函数重新执行的逻辑。

四、分支切换与 cleanup

在使用vue开发项目时项目时,会经常在模板中写一些三元表达式如:

<template>
    <div>{{ obj.ok ? obj.text : 'not' }}</div>
</template>

<script setup lang="ts">
import { reactive } from 'vue'
let obj = reactive({
    ok : true,
    text:'hello world'
})
</script>

思考:执行代码页面显示的是'hello world',当obj.ok变为false时,此时会触发vue的render函数和diff算法从新执行,且更新视图,此时页面会显示'not'。想一下此时让obj.text的值无论如何变化还会触发vue的render函数和diff算法从新执行吗???答案是不会的。

在vue中是这样类似的处理方式,先看代码:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script>
        // 存储副作用函数的桶
        //  const bucket = new Set()
        const bucket = new WeakMap()

        // 原始数据
        const data = { ok: true, text: 'hello world' }
        // 对原始数据的代理
        const obj = new Proxy(data, {
            // 拦截读取操作
            get(target, key) {
                // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
                track(target, key)
                // 返回属性值
                return target[key]
            },
            // 拦截设置操作
            set(target, key, newVal) {
                // 设置属性值
                target[key] = newVal
                // 把副作用函数从桶里取出并执行
                trigger(target, key)
            }
        })


        // 用一个全局变量存储被注册的副作用函数
        let activeEffect
        // effect 函数用于注册副作用函数
        function effect(fn) {
            // // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
            // activeEffect = fn
            // // 执行副作用函数
            // fn()
            const effectFn = () => {   
                //调用 cleanup 函数完成清除工作
                cleanup(effectFn)
                activeEffect = effectFn
                fn()
            }
            effectFn.deps = [] //函数也是对象可以为函数添加属性
            effectFn()
        }
        function cleanup(effectFn) { 
           
            // 遍历 effectFn.deps 数组
            for (let i = 0; i < effectFn.deps.length; i++) {
                // deps 是依赖集合
                const deps = effectFn.deps[i]
                // 将 effectFn 从依赖集合中移除
                deps.delete(effectFn)
            }
            // 最后需要重置 effectFn.deps 数组
            effectFn.deps.length = 0
        }
        effect(
            // 一个匿名的副作用函数
            () => {
                // document.body.innerText  = obj.text
                document.body.innerText = obj.ok ? obj.text : 'not'
            }
        )
        // 1 秒后修改响应式数据
        setTimeout(() => {
            obj.ok = false
            obj.text = 'hello vue3'
        }, 1000)

        function track(target, key) {
            // 没有 activeEffect,直接 return
            if (!activeEffect) return 
            // 根据 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)
            //把当前激活的副作用函数添加到依赖集合 deps 中
            //deps就是一个与当前副作用函数存在联系的依赖集合
            //将其添加到 activeEffect.deps数组中
            activeEffect.deps.push(deps)

        }

        function trigger(target, key, newVal) {
      
            // 根据 target 从桶中取得 depsMap,它是 key --> effects
            const depsMap = bucket.get(target)
            //此时桶中储存了两个副作用函数 但是只做 obj.ok = false这一个设置操作 所以其中一个副作用函数是多余的
            if (!depsMap) return
            // 根据 key 取得所有副作用函数 effects
            const effects = depsMap.get(key)
            // 执行副作用函数
            const  effectsToRun = new Set(effects)
           
            effectsToRun && effectsToRun.forEach(effectFn => effectFn())
            // effects&&effects.forEach(effectFn => effectFn())
        }
    </script>
</body>

</html>

对以上代码做一个解读:

首先,我们需要明确分支切换的定义,如下面的代码所示:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值