一文帶你深入解析vue雙向繫結原理(徹底搞懂它)

2022-02-18 22:00:06
本篇文章自定義一個vue,逐步實現資料的雙向繫結,給大家通過範例來一步步搞懂vue雙向繫結原理,希望對大家有所幫助!

自定義vue類

  • vue最少需要兩個引數:模板和data。【相關推薦:】

  • 建立Compiler物件,將資料渲染到模板後,掛載到指定跟節點中。

class MyVue {
  // 1,接收兩個引數:模板(根節點),和資料物件
  constructor(options) {
    // 儲存模板,和資料物件
    if (this.isElement(options.el)) {
      this.$el = options.el;
    } else {
      this.$el = document.querySelector(options.el);
    }
    this.$data = options.data;
    // 2.根據模板和資料物件,渲染到根節點
    if (this.$el) {
      // 監聽data所有屬性的get/set
      new Observer(this.$data);
      new Compiler(this)
    }
  }
  // 判斷是否是一個dom元素
  isElement(node) {
    return node.nodeType === 1;
  }
}

實現資料首次渲染到頁面

Compiler

1,node2fragment函數將模板元素提取到記憶體中,方便將資料渲染到模板後,再一次性掛載到頁面中

2,模板提取到記憶體後,使用buildTemplate函數遍歷該模板元素

  • 元素節點

    • 使用buildElement函數檢查元素上以v-開頭的屬性
  • 文位元組點

    • 用buildText函數檢查文字中有無{{}}內容

3,建立CompilerUtil類,用於處理vue指令和{{}},完成資料的渲染

4,到此就完成了首次資料渲染,接下來需要實現資料改變時,自動更新檢視。

class Compiler {
  constructor(vm) {
    this.vm = vm;
    // 1.將網頁上的元素放到記憶體中
    let fragment = this.node2fragment(this.vm.$el);
    // 2.利用指定的資料編譯記憶體中的元素
    this.buildTemplate(fragment);
    // 3.將編譯好的內容重新渲染會網頁上
    this.vm.$el.appendChild(fragment);
  }
  node2fragment(app) {
    // 1.建立一個空的檔案碎片物件
    let fragment = document.createDocumentFragment();
    // 2.編譯迴圈取到每一個元素
    let node = app.firstChild;
    while (node) {
      // 注意點: 只要將元素新增到了檔案碎片物件中, 那麼這個元素就會自動從網頁上消失
      fragment.appendChild(node);
      node = app.firstChild;
    }
    // 3.返回儲存了所有元素的檔案碎片物件
    return fragment;
  }
  buildTemplate(fragment) {
    let nodeList = [...fragment.childNodes];
    nodeList.forEach(node => {
      // 需要判斷當前遍歷到的節點是一個元素還是一個文字
      if (this.vm.isElement(node)) {
        // 元素節點
        this.buildElement(node);
        // 處理子元素
        this.buildTemplate(node);
      } else {
        // 文位元組點
        this.buildText(node);
      }
    })
  }
  buildElement(node) {
    let attrs = [...node.attributes];
    attrs.forEach(attr => {
      // v-model="name" => {name:v-model  value:name}
      let { name, value } = attr;
      // v-model / v-html / v-text / v-xxx
      if (name.startsWith('v-')) {
        // v-model -> [v, model]
        let [_, directive] = name.split('-');
        CompilerUtil[directive](node, value, this.vm);
      }
    })
  }
  buildText(node) {
    let content = node.textContent;
    let reg = /\{\{.+?\}\}/gi;
    if (reg.test(content)) {
      CompilerUtil['content'](node, content, this.vm);
    }
  }
}
let CompilerUtil = {
  getValue(vm, value) {
    // 解析this.data.aaa.bbb.ccc這種屬性
    return value.split('.').reduce((data, currentKey) => {
      return data[currentKey.trim()];
    }, vm.$data);
  },
  getContent(vm, value) {
    // 解析{{}}中的變數
    let reg = /\{\{(.+?)\}\}/gi;
    let val = value.replace(reg, (...args) => {
      return this.getValue(vm, args[1]);
    });
    return val;
  },
  // 解析v-model指令
  model: function (node, value, vm) {
    // 在觸發getter之前,為dom建立Wather,併為Watcher.target賦值
    new Watcher(vm, value, (newValue, oldValue) => {
      node.value = newValue;
    });
    let val = this.getValue(vm, value);
    node.value = val;
  },
  // 解析v-html指令
  html: function (node, value, vm) {
    // 在觸發getter之前,為dom建立Wather,併為Watcher.target賦值
    new Watcher(vm, value, (newValue, oldValue) => {
      node.innerHTML = newValue;
    });
    let val = this.getValue(vm, value);
    node.innerHTML = val;
  },
  // 解析v-text指令
  text: function (node, value, vm) {
    // 在觸發getter之前,為dom建立Wather,併為Watcher.target賦值
    new Watcher(vm, value, (newValue, oldValue) => {
      node.innerText = newValue;
    });
    let val = this.getValue(vm, value);
    node.innerText = val;
  },
  // 解析{{}}中的變數
  content: function (node, value, vm) {
    let reg = /\{\{(.+?)\}\}/gi;
    let val = value.replace(reg, (...args) => {
      // 在觸發getter之前,為dom建立Wather,併為Watcher.target賦值
      new Watcher(vm, args[1], (newValue, oldValue) => {
        node.textContent = this.getContent(vm, value);
      });
      return this.getValue(vm, args[1]);
    });
    node.textContent = val;
  }
}

實現資料驅動檢視

Observer

1,使用defineRecative函數對data做Object.defineProperty處理,使得data中的每個資料都可以進行get/set監聽

2,接下來將考慮如何在監聽到data值改變後,更新檢視內容呢?使用觀察者設計模式,建立Dep和Wather類。

class Observer {
  constructor(data) {
    this.observer(data);
  }
  observer(obj) {
    if (obj && typeof obj === 'object') {
      // 遍歷取出傳入物件的所有屬性, 給遍歷到的屬性都增加get/set方法
      for (let key in obj) {
        this.defineRecative(obj, key, obj[key])
      }
    }
  }
  // obj: 需要操作的物件
  // attr: 需要新增get/set方法的屬性
  // value: 需要新增get/set方法屬性的取值
  defineRecative(obj, attr, value) {
    // 如果屬性的取值又是一個物件, 那麼也需要給這個物件的所有屬性新增get/set方法
    this.observer(value);
    // 第三步: 將當前屬性的所有觀察者物件都放到當前屬性的釋出訂閱物件中管理起來
    let dep = new Dep(); // 建立了屬於當前屬性的釋出訂閱物件
    Object.defineProperty(obj, attr, {
      get() {
        // 在這裡收集依賴
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
      set: (newValue) => {
        if (value !== newValue) {
          // 如果給屬性賦值的新值又是一個物件, 那麼也需要給這個物件的所有屬性新增get/set方法
          this.observer(newValue);
          value = newValue;
          dep.notify();
          console.log('監聽到資料的變化');
        }
      }
    })
  }
}

使用觀察者設計模式,建立Dep和Wather類

1,使用觀察者設計模式的目的是:

  • 解析模板,收集data中某個資料在模板中被使用的dom節點集合,當該資料改變時,更新該dom節點集合就實現了資料更新。

  • Dep:用於收集某個data屬性依賴的dom節點集合,並提供更新方法

  • Watcher:每個dom節點的包裹物件

    • attr:該dom使用的data屬性
    • cb:修改該dom值的回撥函數,在建立的時候會接收

2,到這裡感覺思路是沒問題了,已經是勝券在握了。那Dep和Watcher該怎麼使用呢?

  • 為每個屬性新增一個dep,用來收集依賴的dom

  • 因為頁面首次渲染的時候會讀取data資料,這時候會觸發該data的getter,所以在此收集dom

  • 具體如何收集呢,在CompilerUtil類解析v-model,{{}}等命令時,會觸發getter,我們在觸發之前建立Wather,為Watcher新增一個靜態屬性,指向該dom,然後在getter函數裡面獲取該靜態變數,並新增到依賴中,就完成了一次收集。因為每次觸發getter之前都對該靜態變數賦值,所以不存在收集錯依賴的情況。

class Dep {
  constructor() {
    // 這個陣列就是專門用於管理某個屬性所有的觀察者物件的
    this.subs = [];
  }
  // 訂閱觀察的方法
  addSub(watcher) {
    this.subs.push(watcher);
  }
  // 釋出訂閱的方法
  notify() {
    this.subs.forEach(watcher => watcher.update());
  }
}
class Watcher {
  constructor(vm, attr, cb) {
    this.vm = vm;
    this.attr = attr;
    this.cb = cb;
    // 在建立觀察者物件的時候就去獲取當前的舊值
    this.oldValue = this.getOldValue();
  }
  getOldValue() {
    Dep.target = this;
    let oldValue = CompilerUtil.getValue(this.vm, this.attr);
    Dep.target = null;
    return oldValue;
  }
  // 定義一個更新的方法, 用於判斷新值和舊值是否相同
  update() {
    let newValue = CompilerUtil.getValue(this.vm, this.attr);
    if (this.oldValue !== newValue) {
      this.cb(newValue, this.oldValue);
    }
  }
}

3,到這裡就實現了資料繫結時,檢視自動更新,本來想程式碼一步步實現的,但是發現不好處理,就把完整的class貼出來了。

實現檢視驅動資料

其實就是監聽輸入框的input、change事件。修改CompilerUtil的model方法。具體程式碼如下

model: function (node, value, vm) {
    new Watcher(vm, value, (newValue, oldValue)=>{
        node.value = newValue;
    });
    let val = this.getValue(vm, value);
    node.value = val;
	// 看這裡
    node.addEventListener('input', (e)=>{
        let newValue = e.target.value;
        this.setValue(vm, value, newValue);
    })
},

總結

vue雙向繫結原理

vue接收一個模板和data引數。1,首先將data中的資料進行遞迴遍歷,對每個屬性執行Object.defineProperty,定義get和set函數。併為每個屬性新增一個dep陣列。當get執行時,會為呼叫的dom節點建立一個watcher存放在該陣列中。當set執行時,重新賦值,並呼叫dep陣列的notify方法,通知所有使用了該屬性watcher,並更新對應dom的內容。2,將模板載入到記憶體中,遞迴模板中的元素,檢測到元素有v-開頭的命令或者雙大括號的指令,就會從data中取對應的值去修改模板內容,這個時候就將該dom元素新增到了該屬性的dep陣列中。這就實現了資料驅動檢視。在處理v-model指令的時候,為該dom新增input事件(或change),輸入時就去修改對應的屬性的值,實現了頁面驅動資料。3,將模板與資料進行繫結後,將模板新增到真實dom樹中。

如何將watcher放在dep陣列中?

在解析模板的時候,會根據v-指令獲取對應data屬性值,這個時候就會呼叫屬性的get方法,我們先建立Watcher範例,並在其內部獲取該屬性值,作為舊值存放在watcher內部,我們在獲取該值之前,在Watcher原型物件上新增屬性Watcher.target = this;然後取值,將講Watcher.target = null;這樣get在被呼叫的時候就可以根據Watcher.target獲取到watcher範例物件。

methods的原理

建立vue範例的時候,接收methods引數

在解析模板的時候遇到v-on的指令。會對該dom元素新增對應事件的監聽,並使用call方法將vue繫結為該方法的this:vm.$methods[value].call(vm, e);

computed的原理

建立vue範例的時候,接收computed引數

初始化vue範例的時候,為computed的key進行Object.defineProperty處理,並新增get屬性。

(學習視訊分享:)

以上就是一文帶你深入解析vue雙向繫結原理(徹底搞懂它)的詳細內容,更多請關注TW511.COM其它相關文章!