JavaScript 進階之深入理解資料雙向繫結

發表於2017-08-04

前言

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

本場 Chat 是系列 Chat 的開篇,我會首先講解一下資料雙向繫結的基本原理,介紹對比一下三大框架的不同實現方式,同時會一步步完成一個簡單的mvvm示例。讀原始碼不是目的,只是一種學習的方式,目的是在讀原始碼的過程中提升自己,學習基本原理,擴充編碼的思維方式。

模板引擎實現原理

對於頁面渲染,一般分為伺服器端渲染和瀏覽器端渲染。一般來說伺服器端吐html頁面的方式渲染速度更快、更利於SEO,但是瀏覽器端渲染更利於提高開發效率和減少維護成本,是一種相關舒服的前後端協作模式,後端提供介面,前端做檢視和互動邏輯。前端通過Ajax請求資料然後拼接html字串或者使用js模板引擎、資料驅動的框架如Vue進行頁面渲染。

在ES6和Vue這類框架出現以前,前端繫結資料的方式是動態拼接html字串和js模板引擎。模板引擎起到資料和檢視分離的作用,模板對應檢視,關注如何展示資料,在模板外頭準備的資料, 關注那些資料可以被展示。模板引擎的工作原理可以簡單地分成兩個步驟:模板解析 / 編譯(Parse / Compile)和資料渲染(Render)兩部分組成,當今主流的前端模板有三種方式:

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

String-based templating

1460000010065090

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

典型的庫:

之前的一篇文章中我介紹了js模板引擎的實現思路,感興趣的朋友可以看看這裡:JavaScript進階學習(一)—— 基於正規表示式的簡單js模板引擎實現。這篇文章中我們利用正規表示式實現了一個簡單的js模板引擎,利用正則匹配查詢出模板中{{}}之間的內容,然後替換為模型中的資料,從而實現檢視的渲染。

原始碼:http://jsfiddle.net/zhaomengh…

現在ES6支援了模板字串,我們可以用比較簡單的程式碼就可以實現類似的功能:

Dom-based templating

1460000010065091

Dom-based templating 則是從DOM的角度去實現資料的渲染,我們通過遍歷DOM樹,提取屬性與DOM內容,然後將資料寫入到DOM樹中,從而實現頁面渲染。一個簡單的例子如下:

原始碼: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中檢視和資料更新的機制,我們先看一個簡單的例子:

這裡我們可以看出物件o的b屬性的值依賴於a屬性的值,同時b屬性值的變化又可以改變a屬性的值,這個過程相關的屬性值的變化都會影響其他相關的值進行更新。反過來我們看看如果不使用Object.defineProperty()方法,上述的問題通過直接給物件屬性賦值的方法實現,程式碼如下

很顯然使用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為不可列舉。

我們通過Object.defineProperty()修改如下:

這裡需要說明的是我們使用Object.defineProperty()預設情況下是enumerable屬性為false,例如:

其他描述屬性使用方法類似,不做贅述。Vue原始碼core/util/lang.jsS中定義了這樣一個方法:

Object.getOwnPropertyDescriptor()

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

語法:Object.getOwnPropertyDescriptor(obj, prop)

引數:

  • obj:在該物件上檢視屬性
  • prop:一個屬性名稱,該屬性的屬性描述符將被返回

返回值:如果指定的屬性存在於物件上,則返回其屬性描述符(property descriptor),否則返回 undefined。可以訪問“屬性描述符”內容,例如前面的例子:

Vue原始碼分析

本次我們主要分析一下Vue 資料繫結的原始碼,這裡我直接將 Vue.js 1.0.28 版本的程式碼稍作刪減拿過來進行,2.x 的程式碼基於 flow 靜態型別檢查器書寫的,程式碼除了編碼風格在整體結構上基本沒有太大改動,所以依然基於 1.x 進行分析,對於存在差異的部分加以說明。

1497679116104

監聽物件變動

定義一個物件作為資料模型,並監聽這個物件。

效果如下:

1460000010065094

監聽陣列變動

上面我們通過Object.defineProperty把物件的屬性全部轉為 getter/setter 從而實現監聽物件的變動,但是對於陣列物件無法通過Object.defineProperty實現監聽。Vue 包含一組觀察陣列的變異方法,所以它們也將會觸發檢視更新。

Vue.js 1.x 在Array.prototype原型物件上新增了$set$remove方法,在2.X後移除了,使用全域性 API Vue.setVue.delete代替了,後續我們再分析。

定義一個陣列作為資料模型,並對這個陣列呼叫變異的七個方法實現監聽。

效果如下:

1460000010065093

我們將需要監聽的陣列的原型指標指向我們定義的陣列物件,這樣我們的陣列在呼叫上面七個陣列的變異方法時,能夠監聽到變動從而實現對陣列進行跟蹤。

對於__proto__屬性,在ES2015中正式被加入到規範中,標準明確規定,只有瀏覽器必須部署這個屬性,其他執行環境不一定需要部署,所以 Vue 是先進行了判斷,當__proto__屬性存在時將原型指標__proto__指向具有變異方法的陣列物件,不存在時直接將具有變異方法掛在需要追蹤的物件上。

我們可以在上面Observer觀察者建構函式中新增對陣列的監聽,原始碼如下:

原型鏈

對於不瞭解原型鏈的朋友可以看一下我這裡畫的一個基本關係圖:1498395838385

  • 原型物件是建構函式的prototype屬性,是所有例項化物件共享屬性和方法的原型物件;
  • 例項化物件通過new建構函式得到,都繼承了原型物件的屬性和方法;
  • 原型物件中有個隱式的constructor,指向了建構函式本身。

Object.create

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

這一步是通過 Object.create 建立了一個原型物件為Array.prototype的空物件。然後通過Object.defineProperty方法對這個物件定義幾個變異的陣列方法。有些新手可能會直接修改 Array.prototype 上的方法,這是很危險的行為,這樣在引入的時候會全域性影響Array 物件的方法,而使用Object.create實質上是完全了一份拷貝,新生成的arrayMethods物件的原型指標__proto__指向了Array.prototype,修改arrayMethods 物件不會影響Array.prototype。

基於這種原理,我們通常會使用Object.create 實現類式繼承。

釋出-訂閱模式

在上面一部分我們通過Object.defineProperty把物件的屬性全部轉為 getter/setter 以及 陣列變異方法實現了對資料模型變動的監聽,在資料變動的時候,我們通過console.log列印出來提示了,但是對於框架而言,我們相關的邏輯如果直接寫在那些地方,自然是不夠優雅和靈活的,這個時候就需要引入常用的設計模式去實現,vue.js採用了釋出-訂閱模式。釋出-訂閱模式主要是為了達到一種“高內聚、低耦合”的效果。

Vue的Watcher訂閱者作為Observer和Compile之間通訊的橋樑,能夠訂閱並收到每個屬性變動的通知,執行指令繫結的相應回撥函式,從而更新檢視。

Dep 是一個資料結構,其本質是維護了一個watcher佇列,負責新增watcher,更新watcher,移除watcher,通知watcher更新。

模板編譯

compile主要做的事情是解析模板指令,將模板中的變數替換成資料,然後初始化渲染頁面檢視,並將每個指令對應的節點繫結更新函式,新增監聽資料的訂閱者,一旦資料有變動,收到通知,更新檢視。

這種實現和我們講到的Dom-based templating類似,只是更加完備,具有自定義指令的功能。在遍歷節點屬性和文字節點的時候,可以編譯具備{{}}表示式或v-xxx的屬性值的節點,並且通過新增 new Watcher()及繫結事件函式,監聽資料的變動從而對檢視實現雙向繫結。

MVVM例項

在資料繫結初始化的時候,我們需要通過new Observer()來監聽資料模型變化,通過new Compile()來解析編譯模板指令,並利用Watcher搭起Observer和Compile之間的通訊橋樑。

為了能夠直接通過例項化物件運算元據模型,我們需要為MVVM例項新增一個資料模型代理的方法:

至此我們可以通過一個小例子來說明本文的內容:

本文目的不是為了造一個輪子,而是在學習優秀框架實現的過程中去提升自己,搞清楚框架發展的前因後果,由淺及深去學習基礎,本文參考了網上很多優秀博主的文章,由於時間關係,有些內容沒有做深入探討,覺得還是有些遺憾,在後續的學習中會更多的獨立思考,提出更多自己的想法。

參考文件

相關文章