vue2.x-理解雙向繫結
1.前言
每當被問到Vue資料雙向繫結原理的時候,大家可能都會脫口而出:Vue內部通過Object.defineProperty方法屬性攔截的方式,把data物件裡每個資料的讀寫轉化成getter/setter,當資料變化時通知檢視更新。雖然一句話把大概原理概括了,但是其內部的實現方式還是值得深究的,本文就以通俗易懂的方式剖析Vue內部雙向繫結原理的實現過程。
2.思路分析
所謂MVVM資料雙向繫結,即主要是:資料變化更新檢視,檢視變化更新資料。如下圖:
也就是說:
- 輸入框內容變化時,data 中的資料同步變化。即 view => model 的變化。
- data 中的資料變化時,文字節點的內容同步變化。即 model => view 的變化。
3.原理
1.vue 雙向資料繫結是通過 資料劫持 結合 釋出訂閱模式的方式來實現的, 也就是說資料和檢視同步,資料發生變化,檢視跟著變化,檢視變化,資料也隨之發生改變; 2.核心:關於VUE雙向資料繫結,其核心是 Object.defineProperty()方法; 3.介紹一下Object.defineProperty()方法 Object.defineProperty(obj, prop, descriptor) ,這個語法內有三個引數,分別為obj(要定義其上屬性的物件),prop (要定義或修改的屬性),descriptor (具體的改變方法) 簡單地說,就是用這個方法來定義一個值。當呼叫時我們使用了它裡面的get方法,當我們給這個屬性賦值時,又用到了它裡面的set方法
詳情可參考 MDN|Object.defineProperty()
4.具體實現
1.實現效果
先來看一下vue雙向資料繫結是如何進行的,以便我們確定好思考方向
<div id="app">
<input type="text" v-model="text">{{text}}
</div>
//建立一個vue例項
var vm=new Vue({
el:'app',
data:{
text:'hello world'
}
})
2.任務拆分
拆分任務可以讓我們的思路更加清晰:
(1)將vue中的data中的內容繫結到輸入文字框和文字節點中
(2)當文字框的內容改變時,vue例項中的data也同時發生改變
(3)當data中的內容發生改變時,輸入框及文字節點的內容也發生變化
3.分佈執行任務
1.任務1-繫結data到view
我們先了解一下 DocuemntFragment(碎片化文件)
這個概念,你可以把他認為一個dom節點收容器,當你創造了10個節點,當每個節點都插入到文件當中都會引發一次瀏覽器的迴流,也就是說瀏覽器要回流10次,十分消耗資源。而使用碎片化文件,也就是說我把10個節點都先放入到一個容器當中,最後我再把容器直接插入到文件就可以了!瀏覽器只回流了1次。
注意:還有一個很重要的特性是,如果使用appendChid方法將原dom樹中的節點新增到DocumentFragment中時,會刪除原來的節點。
利用DocuemntFragment
的這個特性,可以通過while
迴圈將需要繫結區域的內容新增到碎片化文件中
//碎片化文件,遍歷app所有子元素,呼叫編譯函式更新model
function nodeTofragment(node,vm) {
let fragment=document.createDocumentFragment()
let child
while(child=node.firstChild){
fragment.appendChild(child)
}
return fragment
}
接下來就需要將data中的資料分別繫結到input
框上和文字節點。目前閒置我們已經獲取到了div
的所有子節點了,就在DocumentFragment
裡面,然後對每一個節點進行處理,看是不是有跟vm例項中有關聯的內容,如果有,修改這個節點的內容。然後重新新增入DocumentFragment
中。
首先,我們寫一個處理每一個節點的函式,如果有input
繫結v-model
屬性或者有{{ xxx }}的文字節點出現,就進行內容替換,替換為vm例項中的data中的內容。
// 編譯函式,把data資料更新給model
function compile(node,vm) {
let attr = node.attributes
if(node.nodeType===1){
for(let i=0;i<attr.length;i++){
if(attr[i].nodeName=='v-model'){
let name = attr[i].nodeValue
node.value=vm.data[name]
node.removeAttribute('v-model')
}
}
}
let reg=/\{\{(.*)\}\}/
if(node.nodeType===3){
if(reg.test(node.nodeValue)){
let name = RegExp.$1
name=name.trim()
node.nodeValue=vm.data[name]
}
}
}
然後,在向碎片化文件中新增節點時,每個節點都處理一下。
//碎片化文件,遍歷app所有子元素,呼叫編譯函式更新model
function nodeTofragment(node,vm) {
let fragment=document.createDocumentFragment()
let child
while(child=node.firstChild){
//新增編譯函式
compile(child,vm)
fragment.appendChild(child)
}
return fragment
}
建立Vue的例項化函式
// vue建構函式
function Vue(options) {
let id=options.el
this.data=options.data
let dom = nodeTofragment(document.getElementById(id),this)
document.getElementById(id).appendChild(dom)
}
var vm=new Vue({
el:'app',
data:{
text:'hello world'
}
})
效果如下圖,已經將data中的資料繫結到節點中。
2.任務2-監聽input變化view到model
對於此任務,我們可以通過事件監聽器keyup,input等,來獲取到最新的value,然後通過Object.defineProperty
將獲取的最新的value,賦值給例項vm的text,我們把vm例項中的data下的text通過Object.defineProperty
設定為訪問器屬性,這樣給vm.text賦值,就觸發了set。set函式的作用一個是更新data中的text。
首先實現一個響應式監聽屬性的函式。一旦有賦新值就發生變化。
function defineReactive(vm,key,val) {
Object.defineProperty(vm,key,{
get:function () {
return val
},
set:function (newVal) {
if(newVal==val){
return
}
val=newVal
}
})
}
然後,實現一個觀察者,對於一個例項 每一個屬性值都進行觀察。
//觀察者函式
function observe(obj) {
for(let key of Object.keys(obj)){
defineReactive(obj,key,obj[key])
}
}
改寫編譯函式,注意由於改成了訪問器屬性,只要存在複製操作就行觸發set,訪問的方法也產生變化,同時添加了事件監聽器,把例項的text值隨時更新。
// 編譯函式,把data資料更新給model
function compile(node,vm) {
let attr = node.attributes
if(node.nodeType===1){
for(let i=0;i<attr.length;i++){
if(attr[i].nodeName=='v-model'){
let name = attr[i].nodeValue
//增加時間監聽,將結果賦值給data
node.addEventListener('input',function (e) {
vm.data[name]=e.target.value
})
node.value=vm.data[name]
node.removeAttribute('v-model')
}
}
}
let reg=/\{\{(.*)\}\}/
if(node.nodeType===3){
if(reg.test(node.nodeValue)){
let name = RegExp.$1
name=name.trim()
// node.nodeValue=vm.data[name]
new Watcher(vm,node,name)
}
}
}
然後在例項函式中,觀察data中的所有屬性值,新增observe函式。
// vue建構函式
function Vue(options) {
let id=options.el
this.data=options.data
observe(options.data)
let dom = nodeTofragment(document.getElementById(id),this)
document.getElementById(id).appendChild(dom)
}
做到這一步,列印一下結果我們發現,最終我們改變input中的內容能改變data中的資料,但是頁面上的資料卻沒有重新整理。接下來就進行下一步
3.任務3-釋出訂閱model到view
繼續上一個問題 需要我們注意,當我們修改輸入框,改變了vm例項的屬性,這是1對1的。但是,我們可能在頁面中多處用到 data中的屬性,這是1對多的。也就是說,改變1個model的值可以改變多個view中的值。
這就需要我們引入一個新的知識點:
訂閱/釋出者模式
訂閱釋出模式(又稱觀察者模式)定義了一種一對多的關係,讓多個觀察者同時監聽某一個主題物件,這個主題物件的狀態發生改變時就會通知所有觀察者物件。
下面這裡舉個簡單的例子,具體的詳情可自行尋找資料:
//訂閱釋出者模式
class EventEmitter {
constructor(){
this.listener=Object.create(null)
}
//事件訂閱
on=(event,listerner)=>{
if(!event||!listerner){
return
}
if(this.listener[event]){
//如果已經存在就存入一個新的
this.listener[event].push(listerner)
}else{
//沒有就建立一個新得
this.listener[event]=[listerner]
}
}
//事件釋出
emit=(event,...args)=>{
if(!this.hasBind(event)){
console.log(`沒有監聽event}`)
return
}
this.listener[event].forEach(listener=>{
listener.call(this,...args)
})
}
//取消訂閱
off=(event,listener)=>{
if(!this.hasBind(event)){
console.log(`沒有訂閱${event}`)
return
}
if(!listener){
delete this.listener[event]
return
}
this.listener[event]=this.listener[event].filter(item=>{
item!==listener
})
}
//事件訂閱狀態
hasBind=event=>{
return this.listener[event]&&
this.listener[event].length
}
}
const baseEvent = new EventEmitter()
function cb(value){
console.log("hello "+value)
}
baseEvent.on("click",cb)
baseEvent.emit("click",'2020') //打印出“hello 2020”
上面的例子只是簡單的做個參考,理解其中的意思就行。現在繼續正文,在我們的這個實現中,我們需要在Object.defineProperty
中的get中訂閱我們的事情,在set中釋出事件。在編譯 HTML 的過程中,會為每個與資料繫結相關的節點生成一個訂閱者 watcher,watcher 會將自己新增到相應屬性的 dep 容器中。
我們已經實現:修改輸入框內容 => 在事件回撥函式中修改屬性值 => 觸發屬性的 set 方法。接下來我們要實現的是:發出通知 dep.notify() => 觸發訂閱者的 update 方法 => 更新檢視。這裡的關鍵邏輯是:如何將 watcher 新增到關聯屬性的 dep 中。
注意: 我把直接賦值的操作改為了 新增一個 Watcher 訂閱者
// 編譯函式,把data資料更新給model
function compile(node,vm) {
let attr = node.attributes
if(node.nodeType===1){
for(let i=0;i<attr.length;i++){
if(attr[i].nodeName=='v-model'){
let name = attr[i].nodeValue
node.addEventListener('input',function (e) {
vm.data[name]=e.target.value
})
node.value=vm.data[name]
node.removeAttribute('v-model')
}
}
}
let reg=/\{\{(.*)\}\}/
if(node.nodeType===3){
if(reg.test(node.nodeValue)){
let name = RegExp.$1
name=name.trim()
// node.nodeValue=vm.data[name]
new Watcher(vm,node,name) //這裡建立了一個訂閱者
}
}
}
然後就是寫一個訂閱者函式
function Watcher(vm,node,name) {
Dep.target=this
this.vm=vm
this.node=node
this.name=name
this.update()
}
Watcher.prototype={
get:function () {
this.value=this.vm.data[this.name]
},
update:function () {
this.get()
this.node.nodeValue=this.value
}
}
首先,將自己賦給了一個全域性變數Dep.target
;
其次,執行了 update 方法,進而執行了 get 方法,get 的方法讀取了 vm 的訪問器屬性,從而觸發了訪問器屬性的 get 方法,get 方法中將該 watcher 新增到了對應訪問器屬性的 dep 中;
再次,獲取屬性的值,然後更新檢視。
最後,將 Dep.target
設為空。因為它是全域性變數,也是 watcher 與 dep 關聯的唯一橋樑,任何時刻都必須保證Dep.target
只有一個值。
function defineReactive(vm,key,val) {
var dep=new Dep()
Object.defineProperty(vm,key,{
get:function () {
// 在這裡進行訂閱操作
if(Dep.target) {
console.log(Dep.target)
dep.on(Dep.target)
}
return val
},
set:function (newVal) {
if(newVal==val){
return
}
val=newVal
dep.emit()
console.log('新值'+newVal)
}
})
}
然後寫一個訂閱者釋出者建構函式。
function Dep() {
this.listener=[]
}
Dep.prototype={
on:function(event) {
this.listener.push(event)
},
emit:function () {
this.listener.forEach(event=>{
event.update()
})
}
}
到這裡基本就實現了vue的雙向繫結,有點繁瑣,寫的也有點複雜。在這裡進行記錄一下,下面給上全部程式碼。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">
<input type="text" v-model="text">{{text}}
</div>
<script>
function defineReactive(vm,key,val) {
var dep=new Dep()
Object.defineProperty(vm,key,{
get:function () {
if(Dep.target) {
console.log(Dep.target)
dep.on(Dep.target)
}
return val
},
set:function (newVal) {
if(newVal==val){
return
}
val=newVal
dep.emit()
console.log('新值'+newVal)
}
})
}
//觀察者函式
function observe(obj) {
for(let key of Object.keys(obj)){
defineReactive(obj,key,obj[key])
}
}
// 編譯函式,把data資料更新給model
function compile(node,vm) {
let attr = node.attributes
if(node.nodeType===1){
for(let i=0;i<attr.length;i++){
if(attr[i].nodeName=='v-model'){
let name = attr[i].nodeValue
node.addEventListener('input',function (e) {
vm.data[name]=e.target.value
})
node.value=vm.data[name]
node.removeAttribute('v-model')
}
}
}
let reg=/\{\{(.*)\}\}/
if(node.nodeType===3){
if(reg.test(node.nodeValue)){
let name = RegExp.$1
name=name.trim()
// node.nodeValue=vm.data[name]
new Watcher(vm,node,name)
}
}
}
function Dep() {
this.listener=[]
}
Dep.prototype={
on:function(event) {
this.listener.push(event)
},
emit:function () {
this.listener.forEach(event=>{
event.update()
})
}
}
function Watcher(vm,node,name) {
Dep.target=this
this.vm=vm
this.node=node
this.name=name
this.update()
}
Watcher.prototype={
get:function () {
this.value=this.vm.data[this.name]
},
update:function () {
this.get()
this.node.nodeValue=this.value
}
}
//碎片化文件,遍歷app所有子元素,呼叫編譯函式更新model
function nodeTofragment(node,vm) {
let fragment=document.createDocumentFragment()
let child
while(child=node.firstChild){
compile(child,vm)
fragment.appendChild(child)
}
return fragment
}
// vue建構函式
function Vue(options) {
let id=options.el
this.data=options.data
observe(options.data)
let dom = nodeTofragment(document.getElementById(id),this)
document.getElementById(id).appendChild(dom)
}
var vm=new Vue({
el:'app',
data:{
text:'hello world'
}
})
</script>
</body>
</html>
本文到底就結束啦~