Vue学习系列之一、响应式

733 阅读9分钟

前言

这是一个Vue源码学习系列。打算开个手写Vue的坑,希望能在写代码的同时能把其中的细节讲清楚,最终目的是实现一个简版的vue。不知道自己能写到哪一步,总之尽力而为。如果能完成的话,应该是自我超越了和无限的自信了。放个仓库的 传送门

一、观察者模式

场景:最近因为快到暑假了,产品韩梅梅提前一个月在大象拉了一个需求群,把研发李雷、小明拉进了群。她跟两位研发说,“我们过几天要开发一个新需求,需要两位研发的支持,是关于暑假欢乐谷门票活动的需求,等产品逻辑梳理完,咱们就进入开发。”。一个礼拜之后,韩梅梅在群里发出了需求文档,两位研发开始加班加点干活,需求完美上线。

颇费特~ 好的,观察者模式讲完了。

纳尼?等等...等等...,您这是讲了个啥。

咳咳...不好意思,重新来。

我们看一下上面这个场景,它分了几步

  1. 产品韩梅梅通知两位研发过几天会有个需求让他们进行开发
  2. 几天后,韩梅梅通知研发要开始开发需求了
  3. 研发开始进行开发

总结下来,我们发现有两个角色,一个发布者(产品韩梅梅),一个观察者(李雷等研发),当发布者的状态更新后,会进行通知观察者,观察者开始执行对应的动作。

ok,让我们试着写一下

class Dep {
    constructor(state) {
        this.watchers = []
        this.state = state
    }
    // 添加观察者(研发)
    add(watcher) {
  	!this.watchers.includes(watcher) && this.watchers.push(watcher)
    }
    // 移除观察者(研发)
    remove(watcher) {
        let index = this.watcher.indexOf(watcher)
        if (~index) this.watcher.splice(index, 1)
    }
    // 状态更新, 通知全部观察者
    notify() {
        for (let watcher of this.watchers) watcher.update(this)
    }
}

class Watcher {
    constructor(value) {
        this.value = value
    }
    // 更新
    update() {
        console.log('开始开发!')
    }
}


const HanMeiMei = new Dep()
const XiaoMing = new Watcher()
const LiLei = new Watcher()

// 拉群!
HanMeiMei.add(XiaoMing)
HanMeiMei.add(LiLei)

await new Promise(resolve => setTimeout(resolve, 7 * 24 * 60 * 60 * 1000, '一周过去了')))

// 过了一周开始通知研发开发

HanMeiMei.notify()

问:那如果换成Vue中的视图数据之间的关系呢?哪个是个发布者,哪个是观察者。

答:显而易见,数据是发布者,视图是观察者。当数据改变时,会通知视图,视图重新进行渲染。

这里有几个问题

  1. What,数据都收集什么样的观察者
  2. How,数据怎么收集的观察者
  3. When,数据什么时候收集观察者

ok,带着这些问题咱们继续往下看

二、Vue中的观察者

1、What

首先,明确一点,Vue实例中的响应数据,几乎全部都来源于data,就是那个Option API中的data。不管是props,computed这些都是基于data的。

其次,Vue中的Watcher分为了三种,

  1. render Watcher,可以简单的理解为template;
  2. computed Watcher,在Vue文档中,说到过缓存这个概念,说白了其实就是计算属性的getter中用到的数据(data) 没有发生过变化,那么这个getter就不必重新计算,这个我认为是Vue响应式中最绕的,下面的源码重点讲一下
  3. watch Watcher,没错就是那个Option API中的watch,你想想你数据改了,watch是不是得再执行一遍,那不跟视图是一样的么

所以,what的答案就有了 数据收集了这三种观察者

2、How

说个面试的段子,面试官:vue怎么收集依赖的?

这个其实老生常谈,getter/setter的存储器嘛

诶,那你知道Array是怎么收集的吗?

知道知道,不就是hack的一些原生方法嘛

哦,那为什么要hack呢,咋hack的呢,hack了哪些呢,不同的方法之间又都是怎么处理的数据呢?

......

好了,回去等通知吧(一面挂)

这里信息量太大!关于为什么要hack方法,尤大是给出了回答的,主要原因是因为性能和使用方便之间的取舍,这篇文章有写道:segmentfault.com/a/119000001…

但是你要问我为啥数组附个值还能跟性能扯上关系,咱也不懂,咱也不敢问。

3、When

在vue实例化的时候,在beforeCreate和create之间,会有一个初始化数据的过程,这里会将data、computed全部初始化好,通过getter,哪里用到就在哪里收集观察者。

三、思路

1、先从getter/setter开始

先从转换数据开始,我们来简单实现一个,很简单就是迭代加递归,两个函数搞定。

// 我们先来实现第一个函数observe
function observe(data) {
    if (typeof data !== 'object') return
    for (let key of Object.keys(data)) {
        defineReactive(data, key)
    }
    return data
}

// 然后是defineReactive
function defineReactive(data, key) {
    let val = data[key]
    const dep = new Dep()
    observe(val)
    Object.defineProperty(data, key, {
        configurable: false,
        enumerable: true,
        get() {
            dep.depend()
            return val
        },
        set(newVal) {
            if (val === newVal) return
            val = newVal
            observe(val)
            dep.notify()
        }
    })
}

这里的逻辑很简单就是通过迭代+递归,将所有值都改为存取器。

这里注意defineReactive方法,我并没有直接把data[key] 的这个value通过参数传进去,而是在函数内部取值,之所以为什么做,这里先留一个悬念

2、Dep

这里出现了一个class Dep,这里其实Dep就是来收集Watcher的。

好的,我们继续来实现Dep


class Dep {
  constructor() {
    this.watchers = new Set()
  }
  depend() {
    if (Dep.Target) this.watchers.add(Dep.Target)
  }
  notify() {
    let watchers = this.watchers
    for (let watcher of watchers) {
      watcher.update()
    }
  }
}

这里Dep的实现也很简单,就是收集watchers,使用Set确保watcher的唯一。

但是!这里又双叒出现了一个新的东西,Dep.Target。这东西是个啥,其实看代码也能差不多发现,Dep.Target肯定是个Watcher实例。

诶~,这么多Watcher实例它到底是哪个呢?

好问题!我们先想想一个场景,我们有个数据比如是data,我们还有个渲染函数,然后呢~这个渲染函数用到了这个data。

用到data了肯定就会触发data的getter,从而收集依赖,那我们要收集的依赖肯定就是这个渲染函数了。

相应的Dep.Target的值也就是这个渲染函数


哒嘎! 你以为这样就结束了吗,No,No,No,嘛哒哒!

要是渲染函数里面还有个渲染函数咋整。

纳尼!还有这种操作吗!

有的,而且很多,当我们组件里面嵌入了组件的时候就会出现。

我去,那不是很常见吗!那可怎么办。

别慌,我们只要实现一个栈,有新的函数要执行,我们就push进来,当函数执行结束,给他pop出去就好了。

ok,那我们开始实现一下。

Dep.Target = null
const watcherStack = []

// 入栈
function pushTarget(watcher) {
  Dep.Target = watcher
  watcherStack.push(watcher)
}

// 出栈
function popTarget() {
  watcherStack.pop()
  Dep.Target = watcherStack[watcherStack.length - 1]
}

完美解决~ 那么最后剩下的的就是watcher的实现了。

3、Watcher

RenderWatcher

上面说过,watcher一共有三种,我们先实现最简单、最基础的renderWatcher。

class Watcher {
  constructor(getter) {
    this.getter = getter
    this.value = undefined

    this.value = this.get()
  }
  get() {
    pushTarget(this)
    this.getter()
    popTarget()
  }
  update() {
    this.value = this.get()
  }
}

这里的逻辑很简单,参数getter就是要执行的函数。对于RenderWatcher来说getter就是渲染函数。 好的!万事具备,我们来试着跑个例子。

<body>
  <div id="app"></div>
  <script src="./reactive/reactive.js"></script>
  <script>
    const data = observe({
      age: 12,
      name: 'Sunyanzhe'
    })
    // 渲染函数
    function renderFunction() {
      document.querySelector('#app').innerHTML = `我叫${data.name}, 我${data.age}岁`
    }

    // renderWatcher
    const renderWatcher = new Watcher(renderFunction)

    setTimeout(() => {
      data.age = 25
    }, 2000)

  </script>
</body>

renderWatcher.gif

来,我们捋一下流程:

  1. 第一步当然是数据的处理,先变成getter/setter
  2. new Watcher的时候,这时候Dep.TargetrenderWatcher
  3. 开始执行renderFunction,读取到nameage的属性时,触发getter收集Dep.Target,也就是renderWatcher。此时nameage中的Dep实例都存了renderWatcher
  4. 过了2秒,data.age赋值,触发setter,触发ageDep中保存的watcherupdate方法。此时更新视图。
  5. 这时,renderFunction执行,读取到age时,值为25

别问为啥两秒,一个人就从12变成25了,经历痛苦会让人瞬间成长😂

ComputedWatcher

总听文章里说,computed有什么懒加载,缓存。那是个什么玩意啊

好说,因为computed其实是一个getter,是函数就要执行嘛。懒加载的意思就是它什么时候被用到了,它什么时候执行这个函数。

那缓存又是什么呢?

也很简单,就是computed中用到的值如果没发生改变的话,它的getter函数不进行计算,而是直接用上一次得出的结果。


ok,先到这里,我们先捋一捋思路

首先,刚刚说到,computed可以被缓存,当它用到的值没有发生改变时,getter不需要执行。

也就是说computed本身也是要有Dep,用来收集数据。

其次,他是lazy的,所以即使数据发生了改变也不用立即执行函数,获取结果。而是可以等到,什么时候再次用到这个computed的值再去计算。比如在render函数中用到

最后,computed中的数据改变后不能只通知computed的值需要重新更新,还需要通知用到computed的地方也要进行一次更新

总结下来就是,如果一个render函数中有用到computed,那么computed中的数据更新,不仅要通知computed的值要改变,还要告诉render函数进行重新执行。而当render函数重新执行的时候,就会再次获取computed。这时computed才会执行他的getter函数

好了,思路捋清了,我们实现一下。为此我们要修改一下之前的Dep和Watcher,并且我们还要实现一个computed方法。

class Dep {
  constructor() {
    this.watchers = new Set()
  }
  // 这里发生了变化
  addSub(watcher) {
    this.watchers.add(watcher)
  }
   // 这里发生了变化
  depend() {
    if (Dep.Target) {
      Dep.Target.addDep(this)
    }
  }
  notify() {
    let watchers = this.watchers
    for (let watcher of watchers) {
      watcher.update()
    }
  }
}

class Watcher {
  constructor(
    getter,
    options
  ) {
    this.getter = getter
    this.deps = new Set()
    this.value = undefined
    this.lazy = undefined
    this.dirty = undefined

    if (options) {
      this.lazy = !!options.lazy
    }
    this.dirty = this.lazy
    this.value = this.lazy
      ? undefined
      : this.get()
  }
  get() {
    pushTarget(this)
    let value = this.getter()
    popTarget()
    return value
  }
  addDep(dep) {
    dep.addSub(this)
    this.deps.add(dep)
  }
  depend() {
    let deps = this.deps
    for (let dep of deps) {
      dep.depend()
    }
    
  }
  evalute() {
    this.value = this.get()
    this.dirty = false
  }
  update() {
    if (this.lazy) {
      this.dirty = true
    } else {
      Promise.resolve().then(() => {
        this.run()
      })
    }
  }
  run() {
    this.value = this.get()
  }
}

function computed(computedGetter) {
  const options = { lazy: true }
  const computedWathcer = new Watcher(computedGetter, options)
  const result = {}
  Object.defineProperty(result, 'value', {
    get() {
      if (computedWathcer.dirty) {
        computedWathcer.evalute()
      }
      if (Dep.Target) {
        computedWathcer.depend()
      }
      return computedWathcer.value
    }
  })
  return result
}

看到这里肯定很晕,没关系,咱们再举一个🌰,结合🌰来看懂这块逻辑。大家目前只需要关注一点,就是update中我们用了微任务。

ok,先看例子

<body>
  <div id="app"></div>
  <script src="./reactive/reactive.js"></script>
  <script>
    const data = observe({
      age: 12,
      name: 'Sunyanzhe'
    })

    function renderFunction() {
      document.querySelector('#app').innerHTML = `我叫${data.name}, 我${data.age}岁,明年${nextYear.value}`
    }

    const nextYear = computed(() => data.age + 1)

    const renderWatcher = new Watcher(renderFunction)

    setTimeout(() => {
      data.age = 25
    }, 2000)

  </script>
</body>

computedWatcher.gif

我们还是捋一下执行顺序

  1. 首先依旧是observe Data
  2. 然后我们定义了nextYear这个computed。这时候注意computedWatcher已经生成了,但是由于它是lazy的我们并没有执行get,且这个watcher的dirtytrue。这个很重要!
  3. 开始renderFunction,执行get,收集依赖...这些都没问题,但是大家还记不记得之前说的。此时的栈中有RenderWatcher。
  4. 读取到nextYear,触发nextYear的getter。由于dirty是ture,所以开始计算!此时执行了get方法,中推入了ComputedWatcher。重点来了,RenderWatcher还没执行完!所以目前栈中有两个watcher。computedWatcher的get执行完,出栈,将得到的值赋给value属性。修改dirty属性为false
  5. 另一个关键,此时的Dep.Target依旧指向的是RenderWatcher,然后computedWatcher执行了depend方法。这个方法的意思,就是要让收集到computedWatcher的dep继续收集那个用到computedWatcher的RenderWatche。(有点绕,多看看代码仔细体会)
  6. 值改变,computed的dirty再次变为true,等待renderWatcher的更新,再次出发computedWatcher的计算。

这里为什么使用了微任务,是因为执行顺序的问题,我们的computed的计算必须要在renderWatcher的更新之后,这样才能收集到对应的依赖。在Vue源码中,有一个执行更新的队列,它会将所有的watcher进行排序,避免报错。

WatchWatcher

其实,watch也很简单,就是加了个callback。

watch比较迷惑的地方其实它的getter是什么,在renderWatcher中,getter是render函数;在computedWatcher中,getter是getter函数;那么watch是什么呢。

其实很简单就是个travers函数,想想我们是怎么写watch的

watch: {
  prop1(val) {
    console.log(val)
  }
}

// 转换为
$watch(() => {vm._data.prop1}, console.log)

这里面第一个函数是getter,用来收集依赖,第二个就是callback了

那deep呢? deep其实就是深度遍历

废话少说,直接开始实现!

其实很简单,我们只需要加个callback,找个地方调用一下就好了。

所以我们就改一下constructor和run这两个

class Watcher {
  constructor(
    getter,
    options,
    cb
  ) {
    //...
    this.cb = cb
    this.user = undefined

    if (options) {
      this.user = !!options.user
    }
    // ...
  }
  run() {
    let newValue = this.get()
    if (this.user && newValue !== this.value) {
      // 调用回调
      this.cb(newValue, this.value)
      this.value = newValue
    }
  }
}

function watch(watcheGetter, callback) {
  const options = { user: true }
  new Watcher(watcheGetter, options, callback)
}

就是如此的简单,比computed简单多了~

最后看一下效果

<body>
  <div id="app"></div>
  <script src="./reactive/reactive.js"></script>
  <script>
    const data = observe({
      age: 12,
      name: 'Sunyanzhe'
    })

    function renderFunction() {
      document.querySelector('#app').innerHTML = `我叫${data.name}, 我${data.age}岁,明年${nextYear.value}`
    }

    const nextYear = computed(() => data.age + 1)

    const renderWatcher = new Watcher(renderFunction)

    watch(
      () => data.name, 
      (val, oldVal) => {
        console.log('new---', val)
        console.log('old---', oldVal)
      })
      
    setTimeout(() => {
      data.name = 'yanzhe'
    }, 1000)

    setTimeout(() => {
      data.age = 25
    }, 2000)

  </script>
</body>

watch.gif

四、源码以及拓展阅读

在上文中谈到的为什么DefineReactive不传value的原因,在这个issue中:github.com/vuejs/vue/p…,主要原因是,数据本身就可以是getter/setter

Vue中的源码思路与本文一致,主要多了边界问题的处理,以及数组的hack,有关数组的处理需要大家去看源码去理解

  1. array —— github.com/vuejs/vue/b…
  2. 观察者—— github.com/vuejs/vue/b…