帶你了解響應(yīng)式原理,以及Vue 響應(yīng)式原理模擬實現(xiàn)

Study Notes

本博主會持續(xù)更新各種前端的技術(shù),如果各位道友喜歡,可以關(guān)注、收藏、點贊下本博主的文章。

深入響應(yīng)式原理

數(shù)據(jù)響應(yīng)式、雙向綁定、數(shù)據(jù)驅(qū)動

  • 數(shù)據(jù)響應(yīng)式

    數(shù)據(jù)模型僅僅是普通的 JavaScript 對象,而當(dāng)我們修改數(shù)據(jù)時,視圖會進(jìn)行更新,避免了繁瑣的 DOM 操作,提高開發(fā)效率

  • 雙向綁定

    • 數(shù)據(jù)改變,視圖改變;視圖改變,數(shù)據(jù)也隨之改變
    • 我們可以使用 v-model 在表單元素上創(chuàng)建雙向數(shù)據(jù)綁定
  • 數(shù)據(jù)驅(qū)動是 Vue 最獨特的特性之一

    開發(fā)過程中僅需要關(guān)注數(shù)據(jù)本身,不需要關(guān)心數(shù)據(jù)是如何渲染到視圖

Vue2.x 響應(yīng)式原理

當(dāng)你把一個普通的 JavaScript 對象傳入 Vue 實例作為 data 選項,Vue 將遍歷此對象所有的 property,并使用 Object.defineProperty 把這些 property 全部轉(zhuǎn)為 getter/setter。Object.defineProperty 是 ES5 中一個無法 shim 的特性,這也就是 Vue 不支持 IE8 以及更低版本瀏覽器的原因。

對象單屬性數(shù)據(jù)劫持

  • configurable

    當(dāng)且僅當(dāng)該屬性的 configurable 鍵值為 true 時,該屬性的描述符才能夠被改變,同時該屬性也能從對應(yīng)的對象上被刪除。

  • enumerable

    當(dāng)且僅當(dāng)該屬性的 enumerable 鍵值為 true 時,該屬性才會出現(xiàn)在對象的枚舉屬性中。
    默認(rèn)為 false。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
    />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>
  <body>
    <script>
      const el = document.createElement('p');
      el.textContent = '在控制臺輸入vm.msg = 123';
      document.body.append(el);

      // 模擬Vue中的data選項
      let data = { msg: 12 };
      // 模擬vue實例
      let vm = {};
      // 數(shù)據(jù)劫持:當(dāng)訪問或者設(shè)置 vm 中的成員的時候,做一些干預(yù)操作
      Object.defineProperty(vm, 'msg', {
        configurable: true,
        enumerable: true,
        // 當(dāng)獲取值的時候執(zhí)行
        get: () => {
          console.log('獲取');
          return data.msg;
        },
        // 當(dāng)賦值的時候執(zhí)行
        set: (val) => {
          console.log('賦值msg ==> ', val);
          if (val === data.msg) {
            return;
          }
          data.msg = val;
          // 數(shù)據(jù)更改,更新 DOM 的值
          document.getElementsByTagName('p')[0].textContent = val;
        },
      });
    </script>
  </body>
</html>

對象多屬性數(shù)據(jù)劫持

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
  </head>
  <body>
    <script>
      const el = document.createElement('p');
      el.textContent = '1231';
      document.body.append(el);

      let data = {
        msg: '成功',
        code: 1,
      };
      let vm = {};
      Object.keys(data).forEach((key) => {
        Object.defineProperty(vm, key, {
          enumerable: true,
          configurable: true,
          get: () => {
            console.log(`獲取 ${key}==>${data[key]}`);
            return data[key];
          },
          set: (val) => {
            if (val === data[key]) {
              return;
            }
            console.log(`賦值 ${key}==>${val}`);
            data[key] = val;
            document.getElementsByTagName('p')[0].textContent = val;
          },
        });
      });
    </script>
  </body>
</html>

vue3.x 響應(yīng)式原理

  • Proxy
  • 直接監(jiān)聽對象,而非屬性。
  • ES6中新增,IE 不支持,性能由瀏覽器優(yōu)化
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
  </head>
  <body></body>
  <script>
    const el = document.createElement('p');
    el.textContent = '在控制臺輸入vm.msg = 123';
    document.body.append(el);

    let data = {
      msg: '成功',
      code: 0,
    };
    const vm = new Proxy(data, {
      get(target, key) {
        console.log(`獲取${key}===>${target[key]}`);
        return target[key];
      },
      set(target, key, value) {
        if (target[key] === value) return;
        console.log(`設(shè)置${key}===>${value}`);
        target[key] = value;
        document.getElementsByTagName('p')[0].textContent = value;
      },
    });
  </script>
</html>

發(fā)布訂閱模式和觀察者模式

發(fā)布/訂閱模式

  • 訂閱者
  • 發(fā)布者
  • 信號中心

我們假定,存在一個"信號中心",某個任務(wù)執(zhí)行完成,就向信號中心"發(fā)布"(publish)一個信號,其他任務(wù)可以向信號中心"訂閱"(subscribe)這個信號,從而知道什么時候自己可以開始執(zhí)行。這就叫做"發(fā)布/訂閱模式"(publish-subscribe pattern)

Vue 的自定義事件

let vm = new Vue();
vm.$on('dataChange', () => {
  console.log('dataChange');
});
vm.$on('dataChange', () => {
  console.log('dataChange1');
});
vm.$emit('dataChange');

兄弟組件通信過程

// eventBus.js
// 事件中心
export let eventHub = new Vue()

// ComponentA.vue
import {eventHub} from './eventBus'
// 發(fā)布者
addTodo: function () {
// 發(fā)布消息(事件)
eventHub.$emit('add-todo', { text: this.newTodoText })
this.newTodoText = ''
}
// ComponentB.vue
import {eventHub} from './eventBus'
// 訂閱者
created: function () {
// 訂閱消息(事件)
eventHub.$on('add-todo', this.addTodo)
}

模擬 Vue 自定義事件的實現(xiàn)

/**
 * @author Wuner
 * @date 2020/7/29 21:58
 * @description
 */
class EventEmitter {
  constructor() {
    this.events = {};
  }

  // 訂閱通知
  $on(eventType, handle) {
    this.events[eventType] = this.events[eventType] || [];
    this.events[eventType].push(handle);
  }

  // 發(fā)布通知
  $emit(eventType) {
    if (this.events[eventType]) {
      this.events[eventType].forEach((handle) => handle());
    }
  }
}

// 測試
let ev = new EventEmitter();
ev.$on('click', () => {
  console.log('click1');
});
ev.$on('click', () => {
  console.log('click2');
});
ev.$on('next', () => {
  console.log('next');
});

ev.$emit('click');
ev.$emit('next');

觀察者模式

  • 觀察者(訂閱者) -- Watcher
    • update():當(dāng)事件發(fā)生時,具體要做的事情
  • 目標(biāo)(發(fā)布者) -- Dep
    • subs 數(shù)組:存儲所有的觀察者
    • addSub():添加觀察者
    • notify():當(dāng)事件發(fā)生,調(diào)用所有觀察者的 update() 方法
  • 沒有事件中心
/**
 * @author Wuner
 * @date 2020/7/29 22:45
 * @description
 */
// 目標(biāo)(發(fā)布者)
// Dependency
class Dep {
  constructor() {
    // 存儲所有的觀察者
    this.subs = [];
  }

  // 添加觀察者
  addSub(sub) {
    if (sub && sub.update) {
      this.subs.push(sub);
    }
  }

  // 通知所有觀察者
  notify() {
    this.subs.forEach((sub) => sub.update());
  }
}

// 觀察者(訂閱者)
class Watcher {
  update() {
    console.log('update');
  }
}

// 測試
const watcher = new Watcher();
const dep = new Dep();
dep.addSub(watcher);

dep.notify();

總結(jié)

  • 觀察者模式是由具體目標(biāo)調(diào)度,比如當(dāng)事件觸發(fā),Dep 就會去調(diào)用觀察者的方法,所以觀察者模式的訂閱者與發(fā)布者之間是存在依賴的。
  • 發(fā)布/訂閱模式由統(tǒng)一調(diào)度中心調(diào)用,因此發(fā)布者和訂閱者不需要知道對方的存在。

Vue 響應(yīng)式原理模擬

整體分析

  • Vue

    把 data 中的成員注入到 Vue 實例,并且把 data 中的成員轉(zhuǎn)成 getter/setter

  • Observer(數(shù)據(jù)劫持)

    能夠?qū)?shù)據(jù)對象的所有屬性進(jìn)行監(jiān)聽,如有變動可拿到最新值并通知 Dep

  • Compiler(解析指令)

    解析每個元素中的指令/插值表達(dá)式,并替換成相應(yīng)的數(shù)據(jù)

  • Dep(訂閱者)

    添加觀察者(watcher),當(dāng)數(shù)據(jù)變化通知所有觀察者

  • Watcher(觀察者)

    數(shù)據(jù)變化更新視圖

Vue

  • 負(fù)責(zé)接收初始化的參數(shù)(選項)
  • 負(fù)責(zé)把 data 中的屬性注入到 Vue 實例,轉(zhuǎn)換成 getter/setter
  • 負(fù)責(zé)調(diào)用 observer 監(jiān)聽 data 中所有屬性的變化
  • 負(fù)責(zé)調(diào)用 compiler 解析指令/插值表達(dá)式
/**
 * @author Wuner
 * @date 2020/7/29 23:34
 * @description
 */
class Vue {
  constructor(options) {
    // 初始化參數(shù)(選項)
    this.$options = options || {};
    this.$data = this.$options.data || {};
    const el = this.$options.el;
    // 判斷el是否是字符串,如果是的話,則通過querySelector找到dom節(jié)點,否則直接賦值dom
    this.$el = typeof el === 'string' ? document.querySelector(el) : el;
    // 負(fù)責(zé)把data中的屬性,注入到vue實例,并轉(zhuǎn)換為getter和setter
    this._proxyData(this.$data);
    // 調(diào)用 observer 監(jiān)聽 data 中所有屬性的變化
    new Observer(this.$data);
    // 編譯
    new Compiler(this);
  }

  _proxyData(data) {
    // 遍歷 data 的所有屬性
    Object.keys(data).forEach((key) => {
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get() {
          return data[key];
        },
        set(val) {
          if (val === data[key]) {
            return;
          }
          data[key] = val;
        },
      });
    });
  }
}

Observer

  • 負(fù)責(zé)把 data 選項中的屬性轉(zhuǎn)換成響應(yīng)式數(shù)據(jù)
  • data 中的某個屬性也是對象,把該屬性轉(zhuǎn)換成響應(yīng)式數(shù)據(jù)
  • 數(shù)據(jù)變化發(fā)送通知
/**
 * @author Wuner
 * @date 2020/7/30 0:12
 * @description
 */
// 負(fù)責(zé)數(shù)據(jù)劫持
// 把 $data 中的成員轉(zhuǎn)換成 getter/setter
class Observer {
  constructor(data) {
    this.walk(data);
  }

  walk(data) {
    // 判斷數(shù)據(jù)是否是對象,如果是對象,則遍歷對象的所有屬性,設(shè)置為 getter/setter
    if (data && typeof data === 'object') {
      // 遍歷 data 的所有成員
      Object.keys(data).forEach((key) =>
        this.defineReactive(data, key, data[key]),
      );
    }
  }

  // 定義響應(yīng)式成員
  defineReactive(data, key, val) {
    let dep = new Dep();
    // 如果val是對象,,繼續(xù)設(shè)置它下面的成員為響應(yīng)式數(shù)據(jù)
    this.walk(val);
    Object.defineProperty(data, key, {
      enumerable: true,
      configurable: true,
      get: () => {
        // 收集依賴
        Dep.target && dep.addSub(Dep.target);
        // 這里val不能通過data[key]獲取,否則會陷入自調(diào)用死循環(huán)
        return val;
      },
      set: (newVal) => {
        // 這里val不能通過data[key]獲取,否則會陷入自調(diào)用死循環(huán)
        if (newVal === val) return;
        val = newVal;
        // 如果newVal被賦值為對象,則繼續(xù)設(shè)置它下面的成員為響應(yīng)式數(shù)據(jù)
        this.walk(newVal);

        // 發(fā)送通知
        dep.notify();
      },
    });
  }
}

Compiler

  • 負(fù)責(zé)編譯模板,解析指令/插值表達(dá)式
  • 負(fù)責(zé)頁面的首次渲染
  • 當(dāng)數(shù)據(jù)變化后重新渲染視圖

nodeType

常量 描述
Node.ELEMENT_NODE 1 一個 元素 節(jié)點,例如 <p> 和 <div>。
Node.TEXT_NODE 3 Element 或者 Attr 中實際的 文字
Node.CDATA_SECTION_NODE 4 一個 CDATASection,例如 <!CDATA[[ … ]]>。
Node.PROCESSING_INSTRUCTION_NODE 7 一個用于 XML 文檔的 ProcessingInstruction ,例如 <?xml-stylesheet ... ?> 聲明。
Node.COMMENT_NODE 8 一個 Comment 節(jié)點。
Node.DOCUMENT_NODE 9 一個 Document 節(jié)點。
Node.DOCUMENT_TYPE_NODE 10 描述文檔類型的 DocumentType 節(jié)點。例如 <!DOCTYPE html> 就是用于 HTML5 的。
Node.DOCUMENT_FRAGMENT_NODE 11 一個 DocumentFragment 節(jié)點

已棄用的節(jié)點類型常量

常量 描述
Node.ATTRIBUTE_NODE 2 元素 的耦合屬性 。在 DOM4 規(guī)范里 Node 接口將不再實現(xiàn)這個元素屬性。
Node.ENTITY_REFERENCE_NODE 5 一個 XML 實體引用節(jié)點。 在 DOM4 規(guī)范里被移除。
Node.ENTITY_NODE 6 一個 XML <!ENTITY ...> 節(jié)點。 在 DOM4 規(guī)范中被移除。
Node.NOTATION_NODE 12 一個 XML <!NOTATION ...> 節(jié)點。 在 DOM4 規(guī)范里被移除.
/**
 * @author Wuner
 * @date 2020/7/30 2:52
 * @description
 */
// 負(fù)責(zé)解析指令/插值表達(dá)式
class Compiler {
  constructor(vm) {
    this.el = vm.$el;
    this.vm = vm;
    this.compiler(this.el);
  }

  // 編譯模板,處理文本節(jié)點和元素節(jié)點
  compiler(el) {
    // el.childNodes是一個偽數(shù)組
    const childNodes = Array.from(el.childNodes);
    childNodes.forEach((node) => {
      // console.dir(node);
      if (this.isTextNode(node)) {
        // 處理文本節(jié)點
        this.compilerText(node);
      } else if (this.isElementNode(node)) {
        // 處理元素節(jié)點
        this.compilerElement(node);
      }

      // 判斷當(dāng)前節(jié)點是否存在子節(jié)點,并且子節(jié)點個數(shù)大于0,需遞歸調(diào)用compile
      if (node.childNodes && node.childNodes.length) {
        this.compiler(node);
      }
    });
  }

  // 編譯元素節(jié)點,處理指令
  compilerElement(node) {
    // console.dir(node);
    // attributes是一個偽數(shù)組
    // 遍歷元素節(jié)點中的所有屬性,找到指令
    Array.from(node.attributes).forEach((attr) => {
      // 獲取元素屬性的名稱
      // 判斷當(dāng)前的屬性名稱是否是指令
      if (this.isDirective(attr.name)) {
        this.updater(node, attr);
      }
    });
  }

  // 負(fù)責(zé)更新 DOM
  // 創(chuàng)建 Watcher
  updater(node, attr) {
    // attrName 的形式 v-text v-model
    // 截取屬性的名稱,獲取 text model
    const attrName = attr.name.substr(2);
    // 處理不同的指令
    const fn = this[attrName + 'Updater'];
    // 因為在 textUpdater等中要使用 this
    fn && fn.call(this, node, attr.value);
  }

  // 處理 v-text 指令
  textUpdater(node, key) {
    node.textContent = this.vm[key];
    // 每一個指令中創(chuàng)建一個 watcher,觀察數(shù)據(jù)的變化
    new Watcher(this.vm, key, (newValue) => {
      node.textContent = newValue;
    });
  }

  // 處理 v-model 指令
  modelUpdater(node, key) {
    node.value = this.vm[key];
    // 監(jiān)聽視圖的變化
    node.addEventListener('input', () => (this.vm[key] = node.value));
    // 每一個指令中創(chuàng)建一個 watcher,觀察數(shù)據(jù)的變化
    new Watcher(this.vm, key, (newValue) => {
      node.value = newValue;
    });
  }

  // 編譯文本節(jié)點
  compilerText(node) {
    // console.dir(node);
    let reg = /\{\{(.+?)\}\}/;
    // 獲取文本節(jié)點的內(nèi)容
    let textContent = node.textContent;
    if (reg.test(textContent)) {
      // 插值表達(dá)式中的值就是我們要的屬性名稱
      let key = RegExp.$1.trim();
      // 把插值表達(dá)式替換成具體的值
      node.textContent = this.vm.$data[key];
      new Watcher(this.vm, key, (newValue) => {
        node.textContent = newValue;
      });
    }
  }

  // 判斷是否屬性是指令
  isDirective(attrName) {
    return attrName.startsWith('v-');
  }

  // 判斷是否是文本節(jié)點
  isTextNode(node) {
    return node.nodeType === 3;
  }

  // 判斷是否是元素節(jié)點
  isElementNode(node) {
    return node.nodeType === 1;
  }
}

Dep(Dependency)

  • 收集依賴,添加觀察者(watcher)
  • 通知所有觀察者
/**
 * @author Wuner
 * @date 2020/7/30 22:00
 * @description
 */
class Dep {
  constructor() {
    // 存儲觀察者的數(shù)組
    this.subs = [];
  }

  // 添加觀察者
  addSub(sub) {
    // 判斷是否是觀察者
    sub && sub.update && this.subs.push(sub);
  }

  // 通知所有觀察者
  notify() {
    this.subs.forEach((sub) => sub.update());
  }
}

Watcher

  • 當(dāng)數(shù)據(jù)變化觸發(fā)依賴, dep 通知所有的 Watcher 實例更新視圖
  • 自身實例化的時候往 dep 對象中添加自己
/**
 * @author Wuner
 * @date 2020/7/30 22:17
 * @description
 */
class Watcher {
  constructor(vm, key, callback) {
    this.vm = vm;
    this.key = key;
    // 當(dāng)數(shù)據(jù)變化的時候,調(diào)用 callback 更新視圖
    this.callback = callback;

    // 在 Dep 的靜態(tài)屬性上記錄當(dāng)前 watcher 對象,當(dāng)訪問數(shù)據(jù)的時候把 watcher 添加到 dep 的 subs 中
    Dep.target = this;
    // 這里通過vm取值時,會調(diào)用到observer中的defineReactive中的get方法
    this.oldValue = vm[key];
    // 賦值后,將緩存清空,防止污染
    Dep.target = null;
  }

  update() {
    this.oldValue !== this.vm[this.key] && this.callback(this.vm[this.key]);
  }
}

總結(jié)

問題

  • 給屬性重新賦值成對象,是否是響應(yīng)式的?

    是響應(yīng)式

  • 給 Vue 實例新增一個成員是否是響應(yīng)式的?

    不是響應(yīng)式的。原因

整體流程

  • Vue
    • 記錄傳入的選項,設(shè)置 data/el
    • 把 data 的成員注入到 Vue 實例
    • 負(fù)責(zé)調(diào)用 Observer 實現(xiàn)數(shù)據(jù)響應(yīng)式處理(數(shù)據(jù)劫持)
    • 負(fù)責(zé)調(diào)用 Compiler 編譯指令/插值表達(dá)式等
  • Observer
    • 數(shù)據(jù)劫持
    • 負(fù)責(zé)把 data 中的成員轉(zhuǎn)換成 getter/setter
    • 負(fù)責(zé)把多層屬性轉(zhuǎn)換成 getter/setter
    • 如果給屬性賦值為新對象,把新對象的成員設(shè)置為 getter/setter
    • 添加 Dep 和 Watcher 的依賴關(guān)系
    • 數(shù)據(jù)變化發(fā)送通知
  • Compiler
    • 負(fù)責(zé)編譯模板,解析指令/插值表達(dá)式
    • 負(fù)責(zé)頁面的首次渲染過程
    • 當(dāng)數(shù)據(jù)變化后重新渲染
  • Dep
    • 收集依賴,添加訂閱者(watcher)
    • 通知所有訂閱者
  • Watcher
    • 自身實例化的時候往 dep 對象中添加自己
    • 當(dāng)數(shù)據(jù)變化 dep 通知所有的 Watcher 實例更新視圖

源碼地址

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容