javascript基礎修煉(9)——MVVM中雙向數據綁定的基本原理

来源:https://www.cnblogs.com/dashnowords/archive/2018/11/13/9955460.html
-Advertisement-
Play Games

開發者的javascript造詣取決於對【動態】和【非同步】這兩個詞的理解水平。 一. 概述 1.1 MVVM模型 模型是前端單頁面應用中非常重要的模型之一,也是 的底層思想,如果你也因為自己學習的速度拼不過開發框架版本迭代的速度,或許也應該從更高的抽象層次去理解現代前端開發,因為其實最核心的經典思想 ...


開發者的javascript造詣取決於對【動態】和【非同步】這兩個詞的理解水平。

一. 概述

1.1 MVVM模型

MVVM模型是前端單頁面應用中非常重要的模型之一,也是Single Page Application的底層思想,如果你也因為自己學習的速度拼不過開發框架版本迭代的速度,或許也應該從更高的抽象層次去理解現代前端開發,因為其實最核心的經典思想幾乎都是不怎麼變的。關於MVVM的文章已經非常多了,本文不再贅述。

筆者之前聽過一種很形象的描述覺得有必要提一下,Model可以想象成HTML代碼ViewModel可以想象成瀏覽器,而View可以想象成我們最終看到的頁面, 那麼各個層次所扮演的角色和所需要處理的邏輯就比較清晰了。

1.2 數據綁定

數據綁定,就是將視圖層表現和模型層的數據綁定在一起,關於MVVM中的數據綁定,涉及兩個基本概念單向數據綁定雙向數據綁定,其實兩者並沒有絕對的優劣,只是適用場景不同,現×××發框架都是同時支持兩種形式的。

雙向數據綁定由Angularjs1.x發展起來,在表單等用戶體驗高度依賴於即時反饋的場景中非常便利,但並不是所有場景下都適用的,Angularjs中也可以通過ng-bind=":expr"的形式來實現單向綁定;在Flux數據流架構的影響下,更加易於追蹤和管理的單向數據流思想出現了,各主流框架也進行了實現(例如redux,vuex),在單向數據綁定的框架中,開發者仍然可以在需要的地方監聽變化來手動實現雙向綁定。

關於Angularjs1.x中如何通過臟檢查機制來實現雙向數據綁定和管理,可以參見《構建自己的AngularJS,第一部分:Scope和Digest》一文,講述得非常詳細。

二. 基於數據劫持的綁定

2.1 Vue2.0源碼的學習困惑

Vue2.0版本中的雙向數據綁定,很多開發者都知道是通過劫持屬性的get/set方法來實現的,上圖已經展示了雙向數據綁定的代碼框架,分析源碼的文章也非常多,許多文章都將重點放在了發佈訂閱模式的實現上,筆者自己閱讀時有兩大困擾點:

第一,即使通過defineProperty劫持了屬性的get/set方法,不知道數據模型和頁面之間又是如何聯繫起來的。(很多文章都是順帶一提而沒有詳述,實際上這部分對於整體理解MVVM數據流非常重要)

第二,Vue2.0在實現發佈訂閱模式的時候,使用了一個Dep類作為訂閱器來管理髮布訂閱行為,從代碼的角度講這樣做是很好的實踐,它可以將訂閱者管理(例如避免重覆訂閱)這種與業務無關的代碼解耦出來,符合單一職責的開發原則。但這樣做對於理清代碼邏輯而言會造成困擾,讓發佈-訂閱相關的代碼段變得模糊,實際上將Dep類與發佈者類合併在一起,綁定原理會更加清晰,而在代碼迭代中,考慮到更多更複雜的情況時,即使你是框架的設計者,也會很自然地選擇將Dep抽象成一個獨立的類。

如果你也在閱讀博文的時候出現同樣的困惑,強烈建議讀完本篇後自己動手實現一個MVVM的雙向綁定,你會發現很多時候你不理解一些代碼,是因為你不知道作者面對了怎樣的實際問題

2.2 從標簽開始的代碼推演

ps:下文提及的觀察者類和發佈者類是指同一個類。

2.2.1 示例代碼

我們先來寫幾個包含自定義指令的標簽:

<div id="app" class="container">
        <input type="text" d-model="myname">
        <br>
        輸入的是:<span d-bind="myname"></span>
        <br>
        <button d-click="alarm()">廣播報警</button>
</div>
<script>
       var options = {
            el:'app',
            data:{
                myname:'僵屍'
            },
            methods:{
                alarm:function (node,event) {
                    window.alert(`一大波【${this.data.myname}】正在靠近!`);
                }
            }
        }
        //初始化
        var vm = new Dash(options);
</script>

需要實現的功能就如同你在所有框架中見到的那樣:input標簽的值通過d-model指令和數據模型中的myname進行雙向綁定,span標簽的值通過d-bind指令從myname單向獲取,button標簽的點擊響應通過d-click綁定數據模型中的alarm()方法。初始化所用到的方法已經提供好了,假如我們要在一個叫做DashMVVM框架中實現數據綁定,那麼第一步要做的,是模板解析

2.2.2 模板解析

DOM標簽自身是一個樹形結構,所以需要從最外層的

為起點以遞歸的方式來進行解析。

compiler.js——模板解析器類

/**
 * 模板編譯器
 */
class Compiler{
    constructor(){
       this.strategy = new Strategy();//封裝的策略類,下一節描述
       this.strategyKeys = Object.keys(this.strategy);
    }

    /**
    *編譯方法
    *@params vm Dash類的實例(即VisualModel實例)
    *@params node 待編譯的DOM節點
    */
    compile(vm, node){
        if (node.nodeType === 3) {//解析文本節點
            this.compileTextNode(vm, node);
        }else{
            this.compileNormalNode(vm, node);
        }
    }

    /**
    *編譯文本節點,此處僅實現一個空方法,實際開發中可能是字元串轉義過濾方法
    */
    compileTextNode(vm, node){}

    /**
    *編譯DOM節點,遍歷策略類中支持的自定義指令,如果發現某個指令dir
    *則以this.Strategy[str]的方式取得對應的處理函數並執行。
    */
    compileNormalNode(vm, node){
         this.strategyKeys.forEach(key=>{
            let expr = node.getAttribute(key);
            if (expr) {
                this.strategy[key].call(vm, node, expr);
            }
        });
        //遞歸處理當前DOM標簽的子節點
        let childs = node.childNodes;
        if (childs.length > 0) {
            childs.forEach(subNode => this.compile(vm, subNode));
        }
    }
}
//為方便理解,此處直接在全局生成一個編譯器單例,實際開發中請掛載至適當的命名空間下。
window.Compiler = new Compiler();

2.2.3 策略封裝

我們使用策略模式實現一個單例的策略類Strategy,將所有指令所對應的解析方法封裝起來並傳入解析器,當解析器遞歸解析每一個標簽時,如果遇到可以識別的指令,就從策略類中直接取出對應的處理方法對當前節點進行處理即可,這樣Strategy類只需要實現一個Strategy.register( customDirective, options)方法就可以暴露出未來用以添加自定義指令的介面。(細節可參考附件中的代碼)

strategy.js——指令解析策略類

//策略類的基本結構
class Strategy{
    constructor(){
        let strategy = {
            'd-bind':function(){//...},
            'd-model':function(){//...},
            'd-click':function(){//...}
        }
        return strategy;
    }
    
    //註冊新的指令
    register(customDir,options){
        ...
    }
}

模板解析的工作就比較清晰了,相當於帶著一本《解析指南》去遍歷處理DOM樹,不難看出,實際上綁定的工作就是在策略對應的方法里來實現的,在MVVM結構種,這一步被稱為“依賴收集”

2.2.4 訂閱數據模型變化

以最基本的d-bind指令為例,通過使用strategy['d-bind']方法處理節點後,被處理的節點應該具備感知數據模型變化的能力。以上面的模板為例,當this.data.myname發生變化時,就需要將被處理節點的內容改為對應的值。此處就需要用到發佈-訂閱模式。為了實現這個方法,需要一個觀察者類Observer,它的功能是觀察數據模型的變化(通過數據劫持實現),管理訂閱者(維護一個回調隊列管理訂閱者添加的回調方法), 變化發生時通知訂閱者(依次調用訂閱者註冊的回調方法),同時將提供回調方法並執行視圖更新行為的邏輯抽象為一個訂閱者類Subscriber,訂閱者實例擁有一個update方法,當該方法被觀察者(同時也是發佈者)調用時,就會刷新對應節點的視圖,很明顯,subscriber實例需要被添加至指定的觀察者類的回調隊列中才能夠生效。

//發佈訂閱模式的偽代碼
//...
'd-bind':function(node, expr){
    //實例化訂閱者類
    let sub = new Subscriber(node, 'myname',function(){
        //更新視圖
        node.innerHTML = VM.data['myname'];
    });
    //當觀察者實例化時,需要將這個sub實例的update方法添加進
},
//...

subscriber.js——訂閱者類

class Subscriber{
    constructor(vm, exp, callback){
        this.vm = vm;
        this.exp = exp;
        this.callback = callback;
        this.value = this.vm.data[this.exp];
    }

    /**
     * 提供給發佈者調用的方法
     */
    update(){
        return this.run();
    }

    /**
     * 更新視圖時的實際執行函數
     */
    run(){
        let currentVal = this.vm.data[this.exp];
        if (this.value !== currentVal) {
            this.value = currentVal;
            this.callback.call(this.vm, this.value);
        }
    }
}

2.2.5 數據劫持

在生成一個subscriber實例後,還要實現一個observer實例,然後才能夠通過調用observer.addSub(sub)方法來將訂閱者添加進觀察者的回調隊列中。先來看一下Observer這個類的定義:

observer.js——觀察者類

/**
 * 發佈者類,同時為一個觀察者
 * 功能包括:
 * 1.觀察視圖模型上數據的變化
 * 2.變化出現時發佈變化消息給訂閱者
 */
class Observer{
    constructor(data){
        this.data = data;
        this.subQueue = {};//訂閱者Map
        this.traverse();
    }

    //遍曆數據集中各個屬性並添加觀察器具
    traverse(){
        Object.keys(this.data).forEach(key=>{
            defineReactive(this.data, key, this.data[key], this);
        });
    }

    notify(key){
        this.subQueue[key].forEach(fn=>fn.update());
    }
}

//修改對象屬性的get/set方法實現數據劫持
function defineReactive(obj, key, val, observer) {
    //當鍵的值仍然是一個對象時,遞歸處理,observe方法定義在dash.js中
    let childOb = observe(val);

    //數據劫持
    Object.defineProperty(obj, key, {
        enumerable:true,
        configurable:true,
        get:()=>{
            if (window.curSubscriber) {
                 if (observer.subQueue[key] === undefined) {observer.subQueue[key] = []};
                 observer.subQueue[key].push(window.curSubscriber);
            }
            return val;
        },
        set:(newVal)=>{
            if (val === newVal) return;
            val = newVal;
            //監聽新值
            childOb = observe(newVal);
            //通知所有訂閱者
            observer.notify(key);
        }
    })
}

觀察者類實例化時,傳入一個待觀察的數據對象,構造器調用遍歷方法來改寫數據集中每一個鍵的get/set方法,在讀取某個鍵的值時,將訂閱者監聽器(細節下一節講)添加進回調隊列,當set改變數據集中某個鍵的值時,調用觀察者的notify( )方法找到對應鍵的回調隊列並以此觸發。

上面的代碼可以應付一般情況,但存在一些明顯的問題就是集中式的回調隊列管理,subQueue實際上是一個HashMap結構:

subQueue = {
    'myname':[fn1, fn2, fn3],
    'otherAttr':[fn11,fn12, fn13],
        //...
}

不難看出這種管理回調的方式存在很多問題,遇到嵌套或重名結構就會出現覆蓋,這個時候就不難理解Vue2.0源碼中的做法了,在進行數據劫持時生成一個Dep實例,實例中維護一個回調隊列用來管理髮布訂閱,當數據模型中的屬性被set修改時,調用dep.notify( )方法來依次調用訂閱者添加的回調,當屬性被讀取而觸發get方法時,向dep實例中添加訂閱者的回調函數即可。

2.2.6 發佈訂閱的連接

截止目前為止,還有最後一個問題需要處理,就是訂閱者實例sub和發佈訂閱管理器實例dep存在於兩個不同的作用域里,那麼要怎麼通過調用dep.addSub(sub)來實現訂閱動作呢?換個問法或許你就發現這個問題其實並不難回答,在SPA框架中,兄弟組件之間如何通信呢?通常都是藉助數據上浮(公用數據提升到共同的父級組件中)或者EventBus來實現的。

這裡的做法是一致的,在策略類中某個指令對應的處理方法中,當我們準備從數據模型this.data中讀取對應的初值前,先將訂閱者實例sub掛載到一個更高的層級(附件的demo中簡單粗暴地掛載到全局,Vue2.0源碼中掛載到Dep.target),然後再去讀取this.data[expr],這個時候在expr屬性被劫持的get方法中,不僅可以訪問到屬於自己的訂閱管理器dep實例,也可以通過Dep.target訪問到當前節點所對應的訂閱者實例,那麼完成對應的訂閱邏輯就易如反掌了。

2.2.7 邏輯整合

瞭解了上述細節,我們整理一下思路,整體看一下數據綁定所經歷的各個環節:

2.2.8 Demo

有關上面示例中d-modeld-click指令綁定的實現,本文不再贅述,筆者提供了包含詳細註釋的完整Demo,有需要的讀者可以直接從附件中取用,最後Demo也會存放在我的github倉庫

2.2.9 Vue2.0中有關雙向綁定的源碼

瞭解了上述細節,可以閱讀《vue的雙向綁定原理及實現》來看看 Vue2.0的源代碼中是如何更加規範地實現雙向數據綁定的。

2.3 數據劫持綁定存在的問題

基於劫持的數據綁定方法是無法感知數組方法的,Vue2.0中使用了Hack的方法來實現對於數組元素的感知,其基本原理依舊是通過代理模式實現,在此直接給出源碼Vue源碼鏈接

//Vue2.0中有關數組方法
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// hack 以下幾個函數
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
methodsToPatch.forEach(function (method) {
  // 獲得原生函數
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    // 調用原生函數
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 觸發更新
    ob.dep.notify()
    return result
  })
})

大致的思路是為Array.prototype上幾個原生方法設置了訪問代理,並將訂閱管理器的消息發佈方法混入其中,實現了對特定數組方法的監控。

三. 基於Proxy的數據綁定

Vue官方已經確認3.0版本重構數據綁定代碼,改為Proxy實現。

Proxy對象是ES6引入的原生化的代理對象,和基於defineProperty實現數據劫持在思路上其實並沒有什麼本質區別,都是使用經典的“代理模式”來實現的,只是原生支持的Proxy編寫起來更簡潔,整個天然支持對數組變化的感知能力。ProxyReclect對象基本是成對出現使用的,屬於元編程範疇,可以從語言層面改變原有特性,Proxy可以攔截對象的數十種方法,比手動實現的代理模式要清晰很多,也要方便很多。

基本實現如下:

//使用Proxy代理數據模型對象
let watchVmData = (obj, setBind, getLogger) => {
    let handler = {
        get(target, property, receiver){
            getLogger(target, property);
            return Reflect.get(target, property, receiver);
        },
        set(target, property, value, receiver){
            setBind(value);
            return Reflect.set(target, property, value);
        }
    };
    return new Proxy(obj, handler);
};

//使用Proxy代理
let data = { myname : 1 };
let value;
let vmproxy = watchVmData(obj, (v) => {
    value = v;
},(target, property)=>{
    console.log(`Get ${property} = ${target[property]}`);
});

四. What's next

數據綁定只是MVVM模型中的冰山一角,如果你自己動手實現了上面提及的Demo,一定會發現很多明顯的問題,例如訂閱者刷新函數是直接修改DOM的,稍有開發經驗的前端工程師都會想到需要將變化收集起來,儘可能將高性能消耗的DOM操作合併在一起處理來提升效率,這就引出了一系列我們常常聽到的Virtual-DOM(虛擬DOM樹)Life-Cycle-Hook(生命周期鉤子)等等知識點,如果你對三大框架的底層原理感興趣,可以繼續探索,那是一件非常有意思的事情。

五. 總結

通過原理的學習就會發現學習【設計模式】的重要性,很多時候別人用設計模式的術語交流並不是在裝X,而是它真的代表了一些久經驗證的思想,僅僅是數據綁定這樣一個小小的知識點,就包含了類模式代理模式,原型模式,策略模式,發佈訂閱模式的運用,代碼的實現中也涉及到了單一職責開放封閉等等開發原則的考量,框架編寫是一件非常考驗基本功的事情,在基礎面前,技巧只能是浮雲。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • SQL Server的日誌傳送(log shipping)技術一直比較雞肋,尤其當SQL Server 推出了Always On技術以後,估計使用日誌傳送(log shipping)這種技術方案的企業越來越少,但是日誌傳送也有自己的一些優點,有些特殊場景或業務背景下也有其存在的價值。最近由於特殊業務... ...
  • Intent的用法 意圖的分類和用法: 隱式意圖:通過指定一組數據或者動作實現 顯示意圖:通過指定具體的activity實現 意圖的用途: 顯示意圖用於開啟自己應用內的Activity. 隱式意圖用於開啟其他應用的Activity(主要是系統應用),相比顯示意圖安全性較差. 意圖的實現: 通過Int ...
  • 歡迎大家前往 "騰訊雲+社區" ,獲取更多騰訊海量技術實踐乾貨哦~ 本文由 "落影" 發表於 "雲+社區專欄" 前言 app在渲染視圖時,需要在坐標系中指定繪製區域。 這個概念看似乎簡單,事實並非如此。 When an app draws something in iOS, it has to lo ...
  • 最近公司要在APP上添加一個人臉識別功能,在網上搜了一圈,發現虹軟的人臉識別SDK挺好用的,而且還免費,所以就下載了他們的SDK研究了一下。總的來看功能挺好用的,只是demo上面部分功能不是很完善,所以就在官方demo的基礎上改動了一些小的功能。 新增功能:1. 通過圖片註冊人臉2. 增加列表頁面可 ...
  • 歡迎大家前往 "騰訊雲+社區" ,獲取更多騰訊海量技術實踐乾貨哦~ 本文由 "goo" 發表於 "雲+社區專欄" 相信我們對Android系統都不陌生,而Android系統博大精深,被各種各樣的智能設備承載的同時,我們會否好奇過,如此複雜的Android究竟是怎麼運作起來的呢?借本文給大家分享,筆者 ...
  • 222
    11 ...
  • 在嘗試使用webRTC實現webapp直播失敗後,轉移思路開始另外尋找可行的解決方案。在網頁上嘗試使用webRTC實現視頻的直播與看直播,在谷歌瀏覽器以及safari瀏覽器上測試是可行的。但是基於基座打包為webapp後不行,所以直播的話建議還是原生的好。HBuilder自帶的H5+有提供了原生的視 ...
  • 很久沒寫總結了,在這裡跟大家分享一下自己踩的坑,同時也方便自己多記憶下。 大致流程: 使用create-react-app腳手架生成react相關部分,腳手架內部會通過node自動起一個客戶端,然後和普通的ajax請求一樣,和遠端伺服器進行通信,只不過這裡採用支持rpc通信的grpc-web來發起請 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...