不好意思!耽誤你的十分鐘,讓MVVM原理還給你

chenhongdong發表於2018-04-01

時間在嘀嗒嘀嗒的走著

既然來了就繼續看看吧

  • 這篇文章其實沒有什麼鳥用,只不過對於現在的前端面試而言,已經是一個被問煩了的考點了
  • 既然是考點,那麼我就想簡簡單單的來給大家劃一下重點

眾所周知當下是MVVM盛行的時代,從早期的Angular到現在的React和Vue,再從最初的三分天下到現在的兩虎相爭。

無疑不給我們的開發帶來了一種前所未有的新體驗,告別了操作DOM的思維,換上了資料驅動頁面的思想,果然時代的進步,改變了我們許多許多。

囉嗦話多了起來,這樣不好。我們來進入今天的主題

劃重點

MVVM 雙向資料繫結 在Angular1.x版本的時候通過的是髒值檢測來處理

而現在無論是React還是Vue還是最新的Angular,其實實現方式都更相近了

那就是通過資料劫持+釋出訂閱模式

真正實現其實靠的也是ES5中提供的Object.defineProperty,當然這是不相容的所以Vue等只支援了IE8+

為什麼是它

Object.defineProperty()說實在的我們大家在開發中確實用的不多,多數是修改內部特性,不過就是定義物件上的屬性和值麼?幹嘛搞的這麼費勁(純屬個人想法)

But在實現框架or庫的時候卻發揮了大用場了,這個就不多說了,只不過輕舟一片而已,還沒到寫庫的實力

知其然要知其所以然,來看看如何使用

let obj = {};
let song = '發如雪'; 
obj.singer = '周杰倫';  

Object.defineProperty(obj, 'music', {
    // 1. value: '七里香',
    configurable: true,     // 2. 可以配置物件,刪除屬性
    // writable: true,         // 3. 可以修改物件
    enumerable: true,        // 4. 可以列舉
    // ☆ get,set設定時不能設定writable和value,它們代替了二者且是互斥的
    get() {     // 5. 獲取obj.music的時候就會呼叫get方法
        return song;
    },
    set(val) {      // 6. 將修改的值重新賦給song
        song = val;   
    }
});

// 下面列印的部分分別是對應程式碼寫入順序執行
console.log(obj);   // {singer: '周杰倫', music: '七里香'}  // 1

delete obj.music;   // 如果想對obj裡的屬性進行刪除,configurable要設為true  2
console.log(obj);   // 此時為  {singer: '周杰倫'}

obj.music = '聽媽媽的話';   // 如果想對obj的屬性進行修改,writable要設為true  3
console.log(obj);   // {singer: '周杰倫', music: "聽媽媽的話"}

for (let key in obj) {    
    // 預設情況下通過defineProperty定義的屬性是不能被列舉(遍歷)的
    // 需要設定enumerable為true才可以
    // 不然你是拿不到music這個屬性的,你只能拿到singer
    console.log(key);   // singer, music    4
}

console.log(obj.music);   // '發如雪'  5
obj.music = '夜曲';       // 呼叫set設定新的值
console.log(obj.music);   // '夜曲'    6
複製程式碼

以上是關於Object.defineProperty的用法

下面我們來寫個例項看看,這裡我們以Vue為參照去實現怎麼寫MVVM

// index.html
<body>
    <div id="app">
        <h1>{{song}}</h1>
        <p>《{{album.name}}》是{{singer}}2005年11月發行的專輯</p>
        <p>主打歌為{{album.theme}}</p>
        <p>作詞人為{{singer}}等人。</p>
        為你彈奏肖邦的{{album.theme}}
    </div>
    <!--實現的mvvm-->
    <script src="mvvm.js"></script>
    <script>
        // 寫法和Vue一樣
        let mvvm = new Mvvm({
            el: '#app',
            data: {     // Object.defineProperty(obj, 'song', '發如雪');
                song: '發如雪',
                album: {
                    name: '十一月的蕭邦',
                    theme: '夜曲'
                },
                singer: '周杰倫'
            }
        });
    </script>
</body>
複製程式碼

上面是html裡的寫法,相信用過Vue的同學並不陌生

那麼現在就開始實現一個自己的MVVM吧

打造MVVM

// 建立一個Mvvm建構函式
// 這裡用es6方法將options賦一個初始值,防止沒傳,等同於options || {}
function Mvvm(options = {}) {   
    // vm.$options Vue上是將所有屬性掛載到上面
    // 所以我們也同樣實現,將所有屬性掛載到了$options
    this.$options = options;
    // this._data 這裡也和Vue一樣
    let data = this._data = this.$options.data;
    
    // 資料劫持
    observe(data);
}
複製程式碼

資料劫持

為什麼要做資料劫持?

  • 觀察物件,給物件增加Object.defineProperty
  • vue特點是不能新增不存在的屬性 不存在的屬性沒有get和set
  • 深度響應 因為每次賦予一個新物件時會給這個新物件增加defineProperty(資料劫持)

多說無益,一起看程式碼

// 建立一個Observe建構函式
// 寫資料劫持的主要邏輯
function Observe(data) {
    // 所謂資料劫持就是給物件增加get,set
    // 先遍歷一遍物件再說
    for (let key in data) {     // 把data屬性通過defineProperty的方式定義屬性
        let val = data[key];
        observe(val);   // 遞迴繼續向下找,實現深度的資料劫持
        Object.defineProperty(data, key, {
            configurable: true,
            get() {
                return val;
            },
            set(newVal) {   // 更改值的時候
                if (val === newVal) {   // 設定的值和以前值一樣就不理它
                    return;
                }
                val = newVal;   // 如果以後再獲取值(get)的時候,將剛才設定的值再返回去
                observe(newVal);    // 當設定為新值後,也需要把新值再去定義成屬性
            }
        });
    }
}

// 外面再寫一個函式
// 不用每次呼叫都寫個new
// 也方便遞迴呼叫
function observe(data) {
    // 如果不是物件的話就直接return掉
    // 防止遞迴溢位
    if (!data || typeof data !== 'object') return;
    return new Observe(data);
}
複製程式碼

以上程式碼就實現了資料劫持,不過可能也有些疑惑的地方比如:遞迴

再來細說一下為什麼遞迴吧,看這個栗子

    let mvvm = new Mvvm({
        el: '#app',
        data: {
            a: {
                b: 1
            },
            c: 2
        }
    });
複製程式碼

我們在控制檯裡看下

不好意思!耽誤你的十分鐘,讓MVVM原理還給你
被標記的地方就是通過遞迴observe(val)進行資料劫持新增上了get和set,遞迴繼續向a裡面的物件去定義屬性,親測通過可放心食用

接下來說一下observe(newVal)這裡為什麼也要遞迴

還是在可愛的控制檯上,敲下這麼一段程式碼 mvvm._data.a = {b:'ok'}

然後繼續看圖說話

不好意思!耽誤你的十分鐘,讓MVVM原理還給你
通過observe(newVal)加上了
不好意思!耽誤你的十分鐘,讓MVVM原理還給你
現在大致明白了為什麼要對設定的新值也進行遞迴observe了吧,哈哈,so easy

資料劫持已完成,我們再做個資料代理

資料代理

資料代理就是讓我們每次拿data裡的資料時,不用每次都寫一長串,如mvvm._data.a.b這種,我們其實可以直接寫成mvvm.a.b這種顯而易見的方式

下面繼續看下去,+號表示實現部分

function Mvvm(options = {}) {  
    // 資料劫持
    observe(data);
    // this 代理了this._data
+   for (let key in data) {
        Object.defineProperty(this, key, {
            configurable: true,
            get() {
                return this._data[key];     // 如this.a = {b: 1}
            },
            set(newVal) {
                this._data[key] = newVal;
            }
        });
+   }
}

// 此時就可以簡化寫法了
console.log(mvvm.a.b);   // 1
mvvm.a.b = 'ok';    
console.log(mvvm.a.b);  // 'ok'
複製程式碼

寫到這裡資料劫持和資料代理都實現了,那麼接下來就需要編譯一下了,把{{}}裡面的內容解析出來

資料編譯

function Mvvm(options = {}) {
    // observe(data);
        
    // 編譯    
+   new Compile(options.el, this);    
}

// 建立Compile建構函式
function Compile(el, vm) {
    // 將el掛載到例項上方便呼叫
    vm.$el = document.querySelector(el);
    // 在el範圍裡將內容都拿到,當然不能一個一個的拿
    // 可以選擇移到記憶體中去然後放入文件碎片中,節省開銷
    let fragment = document.createDocumentFragment();
    
    while (child = vm.$el.firstChild) {
        fragment.appendChild(child);    // 此時將el中的內容放入記憶體中
    }
    // 對el裡面的內容進行替換
    function replace(frag) {
        Array.from(frag.childNodes).forEach(node => {
            let txt = node.textContent;
            let reg = /\{\{(.*?)\}\}/g;   // 正則匹配{{}}
            
            if (node.nodeType === 3 && reg.test(txt)) { // 即是文字節點又有大括號的情況{{}}
                console.log(RegExp.$1); // 匹配到的第一個分組 如: a.b, c
                let arr = RegExp.$1.split('.');
                let val = vm;
                arr.forEach(key => {
                    val = val[key];     // 如this.a.b
                });
                // 用trim方法去除一下首尾空格
                node.textContent = txt.replace(reg, val).trim();
            }
            // 如果還有子節點,繼續遞迴replace
            if (node.childNodes && node.childNodes.length) {
                replace(node);
            }
        });
    }
    
    replace(fragment);  // 替換內容
    
    vm.$el.appendChild(fragment);   // 再將文件碎片放入el中
}
複製程式碼

看到這裡在面試中已經可以初露鋒芒了,那就一鼓作氣,做事做全套,來個一條龍

現在資料已經可以編譯了,但是我們手動修改後的資料並沒有在頁面上發生改變

下面我們就來看看怎麼處理,其實這裡就用到了特別常見的設計模式,釋出訂閱模式

釋出訂閱

釋出訂閱主要靠的就是陣列關係,訂閱就是放入函式,釋出就是讓陣列裡的函式執行

// 釋出訂閱模式  訂閱和釋出 如[fn1, fn2, fn3]
function Dep() {
    // 一個陣列(存放函式的事件池)
    this.subs = [];
}
Dep.prototype = {
    addSub(sub) {   
        this.subs.push(sub);    
    },
    notify() {
        // 繫結的方法,都有一個update方法
        this.subs.forEach(sub => sub.update());
    }
};
// 監聽函式
// 通過Watcher這個類建立的例項,都擁有update方法
function Watcher(fn) {
    this.fn = fn;   // 將fn放到例項上
}
Watcher.prototype.update = function() {
    this.fn();  
};

let watcher = new Watcher(() => console.log(111));  // 
let dep = new Dep();
dep.addSub(watcher);    // 將watcher放到陣列中,watcher自帶update方法, => [watcher]
dep.addSub(watcher);
dep.notify();   //  111, 111
複製程式碼

資料更新檢視

  • 現在我們要訂閱一個事件,當資料改變需要重新重新整理檢視,這就需要在replace替換的邏輯裡來處理
  • 通過new Watcher把資料訂閱一下,資料一變就執行改變內容的操作
function replace(frag) {
    // 省略...
    // 替換的邏輯
    node.textContent = txt.replace(reg, val).trim();
    // 監聽變化
    // 給Watcher再新增兩個引數,用來取新的值(newVal)給回撥函式傳參
+   new Watcher(vm, RegExp.$1, newVal => {
        node.textContent = txt.replace(reg, newVal).trim();    
+   });
}

// 重寫Watcher建構函式
function Watcher(vm, exp, fn) {
    this.fn = fn;
+   this.vm = vm;
+   this.exp = exp;
    // 新增一個事件
    // 這裡我們先定義一個屬性
+   Dep.target = this;
+   let arr = exp.split('.');
+   let val = vm;
+   arr.forEach(key => {    // 取值
+      val = val[key];     // 獲取到this.a.b,預設就會呼叫get方法
+   });
+   Dep.target = null;
}
複製程式碼

當獲取值的時候就會自動呼叫get方法,於是我們去找一下資料劫持那裡的get方法

function Observe(data) {
+   let dep = new Dep();
    // 省略...
    Object.defineProperty(data, key, {
        get() {
+           Dep.target && dep.addSub(Dep.target);   // 將watcher新增到訂閱事件中 [watcher]
            return val;
        },
        set(newVal) {
            if (val === newVal) {
                return;
            }
            val = newVal;
            observe(newVal);
+           dep.notify();   // 讓所有watcher的update方法執行即可
        }
    })
}
複製程式碼

當set修改值的時候執行了dep.notify方法,這個方法是執行watcher的update方法,那麼我們再對update進行修改一下

Watcher.prototype.update = function() {
    // notify的時候值已經更改了
    // 再通過vm, exp來獲取新的值
+   let arr = this.exp.split('.');
+   let val = this.vm;
+   arr.forEach(key => {    
+       val = val[key];   // 通過get獲取到新的值
+   });
    this.fn(val);   // 將每次拿到的新值去替換{{}}的內容即可
};
複製程式碼

現在我們資料的更改可以修改檢視了,這很good,還剩最後一點,我們再來看看面試常考的雙向資料繫結吧

雙向資料繫結

    // html結構
    <input v-model="c" type="text">
    
    // 資料部分
    data: {
        a: {
            b: 1
        },
        c: 2
    }
    
    function replace(frag) {
        // 省略...
+       if (node.nodeType === 1) {  // 元素節點
            let nodeAttr = node.attributes; // 獲取dom上的所有屬性,是個類陣列
            Array.from(nodeAttr).forEach(attr => {
                let name = attr.name;   // v-model  type
                let exp = attr.value;   // c        text
                if (name.includes('v-')){
                    node.value = vm[exp];   // this.c 為 2
                }
                // 監聽變化
                new Watcher(vm, exp, function(newVal) {
                    node.value = newVal;   // 當watcher觸發時會自動將內容放進輸入框中
                });
                
                node.addEventListener('input', e => {
                    let newVal = e.target.value;
                    // 相當於給this.c賦了一個新值
                    // 而值的改變會呼叫setset中又會呼叫notify,notify中呼叫watcher的update方法實現了更新
                    vm[exp] = newVal;   
                });
            });
+       }
        if (node.childNodes && node.childNodes.length) {
            replace(node);
        }
    }
複製程式碼

大功告成,面試問Vue的東西不過就是這個罷了,什麼雙向資料繫結怎麼實現的,問的一點心意都沒有,差評!!!

大官人請留步,本來應該收手了,可臨時起意(手癢),再寫點功能吧,再加個computed(計算屬性)和mounted(鉤子函式)吧

computed(計算屬性) && mounted(鉤子函式)

    // html結構
    <p>求和的值是{{sum}}</p>
    
    data: { a: 1, b: 9 },
    computed: {
        sum() {
            return this.a + this.b;
        },
        noop() {}
    },
    mounted() {
        setTimeout(() => {
            console.log('所有事情都搞定了');
        }, 1000);
    }
    
    function Mvvm(options = {}) {
        // 初始化computed,將this指向例項
+       initComputed.call(this);     
        // 編譯
        new Compile(options.el, this);
        // 所有事情處理好後執行mounted鉤子函式
+       options.mounted.call(this); // 這就實現了mounted鉤子函式
    }
    
    function initComputed() {
        let vm = this;
        let computed = this.$options.computed;  // 從options上拿到computed屬性   {sum: ƒ, noop: ƒ}
        // 得到的都是物件的key可以通過Object.keys轉化為陣列
        Object.keys(computed).forEach(key => {  // key就是sum,noop
            Object.defineProperty(vm, key, {
                // 這裡判斷是computed裡的key是物件還是函式
                // 如果是函式直接就會調get方法
                // 如果是物件的話,手動調一下get方法即可
                // 如: sum() {return this.a + this.b;},他們獲取a和b的值就會呼叫get方法
                // 所以不需要new Watcher去監聽變化了
                get: typeof computed[key] === 'function' ? computed[key] : computed[key].get,
                set() {}
            });
        });
    }
複製程式碼

寫了這些內容也不算少了,最後做一個形式上的總結吧

總結

通過自己實現的mvvm一共包含了以下東西

  1. 通過Object.defineProperty的get和set進行資料劫持
  2. 通過遍歷data資料進行資料代理到this上
  3. 通過{{}}對資料進行編譯
  4. 通過釋出訂閱模式實現資料與檢視同步
  5. 通過通過通過,收了,感謝大官人的留步了

補充

針對以上程式碼在實現編譯的時候還是會有一些小bug,再次經過研究和高人指點,完善了編譯,下面請看修改後的程式碼

修復:兩個相鄰的{{}}正則匹配,後一個不能正確編譯成對應的文字,如{{album.name}} {{singer}}

function Compile(el, vm) {
    // 省略...
    function replace(frag) {
        // 省略...
        if (node.nodeType === 3 && reg.test(txt)) {
            function replaceTxt() {
                node.textContent = txt.replace(reg, (matched, placeholder) => {   
                    console.log(placeholder);   // 匹配到的分組 如:song, album.name, singer...
                    new Watcher(vm, placeholder, replaceTxt);   // 監聽變化,進行匹配替換內容
                    
                    return placeholder.split('.').reduce((val, key) => {
                        return val[key]; 
                    }, vm);
                });
            };
            // 替換
            replaceTxt();
        }
    }
}
複製程式碼

上面程式碼主要實現依賴的是reduce方法,reduce 為陣列中的每一個元素依次執行回撥函式

如果還有不太清楚的,那我們單獨抽出來reduce這部分再看一下

    // 將匹配到的每一個值都進行split分割
    // 如:'song'.split('.') => ['song'] => ['song'].reduce((val, key) => val[key]) 
    // 其實就是將vm傳給val做初始值,reduce執行一次回撥返回一個值
    // vm['song'] => '周杰倫'
    
    // 上面不夠深入,我們再來看一個
    // 再如:'album.name'.split('.') => ['album', 'name'] => ['album', 'name'].reduce((val, key) => val[key])
    // 這裡vm還是做為初始值傳給val,進行第一次呼叫,返回的是vm['album']
    // 然後將返回的vm['album']這個物件傳給下一次呼叫的val
    // 最後就變成了vm['album']['name'] => '十一月的蕭邦'
    
    return placeholder.split('.').reduce((val, key) => {
        return val[key]; 
    }, vm);
複製程式碼

reduce的用處多多,比如計算陣列求和是比較普通的方法了,還有一種比較好用的妙處是可以進行二維陣列的展平(flatten),各位不妨來看最後一眼

let arr = [
  [1, 2],
  [3, 4],
  [5, 6]
];

let flatten = arr.reduce((previous, current) => {
  return previous.concat(current);
});

console.log(flatten); // [1, 2, 3, 4, 5, 6]

// ES6中也可以利用...展開運算子來實現的,實現思路一樣,只是寫法更精簡了
flatten = arr.reduce((a, b) => [...a, ...b]);
console.log(flatten); // [1, 2, 3, 4, 5, 6]
複製程式碼

再次感謝父老鄉親,兄弟姐妹們的觀看了!這回真的是最後一眼了,已經到底了!

相關文章