Vue3.0 放棄 Object.defineProperty 你瞭解多少?

想必大家都知道Vue3。0 把資料物件偵測的API 從Object。defineProperty 換成 proxy,原因是使用了Proxy 初始效能更好。

因為proxy是真正的在物件層面做了proxy不會去改變物件的結構,Object。defineProperty需要轉化資料物件屬性為getter、setter 這是比較昂貴的操作。

如果你想從原始碼去了解 Object。defineProperty 有多麼糟糕那我們開始。。。

資料偵測:

function observe(value, asRootData) { if (!isObject(value) || value instanceof VNode) { return } var ob; if (hasOwn(value, ‘__ob__’) && value。__ob__ instanceof Observer) { ob = value。__ob__; } else if ( shouldObserve && !isServerRendering() && (Array。isArray(value) || isPlainObject(value)) && Object。isExtensible(value) && !value。_isVue ) { ob = new Observer(value); } if (asRootData && ob) { ob。vmCount++; } return ob}

observe工廠函式是整個資料響應式系統的入口,它會做幾個事情:

value 如果不是物件 或者是VNode的例項直接終止函式的執行。

value如有“__

ob

__”屬性,或者 value。__ob__ 的值是 Observer例項直接把 value。__ob__的值作為observe返回值。(注:當一個數據物件被觀測之後將會在該物件上定義__ob__屬性)

檢測value的合法性。(不能是Vue例項、必須是陣列物件或者純物件、必須為可配置。。。)

建立Observer例項,將value作為引數傳遞。

Observer建構函式

var Observer = function Observer(value) { this。value = value; this。dep = new Dep(); this。vmCount = 0; def(value, ‘__ob__’, this); if (Array。isArray(value)) { if (hasProto) { protoAugment(value, arrayMethods); } else { copyAugment(value, arrayMethods, arrayKeys); } this。observeArray(value); } else { this。walk(value); }};

將資料物件轉換成響應式資料是Observer建構函式任務之一,一會我們會重點講this。walk這個方法。現在先來認識下 Dep 。

this。dep = new Dep();

很多人會把 Dep 理解為訂閱者建構函式,但訂閱者本身就是一個很抽象的概念,理解上難免會增加心智負擔。 我更願意把Dep 理解成一個“容器” 這個“容器”中儲存的就是觀察者。 什麼是觀察者一會我們來講。先說下這裡的this。dep 就是建立了一個“容器” 這個“容器”中儲存的就是某個物件或者陣列依賴的觀察者。

現在進入到walk中看看:

Observer。prototype。walk = function walk(obj) { var keys = Object。keys(obj); for (var i = 0; i < keys。length; i++) { defineReactive$$1(obj, keys[i]); }};

walk 方法很簡單,使用 Object。keys(obj) 獲取物件所有可列舉的屬性,然後透過 for 迴圈遍歷這些屬性,同時為每個屬性呼叫了 defineReactive$$1 函式。

function defineReactive$$1( obj, key, val, customSetter, shallow) { var dep = new Dep(); var property = Object。getOwnPropertyDescriptor(obj, key); if (property && property。configurable === false) { return } // cater for pre-defined getter/setters var getter = property && property。get; var setter = property && property。set; if ((!getter || setter) && arguments。length === 2) { val = obj[key]; } var childOb = !shallow && observe(val); Object。defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter() { var value = getter ? getter。call(obj) : val; if (Dep。target) { dep。depend(); if (childOb) { childOb。dep。depend(); if (Array。isArray(value)) { dependArray(value); } } } return value }, set: function reactiveSetter(newVal) { var value = getter ? getter。call(obj) : val; /* eslint-disable no-self-compare */ if (newVal === value || (newVal !== newVal && value !== value)) { return } /* eslint-enable no-self-compare */ if (customSetter) { customSetter(); } // #7981: for accessor properties without setter if (getter && !setter) { return } if (setter) { setter。call(obj, newVal); } else { val = newVal; } childOb = !shallow && observe(newVal); dep。notify(); } });}

defineReactive$$1 函式核心就是將資料物件的資料屬性轉換為訪問器屬性,但其中做了很多處理邊界條件的工作這裡我們將不會做過多的闡述。defineReactive 接收五個引數,但是在 walk 方法中呼叫 defineReactive $$1函式時只傳遞了前兩個引數,資料物件和屬性的鍵名。

重要程式碼:

var dep = new Dep(); //1var childOb = !shallow && observe(val); //2Object。defineProperty(obj, key, { //3 enumerable: true, configurable: true, get: function reactiveGetter() { var value = getter ? getter。call(obj) : val; if (Dep。target) { dep。depend(); 。。。 } return value }, set: function reactiveSetter(newVal) { var value = getter ? getter。call(obj) : val; 。。。 if (setter) { setter。call(obj, newVal); } else { val = newVal; } childOb = !shallow && observe(newVal); dep。notify(); }});

需要注意的是:每次呼叫 defineReactive$$1 都會建立一個 Dep 例項即之前我們講過的“容器”,

這裡透過閉包的方法讓資料物件中每個屬性都會對應有一個 "容器" , 這個"容器"會在依賴收集的過程中儲存對應的Watcher物件。

shallow 這個屬性未傳值,它的作用是當未傳值或者傳遞false 那是需要進行深度觀測。接下來會在遞迴呼叫observe 檢測 val 的資料型別是不是引用型別,如果是在把 val 物件中的欄位加入到響應式系統當中,用重新呼叫walk 、defineReactive$$1函式。

看到這裡大家就知道為什麼說Object。defineProperty很糟糕了吧? 當專案複雜度上升、資料物件結構過於複雜、初始效能將會變得越來越差,而Proxy 能完美地規避掉這些東西。如果你只是看標題進來這裡應該能解決你的疑惑了。

但是現在我想跟大家講講關於Dep “容器”的事情。

get: function reactiveGetter() { var value = getter ? getter。call(obj) : val; if (Dep。target) { dep。depend(); 。。。 } return value}

這是我們給資料物件屬性設定的getter, 首先判斷Dep。target是否存在,那麼Dep。target 是什麼呢? 直言不諱的講,Dep。target中儲存的值就是要被收集的依賴(觀察者)。所以如果 Dep。target 存在的話說明有依賴需要被收集,這個時候才需要執行 if 語句塊內的程式碼,如果 Dep。target 不存在就意味著沒有需要被收集的依賴,所以當然就不需要執行 if 語句塊內的程式碼了。

在 if 語句塊內第一句執行的程式碼就是:dep。depend(),它會將依賴收集到 dep 這個“容器”中,這裡的 dep 物件就是屬性的 getter/setter 透過閉包關聯它自身的那個“容器”。

Dep建構函式程式碼很簡單大家自行去閱讀,我們重點放在Dep。target 上。

Dep。target = null;

Dep。target初始值為null, 那什麼時候賦值呢?那我們需要從模板編譯元件掛載說起,模板編譯專欄系列文章,接下來重點放在元件掛載。

function mountComponent(vm, el, hydrating) { vm。$el = el; 。。。 callHook(vm, ‘beforeMount’); var updateComponent = function() { vm。_update(vm。_render(), hydrating); } 。。。 new Watcher(vm, updateComponent, noop, { before: function before() { if (vm。_isMounted && !vm。_isDestroyed) { callHook(vm, ‘beforeUpdate’); } } }, true /* isRenderWatcher */ ); 。。。}

mountComponent就是元件掛載的核心函數了,其內部定義了 updateComponent 函式,該函式的作用是以 vm。_render() 函式的返回值作為第一個引數呼叫 vm。_update() 函式。沒有看過專欄的朋友可能還不瞭解 vm。_render 函式和 vm。_update 函式的作用,但可以先簡單地認為:

vm。_render 函式的作用是呼叫 vm。$options。render 函式並返回生成的虛擬節點(VNode)

vm。_update 函式的作用是把 vm。_render 函式生成的虛擬節點渲染成真正的 DOM

再往下,我們將遇到建立觀察者(Watcher)例項的程式碼:

new Watcher(vm, updateComponent, noop, { before: function before() { if (vm。_isMounted && !vm。_isDestroyed) { callHook(vm, ‘beforeUpdate’); } }}, true /* isRenderWatcher */ );

簡單說下Watcher的作用,他在資料響應式系統中扮演的是對錶達式的求值的角色,觸發資料屬性的 get 攔截器函式(做到這一步不困難想想我們之前講到的 render 函式),從而收集到了依賴,當資料變化時能夠觸發響應。

在上面的程式碼中 Watcher 觀察者例項將對 updateComponent 函式求值,updateComponent 函式執行會間接觸發渲染函式(vm。$options。render)的執行,而渲染函式的執行則會觸發資料屬性的 get 攔截器函式,從而將依賴(觀察者)收集,當資料變化時將重新執行 updateComponent 函式,這就完成了重新渲染。同時我們把上面程式碼中例項化的觀察者物件稱為 渲染函式的觀察者。

Watcher建構函式

var Watcher = function Watcher(vm, expOrFn, cb, options, isRenderWatcher) { 。。。 if (options) { this。deep = !!options。deep; this。user = !!options。user; this。lazy = !!options。lazy; this。sync = !!options。sync; this。before = options。before; } else { this。deep = this。user = this。lazy = this。sync = false; } this。cb = cb; this。id = ++uid$1; // uid for batching this。active = true; this。dirty = this。lazy; // for lazy watchers this。deps = []; this。newDeps = []; this。depIds = new _Set(); this。newDepIds = new _Set(); this。expression = expOrFn。toString(); 。。。 this。value = this。lazy ? undefined : this。get();};

我們目光只放在主線程式碼上,建立 Watcher 例項時可以傳遞五個引數,分別是:元件例項物件vm、要觀察的表示式 expOrFn、當被觀察的表示式的值變化時的回撥函式 cb、一些傳遞給當前觀察者物件的選項 options 以及一個布林值 isRenderWatcher 用來標識該觀察者例項是否是渲染函式的觀察者。

這裡特別注意的是expOrFn 引數, 我們在建立例項的時候對應傳遞給它的是updateComponent 函式,剛剛我們講到 Watcher 的原理是透過對“被觀測目標”的求值,觸發資料屬性的get 攔截器函式從而收集依賴, 那當資料變化的時候呢? 資料一旦是發生變化會執行cb回撥,還會重新對“被觀察目標”求值,也就是說 updateComponent 也會被呼叫,在此過程中生成新的VNode。 說到這裡大家或許又產生了一個疑問:“再次執行updateComponent函式難道不會導致再次觸發資料屬性的get攔截器函式導致重複收集依賴嗎?” 不用擔心,因為 Vue 已經實現了避免收集重複依賴的處理,稍後會講到的。

if (options) { this。deep = !!options。deep; this。user = !!options。user; this。lazy = !!options。lazy; this。sync = !!options。sync; this。before = options。before;} else { this。deep = this。user = this。lazy = this。sync = false;}

重點講講一會用到的lazy,options。lazy 用來標識當前觀察者例項物件是否是計算屬性的觀察者。 在低版本程式碼中它還有另外一個時髦的名字 “options。computed” 。計算屬性的觀察者並不是指一個觀察某個計算屬性變化的觀察者,而是指 Vue 內部在實現計算屬性這個功能時為計算屬性建立的觀察者。後面用到了在詳細解釋。

this。cb = cbthis。id = ++uid // uid for batchingthis。active = truethis。dirty = this。computed // for computed watchers

this。cb屬性,它的值為cb回撥函式。

this。id屬性,它是觀察者例項物件的唯一標識。

this。active屬性,它標識著該觀察者例項物件是否是啟用狀態,預設值為true代表啟用。

this。dirty屬性,該屬性的值與this。lazy屬性的值相同,也就是說只有計算屬性的觀察者例項物件的this。dirty屬性的值才會為真,因為計算屬性是惰性求值。

this。deps = []this。newDeps = []this。depIds = new Set()this。newDepIds = new Set()

重點關注: this。newDeps 與 this。newDepIds 它們兩就是用來避免收集重複依賴,且移除無用依賴。

this。value = this。lazy ? undefined : this。get();

最後一句程式碼意思是除計算屬性的觀察者之外的所有觀察者例項物件都將執行 this。get() 方法。

依賴收集的過程

this。get()它的作用就是

求值

。求值的目的有兩個,第一個是能夠觸發訪問器屬性的get攔截器函式,第二個是能夠獲得被觀察目標的值。而且能夠觸發訪問器屬性的get攔截器函式是依賴被收集的關鍵,下面我們具體檢視一下this。get()方法的內容:

Watcher。prototype。get = function get() { pushTarget(this); var value; var vm = this。vm; try { value = this。getter。call(vm, vm); } catch (e) { if (this。user) { handleError(e, vm, (“getter for watcher \”“ + (this。expression) + ”\“”)); } else { throw e } } finally { // “touch” every property so they are all tracked as // dependencies for deep watching if (this。deep) { traverse(value); } popTarget(); this。cleanupDeps(); } return value};

this。get()方法呼叫了pushTarget(this) 函式,並將當前觀察者例項物件作為引數傳遞。

Dep。target = null;var targetStack = [];function pushTarget(target) { targetStack。push(target); Dep。target = target;}function popTarget() { targetStack。pop(); Dep。target = targetStack[targetStack。length - 1];}

到目前為止已經解決了之前的疑惑。

Dep.target 是什麼呢? 直言不諱的講,Dep.target中儲存的值就是要被收集的依賴(觀察者)。

總結下:

Dep。target屬性初始值為null,pushTarget函式的作用就是用來為Dep。target屬性賦值的,pushTarget函式會將接收到的引數賦值給Dep。target屬性,傳遞給pushTarget函式的引數就是呼叫該函式的觀察者物件,所以Dep。target儲存著一個觀察者物件,其實這個觀察者物件就是即將要收集的目標。

接下來再回到get方法中:

Watcher。prototype。get = function get() { pushTarget(this); var value; var vm = this。vm; try { value = this。getter。call(vm, vm); } catch (e) { if (this。user) { handleError(e, vm, (“getter for watcher \”“ + (this。expression) + ”\“”)); } else { throw e } } finally { // “touch” every property so they are all tracked as // dependencies for deep watching if (this。deep) { traverse(value); } popTarget(); this。cleanupDeps(); } return value};

在呼叫pushTarget函式之後,定義了value變數,該變數的值為this。getter函式的返回值,你先簡單認定this。getter的值就是我們剛剛傳過來的updateComponent 函式,這個函式的執行就意味著對被觀察目標的求值,將得到的值賦值給value變數。而且我們可以看到this。get方法的最後將value返回。

this。value = this。lazy ? undefined : this。get();

在Watcher建構函式中我們看到被觀察目標的值,最終都會儲存在例項的value屬性上。this。get()方法除了對被觀察目標求值之外,大家別忘了正是因為對被觀察目標的求值才得以觸發資料屬性的get攔截器函式,還是以渲染函式的觀察者為例,假設我們有如下模板:

{{message}}

這段模板被編譯將生成如下渲染函式:

function anonymous () { with (this) { return _c(‘div’, { attrs:{ “id”: “app” } }, [_v(_s(message))] ) }}

這個過程在專欄的編譯器中都講過不再重述,可以發現渲染函式的執行會讀取資料屬性message 的值,這將會觸發message 屬性的 get 攔截器函式。

執行如下程式碼defineReactive$$1部分原始碼:

Object。defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter() { var value = getter ? getter。call(obj) : val; if (Dep。target) { dep。depend(); 。。。 } return value }, set: function reactiveSetter(newVal) { var value = getter ? getter。call(obj) : val; 。。。 if (setter) { setter。call(obj, newVal); } else { val = newVal; } childOb = !shallow && observe(newVal); dep。notify(); }});

由於渲染函式讀取了 message 屬性的值,所以 message 屬性的 get 攔截器函式將被執行,執行過程中首先判斷了 Dep。target 是否存在,如果存在則呼叫dep。depend方法收集依賴。那麼 Dep。target 是否存在呢?答案是存在,這就是為什麼 pushTarget 函式要在呼叫this。getter 函式之前被呼叫的原因。既然 dep。depend 方法被執行,那麼我們就找到dep。depend方法。

Dep。prototype。depend = function depend() { if (Dep。target) { Dep。target。addDep(this); }};

順藤摸瓜去找下addDep的原始碼:

Watcher。prototype。addDep = function addDep(dep) { var id = dep。id; if (!this。newDepIds。has(id)) { this。newDepIds。add(id); this。newDeps。push(dep); if (!this。depIds。has(id)) { dep。addSub(this); } }};

可以看到addDep方法接收一個引數,這個引數是一個Dep物件,在 addDep 方法內部首先定義了常量id,它的值是Dep例項物件的唯一 id 值。接著是一段 if 語句塊,該 if 語句塊的程式碼很關鍵,因為它的作用就是用來

避免收集重複依賴

的,既然是用來避免收集重複的依賴,那麼就不得不用到我們前面提到過的兩組屬性,即newDepIds、newDeps以及depIds、deps。

什麼叫收集重複的依賴?舉個例子有模板如下:

{{message}}{{message}}

這段模板被編譯將生成如下渲染函式:

function anonymous () { with (this) { return _c(‘div’, { attrs:{ “id”: “app” } }, [_v(_s(message)+_s(message))] ) }}

渲染函式的執行將讀取兩次資料物件 message 屬性的值,這必然會觸發兩次 message 屬性的 get 攔截器函式,同樣的道理,dep。depend 也將被觸發兩次,最後導致dep。addSub 方法被執行了兩次,且引數一模一樣,這樣就產生了同一個觀察者被收集多次的問題。

if (!this。newDepIds。has(id)) { this。newDepIds。add(id); this。newDeps。push(dep); 。。。}

在 addDep 內部並不是直接呼叫 dep。addSub 收集觀察者,而是先根據 dep。id屬性檢測該Dep例項物件是否已經存在於 newDepIds 中,如果存在那麼說明已經收集過依賴了,什麼都不會做。如果不存在才會繼續執行if語句塊的程式碼,同時將 dep。id 屬性和 Dep 例項物件本身分別新增到 newDepIds 和 newDeps 屬性中,這樣無論一個數據屬性被讀取了多少次,對於同一個觀察者它只會收集一次。

撥出一口長氣,文章到這結束了。。。。現在你是否有點明白用Object。defineProperty 構建資料響應式系統有多糟糕了。