仿vue實現數據雙向綁定

        前段時間筆者學習了一下Vue的源碼,也看了一些前輩對Vue源碼研究的博客,然後用es實現了一個基礎的數據雙向綁定框架Hue,作爲學習成果之一,在此分享給大家。Hue實現了@click,v-model, watch監聽屬性變化這幾個基本的功能,後續如有需要大家可以自行擴展,比如hook之類的,整個框架的組織架構也可以自行調整。其中很多內容的設計和實現都是參照了Vue源碼來實現的,後面會對代碼實現做進一步的闡述。

       先看一下實現的效果動態圖:

       下圖是Hue實例化的運行過程,也是整個Hue實現數據雙向綁定的過程總結:

 下圖是Hue的代碼文件結構:

瀏覽器運行index.html就能看到動圖中的內容,以下是index.html的代碼:

<div id="app">
    <span>Welcome to {{project}}, it's an example</span>
    <div style="margin-top: 10px">
        <div class="row">
            <div class="label">name: </div>
            <input v-model="people.name" />
        </div>
        <div class="row">
            <div class="label">height:</div>
            <input v-model="people.height" />
        </div>
    </div>
    <div>Hello,  I am called {{people.name}} and my height is {{people.height}} cm</div>
    <div>
        <button @click="getTxt(people.name, people.height)">信息輸出</button>
        <button @click="change('code', 173)">設置</button>
    </div>
</div>
<script src="./util.js"></script>
<script src="./observer.js"></script>
<script src="./watcher.js"></script>
<script src="./dep.js"></script>
<script src="./compiler.js"></script>
<script src="./main.js"></script>
<script>
new Hue({
    el: '#app',
    data: {
        count: 1,
        project: 'Hue',
        txt: '35',
        people: {
            name: 'Alice',
            height: 160
        }
    },
    watch: {
        'people.name': {
            handler (n, o) {
                console.log('watch ===> newName:', n, 'oldName:', o)
            }
        },
        // 對象監聽
        // 'people': {
        //     deep: true,
        //     handler (n, o) {
        //         console.log('people changed...')
        //     }
        // }
    },
    methods: {
        change (name, height) {
            this.people.name = name
            this.people.height = height
        },
        getTxt (name, height) {
            console.log('name:', name, 'height:', height)
        }
    }
})
</script>
<style>
    .label{
        width: 60px;
        text-align: right;
        margin-right: 10px;
    }
    .row{
        display: flex;
        margin-bottom: 10px;
    }
</style>

        整個風格也是照着Vue來實現的,這個例子測試了三部分的內容:1 數據雙向綁定;2屬性變化監聽;3不同參數類型的函數綁定(針對基本數據類型和data中的屬性)。從Hue實例化開始,數據雙向綁定的大門就被打開了,接下來讓我們一起解開那些背後的祕密。以下是main.js文件:

class Hue {
    constructor (options) {
        let vm = this
        vm.$options = options
        vm._data = options.data
        for (let key in vm._data) {
            proxy(vm, '_data', key)
        }
        // 初始化$watch監聽函數
        vm.$watch = function (key, cb) {
            new Watcher(vm, key, cb)
        }
        initOptions(vm)
        // watch 選項解析
        resolveWatch(vm)
        // 編譯模板
        new Compiler(vm.$options.el, vm)
    }
}

        這裏做的第一件事情就是把Hue實例化中的參數掛載到實例的$options上去,方便Hue中的函數獲取相關的數據,其中proxy函數對_data的內容做了一層攔截,起到在實例內部簡化調用的作用(例如data中有個屬性name, 可以通過this._data.name獲得,經過proxy代理之後可以通過this.name獲取,符合我們的調用習慣)。緊接着我們開始初始化$watch,函數內部實例化了一個Watcher,這是爲data數據添加回調函數的。後面陸續進行了options的數據劫持,實例watch的解析,模板的編譯。當中的函數來自util.js文件,以下是文件的內容:

function proxy (target, sourceKey, key) {
    Reflect.defineProperty(target, key, {
        set (newVal) {
            target[sourceKey][key] = newVal
        },
        get () {
            return target[sourceKey][key]
        }
    })
}

function observe (data) {
    if (typeof data !== 'object') return false
    return new Observer(data)
}

function defineReactive (obj, key, value) {
    let dep = new Dep()
    Reflect.defineProperty(obj, key, {
        get () {
            if (Dep.target) {
                dep.addDepend()
            }
            return value
        },
        set (newVal) {
            value = newVal
            dep.notify()
        }
    })
}

function pushTarget (watcher) {
    Dep.target = watcher
}

function popTarget () {
    Dep.target = null
}

function resolveWatch (vm) {
    let watch = vm.$options.watch
    if (!watch) return 'no watch'
    Object.keys(watch).forEach(item => {
        if (typeof watch[item] === 'function') {
            vm.$watch(item, watch[item])
        }
        if (typeof watch[item] === 'object') {
            if (watch[item].deep) {
                Object.keys(vm[item]).forEach(key => {
                    vm.$watch(item + '.' + key, watch[item].handler)
                })
            } else {
                vm.$watch(item, watch[item].handler)
            }
        }
    })
}

function initOptions (vm) {
    observe(vm._data)
}

        我們看到proxy函數裏面使用了Reflect而不是Object上的defineProperty函數去進行對象屬性的setter,getter設置,這是因爲Reflect對以往Object內部的一些方法的錯誤行爲結果進行了return false的處理而不是直接報錯,以及它收錄了以往的一些命令式的操作,把他們都函數化了,例如 delete o.name 變成了Reflect.deleteProperty(o, 'name'), 'name' in obj 變成了Reflect.has(obj, 'name') 等等,以上僅僅是Reflect對象的一部分功能,詳情可以去參考es6 Reflect對象的相關內容。

        接下來我們看$watch函數中實例化的Watcher內容,以下是watcher.js內容:

class Watcher {
    constructor (vm, expression, cb) {
        this.vm = vm
        this.expression = expression
        this.cb = cb
        this.value = this.get()
    }
    get () {
        let val = this.vm
        pushTarget(this)
        this.expression.split('.').forEach(item => {
            val = val[item]
        })
        popTarget()
        return val
    }
    update () {
        let val = this.vm
        this.expression.split('.').forEach((key) => {
            val = val[key]
        })
        this.cb.call(this.vm, val, this.value)
        this.value = val
    }
    addDep (dep) {
        dep.addSub(this)
    }
}

        這是發佈-訂閱模式中的訂閱者,構造函數中傳入了Hue實例,訂閱的屬性,回調函數。this.value用以存儲當前的屬性值,之後會隨着屬性值的變化而更新。接下來我們看watcher的get函數,pushTarget的目的是把當前的watcher實例賦值給Dep.target,接下來我們根據expression對屬性取值,這時候會觸發屬性的getter,發佈者會進行依賴收集(defineReactive進行數據劫持的時候會在詳細講解)。最後我們通過popTarget函數把Dep.target賦值爲null,返回屬性值,get函數的功能就完了。pushTarget,popTarget這一組函數用來對Dep.target綁定和釋放當前的watcher實例。vue源碼裏面會複雜一點,通過數組targetStack來追蹤記錄watcher,函數仍然是pushTarget和popTarget這一組。

        initOptions開始處理data數據了,我們看到最後是調用了util中的observe函數,如果data不是對象,直接返回false。如果是對象那就開始Observer的實例化。下面是observer.js的內容:

class Observer {
    constructor (data) {
        this.walk(data)
    }
    walk (data) {
        for (let key in data) {
            if (typeof data[key] === 'object') {
                this.walk(data[key])
                continue
            }
            defineReactive(data, key, data[key])
        }
    }
}

        Observer實例化的時候通過walk函數遍歷了data的屬性,通過defineReactive函數進行數據劫持,屬性值爲對象的則繼續遞歸遍歷。

function defineReactive (obj, key, value) {
    let dep = new Dep()
    Reflect.defineProperty(obj, key, {
        get () {
            if (Dep.target) {
                dep.addDepend()
            }
            return value
        },
        set (newVal) {
            value = newVal
            dep.notify()
        }
    })
}

        整個數據雙向綁定的靈魂就在於數據的獲取和設置是如何被監聽的,一旦監聽到變化我們就可以做相應的操作,defineProperty定義的getter和setter天然就提供了這麼一種監聽的能力。當我們訪問一個對象屬性的時候,getter就會被觸發,當我們設置對象屬性的時候setter就會被觸發。defineProperty 定義getter和setter的過程就是所謂的數據劫持了。我們看到defineReactive函數先實例化了一個Dep(發佈者),接着開始進行數據劫持,當Dep.target有綁定watcher(訂閱者)的時候就開始進行依賴收集(將當前訂閱者添加到發佈者的sub數組中,詳情可見下文Dep對象的介紹)。當數據更新時會觸發setter,這時將新值賦值給value用於getter的返回值,同時dep開始通知(notify)他的訂閱者(sub數組中的所有watcher實例)進行相應的操作(dom更新,或者是執行相關的回調函數)。下面我們來看發佈者Dep對象的內容,以下是dep.js的內容:

class Dep {
    constructor () {
        // 存放watcher
        this.sub = []
    }
    // 依賴添加
    addDepend () {
        if (Dep.target) {
          Dep.target.addDep(this)
        }
    }
    addSub (sub) {
        this.sub.push(sub)
    }
    notify () {
        for (let sub of this.sub) {
            sub.update()
        }
    }
}

        這裏比較有意思的一點就是依賴添加的時候是從dep的addDepend繞到了watcher的addDep,最後又繞到了dep的addSub去進行最終的添加,而不是在addDepend中直接把Dep.target所綁定的watcher直接加到sub中去,這樣做的目的是watcher需要記錄發佈者的信息,以防重複添加相同的發佈者,在vue源碼中有所體現,而本文做極簡處理就不考慮dep的記錄了。以下是vue源碼的關於addDep函數的代碼,位置在源碼src\core\observer\watcher.js處:

/**
   * 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)
      }
    }
  }

      最後我們看一下notify函數,這裏就是通知sub中所有的watcher進行更新操作了。

      到這裏爲止發佈-訂閱的模式就已經都實現了,接下來就是考慮如何更新{{}}中的內容了,也是就實例化Compiler,開始編譯$el的內容。讓我們看一下compiler.js的內容:

class Compiler {
  constructor (el, vm) {
    vm.$el = document.querySelector(el)
    this.replace(vm.$el, vm)
  }
  replace (el, vm) {
    let childNodes = [...el.childNodes]
    let self = this
    childNodes.forEach(node => {
      let txt = node.textContent
      // 正則匹配{{}}
      let reg = /\{\{(.*?)\}\}/g
      if (node.nodeType === Node.TEXT_NODE && reg.test(txt)) {
        // 考慮文本內容出現多次{{}}
        let moustache = txt.match(reg)
        moustache.forEach(subMoustache => {
          // 爲{{}}中的屬性綁定watcher
          this.watch(node, subMoustache, vm, moustache, txt)
        })
      }
      // 如果是元素節點
      if (node.nodeType === Node.ELEMENT_NODE) {
        let nodeAttr = [...node.attributes]
        nodeAttr.forEach(attr => {
          let name = attr.name
          let exp = attr.value
          switch (name) {
            case 'v-model':
                node.value = this.getExpValue(exp, vm)
                vm.$watch(exp, function(newVal) {
                  node.value = newVal
                })
                node.addEventListener('input', e => {
                  let newVal = e.target.value
                  self.setExpValue(exp, newVal, vm)
                })
                break
            case '@click':
                node.addEventListener('click', e => {
                  let tep = /(.+)\((.*)\)/.exec(exp)
                  let [func, params] = [tep[1], tep[2].split(',')]
                  // 判斷參數是否來自data選項,若否則直接以字符形式作爲入參
                  params = params.map(key => {
                    return self.getExpValue(key, vm) || key
                  })
                  vm.$options.methods[func].apply(vm, params)
                })
                break
          }
        })
      }
      // 如果還有子節點,繼續遞歸replace
      if (node.childNodes && node.childNodes.length) {
        this.replace(node, vm);
      }
    })
  }
  watch (node, content, vm, moustache, txt) {
    let self = this
    let prop = (/\{\{(\S*)\}\}/).exec(content)[1]
    self.replaceContent(node, moustache, vm, txt)
    vm.$watch(prop, function () {
      self.replaceContent(node, moustache, vm, txt)
    })
  }
  replaceContent (node, moustache, vm, txt) {
    for (let mkey of moustache) {
      let prop = (/\{\{(\S*)\}\}/).exec(mkey)[1]
      let value = this.getExpValue(prop, vm)
      txt = txt.replace(mkey, value).trim()
    }
    node.textContent = txt
  }
  getExpValue (exp, vm) {
    if (/^\'(.*)\'$/.test(exp) || /^(\d+)$/.test(exp)) return RegExp.$1
    let arr = exp.trim().split('.')
    let val = vm
    for (let key of arr) {
      val = val[key]
    }
    return val
  }
  setExpValue (exp, value, vm) {
    let arr = exp.split('.')
    let val = vm
    arr.forEach((key, i)=> {
      if (i === arr.length - 1) {
        val[key] = value
        return
      }
      val = val[key]
    })
  }
}

      首先獲取el標識的dom,然後開始遞歸遍歷dom的子節點(childNodes),如果是元素節點那就遍歷節點的屬性開始實現自定義的指令比如v-model, @click等。如果是文本節點那就匹配文本中{{}}出現的屬性,在watch函數裏面用屬性值先替換掉{{}}中的內容,然後通過$watch監聽這些屬性變化,在回調函數裏面放置replaceContent以便更新節點的textContent(文本內容)。到此爲止一個基礎版的es數據雙向綁定框架Hue就初步完成了。其中compiler裏的難點就是同一個文本節點出現多個{{}}的時候如何替換,更進一步的話還需要考慮{{}}中是表達式的情況,這個時候就需要進一步的解析。這裏就不再進行更多的實現了,大家有興趣的話可以自行實現。判斷節點類型的時候筆者用到了Node.ELEMENT_NODE而不是1,用常量去代替具體的值,這是一個比較好的習慣,一方面讓代碼更具可讀性,另一方面也可以使之更加容易維護。

    仿vue實現數據雙向綁定的分享到此就結束了,如果對於本文有任何疑問可以在下方留言。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章