小鹿又熬肝寫了一份 Vue 2.0 核心原理!

小鹿又熬肝寫了一份 Vue 2.0 核心原理!

整篇 Vue2.0 核心源碼,差不多寫了一個多半月,由於文章太長,分兩篇分享,通過動手實踐去實現 Vue 2.0 的核心原理,進一步對 Vue 核心原理的理解和認識。

加上現在面試要求越來越高,無論是 Vue 源碼還是 React 源碼,是經常被面試到的,可以說是必問。雖然聽起來擼源碼很高大上、很複雜,但是每一個複雜的事物都是由簡單構成的,如果通過內部看原理,其實就是基礎+數據結構的還有一些設計模式的實現。

說實話,這個月,小鹿肝熬的有點多。後續會把這部分都整理到《大前端面試小冊》中去,會根據面試內容進行優化和補充,肝就完事了!

目錄

小鹿又熬肝寫了一份 Vue 2.0 核心原理!

爲什麼使用 Vue?

從前端這麼些年的發展史來看,從網頁設計年代到了現在大前端時代的來臨,各種各樣的技術層出不窮。尤其是在前端性能優化方面,爲了避免頁面的迴流和重繪,前輩們總結出了各種解決優化方案,基本都是儘量的減少 DOM 操作。

Vue 的誕生,是一個很大的優化方案,直接用虛擬 DOM 映射真實 DOM,來進行更新,避免了直接操作真實 DOM 帶來的性能缺陷。

爲了好理解呢,我們換個通俗一點的說法,當頁面涉及到操作 DOM 的時候,我們不直接進行操作,因爲這樣降低了前端頁面的性能。而是將 DOM 拿到內存中去,在內存中更改頁面的 DOM ,這時候我們操作 DOM 不會導致每次操作 DOM 就會造成不必要的迴流和重繪。更新完所有 DOM 之後,我們將更新完的 DOM 再插入到頁面中,這樣大大提高了頁面的性能。

雖然這樣講有些欠妥或者不標準,其實 Vue 的虛擬 DOM 的作用可以這樣去理解,也是爲了照顧到一些剛剛接觸到 Vue 的初學者。本篇寫作的目的不是去寫一高大上的術語,而是能將分享到的內容讓大部分看明白,就已經足夠了。

你會學到什麼?

本篇主要僅供個人 Vue 源碼學習記錄,主要以 Vue2.0 爲主。

主要分享整個 Vue2.0 源碼的核心功能,會將一下幾個功能通過刪減,通過代碼對核心原理部分展開分享,一些用到的變量和函數方法可能與源碼中不相同,由於時間和精力有限,只分享核心內容部分。主要包括以下幾個核心部分:

1、響應式原理(MVVM)

2、模板編譯 (Compile)

3、依賴追蹤

4、虛擬 DOM (VDDOM)

5、patch

6、diff 算法

帶着問題去學習

有問題纔有學習的動力和激情,如果毫無目的的只扒源碼,顯然是非常枯燥的,前期在挖源碼的時候,小鹿是帶着一下幾個疑問去探索原理的,你是否也存在和小鹿一樣的 vue 問題呢?

1、雙向綁定是怎麼實現的?

2、vue 標籤中的指令內部又是如何解析的?

3、什麼是虛擬 DOM,它比傳統的真實 DOM 有什麼優勢?

4、當數據更新時,虛擬 DOM 如果對比新老節點更新真實 DOM 的?

5、頁面多個地方操作 DOM,內部如何實現優化的?

......

以上幾個個問題,前期給我帶來了探索源碼的動力。當看了源碼一個月過去之後,這個期間通過動手實踐和總結,發現這些東西都是在最原本的事物基礎上進行改進和優化,尤其是對基本功(JS、數據結構與算法)的重要性,越是簡單的東西,越是新事物的組成部分。簡單,簡而不單,單而不簡。能讓你創新出新的事物,萬物皆如此。

Vue2.0 整體概括

初始化 Vue 實例 ==》 設置數據劫持(Object.defineProperty) ==》模板編譯(compile) ==》渲染(render function) ==》轉化爲虛擬 DOM(Object) ==》對比新老虛擬DOM(patch、diff)==》 更新視圖(真實 dom)

1、傳入實例參數

當我們開始寫 Vue 項目時,首先初始化一個 Vue 實例,傳入一個對象參數,參數中包括一下幾個重要屬性:


 1{
 2    el: '#app',
 3    data: {
 4        student: {
 5            name: '公衆號:小鹿動畫學編程',
 6            age: 20,
 7        }
 8    }
 9    computed:{
10        ...
11    }
12    ...
13}

1) el:將渲染好的 DOM 掛載到頁面中(可以傳入一個 id,也可以傳入一個 dom 節點)。

2) data:頁面所需要的數據(對象類型,至於爲什麼,會在數據劫持內容說明)。

3) computed:計算屬性,隨着 data 中的數據變化,來更新頁面關聯的計算屬性。

4) methods:實例所用到的方法集合。

除此之外,還有一些生命週期鉤子函數等其他內容。

2、設置數據劫持

所謂的數據劫持,當 Vue 實例上的 data 中的數據改變時,對應的視圖所用到的 data 中數據也會在頁面改變。所以我們需要給 data 中的所有數據設置一個監聽器,監聽 data 的改變和獲取,一旦數據改變,監聽器會觸發,通知頁面,要改變數據了。


1 Object.defineProperty(obj, key, {
2     get() {
3         return value;
4     },
5     set: newValue => {
6         console.log(---------------更新視圖--------------------)
7     }
8 }

數據劫持的實現就是給每一個 data綁定 Object.defineProperty()。對於 Object.defineProperty()的用法,自己詳細看 MDN ,這也是 MVVM的核心實現 API,下遍很多東西都是圍繞着它轉。

3、模板編譯(compile)

拿到傳入 dom 對象和 data 數據了,如果將這些 data 渲染到 HTML 所對應的 {{student.age}}、v-model="student.name" 等標籤中,這個過程就是模板編譯的過程,主要解析模板中的指令、class、style等等數據。


1// 把當前節點放到內存中去(因爲頻繁渲染造成迴流和重繪)
2let fragment = this.nodefragment(this.el);
3
4// 把節點在內存中替換(編譯模板,數據編譯)
5this.compile(fragment);
6
7// 把內容塞回頁面
8this.el.appendChild(fragment);

我們通過 el 拿到 dom 對象,然後將這個當前的 dom 節點拿到內存中去,然後將數據和 dom 節點進行替換合併,然後再把結果塞會到頁面中。下面會根據代碼實現,具體展開分享。

4、虛擬 DOM(Virtual DOM)

所謂虛擬 DOM,其實就是一個 javascript對象,說白了就是對真實 DOM 的一個描述對象,和真實 dom做一個映射。


 1// 真實 DOM
 2<div>
 3    <span>HelloWord</span>
 4</div>
 5
 6
 7// 虛擬 DOM —— 以上的真實 DOM 被虛擬 DOM 表示如下:
 8{
 9    children:(1) [{…}]  // 子元素
10    domElement: div        // 對應的真實 dom    
11    key: undefined      // key 值
12    props: {}           // 標籤對應的屬性
13    text: undefined     // 文本內容
14    type: "div"         // 節點類型
15    ...
16}

一旦頁面數據有變化,我們不直接操作更新真實 DOM,而是更新虛擬 DOM,又因爲虛擬 DOM和真實 DOM有映射關係,所有真實 DOM也被簡潔更新,避免了迴流和重繪造成性能上的損失。

對於虛擬 DOM,主要核心涉及到 diff算法,新老虛擬結點如何檢查差異的,然後又是如何進行更新的,後邊會展開一點點講。

5、對比新老虛擬 DOM(patch)

patch 主要是對更新後的新節點和更新前的節點進行比對,比對的核心算法就是 diff 算法,比如新節點的屬性值不同,新節點又增加了一個子元素等變化,都需要通過這個過程,將最後新的虛擬 DOM 更新到視圖上,呈現最新的變化,這個過程是一個核心部分,面試也是經常問到的。

6、更新視圖(update view)

當第一次加載 Vue 實例的時候,我們將渲染好的數據掛載到頁面中。當我們已經將實例掛載到了真實 dom 上,我們更新數據時,新老節點對比完成,拿到對比的最新數據狀態,然後更新到視圖上去。

注意:以下代碼並非原封不動的源代碼,爲了能夠清晰易懂,只是將一些核心原理進行抽離,通過自己實現的代碼來展開分享,爲了避免不必要的爭議,請自行翻看源代碼。

實現一個雙向綁定

一、響應式原理

我們都用過 Vue 中的 v-model 實現輸入框和數據的雙向綁定,其實就是 MVVM框架的核心原理實現。

如果剛接觸 MVVM,可以看小鹿之前在公衆號分享的一篇文章:

動畫:淺談後臺 MVC 模型與 MVVM 雙向綁定模型

下面我們動手來實現一個 MVVM 雙向綁定。


 1<!DOCTYPE html>
 2<html lang="en">
 3
 4<head>
 5  <meta charset="UTF-8">
 6  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 7  <meta http-equiv="X-UA-Compatible" content="ie=edge">
 8  <title>Document</title>
 9</head>
10
11<body>
12  <div id="app">
13    <input type="text" v-model="student.name">
14    {{student.age}}
15  </div>
16  <script src="./node_modules/vue/dist/vue.min.js"></script> 
17  <script>
18    let vm = new Vue({
19      el: '#app',
20      data: {
21        student: {
22          name: '公衆號:小鹿動畫學編程',
23          age: 20,
24        }
25      }
26    })
27  </script>
28</body>
29
30</html>

1、初始化

初始化 Vue 實例,這個過程會做很多事情,比如初始化生命週期、data、computed、Method 等。我們將實例中傳入的數據,進行在構造函數中接收。


 1class Vue {
 2  // 傳參接收
 3  constructor(options) {
 4    this.$el = options.el;
 5    this.$data = options.data;
 6    let computed = options.computed;
 7    let methods = options.methods;
 8
 9    // 判斷 $el 根元素是否存在
10    if (this.$el) {
11      // 1、數據劫持
12      new Observer(this.$data);
13
14      // 2、computed 實現
15      this.relatedComputed(computed);
16
17      // 3、methods 實現
18      this.relatedMethods(methods);
19
20      // 4、編譯模板
21      new Compile(this.$el, this);
22
23      // ....
24
25    }
26  }
27}

以上代碼中,判斷當前 $el 是否存在,如果存在,就開始初始化響應式系統以及 computed 、methods的實現,最後編譯模板,顯示在視圖上。

2、數據劫持

響應式的原理就是通過 Object.defineProperty 數據劫持來實現的,也就上述代碼中的 new Observer(this.$data)過程,這個過程發生了什麼?以及如何對 data 中各種類型數據進行監聽的,下面直接看核心實現原理部分。

先看整體的實現代碼,然後分別進行拆分講解:


 1class Observer {
 2  constructor(data) {
 3    this.observer(data);
 4  }
 5
 6  // 觀察者(監聽對象的響應式)
 7  observer(obj) {
 8    // 判斷是否爲對象
 9    if (typeof obj !== "object" || obj == null) return obj;
10
11    // 實時響應數組中對象的變化
12    if (Array.isArray(obj)) {
13      Object.setPrototypeOf(obj, proto);
14      this.observerArray(obj);
15    } else {
16      // 遍歷對象 key value 監聽值的變化
17      for (let key in obj) {
18        this.defineReactive(obj, key, obj[key]);
19      }
20    }
21  }
22
23  defineReactive(obj, key, value) {
24    // value 可能是對象,需要進行遞歸
25    this.observer(value);
26    Object.defineProperty(obj, key, {
27      get() {
28        return value;
29      },
30      set: newValue => {
31        if (newValue !== value) {
32          // 傳入的可能也是對象,需要遞歸
33          this.observer(value);
34          value = newValue;
35          console.log('-------------------------視圖更新-----------------------------')
36        }
37      }
38    });
39  }

首先,聲明一個 Observer 類,接收傳入 data 中要給頁面渲染的數據。


1class Observer {
2  constructor(data) {
3    this.observer(data);
4  } 
5}

調用 this.observer(data) 方法,遍歷 data 中的每個數據進,都通過 Object.defineProperty() 方法設置上監聽。

3、監聽對象

observer() 方法實現主要用於實時響應數組中對象的變化。


 1observer(obj) {
 2  // 判斷是否爲對象
 3  if (typeof obj !== "object" || obj == null) return obj;
 4
 5  // 遍歷對象 key value 監聽值的變化
 6  for (let key in obj) {
 7      this.defineReactive(obj, key, obj[key]);
 8  }
 9}
10
11defineReactive(obj, key, value) {
12  // 遞歸創建 響應式數據,性能不好
13  this.observer(value);  // 遞歸
14  Object.defineProperty(obj, key, {
15    get() {
16      return value;
17    },
18    set: newValue => {
19      if (newValue !== value) {
20        // 設置某個 key 的時候,可能是一個對象
21        this.observer(value);   // 遞歸
22        value = newValue;
23        console.log('-------------------------視圖更新-----------------------------')
24      }
25    }
26  });

data 是一個對象,我們對 data 數據對象進行遍歷,通過調用 defineReactive 方法,給每個屬性分別設置監聽(set 和 get 方法)。

我們對屬性設置的監聽,只是第一層設置了監聽,如果屬性值是個對象,我們也要進行監聽。或者我們在給 Vue 實例 vm 中 data 賦值的時候,也可能是個對象,如下情況:


 1data: {
 2  student: {
 3    name: '小鹿',
 4    age: 20,
 5    address:{   // address 也是一個對象類型的值,需要對 address 中的屬性值進行監聽
 6        country:'china'
 7        province:'shandong',
 8    }
 9  }
10},

所以我們要進行遞歸,也給其設置響應式。


 1...
 2
 3defineReactive(obj, key, value) {
 4  // 遞歸創建 響應式數據,性能不好
 5  this.observer(value);  // 遞歸
 6  ...
 7}
 8...
 9
10...
11set: newValue => {
12      if (newValue !== value) {
13        // 設置某個 key 的時候,可能是一個對象
14        this.observer(value);   // 遞歸
15        value = newValue;
16        console.log('-------------------------視圖更新-----------------------------')
17      }
18    }
19...

設置好之後,當我們運行程序,給 vm 設置某一值的時候,會觸發視圖的更新。

4、監聽數組

上述我們只對對象的屬性進行監聽,但是我們希望監聽的是個數組,對於數組,用Object.defineProperty() 來設置是不起作用的(具體原因見 MDN),所以不能用此方法。

如果數組中存放的是對象,我們也應該監聽屬性的變化,比如監聽數組中 name 的變化。


1{
2  d: [1, 2, 3, { name: "小鹿" }]
3};

首先,我們判斷當前傳入的如果是數組類型,我們就調用 observerArray 方法。

1// 判斷傳入的參數如果是數組,則執行 observerArray 方法
2if (Array.isArray(obj)) {
3   this.observerArray(obj);
4}

observerArray 方法的具體實現如下:


1// 遍歷數組中的對象,並設置監聽
2observerArray(obj) {
3  for (let i = 0; i < obj.length; i++) {
4    let item = obj[i];
5    this.observer(item);    // 如果數組中是對象會被 defineReactive 監聽
6  }
7}

當我們進行下方更改值時,視圖被觸發更新。


1// 初始化 data 中的值
2{
3  d: [1, 2, 3, { name: "小鹿" }]
4}
5
6// 更改數組中的對象屬性的值
7vm.$data.d[3].name = "11";  // 此時視圖會更新

還有一點就是,當我們給當前的數組添加元素時,也要觸發視圖進行更新,比如通過下方的方式更改數組。


1// 通過 push 向 data 中的數組中添加一個值
2vm.$data.d.push({ age: "15" });

除此之外,數組中添加數據的 API 有 push、unshift、splice ,我們可以通過重寫這三個原生方法,對其調用時,進行觸發視圖更新。


 1let arrProto = Array.prototype; // 數組原型上的方法
 2let proto = Object.create(arrProto); // 複製原型上的方法
 3
 4// 重寫數組的三個方法
 5[`push`, `unshift`, `splice`].forEach(method => {
 6  proto[method] = function(...args) {
 7    // 這個數組傳入的對象也應該進行監控
 8    let inserted; // 默認沒有插入新的數據
 9    switch (method) {
10      case `push`:
11      case `unshift`:
12        inserted = args;
13        break;
14      case `splice`:
15        inserted = args.slice(2); // 截出傳入的數據
16        break;
17      default:
18        break;
19    }
20    console.log("---------------視圖更新-----------------");
21    observerArray(inserted); // 如果數組增加的值是對象類型,需要對其設置監聽
22    arrProto[method].call(this, ...args);
23  };
24});
25
26// 實時響應數組中對象的變化
27if (Array.isArray(obj)) {
28    Object.setPrototypeOf(obj, proto);  // 如果是數組,就設置重寫的數組原型對象
29    this.observerArray(obj);
30} else {
31    // 遍歷對象 key value 監聽值的變化
32    for (let key in obj) {
33        this.defineReactive(obj, key, obj[key]);
34    }
35}

好了,我們來測試一下,數組被監聽到,視圖已更新。

小鹿又熬肝寫了一份 Vue 2.0 核心原理!

5、computed 實現

computed主要是計算屬性,每當我們計算屬性所依賴的 data 屬性發生變化時,通過計算,也要更新視圖上的數據。如下實例,如果我們動態改變 this.student.name 屬性值,頁面中的 getNewName 也會發生改變。


 1let vm = new Vue({
 2    el: '#app',
 3    data: {
 4        student: {
 5            name: '小鹿',
 6            age: 20,
 7        },
 8    },
 9    computed: {
10        getNewName() {
11            return this.student.name + ‘公衆號:小鹿動畫學編程’;
12        }
13    },
14})

其實內部的原理做法就是讓 computed 內的計算屬性也依賴於 data 數據,data 變,computed 依賴的數據也變。


1relatedComputed(computed) {
2    for (let key in computed) {
3        Object.defineProperty(this.$data, key, {
4            get: () => {
5                return computed[key].call(this);
6            }
7        });
8    }
9}

6、methods 實現

我們通常調用方法是通過 vm 實例來調用方法的,所以我們要把 methods 掛載到 vm 實例上。


 1// methods
 2relatedMethods(methods) {
 3  for (let key in methods) {
 4    Object.defineProperty(this, key, {
 5      get: () => {
 6        return methods[key];
 7      }
 8    })
 9  }
10}

7、vm.$data 代理到 vm 實例上

我們一般可以通過 vm.$data.student.name = '小鹿' ,但是還可以使用 vm.student.name = ‘小鹿’。我們可以通過代理,將 vm.$data 代理到 vm 上。


 1// 代理 vm.$data
 2proxyVm(data) {
 3    for (let key in data) {
 4        // 綁定到 vm 上
 5        Object.defineProperty(this, key, {
 6            get() {
 7                return data[key];
 8            },
 9            set(newValue) {
10                data[key] = newValue;
11            }
12        });
13    }
14}

依賴收集

1、爲什麼進行依賴收集

我們 data 中的數據,有時候我們在頁面不同地方需要使用,所以當我們動態改變 data 數據的時候,如下:


1<div>{{student.name}}</div>
2<ul>
3    <li>1</li>
4    <li>{{student.name}}</li>
5</ul>
6
7vm.$data.student.name = 'xiaolu '

我們對視圖中,所有依賴 data 屬性中的值進行更新,那麼我們需要對依賴的數據的視圖進行數據依賴收集,當數據變化的時候,就對所依賴數據的視圖更新。對於依賴收集,需要使用觀察者-訂閱者模式。

2、觀察者 Watcher

觀察中的 get() 主要用於獲取當前表達式(如:student.name)的 未更新之前的值,當數據更新時,我們就調用 update 方法,就拿出新值和老值對比,如果有變化,我們就更新相對應的視圖。


 1// 觀察者
 2class Watcher {
 3  /**
 4   * @param {*} vm 當前實例
 5   * @param {*} expr 觀察的值表達式
 6   * @param {*} cb 回調函數
 7   */
 8  constructor(vm, expr, cb) {
 9    this.vm = vm;
10    this.expr = expr;
11    this.cb = cb;
12    // 默認存放一個老值(取出當前表達式的值)
13    this.oldValue = this.get();
14  }
15
16  get() {
17    Dep.target = this;
18    let value = CompileUtil.getValue(this.vm, this.expr);// 根據視圖中的表達式,取 data 中的值
19    Dep.target = null; // 不取消任何值取值 都會添加 water
20    return value;
21  }
22
23  // -> 數據變化後,會調用觀察者的 update 方法
24  update() {
25    let newValue = CompileUtil.getValue(this.vm, this.expr);// 根據視圖中的表達式,取 data 中的值
26    if (newValue !== this.oldValue) {
27      this.cb(newValue);
28    }
29  }
30}

3、訂閱者

訂閱者中主要通過 addSub 方法增加觀察者,通過 notify 通知觀察者,調用觀察者的 update 進行更新相應的視圖。


 1// 訂閱者
 2class Dep {
 3  constructor() {
 4    this.subs = []; // 存放所有的 watcher
 5  }
 6
 7  // 訂閱
 8  addSub(watcher) {
 9    this.subs.push(watcher);
10  }
11
12  // 通知
13  notify() {
14    this.subs.forEach(watcher => watcher.update());
15  }
16}

4、依賴收集

在我們更新視圖的時候進行依賴收集,給每個屬性創建一個發佈訂閱的功能,當我們的值在 set 中改變時,我們就觸發訂閱者的通知,讓各個依賴該數據的視圖進行更新。


 1defineReactive(obj, key, value) {
 2  // 遞歸創建 響應式數據,性能不好
 3  this.observer(value);
 4  let dep = new Dep(); // 給每一個屬性都加上一個具有發佈訂閱的功能
 5  Object.defineProperty(obj, key, {
 6    get() {
 7      // 創建 watcher 時,會取到響應內容,並且把 watcher 放到了全局上
 8      Dep.target && dep.addSub(Dep.target);  // 增加觀察者
 9      return value;
10    },
11    set: newValue => {
12      if (newValue !== value) {
13        // 設置某個 key 的時候,可能是一個對象
14        this.observer(value);
15        value = newValue;
16        console.log('-------------------------視圖更新-----------------------------')
17        dep.notify(); // 通知
18      }
19    }
20  });

剩下的就是我們調用 new Watcher 地方了,這個過程在編譯模板裏邊。

三、編譯模板

對於模板的編譯,我們首先需要判斷傳入的 el 類型,然後拿到頁面的結點到內存中去,把節點上有數據編譯的地方,比如:v-model、v-on、{{student.name}} 進行數據的替換,然後再塞回頁面,就完成的頁面的顯示。


 1// 編譯類
 2class Compile {
 3  constructor(el, vm) {
 4    // 判斷 el 傳入的類型
 5    this.el = this.isElementNode(el) ? el : document.querySelector(el);
 6    this.vm = vm;
 7
 8    // 把當前節點放到內存中去 —— 之所以塞到內存中,是因爲頻繁渲染造成迴流和重繪
 9    let fragment = this.nodefragment(this.el);
10
11    // 把節點在內存中將表達式和命令等進行數據替換
12    this.compile(fragment);
13
14    // 把內容塞回頁面
15    this.el.appendChild(fragment);
16  }
17}

1、將 DOM 拿到內存

首先我們之前已經聲明好 data 了,如下:


1 let vm = new Vue({
2     el: '#app',
3     data: {
4         student: {
5             name: '小鹿',
6             age: 20,
7         },
8     }
9 })

然後我們需要拿到頁面的模板,將頁面中的一些指令(v-model="student.name")或者表達{{student.name}} 的結點替換成我們對應的屬性值。

我們需要通過傳入的 el 屬性值先拿到頁面的 dom 到內存中。


 1/**
 2   * 將 DOM 拿到內存中
 3   * @param {*} node DOM
 4   */
 5nodefragment(node) {
 6    let fragment = document.createDocumentFragment();
 7    let firstChild;
 8    while ((firstChild = node.firstChild)) {
 9        fragment.appendChild(firstChild);
10    }
11    return fragment;
12}

2、數據替換

我們下一步需要將頁面中的這些表達式,替換成相對應的 data 中的屬性值,那麼頁面就將完成的呈現出帶有數據的視圖來。


1<div id="app">
2    <input type="text" v-model="student.name">
3    {{student.age}}
4</div>

通過上邊的方法,已經將所有的頁面結點循環遍歷拿到。下一步開始進行一層層的遍歷,將數據在內存中進行替換。

1/**
 2 * 核心編譯方法
 3 * 編譯內存中的 DOM 節點
 4 * @param {*} node
 5 */
 6compile(node) {
 7  let childNodes = node.childNodes;
 8  [...childNodes].forEach(child => {
 9    // 判斷當前的是元素還是文本節點
10    if (this.isElementNode(child)) {
11      this.compileElement(child);
12      // 如果是元素的話,需要把自己傳進去,再去遍歷子節點
13      this.compile(child);
14    } else {
15      this.compileText(child); // 文本節點有 {{student.age}}
16    }
17  });
18}
19
20/**
21 * 判斷當前傳入的節點是不是元素節點
22 * @param {*} node 節點
23 */
24isElementNode(node) {
25  return node.nodeType == 1; // 1 代表元素節點
26}

this.isElementNode(child)

頁面是由很多的 node 結點構成,在上邊的頁面中,v-model="student.name" 主要存在與元素節點中,{{student.age}} 表達式的值存在於文本節點中,所以我們需要通過 this.isElementNode(child) 進行判斷當前是否爲元素節點,然後對當前節點進行不同的處理。

對於元素節點,我們調用 compileElement(child)方法,當然,元素節點中可能存在子節點的情況,所以我們需要遞歸判斷元素節點裏是否還有子節點,再次調用 this.compile(child); 方法。

我們以解析 v-model 指令爲例,開始對節點進行解析判斷賦值。


1<input type="text" v-model="student.name">

 1/**
 2 * 編譯元素節點 —— 判斷是否存在 v- 指令
 3 * @param {*} node
 4 */
 5compileElement(node) {
 6  let attributes = node.attributes; 
 7  [...attributes].forEach(attr => {
 8    // type = "text" v-model="student.name"
 9    let { name, value: expr } = attr; // name:v-model  expr:"student.name"
10    // 判斷當前是否存在屬性爲 v- 的指令
11    if (this.isDirective(name)) {
12      // v-html  v-bind  v-model
13      let [, directive] = name.split("-");
14      let [directiveName, eventName] = directive.split(":"); // v-on:click
15      // 調用不同的指令來處理
16      CompileUtil[directiveName](node, expr, this.vm, eventName);
17    }
18  });
19}
20
21/**
22 * 判斷是夠是 v- 開頭的指令
23 * @param {*} attrName
24 */
25isDirective(attrName) {
26  return attrName.startsWith("v-");
27}

同時我們還有一個工具類 CompileUtil,主要用於把對應的 data 數據插入到對應節點中。

上一步中,我們通過 let [directiveName, eventName] = directive.split(":") 解析出了 directiveName= v-model ,eventName = student.name。

然後我們將兩個參數 directiveName 和 eventName 傳入工具類對象中。

1// node: 當前節點 expr:當前表達式(student.name) vm:當前 vue 實例
2CompileUtil[directiveName](node, expr, this.vm, eventName);

通過調用不同的指令進行不同的處理。


 1/**
 2 * 工具類(把數據插入到 DOM 中)
 3 * expr: 指令的值(v-model="student.name" 中的 student.name)
 4 */
 5let CompileUtil = {
 6  // ---------------------- 匹配指令或者表達式的函數 ----------------------
 7  // 匹配 v-model
 8  model(node, expr, vm) {
 9    let fn = this.updater["modelUpdater"];
10    new Watcher(vm, expr, newValue => {
11      // 給輸入框添加一個觀察者,如果數據更新了,會觸發此方法,將新值付給 input
12      fn(node, newValue);
13    });
14    // 給 input 綁定事件
15    node.addEventListener("input", e => {
16      let value = e.target.value; // 獲取用戶輸入的內容
17      this.setValue(vm, expr, value);
18    });
19    let value = this.getValue(vm, expr);
20    fn(node, value);
21  },
22
23  // ---------------- 其他用到的工具函數 -------------------
24  // $data取值 [student, name]
25  getValue(vm, expr) {
26    return expr.split(".").reduce((data, current) => {
27      return data[current];
28    }, vm.$data);
29  },
30
31  // 給 vm.$data 中數據賦值
32  setValue(vm, expr, value) {
33    expr.split(".").reduce((data, current, index, arr) => {
34      // 如果遍歷取到最後一個,我就給賦值
35      if (index == arr.length - 1) {
36        return (data[current] = value);
37      }
38      return data[current];
39    }, vm.$data);
40  },
41
42  // -------------- 給對應的 dom 進行賦值 -------------------
43  updater: {
44    modelUpdater(node, value) {
45      // 處理指令結點 v-model
46      node.value = value;
47    }
48  }
49};

以上就會觸發這個函數:


 1// 匹配 v-model
 2model(node, expr, vm) {
 3    let fn = this.updater["modelUpdater"];
 4    new Watcher(vm, expr, newValue => {
 5        // 給輸入框添加一個觀察者,如果數據更新了,會觸發此方法,將新值付給 input
 6        fn(node, newValue);
 7    });
 8    // 給 input 綁定事件
 9    node.addEventListener("input", e => {
10        let value = e.target.value; // 獲取用戶輸入的內容
11        this.setValue(vm, expr, value);
12    });
13    let value = this.getValue(vm, expr);
14    fn(node, value);
15},

同時我們看到了 new Watch 對該屬性創建一個觀察者,用於以後數據更新時,通知視圖進行相應的更新的。


1new Watcher(vm, expr, newValue => {
2    // 給輸入框添加一個觀察者,如果數據更新了,會觸發此方法,將新值付給 input
3    fn(node, newValue);
4});

同時又給 input 綁定了一個事件,用於實現對 input 框的監聽,相對應的 data 也要更新,這就實現了v-model輸入框的雙向綁定功能。


1// 給 input 綁定事件
2node.addEventListener("input", e => {
3    let value = e.target.value; // 獲取用戶輸入的內容
4    this.setValue(vm, expr, value);
5});

每當 data 數據被改變,我們就觸發 this.updater 中的視圖更新函數。


1let fn = this.updater["textUpdater"];
2fn(node, value);
1// 給 dom 文本結點賦值數據
2updater: {
3  modelUpdater(node, value) {
4    // 處理指令結點 v-model
5    node.value = value;
6  }
7}

對於文本節點,調用 this.compileText(child) 方法和以上同樣的實現方法。這一部分的整體實現代碼如下:


 1**
 2 * 工具類(把數據插入到 DOM 中)
 3 * expr: 指令的值(v-model="school.name" 中的 school.name)
 4 */
 5let CompileUtil = {
 6  // $data取值 [school, name]
 7  getValue(vm, expr) {
 8    return expr.split(".").reduce((data, current) => {
 9      return data[current];
10    }, vm.$data);
11  },
12
13  // 給 vm.$data 中數據賦值
14  setValue(vm, expr, value) {
15    expr.split(".").reduce((data, current, index, arr) => {
16      // 如果遍歷取到最後一個,我就給賦值
17      if (index == arr.length - 1) {
18        return (data[current] = value);
19      }
20      return data[current];
21    }, vm.$data);
22  },
23
24  // 匹配 v-model
25  model(node, expr, vm) {
26    let fn = this.updater["modelUpdater"];
27    new Watcher(vm, expr, newValue => {
28      // 給輸入框添加一個觀察者,如果數據更新了,會觸發此方法,將新值付給 input
29      fn(node, newValue);
30    });
31    // 給 input 綁定事件
32    node.addEventListener("input", e => {
33      let value = e.target.value; // 獲取用戶輸入的內容
34      this.setValue(vm, expr, value);
35    });
36    let value = this.getValue(vm, expr);
37    fn(node, value);
38  },
39
40  html(node, expr, vm) {
41    //xss
42    let fn = this.updater["htmlUpdater"];
43    new Watcher(vm, expr, newValue => {
44      console.log(newValue);
45      fn(node, newValue);
46    });
47    let value = this.getValue(vm, expr);
48    fn(node, value);
49  },
50
51  // 獲取 {{a}} 中的值
52  getContentValue(vm, expr) {
53    // 遍歷表達式 將內容 重新特換成一個完整的內容 返還出去
54    return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
55      return this.getValue(vm, args[1]);
56    });
57  },
58
59  // v-on:click="change"
60  on(node, expr, vm, eventName) {
61    node.addEventListener(eventName, e => {
62      vm[expr].call(vm, e);
63    });
64  },
65
66  // 可能存在 {{a}} {{b}} 多個樣式
67  text(node, expr, vm) {
68    let fn = this.updater["textUpdater"];
69    let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
70      // 給表達式 {{}} 中的值添加一個觀察者,如果數據更新了,會觸發此方法
71      new Watcher(vm, args[1], () => {
72        fn(node, this.getContentValue(vm, expr)); // 返回一個全新的字符串
73      });
74      return this.getValue(vm, args[1]);
75    });
76    fn(node, content);
77  },
78
79// 給 dom 文本結點賦值數據
80updater: {
81  modelUpdater(node, value) {
82    // 處理指令結點 v-model
83    node.value = value;
84  },
85  textUpdater(node, value) {
86    // 處理文本結點 {{}}
87    node.textContent = value;
88  },
89  htmlUpdater(node, value) {
90    // 處理指令結點 v-html
91    node.innerHTML = value;
92  }
93}
94};

3、塞回頁面

此時,我們將渲染好的 fragment 塞回到真實 DOM中就可以正常顯示了。


1this.el.appendChild(fragment);

當我們在輸入框中輸入數據時,相對應的視圖上 {{student.name}} 的地方進行實時的更新;當我們通過 vm.$data.student.name 改變數據時,輸入框內的數據也會發生改變。

小鹿又熬肝寫了一份 Vue 2.0 核心原理!

從頭到尾我們實現了一個雙向綁定。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章