稍微學(xué)一下 MVVM 原理

vue.jpg

博客原文

介紹

本文通過仿照 Vue 浴鸿,簡單實現(xiàn)一個的 MVVM宇植,希望對大家學(xué)習(xí)和理解 Vue 的原理有所幫助蔬顾。

前置知識

nodeType

nodeType 為 HTML 原生節(jié)點的一個屬性郊供,用于表示節(jié)點的類型

Vue 中通過每個節(jié)點的 nodeType 屬性是1還是3判斷是元素節(jié)點還是文本節(jié)點瞎惫,針對不同類型節(jié)點做不同的處理。

DocumentFragment

DocumentFragment是一個可以被 js 操作但不會直接出發(fā)渲染的文檔對象译株,Vue 中編譯模板時是現(xiàn)將所有節(jié)點存到 DocumentFragment 中瓜喇,操作完后再統(tǒng)一插入到 html 中,這樣就避免了多次修改 Dom 出發(fā)渲染導(dǎo)致的性能問題歉糜。

Object.defineProperty

Object.defineProperty接收三個參數(shù) Object.defineProperty(obj, prop, descriptor), 可以為一個對象的屬性 obj.prop t通過 descriptor 定義 get 和 set 方法進(jìn)行攔截乘寒,定義之后該屬性的取值和修改時會自動觸發(fā)其 get 和 set 方法。

從零實現(xiàn)一個類 Vue

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

目錄結(jié)構(gòu)

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

實現(xiàn)的這個 類 Vue 包含了4個主要模塊:

  • index.js 為入口文件现恼,提供了一個 Vue 類肃续,并在類的初始化時調(diào)用 obsever 與 compile 分別進(jìn)行數(shù)據(jù)攔截與模板編譯;
  • obsever.js 中提供了一個 Obsever 類及一個 Dep 類叉袍,Obsever 對 vue 的 data 屬性遍歷始锚,給所有數(shù)據(jù)都添加 getter 與 setter 進(jìn)行攔截,Dep 用于記錄每個數(shù)據(jù)的依賴喳逛;
  • compile.js 中提供了一個 Compile 類瞧捌,對傳入的 html 節(jié)點的所有子節(jié)點遍歷編譯,分析 vue 不同的指令并解析 {{}} 的語法;
  • watcher.js 中提供了一個 Watcher 類姐呐,用于監(jiān)聽每個數(shù)據(jù)的變化殿怜,當(dāng)數(shù)據(jù)變化時調(diào)用傳入的回調(diào)函數(shù);

入口文件

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

<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 的類并進(jìn)行一些初始化操作:

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

Compile

index.js 中調(diào)用了 new Compile() 進(jìn)行模板編譯曙砂,因此這里需要提供一個 Compile 類:

class Compile {
  constructor(el, vm) {
    this.el = el;
    this.vm = vm;
    if (this.el) {
      // 將 dom 轉(zhuǎn)入 fragment 內(nèi)存中
      const fragment = this.node2fragment(this.el);
      // 編譯  提取需要的節(jié)點并替換為對應(yīng)數(shù)據(jù)
      this.compile(fragment);
      // 插回頁面中去
      this.el.appendChild(fragment);
    }
  }
  // 編譯元素節(jié)點  獲取 Vue 指令并執(zhí)行對應(yīng)的編譯函數(shù)(取值并更新 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('');
        // 調(diào)用指令對應(yīng)的方法更新 dom
        CompileUtil[type](node, this.vm, expr);
      }
    })
  }
  // 編譯文本節(jié)點  判斷文本內(nèi)容包含 {{}} 則執(zhí)行文本節(jié)點編譯函數(shù)(取值并更新 dom)
  compileText(node) {
    const expr = node.textContent;
    const reg = /\{\{\s*([^}\s]+)\s*\}\}/;
    if (reg.test(expr)) {
      // 調(diào)用文本節(jié)點對應(yīng)的方法更新 dom
      CompileUtil['text'](node, this.vm, expr);
    }
  }
  // 遞歸遍歷 fragment 中所有節(jié)點判斷節(jié)點類型并編譯
  compile(fragment) {
    const childNodes = fragment.childNodes;
    Array.from(childNodes).forEach(node => {
      if (this.isElementNode(node)) {
        // 元素節(jié)點  編譯并遞歸
        this.compileElement(node);
        this.compile(node);
      } else {
        // 文本節(jié)點
        this.compileText(node);
      }
    })
  }
  // 循環(huán)將 el 中每個節(jié)點插入 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 區(qū)分 元素節(jié)點 還是 文本節(jié)點头谜,分別調(diào)用了 compileElement 和 compileText。

compileElement 及 compileText 中最終調(diào)用了 CompileUtil 的方法更新 dom鸠澈。

CompileUtil = {
  // 獲取實例上對應(yīng)數(shù)據(jù)
  getVal(vm, expr) {
    expr = expr.split('.');
    return expr.reduce((prev, next) => {
      return prev[next];
    }, vm.$data);
  },
  // 文本節(jié)點需先去除 {{}} 并利用正則匹配多組
  getTextVal(vm, expr) {
    return expr.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (...arguments) => {
      return this.getVal(vm, arguments[1]);
    })
  },
  // 從 vm.$data 上取值并更新節(jié)點的文本內(nèi)容
  text(node, vm, expr) {
    expr.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (...arguments) => {
      // 添加數(shù)據(jù)監(jiān)聽柱告,數(shù)據(jù)變化時調(diào)用回調(diào)函數(shù)
      new Watcher(vm, arguments[1], () => {
        this.updater.textUpdater(node, this.getTextVal(vm, expr));
      })
    })
    this.updater.textUpdater(node, this.getTextVal(vm, expr));
  },
  // 從 vm.$data 上取值并更新輸入框內(nèi)容
  model(node, vm, expr) {
    // 添加數(shù)據(jù)監(jiān)聽,數(shù)據(jù)變化時調(diào)用回調(diào)函數(shù)
    new Watcher(vm, expr, () => {
      this.updater.modelUpdater(node, this.getVal(vm, expr));
    })
    // 輸入框輸入時修改 data 中對應(yīng)數(shù)據(jù)
    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 方法用于處理嵌套對象的屬性笑陈,如傳入表達(dá)式 expr 為 user.name 的情況际度,利用 reduce 從 vm.$data 上拿到。

Observer

index.js 中調(diào)用了 new Observer() 進(jìn)行數(shù)據(jù)劫持涵妥,Vue 實例 data 屬性的每項數(shù)據(jù)都通過 defineProperty 方法添加 getter setter 攔截數(shù)據(jù)操作將其定義為響應(yīng)式數(shù)據(jù)乖菱,因此這里首先需要提供一個 Observer 類:

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

定義為響應(yīng)式數(shù)據(jù)后再對其取值和修改是會觸發(fā)對應(yīng)的 get 和 set 方法蓬网。
取值時將改值本身返回窒所,并先判斷是否有依賴目標(biāo) Dep.target,如果有則保存起來拳缠。
修改值時先手動將原值修改并通知保存的所有依賴目標(biāo)進(jìn)行更新操作墩新。

這里對每項數(shù)據(jù)都通過創(chuàng)建一個 Dep 類實例進(jìn)行保存依賴和通知更新的操作,因此需要寫一個 Dep 類:

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

Dep 中有一個數(shù)組窟坐,用于保存數(shù)據(jù)的依賴目標(biāo)(watcher)海渊,notify 遍歷所有依賴并調(diào)用其 update 方法進(jìn)行更新。

Watcher

通過上面的 Observer 可以知道哲鸳,每項數(shù)據(jù)在被調(diào)用時可能會有依賴目標(biāo)臣疑,依賴目標(biāo)需要被保存并在取值時調(diào)用 notify 通知更新,且通過 Dep 可以知道依賴目標(biāo)是一個有 update 方法的對象實例徙菠。

因此需要創(chuàng)建一個 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 會觸發(fā)對應(yīng)數(shù)據(jù)的 get 方法讯沈,get 方法中從 Dep.target 拿到 Watcher 實例
    let value = this.getVal(this.vm, this.expr);
    Dep.target = null;
    return value;
  }
  // 對外暴露的方法,獲取新值與舊值對比后若不同則觸發(fā)回調(diào)函數(shù)
  update() {
    let newValue = this.getVal(this.vm, this.expr);
    let oldValue = this.value;
    if (newValue !== oldValue) {
      this.cb(newValue);
    }
  }
}

依賴目標(biāo)就是 Watcher 的實例婿奔,對外提供了 update 方法缺狠,調(diào)用 update 時會重新根據(jù)表達(dá)式 expr 取值與老值對比并調(diào)用回調(diào)函數(shù)。
這里的回調(diào)函數(shù)就是對應(yīng)的更新 dom 的方法萍摊,在 compile.js 中的 model 及 text 方法中有執(zhí)行 new Watcher() 挤茄,在模板解析時就為每項數(shù)據(jù)添加了監(jiān)聽:

model(node, vm, expr) {
  // 添加數(shù)據(jù)監(jiān)聽,數(shù)據(jù)變化時調(diào)用回調(diào)函數(shù)
  new Watcher(vm, expr, () => {
    this.updater.modelUpdater(node, this.getVal(vm, expr));
  })
  this.updater.modelUpdater(node, this.getVal(vm, expr));
},

Watcher 中很巧妙的一點就是冰木,模板編譯之前已經(jīng)將所有添加了數(shù)據(jù)攔截穷劈,在 Watcher 的 get 方法中調(diào)用 getVal 取值時會觸發(fā)該數(shù)據(jù)的 getter 方法笼恰,因此這里在取值前通過 Dep.target = this; 將該 Watcher 實例暫存,對應(yīng)數(shù)據(jù)的 getter 方法中又將該實例作為依賴目標(biāo)保存到了自身對應(yīng)的 Dep 實例中歇终。

總結(jié)

這樣就實現(xiàn)了一個簡易的 MVVM 原理社证,里面的一些思路還是非常值得反復(fù)體會學(xué)習(xí)的。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末评凝,一起剝皮案震驚了整個濱河市追葡,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌奕短,老刑警劉巖辽俗,帶你破解...
    沈念sama閱讀 206,311評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異篡诽,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)榴捡,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,339評論 2 382
  • 文/潘曉璐 我一進(jìn)店門杈女,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人吊圾,你說我怎么就攤上這事达椰。” “怎么了项乒?”我有些...
    開封第一講書人閱讀 152,671評論 0 342
  • 文/不壞的土叔 我叫張陵啰劲,是天一觀的道長。 經(jīng)常有香客問我檀何,道長蝇裤,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,252評論 1 279
  • 正文 為了忘掉前任频鉴,我火速辦了婚禮栓辜,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘垛孔。我一直安慰自己藕甩,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 64,253評論 5 371
  • 文/花漫 我一把揭開白布周荐。 她就那樣靜靜地躺著狭莱,像睡著了一般。 火紅的嫁衣襯著肌膚如雪概作。 梳的紋絲不亂的頭發(fā)上腋妙,一...
    開封第一講書人閱讀 49,031評論 1 285
  • 那天,我揣著相機(jī)與錄音仆嗦,去河邊找鬼辉阶。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的谆甜。 我是一名探鬼主播垃僚,決...
    沈念sama閱讀 38,340評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼规辱!你這毒婦竟也來了谆棺?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,973評論 0 259
  • 序言:老撾萬榮一對情侶失蹤罕袋,失蹤者是張志新(化名)和其女友劉穎改淑,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體浴讯,經(jīng)...
    沈念sama閱讀 43,466評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡朵夏,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,937評論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了榆纽。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片仰猖。...
    茶點故事閱讀 38,039評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖奈籽,靈堂內(nèi)的尸體忽然破棺而出饥侵,到底是詐尸還是另有隱情,我是刑警寧澤衣屏,帶...
    沈念sama閱讀 33,701評論 4 323
  • 正文 年R本政府宣布躏升,位于F島的核電站,受9級特大地震影響狼忱,放射性物質(zhì)發(fā)生泄漏膨疏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,254評論 3 307
  • 文/蒙蒙 一藕赞、第九天 我趴在偏房一處隱蔽的房頂上張望成肘。 院中可真熱鬧,春花似錦斧蜕、人聲如沸双霍。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,259評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽洒闸。三九已至,卻和暖如春均芽,著一層夾襖步出監(jiān)牢的瞬間丘逸,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工掀宋, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留深纲,地道東北人仲锄。 一個月前我還...
    沈念sama閱讀 45,497評論 2 354
  • 正文 我出身青樓,卻偏偏與公主長得像湃鹊,于是被迫代替她去往敵國和親儒喊。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,786評論 2 345

推薦閱讀更多精彩內(nèi)容