一個極其簡易版的vue.js實現


前言

之前項目中一直在用vue,也邊做邊學摸滾打爬了近一年。對一些基礎原理性的東西有過了解,但是不深入,例如面試經常問的vue的響應式原理,可能大多數人都能答出來Object.defineProperty進行數據劫持,但是深入其實現細節,還是有很多之前沒考慮到的東西,例如依賴收集后如何通知訂閱器,以及訂閱發布模式如何實現等等。過程中讀了部分源碼,受益匪淺,除此之外,動手去實現它也是個很棒的學習方式,話不多說,看代碼,倉庫地址

實現

vue的更新機制我們簡單概括一下就是,先對template進行解析,若檢測到template中使用了data中定義的屬性,則生成一個對應的watcher,通過劫持getter進行依賴(即watcher)收集,收集的內容保存在訂閱器Dep,通過劫持setter做到改變屬性從而通知訂閱器更新,那么我們首先要做的就是對屬性進行劫持。
vue2.0中使用的是Object.defineProperty,有傳言說vue 3.0將會使用Proxy來代替Object.defineProperty,其有諸多好處:

  • defineProperty不能對數組進行劫持,因此vue的文檔中才會提到只有push、pop等8種方法能夠檢測變化,而arr[index] = newValue並不能檢測變化,push等方法能檢測變化也是因為開發者對Array原生方法進行hack實現的。
  • defineProperty只能改變對象的某一個屬性,若需要劫持整個對象,必須遍歷對象,對每個屬性劫持,因此效率並不高。而Proxy更像是一個代理,它會產生一個新的對象,該對象內部的屬性均以實現劫持。但要注意,某個屬性若也是一個對象類型,需要對該屬性也執行proxy操作才能實現劫持。

Proxy目前來看唯一的缺點就是兼容性可能存在問題,不過無傷大雅,我們也順應潮流,使用Proxy來實現數據劫持,代碼很簡單:

/**
 * 接受一個對象,對屬性進行依賴追蹤
 */
function observable(obj) {
  const dep = new Dep()
  
  const proxy = new Proxy(obj, {
    get(target, property) {
      const value = target[property]
      if (value && typeof value === 'object') { // 若屬性為object,遞歸處理
        target[property] = observable(value)
      }
      if (Dep.target) { // Dep.target指向當前watcher
        dep.addWatcher(Dep.target)
      }
      return target[property]
    },
    set(target, property, value) {
      target[property] = value
      dep.notify() // 通知訂閱器
    }
  })
  return proxy
}

注意該方法需要返回proxy實例,因為只有通過proxy實例訪問屬性才具有劫持效果。我們可以看到代碼中有一個Dep,這個東西即是訂閱器,可以理解為它維護了一個依賴(watcher)的數組,並實現了一些管理數據的方法諸如addWatcher添加依賴,以及需要提供一個notify方法來遍歷所有的watcher執行其相應的更新函數,同樣代碼很簡單:

/**
 * 依賴收集器,存放所有的watcher,並提供發布功能(notify)
 */
class Dep {
  constructor() {
    this.watchers = []
  }
  addWatcher(watcher) { // 添加watcher
    this.watchers.push(watcher)
  }
  notify() { // 通知方法,調用即依次遍歷所有watcher執行更新
    this.watchers.forEach((watcher) => {
      watcher.update()
    })
  }
}

最后我們來看下watcher,我們知道watcher即我們所說的依賴,它是在編譯template的時候,若找到data中聲明的屬性,即會生成一個對應的watcher實例,觸發依賴收集,加入訂閱器。同時還需要提供一個update函數,在觸發notify的時候調用來更新視圖,代碼如下:

/**
 * watcher即所謂的依賴,監聽具體的某個屬性
 */
class Watcher {
  constructor(proxy, property, cb) {
    this.proxy = proxy
    this.property = property
    this.cb = cb
    this.value = this.get()
  }
  update() { // 執行更新
    const newValue = this.proxy[this.property]
    if (newValue !== this.value && this.cb) { // 對比property新舊值,決定是否更新
      this.cb(newValue)
    }
  }
  get() { // 只在初始化時調用,用於依賴收集
    Dep.target = this // 將自身指向Dep.target,執行完依賴收集再去釋放
    const value = this.proxy[this.property]
    Dep.target = null
    return value
  }
}

至此,響應式原理大致已經成形,接着我們只要寫一個簡易的模板解析,demo就能跑起來啦。我這邊的實現比較挫,僅僅是通過正則匹配來實現了一個不帶diff的virture dom,純屬娛樂,重點還是在實現響應式原理上,這邊貼一下代碼:

let init = false // 只在初始化時去生成watcher
const eventMap = new Map() // 存放事件
const root = document.getElementById('root') // 根節點

/**
 * 用於將傳入RayActive的vm對象進行代理,可通過this.xx訪問this.data.xx
 * @param {Object} vm 
 * @param {Proxy} proxydata 經過proxy代理的vm.data對象,使this.xx操作也能觸發視圖更新
 */
function vmProxy(vm, proxydata) {
  return new Proxy(vm, {
    get(target, property) {
      return target.data[property] || target.methods[property]
    },
    set(target, property, value) {
      proxydata[property] = value
    }
  })
}

/**
 * 編譯vm,分別對data和render做相應處理
 * @param {Object} vm 需要被編譯的vm對象
 */
function compile(vm) {
  const proxydata = compileData(vm.data)
  compileRender(proxydata, vm.render)
  bindEvents(vm, vmProxy(vm, proxydata))
}

/**
 * 
 * @param {Object} data 需要被編譯的vm中的data對象
 */
function compileData(data) {
  return observable(data)
}

/**
 * 
 * @param {*} render 需要被編譯的render字符串
 * @param {*} proxydata 經proxy轉換過的data
 */
function compileRender(proxydata, render) {
  if (render) {
    const variableRegexp = /\{\{(.*?)\}\}/g
    const variableResult = render.replace(variableRegexp, (a, b) => { // 替換變量為相應的data值
      if (!init) { // 只在初始化時去生成watcher
        new Watcher(proxydata, b, function() {
          compileRender(proxydata, render)
        })
      }
      return proxydata[b]
    })
    const eventRegexp = /(?<=<.*)@(.*)="(.*?)"(?=.*>)/
    const result = variableResult.replace(eventRegexp, (a, b, c) => { // 為綁定事件的標簽添加唯一id標識
      const id = Math.random().toString(36).slice(2)
      eventMap.set(id, {
        type: b,
        method: c
      })
      return a + ` id=${id}`
    })
    init = true
    root.innerHTML = result
  }
}

/**
 * 通過root節點做事件代理,綁定模板中聲明的事件
 * @param {*} vm 
 * @param {*} proxyvm 經過proxy代理的vm
 */
function bindEvents(vm, proxyvm) {
  for (let [key, value] of eventMap) {
    root.addEventListener(value.type, (e) => {
      const method = vm.methods[value.method]
      if (method && e.target.id === key) {
        method.apply(proxyvm) // 將vm中methods方法的this指向經過proxy的vm對象
      }
    })
  }
}

/**
 * 可理解為Vue中的Vue類,使用方式為new RayActive(vm)
 */
class RayActive {
  constructor(vm) {
    compile(vm)
  }
}

總結

這個簡易實現僅僅是幫助大家學習vue的一些原理性的東西,跟vue比其他來只是冰山一角。這個代碼還有很大的優化空間,比如執行notify時這里會通知所有的watcher等等,值得有空去研究一下。同時,我們能看到訂閱發布模式帶來的好處。如果不引入訂閱器,那我們更新dom的代碼得放到setter中去,那么就耦合了數據劫持與操作dom的邏輯。引入訂閱器,能讓我們在proxy中僅僅做依賴收集和通知的操作,剩下的各種復雜的或是個性化的邏輯可以放到watcher中去實現,完美做到了關注點分離。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM