天天看點

[JavaScript][Vue]資料雙向綁定資料雙向綁定參考資料

  • 資料雙向綁定
    • 觀察者模式
    • 釋出-訂閱模式
      • 兩種模式差異
    • 髒值檢測
    • 資料劫持
      • 了解對象
          • 資料屬性
          • 通路器屬性
    • vue的做法:資料劫持+釋出訂閱
      • Vue資料雙向綁定方案
        • 回顧Vue生命周期
        • 管理訂閱者
        • 監聽資料變化
        • 解析器Compile初始化訂閱者
    • 總結
  • 參考資料

資料雙向綁定

[JavaScript][Vue]資料雙向綁定資料雙向綁定參考資料

MVVM(model-view-viewmodel)類架構的一般特色就是對象屬性和UI資料的雙向綁定。方法一般有釋出訂閱模式(BackBone.js)、髒值檢測(Angular.js)、資料劫持(Vue.js)

觀察者模式

有人認為觀察者模式 == 釋出訂閱模式,其實不然。

來自維基百科的定義:

觀察者模式 在軟體設計中是一個對象(Subject),維護一個依賴清單(Observers),當任何狀态發生改變(event)自動通知(notify)它們。
[JavaScript][Vue]資料雙向綁定資料雙向綁定參考資料
/*
Subject維護了一個依賴清單,event類型作為依賴對象的key,每種event對應一個回調函數清單,每當狀态改變時,調用對應的回調函數們
Subject的原型上有下列方法:
添附:新增觀察者到依賴清單裡
解附:将已存在的觀察者從依賴清單中移除
通知:利用觀察者所提供的回調函數來通知此目标已經産生變化
*/

// 維護依賴清單
function Subject() {
    this.handlers = {};
}

Subject.prototype = {
    // 添附
    attach: function (eventType, callback) {
        var self = this;
        var keys = Object.keys(self.handlers);
        if (!keys.find((key) => key === eventType)) {
            self.handlers[eventType] = [];
        }
        self.handlers[eventType].push(callback);
    },
    // 解附 detach
    detach: () => {},
    // 通知
    notify: function (eventType) {
        var self = this;
        var args = Array.prototype.slice.call(arguments, );
        // 通知 notify 直接調用回調函數,一一通知觀察者
        self.handlers[eventType].forEach((handler) => {
            handler.apply(self, args);
        });
    }
};
           

用觀察者模式實作一個最簡單的input與span的雙向綁定:

function updateSpan(value) {
    // 更新資料
    spantext.innerHTML = value;
    // 釋出modelUpt事件,通知觀察者
    subject.noify('modelUpt', value);
}
// 訂閱viewUpt事件
subject.attach('viewUpt', function(value){
    updateSpan(value);
});

// 訂閱modelUpt事件
subject.attach('modelUpt', function(value) {
    // 更新視圖中綁定的值
    inputEle.value = value;
});

// 視圖資料修改,釋出viewUpt事件,通知觀察者
inputEle.addEventListener('keyup', function() {
    subject.notify('viewUpt', inputEle.value);
});
           

問題:

  1. 視圖資料更改時怎麼通知觀察者?

    從視圖改變資料的一般方法:表單元素的改變(input, select, checkboxes, textarea,radio等)。利用DOM的事件處理程式,為可能引發視圖資料改變的事件(change、keypress、keyup、paste)注冊處理函數,在處理函數中通知觀察者。

  2. 如何檢測對象屬性變更?
    • 髒值檢測
    • 資料劫持

釋出-訂閱模式

來自維基百科的定義

在軟體架構中,釋出-訂閱是一種消息範式,消息的發送者(稱為釋出者)不會将消息直接發送給特定的接收者(稱為訂閱者)。而是将釋出的消息分為不同的類别,無需了解哪些訂閱者(如果有的話)可能存在。同樣的,訂閱者可以表達對一個或多個類别的興趣,隻接收感興趣的消息,無需了解哪些釋出者(如果有的話)存在。

在許多釋出/訂閱系統中,釋出者釋出消息到一個中間的消息代理,然後訂閱者向該消息代理注冊訂閱,由消息代理來進行過濾。消息代理通常執行存儲轉發的功能将消息從釋出者發送到訂閱者。

釋出者和訂閱者不知道對方的存在。需要一個第三方元件,叫做資訊中介,它将訂閱者和釋出者串聯起來,它過濾和配置設定所有輸入的消息。換句話說,釋出-訂閱模式用來處理不同系統元件的資訊交流,即使這些元件不知道對方的存在。

[JavaScript][Vue]資料雙向綁定資料雙向綁定參考資料

圖檔來源: developers-club

兩種模式差異

由此可以總結出兩種模式的以下差異:

  • 觀察者模式因為Subject維護着Observers隊列,對觀察者保持着記錄,但是釋出-訂閱模式中,釋出者與訂閱者不知道對方的存在,它們通過消息代理進行通信,釋出者的消息不是直接發送給訂閱者。
  • 釋出訂閱模式的元件是松散耦合的,與觀察者模式相反
  • 觀察者模式大多數是同步的,如上述例子,通知事件時依次調用回調函數序列的函數。釋出-訂閱模式大多數是異步的(使用消息隊列)

髒值檢測

Angular.js是通過髒值檢查的方式來對比資料是否有變更,來決定是否更新視圖,最簡單的方式就是通過setInterval()定時檢測資料變動,angular隻有在指定事件被觸發時進入髒值檢測,大體如下:
 1.DOM事件,比如使用者輸入文本,點選按鈕等。(ng-click)
 2.XHR響應事件(http)
 3.浏覽器Location變更(location)
 4.Time事件(timeout, interval)
 5.執行digest()或者apply()
           

資料劫持

了解對象

資料屬性

資料屬性包含一個資料值的位置,在這個位置可以讀取和寫入值。

屬性名 描述 預設值
[[Configurable]] 能否通過delete删除屬性進而重新定義屬性,能否修改屬性的特性,能否把屬性修改為通路器屬性 true
[[Enumerable]] 能否通過for-in循環傳回屬性 true
[[Writable]] 能否修改屬性的值 true
[[Value]] 包含這個屬性的資料值。讀取屬性的時候,從這個位置讀;寫入資料值得時候,把新值儲存在這個位置 undefined

要修改屬性預設的特性,使用ES5的Object.defineProperty()方法。接收三個參數:屬性所在的對象,屬性的名字,一個描述符對象。描述符對象的屬性必須是configurable,enumerable,writable,value

var person = {};
Object.defineProperty(person, 'name', {
    writable: false,
    value: 'May',
});
console.log(person.name);// May
person.name = 'Julia'; // 嚴格模式下報錯,非嚴格模式下指派操作被忽略
console.log(person.name);// May
           
通路器屬性

通路器屬性不包含資料值,包含一對getter和setter函數。在讀取通路器屬性時會調用getter,寫入通路器屬性時,會調用set函數并傳入新的值。定義通路器屬性也用Object.defineProperty()方法。

資料劫持就是利用Object.defineProperty()來劫持對象的getter和setter操作,在資料變動時執行操作。

vue的做法:資料劫持+釋出訂閱

Vue資料雙向綁定方案

回顧Vue生命周期

[JavaScript][Vue]資料雙向綁定資料雙向綁定參考資料

beforeCreate & created

在Vue的構造函數中,對傳入的options對象調用了_init函數,其中執行了以下操作。

// new Vue 執行個體初始化
initLifecycle(vm) // 初始化生命周期,給vm對象添加了$parent $root $children,_watchers等屬性,以及一些生命周期相關的辨別 
initEvents(vm) // 初始化事件相關的屬性,會在這裡把父元件綁定在自定義标簽上的事件添加到子元件裡
initRender(vm) // 初始化渲染
callHook(vm, 'beforeCreate')// beforeCreate   執行beforeCreated的鈎子函數

initInjections(vm) // 初始化inject resolve injections before data/props
initState(vm) // 初始化資料
initProvide(vm) // 初始化provide resolve provide after data/props
callHook(vm, 'created') // created
           

initState 初始化資料,對props、methods、data、computed、watch的處理

function initState(vm) {
    vm._watchers = [];
    var opts = vm.$options;
    if (opts.props) {
      initProps(vm, opts.props);
    }
    if (opts.methods) {
      initMethods(vm, opts.methods);
    }
    if (opts.data) {
      initData(vm);
    } else {
      observe(vm._data = {}, true /* asRootData */ );
    }
    if (opts.computed) {
      initComputed(vm, opts.computed);
    }
    if (opts.watch && opts.watch !== nativeWatch) {
      initWatch(vm, opts.watch);
    }
  }
           

initData主要是初始化data中的資料,将資料進行Observer,監聽資料的變化

function initData(vm) {
    /*得到data資料*/
    var data = vm.$options.data;
    data = vm._data = typeof data === 'function' ?
      getData(data, vm) :
      data || {};

    /*判斷是否是對象*/
    if (!isPlainObject(data)) {
      data = {};
      "development" !== 'production' && warn(
        'data functions should return an object:\n' +
        'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
        vm
      );
    }

    /*周遊data對象*/
    // proxy data on instance
    var keys = Object.keys(data);
    var props = vm.$options.props;
    var methods = vm.$options.methods;
    var i = keys.length;
    while (i--) {
      var key = keys[i]; 
      {
        if (methods && hasOwn(methods, key)) {
          warn(
            ("Method \"" + key + "\" has already been defined as a data property."),
            vm
          );
        }
      }
      /*保證data中的key不與props中的key重複,props優先,如果有沖突會産生warning*/
      if (props && hasOwn(props, key)) {
        "development" !== 'production' && warn(
          "The data property \"" + key + "\" is already declared as a prop. " +
          "Use prop default value instead.",
          vm
        );
      } else if (!isReserved(key)) {
        /*判斷是否是保留字段*/
        /*将data上面的屬性代理到了vm執行個體上*/
        proxy(vm, "_data", key);
      }
    }
    // observe data 980
    observe(data, true /* asRootData */ );
  }
           

管理訂閱者

對訂閱者進行收集、存儲和通知

Dep構造函數

/**
  * Dep類是一個釋出者,有多個訂閱者可以訂閱它
  */
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.target的時候添加訂閱者對象
Dep.prototype.depend = function depend() {
    if (Dep.target) {
        Dep.target.addDep(this);
    }
};
// 通知所有訂閱者
Dep.prototype.notify = function notify() {
    // stabilize the subscriber list first
    var subs = this.subs.slice();
    for (var i = , l = subs.length; i < l; i++) {
        subs[i].update();
    }
};

// the current target watcher being evaluated.
// this is globally unique because there could be only one
// watcher being evaluated at any time.
/*依賴收集完需要将Dep.target設為null,防止後面重複添加依賴。*/
Dep.target = null;
           

監聽資料變化

proxy代理

将data的最外層屬性代理到Vue執行個體上

/*添加代理*/ 
function proxy(target, sourceKey, key) {
    sharedPropertyDefinition.get = function proxyGetter() {
        return this[sourceKey][key]
    };
    sharedPropertyDefinition.set = function proxySetter(val) {
        this[sourceKey][key] = val;
    };
    Object.defineProperty(target, key, sharedPropertyDefinition);
}
           

observe嘗試建立一個新的Observer執行個體(

_ob_

),Vue的響應式資料都會有一個

_ob_

的屬性作為标記,裡面存放了該屬性的觀察器,也就是Observer的執行個體,防止重複綁定。

/**
   * 嘗試建立一個Observer執行個體(__ob__),
   * 如果成功建立Observer執行個體則傳回新的Observer執行個體,
   * 如果已有Observer執行個體則傳回現有的Observer執行個體。
  */
  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
    ) {
      /*建立一個新的Observer執行個體*/
      ob = new Observer(value);
    }
    if (asRootData && ob) {
      ob.vmCount++;
    }
    return ob
  }
           

Observer構造函數

var Observer = function Observer(value) {
    this.value = value;
    this.dep = new Dep();
    this.vmCount = ;
    // 将Observer執行個體綁定到data的__ob__屬性上面
    def(value, '__ob__', this);
    if (Array.isArray(value)) {
        var augment = hasProto 
            ? protoAugment 
            : copyAugment
        augment(value, arrayMethods, arrayKeys);
        /*如果是數組則需要周遊數組的每一個成員,為其建立一個Observer執行個體(_ob_)*/
        this.observeArray(value);
    } else {
        /*如果是對象則使用walk進行綁定*/
        this.walk(value);
    }
};
           

walk

Observer.prototype.walk = function walk(obj) {
    var keys = Object.keys(obj);
    /*walk方法會周遊對象的每一個屬性進行defineReactive綁定*/
    for (var i = ; i < keys.length; i++) {
        defineReactive(obj, keys[i]);
    }
};
           

defineReactive 通過Object.defineProperty為資料定義上getter\setter方法,進行依賴收集後Deps會存放Watcher對象。觸發setter改變資料的時候會通知Deps訂閱者通知所有的Watcher觀察者對象進行UI的更新。

function defineReactive(
    obj,
    key,
    val,
    customSetter,
    shallow
  ) {
    // 定義一個dep對象
    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 === ) {
      val = obj[key];
    }

    // 對象的子對象遞歸進行observe并傳回子節點的observe對象
    var childOb = !shallow && observe(val);
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get: function reactiveGetter() {
        // 如果對象原本就有getter則執行
        var value = getter ? getter.call(obj) : val;
        // 如果Dep類存在target屬性,将其添加到dep執行個體的subs數組中
        // target指向一個Watcher執行個體,每個Watcher都是一個訂閱者
        // Watcher執行個體在執行個體化過程中,會讀取data中的某個屬性,進而觸發目前get方法
        // 并不是每次Dep.target有值時都需要添加到訂閱者管理者中去管理,需要對訂閱者去重
        if (Dep.target) {
          // 進行依賴收集
          dep.depend();
          if (childOb) {
            childOb.dep.depend();
            if (Array.isArray(value)) {
              // 如果是數組對象則需要對每一個成員都進行依賴收集
              dependArray(value);
            }
          }
        }
        return value
      },
      set: function reactiveSetter(newVal) {
        // 通過getter方法擷取目前值,與新值進行比較,一緻則不需要執行set操作
        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 ("development" !== 'production' && customSetter) {
          customSetter();
        }
        // 如果原對象有setter方法則執行setter
        if (setter) {
          setter.call(obj, newVal);
        } else {
          val = newVal;
        }
        // 新值重新進行observe,保證資料響應式
        childOb = !shallow && observe(newVal);
        // dep對象通知所有的觀察者
        dep.notify();
      }
    });
  }
           

Watcher是一個觀察者對象。依賴收集後Watcher對象會被儲存在Deps中,資料變動時,Deps會通知Watcher執行個體,然後由Watcher執行個體回調進行視圖的更新。訂閱者維護着每一次更新之前的資料,然後将其和更新之後的資料進行對比,如果發生了變化,則執行回調函數,并更新訂閱者中維護的資料的值。

beforeMount & mounted

解析器Compile初始化訂閱者

Compile(HTML指令解析器),對元素節點的指令進行掃描和解析,如果存在v-model、v-on、插值等,則初始化這類節點的模闆資料,使之可以顯示在視圖上,然後初始化相應的訂閱者(Watcher),接收到屬性變化時執行回調函數更新視圖。

  • 如果是元素節點(有v-指令),對元素添加監聽事件(addEventListener,對input、click、change等事件添加監聽)
  • 如果是文本節點(有插值),則提取出{{}}内的data,然後根據綁定的資料對其進行初始化

beforeUpdate & updated

[JavaScript][Vue]資料雙向綁定資料雙向綁定參考資料

資料劫持:

Object.defineProperty

定義對象屬性的getter内進行依賴收集,把訂閱者添加到Dep的subs數組内,setter内通過getter方法擷取目前值,與新值進行比較,一緻則不需要執行set操作,并将改變通知給所有的訂閱者

釋出訂閱:

使用 Dep 解耦了依賴者與被依賴者之間關系的确定過程。簡單來說:

  • 通過 Observer 提供的接口,周遊狀态對象,給對象的每個屬性、子屬性都綁定了一個專用的

    Dep

    對象。這裡的狀态對象主要指元件當中的

    data

    屬性。
  • 建立三種類型的watcher:
    1. 調用 initComputed 将

      computed

      屬性轉化為

      watcher

      執行個體
    2. 調用 initWatch 方法,将

      watch

      配置轉化為

      watcher

      執行個體
    3. 調用 mountComponent 方法,為

      render

      函數綁定

      watcher

      執行個體
  • 狀态變更後,觸發

    dep.notify()

    函數,該函數再進一步觸發 Watcher 對象

    update

    函數,執行watcher的重新計算。
[JavaScript][Vue]資料雙向綁定資料雙向綁定參考資料
  1. 對數組進行push、pop等操作時,對于數組的新對象何時進行雙向綁定,怎麼監聽數組的這些變化?

    Vue的方法:重寫push、pop、shift、unshift、splice、sort、reverse這些數組的原型方法。在使用了這些方法,如果數組增加了元素(push、unshift、splice),給新元素再添加雙向綁定。同時數組元素發生變化,dep通知(notify change)所有注冊的觀察者進行響應式處理 。源碼可見observer/array.js

  2. 資料劫持利用的

    Object.defineProperty

    是ES5的方法,需要支援ES5的浏覽器
  3. 屬性劫持的出發點是“變”,是以Vue無法很好接入immutable模式
  4. 訂閱者進行資料更新時會維護原資料,增加記憶體成本

總結

Vue利用的資料劫持+釋出訂閱模式,實作了視圖和對象屬性的雙向綁定。Vue3的作者宣稱他們會使用ES2015的Proxy來實作雙向綁定,在目标對象外面架設一層“攔截”,外部對該對象的通路,都會先通過這層攔截,是以提供了對外界的通路進行過濾和改寫的機制,同時Proxy除了可以直接監聽對象而非屬性、直接監聽數組變化,攔截方式還十分多樣。即使Proxy有相容性問題,但仍值得期待。

參考資料

設計模式之觀察者模式 -cnblogs

觀察者模式 -wiki

vuejs -github

觀察者模式 vs 釋出-訂閱模式 -掘金

資料雙向綁定的 分析和簡單實作 - 知乎專欄

繼續閱讀