Vue原始碼學習之雙向繫結

走音發表於2019-04-09

(注:此篇部落格主要討論Watcher,Dep,Observer的實現)

原理

當你把一個普通的 JavaScript 物件傳給 Vue 例項的 data 選項,Vue 將遍歷此物件所有的屬性,並使用 Object.defineProperty 把這些屬性全部轉為 getter/setter。Object.defineProperty 是 ES5 中一個無法 shim 的特性,這也就是為什麼 Vue 不支援 IE8 以及更低版本瀏覽器。

上面那段話是Vue官方文件中擷取的,可以看到是使用Object.defineProperty實現對資料改變的監聽。Vue主要使用了觀察者模式來實現資料與檢視的雙向繫結。

function initData(vm) { //將data上資料複製到_data並遍歷所有屬性新增代理
  vm._data = vm.$options.data;
  const keys = Object.keys(vm._data); 
  let i = keys.length;
  while(i--) {  
    const key = keys[i];
    proxy(vm, `_data`, key);
  }
  observe(data, true /* asRootData */) //對data進行監聽
}
複製程式碼

在第一篇資料初始化中,執行new Vue()操作後會執行initData()去初始化使用者傳入的data,最後一步操作就是為data新增響應式。

實現

在Vue內部存在三個物件:Observer、Dep、Watcher,這也是實現響應式的核心。

Observer

Observer物件將data中所有的屬性轉為getter/setter形式,以下是簡化版程式碼,詳細程式碼請看這裡

export function observe (value) {
  //遞迴子屬性時的判斷
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  ...
  ob = new Observer(value)
}
export class Observer {
  constructor (value) {
    ... //此處省略對陣列的處理
    this.walk(value)
  }

  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i]) //為每個屬性建立setter/getter
    }
  }
  ...
}

//設定set/get
export function defineReactive (
  obj: Object,
  key: string,
  val: any
) {
  //利用閉包儲存每個屬性關聯的watcher佇列,當setter觸發時依然能訪問到
  const dep = new Dep()
  ...
  //如果屬性為物件也建立相應observer
  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      if (Dep.target) {
        dep.depend() //將當前dep傳到對應watcher中再執行watcher.addDep將watcher新增到當前dep.subs中
        if (childOb) {  //如果屬性是物件則繼續收集依賴
          childOb.dep.depend()
          ...
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      ...
      childOb = observe(newVal) //如果設定的新值是物件,則為其建立observe
      dep.notify() //通知佇列中的watcher進行更新
    }
  })
}
複製程式碼

建立Observer物件時,為data的每個屬性都執行了一遍defineReactive方法,如果當前屬性為物件,則通過遞迴進行深度遍歷。該方法中建立了一個Dep例項,每一個屬性都有一個與之對應的dep,儲存所有的依賴。然後為屬性設定setter/getter,在getter時收集依賴,setter時派發更新。這裡收集依賴不直接使用addSub是為了能讓Watcher建立時自動將自己新增到dep.subs中,這樣只有當資料被訪問時才會進行依賴收集,可以避免一些不必要的依賴收集。

Dep

Dep就是一個釋出者,負責收集依賴,當資料更新是去通知訂閱者(watcher)。原始碼地址

export default class Dep {
  static target: ?Watcher; //指向當前watcher
  constructor () {
    this.subs = []
  }
  //新增watcher
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  //移除watcher
  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }
  //通過watcher將自身新增到dep中
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
  //派發更新資訊
  notify () {
    ...
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}
複製程式碼

Watcher

原始碼地址

//解析表示式(a.b),返回一個函式
export function parsePath (path: string): any {
  if (bailRE.test(path)) {
    return
  }
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]    //遍歷得到表示式所代表的屬性
    }
    return obj
  }
}
export default class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    } 
    //對建立的watcher進行收集,destroy時對這些watcher進行銷燬
    vm._watchers.push(this)
    // options
    if (options) {
      ...
      this.before = options.before
    }
    ...
    //上一輪收集的依賴集合Dep以及對應的id
    this.deps = []
    this.depIds = new Set()
    //新收集的依賴集合Dep以及對應的id
    this.newDeps = []
    this.newDepIds = new Set()
    this.expression = process.env.NODE_ENV !== 'production'
      ? expOrFn.toString()
      : ''
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
      ...
    }
    ...
    this.value = this.get()
  }

  /** * Evaluate the getter, and re-collect dependencies. */
  get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      popTarget()
      this.cleanupDeps() //清空上一輪的依賴
    }
    return value
  }

  /** * Add a dependency to this directive. */
  addDep (dep: 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
  },
  update () {
    ...
    //經過一些優化處理後,最終執行this.get
    this.get();
  }
  // ...
}
複製程式碼

依賴收集的觸發是在執行render之前,會建立一個渲染Watcher:

updateComponent = () => {
  vm._update(vm._render(), hydrating) //執行render生成VNode並更新dom
}
new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)
複製程式碼

渲染Watcher建立時會將Dep.target指向自身並觸發updateComponent也就是執行_render生成VNode並執行_updateVNode渲染成真實DOM,在render過程中會對模板進行編譯,此時就會對data進行訪問從而觸發getter,由於此時Dep.target已經指向了渲染Watcher,接著渲染Watcher會執行自身的addDep,做一些去重判斷然後執行dep.addSub(this)將自身push到屬性對應的dep.subs中,同一個屬性只會被新增一次,表示資料在當前Watcher中被引用。

當_render結束後,會執行popTarget(),將當前Dep.target回退到上一輪的指,最終又回到了null,也就是所有收集已完畢。之後執行cleanupDeps()將上一輪不需要的依賴清除。當資料變化是,觸發setter,執行對應Watcher的update屬性,去執行get方法又重新將Dep.target指向當前執行的Watcher觸發該Watcher的更新。

這裡可以看到有deps,newDeps兩個依賴表,也就是上一輪的依賴和最新的依賴,這兩個依賴表主要是用來做依賴清除的。但在addDep中可以看到if (!this.newDepIds.has(id))已經對收集的依賴進行了唯一性判斷,不收集重複的資料依賴。為何又要在cleanupDeps中再作一次判斷呢?

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
複製程式碼

cleanupDeps中主要清除上一輪中的依賴在新一輪中沒有重新收集的,也就是資料重新整理後某些資料不再被渲染出來了,例如:

<body>
  <div id="app">
    <div v-if='flag'> </div>     
    <div v-else> </div> 
    <button @click="msg1 += '1'">change</button>     
    <button @click="flag = !flag">toggle</button>   
  </div> 
    <script type="text/javascript">
    var vm = new Vue({
      el: '#app',
      data: {
        flag: true,
        msg1: 'msg1',
        msg2: 'msg2'
      }
    })
    </script> 
</body>
複製程式碼

每次點選change,msg1都會拼接一個1,此時就會觸發重新渲染。當我們點選toggle時,由於flag改變,msg1不再被渲染,但當我們點選change時,msg1發生了變化,但卻沒有觸發重新渲染,這就是cleanupDeps起的作用。如果去除掉cleanupDeps這個步驟,只是能防止新增相同的依賴,但是資料每次更新都會觸發重新渲染,又去重新收集依賴。這個例子中,toggle後,重新收集的依賴中並沒有msg1,因為它不需要被顯示,但是由於設定了setter,此時去改變msg1依然會觸發setter,如果沒有執行cleanupDeps,那麼msg1的依賴依然存在依賴表裡,又會去觸發重新渲染,這是不合理的,所以需要每次依賴收集完畢後清除掉一些不需要的依賴。

總結

依賴收集其實就是收集每個資料被哪些Watcher(渲染Watcher、computedWatcher等)所引用,當這些資料更新時,就去通知依賴它的Watcher去更新。


相關文章