JavaScript 進階之深刻理解數據雙向綁定

前言

談起當前前端最熱門的 js 框架,必少不了 Vue、React、Angular,對於大多數人來講,咱們更多的是在使用框架,對於框架解決痛點背後使用的基本原理每每關注很少,近期在研讀 Vue.js 源碼,也在寫源碼解讀的系列文章。和多數源碼解讀的文章不一樣的是,我會嘗試從一個初級前端的角度入手,由淺入深去講解源碼實現思路和基本的語法知識,經過一些基礎事例一步步去實現一些小功能。javascript

本場 Chat 是系列 Chat 的開篇,我會首先講解一下數據雙向綁定的基本原理,介紹對比一下三大框架的不一樣實現方式,同時會一步步完成一個簡單的mvvm示例。讀源碼不是目的,只是一種學習的方式,目的是在讀源碼的過程當中提高本身,學習基本原理,拓展編碼的思惟方式。html

模板引擎實現原理

對於頁面渲染,通常分爲服務器端渲染和瀏覽器端渲染。通常來講服務器端吐html頁面的方式渲染速度更快、更利於SEO,可是瀏覽器端渲染更利於提升開發效率和減小維護成本,是一種相關舒服的先後端協做模式,後端提供接口,前端作視圖和交互邏輯。前端經過Ajax請求數據而後拼接html字符串或者使用js模板引擎、數據驅動的框架如Vue進行頁面渲染。前端

在ES6和Vue這類框架出現之前,前端綁定數據的方式是動態拼接html字符串和js模板引擎。模板引擎起到數據和視圖分離的做用,模板對應視圖,關注如何展現數據,在模板外頭準備的數據, 關注那些數據能夠被展現。模板引擎的工做原理能夠簡單地分紅兩個步驟:模板解析 / 編譯(Parse / Compile)和數據渲染(Render)兩部分組成,當今主流的前端模板有三種方式:vue

  • String-based templating (基於字符串的parse和compile過程)
  • Dom-based templating (基於Dom的link或compile過程)
  • Living templating (基於字符串的parse 和 基於dom的compile過程)

String-based templating

基於字符串的模板引擎,本質上依然是字符串拼接的形式,只是通常的庫作了封裝和優化,提供了更多方便的語法簡化了咱們的工做。基本原理以下:html5

String-based-Template

典型的庫:java

以前的一篇文章中我介紹了js模板引擎的實現思路,感興趣的朋友能夠看看這裏:JavaScript進階學習(一)—— 基於正則表達式的簡單js模板引擎實現。這篇文章中咱們利用正則表達式實現了一個簡單的js模板引擎,利用正則匹配查找出模板中{{}}之間的內容,而後替換爲模型中的數據,從而實現視圖的渲染。node

var template = function(tpl, data) {
  var re = /{{(.+?)}}/g,
    cursor = 0,
    reExp = /(^( )?(var|if|for|else|switch|case|break|{|}|;))(.*)?/g,    
    code = 'var r=[];\n';

  // 解析html
  function parsehtml(line) {
    // 單雙引號轉義,換行符替換爲空格,去掉先後的空格
    line = line.replace(/('|")/g, '\\$1').replace(/\n/g, ' ').replace(/(^\s+)|(\s+$)/g,"");
    code +='r.push("' + line + '");\n';
  }
  
  // 解析js代碼        
  function parsejs(line) {   
    // 去掉先後的空格
    line = line.replace(/(^\s+)|(\s+$)/g,"");
    code += line.match(reExp)? line + '\n' : 'r.push(' + 'this.' + line + ');\n';
  }    
    
  // 編譯模板
  while((match = re.exec(tpl))!== null) {
    // 開始標籤  {{ 前的內容和結束標籤 }} 後的內容
    parsehtml(tpl.slice(cursor, match.index));
    // 開始標籤  {{ 和 結束標籤 }} 之間的內容
    parsejs(match[1]);
    // 每一次匹配完成移動指針
    cursor = match.index + match[0].length;
  }
  // 最後一次匹配完的內容
  parsehtml(tpl.substr(cursor, tpl.length - cursor));
  code += 'return r.join("");';
  return new Function(code.replace(/[\r\t\n]/g, '')).apply(data);
}

源代碼:http://jsfiddle.net/zhaomengh...react

如今ES6支持了模板字符串,咱們能夠用比較簡單的代碼就能夠實現相似的功能:android

const template = data => `
  <p>name: ${data.name}</p>
  <p>age: ${data.profile.age}</p>
  <ul>
    ${data.skills.map(skill => `
      <li>${skill}</li>
    `).join('')}
  </ul>`

const data = {
  name: 'zhaomenghuan',
  profile: { age: 24 },
  skills: ['html5', 'javascript', 'android']
}

document.body.innerHTML = template(data)

Dom-based templating

Dom-based-Template

Dom-based templating 則是從DOM的角度去實現數據的渲染,咱們經過遍歷DOM樹,提取屬性與DOM內容,而後將數據寫入到DOM樹中,從而實現頁面渲染。一個簡單的例子以下:git

function MVVM(opt) {
  this.dom = document.querySelector(opt.el);
  this.data = opt.data || {};
  this.renderDom(this.dom);
}

MVVM.prototype = {
  init: {
    sTag: '{{',
    eTag: '}}'
  },
  render: function (node) {
    var self = this;
    var sTag = self.init.sTag;
    var eTag = self.init.eTag;

    var matchs = node.textContent.split(sTag);
    if (matchs.length){
      var ret = '';
      for (var i = 0; i < matchs.length; i++) {
        var match = matchs[i].split(eTag);
        if (match.length == 1) {
            ret += matchs[i];
        } else {
            ret = self.data[match[0]];
        }
        node.textContent = ret;
      }
    }
  },
  renderDom: function(dom) {
    var self = this;

    var attrs = dom.attributes;
    var nodes = dom.childNodes;

    Array.prototype.forEach.call(attrs, function(item) {
      self.render(item);
    });

    Array.prototype.forEach.call(nodes, function(item) {
      if (item.nodeType === 1) {
        return self.renderDom(item);
      }
      self.render(item);
    });
  }
}

var app = new MVVM({
  el: '#app',
  data: {
    name: 'zhaomenghuan',
    age: '24',
    color: 'red'
  }
});

源代碼:http://jsfiddle.net/zhaomengh...

頁面渲染的函數 renderDom 是直接遍歷DOM樹,而不是遍歷html字符串。遍歷DOM樹節點屬性(attributes)和子節點(childNodes),而後調用渲染函數render。當DOM樹子節點的類型是元素時,遞歸調用遍歷DOM樹的方法。根據DOM樹節點類型一直遍歷子節點,直到文本節點。

render的函數做用是提取{{}}中的關鍵詞,而後使用數據模型中的數據進行替換。咱們經過textContent獲取Node節點的nodeValue,而後使用字符串的split方法對nodeValue進行分割,提取{{}}中的關鍵詞而後替換爲數據模型中的值。

DOM 的相關基礎

注:元素類型對應NodeType

元素類型 NodeType
元素 1
屬性 2
文本 3
註釋 8
文檔 9

childNodes 屬性返回包含被選節點的子節點的 NodeList。childNodes包含的不只僅只有html節點,全部屬性,文本、註釋等節點都包含在childNodes裏面。children只返回元素如input, span, script, div等,不會返回TextNode,註釋。

數據雙向綁定實現原理

js模板引擎能夠認爲是一個基於MVC的結構,咱們經過創建模板做爲視圖,而後經過引擎函數做爲控制器實現數據和視圖的綁定,從而實現實現數據在頁面渲染,可是當數據模型發生變化時,視圖不能自動更新;當視圖數據發生變化時,模型數據不能實現更新,這個時候雙向數據綁定應運而生。檢測視圖數據更新實現數據綁定的方法有不少種,目前主要分爲三個流派,Angular使用的是髒檢查,只在特定的事件下才會觸發視圖刷新,Vue使用的是Getter/Setter機制,而React則是經過 Virtual DOM 算法檢查DOM的變更的刷新機制。

本文限於篇幅和內容在此只探討一下 Vue.js 數據綁定的實現,對於 angular 和 react 後續再作說明,讀者也能夠自行閱讀源碼。Vue 監聽數據變化的機制是把一個普通 JavaScript 對象傳給 Vue 實例的 data 選項,Vue 將遍歷此對象全部的屬性,並使用 Object.defineProperty 把這些屬性所有轉爲 getter/setter。Vue 2.x 對 Virtual DOM 進行了支持,這部份內容後續咱們再作探討。

引子

爲了更好的理解Vue中視圖和數據更新的機制,咱們先看一個簡單的例子:

var o = {
  a: 0 
}
Object.defineProperty(o, "b", { 
  get: function () { 
    return this.a + 1; 
  },
  set: function (value) { 
    this.a = value / 2; 
  }
});
console.log(o.a); // "0"
console.log(o.b); // "1"

// 更新o.a
o.a = 5;
console.log(o.a); // "5"
console.log(o.b); // "6"

// 更新o.b
o.b = 10; 
console.log(o.a); // "5"
console.log(o.b); // "6"

這裏咱們能夠看出對象o的b屬性的值依賴於a屬性的值,同時b屬性值的變化又能夠改變a屬性的值,這個過程相關的屬性值的變化都會影響其餘相關的值進行更新。反過來咱們看看若是不使用Object.defineProperty()方法,上述的問題經過直接給對象屬性賦值的方法實現,代碼以下:

var o = {
  a: 0
}    
o.b = o.a + 1;
console.log(o.a); // "0"
console.log(o.b); // "1"

// 更新o.a
o.a = 5;
o.b = o.a + 1;
console.log(o.a); // "5"
console.log(o.b); // "6"

// 更新o.b
o.b = 10; 
o.a = o.b / 2;
o.b = o.a + 1;
console.log(o.a); // "5"
console.log(o.b); // "6"

很顯然使用Object.defineProperty()方法能夠更方便的監聽一個對象的變化。當咱們的視圖和數據任何一方發生變化的時候,咱們但願可以通知對方也更新,這就是所謂的數據雙向綁定。既然明白這個道理咱們就能夠看看Vue源碼中相關的處理細節。

Object.defineProperty()

Object.defineProperty()方法能夠直接在一個對象上定義一個新屬性,或者修改一個已經存在的屬性, 並返回這個對象。

語法:Object.defineProperty(obj, prop, descriptor)

參數:

  • obj:須要定義屬性的對象。
  • prop:需被定義或修改的屬性名。
  • descriptor:需被定義或修改的屬性的描述符。

返回值:返回傳入函數的對象,即第一個參數obj

該方法重點是描述,對象裏目前存在的屬性描述符有兩種主要形式:數據描述符存取描述符數據描述符是一個擁有可寫或不可寫值的屬性。存取描述符是由一對 getter-setter 函數功能來描述的屬性。描述符必須是兩種形式之一;不能同時是二者。

數據描述符存取描述符均具備如下可選鍵值:

  • configurable:當且僅當該屬性的 configurable 爲 true 時,該屬性纔可以被改變,也可以被刪除。默認爲 false。
  • enumerable:當且僅當該屬性的 enumerable 爲 true 時,該屬性纔可以出如今對象的枚舉屬性中。默認爲 false。

數據描述符同時具備如下可選鍵值:

  • value:該屬性對應的值。能夠是任何有效的 JavaScript 值(數值,對象,函數等)。默認爲 undefined。
  • writable:當且僅當僅當該屬性的writable爲 true 時,該屬性才能被賦值運算符改變。默認爲 false。

存取描述符同時具備如下可選鍵值:

  • get:一個給屬性提供 getter 的方法,若是沒有 getter 則爲 undefined。該方法返回值被用做屬性值。默認爲undefined。
  • set:一個給屬性提供 setter 的方法,若是沒有 setter 則爲 undefined。該方法將接受惟一參數,並將該參數的新值分配給該屬性。默認爲undefined。

咱們能夠經過Object.defineProperty()方法精確添加或修改對象的屬性。好比,直接賦值建立的屬性默認狀況是能夠枚舉的,可是咱們能夠經過Object.defineProperty()方法設置enumerable屬性爲false爲不可枚舉。

var obj = {
  a: 0,
  b: 1
}
for (var prop in obj) {
  console.log(`obj.${prop} = ${obj[prop]}`);
}

結果:
"obj.a = 0"
"obj.b = 1"

咱們經過Object.defineProperty()修改以下:

var obj = {
  a: 0,
  b: 1
}
Object.defineProperty(obj, 'b', {
  enumerable: false
})
for (var prop in obj) {
  console.log(`obj.${prop} = ${obj[prop]}`);
}

結果:
"obj.a = 0"

這裏須要說明的是咱們使用Object.defineProperty()默認狀況下是enumerable屬性爲false,例如:

var obj = {
  a: 0
}
Object.defineProperty(obj, 'b', {
  value: 1
})
for (var prop in obj) {
  console.log(`obj.${prop} = ${obj[prop]}`);
}

結果:
"obj.a = 0"

其餘描述屬性使用方法相似,不作贅述。Vue源碼core/util/lang.jsS中定義了這樣一個方法:

/**
 * Define a property.
 */
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

Object.getOwnPropertyDescriptor()

Object.getOwnPropertyDescriptor() 返回指定對象上一個自有屬性對應的屬性描述符。(自有屬性指的是直接賦予該對象的屬性,不須要從原型鏈上進行查找的屬性)

語法:Object.getOwnPropertyDescriptor(obj, prop)

參數:

  • obj:在該對象上查看屬性
  • prop:一個屬性名稱,該屬性的屬性描述符將被返回

返回值:若是指定的屬性存在於對象上,則返回其屬性描述符(property descriptor),不然返回 undefined。能夠訪問「屬性描述符」內容,例如前面的例子:

var o = {
  a: 0 
}
            
Object.defineProperty(o, "b", { 
  get: function () { 
    return this.a + 1; 
  },
  set: function (value) { 
    this.a = value / 2; 
  }
});
            
var des = Object.getOwnPropertyDescriptor(o,'b');
console.log(des);
console.log(des.get);

Vue源碼分析

本次咱們主要分析一下Vue 數據綁定的源碼,這裏我直接將 Vue.js 1.0.28 版本的代碼稍做刪減拿過來進行,2.x 的代碼基於 flow 靜態類型檢查器書寫的,代碼除了編碼風格在總體結構上基本沒有太大改動,因此依然基於 1.x 進行分析,對於存在差別的部分加以說明。

Alt text

監聽對象變更

// 觀察者構造函數
function Observer (value) {
  this.value = value
  this.walk(value)
}

// 遞歸調用,爲對象綁定getter/setter
Observer.prototype.walk = function (obj) {
  var keys = Object.keys(obj)
  for (var i = 0, l = keys.length; i < l; i++) {
    this.convert(keys[i], obj[keys[i]])
  }
}

// 將屬性轉換爲getter/setter
Observer.prototype.convert = function (key, val) {
  defineReactive(this.value, key, val)
}

// 建立數據觀察者實例
function observe (value) {
  // 當值不存在或者不是對象類型時,不須要繼續深刻監聽
  if (!value || typeof value !== 'object') {
    return
  }
  return new Observer(value)
}

// 定義對象屬性的getter/setter
function defineReactive (obj, key, val) {
  var property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // 保存對象屬性預先定義的getter/setter
  var getter = property && property.get
  var setter = property && property.set

  var childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      var value = getter ? getter.call(obj) : val
      console.log("訪問:"+key)
      return value
    },
    set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val
      if (newVal === value) {
        return
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 對新值進行監聽
      childOb = observe(newVal)
      console.log('更新:' + key + ' = ' + newVal)
    }
  })
}

定義一個對象做爲數據模型,並監聽這個對象。

let data = {
  user: {
    name: 'zhaomenghuan',
    age: '24'
  },
  address: {
    city: 'beijing'
  }
}
observe(data)

console.log(data.user.name) 
// 訪問:user 
// 訪問:name

data.user.name = 'ZHAO MENGHUAN'
// 訪問:user
// 更新:name = ZHAO MENGHUAN

效果以下:

監聽數組變更

上面咱們經過Object.defineProperty把對象的屬性所有轉爲 getter/setter 從而實現監聽對象的變更,可是對於數組對象沒法經過Object.defineProperty實現監聽。Vue 包含一組觀察數組的變異方法,因此它們也將會觸發視圖更新。

const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)

function def(obj, key, val, enumerable) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

// 數組的變異方法
;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
.forEach(function (method) {
  // 緩存數組原始方法
  var original = arrayProto[method]
  def(arrayMethods, method, function mutator () {
    var i = arguments.length
    var args = new Array(i)
    while (i--) {
      args[i] = arguments[i]
    }
    console.log('數組變更')
    return original.apply(this, args)
  })
})

Vue.js 1.x 在Array.prototype原型對象上添加了$set$remove方法,在2.X後移除了,使用全局 API Vue.setVue.delete代替了,後續咱們再分析。

定義一個數組做爲數據模型,並對這個數組調用變異的七個方法實現監聽。

let skills = ['JavaScript', 'Node.js', 'html5']
// 原型指針指向具備變異方法的數組對象
skills.__proto__ = arrayMethods

skills.push('java')
// 數組變更
skills.pop()
// 數組變更

效果以下:

咱們將須要監聽的數組的原型指針指向咱們定義的數組對象,這樣咱們的數組在調用上面七個數組的變異方法時,可以監聽到變更從而實現對數組進行跟蹤。

對於__proto__屬性,在ES2015中正式被加入到規範中,標準明確規定,只有瀏覽器必須部署這個屬性,其餘運行環境不必定須要部署,因此 Vue 是先進行了判斷,當__proto__屬性存在時將原型指針__proto__指向具備變異方法的數組對象,不存在時直接將具備變異方法掛在須要追蹤的對象上。

咱們能夠在上面Observer觀察者構造函數中添加對數組的監聽,源碼以下:

const hasProto = '__proto__' in {}
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)

// 觀察者構造函數
function Observer (value) {
  this.value = value
  if (Array.isArray(value)) {
    var augment = hasProto
      ? protoAugment
      : copyAugment
    augment(value, arrayMethods, arrayKeys)
    this.observeArray(value)
  } else {
    this.walk(value)
  }
}

// 觀察數組的每一項
Observer.prototype.observeArray = function (items) {
  for (var i = 0, l = items.length; i < l; i++) {
    observe(items[i])
  }
}

// 將目標對象/數組的原型指針__proto__指向src
function protoAugment (target, src) {
  target.__proto__ = src
}

// 將具備變異方法掛在須要追蹤的對象上
function copyAugment (target, src, keys) {
  for (var i = 0, l = keys.length; i < l; i++) {
    var key = keys[i]
    def(target, key, src[key])
  }
}

原型鏈

對於不瞭解原型鏈的朋友能夠看一下我這裏畫的一個基本關係圖:
Alt text

  • 原型對象是構造函數的prototype屬性,是全部實例化對象共享屬性和方法的原型對象;
  • 實例化對象經過new構造函數獲得,都繼承了原型對象的屬性和方法;
  • 原型對象中有個隱式的constructor,指向了構造函數自己。

Object.create

Object.create 使用指定的原型對象和其屬性建立了一個新的對象。

const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)

這一步是經過 Object.create 建立了一個原型對象爲Array.prototype的空對象。而後經過Object.defineProperty方法對這個對象定義幾個變異的數組方法。有些新手可能會直接修改 Array.prototype 上的方法,這是很危險的行爲,這樣在引入的時候會全局影響Array 對象的方法,而使用Object.create實質上是徹底了一份拷貝,新生成的arrayMethods對象的原型指針__proto__指向了Array.prototype,修改arrayMethods 對象不會影響Array.prototype。

基於這種原理,咱們一般會使用Object.create 實現類式繼承。

// 實現繼承
var extend = function(Child, Parent) {
    // 拷貝Parent原型對象
    Child.prototype = Object.create(Parent.prototype);
    // 將Child構造函數賦值給Child的原型對象
    Child.prototype.constructor = Child;
}

// 實例
var Parent = function () {
    this.name = 'Parent';
}
Parent.prototype.getName = function () {
    return this.name;
}
var Child = function () {
    this.name = 'Child';
}
extend(Child, Parent);
var child = new Child();
console.log(child.getName())

發佈-訂閱模式

在上面一部分咱們經過Object.defineProperty把對象的屬性所有轉爲 getter/setter 以及 數組變異方法實現了對數據模型變更的監聽,在數據變更的時候,咱們經過console.log打印出來提示了,可是對於框架而言,咱們相關的邏輯若是直接寫在那些地方,天然是不夠優雅和靈活的,這個時候就須要引入經常使用的設計模式去實現,vue.js採用了發佈-訂閱模式。發佈-訂閱模式主要是爲了達到一種「高內聚、低耦合"的效果。

Vue的Watcher訂閱者做爲Observer和Compile之間通訊的橋樑,可以訂閱並收到每一個屬性變更的通知,執行指令綁定的相應回調函數,從而更新視圖。

/**
 * 觀察者對象
 */
function Watcher(vm, expOrFn, cb) {
    this.vm = vm
    this.cb = cb
    this.depIds = {}
    if (typeof expOrFn === 'function') {
        this.getter = expOrFn
    } else {
        this.getter = this.parseExpression(expOrFn)
    }
    this.value = this.get()
}

/**
 * 收集依賴
 */
Watcher.prototype.get = function () {
    // 當前訂閱者(Watcher)讀取被訂閱數據的最新更新後的值時,通知訂閱者管理員收集當前訂閱者
    Dep.target = this
    // 觸發getter,將自身添加到dep中
    const value = this.getter.call(this.vm, this.vm)
    // 依賴收集完成,置空,用於下一個Watcher使用
    Dep.target = null
    return value
}

Watcher.prototype.addDep = function (dep) {
    if (!this.depIds.hasOwnProperty(dep.id)) {
        dep.addSub(this)
        this.depIds[dep.id] = dep
    }
}

/**
 * 依賴變更更新
 *
 * @param {Boolean} shallow
 */
Watcher.prototype.update = function () {
    this.run()
}

Watcher.prototype.run = function () {
    var value = this.get()
    if (value !== this.value) {
        var oldValue = this.value
        this.value = value
        // 將newVal, oldVal掛載到MVVM實例上
        this.cb.call(this.vm, value, oldValue)
    }
}

Watcher.prototype.parseExpression = function (exp) {
    if (/[^\w.$]/.test(exp)) {
        return
    }
    var exps = exp.split('.')
    
    return function(obj) {
        for (var i = 0, len = exps.length; i < len; i++) {
            if (!obj) return
            obj = obj[exps[i]]
        }
        return obj
    }
}

Dep 是一個數據結構,其本質是維護了一個watcher隊列,負責添加watcher,更新watcher,移除watcher,通知watcher更新。

let uid = 0

function Dep() {
    this.id = uid++
    this.subs = []
}

Dep.target = null

/**
 * 添加一個訂閱者
 *
 * @param {Directive} sub
 */
Dep.prototype.addSub = function (sub) {
    this.subs.push(sub)
}

/**
 * 移除一個訂閱者
 *
 * @param {Directive} sub
 */
Dep.prototype.removeSub = function (sub) {
    let index = this.subs.indexOf(sub);
    if (index !== -1) {
        this.subs.splice(index, 1);
    }
}

/**
 * 將自身做爲依賴添加到目標watcher
 */
Dep.prototype.depend = function () {
    Dep.target.addDep(this)
}

/**
 * 通知數據變動
 */
Dep.prototype.notify = function () {
    var subs = toArray(this.subs)
    // stablize the subscriber list first
    for (var i = 0, l = subs.length; i < l; i++) {
        // 執行訂閱者的update更新函數
        subs[i].update()
    }
}

模板編譯

compile主要作的事情是解析模板指令,將模板中的變量替換成數據,而後初始化渲染頁面視圖,並將每一個指令對應的節點綁定更新函數,添加監聽數據的訂閱者,一旦數據有變更,收到通知,更新視圖。

function Compile(el, value) {
    this.$vm = value
    this.$el = this.isElementNode(el) ? el : document.querySelector(el)
    if (this.$el) {
        this.compileElement(this.$el)
    }
}

Compile.prototype.compileElement = function (el) {
    let self = this
    let childNodes = el.childNodes

    ;[].slice.call(childNodes).forEach(node => {
        let text = node.textContent
        let reg = /\{\{((?:.|\n)+?)\}\}/
        // 處理element節點
        if (self.isElementNode(node)) {
            self.compile(node)
        } else if (self.isTextNode(node) && reg.test(text)) { // 處理text節點
            self.compileText(node, RegExp.$1.trim())
        }
        // 解析子節點包含的指令
        if (node.childNodes && node.childNodes.length) {
            self.compileElement(node)
        }
    })
}

Compile.prototype.compile = function (node) {
    let nodeAttrs = node.attributes
    let self = this

    ;[].slice.call(nodeAttrs).forEach(attr => {
        var attrName = attr.name
        if (self.isDirective(attrName)) {
            let exp = attr.value
            let dir = attrName.substring(2)
            if (self.isEventDirective(dir)) {
                compileUtil.eventHandler(node, self.$vm, exp, dir)
            } else {
                compileUtil[dir] && compileUtil[dir](node, self.$vm, exp)
            }
            node.removeAttribute(attrName)
        }
    });
}

Compile.prototype.compileText = function (node, exp) {
    compileUtil.text(node, this.$vm, exp);
}

Compile.prototype.isDirective = function (attr) {
    return attr.indexOf('v-') === 0
}

Compile.prototype.isEventDirective = function (dir) {
    return dir.indexOf('on') === 0;
}

Compile.prototype.isElementNode = function (node) {
    return node.nodeType === 1
}

Compile.prototype.isTextNode = function (node) {
    return node.nodeType === 3
}

// 指令處理集合
var compileUtil = {
    text: function (node, vm, exp) {
        this.bind(node, vm, exp, 'text')
    },
    html: function (node, vm, exp) {
        this.bind(node, vm, exp, 'html')
    },
    model: function (node, vm, exp) {
        this.bind(node, vm, exp, 'model')

        let self = this, val = this._getVMVal(vm, exp)
        node.addEventListener('input', function (e) {
            var newValue = e.target.value
            if (val === newValue) {
                return
            }
            self._setVMVal(vm, exp, newValue)
            val = newValue
        });
    },
    bind: function (node, vm, exp, dir) {
        var updaterFn = updater[dir + 'Updater']
        updaterFn && updaterFn(node, this._getVMVal(vm, exp))
        new Watcher(vm, exp, function (value, oldValue) {
            updaterFn && updaterFn(node, value, oldValue)
        })
    },
    eventHandler: function (node, vm, exp, dir) {
        var eventType = dir.split(':')[1],
            fn = vm.$options.methods && vm.$options.methods[exp];

        if (eventType && fn) {
            node.addEventListener(eventType, fn.bind(vm), false);
        }
    },
    _getVMVal: function (vm, exp) {
        var val = vm
        exp = exp.split('.')
        exp.forEach(function (k) {
            val = val[k]
        })
        return val
    },
    _setVMVal: function (vm, exp, value) {
        var val = vm;
        exp = exp.split('.')
        exp.forEach(function (k, i) {
            // 非最後一個key,更新val的值
            if (i < exp.length - 1) {
                val = val[k]
            } else {
                val[k] = value
            }
        })
    }
}

var updater = {
    textUpdater: function (node, value) {
        node.textContent = typeof value == 'undefined' ? '' : value
    },
    htmlUpdater: function (node, value) {
        node.innerHTML = typeof value == 'undefined' ? '' : value
    },
    modelUpdater: function (node, value, oldValue) {
        node.value = typeof value == 'undefined' ? '' : value
    }
}

這種實現和咱們講到的Dom-based templating相似,只是更加完備,具備自定義指令的功能。在遍歷節點屬性和文本節點的時候,能夠編譯具有{{}}表達式或v-xxx的屬性值的節點,而且經過添加 new Watcher()及綁定事件函數,監聽數據的變更從而對視圖實現雙向綁定。

MVVM實例

在數據綁定初始化的時候,咱們須要經過new Observer()來監聽數據模型變化,經過new Compile()來解析編譯模板指令,並利用Watcher搭起Observer和Compile之間的通訊橋樑。

/**
 * @class 雙向綁定類 MVVM
 * @param {[type]} options [description]
 */
function MVVM(options) {
    this.$options = options || {}
    // 簡化了對data的處理
    let data = this._data = this.$options.data
    // 監聽數據
    observe(data)
    new Compile(options.el || document.body, this)
}

MVVM.prototype.$watch = function (expOrFn, cb) {
    new Watcher(this, expOrFn, cb)
}

爲了可以直接經過實例化對象操做數據模型,咱們須要爲MVVM實例添加一個數據模型代理的方法:

MVVM.prototype._proxy = function (key) {
    Object.defineProperty(this, key, {
        configurable: true,
        enumerable: true,
        get: () => this._data[key],
        set: (val) => {
            this._data[key] = val
        }
    })
}

至此咱們能夠經過一個小例子來講明本文的內容:

<div id="app">
    <h3>{{user.name}}</h3>
    <input type="text" v-model="modelValue">
    <p>{{modelValue}}</p>
</div>
<script>
    let vm = new MVVM({
        el: '#app',
        data: {
            modelValue: '',
            user: {
                name: 'zhaomenghuan',
                age: '24'
            },
            address: {
                city: 'beijing'
            },
            skills: ['JavaScript', 'Node.js', 'html5']
        }
    })
    
    vm.$watch('modelValue', val => console.log(`watch modelValue :${val}`))
</script>

本文目的不是爲了造一個輪子,而是在學習優秀框架實現的過程當中去提高本身,搞清楚框架發展的來龍去脈,由淺及深去學習基礎,本文參考了網上不少優秀博主的文章,因爲時間關係,有些內容沒有作深刻探討,以爲仍是有些遺憾,在後續的學習中會更多的獨立思考,提出更多本身的想法。

參考文檔

說明

本文的完整代碼及圖片能夠在這裏下載:learn-javascript/mvvm


原文首發於 GitChat :http://gitbook.cn/books/593fa...,歡迎關注個人新話題:JavaScript 進階之 Vue.js + Node.js 入門實戰開發

我在segmentfault上有兩期講座,歡迎來圍觀:
html5+ App開發工程化實踐之路
html5+ App開發之 Android 平臺離線集成 5+ SDK

相關文章
相關標籤/搜索