Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

vue2.x 响应式原理及依赖收集的简单实现 #8

Open
impeiran opened this issue May 3, 2020 · 0 comments
Open

vue2.x 响应式原理及依赖收集的简单实现 #8

impeiran opened this issue May 3, 2020 · 0 comments

Comments

@impeiran
Copy link
Owner

impeiran commented May 3, 2020

本文将把vue 2.x的响应式原理及其依赖收集进行分析讲解,并简单用代码模拟一遍vue这一部分的源码。

点击查看模式实现的代码

(此处及之后的vue泛指2.x版本的vue)

绝大部分人都知道vue2.x的响应式依赖一个APIObject.defineProperty,通过该API可以劫持属性的获取(get)/赋值(set),在回调中进行更新视图,从而达到由数据驱动视图去更新。

但要实现响应式,细节上还需要更多,譬如:

  • 属性被set之后,具备什么样的条件才能更新视图。

  • 上述第一点满足后,框架如何知道具体要更新哪一个视图组件。

  • 如何解决多个属性触发同一个组件更新的情况。

  • ...

抛出上述问题之后,vue的做法是这样的:

  1. 引入依赖收集机制:递归遍历组件状态data()之后,每个属性key作为一个依赖,实例化一个名为Dep的依赖对象const dep = new Dep(),并用Object.defineProperty劫持get/set
    • 视图渲染过程中触发属性getter,在getter的回调中收集其对应的依赖dep
    • 主动set属性时,在属性的setter回调中,其对应dep通知所有收集到它的对象。
  2. 设立一个对象Watcher,进行上述依赖的收集和管理。该watcher对应到每一个组件,Vue把其称为render watcher。属性被set之后,依赖对象dep就会通知将它收集的watcher,由watcher进行更新视图。

引用vue官方的一张图展示这一个过程:

data

首先组件render的时候,渲染在视图中的状态都会触发其getter,然后组件对应的Watchergetter回调中将其作为依赖进行收集。当状态发生变化后,通知notify收集其依赖的Watcher,然后Watcher进行更新,触发组件的重新render。

接下来由代码来讲述这整个流程,基本就是vue源代码的简化版,省略了大部分的变量校验和与本文主题无关的代码。想仔细研究完整版本请自行查阅源码。

状态属性的getter/setter

首先我们要拿到组件中声明的状态,默认规定是一个工厂函数,返回一个object,这样就可以保证每次根据配置实例化的状态,不会指向同一份状态。拿到状态data后,我们就进行观察劫持。

// 获取data 并保留一份指引在实例上,即this._data
const data = vm._data = vm.$option.data.call(vm)

// 将状态代理到实例上,就可以通过this.xxx获取
// 源码中是将 this.xxx 代理到 this._data.xxx
const keys = Object.keys(data)
let i = keys.length
while (i--) {
  const key = keys[i]
  proxy(this, '_data', key)
}

// 然后进行递归的observe
observe(data)

下面则是进行代理的proxy方法实现

// 将状态代理到目标上
function proxy (source, sourceKey, k) {
	Object.defineProperty(source, k, {
    enumerable: true,
    configurable: true,
    get: function () {
      return this[sourceKey][k]
    },
    set: function (val) {
      this[sourceKey][k] = val
    }
  })
}

接着讲解的是observe(data),这里要完成的就是递归进行劫持。源码中整个流程:observe(data)->new Observer(data)->walk(data)->defineReative(data, key)。当中涉及到数组和普通对象的处理,以及是否需要劫持的判断,故在此不作展开。简化版如下,并不影响我们的逻辑分析:

function observe (obj) {
  const keys = Object.keys(obj)
  for (const key of keys) {
    const dep = new Dep()
		// 保存对象-Key的取值
    let val = obj[key]
		
    // 	递归劫持
    if (Object.prototype.toString.call(val) === 'object') {
      observe(val)
    }

    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get: function () {
        // 进行依赖收集
        if (Dep.target) {
          dep.depend()
        }
        return val
      },

      set: function (newVal) {
        // vue此处做了优化,如果值没变化,则不会通知watcher
        if (newVal === val) return
        // 变化之后需要再次赋值
        val = newVal
        // 由依赖进行通知
        dep.notify()
      }
    })
  }
}

依赖 Dep

这是递归遍历data时,为每一个key值实例化的类,一个key对应一个dep。首先看下Dep类的简单实现:

let depId = 0
class Dep {
  // 静态属性,类型是Watcher
  static target;
  
  constructor () {
    this.id = depId++
    this.subs = []
  }
	
  // 添加订阅者,当属性发生变化,可以透过属性去查找其对应的watcher
  addSub (sub) {
    this.subs.push(sub)
  }
  
  // 移除订阅
  removeSub (sub) {
    const index = this.subs.findIndex(sub)
    if (index !== -1) {
      this.subs.splice(index, 1)
    }
  }
	// 依赖收集
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
	
  // 广播更新
  notify () {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}
Dep.target = null

有两个设计点:

  1. 内部维护了一个订阅数组,一个属性不仅可以被render watcher收集,也可以被user watcher收集,即用户自己编写的watch选项,等等。
  2. 维护了一个静态属性target,存放当前进行renderwatcher。虽然说vue的更新是异步的,但是这个异步只是相对于改变状态的操作而言,对于模版/render方法渲染单个组件的过程依然是js同步进行的。所以全局同一时候只会有一个watcher进行更新,更新完当前的watcher,再将新的watcher重新赋值到Dep.target

vue使用了一个栈来维护当前的Dep.target,因为考虑到当前watcher更新时,可能会触发另一个watcher的更新渲染,需要对上一个watcher进行保留。

const targetStack = []

function pushTarget (target) {
  targetStack.push(target)
  Dep.target = target
}

function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

而什么时候需要赋值当前的Dep.target,就在于Watcher的设计了。

Watcher

vue里面的Watcher,负责了三个功能:computed、用户自定义watcherdata状态的依赖收集,而前两者都有依赖于第三个依赖收集的机制。源码里的Watcher考虑到了更多的情景,这里只针对依赖收集的那一部分代码进行简单实现:

let watchId = 0
class Watcher {
  constructor (vm, expOrFn) {
    this.id = watchId++
    this.vm = vm
    // 这里是获取值的回调,也可以穿入render/update方法
    this.getter = expOrFn		

    // 用于处理依赖
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()
		
    // 实例化时,会执行一遍“获取”的get函数
    this.value = this.get()
  }
	
  // 收集依赖
  addDep (dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }
	
  // 清理依赖
  cleanupDeps () {
    let i = this.deps.length
    while (i--) {
      const dep = this.deps[i]
      if (!this.newDepIds.has(dep.id)) {
        dep.removeSub(this)
      }
    }
    let tmp = this.depIds
    this.depIds = this.newDepIds
    this.newDepIds = tmp
    this.newDepIds.clear()
    tmp = this.deps
    this.deps = this.newDeps
    this.newDeps = tmp
    this.newDeps.length = 0
  }
	// 获取watcher渲染的值,如果是render watcher,其返回的值不必用到,只会执行逻辑上的渲染
  get () {
    pushTarget(this)
    const value = this.getter.call(this.vm)
    popTarget()

    return value
  }
	// 这里源码里是会启动一个异步队列,进行更新
  update () {
    Promise.resolve().then(() => {
      this.get()
    })
  }
}

watcher的实例化时机就在所有状态、事件、注入都初始化完了之后,DOM进行mount之前。那一刻data中的状态均已完成了响应式劫持的声明。

// 源码中大概的调用:
updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

对照回往上watcher的声明,可以发现其实render watcher里,expOrFn函数就会被传递成一个render的函数。代表着,实例化watcher代码的最后执行this.get(),相当于都会执行一次传递过来的render

紧接着我们来看get函数

get () {
  pushTarget(this)
  const value = this.getter.call(this.vm)
  popTarget()

  return value
}

在这里,就把当前正在渲染watcher变成Dep.target,然后执行参数中传递过来的render,在render过程中就会触发data状态的get属性回调,并执行依赖收集。

addDep (dep) {
  const id = dep.id
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id)
    this.newDeps.push(dep)
    if (!this.depIds.has(id)) {
      dep.addSub(this)
    }
  }
}

依赖收集这里用了两对数组,一对是new开头的,分别存放id和实例的。另一对则不带new,意味着是原来的。

这样设计的原因是**每一次的render,模板中用到的状态可能会不一样。**e.g:v-if的状态由true改变成了false,并且v-if下的代码块中包含了声明的状态date。那么对比前后,第一次渲染的时候watcher收集到了date的依赖,但是状态改变之后,date的状态被v-if="false"包裹了,对于视图来说我们不需要收集这个依赖去更新了。

所以每一次更新我们都要重新清除cleanupDeps上一次收集过的依赖,赋值给新的依赖。就可以避免改变视图中没用到的状态,也会触发更新这个场景,可以说是一种优化。

实践 && 测试

结合了上述编写的简单DepWatcher,简单写一个Vue类试验一下。

class Vue {
  constructor (option) {
    this._option = option
    this._el = document.querySelector(option.el)
    this._template = this._el.innerHTML

    this._initState(this)

    new Watcher(this, function update () {
      this._mount()
    })
  }
	
  // 递归遍历data 初始化响应式
  _initState () {
    const data = this._data = this._option.data
      ? this._option.data() : {}

    const keys = Object.keys(data)
    let i = keys.length
    while (i--) {
      const key = keys[i]
      proxy(this, '_data', key)
    }
    observe(data)
  }

  _mount () {
    const _this = this
    let template = _this._template

    // 替换差值表达式
    let matchText
    while ((matchText = /\{\{((\w)+?)\}\}/.exec(template))) {
      template = template.replace(matchText[0], _this._data[matchText[1]])
    }

    _this._el.innerHTML = template
  }
}

这里没有模拟virtual-dom,只模拟vue中响应式和依赖收集的场景。然后html中编写以下代码:

<body>
  <div id="app">
    <div>计数器:{{counter}}</div>
    <div>当前时间戳:{{currentDate}}</div>
  </div>

  <button id="counter">增加</button>
  <button id="timer">打印时间</button>
</body>

<script>
const app = new Vue({
  el: '#app',
  data () {
    return {
      counter: 1,
      currentDate: Date.now()
    }
  }
})

const $ = sel => document.querySelector(sel)

$('#counter').onclick = () => {
  app.counter++
}

$('#timer').onclick = () => {
  app.currentDate = Date.now()
}
</script>

试验也能成功

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant