Vue高級指南-01 Vue源碼解析之手寫Vue源碼

前言

如今前端面試Vue中都會問到響應式原理以及如何實現的,若是你還只是簡單回答經過Object.defineProperty()來劫持屬性可能已經不夠了。javascript

本篇文章經過學習文檔及視頻教程實現手寫一個簡易的Vue源碼實現數據雙向綁定,解析指令等。html

幾種實現雙向綁定的作法

目前幾種主流的mvc(vm)框架都實現了單向數據綁定,而我所理解的雙向數據綁定無非就是在單向綁定的基礎上給可輸入的元素(input, textare等)添加了change(input)事件,來動態修改model和view,並無多高深,因此無需太過介懷是實現的單向或雙向綁定。前端

實現數據綁定的作法有大體以下幾種:

發佈者-訂閱者模式(backbone.js)vue

髒值檢查(angular.js)java

數據劫持(Vue.js)node

  • 發佈者-訂閱者模式

通常是經過sub, pub的方式來實現數據和試圖的綁定堅聽,更細數據方法一般作法是vm.set('property', value) 這種方式如今畢竟太low來,咱們更但願經過vm.property = value這種方式更新數據,同時自動更新視圖,因而有來下面兩種方式。git

  • 髒值檢查

angular.js是經過髒值檢測的方式對比數據是否有變動,來決定是否更新視圖,最簡單的方式就是經過setInterval()定時輪詢檢測數據變更,固然Google不會這麼low,angular只有在制定的事件觸發時進入髒值檢測,大體以下github

* DOM事件,臂如用戶輸入文本,點擊按鈕等(ng-click)
* XHR響應事件($http)
* 瀏覽器location變動事件($location)
* Timer事件($timeout, $interval)
* 執行$diaest()或¥apply()
複製代碼
  • 數據劫持

Vue.js則是經過數據劫持結合發佈者-訂閱者模式的方式,經過Object.defineProperty()來劫持各個屬性的setter,getter,在數據變更時發佈消息給訂閱者,觸發相應的監聽回調。面試

Vue源碼實現

index.html數組

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8" />
		<title></title>
		<script type="text/javascript" src="./compile.js"></script>
		<script type="text/javascript" src="./observe.js"></script>
		<script type="text/javascript" src="./myvue.js"></script>
	</head>
	<body>
		<div id="app">
			<h2>{{person.name}} -- {{person.age}}</h2>
			<h3>{{person.sex}}</h3>
			<ul>
				<li>1</li>
				<li>2</li>
				<li>3</li>
			</ul>
			<div v-text="msg"></div>
			<div>{{msg}}</div>
			<div v-text="person.name"></div>
			<div v-html="htmlStr"></div>
			<input type="text" v-model="msg" />
			<button type="button" v-on:click="btnClick">v-on:事件</button>
			<button type="button" @click="btnClick">@事件</button>
		</div>
		<script type="text/javascript">
			let vm = new Myvue({
				el: '#app',
				data: {
					person: {
						name: '只會番茄炒蛋',
						age: 18,
						sex: '男'
					},
					msg: '學習MVVM實現原理',
					htmlStr: '<h1>我是html指令渲染的</h1>'
				},
				methods: {
					btnClick() {
						console.log(this.msg)
					}
				}
			})
		</script>
	</body>
</html>
複製代碼

第一步 - 實現一個指令解析器(Compile)

compile主要作的事情是解析模板指令,將模板中的變量替換成數據,而後初始化渲染頁面視圖,並將每一個指令對應的節點綁定更新函數,添加監聽數據的訂閱者,一旦數據有變更,收到通知,更新視圖

myvue.js

// 工具類根據指令執行對應方法
const compileUtils = {
	/*
	 * node 當前元素節點
	 * expr 當前指令的value
	 * vm 當前Myvue實例, 
	 * eventName 當前指令事件名稱
	 */

	// 因爲指令綁定的屬性有多是原始類型,也有多是引用類型, 所以要取到最終渲染的值
	getValue(expr, vm) {
		// reduce() 方法對數組中的每一個元素執行一個由您提供的reducer函數(升序執行),將其結果彙總爲單個返回值。
		return expr.split('.').reduce((data, currentVal) => {
			return data[currentVal]
		}, vm.$data)
	},
	// input雙向數據綁定
	setValue(expr, vm, inputVal) {
		// reduce() 方法對數組中的每一個元素執行一個由您提供的reducer函數(升序執行),將其結果彙總爲單個返回值。
		return expr.split('.').reduce((data, currentVal) => {
			// 將當前改變的值賦值
			data[currentVal] = inputVal
			console.log(data);
		}, vm.$data)
	},

	// 處理{{person.name}}--{{person.age}}這種格式的數據,不更新值的時候會所有替換了
	getContentVal(expr, vm) {
		return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
			// 獲取{{}}中的屬性
			return this.getValue(args[1], vm)
		})

	},
	// 這裏簡單就封裝了幾個指令方法
	text(node, expr, vm) {
		let value;
		// 處理{{}}的格式
		if (expr.indexOf('{{') !== -1) {
			value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
				// 綁定觀察者
				new Watcher(vm, args[1], (newValue) => {
					// 處理{{person.name}}--{{person.age}}這種格式的數據,否則更新值的時候會所有替換了
					this.upDater.textUpDater(node, this.getContentVal(expr, vm))
				})
				// 獲取{{}}中的屬性
				return this.getValue(args[1], vm)
			})
		} else {
			new Watcher(vm, expr, (newValue) => {
				this.upDater.textUpDater(node, newValue)
			})
			// 獲取當前要節點要更新展現的值
			value = this.getValue(expr, vm)
		}
		// 更新的工具類
		this.upDater.textUpDater(node, value)
	},
	html(node, expr, vm) {
		const value = this.getValue(expr, vm)
		// 綁定觀察者
		new Watcher(vm, expr, (newValue) => {
			this.upDater.htmlUpDater(node, newValue)
		})
		// 更新的工具類
		this.upDater.htmlUpDater(node, value)
	},
	model(node, expr, vm) {
		const value = this.getValue(expr, vm)
		// 綁定觀察者
		new Watcher(vm, expr, (newValue) => {
			this.upDater.modelUpDater(node, newValue)
		})
		node.addEventListener('input', (e) => {
			// 設置值
			this.setValue(expr, vm, e.target.value)
		})
		// 更新的工具類
		this.upDater.modelUpDater(node, value)
	},
	on(node, expr, vm, eventName) {
		// 獲取當前指令對應的方法
		const fn = vm.$options.methods && vm.$options.methods[expr]
		// console.log(fn);
		node.addEventListener(eventName, fn.bind(vm), false)
	},
	// 更新的工具類
	upDater: {
		// v-text指令的更新函數
		textUpDater(node, value) {
			node.textContent = value
		},
		// v-html指令的更新函數
		htmlUpDater(node, value) {
			node.innerHTML = value
		},
		// v-model指令的更新函數
		modelUpDater(node, value) {
			node.value = value
		}
	}
}

// Myvue
class Myvue {
	constructor(options) {
		this.$el = options.el;
		this.$data = options.data;
		this.$options = options;
		if (this.$el) {
			// 1.實現一個數據觀察者
			new Observe(this.$data)

			// 2.實現一個指令解析器
			new Compile(this.$el, this)

			// 3.實現this代理, 訪問數據能夠直接經過this訪問
			this.proxyData(this.$data)
		}
	}
	proxyData(data) {
		for (const key in data) {
			Object.defineProperty(this, key, {
				get() {
					return data[key]
				},
				set(newValue) {
					data[key] = newValue
				}
			})
		}
	}
}
複製代碼

compile.js

// 指令解析器
class Compile {
	constructor(el, vm) {
		// 判斷當前傳入的el是否是一個元素節點
		// document.querySelector返回與指定的選擇器組匹配的元素的後代的第一個元素。
		this.el = this.isElementNode(el) ? el : document.querySelector(el)
		this.vm = vm
		// 1.匹配節點內容及指令替換相應的內容, 由於每次匹配替換會致使頁面迴流和重繪, 因此使用文檔碎片對象
		// 獲取文檔碎片對象, 放入內存中會減小頁面的迴流和重繪
		const fragment = this.node2Fragment(this.el)

		// 2.編譯模版
		this.compile(fragment)

		// 3.追加子元素到根元素
		this.el.appendChild(fragment)

	}

	// 判斷是不是元素節點
	isElementNode(node) {
		return node.nodeType === 1
	}

	// 將當前根元素中的全部子元素一層層取出來放到文檔碎片中, 以減小頁面迴流和重繪
	node2Fragment(el) {
		// 建立文檔碎片對象
		const fragment = document.createDocumentFragment()
		let firstChild;
		// 將當前el節點對象的全部子節點追加到文檔碎片對象中
		while (firstChild = el.firstChild) {
			fragment.appendChild(firstChild)
		}
		return fragment
	}

	// 編譯模版, 解析指令
	compile(fragment) {
		// 1.獲取到全部的子節點, 當前獲取的子節點數組是一個僞數組, 須要轉爲數組
		const childNodes = [...fragment.childNodes]
		childNodes.forEach(child => {
			// 判斷當前節點是元素節點仍是文本節點
			if (this.isElementNode(child)) {
				// 編譯元素節點
				this.compileElement(child)
			} else {
				// 編譯文本節點
				this.compileText(child)
			}
			// 遞歸遍歷當前節點時候還有子節點對象
			if (child.childNodes && child.childNodes.length) {
				this.compile(child)
			}
		})

	}

	// 編譯元素節點
	compileElement(node) {
		// 根據不一樣指令屬性, 編譯模版信息
		const attributes = [...node.attributes];
		attributes.forEach(attr => {
			// 經過解構將指令的name和value獲取到
			const {
				name,
				value
			} = attr
			// 判斷當前屬性是指令仍是原生屬性
			if (this.isDirective(name)) {
				// 截取指令, 不須要v-
				const directive = name.split('-')[1]
				// 因爲指令格式有 v-text v-html v-bind:屬性 v-on:事件等等, 按照 : 再次分割
				const [dirName, eventName] = directive.split(':')
				// 更新數據, 數據驅動視圖
				compileUtils[dirName](node, value, this.vm, eventName)
				// 刪除有指令的標籤上的屬性
				node.removeAttribute('v-' + directive)
			} else if (this.isEventName(name)) { // 判斷指令是以@開頭綁定的事件
				// 截取指令, 不須要@, 這裏就省略處理裏 @click.stop.prevent等事件修飾符, 原理不難
				const eventName = name.split('@')[1]
				// 更新數據, 數據驅動視圖
				compileUtils['on'](node, value, this.vm, eventName)
			}
		})
	}

	// 編譯文本節點
	compileText(node) {
		// node.textContent獲取文本而且匹配{{}} 模版字符串類型的
		const content = node.textContent
		if (/\{\{(.+?)\}\}/.test(content)) {
			compileUtils['text'](node, content, this.vm)
		}
	}

	// 判斷當前屬性是指令仍是原生屬性
	isDirective(attrName) {
		// startsWith() 方法用來判斷當前字符串是否以另一個給定的子字符串開頭,並根據判斷結果返回 truefalsereturn attrName.startsWith('v-')
	}

	// 判斷指令是以@開頭綁定的事件
	isEventName(attrName) {
		return attrName.startsWith('@')
	}
}
複製代碼

第二步 - 實現一個數據監聽器(Observer)

利用Obeject.defineProperty()來監聽屬性變更 那麼將須要observe的數據對象進行遞歸遍歷,包括子屬性對象的屬性,都加上 setter和getter 這樣的話,給這個對象的某個值賦值,就會觸發setter,那麼就能監聽到了數據變化。

observer.js

// 數據劫持
class Observe {
	constructor(data) {
		this.observe(data)
	}
	// 使用object.defineProperty監聽對象, 數組暫時不考慮,太複雜
	observe(data) {
		if (data && typeof data === 'object') {
			// console.log(data);
			Object.keys(data).forEach(key => {
				this.defineReactive(data, key, data[key])
			})
		}
	}

	// 劫持屬性
	defineReactive(obj, key, value) {
		// 遞歸遍歷
		this.observe(value)
		// 建立依賴收集器
		const dep = new Dep()
		// console.log(dep);
		Object.defineProperty(obj, key, { // obj爲已有對象, key爲屬性, 第三個參數爲屬性描述符
			enumerable: true, // enumerable:是否能夠被枚舉(for in),默認false
			configurable: false, // 是否能夠被刪除,默認false
			// 獲取
			get() {
				// console.log(dep.target);
				// 訂閱數據變化時, 往Dep中添加觀察者
				Dep.target && dep.addSub(Dep.target)
				return value
			},
			// 設置
			set: (newValue) => {
				// 這裏要注意新設置的值也須要劫持他的屬性
				this.observe(newValue)
				if (newValue !== value) {
					value = newValue
				}
				// 通知訂閱器找到對應的觀察者,通知觀察者更新視圖
				dep.notify()
			}
		})
	}
}
複製代碼

第三部 - 實現一個Watcher去更新視圖

在初始化myvue實例的時候,經過object。defineProperty()的get屬性時去添加觀察者,在set更改屬性的時候去觸發notify()來調用upDate方法更新視圖

// 觀察者
class Watcher {
	constructor(vm, expr, cb) {
		this.vm = vm
		this.expr = expr
		this.cb = cb
		// 存儲舊值
		this.oldValue = this.getOldValue()
	}
	// 獲取舊值
	getOldValue() {
		// 在獲取舊值的時候將觀察者掛在到Dep訂閱器上
		Dep.target = this
		const oldValue = compileUtils.getValue(this.expr, this.vm)
		// 銷燬Dep上的觀察者
		Dep.target = null
	}

	// 更新視圖
	upDate() {
		// 獲取新值
		const newValue = compileUtils.getValue(this.expr, this.vm)
		if (newValue !== this.oldValue) {
			this.cb(newValue)
		}
	}
}

// 訂閱器
class Dep {
	constructor() {
		this.subs = []
	}
	// 收集觀察者
	addSub(watcher) {
		this.subs.push(watcher)
	}
	// 通知觀察者去更新視圖
	notify() {
		this.subs.forEach(watcher => {
			watcher.upDate()
		})
	}
}
複製代碼

面試題-闡述你所理解的MVVM響應式原理

Vue是採用數據劫持配合發佈者-訂閱者模式,經過Object.defineProperty來()來劫持各個屬性的getter和setter,在數據發生變化的時候,發佈消息給依賴收集器,去通知觀察者,作出對應的回調函數去更新視圖。

具體就是:MVVM做爲綁定的入口,整合Observe,Compil和Watcher三者,經過Observe來監聽model的變化,經過Compil來解析編譯模版指令,最終利用Watcher搭起Observe和Compil以前的通訊橋樑,從而達到數據變化 => 更新視圖,視圖交互變化(input) => 數據model變動的雙向綁定效果。

總結

本篇文章主要以幾種實現雙向綁定的作法實現Observer實現Compile實現Watcher實現MVVM這幾個模塊來闡述了雙向綁定的原理和實現。並根據思路流程漸進梳理講解了一些細節思路和比較關鍵的內容點,固然確定有不少不完善的地方,可是對於如何實現雙向數據綁定你確定有了更加深入的瞭解。

本篇文章也是經過查看Vue源碼解析文章,以及B站相關視頻總結出來的,俗話說好記性不如爛筆頭, 本身即便照着抄一遍也能更加印象深入。

最後,感謝您的閱讀!

源碼地址 參考文章

相關文章
相關標籤/搜索