Vue响应式原理的简单模型

1.前言

最近在梳理vue响应式的原理,有一些心得,值得写一篇博客出来看看。
其实之前也尝试过了解vue响应式的原理,毕竟现在面试看你用的是vue的话,基本上都会问你几句vue响应式的原理。以往学习这块就是看看别人写的文章,或者翻翻源码。这个过程中发现相当一部分文章看完之后一句话总结就是—— vue通过 Object.defineProperty 或者 Proxy API 拦截了数据的getter/setter,再在getter/setter里面做数据响应的相关逻辑。除此之外就一无所知了。与此同时又发现直接看源码的话,所花费的时间跟收获比太小了。当然,看源码困难的原因还是现在的水平不够,没到看源码的那一步。
后来我在知乎上看到 一年内的前端看不懂前端框架源码怎么办?这个问题,以及尤雨溪对 维护一个大型开源项目是怎样的体验? 这个问题的回答。思考总结了这么几点启示:

  1. 注意数据结构、算法、设计模式等基础能力。这些基础能力的不足,往往导致我们理解不了源码作者的想法。同时,提升这些基础能力也可以提高我们的上限。
  2. 从某些功能点入手,不要从入口文件开始看。比如vue,我对其响应式原理比较感兴趣,我就专门去看响应式相关的源码和文章。
  3. 不要试图弄清源码的每一行代码,首要的是掌握其思想。源码的细节非常多,陷入其中往往会对阅读源码造成极大的困难。最重要的是学习源码为了实现某些功能而采用了什么方法,没有必要了解源码每一行都干了什么。
  4. 看一百遍,不如做一遍。有时候光看源码和文章,不一定能有多少收获,或者看了就忘了。其实按照自己的理解或者想法动手实现一遍会更好。具体实现细节不一定要和源码对齐,功能上也可以简陋一点。我相信自己做一遍,能帮助我们更为高效的理解源码。

Vue响应式的关键在于数据劫持和发布订阅模式,数据劫持的目的是在于实现发布订阅,通过发布订阅模式,在数据发生变更时通知到各“订阅者”,也就是Vue中的各种模板语法、计算属性、侦听器等等。下面就先介绍与发布订阅相关的几个概念,然后通过自己实现一个简单版Vue的方式介绍一下Vue2.0和Vue3.0响应式的简单模型。注意只是简单模型,但应该可以帮你应付大部分的面试。
本篇文章所用到的代码都放到了GitHub上

2.观察者模式

2.1 概念

观察者模式是一种通知机制,让发送通知的一方(被观察者)和接收通知的一方(观察者)能彼此分离,互不影响。
观察者模式和发布订阅模式是有区别的,具体后面会详细介绍。

2.2 示例

这里通过一个简单的例子展示观察者模式,具体的业务逻辑是在商店有新商品上架时通知到顾客。为了方便,这个例子可以直接通过node运行,不需要关注ui的实现。

// 被观察者
module.exports = class Store {
     
  constructor() {
     
    // 商品列表
    this.products = new Set()
    // 观察者列表
    this.observers = []
  }

  // 注册观察者
  addObserver(watcher) {
     
    this.observers.push(watcher)
  }

  // 有新商品时通知观察者
  addProduct(name) {
     
    if (this.products.has(name)) return

    this.products.add(name)

	// 遍历观察者列表,调用观察者对应处理方法
    this.observers.forEach((watcher) => watcher.onPublished(name))
  }
}
// 观察者
module.exports = class watcher {
     
  constructor(name) {
     
    this.name = name
  }

  onPublished(product) {
     
    console.log(`观察者${
       this.name}观察到商品${
       product}上架`)
  }
}
// 在商店有新品上架时,通知顾客
const Store = require('./store')
const Watcher = require('./watcher')

const supermarket = new Store()
const watchA = new Watcher('A')
const watchB = new Watcher('B')

supermarket.addObserver(watchA)
supermarket.addObserver(watchB)

supermarket.addProduct('香蕉')

setTimeout(() => {
     
  supermarket.addProduct('苹果')
}, 3000)

观察者模式有个明显的特征,那就是被观察者维护了一份观察者列表,也就是说被观察者知道有哪些观察者在观察自己。在需要进行通知的时候,遍历列表,通知观察者们。

3.发布订阅模式

3.1 与观察者模式的区别

参考上面观察者模式的例子,在观察者模式中,被观察者主动收集观察者。
而在发布订阅模式中,发布者不需要主动收集订阅者,订阅者订阅发布平台,发布者将变更推给发布平台,由发布平台告知订阅者。

3.2 打个比方

我在 GitHub 上看到一个很形象的比喻:

发布-订阅模式就好像报社, 邮局和个人的关系,报纸的订阅和分发是由邮局来完成的。报社只负责将报纸发送给邮局。
观察者模式就好像 个体奶农和个人的关系。奶农负责统计有多少人订了产品,所以个人都会有一个相同拿牛奶的方法。奶农有新奶了就负责调用这个方法。

3.3 示例

根据上面的比喻,我们实现一个简单的读者通过邮局订阅报纸的项目

// 发布者,也就是报社
module.exports = class Publisher {
     
  constructor() {
     
    this.subscribers = []
  }

  addSubscriber(subscriber) {
     
    this.subscribers.push(subscriber)
  }

  publish(value) {
     
    this.subscribers.forEach((subscribe) =>{
     
		subscribe.update(value)
	})
  }
}
// 订阅者,也就是读者
module.exports = class Subscriber {
     
  constructor(cb) {
     
    this.cb = cb
  }

  update(val) {
     
    this.cb(val)
  }

  // 订阅者主动订阅发布平台
  subscribe(publisher) {
     
    publisher.addSubscriber(this)
  }
}
// 发布平台,也就是邮局
module.exports = class Publisher {
     
  constructor() {
     
    this.subscribers = []
  }

  addSubscriber(subscriber) {
     
    this.subscribers.push(subscriber)
  }

  publish(value) {
     
    this.subscribers.forEach((subscribe) =>{
     
		subscribe.update(value)
	})
  }
}
// 通过邮局订阅报纸
const Producer = require('./producer')
const Publisher = require('./publisher')
const Subscriber = require('./subscriber')

// 创建一个报社
const newspaper = new Producer()

// 创建两个邮局用于分发报纸
const postOfficeA = new Publisher()
const postOfficeB = new Publisher()

// 创建三个读者,通过不同的报社订阅报纸
const readerA = new Subscriber((value) => {
     
  console.log(`读者A收到${
       value}`)
})
const readerB = new Subscriber((value) => {
     
  console.log(`读者B收到${
       value}`)
})
const readerC = new Subscriber((value) => {
     
  console.log(`读者C收到${
       value}`)
})

readerA.subscribe(postOfficeA)
readerA.subscribe(postOfficeB)
readerB.subscribe(postOfficeB)
readerC.subscribe(postOfficeB)

newspaper.addPublisher(postOfficeA)
newspaper.addPublisher(postOfficeB)

newspaper.publish('新青年')

从上面的代码可以看出,发布订阅模式与观察者模式最大的不同在于多了一个发布平台。除此之外,发布者不用主动收集订阅者,而是指定发布平台。同时,订阅者也需要在发布平台中注册自己,也就是把自己添加到发布平台内维护的一份订阅者列表中。这里发布平台并没有主动收集订阅者,而是订阅者调用发布平台对应方法,将自己注册到发布平台中。发布者变更时首先通知发布平台,发布平台再遍历自己的订阅者列表,将变更告知订阅者们。

4.简单模型

4.1 发布订阅逻辑

在了解了观察者模式,以及发布订阅模式之后,我们就可以理解Vue响应式的基本模型了。在Vue的官网上有这么一张图:
Vue响应式原理的简单模型_第1张图片
这张图大概说明了发布订阅的相关逻辑,通过自己的理解提炼出了三个角色:

  • 发布者
    Vue中声明的data
  • 订阅者
    Vue中的模板语法、计算属性、侦听器等等都是订阅者,订阅者通常都有类似于“回调函数”的机制,在变更的回调函数中消费data
    提供一个方法处理发布者变更
  • 发布平台
    data中的每一个属性都有对应的发布平台
    发布平台收集使用到该属性的订阅者
    发布者发送变化时,通知订阅者属性发生变更

三个角色联动的关键问题有三个:

  • 如何为data每一个属性创建发布平台
    通过遍历data的key,为每一个key创建发布平台
  • 发布平台如何收集订阅者
    1.订阅者在创建时,会调用其变更回调函数,触发所用到属性的getter。
    2.发布平台维护一个单例 Dep.depTarget,订阅者调用回调函数前将Dep.depTarget指向自己,回调完成后将Dep.depTarget置为null。
    3.发布平台事先通过Object.defineProperty/Proxy API劫持get方法,在属性被get时将Dep.depTarget添加到该属性发布平台的订阅者列表中,这个过程会判断Dep.depTarget是否为空、所指向的订阅者是否已经在列表中等等。
  • 发布平台如何获取发布者发送变化
    发布平台事先通过Object.defineProperty/Proxy API劫持set方法,在set方法中调用该属性发布平台订阅者列表中各订阅者的处理方法

4.2 Vue2.0响应式的简单模型

正如前言所说,自己实现一遍,能够更好的理解源码。这里就简单的通过 Object.defineProperty 实现监听数据更新进而刷新页面。
首先创建一个html文件和js文件



<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>轻量级vue2.0实现title>
head>
<body>
  <div id="app">div>
  
  <script type="module" src="./index.js">script>
body>
html>
// index.js
import Vue from './src/vue.js'

const App = new Vue({
     
  el: '#app',
  data() {
     
    return {
     
      name: '特朗普',
      info: {
     
        message: '没有人比他更懂',
      },
    }
  },
  render(createElement) {
     
    return createElement(
      'div',
      [
        createElement('span', `${
       this._data.name} 说: ${
       this._data.info.message}`)
      ]
    )
  }
})

setTimeout(() => {
     
  App._data.name = '川宝'
}, 2000)

setTimeout(() => {
     
  App._data.info.message = 'MAGA!!!!'
}, 4000)

在index.js中我们创建了一个自己实现的Vue实例,将其挂在到id为app的节点上,接下来实现 ./src/vue.js中的代码

// ./src/vue.js
// 引入处理数据劫持的函数
import observe from './observer.js'
// 引入观察者
import Watcher from './watcher.js'

class Vue {
     
  constructor(options) {
     
    this.$options = options
    this._data = options.data()
    this.render = options.render
    this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el

	// 数据劫持
    observe(this._data)

    // 创建一个订阅者,订阅_data的变更
    // 订阅者收到变更通知时重新渲染组件
    new Watcher(this._data, ()=> {
     
      this.$mount()
    })
  }

  // 这里就是创建html节点
  createElement(tagName, children) {
     
    let element = document.createElement(tagName)

    if (Object.prototype.toString.call(children) === '[object Array]') {
     
      children.forEach((child) => {
     
        element.appendChild(child)
      })
    } else {
     
      element.textContent = children
    }

    return element
  }

  // 创建并挂载节点
  $mount() {
     
    const elements = this.render(this.createElement)
    this.$el.innerHTML = ''
    this.$el.appendChild(elements)
  }
}

export default Vue

接下来就是关键的处理数据劫持的方法

// ./src/observer.js
import Dep from './dep.js'

const typeTo = (val) => Object.prototype.toString.call(val)

// 重写属性get/set方法
function defineReactive(obj, key, val) {
     
  // 每个对象的属性都有一个 Dep 作为该属性变更的发布平台
  let dep = new Dep()

  Object.defineProperty(obj, key, {
     
    enumerable: true,
    configurable: true,
    // get时发布平台收集订阅者
    get() {
     
      console.log(`get ${
       key}`)

      if (Dep.depTarget && Dep.depTarget.id >= 0) {
     
        console.log(`当前订阅者id: ${
       Dep.depTarget.id}`)
      }

      dep.addSub(Dep.depTarget)

      return val
    },
    // set时发布平台dep通知订阅者
    set(newValue) {
     
      console.log(`set ${
       key}`)

      if (newValue === val) return

      val = newValue
      dep.notify()
    },
  })
}

function walk(obj) {
     
  Object.keys(obj).forEach((key) => {
     
    // 如果值是对象,继续处理对象内部的字段
    if(typeTo(obj[key]) === '[object Object]'){
     
      walk(obj[key])
    }

    // 处理属性本身
    defineReactive(obj, key, obj[key])
  })
}

// observe用于劫持数据
function observe(obj) {
     
  if(typeTo(obj) !== '[object Object]') {
     
    return null
  }

  walk(obj)
}

export default observe

在./src/observer.js 中引入了dep.js,这便是发布订阅模型中的发布平台对应的类。在observer.js 中通过遍历对象的key,为每个key创建发布平台。当属性get被触发时,证明有订阅者在使用该属性,此时Dep.depTarget会指向使用该属性的订阅者,将其添加到发布平台的订阅者列表中。当属性被设值时,触发set,发布平台遍历订阅者列表,通知订阅者。
在看./src/dep.js的实现

// ./src/dep.js
// 发布订阅模型中的发布平台
class Dep{
     
  constructor() {
     
    // 订阅者列表
    this.subs = []
  }

  addSub(sub) {
     
  	// 如果订阅者不在订阅者列表,就把它添加进来
    if(sub && (this.subs.indexOf(sub) === -1)) {
     
      this.subs.push(sub)
    }
  }

  notify() {
     
    console.log('通知变更', this.subs.length)
    this.subs.length > 0 && this.subs.forEach((sub) => {
     
      sub.update()
    })
  }
}

Dep.depTarget = null

export default Dep

这部分代码比较易懂,剩下的就是./src/watcher.js的实现了

// ./src/watcher.js
import Dep from './dep.js'

// 通过id区分不同的订阅者
let id = 0

class Watcher{
     
  // 在订阅者创建时,将单例Dep.depTarget指向当前订阅者
  constructor(value, cb) {
     
    this.cb = cb

    // 创建时调用get方法的目的主要是通过调用cb触发所用到的属性的get
    // 进而在对应属性的发布平台中添加该订阅者
    this.get()

    // this.val指向vue._data
    this.val = value

    // 通过id可以确定是否为同一个订阅者
    this.id = ++id
  }

  get() {
     
    Dep.depTarget = this

    this.cb()

    // 发布平台收集完订阅者后重置单例
    Dep.depTarget = null
  }

  // 订阅者在更新的时候调用this.cb()触发所用到属性的get
  // 如果该订阅者不在对应属性发布平台的订阅者列表中,则会被添加进列表
  update() {
     
    this.get()

    console.log('val value', this.val.name, this.val.info.message)
  }
}

export default Watcher

现在,这个简易版的Vue2.0就可以在浏览器里看到效果了
Vue响应式原理的简单模型_第2张图片

4.2 Vue3.0响应式的简单模型

在简易版的实现中,3.0和2.0的不同主要体现在 ./src/observer.js 用Proxy API做数据劫持,由于Proxy API返回一个Proxy对象,因此index.js和./src/vue.js某些地方的写法也有所不同

// ./src/observer.js 
// 使用proxy api做数据劫持
import Dep from './dep.js'

const typeTo = (val) => Object.prototype.toString.call(val)

// 重写属性get/set方法
function defineReactive(obj) {
     
  // 每个对象的属性都有一个 Dep 作为该属性变更的发布平台
  let dep = new Dep()

  if (typeTo(obj) !== '[object Object]') {
     
      return null
  }

  return new Proxy(obj, {
     
    get(target, key) {
     
      console.log('触发get', target, key)

      dep.addSub(Dep.depTarget)

      return target[key]
    },
    set(target, key, value, receiver) {
     
      console.log('触发set', target, key, value)

      let newValue = Reflect.set(target, key, value, receiver)

      dep.notify()

      return true
    }
  })
}

function walk(obj) {
     
  const res = {
     }

  Object.keys(obj).forEach((key) => {
     
    if (typeTo(obj[key]) === '[object Object]') {
     
      // 如果值是对象,继续处理对象内部的字段
      res[key] = walk(obj[key])
    } else {
     
      // 如果不是对象,则赋值
      res[key] = obj[key]
    }
  })

  // 记得处理该属性本身
  return defineReactive(res)
}

// observe用于劫持数据
function observe(obj) {
     
  if(typeTo(obj) !== '[object Object]') {
     
    return null
  }

  return walk(obj)
}

export default observe

// ./src/vue.js 
import observe from './observer.js'
import Watcher from './watcher.js'

class Vue {
     
  constructor(options) {
     
    this.$options = options
    this._data = options.data()
    this.render = options.render
    this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el

	// 这里和2.0不同
    this.$data = observe(this._data)

    // 创建一个订阅者,订阅_data的变更
    // 订阅者收到变更通知时重新渲染组件
    new Watcher(this.$data, ()=> {
     
      this.$mount()
    })
  }

  createElement(tagName, children) {
     
    let element = document.createElement(tagName)

    if (Object.prototype.toString.call(children) === '[object Array]') {
     
      children.forEach((child) => {
     
        element.appendChild(child)
      })
    } else {
     
      element.textContent = children
    }

    return element
  }

  $mount() {
     
    const elements = this.render(this.createElement)
    this.$el.innerHTML = ''
    this.$el.appendChild(elements)
  }
}

export default Vue
// index.js
import Vue from './src/vue.js'

const App = new Vue({
     
  el: '#app',
  data() {
     
    return {
     
      name: '特朗普',
      info: {
     
        message: '没有人比他更懂',
      },
    }
  },
  render(createElement) {
     
    return createElement(
      'div',
      [
        createElement('span', `${
       this.$data.name} 说: ${
       this.$data.info.message}`)
      ]
    )
  }
})

setTimeout(() => {
     
  // 通过$data属性操作Proxy API返回的对象
  App.$data.name = '川宝'
}, 2000)

setTimeout(() => {
     
  App.$data.info.message = 'MAGA!!!!'
}, 4000)

最终呈现的结果和2.0一样

5.结束

这里只展示了Vue响应式最简单的模型,肯定在细节和功能上与源码有很大的差异,但通过对发布订阅模式的了解,以及自己分别实现Vue2.0和3.0的简单模型,下次面试官再问的时候心里就不慌了。

6.参考

【vue系列】从发布订阅模式解读,到vue响应式原理实现(包含vue3.0)
观察者
介绍下观察者模式和订阅-发布模式的区别,各自适用于什么场景

你可能感兴趣的:(Vue,vue.js)