問題描述
面試中,面試官除了問基礎知識以外,還喜歡問一些框架原理。比如:你對vue的資料雙向繫結mvvm是如何理解的?
網上的部分貼子可能寫的有點抽象,不便於快速閱讀理解。本篇文章就使用通俗易懂的簡單方式,來講解並實現一個簡單的vue資料雙向繫結原理demo,希望對大家有一定的幫助
先複習基本知識
為了便於大家更好的理解下文資料雙向繫結的程式碼,我們最好先複習一下舊知識,如果基礎知識紮實的道友,可以直接跳過這一段。
DOM.children屬性返回DOM元素有哪些元素子節點
程式碼:
<body>
<div class="divClass">
<span>孫悟空</span>
<h4>豬八戒</h4>
<input type="text" value="沙和尚">
</div>
<script>
let divBox = document.querySelector('.divClass')
console.log('元素節點', divBox);
console.log('元素節點的子節點偽陣列', divBox.children);
</script>
</body>
示例圖:
注意區分:DOM.childNodes得到所有的節點
,比如元素節點、文字節點、註釋節點;而,DOM.children只得到所有的元素節點
。二者返回的都是一個偽陣列,但偽陣列有length長度,代表有多少個節點,且可以迴圈遍歷,遍歷的每一項都是一個dom元素標籤!
不過偽陣列不能使用陣列的方法
DOM.hasAttribute(key)/getAttribute(key)判斷元素標籤是否有key屬性以及訪問對應value值
程式碼:
<body>
<h3 class="styleCss" like="coding" v-bind="fire in the hole">穿越火線</h3>
<script>
let h3 = document.querySelector('h3')
console.log(h3.hasAttribute('v-hello')); // 看看此標籤有沒有加上v-hello這個屬性,沒的,故列印:false
console.log(h3.hasAttribute('like')); // 看看此標籤有沒有加上like這個屬性,有,故列印:true
console.log(h3.getAttribute('like')); // 訪問此標籤上加上的這個v-bind屬性值是啥,列印:coding
console.log(h3.hasAttribute('v-bind')); // 看看此標籤有沒有加上v-bind這個屬性,,有的,故列印:true
console.log(h3.getAttribute('v-bind')); // 訪問此標籤上加上的這個v-bind屬性值是啥,列印:fire in the hole
console.log(h3.attributes); // 可以看到所有的在標籤上繫結的屬性名和屬性值(key="value"),是一個偽陣列
</script>
</body>
示例圖:
這兩個api可以用來看標籤上是否繫結了vue的指令,以及看看vue指令值是啥,以便於我們去與data中的相應資料做對應
DOM.innerHTML與DOM.innerText的區別
二者均可以修改dom的文字內容。innerHTML是符合W3C標準的屬性,所以是主流使用的dom的api。而innerText雖然相容性要好一些,不過主流還是innerHTML
程式碼:
<body>
<h3>西遊記</h3>
<button>更改dom內容</button>
<script>
let h3 = document.querySelector('h3')
let btn = document.querySelector('button')
btn.onclick = () => {
h3.innerHTML = h3.innerHTML + '6'
}
</script>
</body>
示例圖:
DOM.innerHtml這個api可用於更改vue中的差值表示式{{key}}對應的內容值
資料雙向繫結成品效果圖
我們先看一下,我們所要實現的成品的效果圖
需求分析
- 輸入框輸入值內容發生變化,頁面也發生對應變化
- 點選按鈕,輸入框和頁面都發生對應變化
即: - 頁面變化(輸入框引起)觸發資料data變化,最終觸發頁面變化;
- 資料data變化(按鈕引起),觸發頁面變化
關於MVVM的理解
簡單理解
mvvm即為m v vm分別對應的是:
- m是model資料層(就是vue中的data、computed、watch啊之類的資料配置項)
- v是view檢視層(檢視層效果是dom堆疊出來的,所以檢視層可以理解為dom元素)
- vm是model資料層和view檢視層的中間層view_model(vm)層,是vue中的核心,功能強大
vm可以監聽檢視層dom的變化
,比如監聽input標籤dom的value值變化,去更改model資料層中的data對應值,vm也可以監聽model資料層中的data對應key的value的值的變化,
去更改input標籤dom的value值。即:vm相當於一個擺渡人,可把此岸人渡到彼岸、彼岸人渡到此岸
核心理解
所以,MVVM的核心是,所以,MVVM的核心是,所以,MVVM的核心是(重要的事情說三遍:)
監聽頁面的DOM的內容值變化,從而通知到data中做對應資料變化(主要是監聽表單標籤)
監聽表單標籤的變化,是使用dom.addEventListener()這個方法
當data中資料變化以後,再去及時更新頁面DOM的內容變化
監聽data中資料的變化,是使用Object.defineProperty()的set方法,自動幫我們監聽變化,至於更新dom,就是首先找到要更新哪個dom,如果是普通標籤就更新其innerHTML值、如果是表單標籤,就更改其value即可
關於Object.defineProperty的理解
關於Object.defineProperty這個方法,一言以蔽之,給物件定義響應式。論壇有很多資料帖子,在此不贅述。推薦看官方文件:https://developer.mozilla.org...
關於這個方法,我們先理解下面案例就差不多了:
案例需求
有一個物件obj,裡面有name和age屬性,要讓這個obj的每一個屬性,都是響應式的,訪問和修改的時候,都要對應列印資訊。
案例程式碼
複製貼上跑一下,大致就明白了
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="nameId">修改名字</button>
<button id="ageId">修改年齡</button>
<script>
let obj = {
name: '孫悟空',
age: 500,
}
for (const key in obj) { // 因為是給物件中每一個屬性都新增響應式,所以要遍歷物件
let value = obj[key] // 存一份對應value值,用於訪問返回,以及新值修改賦值
Object.defineProperty(obj, key, { // 給這個obj物件的每一個屬性名key都定義響應式
get() {
console.log('訪問之(自動觸發),訪問值為:', value);
return value
},
set(newVal) {
console.log('修改之(自動觸發),修改的屬性名為:', key, '屬性值為:', newVal);
value = newVal
}
})
}
let nameBtn = document.querySelector('#nameId')
let ageBtn = document.querySelector('#ageId')
nameBtn.onclick = () => {
obj.name = obj.name + '^_^ | '
}
ageBtn.onclick = () => {
obj.age = obj.age + 1
}
// 這樣的話,訪問和修改的時候都會觸發啦(修改的時候是要先訪問找到,再去修改,故列印兩次)
</script>
</body>
</html>
案例效果圖
完整程式碼
程式碼中寫了不少註釋,大家跟著註釋步驟閱讀應該就可以了。演示的話直接複製貼上即可。注意程式碼中的subArr,蒐集依賴,目的是看看有哪些dom元素需要做後續的響應式更新內容
列印new出來的Vue例項
如果下方的完整程式碼,有助於各位道友更好的理解mvvm的話,那就給我們點個贊鼓勵一下創作唄^_^
完整MVVM程式碼
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#app {
width: 600px;
height: 216px;
background-color: #ccc;
padding: 24px;
}
button {
cursor: pointer;
}
</style>
</head>
<body>
<!-- view檢視層dom,為了便於理解,這裡以#app的根元素內部只有一層dom為例(多層需要遞迴) -->
<div id="app">
<input v-model="name" placeholder="請填寫名字">
<span>名字是:</span><span v-bind="name"></span>
<br>
<br>
<input v-model="age" placeholder="請填寫年齡">
<span>年齡是:</span><span v-bind="age"></span>
<br>
<br>
<h3>{{name}}</h3>
<h3>{{age}}</h3>
<button id="nameId">更改名字</button>
<button id="ageId">更改年齡</button>
<button id="resetId">恢復預設</button>
<button id="removeId">全部清空</button>
</div>
<script>
// 簡單函式封裝 之 判斷標籤內是否包含雙差值表示式
function isIncludesFourKuoHao(str) {
// 不過這裡不是特別嚴謹。嚴謹需要使用正則限制,大家明白思路即可
if (str.length <= 4) { // 得大於4個字元
return false
}
if ( // 且要有雙差值表示式,
str[0] == '{' &
str[1] == '{' &
str[str.length - 1] == '}' &
str[str.length - 2] == '}'
) {
return true
} else {
return false
}
}
// 簡單函式封裝 之 獲取雙差值表示式之間的變數名
function getKuoHaoBetweenValue(params) {
// 這裡也不是特別嚴謹,嚴謹也需要使用正則,大家明白思路即可
return params.slice(2, params.length - 2) // {{name}} --> name
}
// 這裡使用建構函式,使之擁有new的功能。當然也可以使用class類程式設計
function Vue(options) {
/**
* 第一步,獲取根節點dom元素,這一步的作用是有了根節點dom以後,可以通過dom.children獲取其所有子節點的dom元素,
* 便於我們對子節點的dom進行操作,比如給子節點的input標籤繫結input事件監聽,這樣就可以通過dom.value
* 實時拿到使用者在輸入框輸入的值了
* */
this.$el = document.querySelector(options.el);
/**
* 第二步,把data中的資料{name:'jack',age:'500'}存一份,因為我們除了修改this.name要是響應式的,同樣:
* this.$data.name也要是響應式的
*/
this.$data = options.data;
/**
* 第三步,定義一個陣列蒐集要變化的dom元素,當我們修改data中資料的時候,觸發Object.defineProperty()的set方法執行
* 然後去subArr陣列中去尋找,看看是要修改那個dom元素的資料值即可,大家列印一下,就會發現subArr存放的是一個又
* 一個物件,物件中記錄的是 哪一個dom,什麼屬性名key,以及對應更改innerHTML或value
* */
this.subArr = []
/**
* 第四步,執行模板編譯操作,把data中的資料做頁面呈現。這裡又分為兩部分
* 4.1 給相應的互動輸入類標籤繫結事件監聽,比如input標籤繫結input事件,select標籤繫結change事件等。為便於理解
* 本案例中只以input標籤為例說明(當然前提是:加了v-model指令做資料雙向繫結才會去操作這一步)
* 4.2 把v-bind和插值表示式{{}}做內容呈現,即:把model中的對應資料值,並找到對應dom,更改其innerHTML的值為對應資料值
* */
this.useDataToCompileRenderPage(); // 使用data中的資料做模板編譯並渲染到頁面上
/**
* 第五步,給m中的資料使用Object.defineProperty做資料劫持,這樣的話,訪問或者修改物件的屬性值時,都可以得知。即:
* 訪問時,不用額外操作。不過修改時,model中的data的值變化了,於此同時,還需同時更新dom,因為m變v也要跟著變
* 即:dataChangeUpdatePage方法的執行,只要一set更新,我就讓dataChangeUpdatePage方法去更新對應的dom值
* (因為第四步以後,data中資料是渲染到頁面上了,但還需讓data中的資料變化,頁面也跟著變化,故要做資料劫持)
* */
this.definePropertyAllDataKey(); // 資料劫持data中的所有key使之成為響應式的
}
// 先把data中的資料,去編譯渲染到頁面上
Vue.prototype.useDataToCompileRenderPage = function () {
let _this = this; // 存一份this例項物件
let nodes = this.$el.children; // 獲取根元素下的所有的子節點dom;值為偽陣列,列印結果:[input, span, span, br, br, input, span, span, br, br, button]
for (let i = 0; i < nodes.length; i++) { // 迴圈這個子節點dom偽陣列,
let node = nodes[i]; // 所有的標籤,一個一個去判斷,判斷這個標籤有沒有加上v-model,有沒有加上v-bind,有沒有差值表示式{{}} ,以這三種情況為例
// 若dom標籤節點上加上了v-model指令
if (node.hasAttribute('v-model')) {
let dataKey = node.getAttribute('v-model');// 去獲取v-model繫結的那個屬性值,本例中為dataKey的值分別為:name、age
node.addEventListener('input', function () { // 以input輸入框為例:給標籤繫結input輸入事件監聽,即:<input/>.addEventListener('input',function(){})
/** 注意,這裡是頁面到資料的處理,即v --> vm --> m的流程 */
_this.$data[dataKey] = node.value; // 如果是input標籤,可以直接通過inputDom.value獲取到input標籤中使用者輸入的值
_this[dataKey] = node.value; // 上一行是$data更改,即:this.$data.name或age獲取dom最新的值、這一行是this.name或age獲取最新的值
});
/** 把model中的資料更新賦值(編譯)到頁面上 */
node['value'] = _this.$data[dataKey]; // inputDom.value = this.$data.name或age 賦值
/** 所以,經過這一波操作,成功的把輸入框(變化)的值,更改到資料層中了 即:v --> vm --> m */
/** 注意這裡,就是蒐集依賴,可以提取一個方法的,為了便於理解,就不提取了 */
_this.subArr.push({
nodeLabelDom: node, // 哪個dom標籤元素
whichAttribute: dataKey, // 哪一個屬性name或age
valueOrInnerHtml: 'value', // 更改value還是innerHTML
})
}
// 若dom標籤節點上加上了v-bind指令
if (node.hasAttribute('v-bind')) {
/** 如果是v-bind指令,只需要新增watcher即可 * */
let dataKey = node.getAttribute('v-bind'); // 去獲取v-bind繫結的那個屬性值,本例中為dataKey的值分別為:name、age
node['innerHTML'] = _this.$data[dataKey]; // normalDom.innerHtml = this.$data.name或age 普通dom顯示賦值操作
/** 注意這裡,就是蒐集依賴,可以提取一個方法的,為了便於理解,就不提取了 */
_this.subArr.push({
nodeLabelDom: node, // 哪個dom標籤元素
whichAttribute: dataKey, // 哪一個屬性name或age
valueOrInnerHtml: 'innerHTML', // 更改value還是innerHTML
})
}
// 如果包含雙差值表示式{{}}
if (isIncludesFourKuoHao(node.textContent)) {
let dataKey = getKuoHaoBetweenValue(node.textContent) // 就拿到雙差值表示式中間的key,屬性名,這裡的dataKey分別為:name、age
node['innerHTML'] = _this.$data[dataKey]; // 把雙差值表示式中的key做一個替換對應值
/** 注意這裡,就是蒐集依賴,可以提取一個方法的,為了便於理解,就不提取了 */
_this.subArr.push({
nodeLabelDom: node, // 哪個dom標籤元素
whichAttribute: dataKey, // 哪一個屬性name或age
valueOrInnerHtml: 'innerHTML', // 更改value還是innerHTML
})
}
}
}
// 再做資料劫持,遍歷給data中的每一個資料都劫持,使之,都用於set和get方法
Vue.prototype.definePropertyAllDataKey = function () {
let _this = this; // 存一份this以便使用
for (let key in _this.$data) { // 遍歷物件{name:'孫悟空',age: 500}
let value = _this.$data[key]; // value值為孫悟空、500 key的值自然是name和age
Object.defineProperty(_this.$data, key, { // 使用defineProperty去新增攔截、劫持(劫持到$data身上)
get: function () { //
return value; // 訪問key,訪問name或者age,就返回對應的值
},
set: function (newVal) {
value = newVal; // 修改key的屬性值,修改name或者age的屬性值,在做正常操作value = newVal賦值的同時
// 每當更新this.$data資料時,如:this.$data.name = 'newVal'就去做對應dom的更新即可
_this.dataChangeUpdatePage(key, newVal)
}
})
Object.defineProperty(_this, key, { // 劫持到自己身上
get: function () {
return value;
},
set: function (newVal) {
value = newVal;
// 每當更新this資料時,如:this.name = 'newVal'就去做對應dom的更新即可
_this.dataChangeUpdatePage(key, newVal)
}
})
}
}
// 公共方法,當更新觸發的時候,去根據資料做頁面渲染
Vue.prototype.dataChangeUpdatePage = function (key, newVal) {
let _this = this; // 存一份this例項物件
// 也要去更新對應dom的內容
_this.subArr.forEach((item) => {
if (key == item.whichAttribute) {
// 哪個dom的 // innerText或者value // 賦新值
item.nodeLabelDom[item.valueOrInnerHtml] = newVal;
}
})
}
let vm = new Vue({
el: '#app', // 指定vue的根元素
/**
* model資料層,為了便於理解,這裡也是舉例data中資料只有一層,多層需要遞迴
* */
data: {
name: '孫悟空',
age: 500,
}
});
console.log('vmvm', vm);
// 更改名字
let nameBtn = document.querySelector('#nameId')
nameBtn.onclick = () => {
vm.name = vm.name + '^' // 直接訪問
}
// 更改年齡
let ageBtn = document.querySelector('#ageId')
ageBtn.onclick = () => {
vm.$data.age = vm.$data.age * 1 + 1 // 通過$data間接訪問
}
// 恢復預設的名字和年齡
let resetBtn = document.querySelector('#resetId')
resetBtn.onclick = () => {
vm.$data.name = '孫悟空'
vm.age = 500
}
// 清空名字和年齡
let removeBtn = document.querySelector('#removeId')
removeBtn.onclick = () => {
vm.name = ''
vm.$data.age = null
}
</script>
</body>
</html>
好記性不如爛筆頭,記錄一下唄