逐行代码,带你实现一个简版vue3响应式系统!

208 阅读6分钟

1. 开始

我们知道vue2的响应式是基于Object.defineProperty实现的,而vue3的响应式是基于proxy实现的,关于proxy可以到14. Proxy - Proxy 实例的方法 - 《阮一峰 ECMAScript 6 (ES6) 标准入门教程 第三版》 - 书栈网 · BookStack去学习或者复习。本篇文章来实现一个简版的vue3响应式系统,借此来更深入的了解依赖是如何收集的,以及当响应式数据改变后,依赖是如何更新的。

首先

2. reactive

首先来实现第一个关键的apireactive,reactive是一个方法,调用后会返回一个对象的代理。当我们去修改这个代理对象中的属性值时,所用到这个对象属性的视图也会同步更新,通过这句话基本就可以明确我们需要做的主要是两步:1. 监听对象值的改变,2. 修改对应的视图。

1. 监听对象值的改变

关于对象值的监听,我们借助proxy的get和set函数,具体如下,我们新建index.js,这里我们导出reactive方法,允许使用者传入一个对象,并实现对象属性值读取和修改的监听。

const isObject = (target) => typeof target === 'object' && target !== null

export const reactive = function reactive (target) {

  if(!isObject(target)) {
    console.error('函数必须接收一个对象')
    return target
  }

  return new Proxy(target, {
    get (target, key, receiver) {
      console.log('触发了get函数', target, key)
      return Reflect.get(target, key, receiver)
    },
    set (target, key, value, receiver) {
      let res = Reflect.set(target, key, value, receiver)
      console.log('触发了set函数', target, key, "新的值是:" + value)
      return res
    }
  })
}

回到index.html 中,我们的结构初始化是这样的

<!DOCTYPE html>
<html lang="en">
<meta charset="UTF-8">
<head>
    <title>vue3响应式系统简版</title>
</head>
<body>
<div id="app">
    <div class="person-info"></div>
    <button class="person-btn">按钮-person</button>
</div>
<script type="module">
  import { reactive } from './index.js'
  let personInfo = document.querySelector('.person-info')
  let personBtn = document.querySelector('.person-btn')

  let person = reactive({
    name: 'm',
    age: 20
  })

  personBtn.addEventListener('click', () => {
    person.age = 18
    personInfo.innerHTML = `姓名:${person.name},年龄:${person.age}`
  })
</script>
</body>
</html>

逻辑非常简单,定义了一个代理对象person,当点击按钮的时候,去修改代理对象的值并在页面显示,于是我们的控制台就能得到这样的输出:

11.png

可以看到,当按钮的点击事件触发后,执行了一次set 函数,拦截到了person.age = 18的赋值操作。执行了两次get函数,分别拦截到了上面代码段中第24行的两次取值操作。观察上面的效果,我们好像已经实现了点击按钮页面页面同步更新的效果,但是其实是使用原生js实现。回想在vue中,我们只需要在模板中写插值语法,在js中不用去关注dom,修改响应式数据后,视图就同步发生变化了,那么是怎么做到的呢?

其实在vue中有一步叫做模板解析,它会将你的template模板按照字符串审视,匹配到差值语法中的变量,从而转换成上面代码段中24行那样的代码。如:

<template>
  <div class="person-info">姓名:{{person.name}},年龄:{{person.age}}</div>
</template>
// ---------------------------------------------------------------⬇⬇⬇⬇
personInfo.innerHTML = `姓名:${person.name},年龄:${person.age}`

理解了原理,由于我们没有vue中的模板解析,所以我们就用一个函数fn来模拟下模板解析后的结果:

  const fn = () => {
    personInfo.innerHTML = `姓名:${person.name},年龄:${person.age}`
  }

  personBtn.addEventListener('click', () => {
    person.age = 18
    fn()
  })

2. 数据改变引起视图改变

每当我们点击按钮的时候去调用这个函数,但是迄今为止还是我们在手动的去调用函数来实现视图的更新,那么如何将变量的修改和视图的更新关联起来呢?这时候我们就需要利用get函数了,当变量被修改时,我们就来调用函数更新视图,我们可以这样做:在index.js中创建一个effect函数,用来收集副作用函数(可以理解为,响应式数据改变后,需要重新执行的函数),并在内部调用:

export const effect = function (fn){
  fn()
}

export const reactive = function reactive (target) {
    // 这里省略没有改动的代码
    set (target, key, value, receiver) {
      let res = Reflect.set(target, key, value, receiver)
      console.log('触发了set函数', target, key, "新的值是:" + value)
      effect()
      return res
    }
  })
}

在index.html中,我们引入effect函数并将代码改造如下:

  import { reactive, effect } from './index.js'
  // 这里省略没有改动的代码
  effect(() => {
    personInfo.innerHTML = `姓名:${person.name},年龄:${person.age}`
  })

  personBtn.addEventListener('click', () => {
    console.log('点击了按钮')
    person.age += 1
  })

我们的预期是,当点击按钮后,给person.age字段赋值->触发set函数->set函数内部的effect函数调用->页面视图发生变化,但是真的能达到我们的预期吗?

22.jpg

当我们点击按钮后,控制台输出了一个报错,我们来分析下,上面代码段中的第3行调用了effect函数,effect函数内部帮我们调用了我们传入的函数,我们传入的函数读取了两次代理对象中的变量,所以最开始,我们的控制台触发了两次get函数。当我们点击按钮后为什么会报错?我们知道,js引擎在去执行函数时,会创建一个函数执行上下文,当这个函数调用完成之后,这个函数执行上下文也会随之消失,从而也就访问不到传入的函数而引起报错,那么要如何去保留我们传入的函数呢?我们可以这样做的,在index.js中定义一个模块内的全局变量activeEffect,当每次set函数触发时,就去调用这个变量保存的方法:

let activeEffect = null
export const effect = function (fn){
  activeEffect = fn
  activeEffect()
}

export const reactive = function reactive (target) {
    // 这里省略没有改动的代码
    set (target, key, value, receiver) {
      let res = Reflect.set(target, key, value, receiver)
      console.log('触发了set函数', target, key, "新的值是:" + value)
      // 这里调用activeEffect
      activeEffect()
      return res
    }
  })
}

这样我们的控制台就不会报错并且功能也可以正常运作了:

33.jpg

3. 实现深层次对象代理

现在我们要代理的对象不仅仅是一层,而是变成了这样:

  let person = reactive({
    name: 'm',
    age: 20,
    hobbies: ['唱', '跳', 'rap', '足球'],
  })

  effect(() => {
    personInfo.innerHTML = `姓名:${person.name},年龄:${person.age},爱好:${person.hobbies[3]}`
  })

  personBtn.addEventListener('click', () => {
    console.log('点击了按钮')
    person.hobbies[3] = '篮球'
  })

44.jpg

我们发现,当我们像修改第四个爱好为篮球时,只触发了person对象get方法,我们hobbies对象的set方法并没有被执行,原因是我们的hobbies对象并不是一个被Proxy代理的对象,所以,当我们修改hobbies[3]并不会触发set方法。为了解决这个问题,我们只需要在触发person的get方法中判断,如过person中的变量是对象的话,再调用一次reactive方法,让它也成为一个proxy代理对象:

export const reactive = function reactive (target) {
  return new Proxy(target, {
    // 这里省略没有改动的代码
    get (target, key, receiver) {
      console.log('触发了get函数', target, key)
      // 深度代理
      if (isObject(target[key])) {
        return reactive(target[key])
      }
      return Reflect.get(target, key, receiver)
    },
     // 这里省略没有改动的代码
  })
}

55.jpg

可以看到,我们点击按钮后,先触发了get函数,此时hobbies也被变成了代理对象,从而能够拦截到hobbies对象的set方法。

4. 依赖的收集和触发

为什么需要依赖收集?我们来举个例子,现在,我们再创建一个cat对象,它也需要被代理,并且需要在视图展示:

<div id="app">
    <div class="person-info"></div>
    <button class="person-btn">按钮-person</button>
    <hr>
    <div class="cat-info"></div>
    <button class="cat-btn">按钮-cat</button>
</div>
  // 这里省略没有改动的代码 
  let catInfo = document.querySelector('.cat-info')
  let catBtn = document.querySelector('.cat-btn')
  let cat = reactive({
    name: 'mimi',
    age: 2,
    hobbies: ['吃鱼', '抓老鼠'],
  })
  effect(() => {
    console.log('小猫的effect执行了')
    catInfo.innerHTML = `姓名:${cat.name},年龄:${cat.age},爱好:${cat.hobbies[0]}`
  })
  catBtn.addEventListener('click', () => {
    console.log('点击小猫的按钮')
    cat.hobbies[0] = '吃骨头'
  })

这时候我们再去点击”按钮-person“就不生效了,而是会在控制台打印小猫的effect执行了,这是为什么?我们的effect函数触发了两次,但是activeEffect变量只有一个,当执行cat对象的effect后,activeEffect就变成了:

activeEffect = () => {
  console.log('小猫的effect执行了')
  catInfo.innerHTML = `姓名:${cat.name},年龄:${cat.age},爱好:${cat.hobbies[0]}`
}

所以,我们现在要考虑的就是如何使被依赖项发生变化会执行自己的副作用函数?最开始想到的可能就是再定义一个activeEffect2变量来收集cat对象的副作用函数,然后在set触发后去判断执行那个effect,这是一种方法,但是一旦我们再去添加dog对象,pig对象...代码就会变得无法维护了。

现在我们来实现一个track函数来收集依赖,这个函数的作用是将effect副作用函数和指定的对象关联起来,那么问题就来了,**依赖收集起来应该用什么来保存?应该在什么时候去收集依赖?**先说第一个问题,在vue3源码中,创建了weakMap对象来保存依赖,关于weakMapMap的区别,主要是weakMap的建是弱引用,当这个对象不再被需要的时候会被垃圾回收机制直接回收,进而节省内存,这也是vue性能优化的一个点,关于其它区别,可以自行查阅资料。这里如果对weakMap不了解,可以直接让他看成一个普通的Map。我们知道一个对象可能有多个键,每个键有可能会有多个依赖项,所以,用对象和数组表示,我们能的出来这样的数据结构

// 保存全部依赖关系
targetMap:{
  // 保存person的依赖
  person:{
    // 保存name属性的依赖项
    name:[],
    // 保存age属性的依赖项
    age:[],
    // 保存hobbies属性的依赖项
    hobbies:[]  
  },
  // 同理
  cat:{
    ....
  },
  .....
}

所以我们的track方法就是下面这样:

const targetMap = new WeakMap() // 保存全部依赖
// 收集依赖
export const track = (target, key) => {
  // 获取对象对应的依赖
  let depsMap = targetMap.get(target)
  // 如果不存在则初始化
  if (!depsMap) {
    targetMap.set(target, depsMap = new Map())
  }

  // 获取对象中key对应的依赖
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, deps = new Set())
  }

  // 将key对应的依赖项收集起来
  deps.add(activeEffect)
}

解决了用什么保存依赖及track函数,我们再来说第二个问题,什么时候收集依赖?我们知道,每个effect函数都会在内部帮我们调用传入的副作用函数,此时就会触发对应对象的get方法,我们来打些log方便查看

  effect(() => {
    console.log('人的effect执行了')
    personInfo.innerHTML = `姓名:${person.name},年龄:${person.age},爱好:${person.hobbies[3]}`
  })
  effect(() => {
    console.log('小猫的effect执行了')
    catInfo.innerHTML = `姓名:${cat.name},年龄:${cat.age},爱好:${cat.hobbies[0]}`
  })
export const reactive = function reactive (target) {
  return new Proxy(target, {
    get (target, key, receiver) {
      console.log('触发了get函数', target, key, '此时的activeEffect是:' + activeEffect)
      // 深度代理
      if (isObject(target[key])) {
        return reactive(target[key])
      }
      return Reflect.get(target, key, receiver)
    },
    // 这里省略没有改动的代码
    }
  })
}

我们可以看到控制台会有如下输出:

66.jpg

代码是这样运行的:第一个effect函数触发 -> get函数触发,此时的activeEffect是person对象的effect,第二个effect函数触发 -> get函数触发,此时的activeEffect是cat对象的effect,所以,我们在get函数触发的时候就能很轻易的将依赖收集起来

export const reactive = function reactive (target) {
  return new Proxy(target, {
    get (target, key, receiver) {
      console.log('触发了get函数', target, key, '此时的activeEffect是:' + activeEffect)
      // 在这里收集依赖
      track(target, key)
      // 深度代理
      if (isObject(target[key])) {
        return reactive(target[key])
      }
      return Reflect.get(target, key, receiver)
    },
    // 这里省略没有改动的代码
    }
  })
}

依赖收集完,接下来要考虑的就是如何触发依赖,之前我们是在set函数中调用activeEffect函数来更新视图,现在只需要在set函数中触发targetMap中收集的依赖项即可,触发依赖前,我们来实现下依赖触发的方法trigger

// 触发副作用函数
export const trigger = (target, key) => {
  // 获取对象对应的依赖
  const depsMap = targetMap.get(target)
  // 获取对象中key对应的依赖
  const deps = depsMap.get(key)
  // 执行依赖
  deps.forEach(effect => effect())
}

最后我们只需要在set函数中触发对应的依赖即可:

    set (target, key, value, receiver) {
      let res = Reflect.set(target, key, value, receiver)
      // 在这里触发依赖
      trigger(target, key)
      console.log('触发了set函数', target, key, "新的值是:" + value)
      activeEffect()
      return res
    }

最后在到页面测试,我们的功能就没问题了,到这里也就实现了vue3简版的响应式系统。

3. ref

关于ref,下面是vue官方给出的介绍:

接受一个内部值,返回一个响应式的、可更改的 ref 对象,此对象只有一个指向其内部值的属性 .value

首先要知道ref也是一个函数,它的返回值是一个对象,并且ref是可以接收原始数据类型的,在没看源码之前,我们可以猜想下,ref是如何实现的,proxy吗?但是proxy并不能代理原始数据类型,defineProperty吗?这好像是vue2的产物了。看过vue的源码,我们发现它是采用了更灵活的浅代理和属性访问器模式来实现响应式,接下来我们来简单实现下。

export const ref = (value) => {

  class RefImpl {
    _value

    constructor (value) {
      this._value = value
    }

    get value () {
      console.log('触发了ref的get value函数')
      return this._value
    }

    set value (newValue) {
      console.log('触发了ref的set value函数,最新值是:', newValue)
      this._value = newValue
    }
  }

  return new RefImpl(value)
}

可以看到,ref函数调用后其实是返回了RefImpl类的实例,并且监听了value的get和set操作,这也就是为什么我们使用ref要加.value的原因,下面我们在index.html中引入ref方法进行测试

    <div class="person-info"></div>
    <button class="person-btn">按钮-person</button>
  let personInfo = document.querySelector('.person-info')
  let personBtn = document.querySelector('.person-btn')
  let person = ref('发发发')
  personInfo.innerHTML = `${person.value}`
  personBtn.addEventListener('click', () => {
    console.log('点击了按钮')
    person.value += '!'
  })

77.png

对于原始数据类型

可以看到,当我们去修改和读取value的时候都能监听到,所以我们只需要在get中完成依赖收集,并且在set中触发即可:

    get value () {
      console.log('触发了ref的get value函数',this._value)
      track(this, value)
      return this._value
    }

    set value (newValue) {
      console.log('触发了ref的set value函数,最新值是:', newValue)
      this._value = newValue
      trigger(this, value)
    }
  let person = ref('发发发')
  personBtn.addEventListener('click', () => {
    console.log('点击了按钮')
    person.value += '!'
  })

  effect(() => {
    console.log('人的effect执行了')
    personInfo.innerHTML = person.value
  })

88.png

复杂数据类型

但是要注意上面我们传入的是原始属性,如果传入一个对象,那个对象内部的属性访问和赋值都是无法被监听的,如下:

  let person = ref({
    name: 'm',
    age: 20,
    hobbies: ['唱', '跳', 'rap', '足球'],
  })
  personInfo.innerHTML = `${person.value.age}`
  personBtn.addEventListener('click', () => {
    console.log('点击了按钮')
    person.value.age += 1
  })

当我们点击按钮的时候并不会触发set函数,所以要解决这个问题,我们可以在构造函数中加个判断,如果传入的值是对象的话就交给reactive方法处理,如果是原始类型的话就直接走get和set方法,

    constructor (value) {
      // 这里判断value的数据类型
      this._value = isObject(value) ? reactive(value) : value
    }
    get value () {
      console.log('触发了ref的get value函数',this._value)
      return this._value
    }

我们借用上面实现的effect函数,改造我们的index.html,并运行观察控制台的输出

  let personInfo = document.querySelector('.person-info')
  let personBtn = document.querySelector('.person-btn')
  let person = ref({
    name: 'm',
    age: 20,
    hobbies: ['唱', '跳', 'rap', '足球'],
  })
  personBtn.addEventListener('click', () => {
    console.log('点击了按钮')
    person.value.age += 1
  })

  effect(() => {
    console.log('人的effect执行了')
    personInfo.innerHTML = `姓名:${person.value.name},年龄:${person.value.age},爱好:${person.value.hobbies[3]}`
  })

99.png

我们发现,类内部的get方法打印出来的this._value直接变成了proxy对象。代码的执行顺序是这样的:调用RefImpl类的构造函数->value是对象,交由reactive处理->effect首次执行,分别触发get value和proxy对象内部的get方法,并在proxy对象内部的get方法中完成依赖收集 -> 点击按钮触发proxy对象的set方法->触发依赖更新。