往期
前言
本文介紹了兩種監聽物件某個屬性的變化的思路, 分別是利用私有屬性以及利用觀察者模式,建議有經驗的讀者直接閱讀最後的實現部分 :)
前景回顧
上一篇文章我們談到了如何監聽物件的變化。
下面我們來探究如何用$watch
方法中的callback來替換console.warn(newVal, oldVal)
, 以及如何只監聽物件的某個屬性的變化。
另外本文只討論Vue.prototype.$watch
keypath的寫法,即this.$watch('a.b.c', () => {});
。
function proxy(obj) {
const handler = {
get(target, prop) {
try {
return new Proxy(target[prop], handler);
} catch (error) {
return target[prop];
}
},
set(target, prop, newVal) {
const oldVal = target[prop];
if (oldVal !== newVal) {
// 如何替換這個強耦合的函式
console.warn(newVal, oldVal);
}
target[prop] = newVal;
return true;
},
};
return new Proxy(obj, handler);
}
const obj = proxy({
a: 'a',
b: 'b',
c: 'c',
});
// 以及如何做到當obj.a改變時只觸發第一個callback
$watch(obj, 'a', (val, oldVal) => {
console.warn('watch obj.a: ', val, oldVal);
});
$watch(obj, 'b', (val, oldVal) => {
console.warn('watch obj.b: ', val, oldVal);
});
複製程式碼
思路
關於私有屬性
有個十分簡單的思路: 把callback和要監聽的屬性值, 作為被監聽物件某一層級的私有屬性注入
// 監聽obj.a和obj.b
const obj = {
a: 'a',
b: 'b',
c: 'c',
// 因為我們需要監聽兩個屬性,所以需要使用集合
__waters__: [{
key: 'a',
cb: () => {},
}, {
key: 'b',
cb: () => {},
}],
};
// 對於多層級的被監聽物件, __watchers__掛載在不同的層級下
const obj = {
o: {
name: 'obj',
__watchers__: [{
key: 'name',
cb: () => {},
}],
},
odeep: {
path: {
name: 'obj deep',
__watchers__: [{
key: 'name',
cb: () => {},
}],
},
},
};
複製程式碼
關於觀察者模式
先讓我們看看維基百科是怎麼說的:
The observer pattern is a software design pattern in which an object, called the subject, maintains a list of its dependents, called observers, and notifies them automatically of any state changes, usually by calling one of their methods.
也就是說subject用來維護依賴列表, 每個依賴都是一個observer。當依賴列表中的某一項發生了變化,就自動通知subject自身狀態的變更。
function proxy(obj) {
const handler = {
get(target, prop) {
// 設定observer(依賴)
return target[prop]; // 不遞迴監聽
},
set(target, prop, newVal) {
const val = target[prop];
if (newVal !== val) {
target[prop] = newVal;
// observer通知自身狀態的改變, 即呼叫callback
}
return true;
},
};
return new Proxy(obj, handler);
}
複製程式碼
但是callback在$watch
函式中,如何傳遞給observer, 並在被監聽物件變化時呼叫呢?
我們可以利用一個全域性變數,在訪問變數的時候設定為$watch
函式的callback, 訪問結束後置空。
let DepTarget = null;
function $watch(obj, path, cb) {
DepTarget = cb;
// 訪問obj,自動呼叫get方法實現依賴注入
DepTarget = null;
}
class Dep {
constructor() {
this.subs = new Set();
}
add(sub) {
this.subs.add(sub);
}
notify() {
this.subs.forEach((sub) => {
// sub需要儲存oldVal和newVal, 當且僅當oldVal不等於newVal時呼叫callback
sub.update();
});
}
}
複製程式碼
實現
關於私有屬性
對於這個思路而言,我們只需要找出需要監聽的屬性的上一層級, 不難抽象出下面的函式:
function parseParentPath(path, obj) {
if (/[^\w.$]/.test(path)) {
return {};
}
let segs = path.split('.');
// 監聽屬性的上一層,所以是length - 1
segs = segs.slice(0, segs.length - 1);
for (let i = 0; i < segs.length; i += 1) {
if (!obj) {
return {};
}
obj = obj[segs[i]];
}
return obj;
}
複製程式碼
那麼$watch
也不難寫出來了
function $watch(obj, path, cb) {
const parent = parseParentPath(path, obj);
// 限於篇幅,邊界判斷還請自行腦補 :)
const segs = path.split('.');
const key = segs[segs.length - 1];
if (!parent.__watchers__) {
Object.defineProperty(parent, '__watchers__', {
value: [],
configurable: true,
});
}
parent.__watchers__.push({ key, cb });
}
const handler = {
get(target, prop) {
try {
return new Proxy(target[prop], handler);
} catch (error) {
return target[prop];
}
},
set(target, prop, newVal) {
const oldVal = target[prop];
const { __watchers__ } = target;
if (__watchers__) {
const current = __watchers__.find(e => e.key === prop);
if (oldVal !== newVal && current && typeof current.cb === 'function') {
current.cb(newVal, oldVal);
}
}
target[prop] = newVal;
return true;
},
};
obj = new Proxy(obj, handler);
複製程式碼
好了,讓我們來試試吧!
let obj = {
b: true,
o: { name: 'obj', age: 18 },
a: ['a', 'b', 'c'],
odeep: {
path: {
name: 'obj deep',
value: [],
},
},
};
$watch(obj, 'b', (newVal, oldVal) => {
console.error('watch b: ', newVal, oldVal);
});
$watch(obj, 'o.name', (newVal, oldVal) => {
console.error('watch o.name: ', newVal, oldVal);
});
$watch(obj, 'odeep.path.name', (newVal, oldVal) => {
console.error('watch odeep.path.name: ', newVal, oldVal);
});
setTimeout(() => {
// 當然不會有什麼問題
obj.o.name = 'new obj';
obj.b = false;
obj.odeep.path.name = 'new obj deep';
}, 1000);
複製程式碼
但是這樣的寫法存在一些侷限性
$watch(obj, 'odeep', (newVal, oldVal) => {
console.error('watch odeep: ', newVal, oldVal);
}, { deep: true });
setTimeout(() => {
// 對於{ deep: true }, 需要在物件的每個層級新增__watchers__屬性,顯然不太合適
obj.odeep.path.name = 'new obj deep';
}, 1000);
複製程式碼
關於觀察者模式
我們利用sub來儲存oldVal和newVal, 並將$watch
的邏輯寫入sub的get
方法中
class Sub {
constructor(obj, path, cb) {
this.obj = obj;
this.path = path;
this.cb = cb;
this.value = this.get();
}
get() {
// 因為Dep的add方法傳參為sub, 因此全域性變數設定為當前sub
DepTarget = this;
// 訪問obj
const value = parsePath(this.path)(this.obj);
DepTarget = null;
return value;
}
update() {
const value = this.get();
if (this.value !== value) {
const oldVal = this.value;
this.value = value;
this.cb.call(this.obj, value, oldVal);
}
}
}
複製程式碼
下面是完整的例子:
let DepTarget = null;
class Sub {
constructor(obj, path, cb) {
this.obj = obj;
this.path = path;
this.cb = cb;
this.value = this.get();
}
get() {
DepTarget = this;
// 訪問obj
const value = parsePath(this.path)(this.obj);
DepTarget = null;
return value;
}
update() {
const value = this.get();
if (this.value !== value) {
const oldVal = this.value;
this.value = value;
this.cb.call(this.obj, value, oldVal);
}
}
}
class Dep {
constructor() {
this.subs = new Set();
}
add(sub) {
this.subs.add(sub);
}
notify() {
this.subs.forEach((sub) => {
// sub需要儲存oldVal和newVal, 當且僅當oldVal不等於newVal時呼叫callback
sub.update();
});
}
}
function proxy(obj) {
const dep = new Dep();
const handler = {
get(target, prop) {
if (DepTarget) {
dep.add(DepTarget);
}
// 不遞迴監聽
return target[prop];
},
set(target, prop, newVal) {
const val = target[prop];
if (newVal !== val) {
target[prop] = newVal;
dep.notify();
}
return true;
},
};
return new Proxy(obj, handler);
}
function parsePath(path) {
if (/[^\w.$]/.test(path)) {
return;
}
var segs = path.split('.');
return function(obj) {
for (let i = 0; i < segs.length; i += 1) {
if (!obj) {
return;
}
obj = obj[segs[i]];
}
return obj;
};
}
const obj = proxy({
a: 'a',
b: 'b',
o: { name: 'a', age: 18 },
arr: [1, 2],
});
function $watch(obj, path, cb) {
return new Sub(obj, path, cb);
}
$watch(obj, 'a', (val, newVal) => {
console.warn('watch a: ', val, newVal);
});
$watch(obj, 'b', (val, newVal) => {
console.warn('watch b: ', val, newVal);
});
$watch(obj, 'o.age', (val, newVal) => {
console.warn('watch o.age: ', val, newVal);
});
$watch(obj, 'arr', (val, newVal) => {
console.warn('watch arr: ', val, newVal);
});
setTimeout(() => {
obj.b = 'new b';
obj.o.age -= 1;
// vue會列印相同的值, 你會發現我們的實現不會列印
obj.arr.push(3);
obj.arr = [3];
}, 1000);
複製程式碼
細心的你應該發現了,我們沒有實現Vue.prototype.$watch
常用的{ deep: true }
引數, 限於篇幅, 筆者決定還是放在下一篇文章介紹 :)