前言
在之前面試中,有被問到這個問題,雖然了解過是劫持Object.defineProperty
方法,但是其細節并不太清楚,于是遭到了面試官的鄙視??,只能回頭認真在網上看一下。
剛開始看了很多文章,還是沒看懂。
最后我是看這篇文章看懂的,其他的要么略過太多細節,看著有種斷層感,根本不知道怎么突然到這一步了。有些要么跟著代碼講思路,有點亂。
這篇文章已經講解得很好了,但是作為一個小白,我還是看了老半天才懂,原因就是看的源碼少,水平不夠。
所以我決定重新捋一捋里面的思想,把細節盡可能說清楚,讓跟我一樣沒學過任何源碼的人也能搞清楚。
補充一下個人想法,對于這些精妙的思維接觸不多,而這些往往是決定我們的高度的,是一個使用者還是研究者?有時候眼光的高低,決定著我們未來道路的長短。
大致原理
vue的響應原理可以從下面官網的分析圖大致了解。
官網的解釋是這樣的:
每個組件實例都有相應的 watcher 實例對象,它會在組件渲染的過程中把屬性記錄為依賴,之后當依賴項的 setter 被調用時,會通知 watcher 重新計算,從而致使它關聯的組件得以更新。
看不懂?沒關系,有個大概印象就可以了。
defineProperty是什么鬼?
為什么要先從這里說起?因為這是眾所周知vue雙向綁定的原理。
MDN解釋在這里
簡單地說,就是對于我們的對象的屬性,我們可以通過defineProperty
來設置它的get
和set
方法,一旦獲取值,就會觸發get方法,一旦修改值,就會觸發set方法。
比如下面簡單的例子
var obj = {name:'zeller'};
Object.defineProperty(obj,'name',{
get:function(){
console.log(`你正在獲取obj的name值.`)
},
set:function(newVal){
console.log(`name值修改中,新的name值是${newVal}`)
},
})
obj.name//"你正在獲取obj的name值."
obj.name = 'atoms'//"name值修改中,新的name值是atoms"
用defineProperty實現一個極簡的雙向綁定例子
既然這個方法這么有用,我們設置一個容器obj,直接在set里面渲染我們的html,然后監聽input的keyup
事件,當事件觸發時,修改obj對應的值,從而再觸發html的改變。
既然大概思路有了,我們可以嘗試一下.
<!--html-->
<input type="text" id="content">請輸入內容
<br><br>
他輸入的內容是:<p id="reflect" style="color:red;"></p>
var obj={};
//假設我們監聽'hello'這個屬性
Object.defineProperty(obj,'hello',{
set:function(newVal){
var p = document.getElementById('reflect');
p.innerHTML = newVal;
}
})
var input = document.getElementById('content');
input.addEventListener('keyup',function(e){
obj.hello = e.target.value;
})
分解實際任務
雖然上面的簡單演示我們貌似做出來了,但是與實際的樣子卻不一樣。我們看看。
實際是上面這樣子調用的,所以我們需要分析一下,該如何實現。
首先,我們要在初次渲染html能拿到data的數據
其次,輸入框輸入內容變化時,data中的相應屬性也能變化
最后,data中的數據變化時,html能實時跟著變化
所以我們大概可以分為3個任務
1、輸入框以及文本節點與data中的數據綁定(初始渲染)
2、輸入框內容變化時,data中的數據同步變化。即view => model的變化。
3、data中的數據變化時,文本節點的內容同步變化。即model => view的變化。
任務1:初始加載渲染data里的屬性
既然要加載data里的屬性值,我們就要考慮兩種情況,app里的子節點的類型,
- 當childNode是文本節點,而我們匹配到
{{attr}}
時,我們需要去找vue里面綁定的data的attr屬性,把它的值替換給文本節點. - 當childNode是元素節點時,比如
<input v-model="attr">
,我們就要去找vue.data.attr的值,并賦給childNode
因此可以看出,我們需要先把所有子節點遍歷出來,看看有沒有符合以下兩個規則的內容:
- 文本節點,含有
{{attr}}
- 元素節點,含有
v-model
這樣把值替換完我們就可以返回去了,但是考慮到多次操作dom的開銷,我們用createDocumentFragment()
它相當與創建一個倉庫,每次把子節點修改完,我們不直接插入父節點(#app),而是放入倉庫,最后直接把倉庫里的東西替換掉就可以了。
創建fragment倉庫
function nodeToFragment (node, vm) {
var flag = document.createDocumentFragment();
var child;
// 許多同學反應看不懂這一段,這里有必要解釋一下
// 首先,所有表達式必然會返回一個值,賦值表達式亦不例外
//child = node.firstChild返回的是賦值的node.firstChild
//即只要firstChild存在,就把firstChild賦給child
// 理解了上面這一點,就能理解 while (child = node.firstChild) 這種用法
// 其次,appendChild 方法有個隱蔽的地方,就是調用以后 child 會從原來 DOM 中移除
// 所以,第二次循環時,node.firstChild 已經不再是之前的第一個子元素了
while (child = node.firstChild) {
compile(child,vm)//講data轉化為html
flag.appendChild(child); // 將子節點劫持到文檔片段中
}
return flag
}
compile方法在下面解釋
替換html
這里主要用的是正則表達式的檢測方法,其中對RegExp.$1
的用法不了解的同學可以Google一下,這是正則一個非常巧妙而且強大的地方。
function compile (node, vm) {
var reg = /\{\{(.*)\}\}/;
// 節點類型為元素
if (node.nodeType === 1) {
var attr = node.attributes;
// 解析屬性
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName == 'v-model') {
var name = attr[i].nodeValue; // 獲取 v-model 綁定的屬性名
node.value = vm.data[name];
node.removeAttribute('v-model')
}
};
}
// 節點類型為 text
if (node.nodeType === 3) {
if (reg.test(node.nodeValue)) {
var name = RegExp.$1; // 獲取匹配到的字符串
name = name.trim();
node.nodeValue = vm.data[name]
}
}
}
我們看看上面的代碼,主要就是判斷子節點的類型,一旦是元素節點,我們就給它的input事件綁定方法,把input的value傳給vm.data[name],如果是文本節點,就直接替換.
這里要注意,element節點我們用的是node.value
,text節點我們用的是node.nodeValue
,這兩個寫法的區別可以自行Google一下.
最后再創建一個Vue實例
下面是codepen的實例
任務2:響應式的數據綁定
再來看任務二的實現思路:當我們在輸入框輸入數據的時候,首先觸發input事件(或者keyup、change事件),在相應的事件處理程序中,我們獲取輸入框的value并賦值給vm實例的text屬性。我們會利用defineProperty將data中的text劫持為vm的訪問器屬性,因此給vm.data.text賦值,就會觸發set方法。在set方法中主要做兩件事,第一是更新屬性的值,第二留到任務三再說。
具體怎么做呢?
監聽input事件
input節點
當我們觸發input時,要在dom節點上綁定事件?
怎么綁定呢?記得我們前面的nodeToFragment函數嗎?就是用于遍歷所有的子節點,進行節點修改的。
而里面具體干活的是compile函數,nodeToFragment只是一個包工頭。
這樣,我們就可以在v-model的標簽里監聽input事件
if (attr[i].nodeName == 'v-model') {
var name = attr[i].nodeValue; // 獲取 v-model 綁定的屬性名
node.addEventListener('input', function (e) {
// 給相應的 data 屬性賦值,進而觸發該屬性的 set 方法
vm.data[name] = e.target.value;
});
node.value = vm.data[name]; // 將 data 的值賦給該 node
node.removeAttribute('v-model');
我們看看邏輯,一開始就是從vm.data[name]
獲取value,一旦自己的內容改變了(e.target.value
),就把這個值告訴(賦值)給vm.data[name]
文本節點
而對于文本節點,是不需要的,我們只需要從vm.data獲取數據就可以了。因為它不是可以通過input改變內容的。
node.nodeValue = vm.data[name];
劫持get和set方法
想想我們的思路,我們input觸發時,是這樣修改data值的
vm.data[name] = e.target.value;
我們希望觸發點東西,但那是下一章的內容,無論如何,我們先劫持這些vm.data的所有屬性的get和set方法。
以后究竟要怎么搞事我們再決定。
怎么劫持呢?
我們只有在Vue中寫入一個observe,用于遍歷所有屬性,進行get和set的劫持。
function Vue (options) {
this.data = options.data;
var data = this.data;
observe(data, this);
var id = options.el;
var dom = nodeToFragment(document.getElementById(id), this);
// 編譯完成后,將 dom 返回到 app 中
document.getElementById(id).appendChild(dom);
}
接下來就是怎么寫這個observe。
首先必須遍歷所有節點。
然后用defineProperty設置get和set方法,這是我們暫且在set時打印新值,看看data是否真的改變了
function observe (obj, vm) {
Object.keys(obj).forEach(function (key) {
defineReactive(vm.data, key, obj[key]);
})
}
function defineReactive (obj, key, val) {
Object.defineProperty(obj, key, {
get: function () {
return val
},
set: function (newVal) {
if (newVal === val) return
val = newVal;
console.log(obj[key])
}
});
}
以上就是我們的第二部分,主要實現兩部分:
1、設置觀察函數observe,改寫get和set
2、監聽元素節點的input,當符合條件(匹配正則)時,首先從vm.data.key獲取相應屬性的值,觸發get。
當input的內容發生改變時,把該值賦給vm.data.key,觸發set。
codepen完整代碼在這里
可以看到當input的值發生改變時,vm.data.key也發生改變,這里我們先用console來判斷這個值是否改變了。
至此,第二部分已經完成。
任務3:把data的值渲染到dom里面
上面已經實現了值的雙向傳遞,我們主要用了屬性劫持和方法監聽(input)。
接下來想想我們該如何把data渲染進dom。
記得我們剛開始的極簡版demo嗎?
Object.defineProperty(obj,'hello',{
set:function(newVal){
var p = document.getElementById('reflect');
p.innerHTML = newVal;
}
})
我們是通過找到p元素,當data改變時,直接把新值傳給p元素。
但是有一個問題,我們這里假設已經知道p元素與data雙向綁定了。
如果我們不知道呢?
仔細看看這句代碼p.innerHTML = newVal;
到底哪一個元素的innerHTML才是newVal?
所以我們的關鍵是找到哪一個節點的對應哪一個屬性(vm.data)
這是vue最核心的部分之一
假設我們有一個容器,當我們get內容時,那這個節點肯定與data綁定了,此時我們把這個節點push進這個容器,這樣只要每次data改變,我們遍歷所有的節點不就可以了嗎?
vue管這個容器叫"依賴"(dep),或許表示所有dep里的節點都會依賴這個容器dep。
這么說有點繞口,比如這樣,我們在每個屬性上綁定一個容器dep,容器上有個數組subs,當有節點要get這個屬性的值時,我們就記錄下這個節點,push進subs。
而當我們的data改變時,就可以遍歷所有的節點,讓他們更新dom了。
意思就是連接節點和data的基本思路。具體怎么實現呢?
首先我們每個屬性各自都需要一個依賴dep,我們可以寫一個構造函數Dep,實例對象維護一個數組,用于存放節點。
function Dep () {
this.subs = []
}
這個依賴還必須有兩個功能,添加和更新。
有節點綁定了,就把它添加到數組。
有內容(data)更新了,就”告訴“所有節點去更新dom
所以原型還需要添加這兩個方法:
Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
});
}
}
這個dep是跟著屬性走的,所以我們需要在遍歷屬性時創建。
function defineReactive (obj, key, val) {
var dep = new Dep();
Object.defineProperty(obj, key, {
get: function () {
// 添加訂閱者 watcher 到主題對象 Dep
if (添加一個條件) dep.addSub();
return val
},
set: function (newVal) {
if (newVal === val) return
val = newVal;
// 作為發布者發出通知
dep.notify();
}
});
這里的get我們應該把節點push進容器數組,但是想一想,是不是連接建立后我們才要把這個節點push進去呢?怎么判斷是不是建立連接了呢?
記得我們的compile函數嗎?
if (attr[i].nodeName == 'v-model') {
var name = attr[i].nodeValue; // 獲取 v-model 綁定的屬性名
node.addEventListener('input', function (e) {
// 給相應的 data 屬性賦值,進而觸發該屬性的 set 方法
vm.data[name] = e.target.value;
});
node.value = vm.data[name]; // 將 data 的值賦給該 node
node.removeAttribute('v-model');
}
此時是不是通過判斷節點是否有”v-model“,但有時,從data里獲取v-model綁定的屬性值?
這是連接建立的關鍵,所以再這之后,我們可以判斷可以把節點push進去了。
但是想想,光是節點夠嗎?我們是否還需要更新的函數?能否寫在一起?
所以我們可以建立一個Watcher函數,用于更新dom,這樣當有data改變時,只要dep告訴我們去更新所有的Watcher就可以了。
這個Watcher就相當于一個容易,包裹了dom元素的內容還有更新方法。
所以我們push進dep的是一個個的Watcher,有更新就調用Watcher的update方法就可以了。
Watcher應該像下面這么寫
function Watcher (vm, node, name, nodeType) {
Dep.target = this;
this.name = name;
this.node = node;
this.vm = vm;
this.nodeType = nodeType;
this.update();
Dep.target = null;
}
Watcher.prototype = {
update: function () {
this.get();
if (this.nodeType == 'text') {
this.node.nodeValue = this.value;
}
if (this.nodeType == 'input') {
this.node.value = this.value;
}
},
// 獲取 data 中的屬性值
get: function () {
this.value = this.vm.data[this.name]; // 觸發相應屬性的 get
}
}
這里的Dep.target是作為節點與data綁定的標志,一旦這個存在了,說明我們要去get方法那里push Watcher了。
之后我們要清除這個Dep.target,有其他Watcher實例對象創建時再賦值,傳給dep.
因此相當于一個臨時的標志容器,且是全局的。
現在看看上面劫持get時的if條件,應該知道怎么寫了吧。
就是Dep.target存在的時候
get: function () {
// 添加訂閱者 watcher 到主題對象 Dep
if (Dep.target) dep.addSub();
return val
}
至此,我們的程序就完成了。
測試是沒有問題的。
下面是我畫的流程圖,可以幫助理解。
完整示例在這里
回顧
我們創建了一個類似vue的雙向綁定機制,怎么實現的呢?
我想從data獲取數值,于是我們改變dom,通過匹配正則,符合條件的把data的值賦給dom的value或nodeValue
我們想把內容變更傳遞給data,于是我們改造所有的data.
各自給它們一個容器dep的數組subs,當連接建立(標志是)同樣是正則匹配上了。
此時新建一個watcher,用于標識dom和存放更新dom的方法。
當input的內容改變時,觸發obj的set方法,set方法命令subs更新dom,subs遍歷所有watcher,讓所有watcher中的方法去更新自己的dom。
初次寫這么長的文章,剛開始理解這個機制對我來說也有點吃力,但總算搞懂了。
以上,我的解釋還有許多不足,歡迎指教,感謝閱讀。