Skip to content
New issue

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

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

Already on GitHub? Sign in 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>

试验也能成功

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

No branches or pull requests

1 participant