文章中的代碼時階段,可以下載源碼測試一下。
git項目地址:https://github.com/xubaodian/SimuVue
項目使用webpack構建,下載後先執行:
npm install
安裝依賴後使用指令:
npm run dev
可以運行項目。
上篇文章,我們講解了Vue的data屬性映射和方法的重定義,鏈接地址如下:
Vue源碼解析(一)data屬性映射和methods函數引用的重定義
這篇文章給大家帶來的是Vue的雙向綁定講解。
什麼是雙向綁定
我們看一張圖:
可以看到,輸入框上方的內同和輸入框中的值是一致的。輸入框的之變化,上方的值跟着一起變化。
這就是Vue的雙向綁定。
對象屬性監聽實現
我們先不着急瞭解Vue時如何實現這一功能的,如果我們自己要實現這樣的功能,如何實現呢?
我的思路是這樣:
可以分爲幾個步驟,如下:
1、首先給輸入框添加input事件,監視輸入值,存放在變量value中。
2、監視value變量,確保value變化時,監視器可以發現。
3、若value發生變化,則重新渲染視圖。
上面三個步驟,1(addEventListener)和3(操作dom)都很好實現,對於2的實現,可能有一下兩個方案:
1、使用Object.defineProperty()重新定義對象set和get,在值發生變化時,通知訂閱者。
2、使用定時器定時檢查value的值,發生變化就通知訂閱者。(這個方法不好,定時器不能實時反應value變化)。
Vue源碼中採用了方案1,我們首先用方案1實現對對象值的監聽,代碼如下:
function defineReactive(obj, key, val, customSetter) {
//獲取對象給定屬性的描述符
let property = Object.getOwnPropertyDescriptor(obj, key);
//對象該屬性不可配置,直接返回
if (property && property.configurable === false) {
return;
}
//獲取屬性get和set屬性,若此前該屬性已經進行監聽,則確保監聽屬性不會被覆蓋
let getter = property && property.get;
let setter = property && property.set;
if (arguments.length < 3) {
val = obj[key];
}
//監聽屬性
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val;
console.log(`讀取了${key}屬性`);
return value;
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val;
//如果值沒有變化,則不做改動
if (newVal === value) {
return;
}
//自定義響應函數
if (customSetter) {
customSetter(newVal);
}
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
console.log(`屬性${key}發生變化:${value} => ${newValue}`);
}
})
}
下面我們測試下,測試代碼如下:
let obj = {
name: 'xxx',
age: 20
};
defineReactive(obj, 'name');
let name = obj.name;
obj.name = '1111';
控制檯輸出爲:
讀取了name屬性
test.html:51 屬性name發生變化:xxx => 1111
可見,我們已經實現了對obj對象name屬性讀和寫的監聽。
實現了監聽,這沒問題,但是視圖怎麼知道這些屬性發生了變化呢?可以使用發佈訂閱模式實現。
發佈訂閱模式
什麼是發佈訂閱模式呢?
我畫了一個示意圖,如下:
發佈訂閱模式有幾個部分構成:
1、訂閱中心,管理訂閱者列表,發佈者發消息時,通知相應的訂閱者。
2、訂閱者,這個是訂閱消息的主體,就像關注微信公衆號一樣,有文章就會通知關注者。
3、發佈者,類似微信公衆號的文章發佈者。
訂閱中心的代碼如下:
export class Dep {
constructor() {
this.id = uid++;
//訂閱列表
this.subs = [];
}
//添加訂閱
addSub(watcher) {
this.subs.push(watcher);
}
//刪除訂閱者
remove(watcher) {
let index = this.subs.findIndex(item => item.id === watcher.id);
if (index > -1) {
this.subs.splice(index, 1);
}
}
depend () {
if (Dep.target) {
Dep.target.addDep(this);
}
}
//通知訂閱者
notify() {
this.subs.map(item => {
item.update();
});
}
}
//訂閱中心 靜態變量,訂閱時使用
Dep.target = null;
const targetStack = [];
export function pushTarget (target) {
targetStack.push(target);
Dep.target = target;
}
export function popTarget () {
targetStack.pop();
Dep.target = targetStack[targetStack.length - 1];
}
訂閱中心已經實現,還有發佈者和訂閱者,先看下發布者,這裏誰是發佈者呢?
沒錯,就是defineReactive函數,這個函數實現了對data屬性的監聽,它可以檢測到data屬性的修改,發生修改時,通知訂閱中心,所以defineReactive做一些修改,如下:
//屬性監聽
export function defineReactive(obj, key, val, customSetter) {
//獲取對象給定屬性的描述符
let property = Object.getOwnPropertyDescriptor(obj, key);
//對象該屬性不可配置,直接返回
if (property && property.configurable === false) {
return;
}
//訂閱中心
const dep = new Dep();
//獲取屬性get和set屬性,若此前該屬性已經進行監聽,則確保監聽屬性不會被覆蓋
let getter = property && property.get;
let setter = property && property.set;
if (arguments.length < 3) {
val = obj[key];
}
//如果監聽的是一個對象,繼續深入監聽
let childOb = observe(val);
//監聽屬性
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val;
//這段代碼時添加訂閱時使用的
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
}
}
return value;
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val;
//如果值沒有變化,則不做改動
if (newVal === value) {
return;
}
//自定義響應函數
if (customSetter) {
customSetter(newVal);
}
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
//如果新的值爲對象,重新監聽
childOb = observe(newVal);
/**
* 訂閱中心通知所有訂閱者
**/
dep.notify();
}
})
}
這裏設計到閉包的概念,我們在函數裏定義了:
const dep = new Dep();
由於set和get函數一直都存在的,所有dep會一直存在,不會被回收。
當值發生變化後,利用下面的代碼通知訂閱者:
dep.notify();
訂閱中心和發佈者都有了,我們何時訂閱呢?或者什麼時間訂閱合適呢?
我們是希望實現當讀取data屬性時候,實現訂閱。所以在defineReactive函數的get監聽中添加了如下代碼:
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
}
}
return value;
Dep.target是一個靜態變量,用來存儲訂閱者的,每次訂閱前指向訂閱者,訂閱者置爲null。
訂閱者代碼如下:
let uid = 0;
//訂閱者類
export class Watcher{
//構造器,vm是vue實例
constructor(vm, expOrFn, cb) {
this.vm = vm;
this.cb = cb;
this.id = uid++;
this.deps = [];
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}
this.value = this.get();
}
//將訂閱這添加到訂閱中心
get() {
//訂閱前,設置Dep.target變量,指向自身
pushTarget(this)
let value;
const vm = this.vm;
/**
* 這個地方讀取data屬性,觸發下面的訂閱代碼,
* if (Dep.target) {
* dep.depend();
* if (childOb) {
* childOb.dep.depend();
* }
* }
* return value;
**/
value = this.getter.call(vm, vm);
//訂閱後,置Dep.target爲null
popTarget();
return value
}
//值變化,調用回調函數
update() {
this.cb(this.value);
}
//添加依賴
addDep(dep) {
this.deps.push(dep);
dep.addSub(this);
}
}
//解析類屬性的路徑,例如obj.sub.name,返回實際的值
export function parsePath (path){
const segments = path.split('.');
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return;
obj = obj[segments[i]];
}
return obj;
}
}
除了發佈訂閱以外,雙向綁定還需要編譯dom。
dom編譯和input綁定
主要實現兩個功能:
1、將dom中的{{key}}元素替換爲Vue中的屬性。
2、檢測帶有v-model屬性的input元素,添加input事件,有修改時,修改Vue實例的屬性。
檢測v-model,綁定事件的代碼如下:
export function initModelMixin(Vue) {
Vue.prototype._initModel = function () {
if (this._dom == undefined) {
if (this.$options.el) {
let el = this.$options.el;
let dom = document.querySelector(el);
if (dom) {
this._dom = dom;
} else {
console.error(`未發現dom: ${el}`);
}
} else {
console.error('vue實例未綁定dom');
}
}
bindModel(this._dom, this);
}
}
//input輸入框有V-model屬性,則綁定input事件
function bindModel(dom, vm) {
if (dom) {
if (dom.tagName === 'INPUT') {
let attrs = Array.from(dom.attributes);
attrs.map(item => {
if (item.name === 'v-model') {
let value = item.value;
dom.value = getValue(vm, value);
//綁定事件,暫不考慮清除綁定,因此刪除dom造成的內存泄露我們暫不考慮,這些問題後續解決
dom.addEventListener('input', (event) => {
setValue(vm, value, event.target.value);
});
}
})
}
let children = Array.from(dom.children);
if (children) {
children.map(item => {
bindModel(item, vm);
});
}
}
}
替換dom中{{key}}類似的屬性代碼:
export function renderMixin(Vue) {
Vue.prototype._render = function () {
if (this._dom == undefined) {
if (this.$options.el) {
let el = this.$options.el;
let dom = document.querySelector(el);
if (dom) {
this._dom = dom;
} else {
console.error(`未發現dom: ${el}`);
}
} else {
console.error('vue實例未綁定dom');
}
}
replaceText(this._dom, this);
}
}
//替換dom的innerText
function replaceText(dom, vm) {
if (dom) {
let children = Array.from(dom.childNodes);
children.map(item => {
if (item.nodeType === 3) {
if (item.originStr === undefined) {
item.originStr = item.nodeValue;
}
let str = replaceValue(item.originStr, function(key){
return getValue(vm, key);
});
item.nodeValue = str;
} else if (item.nodeType === 1) {
replaceText(item, vm);
}
});
}
}
到此位置,就實現了雙向綁定。
測試代碼如下,因爲我用webpack構建的前端項目,html模板如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>test
</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="app">
<div class="test">{{name}}</div>
<input type="text" v-model="name">
</div>
</body>
</html>
main.js代碼:
import { Vue } from '../src/index';
let options = {
el: '#app',
data: {
name: 'xxx',
age: 18
},
methods: {
sayName() {
console.log(this.name);
}
}
}
let vm = new Vue(options);
效果如下:
可以下載源碼嘗試,git項目地址:https://github.com/xubaodian/SimuVue
項目使用webpack構建,下載後先執行:
npm install
安裝依賴後使用指令:
npm run dev
可以運行項目。
如有疑問,歡迎留言或發送郵件至[email protected]。