試著用Proxy 實現一個簡單mvvm

naice發表於2018-07-03

Proxy、Reflect的簡單概述

Proxy 可以理解成,在目標物件之前架設一層“攔截”,外界對該物件的訪問,都必須先通過這層攔截,因此提供了一種機制,可以對外界的訪問進行過濾和改寫。Proxy 這個詞的原意是代理,用在這裡表示由它來“代理”某些操作,可以譯為“代理器”。 出自阮一峰老師的ECMAScript 6 入門,詳細點選es6.ruanyifeng.com/#docs/proxy

例如:

var obj = new Proxy({}, {
  get: function (target, key, receiver) {
    console.log(`getting ${key}!`);
    return Reflect.get(target, key, receiver);
  },
  set: function (target, key, value, receiver) {
    console.log(`setting ${key}!`);
    return Reflect.set(target, key, value, receiver);
  }
});
複製程式碼

上面程式碼對一個空物件架設了一層攔截,重定義了屬性的讀取(get)和設定(set)行為。這裡暫時先不解釋具體的語法,只看執行結果。對設定了攔截行為的物件obj,去讀寫它的屬性,就會得到下面的結果。

obj.count = 1
//  setting count!
++obj.count
//  getting count!
//  setting count!
//  2
複製程式碼
var proxy = new Proxy(target, handler);
複製程式碼

這裡有兩個引數,target參數列示所要攔截的目標物件,handler引數也是一個物件,用來定製攔截行為。

注意,要使得Proxy起作用,必須針對Proxy例項(上例是proxy物件)進行操作,而不是針對目標物件(上例是空物件)進行操作。

Reflect對象與Proxy物件一樣,也是 ES6 為了操作物件而提供的新API

Reflect物件的方法與Proxy物件的方法一一對應,只要是Proxy物件的方法,就能在Reflect物件上找到對應的方法。這就讓Proxy物件可以方便地呼叫對應的Reflect方法,完成預設行為,作為修改行為的基礎。也就是說,不管Proxy怎麼修改預設行為,你總可以在Reflect上獲取預設行為。

同樣也放上阮一峰老師的連結es6.ruanyifeng.com/#docs/refle…

初始化結構

看到這裡,我就當大家有比較明白Proxy(代理)是做什麼用的,然後下面我們看下要做最終的圖騙。

試著用Proxy 實現一個簡單mvvm

看到上面的圖片,首先我們新建一個index.html,然後裡面的程式碼是這樣子滴。很簡單

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>簡單版mvvm</title>
</head>
<body>
<div id="app">
    <h1>開發語言:{{language}}</h1>
    <h2>組成部分:</h2>
    <ul>
        <li>{{makeUp.one}}</li>
        <li>{{makeUp.two}}</li>
        <li>{{makeUp.three}}</li>
    </ul>
    <h2>描述:</h2>
    <p>{{describe}}</p>
    <p>計算屬性:{{sum}}</p>
    <input placeholder="123" v-module="language" />
</div>
<script>
// 寫法和Vue一樣
const mvvm = new Mvvm({
    el: '#app',
    data: {
        language: 'Javascript',
        makeUp: {
            one: 'ECMAScript',
            two: '文件物件模型(DOM)',
            three: '瀏覽器物件模型(BOM)'
        },
        describe: '沒什麼產品是寫不了的',
        a: 1,
        b: 2
    },
    computed: {
        sum() {
        return this.a + this.b
    }
})
</script>
</body>
</html>

複製程式碼

看到上面的程式碼,大概跟vue長得差不多,下面去實現Mvvm這個建構函式

實現Mvvm這個建構函式

首先宣告一個Mvvm函式,options當作引數傳進來,options就是上面程式碼的配置,裡面有eldatacomputed~~

function Mvvm(options = {}) {
    // 把options 賦值給this.$options
    this.$options = options
    // 把options.data賦值給this._data
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
    return this._vm
}
複製程式碼

上面Mvvm函式很簡單,就是把引數options 賦值給this.$options、把options.data賦值給this._data、然後呼叫初始化initVm函式,並用call改變this的指向,方便initVm函操作。然後返回一個this._vm,這個是在initVm函式生成的。

下面繼續寫initVm函式,


function initVm () {
    this._vm = new Proxy(this, {
        // 攔截get
        get: (target, key, receiver) => {
            return this[key] || this._data[key] || this._computed[key]
        },
        // 攔截set
        set: (target, key, value) => {
            return Reflect.set(this._data, key, value)
        }
    })
    return this._vm
}

複製程式碼

這個init函式用到Proxy攔截了,this物件,生產Proxy例項的然後賦值給this._vm,最後返回this._vm

上面我們說了,要使得Proxy起作用,必須針對Proxy例項。

在代理裡面,攔截了getsetget函式裡面,返回this物件的對應的key的值,沒有就去this._data物件裡面取對應的key,再沒有去this._computed物件裡面去對應的key值。set函式就是直接返回修改this._data對應key

做好這些各種攔截工作。我們就可以直接從實力上訪問到我們相對應的值了。(mvvm使我們第一塊程式碼生成的例項)

mvvm.b // 2
mvvm.a // 1
mvvm.language // "Javascript"
複製程式碼

試著用Proxy 實現一個簡單mvvm

如上圖看控制檯。可以設定值,可以獲取值,但是這不是響應式的。

開啟控制檯看一下

試著用Proxy 實現一個簡單mvvm

可以詳細的看到。只有_vm這個是proxy,我們需要的是,_data下面所有資料都是有攔截代理的;下面我們就去實現它。

實現所有資料代理攔截

我們首先在Mvvm裡面加一個initObserve,如下

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
+   initObserve.call(this, data) // 初始化data的Observe
    return this._vm
}
複製程式碼

initObserve這個函式主要是把,this._data都加上代理。如下


function initObserve(data) {
    this._data = observe(data) // 把所有observe都賦值到 this._data
}

// 分開這個主要是為了下面遞迴呼叫
function observe(data) {
    if (!data || typeof data !== 'object') return data // 如果不是物件直接返回值
    return new Observe(data) // 物件呼叫Observe
}
複製程式碼

下面主要實現Observe類

// Observe類
class Observe {
    constructor(data) {
        this.dep = new Dep() // 訂閱類,後面會介紹
        for (let key in data) {
            data[key] = observe(data[key]) // 遞迴呼叫子物件
        }
        return this.proxy(data)
    }
    proxy(data) {
      let dep = this.dep
      return new Proxy(data, {
        get: (target, key, receiver) => {
          return Reflect.get(target, key, receiver)
        },
        set: (target, key, value) => {
          const result = Reflect.set(target, key, observe(value)) // 對於新新增的物件也要進行新增observe
          return result  
        }
      })
    }
  }

複製程式碼

這樣子,通過我們層層遞迴新增proxy,把我們的_data物件都新增一遍,再看一下控制檯

試著用Proxy 實現一個簡單mvvm

很不錯,_data也有proxy了,很王祖藍式的完美。

看到我們的html的介面,都是沒有資料的,上面我們把資料都準備好了,下面我們就開始把資料結合到html的介面上。

套資料,實現hmtl介面

先把計算屬性這個html註釋掉,後面進行實現

<!-- <p>計算屬性:{{sum}}</p> -->
複製程式碼

然後在Mvvm函式中增加一個編譯函式,➕號表示是新增的函式

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
+   new Compile(this.$options.el, vm) // 新增一個編譯函式
    return this._vm
}
複製程式碼

上面我們新增了一個Compile的建構函式。把配置的el作為引數傳機進來,把生成proxy的例項vm也傳進去,這樣子我們就可以拿到vm下面的資料,下面我們就去實現它。順序讀註釋就可以了,很好理解

// 編譯類
class Compile {
    constructor (el, vm) {
        this.vm = vm // 把傳進來的vm 存起來,因為這個vm.a = 1 沒毛病
        let element = document.querySelector(el) // 拿到 app 節點
        let fragment = document.createDocumentFragment() // 建立fragment程式碼片段
        fragment.append(element) // 把app節點 新增到 建立fragment程式碼片段中
        this.replace(fragment) // 套資料函式
        document.body.appendChild(fragment) // 最後新增到body中
    }
    replace(frag) {
        let vm = this.vm // 拿到之前存起來的vm
        // 迴圈frag.childNodes
        Array.from(frag.childNodes).forEach(node => {
            let txt = node.textContent // 拿到文字 例如:"開發語言:{{language}}"
            let reg = /\{\{(.*?)\}\}/g // 定義匹配正則
            if (node.nodeType === 3 && reg.test(txt)) {
            
                replaceTxt()
                
                function replaceTxt() {
                    // 如果匹配到的話,就替換文字
                    node.textContent = txt.replace(reg, (matched, placeholder) => {
                        return placeholder.split('.').reduce((obj, key) => {
                            return obj[key] // 例如:去vm.makeUp.one物件拿到值
                        }, vm)
                    })
                }
            }
            // 如果還有位元組點,並且長度不為0 
            if (node.childNodes && node.childNodes.length) {
                // 直接遞迴匹配替換
                this.replace(node)
            }
        })
    }
}

複製程式碼

上面的編譯函式,總之就是一句話,千方百計的把{{xxx}}的佔位符通過正則替換成真實的資料。

然後重新整理瀏覽器,鐺鐺檔鐺鐺檔,就出現我們要的資料了。

試著用Proxy 實現一個簡單mvvm

很好很好,但是我們現在的資料並不是改變了 就發生變化了。還需要訂閱釋出和watcher來配合,才能做好改變資料就發生變化了。下面我們先實現訂閱釋出。

實現訂閱釋出

訂閱釋出其實是一種常見的程式設計模式,簡單直白來說就是:

把函式push到一個陣列裡面,然後迴圈資料呼叫函式。

例如:舉個很直白的例子

let arr = [] 
let a = () => {console.log('a')}

arr.push(a) // 訂閱a函式
arr.push(a) // 又訂閱a函式
arr.push(a) // 雙訂閱a函式

arr.forEach(fn => fn()) // 釋出所有

// 此時會列印三個a
複製程式碼

很簡單吧。下面我們去實現我們的程式碼

// 訂閱類
class Dep {
    constructor() {
        this.subs = [] // 定義陣列
    }
    // 訂閱函式
    addSub(sub) {
        this.subs.push(sub)
    }
    // 釋出函式
    notify() {
        this.subs.filter(item => typeof item !== 'string').forEach(sub => sub.update())
    }
}
複製程式碼

訂閱釋出是寫好了,但是在什麼時候訂閱,什麼時候釋出??這時候,我們是在資料獲取的時候訂閱watcher,然後在資料設定的時候釋出watcher,在上面的Observe類裡面裡面,看➕號的程式碼。 .

... //省略程式碼
...
proxy(data) {
    let dep = this.dep
    return new Proxy(data, {
        // 攔截get
        get: (target, prop, receiver) => {
+           if (Dep.target) {
                // 如果之前是push過的,就不用重複push了
                if (!dep.subs.includes(Dep.exp)) {
                    dep.addSub(Dep.exp) // 把Dep.exp。push到sub陣列裡面,訂閱
                    dep.addSub(Dep.target) // 把Dep.target。push到sub陣列裡面,訂閱
                }
+           }
            return Reflect.get(target, prop, receiver)
        },
        // 攔截set
        set: (target, prop, value) => {
            const result = Reflect.set(target, prop, observe(value))
+           dep.notify() // 釋出
            return result  
        }
    })
}
複製程式碼

上面程式碼說到,watcher是什麼鬼?然後釋出裡面的sub.update()又是什麼鬼??

帶著一堆疑問我們來到了watcher

實現watcher

看詳細註釋

// Watcher類
class Watcher {
    constructor (vm, exp, fn) {
        this.fn = fn // 傳進來的fn
        this.vm = vm // 傳進來的vm
        this.exp = exp // 傳進來的匹配到exp 例如:"language""makeUp.one"
        Dep.exp = exp // 給Dep類掛載一個exp
        Dep.target = this // 給Dep類掛載一個watcher物件,跟新的時候就用到了
        let arr = exp.split('.')
        let val = vm
        arr.forEach(key => {
            val = val[key] // 獲取值,這時候會粗發vm.proxy的get()函式,get()裡面就新增addSub訂閱函式
        })
        Dep.target = null // 新增了訂閱之後,把Dep.target清空
    }
    update() {
        // 設定值會觸發vm.proxy.set函式,然後呼叫釋出的notify,
        // 最後呼叫update,update裡面繼續呼叫this.fn(val)
        let exp = this.exp
        let arr = exp.split('.')
        let val = this.vm
        arr.forEach(key => {
            val = val[key]
        })
        this.fn(val)
    }
}

複製程式碼

Watcher類就是我們要訂閱的watcher,裡面有回撥函式fn,有update函式呼叫fn,

我們都弄好了。但是在哪裡新增watcher呢??如下程式碼

在Compile裡面

...
...
function replaceTxt() {
    node.textContent = txt.replace(reg, (matched, placeholder) => {
+       new Watcher(vm, placeholder, replaceTxt);   // 監聽變化,進行匹配替換內容
        return placeholder.split('.').reduce((val, key) => {
            return val[key]
        }, vm)
    })
}

複製程式碼

新增好有所的東西了,我們看一下控制檯。修改發現果然起作用了。

試著用Proxy 實現一個簡單mvvm

然後我們回顧一下所有的流程,然後看見古老(我也是別的地方弄來的)的一張圖。

幫助理解嘛

試著用Proxy 實現一個簡單mvvm

響應式的資料我們都已經完成了,下面我們完成一下雙向繫結。

實現雙向繫結

看到我們html裡面有個<input placeholder="123" v-module="language" />v-module繫結了一個language,然後在Compile類裡面的replace函式,我們加上

replace(frag) {
    let vm = this.vm
    Array.from(frag.childNodes).forEach(node => {
        let txt = node.textContent
        let reg = /\{\{(.*?)\}\}/g
        // 判斷nodeType
+       if (node.nodeType === 1) {
            const nodeAttr = node.attributes // 屬性集合
            Array.from(nodeAttr).forEach(item => {
                let name = item.name // 屬性名
                let exp = item.value // 屬性值
                // 如果屬性有 v-
                if (name.includes('v-')){
                    node.value = vm[exp]
                    node.addEventListener('input', e => {
                        // 相當於給this.language賦了一個新值
                        // 而值的改變會呼叫setset中又會呼叫notify,notify中呼叫watcher的update方法實現了更新操作
                        vm[exp] = e.target.value
                    })
                }
            });
+       }
        ...
        ...
    }
  }

複製程式碼

上面的方法就是,讓我們的input節點繫結一個input事件,然後當input事件觸發的時候,改變我們的值,而值的改變會呼叫setset中又會呼叫notifynotify中呼叫watcherupdate方法實現了更新操作。

然後我們看一下,介面

試著用Proxy 實現一個簡單mvvm

雙向資料繫結我們基本完成了,別忘了,我們上面還有個註釋掉的計算屬性。

計算屬性

先把<p>計算屬性:{{sum}}</p>註釋去掉,以為上面一開始initVm函式裡面,我們加了這個程式碼return this[key] || this._data[key] || this._computed[key],到這裡大家都明白了,只需要把this._computed也加一個watcher就好了。

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
    initObserve.call(this, data)
+   initComputed.call(this) // 新增計算函式,改變this指向
    new Compile(this.$options.el, vm)
    return this._vm
}


function initComputed() {
    let vm = this
    let computed = this.$options.computed // 拿到配置的computed
    vm._computed = {}
    if (!computed) return // 沒有計算直接返回
    Object.keys(computed).forEach(key => {
        // 相當於把sum裡的this指向到this._vm,然後就可以拿到this.a、this、b
        this._computed[key] = computed[key].call(this._vm)
        // 新增新的Watcher
        new Watcher(this._vm, key, val => {
            // 每次設定的時候都會計算
            this._computed[key] = computed[key].call(this._vm)
        })
    })
}

複製程式碼

上面的initComputed 就是新增一個watcher,大致流程:

this._vm改變 ---> vm.set() ---> notify() -->update()-->更新介面

最後看看圖片

試著用Proxy 實現一個簡單mvvm

一切似乎沒什麼毛病~~~~

新增mounted鉤子

新增mounted也很簡單

// 寫法和Vue一樣
let mvvm = new Mvvm({
    el: '#app',
    data: {
        ...
        ...
    },
    computed: {
        ...
        ...
    },
    mounted() {
        console.log('i am mounted', this.a)
    }
})

複製程式碼

在new Mvvm裡面新增mounted, 然後到function Mvvm裡面加上

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
    initObserve.call(this, data)
    initComputed.call(this)
    new Compile(this.$options.el, vm)
+   mounted.call(this._vm) // 加上mounted,改變指向
    return this._vm
}

// 執行mounted
+ function mounted() {
    let mounted = this.$options.mounted
    mounted && mounted.call(this)
+ }

複製程式碼

執行之後會列印出

i am mounted 1
複製程式碼

完結~~~~撒花

ps:編譯裡面的,參考到這個大神的操作。@chenhongdong,謝謝大佬

最後附上,原始碼地址,直接下載執行就可以啦。

原始碼地址:github.com/naihe138/pr…

預覽地址:gitblog.naice.me/proxy-mvvm/…

相關文章