稍微學一下 MVVM 原理

圖片描述

博客原文html

介紹

本文經過仿照 Vue ,簡單實現一個的 MVVM,但願對你們學習和理解 Vue 的原理有所幫助。vue

前置知識

nodeType

nodeType 爲 HTML 原生節點的一個屬性,用於表示節點的類型node

Vue 中經過每一個節點的 nodeType 屬性是1仍是3判斷是元素節點仍是文本節點,針對不一樣類型節點作不一樣的處理。git

DocumentFragment

DocumentFragment是一個能夠被 js 操做但不會直接出發渲染的文檔對象,Vue 中編譯模板時是現將全部節點存到 DocumentFragment 中,操做完後再統一插入到 html 中,這樣就避免了屢次修改 Dom 出發渲染致使的性能問題。github

Object.defineProperty

Object.defineProperty接收三個參數 Object.defineProperty(obj, prop, descriptor), 能夠爲一個對象的屬性 obj.prop t經過 descriptor 定義 get 和 set 方法進行攔截,定義以後該屬性的取值和修改時會自動觸發其 get 和 set 方法。數組

從零實現一個類 Vue

如下代碼的 git 地址: 如下代碼的 git 地址

目錄結構

├── vue
│   ├── index.js
│   ├── obsever.js
│   ├── compile.js
│   └── watcher.js
└── index.html

實現的這個 類 Vue 包含了4個主要模塊:app

  • index.js 爲入口文件,提供了一個 Vue 類,並在類的初始化時調用 obsever 與 compile 分別進行數據攔截與模板編譯;
  • obsever.js 中提供了一個 Obsever 類及一個 Dep 類,Obsever 對 vue 的 data 屬性遍歷,給全部數據都添加 getter 與 setter 進行攔截,Dep 用於記錄每一個數據的依賴;
  • compile.js 中提供了一個 Compile 類,對傳入的 html 節點的全部子節點遍歷編譯,分析 vue 不一樣的指令並解析 {{}} 的語法;
  • watcher.js 中提供了一個 Watcher 類,用於監聽每一個數據的變化,當數據變化時調用傳入的回調函數;

入口文件

在 index.html 中是經過 new Vue() 來使用的:frontend

<div id="app">
  <input type="text" v-model="msg">
  {{ msg }}
  {{ user.name }}
</div>
<script>
  const vm = new Vue({
    el: '#app',
    data: {
      msg: 'hello',
      user: {
        name: 'pan'
      }
    }
  })
</script>

所以入口文件需提供這個 Vue 的類並進行一些初始化操做:dom

class Vue {
  constructor(options) {
    // 參數掛載到實例
    this.$el = document.querySelector(options.el);
    this.$data = options.data;
    if (this.$el) {
      // 數據劫持
      new Observer(this.$data);
      // 編譯模板
      new Compile(this.$el, this);
    }
  }
}

Compile

index.js 中調用了 new Compile() 進行模板編譯,所以這裏須要提供一個 Compile 類:mvvm

class Compile {
  constructor(el, vm) {
    this.el = el;
    this.vm = vm;
    if (this.el) {
      // 將 dom 轉入 fragment 內存中
      const fragment = this.node2fragment(this.el);
      // 編譯  提取須要的節點並替換爲對應數據
      this.compile(fragment);
      // 插回頁面中去
      this.el.appendChild(fragment);
    }
  }
  // 編譯元素節點  獲取 Vue 指令並執行對應的編譯函數(取值並更新 dom)
  compileElement(node) {
    const attrs = node.attributes;
    Array.from(attrs).forEach(attr => {
      const attrName = attr.name;
      if (this.isDirective(attrName)) {
        const expr = attr.value;
        let [, ...type] = attrName.split('-');
        type = type.join('');
        // 調用指令對應的方法更新 dom
        CompileUtil[type](node, this.vm, expr);
      }
    })
  }
  // 編譯文本節點  判斷文本內容包含 {{}} 則執行文本節點編譯函數(取值並更新 dom)
  compileText(node) {
    const expr = node.textContent;
    const reg = /\{\{\s*([^}\s]+)\s*\}\}/;
    if (reg.test(expr)) {
      // 調用文本節點對應的方法更新 dom
      CompileUtil['text'](node, this.vm, expr);
    }
  }
  // 遞歸遍歷 fragment 中全部節點判斷節點類型並編譯
  compile(fragment) {
    const childNodes = fragment.childNodes;
    Array.from(childNodes).forEach(node => {
      if (this.isElementNode(node)) {
        // 元素節點  編譯並遞歸
        this.compileElement(node);
        this.compile(node);
      } else {
        // 文本節點
        this.compileText(node);
      }
    })
  }
  // 循環將 el 中每一個節點插入 fragment 中
  node2fragment(el) {
    const fragment = document.createDocumentFragment();
    let firstChild;
    while (firstChild = el.firstChild) {
      fragment.appendChild(firstChild);
    }
    return fragment;
  }
  isElementNode(node) {
    return node.nodeType === 1;
  }
  isDirective(name) {
    return name.startsWith('v-');
  }
}

這裏利用了 nodeType 區分 元素節點 仍是 文本節點,分別調用了 compileElement 和 compileText。

compileElement 及 compileText 中最終調用了 CompileUtil 的方法更新 dom。

CompileUtil = {
  // 獲取實例上對應數據
  getVal(vm, expr) {
    expr = expr.split('.');
    return expr.reduce((prev, next) => {
      return prev[next];
    }, vm.$data);
  },
  // 文本節點需先去除 {{}} 並利用正則匹配多組
  getTextVal(vm, expr) {
    return expr.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (...arguments) => {
      return this.getVal(vm, arguments[1]);
    })
  },
  // 從 vm.$data 上取值並更新節點的文本內容
  text(node, vm, expr) {
    expr.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (...arguments) => {
      // 添加數據監聽,數據變化時調用回調函數
      new Watcher(vm, arguments[1], () => {
        this.updater.textUpdater(node, this.getTextVal(vm, expr));
      })
    })
    this.updater.textUpdater(node, this.getTextVal(vm, expr));
  },
  // 從 vm.$data 上取值並更新輸入框內容
  model(node, vm, expr) {
    // 添加數據監聽,數據變化時調用回調函數
    new Watcher(vm, expr, () => {
      this.updater.modelUpdater(node, this.getVal(vm, expr));
    })
    // 輸入框輸入時修改 data 中對應數據
    node.addEventListener('input', e => {
      const newValue = e.target.value;
      this.setVal(vm, expr, newValue);
    })
    this.updater.modelUpdater(node, this.getVal(vm, expr));
  },
  updater: {
    textUpdater(node, value) {
      node.textContent = value;
    },
    modelUpdater(node, value) {
      node.value = value;
    }
  }
}

getVal 方法用於處理嵌套對象的屬性,如傳入表達式 expr 爲 user.name 的狀況,利用 reduce 從 vm.$data 上拿到。

Observer

index.js 中調用了 new Observer() 進行數據劫持,Vue 實例 data 屬性的每項數據都經過 defineProperty 方法添加 getter setter 攔截數據操做將其定義爲響應式數據,所以這裏首先須要提供一個 Observer 類:

class Observer {
  constructor(data) {
    // 遍歷 data 將每一個屬性定義爲響應式
    this.observer(data);
  }
  observer(data) {
    if (!data || typeof data !== 'object') {
      return;
    }
    for (const [key, value] of Object.entries(data)) {
      this.defineReactive(data, key, value);
      // 當屬性爲對象則需遞歸遍歷
      this.observer(value);
    }
  }
  // 定義響應式屬性
  defineReactive(obj, key, value) {
    const that = this;
    const dep = new Dep();
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: false,
      // 獲取數據時調用
      get() {
        // 將 Watcher 實例存入依賴
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
      // 設置數據時調用
      set(newVal) {
        if (newVal !== value) {
          // 當新值爲對象時,需遍歷並定義對象內屬性爲響應式
          that.observer(newVal);
          value = newVal;
          // 通知依賴更新
          dep.notify();
        }
      }
    })
  }
}

定義爲響應式數據後再對其取值和修改是會觸發對應的 get 和 set 方法。
取值時將改值自己返回,並先判斷是否有依賴目標 Dep.target,若是有則保存起來。
修改值時先手動將原值修改並通知保存的全部依賴目標進行更新操做。

這裏對每項數據都經過建立一個 Dep 類實例進行保存依賴和通知更新的操做,所以須要寫一個 Dep 類:

class Dep {
  constructor() {
    this.subs = [];
  }
  addSub(watcher) {
    this.subs.push(watcher);
  }
  notify() {
    this.subs.forEach(watcher => watcher.update());
  }
}

Dep 中有一個數組,用於保存數據的依賴目標(watcher),notify 遍歷全部依賴並調用其 update 方法進行更新。

Watcher

經過上面的 Observer 能夠知道,每項數據在被調用時可能會有依賴目標,依賴目標須要被保存並在取值時調用 notify 通知更新,且經過 Dep 能夠知道依賴目標是一個有 update 方法的對象實例。

所以須要建立一個 Watcher 類:

class Watcher {
  constructor(vm, expr, cb) {
    this.vm = vm;
    this.expr = expr;
    this.cb = cb;
    // 記錄舊值
    this.value = this.get();
  }
  getVal(vm, expr) {
    expr = expr.split('.');
    return expr.reduce((prev, next) => {
      return prev[next];
    }, vm.$data);
  }
  get() {
    Dep.target = this;
    // 獲取 data 會觸發對應數據的 get 方法,get 方法中從 Dep.target 拿到 Watcher 實例
    let value = this.getVal(this.vm, this.expr);
    Dep.target = null;
    return value;
  }
  // 對外暴露的方法,獲取新值與舊值對比後若不一樣則觸發回調函數
  update() {
    let newValue = this.getVal(this.vm, this.expr);
    let oldValue = this.value;
    if (newValue !== oldValue) {
      this.cb(newValue);
    }
  }
}

依賴目標就是 Watcher 的實例,對外提供了 update 方法,調用 update 時會從新根據表達式 expr 取值與老值對比並調用回調函數。
這裏的回調函數就是對應的更新 dom 的方法,在 compile.js 中的 model 及 text 方法中有執行 new Watcher() ,在模板解析時就爲每項數據添加了監聽:

model(node, vm, expr) {
  // 添加數據監聽,數據變化時調用回調函數
  new Watcher(vm, expr, () => {
    this.updater.modelUpdater(node, this.getVal(vm, expr));
  })
  this.updater.modelUpdater(node, this.getVal(vm, expr));
},

Watcher 中很巧妙的一點就是,模板編譯以前已經將全部添加了數據攔截,在 Watcher 的 get 方法中調用 getVal 取值時會觸發該數據的 getter 方法,所以這裏在取值前經過 Dep.target = this; 將該 Watcher 實例暫存,對應數據的 getter 方法中又將該實例做爲依賴目標保存到了自身對應的 Dep 實例中。

總結

這樣就實現了一個簡易的 MVVM 原理,裏面的一些思路仍是很是值得反覆體會學習的。

相關文章
相關標籤/搜索