Vue 原始碼解析(例項化前) - 響應式資料的實現原理

熱情的劉大爺發表於2019-01-14

前言

上一篇文章,大概的講解了Vue例項化前的一些配置,如果沒有看到上一篇,通道在這裡:Vue 原始碼解析 - 例項化 Vue 前(一)

在上一篇的結尾,我說這一篇後著重講一下 defineReactive 這個方法,這個方法,其實就是大家可以在外面看見一些文章對 vue 實現響應式資料原理的過程。

在這裡,根據原始碼,我決定在給大家講一遍,看看和大家平時自己看的,有沒有區別,如果有遺漏的點,歡迎評論

正文

先來一段 defineReactive 的原始碼:

//在Object上定義反應屬性。
function defineReactive (
  obj,
  key,
  val,
  customSetter,
  shallow
) {
  var dep = new Dep();

  var property = Object.getOwnPropertyDescriptor(obj, key);
  if (property && property.configurable === false) {
    return
  }
  var getter = property && property.get;
  if (!getter && arguments.length === 2) {
    val = obj[key];
  }
  var setter = property && property.set;

  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 (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter();
      }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      childOb = !shallow && observe(newVal);
      dep.notify();
    }
  });
}
複製程式碼

在講解這段原始碼之前,我想先在開始講一下 Object 的兩個方法 Object.defineProperty() Object.getOwnPropertyDescriptor()

雖然很多前端的大佬知道它的作用,但是我相信還是有一些朋友是不認識的,我希望我寫的文章,不只是傳達vue內部實現的一些精神,更能幫助一些小白去了解一些原生的api。


defineProperty

在 MDN 上的解釋是:

Object.defineProperty() 方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性, 並返回這個物件。
複製程式碼

這裡,其實就是用來實現響應式資料的核心之一,主要做的事情就是資料的更新, Object.defineProperty() 最多接收三個引數:obj , prop , descriptor

obj

要在其上定義屬性的物件。
複製程式碼

prop

要定義或修改的屬性的名稱。
複製程式碼

descriptor

將被定義或修改的屬性描述符。
複製程式碼

返回值

被傳遞給函式的物件。
複製程式碼

在這裡要注意一點:在ES6中,由於 Symbol型別的特殊性,用Symbol型別的值來做物件的key與常規的定義或修改不同,而Object.defineProperty 是定義key為Symbol的屬性的方法之一。

物件裡目前存在的屬性描述符有兩種主要形式:資料描述符存取描述符資料描述符是一個具有值的屬性,該值可能是可寫的,也可能不是可寫的。存取描述符是由getter-setter函式對描述的屬性。描述符必須是這兩種形式之一;不能同時是兩者。

資料描述符和存取描述符均具有以下可選鍵值:

configurable

當且僅當該屬性的 configurable 為 true 時,該屬性描述符才能夠被改變,同時該屬性也能從對應的物件上被刪除。

預設值: false
複製程式碼

enumerable

當且僅當該屬性的 enumerable 為 true 時,該屬性才能夠出現在物件的列舉屬性中。

預設為 false。
複製程式碼

資料描述符同時具有以下可選鍵值:

value

該屬性對應的值。可以是任何有效的 JavaScript 值(數值,物件,函式等)。

預設為 undefined。
複製程式碼

writable

當且僅當該屬性的 writable 為 true 時,value 才能被賦值運算子改變。

預設為 false。
複製程式碼

存取描述符同時具有以下可選鍵值:

get

一個給屬性提供 getter 的方法,如果沒有 getter 則為 undefined。當訪問該屬性時,該方法會被執行,方法執行時沒有引數傳入,但是會傳入this物件(由於繼承關係,這裡的this並不一定是定義該屬性的物件)。

預設為 undefined。
複製程式碼

set

一個給屬性提供 setter 的方法,如果沒有 setter 則為 undefined。當屬性值修改時,觸發執行該方法。該方法將接受唯一引數,即該屬性新的引數值。

預設為 undefined。
複製程式碼

Object.getOwnPropertyDescriptor()

obj

需要查詢的目標物件
複製程式碼

prop

目標物件內屬性名稱(String型別)
複製程式碼

descriptor

將被定義或修改的屬性描述符。
複製程式碼

返回值

返回值其實就是 Object.defineProperty() 中的那六個在 descriptor
物件中可設定的屬性,這裡就不廢話浪費篇幅了,大家看一眼上面就好
複製程式碼

defineReactive 的引數我就不一一列舉的來講了,大概從引數名也可以知道大概的意思,具體講函式內容的時候,在細講。


Dep

var dep = new Dep();
複製程式碼

在一進入到 defineReactive 這個函式時,就例項化了一個Dep的建構函式,並把它指向了一個名為dep的變數,下面,我們來看看Dep這個建構函式都做了什麼:

var uid = 0;

var Dep = function Dep () {
  this.id = uid++;
  this.subs = [];
};

Dep.prototype.addSub = function addSub (sub) {
  this.subs.push(sub);
};

Dep.prototype.removeSub = function removeSub (sub) {
  remove(this.subs, sub);
};

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

Dep.prototype.notify = function notify () {
  var subs = this.subs.slice();
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }
};

Dep.target = null;
複製程式碼

在例項化 Dep 之前,給 Dep 新增了一個 target 的屬性,預設值為 null;

Dep在例項化的時候,宣告瞭一個 id 的屬性,每一次例項化Dep的id都是唯一的;

然後宣告瞭一個 subs 的空陣列, subs 要做的事情,就是收集所有的依賴;

addSub

從字面意思,大家也可以看的出來,它就是做了一個新增依賴的動作;

removeSub

其實就是移除了某一個依賴,只不過實現沒有在當前的方法裡寫,而是呼叫的一個 remove 的方法:

function remove (arr, item) {
  if (arr.length) {
    var index = arr.indexOf(item);
    if (index > -1) {
      return arr.splice(index, 1)
    }
  }
}
複製程式碼

這個方法,就是從陣列中,移除了某一項;

depend

新增一個依賴陣列項;

notify

通知每一個陣列項,更新每一個方法;

這裡 subs 呼叫了 slice 方法,官方註釋是 “ stabilize the subscriber list first ” 字面意思是 “首先穩定訂戶列表”,這裡我不是很清楚,如果知道的大佬,還請指點一下
複製程式碼

Dep.target 在 Vue 例項化之前一直都是 null ,只有在 Vue 例項化後,例項化了一個 Watcher 的建構函式,在呼叫 Watcher 的 get 方法的時候,才會改變 Dep.target 不為 null ,由於 Watcher 涉及的內容也很多,所以我準備單拿出一章內容,在 Vue 例項化之後去講解,現在,我們就暫時當作 Dep.target 不為空。

現在,Dep 建構函式講解的就差不多了,我們繼續接著往下看:

var property = Object.getOwnPropertyDescriptor(obj, key);
複製程式碼

方法返回指定物件上一個自有屬性對應的屬性描述符並賦值給property;

if (property && property.configurable === false) {
    return
}
複製程式碼

我們要實現響應式資料的時候,要看當前的 object 上面是否有當前要實現響應式資料的這個屬性,如果沒有,並且 configurable 為 false,那麼就直接退出該方法。

在上面我們介紹過 configurable 這個屬性,如果它是 flase ,說明它是不允許被更改的,那麼就肯定不支援響應式資料了,那肯定是要退出該方法的。

var getter = property && property.get;

if (!getter && arguments.length === 2) {
    val = obj[key];
}
複製程式碼

獲取當前該屬性的 get 方法,如果沒有該方法,並且只有兩個引數(obj 和 key),那麼 val 就是直接從這個當前的 obj 裡面獲取。

var setter = property && property.set;
複製程式碼

獲取當前屬性的 set 方法。

var childOb = !shallow && observe(val);
複製程式碼

判斷是否要淺拷貝,如果傳的是 false ,那麼就是要進行深拷貝,這個時候,就需要把當前的值傳遞給 observe 的方法:

observe

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
}
複製程式碼

defineReactive 中,呼叫 observe 方法,只傳了一個引數,所以這裡是只有 value 一個值的,第二個值其實就是一個 boolean 值,用來判斷是否是根資料;

function isObject (obj) {
    return obj !== null && typeof obj === 'object'
}
複製程式碼

首先,要檢查當前的值是不是物件,或者說當前的值的原型是否在 VNode 上,那就直接 return 出當前方法, VNode 是一個建構函式,內容比較多,所以這一章暫時不講,接下來單獨寫一篇去講 VNode。

var hasOwnProperty = Object.prototype.hasOwnProperty;
function hasOwn (obj, key) {
  return hasOwnProperty.call(obj, key)
}
複製程式碼

這裡用來判斷物件是否具有該屬性,並且物件上的該屬性原型是否指向的是 Observer ;

如果是,說明這個值是之前存在的,那麼變數 ob 就等於當前觀察的例項;

如果不是,則是做如下判斷:

var shouldObserve = true;
function toggleObserving (value) {
    shouldObserve = value;
}
複製程式碼

shouldObserve 用來判斷是否應該觀察,預設是觀察;

var _isServer;
var isServerRendering = function () {
  if (_isServer === undefined) {
    /* istanbul ignore if */
    if (!inBrowser && !inWeex && typeof global !== 'undefined') {
      // detect presence of vue-server-renderer and avoid
      // Webpack shimming the process
      _isServer = global['process'] && global['process'].env.VUE_ENV === 'server';
    } else {
      _isServer = false;
    }
  }
  return _isServer
};
複製程式碼

是否支援服務端渲染;

Array.isArray(value)
複製程式碼

當前的值是否是陣列;

isPlainObject(value)
複製程式碼

用來判斷是否是Object;具體程式碼上一篇文章當中有描述,入口在這裡:Vue 原始碼解析 - 例項化 Vue 前(一)

Object.isExtensible(value)
複製程式碼

判斷一個物件是否是可擴充套件的

value._isVue
複製程式碼

判斷是否可以被觀察到,初始化是在 initMixin 方法裡初始化的,這裡暫時先不做太多的介紹。

這麼多判斷的總體意思,就是用來判斷,當前的值,是否是被觀察的,如果沒有,那麼就建立一個新的出來,並賦值給變數 ob;

asRootData 如果是 true,並且 ob 也存在的話,那麼就給 vmCount 加 1;

最後返回一個 ob。


接下來,開始響應式資料的核心程式碼部分了:

Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
    },
    set: function reactiveSetter (newVal) {
    }
});
複製程式碼

首先,要確保要監聽的該屬性,是可列舉、可修改的的;


get

var value = getter ? getter.call(obj) : val;
複製程式碼

先前,在前面把當前屬性的 get 方法,傳給 getter 變數,如果 getter 變數存在,那麼就把當前的 getter 的 this 指向當前的 obj 並傳給 value 變數;如果不存在,那麼就把當前方法接收到的 val 引數傳給 value 變數;

if (Dep.target) {
    dep.depend();
    if (childOb) {
      childOb.dep.depend();
      if (Array.isArray(value)) {
        dependArray(value);
      }
    }
}
return value
複製程式碼

每次在 get 的時候,判斷 Dep.target 是否為空,如果不為空,那麼就去新增一個依賴,呼叫例項物件 dep 的 depend 方法,這裡在 Watcher 的建構函式裡,還做了一些特殊處理,等到講解 Watcher 的時候,我會把這裡在帶過去一起講一下。

反正大家記著,在 get 的時候新增了一個依賴就好。

如果是存在子級的話,並且給子級新增一個依賴:

function dependArray (value) {
  for (var e = (void 0), i = 0, l = value.length; i < l; i++) {
    e = value[i];
    e && e.__ob__ && e.__ob__.dep.depend();
    if (Array.isArray(e)) {
      dependArray(e);
    }
  }
}
複製程式碼

如果當前的值是陣列,那麼我們就要給這個陣列新增一個監聽,因為本身 Array 是不支援 defineProperty 方法的;

所以在這裡,作者給所有的陣列項,新增了一個依賴,這樣每一個陣列選項,都有了自己的監聽,當它被改變的時候,會根據監聽的依賴,去做對應的更新。


set

var value = getter ? getter.call(obj) : val;
複製程式碼

這裡,和 get 時候一樣,獲取當前的一個值,如果不存在,就返回函式接收到的值;

if (newVal === value || (newVal !== newVal && value !== value)) {
    return
}
if (process.env.NODE_ENV !== 'production' && customSetter) {
    customSetter();
}
if (setter) {
    setter.call(obj, newVal);
} else {
    val = newVal;
}
childOb = !shallow && observe(newVal);
dep.notify();
複製程式碼

如果當前值和新的值一樣,那就說明沒有什麼變化,這樣就不需要改,直接 return 出去;

如果是在開發環境下,並且存在 customSetter 方法,那麼就呼叫它;

如果當前的屬性存在 set 方法,那麼就把 set 方法指向 obj,並把 newVal 傳過去;

如果不存在,那麼就直接把值給覆蓋掉;

如果不是淺拷貝的話,那麼就把當前的新值傳給 observe 方法,去檢查是否已經被觀察,並且把新的值覆蓋到 childOb 上;

最後呼叫 dep 的 notify 方法去通知所有的依賴進行值的更新。


概括

到這裡,基本上 vue 實現的響應式資料的原理,拋析的就差不多了,但是整體涉及的東西比較多,可能看起來會比較費勁一些,這裡我概括一下:

  • 每次在監聽某一個屬性時,要先例項化一個佇列 Dep,負責監聽依賴和通知依賴;
  • 確認當前要監聽的屬性是否存在,並且是可修改的;
  • 如果沒有接收到引數 val,並且引數只接收到2個,那麼就直接把 val 設定成當前的屬性的值,不存在就是 undefined;
  • 判斷當前要監聽的值是需要深拷貝還是淺拷貝,如果是深拷貝,那麼就去檢查當前的值是否被監聽,沒有被監聽,那麼就去例項化一個監聽物件;
  • 在呼叫 get 方法,獲取到當前屬性的值,不存在就接收呼叫該方法時接收到的值;
  • 檢查當前的佇列,要對哪一個 obj 進行變更,如果存在檢查的目標的話,那就新增一個依賴;
  • 如果存在觀察例項的話,在去檢查一下當前的值是否是陣列,如果是陣列的話,那麼就做一個陣列項的依賴檢查;
  • 在更新值的時候,發現當前值和要改變的值是相同的,那麼就不進行任何操作;
  • 如果是開發環境下,還會執行一個回撥,該回撥實在值改變前但是符合改變條件時執行的;
  • 如果當前的屬性存在 setter 方法,那麼就把當前的值傳給 setter 方法,並讓當前的 setter 方法的 this 指向當前的 obj,如果不存在,直接用新值覆蓋舊值就好;
  • 如果是深拷貝的話,就去檢查遍當前的值是否被觀察,如果沒有被觀察,就進行觀察;(上面大家可能有發現,它已經進行了一次觀察,為什麼還要執行呢?因為上面是在初始化的時候去觀察的,當該值改變以後,比如型別改變,是要進行重新觀察,確保如果改變為類似陣列的值的時候,還可以進行雙向繫結)
  • 最後,通知所有新增對該屬性進行依賴的位置。

結束語

對應 vue 的響應式資料,到這裡就總結完了,未來在例項化 vue 物件的地方,會涉及到很多有關響應式資料的地方,所以建議大家好好看一下這裡。

對於原始碼,我們瞭解了作者的思想就好,我們不一定要完全按照作者的寫法來寫,我們要學習的,是他的程式設計思想,而不是他的寫法,其實好多地方我覺得寫的不是很合適,但是我不是很明白為什麼要這麼做,也許是我水平還比較低,沒有涉及到,接下來我會對這些疑問點,進行總結,去研究為什麼要這麼做,如果不合適,我會在 github 中新增 issues 到時候會把連結丟擲來,以供大家參考學習。

最後還是老話,點贊,點關注,有問題了,評論區開噴就好

相關文章