Vue的運做原理——淺析MVVM及Virtual DOM

前言

本文不會拉出Vue的源碼出來剖析一番,也不會掛一大段代碼去籠統地講,儘可能會從邏輯角度一步步來梳理html

若是你跟以前的我同樣,據說過MVVM,也對Virtual Dom有所耳聞,可是說不出個大概
那麼但願這篇文章能對你有所幫助前端

MVVM

都說前端框架運用了MVVM的思想,那麼MVVM是什麼vue

  • M:Model(數據)node

  • V:View(視圖)git

  • VM:ViewModelgithub

其中VM就是解放生產力的核心
它讓咱們再也不須要去手動操做Dom更新視圖,一切都是自動完成的,咱們只需專一於數據邏輯以及頁面呈現web

如何自動更新

既然要自動更新,那麼這個中間人VM至少作了三件事正則表達式

  • 1 監聽到了數據變化算法

  • 2 通知視圖編程

  • 3 視圖執行更新

監聽數據變化

Vue目前用到的是Object.defineProperty()這個方法
未來Vue3.0會換成Proxy,不過是有點相似的,因此不用擔憂到時候須要從新學,理解了思想以後一切都很快

Object.defineProperty()接收三個參數

  • 一、實例

  • 二、屬性

  • 三、屬性描述符

好比說咱們如今想要監聽data對象身上的a屬性

data:{a:val,b:2, c:3}
    var val=1
    Object.defineProperty(data, key, {
    writable: true, // 可枚舉
        enumerable: true, // 可枚舉
        configurable: false, // 不能再define
        get: function () {
            dep.bind()
            return val;
        },
        set: function (newVal) {
            if (newVal === val) return;
            console.log('監聽到值變化了: ', val, '==>', newVal);
            val = newVal;
            dep.notify()
        }
    });
複製代碼

咱們把原來單純的一個值,拆分紅一個getter和一個setter
這樣不管值被獲取仍是重寫,咱們都能知道

通知視圖

上面只是console了一下,咱們實際須要去通知視圖

因而咱們給每個屬性都設一個傳聲筒dep,由他來負責通知
同時,咱們也得讓它知道到底去通知誰

新建一個類Dep,在每一次調用Object.defineProperty()的時候,順便new一個dep實例出來
它身上設定兩個方法,bind()和notify()

視圖第一次渲染會調用屬性的get去取值,咱們就能夠用bind()讓dep綁定要通知的對象
而修改數據的時候,就觸發dep.notity()去通知

咱們先無論這兩個方法具體是怎麼實現的
定義一個defineProperty()方法,把骨架搭出來

defineValue(data, key, val) {
    var dep = new Dep() //給這個屬性創建一個傳聲筒
    Object.defineProperty(data, key, {
	..............
        get: function () {
            dep.bind()
            return val;
        },
        set: function (newVal) {
            if (newVal === val) return;
            val = newVal;
            dep.notify()
        }
    });
}
複製代碼

視圖更新

如今把目光轉移到視圖這一邊,假設如今html長這樣

<div>
			<p>{{ a }}</p>
			<p>{{ b }}</p>
			<p>{{ c }}</p>
		</div>
		<div>
		         <p>{{ c }}</p>
		</div>
複製代碼

這串html在關心a,b,c三個數據
但不一樣p標籤關心的數據不同
若是數據c變了,咱們確定但願c的傳聲筒只去通知後面兩個p標籤,別的p標籤不用知道

因此在視圖這邊,也須要給每個引用到數據的地方,設立一個經紀人watcher,區分開來,這樣dep就知道去通知誰了

如今咱們假設每一個watcher身上都有一個update()方法
因此dep的notify方法,就是調用全部與dep相關的watcher身上的update()

有點亂?

話講到這裏,忽然就在原來的v和m的基礎上,多出來兩個角色dep和watcher,感受愈來愈亂了
等等,dep和watcher,那這兩我的就是VM的真身
沒錯,他們一塊兒組成了VM核心
而這個模式就是大名鼎鼎的「發佈-訂閱模式

下面咱們從頭開始,補全全部的細節

建立Mvvm類

-- 想一想Vue是怎麼建立實例的

var app=new Vue({
			el:'#app',
			data:{
				a:1,
				b:2,
				c:3,
				d:{
				    e:4
				}
			}
		})
複製代碼

咱們模仿Vue,建立一個Mvvm類,獲取用戶傳進來的參數,並把data賦給自身的$data屬性

class Mvvm {
constructor(options = {}) {
    //將全部屬性掛載到$options
    this.$options = options;
    // 將data數據取出來賦給$data
    this.$data = this.$options.data;

    // 數據劫持
    this.observe(this.$data);

    //數據代理
    this.proxyData(this.$data)

    //編譯頁面
    this.$compiler = new Compiler(this, this.$options.el || document.body) 
    }
    observe(){}
    defineProperty()
    proxyData(){}
}
複製代碼

在Mvvm實例的初始化中,賦值完以後,依次作了三件事
數據劫持,數據代理,編譯頁面

數據劫持

數據劫持就是咱們上面定義過的defineProperty,不過這裏須要補充一個嵌套劫持,讓對象屬性中的屬性也能被監聽
重複部分就省略了,重點關注實現嵌套的代碼

observe(data) {
    if (!data || typeof data !== 'object') return;
    Object.keys(data).forEach(key => {
        this.defineValue(data, key, data[key]);
    });
}
defineValue(data, key, val) {
    ...............
    _this.observe(val); // 監聽子屬性
    Object.defineProperty(data, key, {
        ................
        set: function (newVal) {
	    ....................
            _this.observe(val) //對新值進行監聽,由於它多是個對象
            dep.notify()
        }
    });
複製代碼

數據代理

在vue中,咱們獲取一個數據不是經過vm.data.a這樣的形式的,是直接vm.a進行讀寫,因此咱們還要進行一下數據代理
至關於把$data給鏡像過來,暴露給用戶

proxyData(data) {
    //由於只是爲了省略$data,因此只須要遍歷第一層,不用深度遍歷
    Object.keys(data).forEach(key => {
        Object.defineProperty(this, key, {
            configurable: false,
            enumerable: true,
            get: function () {
                return this.$data[key]
            },
            set: function (newVal) {
                this.$data[key] = newVal
            }
        }
        )
    })
}
複製代碼

編譯頁面

給咱們一段html,咱們須要分析出裏面哪些地方引用了數據,哪些地方用到了指令,進行第一次的數據更新渲染
同時還有就是給用到數據的地方分配watcher

離線操做Dom

實際操做Dom是很慢的,因此咱們這裏用到了fragment(文檔碎片),把Dom都拷到這裏面進行操做
固然這個實際上是vue1.x時候使用的,不過對咱們理解mvvm很是有幫助

定義一個轉移Dom到fragment的方法

node2fragemt(el) {
    var fragment = document.createDocumentFragment()
    var child
    while (child = el.firstChild) {
        fragment.appendChild(child)
    }
    return fragment
}
複製代碼

由於節點不能有兩個父親,因此調用appendChild的時候,就至關於把Dom節點搶了過來

操做完了之後,再把fragment丟給真實Dom便可

定義一些判斷節點的方法

//是不是節點
isElement(node) {
    return node.nodeType == 1;
}
//是不是指令
isDirective(node) {
    return node.substring(0, 2) === 'v-';
}
	
//是不是事件指令
isEventDirevtive(dir) {
    return dir.indexOf('on') === 0;
}
//是不是文本節點
isTextElement(node) {
    return node.nodeType == 3
}
複製代碼

而後定義Compiler類

class Compiler {
		constructor(vm, el) {
			this.$vm = vm
			this.$el = this.isElement(el) ? el : document.querySelector(el)
			if (this.$el) {
				this.$fragment = this.node2fragemt(this.$el)
				this.compile(this.$fragment)
				this.$el.appendChild(this.$fragment)
			}
		}
複製代碼

分類解析節點

compiler類的核心是compile函數
分類去解析節點

compile(el) {
    var nodes = el.childNodes
    Array.from(nodes).forEach(node => {
        if (this.isElement(node)) {
	//普通節點
        ....................................
        }
        else if (this.isTextElement(node)) {
	//文本節點
        ...................................
        }
        //先進行上面的解析,若是發現node還有子節點,就遞歸地進行子節點的解析
        if (node.childNodes && node.childNodes.length)
            this.compile(node)
    })
}
複製代碼

遞歸解析每一個節點,分爲兩類處理,一個是普通節點,一個是文本節點
普通節點上可能有指令,文本節點上可能有{{}}

普通指令及雙向綁定

經過attribute屬性取到指令,先判斷一下格式是否正確,是不是v-開頭的
而後這裏面又分爲事件指令(on:click之類)或者普通指令(v-text,v-model,v-html)
本文先只介紹普通指令

var attrs = node.attributes
    Array.from(attrs).forEach(attr => {
        if (!this.isDirective(attr.name)) return; //若是不是以v-開頭的指令,直接返回不處理
        var exp = attr.value.trim() //是string類型,因此還要去除一下兩邊的空格
        var dir = attr.name //例如v-text
        if (this.isEventDirevtive(dir)) {
            //若是是事件處理函數
        } else {
            //普通指令
            updateFn[dir] && updateFn[dir](node, this.getVal(exp), this.$vm, exp)
            new Watcher(this.$vm, exp, (value) => {
                updateFn[dir] && updateFn[dir](node, value, this.$vm, exp);
            });
        }
    })
複製代碼

能夠看到在普通指令中,用dir去取到了一個方法進行執行,同時新建了一個Watcher,它的回調函數也是這個方法
Watcher先放一放,咱們看看在執行什麼方法

//指令函數
var updateFn = {
		"v-text": function (node, val) {
		    node.textContent = val === undefined ? '' : val
		},
		"v-html": function (node, val) {
		    node.innerHTML = val === undefined ? '' : val
		},
		"v-model": function (node, val, vm, exps) {
		    node.value = val === undefined ? '' : val
		    node.addEventListener('input', e => {
			exp = exps.split('.')
			var len = exp.length
			if (len == 1) {
				return vm[exp] = e.target.value
			}
			var data = vm
			for (let i = 0; i < len - 1; i++) {
				data = data[exp[i]]
				console.log(exp[i])
			}
			data[exp[len - 1]] = e.target.value
		})
	}
}
複製代碼

前兩個很好理解,就是純粹去用取到的數據值更新節點的值
而v-model,也就是咱們大名鼎鼎的雙向綁定,其實很簡單
它就只是比上面兩個多一個監聽事件,去更新實例上的值罷了

模板語法{{}}

處理完普通節點,來處理咱們的文本節點
其實就是處理一個模板語法{{}}
咱們想要把其中的變量取出來,因此就要用到正則表達式的捕獲組,用括號去捕捉
而後用RegExp.$1去取到捕獲的值
不過若是處理相似{{a}},{{b}}這種的話,捕獲的時候,後一個會覆蓋前一個,RegExp.$1就只能取到b
因此咱們先用match把他們拆分出來,再分別捕獲

var exps = node.textContent.match(/\{\{.*?\}\}/g) //先進行拆分
if (!exps) return;
Array.from(exps).forEach(item => {
    item.match(/\{\{(.*?)\}\}/g)
    this.compileText(node, item, RegExp.$1.trim()) //經過正則的括號進行捕獲,trim()用來去除空格
})
複製代碼

compileText其實就是把{{}}替換成值

compileText(node, exp, content) {
    var val = this.getVal(content)
    if (val === undefined) val = "";
    var text = node.textContent //保留一份原來的格式以供更新
    node.textContent = node.textContent.replace(exp, val)
    new Watcher(this.$vm, content, (value) => {
        if (value === undefined) value = "";
        node.textContent = text.replace(exp, value)
    });
}
複製代碼

這裏也建立了一個watcher 因此在編譯的時候,每一個用到數據的地方,都建立了一個wacther

Watcher和Dep綁定

再來回顧一下這張圖

如今還剩下的就是,watcher和dep的互相關聯以及watcher執行update()更新視圖了
而咱們至今還沒揭曉這兩個類到底長啥樣

首先解開Dep的面紗
還記得咱們以前賣了個關子,沒有說dep.bind()是怎麼實現的
下面咱們就來看看dep.bind()在幹嗎

class Dep {
	constructor() {
	    this.subs = new Set(); //爲了保證不重複添加
	}
	bind() {
	    //註冊當前活躍的用戶爲訂閱者,並讓對方添加本身
	    this.subs.add(Dep.target)
	    Dep.target.addDep(this)
	}
}
複製代碼

每一個dep實例都有一個subs,保存本身要通知的那些watcher,爲了避免重複,使用了Set結構、 在第一次渲染視圖的時候,有向實例拿過數據,就在那時已經觸發了實例屬性的get方法,進而觸發了bind函數了

那這個Dep.target又是哪來的?
答案就是在wathcer實例初始化的時候

Wacher類

class Watcher {
    constructor(vm, exp, cb) {
    this.$vm = vm
    this.$cb = cb
    this.$deps = new Set()

    if (typeof exp === 'function') {
        this.getter = exp
    }
    else {
        this.getter = this.createGetter(exp)
    }
    this.$value = this.runGetter()
}

addDep(dep) {
    this.$deps.add(dep)
}
runGetter() {
    if (!this.getter) return;
    Dep.target = this
    var value = this.getter.call(this.$vm, this.$vm);
    Dep.target = null
    return value;
}
createGetter(exp) {
    var exps = exp.split('.')
    return function (vm) {
        var val = vm
        exps.forEach(key => {
            val = val[key]
        });
        return val;
    }
}
複製代碼

}

在watcher初始化的時候,根據exp(也就是咱們以前編譯時辛辛苦苦取到的變量)的類型建立了一個getter方法
若是exp是函數,getter就是直接運行它,不然就是去實例身上取值,爲了能經過相似「a.b.c」這樣的字符串取到值,咱們用了代碼中那個層層遍歷遞進的方法
有了getter以後,咱們就在初始化的時候執行一下,把值保存下來,以便未來作比對
同時制定Dep.target爲本身

因此整個運行的順序

  • 建立watcher
  • 建立getter
  • 指定Dep.target爲本身
  • 運行getter
  • 觸發屬性的get()
  • 觸發dep.bind()
  • dep把watcher添加到本身的subs中
  • watcher把dep也添加到本身的deps中
  • 綁定結束,屬性把值返回給watcher=>清空Dep.target=>watcher把value保存到$value中

通知更新

最後一步,當數據更新的時候,要讓dep去通知watcher執行update
還記得屬性set()裏面的dep.notify()方法麼

class Dep{
...............
	notify() {
		//通知全部訂閱者執行更新函數
		this.subs.forEach(item => {
			item.update()
		})
	}
}
複製代碼

很簡單,就是通知全部綁定的watcher去執行update

那麼watcher的update()長啥樣呢

class Wacther{
			................
			update() {
				var oldVal = this.$value
				var newVal = this.runGetter()
				if (newVal === oldVal) return;
				this.$value = newVal
				this.$cb.call(this.$vm, newVal, oldVal)
			}
	}
複製代碼

就是調用getter去獲取數據最新的值,而後調用以前保存的回調函數更新視圖

這裏你們可能有個疑問,爲何dep的notify不帶上新的值做爲參數告訴watcher,而讓watcher再本身取一次?
由於其實dep不知道手下的每一個watcher究竟在觀察什麼,好比dep管理着a,而a={b:'dsd',c:'dsads'},這個watcher可能只是在關心a.b變化了沒,另外一個watcher在關心a.c,可是dep並不知道
因此Dep不作傳值,只是在數據變化的時候通知全部相關訂閱者,本身去看看數據變成啥樣了

小總結

至此,咱們的Mvvm就告一段落了,其實還有很多功能,好比computed,watch,事件指令

不過搞懂核心的東西,就足夠了,之後再能夠繼續完善
目前爲止的代碼能夠去個人github上看
github.com/ssevenk/Mvv…

不過,如今這些東西,都只是Vue.1x
除了咱們以前提到的,Vue3.0會把Object.defineProperty()換成proxy之外
渲染和更新如今用的也不是fragment了,而是下面這一位

Virtual Dom

大名鼎鼎的Virtual Dom 並不是React發明,但由React發揚光大
曾經的Vue用的是咱們上文介紹的這種依賴收集,而後局部更新的方法,但從Vue2.x開始,使用的也是Virtual Dom了

三種更新的方法

如何把數據的更新投射到視圖上,三大框架曾經各抒己見

  • Angular使用的是髒檢查,當咱們觸發了某些事件(定時,異步請求,事件觸發等),執行完事件以後,Angular會遍歷全部「註冊」過的值,判斷是否和以前的一致
    因此它的更新複雜度穩定在O(watcher count) + 必要的DOM更新 O(DOM change)

  • Vue曾經使用的是咱們本文講解的依賴收集,每一次更新數據會針對新數據從新收集一次依賴
    因此複雜度爲 O(data change) + 必要 DOM 更新 O(DOM change)

  • React用的的Virtual Dom,它的本質其實相似離線Dom,每次操做以後,與原來的Virtual\ Dom進行diff比對,把patch投射到真實Dom上,在diff比對上,原本兩棵Dom樹的比對會達到O(n^3)的複雜度,可是React團隊用了取巧的方法,考慮到web應用中不多會出現跨層移動Dom節點,因此只進行兩棵樹的同層比對,強行把複雜度下降到了O(n)
    因此最後總的複雜度是O(template size) + 必要的 DOM 更新 O(DOM change)

Virtual Dom是最快的?

不少地方都會大肆宣揚Virtual Dom的快速,確實Virtual Dom很快,但是那也得看跟誰比
若是是跟很粗暴地把整棵真實Dom樹直接更新了,也就是直接設置node.innerHTML相比,確定Virtual Dom要快多了
由於操做Dom是很耗時間的,而Virtual Dom實際上是JS對象,操做JS可快多了

但是實際上沒有人會一有數據更新,就把整棵真實的Dom樹給更新了的
一來,若是你去手動優化,精準地更新Dom,那確定要比Virtual Dom快

固然框架是要追求普適性的,不過即便上面那兩個一樣具備普適性的方法,髒檢查和依賴收集相比,那也是互有勝場

借用尤大大的回答 www.zhihu.com/question/31…

  • 初始渲染:Virtual DOM > 髒檢查 >= 依賴收集
  • 小量數據更新:依賴收集 >> Virtual DOM + 優化 > 髒檢查(沒法優化) > Virtual DOM 無優化
  • 大量數據更新:髒檢查 + 優化 >= 依賴收集 + 優化 > Virtual DOM(沒法/無需優化)>> MVVM 無優化

優勢

既然在速度上互有勝場,那Virtual Dom必定還有更增強大的優勢,能促使Vue去使用它
仍是看尤大大本身的講解,能夠看下這段視頻
www.bilibili.com/video/av621…

Virtual Dom最大的優點在於

  • 它把渲染的邏輯從真實Dom中解耦出來了,也就是說若是沒有最後一步:把虛擬Dom渲染回真實Dom的話,其實咱們徹底能夠把這串虛擬Dom渲染到別的地方去,只要它支持JS,好比手機終端,pdf,canvas,webgl,也就是求之不得的「一次編寫,到處運行」,是更能面向將來的
  • 同時Virtual Dom也實現了組件的高度抽象化,爲函數式的UI編程打開了大門

修改後的流程

Vue在改用Virtual Dom後,流程又是怎麼走的呢 借用這篇文章的圖片 github.com/wangfupeng1…

能夠看到新出來兩個概念,ASTRender

與html轉爲Dom樹的過程相似,Vue會先把模板給解析成抽象語法樹,而後優化AST,找到其中靜態和動態的部分,在優化方面Vue3.0會有更好的突破,目前還不是很完善,總之目標就是肯定哪些部分是可能會變化的,哪些是靜態的不會變的,最後生成一個render函數

render函數

若是是在開發環境下,會在運行時把模板根據上面的步驟解析爲render函數,而若是是生產環境,這一步是發生在編譯打包階段的,因此最後的文件中直接就是render函數

那這個render函數就是用來返回Virtrual Dom的
若是是初次渲染,就會把這個Virtual Dom樹直接投射生成真實Dom
若是是數據更新,就相似本文介紹的那個流程,觸發通知,執行update函數,觸發render函數的從新執行,生成一個新的Virtual Dom樹,與原來的進行diff比對,並把最小差別patch到真實Dom上進行局部從新渲染
這個渲染是異步的,也就是說一次渲染會集合多個數據的變化

就地複用

在渲染的過程當中,有一個很重要的概念,就是就地複用
數據的角度來講,一個列表的數據變了,那就應該銷燬實例從新建立
可是Virtual Dom是基於Dom進行變更檢查的,若是最終的渲染結果沒有變化,就不該該有這種額外的勞動
好比若是數據項的順序被改變,Vue 將不會移動 DOM 元素來匹配數據項的順序, 而是簡單複用此處每一個元素

不過這種機制在沒有key的狀況下會形成一些問題,好比按刪除按鈕,但被刪除的元素不是你想指定的那個
因此要爲每一項肯定一個惟一的id,從Virtual Dom的角度也能更好的作恰當的優化

大總結

再來借用一次那張流程圖

若是以前的東西沒有徹底看懂,能夠再來對着這張圖整理一下總流程

  • 一、對數據運用Object.defineProperty()進行數據劫持,並進行數據代理
  • 二、發佈——訂閱模式,用dep和watcher進行綁定
  • 三、編譯模板成AST抽象語法樹,進行靜態優化後,生成render函數,返回Virtual DOM,並進行第一次的渲染
  • 四、數據更新時,由dep發起通知,watcher調用update,render函數從新執行,返回新的Virtual DOM,與原來的進行diff對比,把最小差別patch到真實DOM上局部從新渲染

但願本文對於你們理清Vue的運做原理有所幫助

參考資料

尤大大對於Virtual Dom的介紹
www.bilibili.com/video/av621…
Mvvm視頻教程
www.bilibili.com/video/av240…
尤大大講解Vue源碼
www.bilibili.com/video/av514…
Object.defineProperty()和proxy的區別
www.fly63.com/article/det…
數據雙向綁定系列教程
www.chuchur.com/article/vue…
收藏好這篇,別再只說「數據劫持」了
juejin.im/post/5af198…
很差意思!耽誤你的十分鐘,讓MVVM原理還給你
juejin.im/post/5abdd6…
DOM和Virtual DOM之間的區別
www.jianshu.com/p/620b0435d…
vue核心之虛擬DOM(vdom)
www.jianshu.com/p/af0b39860…
虛擬DOM與DIFF算法學習
segmentfault.com/a/119000001…
爲何虛擬DOM更優勝一籌
www.cnblogs.com/rubylouvre/…
談談Vue/React中的虛擬DOM(vDOM)與Key值
juejin.im/post/5cff1b…
尤大大關於Virtual DOM的知乎回答
www.zhihu.com/question/31…
Vue源碼學習筆記
jiongks.name/blog/vue-co…
Vue技術揭祕
ustbhuangyi.github.io/vue-analysi…
Vue源碼分析
github.com/liutao/vue2…
入口文件開始,分析Vue源碼實現
juejin.im/post/5adead…
Vue源碼學習
hcysun.me/2017/03/03/…
快速瞭解 Vue2 MVVM
github.com/wangfupeng1…

其中最推薦的是看最後這一篇,我的認爲是講的最清楚的,流程圖也是借用這位做者的

個人我的博客

這是個人我的網站,記錄下前端學習的點滴,歡迎你們參觀
www.ssevenk.com

相關文章
相關標籤/搜索