一步一步實(shí)現(xiàn)Vue的響應(yīng)式(對象觀測)
平時(shí)開發(fā)中,Vue的響應(yīng)式系統(tǒng)讓我們不再去操作DOM,只需關(guān)心數(shù)據(jù)邏輯的處理,極大地降低了代碼的復(fù)雜度。而響應(yīng)式系統(tǒng)也是Vue的核心,作為開發(fā)者有必要了解其實(shí)現(xiàn)原理!
簡易版
以watch為切入點(diǎn)
watch是平時(shí)開發(fā)中使用率非常高的功能,其目的是觀測一個(gè)數(shù)據(jù),當(dāng)數(shù)據(jù)變化時(shí)執(zhí)行我們預(yù)先定義的回調(diào)。使用方式如下:
{
watch: {
obj(val, oldVal) {
console.log(val, oldVal);
}
}
}
上面觀測了Vue實(shí)例的obj屬性,當(dāng)其值發(fā)生變化時(shí),打印出新值與舊值。
因此,我們定義一個(gè)watch函數(shù):
function watch (data, key, cb) {
// do something
}
- watch函數(shù)接收3個(gè)屬性,分別是
- data: 被觀測對象 key: 被觀測的屬性
- cb: 數(shù)據(jù)變化后要執(zhí)行的回調(diào)
Object.defineProperty
既然要在數(shù)據(jù)變化后再執(zhí)行回調(diào),所以需要知道數(shù)據(jù)是什么時(shí)候被修改的,這就是Object.defineProperty的作用,其為數(shù)據(jù)定義了訪問器屬性。在數(shù)據(jù)被讀取時(shí)會觸發(fā)get,在數(shù)據(jù)被修改時(shí)會觸發(fā)set。
我們定義一個(gè)defineReactive函數(shù),其用來將一個(gè)數(shù)據(jù)變成響應(yīng)式的:
function defineReactive(data, key) {
let val = data[key];
Object.defineProperty(data, key, {
configurable: true,
enumerable: true,
get: function() {
return val;
},
set: function(newVal) {
if (newVal === val) {
return;
}
val = newVal;
}
});
}
defineReactive函數(shù)為data對象的key屬性定義了get、set,get返回屬性key的值val,set中修改key的值為新值newVal。到目前為止,key屬性還是沒有什么特殊之處。
數(shù)據(jù)被修改會觸發(fā)set,那cb一定是在set中被執(zhí)行。但set與cb之間好像并沒有什么聯(lián)系,所以我們來搭建一座橋梁,來構(gòu)建兩者的聯(lián)系:
let target = null;
我們在全局定義了一個(gè)target變量,它用來保存cb的值,然后在set中調(diào)用。所以,cb什么時(shí)候被保存在target中?回到出發(fā)點(diǎn),我們要調(diào)用watch函數(shù)來觀測data的key屬性,當(dāng)值被修改時(shí)執(zhí)行我們定義的回調(diào)cb,這就是cb被保存在target中的時(shí)機(jī)了:
function watch(data, key, cb) {
target = cb;
}
watch函數(shù)中target被修改了,但我要是再想調(diào)用watch函數(shù)一次,也就是說我想在data[key]被修改時(shí),執(zhí)行兩個(gè)不同的回調(diào),又或者說,我想再觀測data的其它屬性,那該怎么辦?必須得在target被再次修改前,將其值保存到別處。因?yàn)?,target是同個(gè)屬性的不同回調(diào)或不同屬性的回調(diào)所共有的。
我們有必要為key屬性建立一個(gè)私有的倉庫,來保存回調(diào)。其實(shí)defineReactive函數(shù)有一點(diǎn)特殊地方:函數(shù)內(nèi)部定義了一個(gè)val變量,然后在get和set函數(shù)都使用了val變量,這形成一個(gè)閉包,defineReactive函數(shù)的作用域是key屬性私有的,這就是天然的私有倉庫了:
function defineReactive(data, key) {
let val = data[key];
const dep = [];
Object.defineProperty(data, key, {
configurable: true,
enumerable: true,
get: function() {
target && dep.push(target);
return val;
},
set: function(newVal) {
if (newVal === val) {
return;
}
dep.forEach(fn => fn(newVal, val));
val = newVal;
}
});
}
我們在defineReactive函數(shù)內(nèi)定義了一個(gè)數(shù)組dep,其保存著每個(gè)屬性key的回調(diào)集合,也稱為依賴集合。在get函數(shù)中將依賴收集到dep中,在set函數(shù)中循環(huán)dep執(zhí)行每一個(gè)依賴??偨Y(jié)起來就是:在get中收集依賴,set中觸發(fā)依賴。
既然是在get中收集依賴,那就要想辦法在tatget被修改時(shí)候觸發(fā)get,所以我們在watch函數(shù)中讀取一下屬性key的值:
function watch(data, key, cb) {
target = cb;
data[key];
target = null;
}
接下來我們測試下代碼:

完全ok!
依賴
回想簡易版中,我們一共提到3個(gè)角色:defineReactive、dep、watch,三者其實(shí)各司其職,但我們把三者代碼耦合在了一起,不方便接下來擴(kuò)展與理解,所以我們來做一下歸類。
Watcher
觀察者,也稱為依賴,它的職責(zé)就是訂閱一個(gè)數(shù)據(jù),當(dāng)數(shù)據(jù)發(fā)生變化時(shí),做些什么:
class Watcher {
constructor(data, key, cb) {
this.vm = data;
this.key = key;
this.cb = cb;
this.value = this.get();
}
get() {
Dep.target = this;
const value = this.vm[this.key];
Dep.target = null;
return value;
}
update() {
const oldValue = this.value;
this.value = this.vm[this.key];
this.cb.call(this.vm, this.value, oldVal);
}
}
首先在構(gòu)造函數(shù)中讀取了屬性key的值,這會觸發(fā)屬性key的set,然后將自己作為依賴存入其dep數(shù)組中。當(dāng)然,在讀取屬性值之前,需要將自己賦值給橋梁Dep.target,這是get方法所做的事。最后是update方法,這是當(dāng)訂閱的數(shù)據(jù)發(fā)生變化后,需要被執(zhí)行的,其主要目的就是要執(zhí)行cb,因?yàn)閏d需要變化后的新值作為參數(shù),所以要再一次讀取屬性值。
Dep
Dep的職責(zé)就是構(gòu)建屬性key與依賴Watcher之間的聯(lián)系,其實(shí)例一定有一個(gè)獨(dú)一無二的屬于屬性key的依賴收集框:
class Dep {
constructor() {
this.subs = [];
}
addSub(sub) {
this.subs.push(sub);
}
depend() {
Dep.taget && this.addSub(Dep.target);
}
notify() {
for (let sub of subs) {
sub.update();
}
}
}
subs就是依賴收集框,當(dāng)屬性值被讀取時(shí),在depend方法中將依賴收入到框內(nèi);當(dāng)屬性值被修改時(shí),在notify方法中將依賴收集框遍歷,每一個(gè)依賴的update方法都將被執(zhí)行。
Observer
defineReactive函數(shù)只做了一件事,將數(shù)據(jù)轉(zhuǎn)換成響應(yīng)式的,我們定義一個(gè)Observer類來聚合其功能:
class Observer {
constructor(data, key) {
this.value = data;
defineReactive(data, key);
}
}
function defineReactive(data, key) {
let val = data[key];
const dep = new Dep();
Object.defineProperty(data, key, {
configurable: true,
enumerable: true,
get: function() {
dep.depend();
return val;
},
set: function(newVal) {
if (newVal === val) {
return;
}
dep.notify();
val = newVal;
}
});
}
dep不再是一個(gè)純粹的數(shù)組,而是一個(gè)Dep類的實(shí)例。get函數(shù)中的依賴收集、set函數(shù)中的依賴觸發(fā)的邏輯,分別用dep.depend、dep.update替代,這讓defineReactive函數(shù)邏輯變得變得更加清晰。但是Observer類只是在構(gòu)造函數(shù)中調(diào)用defineReactive函數(shù),沒起什么作用?這當(dāng)然都是為后面做鋪墊的!
測試一下代碼:

觀測所有屬性
到目前為止我們都只在針對一個(gè)屬性,而一個(gè)對象可能有n多個(gè)屬性,因此我們要對做下調(diào)整。
觀測一個(gè)對象的所有屬性
觀測一個(gè)屬性主要是要定義其訪問器屬性,對于我們的代碼來說,就是要執(zhí)行defineReactive函數(shù),所以對Observer類做下修改:
class Observer {
constructor(data) {
this.value = data;
if (isPlainObject(data)) {
this.walk(data);
}
}
walk(value) {
const keys = Object.keys(value);
for (let key of keys) {
defineReactive(value, key);
}
}
}
function isPlainObject(obj) {
return ({}).toString.call(obj) === '[object Object]';
}
我們在Observer類中定義一個(gè)walk方法,其作用就是遍歷對象的所有屬性,然后在構(gòu)造函數(shù)中調(diào)用。調(diào)用的前提是對象是一個(gè)純對象,即對象是通過字面量或new Object()初始化的,因?yàn)橄馎rray、Function等也都是對象。
測試一下代碼:

深度觀測
我們只要對象是可以嵌套的,即一個(gè)對象的某個(gè)屬性值也可以是對象,我們的代碼目前還做不到這一點(diǎn)。其實(shí)也很簡單,做一下遞歸遍歷的就好了:
class Observer {
constructor(data) {
this.value = data;
if (isPlainObject(data)) {
this.walk(data);
}
}
walk(value) {
const keys = Object.keys(value);
for (let key of keys) {
const val = value[key];
if (isPlainObject(val)) {
this.walk(val);
}
else {
defineReactive(value, key);
}
}
}
}
我們在walk方法中做了判斷,如果key的屬性值val是個(gè)純對象,那就調(diào)用walk方法去遍歷其屬性值。既然是深度觀測,那watcher類中的key的用法也發(fā)生了變化,比如說:'a.b.c',那我們就要兼容這種嵌套key的寫法:
class Watcher {
constructor(data, path, cb) {
this.vm = data;
this.cb = cb;
this.getter = parsePath(path);
this.value = this.get();
}
get() {
Dep.target = this;
const value = this.getter.call(this.vm);
Dep.target = null;
return value;
}
update() {
const oldValue = this.value;
this.value = this.getter.call(this.vm, this.vm);
this.cb.call(this.vm, this.value, oldValue);
}
}
function parsePath(path) {
if (/.$_/.test(path)) {
return;
}
const segments = path.split('.');
return function(obj) {
for (let segment of segments) {
obj = obj[segment]
}
return obj;
}
}
Watcher類實(shí)例新增了getter屬性,其值為parsePath函數(shù)的返回值,在parsePath函數(shù)中,返回的是一個(gè)匿名函數(shù),匿名函數(shù)接收一個(gè)參數(shù)obj,最后又將obj作為返回值返回,那么這里的重點(diǎn)是匿名函數(shù)對obj做了什么處理。
匿名函數(shù)內(nèi)只有一個(gè)for...of迭代,迭代對象為segments,segments是通過path對'.'分割得到的一個(gè)數(shù)組,比如path為'a.b.c',那么segments就為['a', 'b', 'c']。迭代內(nèi)只有一個(gè)語句,obj被賦值為obj的屬性值,這相當(dāng)于一層一層去讀取,比如說,obj初始值為:
obj = {
a: {
b: {
c: 1
}
}
}
那么最后的結(jié)果為:
obj = 1
讀取屬性值的目的就是為了收集依賴,比如我們要觀測obj.a.b.c,那么目的就達(dá)到了。 既然知道了getter是一個(gè)函數(shù),那么在get方法中執(zhí)行g(shù)etter,就可以獲取值了。
測試下代碼:

這里有個(gè)細(xì)節(jié),我們看Watcher類的get方法:
get() {
Dep.target = this;
const value = this.getter.call(this.vm);
Dep.target = null;
return value;
}
在執(zhí)行this.getter函數(shù)的時(shí)候,Dep.target的值一直都是當(dāng)前依賴,而this.getter函數(shù)中一層一層讀取屬性值,在這路徑之中的所有屬性其實(shí)都收集了當(dāng)前依賴。比如上面的例子來說,屬性'a.b.c'的依賴,被收集到obj.a、obj.a.b、obj.a.b.c的dep中,那么修改obj.a或obj.b都是會觸發(fā)當(dāng)前依賴的:

避免重復(fù)收集依賴
觀測表達(dá)式
在Vue中,$watch方法的第一個(gè)參數(shù)是可以傳函數(shù)的:
this.$watch(() => {
return this.a + this.b;
}, (val, oldVal) => {
console.log(val, oldVal);
});
這種寫法相當(dāng)于觀測一個(gè)表達(dá)式,類似與Vue中computed,依賴會被收集到屬性a與屬性b的dep中,無論修改其中任一,只要表達(dá)式的值發(fā)生變化,依賴都將會觸發(fā)。
為了兼容函數(shù)的傳入,我們稍微修改下Watcher類:
class Watcher {
constructor(data, pathOrFn, cb) {
this.vm = data;
this.cb = cb;
this.getter = typeof pathOrFn === 'function' ? pathOrFn : parsePath(pathOrFn);
this.value = this.get();
}
...
update() {
const oldValue = this.value;
this.value = this.get();
this.cb.call(this.vm, this.value, oldValue);
}
}
對于第二個(gè)參數(shù)pathOrFn,我們優(yōu)先判斷其本身是否已經(jīng)是函數(shù),是則直接賦值給this.getter,否則調(diào)用parsePath函數(shù)解析。在update方法中,再次調(diào)用了get方法來獲取被修改后的值。
測試下代碼:

結(jié)果好像有點(diǎn)不對?輸出了1949次!而且還在增加之中,一定是某個(gè)陷入無限循環(huán)了。仔細(xì)回看我們修改的點(diǎn),在update方法中,我們再次調(diào)用了get方法,這又會觸發(fā)一次依賴的收集。然后我們在Dep類的notify方法中遍歷依賴集合,每次觸發(fā)依賴都會導(dǎo)致依賴的再次收集,這就是個(gè)無限循環(huán)了!
發(fā)現(xiàn)了問題,就來解決問題。我們要對依賴做唯一性校驗(yàn):
let uid = 1;
class Watcher {
constructor(data, pathOrFn) {
this.id = uid++;
...
}
}
class Dep() {
construct() {
this.subs = [];
this.subIds = new Set();
}
...
addSub(sub) {
const id = sub.id;
if (!this.subIds.has(id)) {
this.subs.push(sub);
this.subIds.add(id);
}
}
...
}
既然要做唯一性校驗(yàn),我們給Watcher類實(shí)例增加了獨(dú)一無二的id。在Dep類中,我們給構(gòu)造函數(shù)里增加了屬性subIds,其初始值為空Set,作用是存儲依賴的id。然后在addSub方法中,在將依賴添加到subs之前,先判斷這個(gè)依賴的id是否已經(jīng)存在。
測試下代碼:

只輸出了一次,完全ok。
在Vue中的意義
防止依賴的重復(fù)收集,除了防止上面提到的陷入無限循環(huán),在Vue中還有更重要的意義,比如一下模板:
<template>
<div>
<p>{{ a }}</p>
<p>{{ a }}</p>
<p>{{ a }}</p>
</div>
</template>
在Vue中,除了watch選項(xiàng)的依賴,還有一個(gè)特殊依賴叫渲染函數(shù)的依賴,其作用就是當(dāng)模板中的變量發(fā)生變化時(shí),更新VNode,重新生成DOM。在我們上面定義的模板中,一共使用a變量3次,當(dāng)a變量被修改,如果沒有防止重復(fù)依賴的收集,渲染函數(shù)就會被執(zhí)行3次!這是完全必要的!并且3次只是個(gè)例子,實(shí)際可能會更多!
以上就是本文的全部內(nèi)容,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
Vue build過程取消console debugger控制臺信息輸出方法詳解
這篇文章主要為大家介紹了Vue build過程取消console debugger控制臺信息輸出方法詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-09-09
vue3+typescript實(shí)現(xiàn)圖片懶加載插件
這篇文章主要介紹了vue3+typescript實(shí)現(xiàn)圖片懶加載插件,幫助大家更好的理解和使用vue,感興趣的朋友可以了解下2020-10-10
Vue項(xiàng)目中使用addRoutes出現(xiàn)問題的解決方法
大家應(yīng)該都知道可以通過vue-router官方提供的一個(gè)api-->addRoutes可以實(shí)現(xiàn)路由添加的功能,事實(shí)上就也就實(shí)現(xiàn)了用戶權(quán)限,這篇文章主要給大家介紹了關(guān)于Vue項(xiàng)目中使用addRoutes出現(xiàn)問題的解決方法,需要的朋友可以參考下2021-08-08
vue中使用AJAX實(shí)現(xiàn)讀取來自XML文件的信息
這篇文章主要為大家詳細(xì)介紹了vue中如何使用AJAX實(shí)現(xiàn)讀取來自XML文件的信息,文中的示例代碼講解詳細(xì),具有一定的借鑒價(jià)值,需要的小伙伴可以參考下2023-12-12
Element-ui Drawer抽屜按需引入基礎(chǔ)使用
這篇文章主要為大家介紹了Element-ui Drawer抽屜按需引入基礎(chǔ)使用,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-07-07
Vue?Router動(dòng)態(tài)路由實(shí)現(xiàn)實(shí)現(xiàn)更靈活的頁面交互
Vue?Router是Vue.js官方的路由管理器,用于構(gòu)建SPA(單頁應(yīng)用程序),本文將深入探討Vue?Router的動(dòng)態(tài)路由功能,希望可以幫助大家更好地理解和應(yīng)用Vue.js框架2024-02-02
ant design vue嵌套表格及表格內(nèi)部編輯的用法說明
這篇文章主要介紹了ant design vue嵌套表格及表格內(nèi)部編輯的用法說明,具有很好的參考價(jià)值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-10-10

