mv*設(shè)計思想

前言

Vue是一個非常出名的MVVM框架吉嚣,其設(shè)計理念主要核心是view-model。現(xiàn)在Vue3使用的是Proxy阻肿,而過去則是Object.defineProperty。這篇轉(zhuǎn)文主要參考的是后者相關(guān)資料以及設(shè)計理念丛塌,從實現(xiàn)一個簡單的Vue著手畜疾,很有閱讀價值。


MVVM雙向綁定的簡單流程

首先是思路上的明確啡捶。Vue中采用的是Object.defineProperty()中的settergetter來對每個屬性進行劫持彤敛,同時結(jié)合訂閱者-發(fā)布者模式的方式,實現(xiàn)數(shù)據(jù)模版的自動更新墨榄。

簡單的流程可以這樣來理解:

一、MVVM編譯前新建一個Observer對象來攔截data里面的每個數(shù)據(jù)阵翎,大概有以下幾個步驟:

  1. 使用閉包之剧,讓每個屬性有一個各自的Dep對象來存放自己的依賴隊列,里面是一系列的Watcher
  2. getter中把Watcher添加到隊列上
  3. setter中讓Dep對象觸發(fā)update背稼,即依次執(zhí)行里面的Watcher
  4. 視圖更新的邏輯就放在Watcher

這樣蟹肘,我們在每次更新數(shù)據(jù)的時候就是觸發(fā)setter從而實現(xiàn)視圖更新。

但這里有兩個關(guān)鍵問題需要解決:

  1. 編譯是在Observer之后的疆前,如何實現(xiàn)在getter中把Watcher添加到隊列上?
  2. 一個屬性就只有一個getter童太,但卻可以有多個Watcher依賴胸完,如何確保準確地添加Watcher到隊列上?

二爆惧、新建一個Compiler對象進行模版編譯锨能,大概有以下幾個步驟:

  1. 對每個元素節(jié)點的指令進行掃描和解析,根據(jù)指令調(diào)用相應(yīng)的handler函數(shù)進行處理
  2. 對每個屬性依賴新建Watcher對象進行監(jiān)聽

三址遇、在MVVM實例初始化中整合流程一和流程二

一個簡單的MVVM實現(xiàn)流程大概就是這些步驟,下面大概介紹下具體實現(xiàn)思路:

Observer實現(xiàn)

對于第一個問題秃殉,解決也不難,我們在Watcher的初始化中對要監(jiān)聽的數(shù)據(jù)進行訪問钾军,自然就會觸發(fā)到getter

巧妙的地方在第二點拗小,如何確保當前的Watcher能夠被正確添加砸泛?在需要把自身添加到隊列時,我們可以在Dep全局對象中設(shè)置Dep.target為自身勾栗,在getter中則進行判斷是否有Dep.target這個屬性才決定是否進行添加隊列操作盏筐,看一下別人實現(xiàn)的一個簡單版的代碼,留意 get方法里面的一段:

var uid = 0; //避免重復(fù)添加

function Watcher(vm, expOrFn, cb){
  this.uid = uid++;
  this.$vm = vm;
  this.expOrFn = expOrFn;
  this.value = null;
  this.cb = cb;
  this.update();//初始化時執(zhí)行一遍
}
Watcher.prototype = {
  get: function(){
    Dep.target = this;   //把自身添加到target上
    var value = computeExpression(this.$vm, this.expOrFn);  //這里會觸發(fā)到getter
    Dep.target = null;   //執(zhí)行完記得設(shè)為null
    return value;
  },
  ....//省略
}

//精髓在于with與eval的應(yīng)用琢融,用with指定scope作用域漾抬,然后用eval執(zhí)行表達式
//其實用eval會有安全問題,而且性能上不太好纳令,更好的解決辦法是使用New Function()來動態(tài)構(gòu)建函數(shù),表達式置于函數(shù)體內(nèi)
function computeExpression(scope, exp){
  try{
    with(scope){
      return eval(exp);
    }
  } catch(e){
    console.error('ERROR', e);
  }
}

然后是getter里面圈匆,留意到判斷到有Dep.target屬性才添加到依賴隊列中:

Observer.prototype = {
    ....//省略
    defineReactive: function(data, key, val){
        var dep = new Dep();
        var self = this;
        self.observe(val); //如果是對象則遞歸遍歷

        Object.defineProperty(data, key, {
            enumerable: true, //可枚舉遍歷
            configureable: false, //不可再次配置
            get: function(){
                Dep.target && dep.addSub(Dep.target);
                return val;
            },
            set: function(newVal){
                if(val === newVal){ return; }
                val = newVal;
                self.observe(newVal);  //對新值進行遍歷
                dep.notify();  //執(zhí)行更新
            }
        })
    }
}

Dep里面的代碼比較簡單捏雌,無非就是維護一個存放Watcher的對象:

function Dep(){
  this.subs = {};
}
Dep.prototype = {
  addSub: function(sub){
    //防止重復(fù)添加Watcher
    if(!this.subs[sub.uid]){
      this.subs[sub.uid] = sub;
    }
  },
  notify: function(){
    for(var uid in this.subs){
      this.subs[uid].update();
    }
  }
}

要知道js是單線程的性湿,所以可以確保每次只有一個Watcher在調(diào)用,也就確保了它能準備地添加到它所監(jiān)聽變量的依賴隊列上肤频。這個做法可謂是十分巧妙,不得不佩服尤大大的厲害。

經(jīng)過這樣完整的一個結(jié)構(gòu),我們就已經(jīng)可以簡單地實現(xiàn)攔截變量和通知變化的功能了摔竿。

Compiler實現(xiàn)

要實現(xiàn)這個需要對原生的一些DOM屬性和節(jié)點操作辦法比較熟悉少孝,下面以一個最簡單的文本節(jié)點解析為例。

文本節(jié)點里面的表達式一般是 {{ a + 'b' }} + 某些文字 這樣稍走,對于這種字符串,我們就要轉(zhuǎn)換為scope.a + 'b' + '某些文字'這樣的表達式來執(zhí)行粱胜,可以回顧一下上面的computeExpression函數(shù)狐树,下面繼續(xù)看一下別人的簡單實現(xiàn)

//先看下parseTextExp函數(shù),其實就是正則匹配加字符串拼接的過程
function parseTextExp(text) {
    //匹配{{ }}里面的內(nèi)容
    var regText = /\{\{(.+?)\}\}/g;
    //存放其余的片段涯曲,類似'某些文字'這些
    var pieces = text.split(regText);
    var matches = text.match(regText);
    var tokens = [];
    pieces.forEach(function (piece) {
        if (matches && matches.indexOf('{{' + piece + '}}') > -1) {    // 注意排除無{{}}的情況
            tokens.push(piece);
        } else if (piece) {
            tokens.push('`' + piece + '`');
        }
    });
    //最后返回類似 scope.a + 'b' + '某些文字' 這樣的字符串表達式
    return tokens.join('+');
}

function Compiler(el, vm){
    this.$el = el;
    this.$vm = vm;
    if (this.$el) {
        //轉(zhuǎn)換為節(jié)點片段在塔,提高執(zhí)行效率,同時用于去除一些注釋節(jié)點绰沥,空文本節(jié)點等
        this.$fragment = nodeToFragment(this.$el);
        this.compiler(this.$fragment);
        this.$el.appendChild(this.$fragment);
    }
}
Compiler.prototype = {
    //分兩類城榛,元素節(jié)點和文本節(jié)點,同時進行遞歸
    compiler: function(node, scope){
        var childs = [].slice.call(node.childNodes);
        childs.forEach(function(child){
            if (child.nodeType === 1) {
                this.compileElementNode(child, scope);
            }else if(child.nodeType === 3){
                this.compileTextNode(child, scope);
            }
        }.bind(this))
    },
    compileTextNode: function(textNode, scope){
        var text = textNode.textContent.trim();
        if(!text) return;

        //將文本中的{{a + 'bbb'}} asdsd 轉(zhuǎn)換成 scope.a + 'bbb' + asdsd 的形式
        var exp = parseTextExp(text);
        scope = scope || this.$vm;

        this.textHandler(textNode, exp, scope);
    },
    textHandler: function(textNode, exp, scope){
        //增加一個Watcher依賴
        new Watcher(scope, exp, function(newVal){
            textNode.textContent = !newVal ? '' : newVal ;
        })
    },
    ....//省略
}

先轉(zhuǎn)換成fragment疟位,然后對于文本節(jié)點喘垂,直接解析并轉(zhuǎn)換里面的內(nèi)容,然后增加一個Watcher依賴得院。我們再看一下Watcher里面的代碼

function Watcher(vm, expOrFn, cb){
  this.uid = uid++;
  this.$vm = vm;
  this.expOrFn = expOrFn;
  this.value = null;
  this.cb = cb;
  this.update(); //初始化時就先執(zhí)行一次cb函數(shù)
}
Watcher.prototype = {
  get: function(){
    Dep.target = this;
    var value = computeExpression(this.$vm, this.expOrFn);
    Dep.target = null;
    return value;
  },
  update: function(){
    //此處會調(diào)用getter章贞,將Watcher添加到dep里面
    var newVal = this.get();
    if(newVal !== this.value){
      this.cb.call(this.$vm, newVal, this.value);
      this.value = newVal;
    }
  }
}

可以看到,在Watcher初始化時便自動添加到依賴隊列中蜕径,同時也得到了經(jīng)過首次解析后的表達式的值,并存放在this.value中兜喻,而且還執(zhí)行了回調(diào),觸發(fā)了視圖更新帕识。

這是最簡單的文本節(jié)點的解析遂铡,對于像v-forv-if忧便、v-model等其他較為復(fù)雜的指令都有其相應(yīng)的處理辦法,并且有些指令的實現(xiàn)是十分有趣的超歌,詳情可以閱讀文章最后的Reference蒂教,我就不復(fù)制粘貼了。

MVVM實例化

有了前兩部分的實現(xiàn)凝垛,這里的實例化就顯得簡單很多了,繼續(xù)看代碼:

function MVVM(options){
  this.$options = options;
  //先提取根節(jié)點
  this.$el = typeof options.el === 'string'
    ? document.querySelector(options.el)
  : options.el || document.body;

  var data = this._data = this.$options.data;

  //Observer所有數(shù)據(jù)
  var ob = new Observer(this._data);
  if(!ob) return;

  //對data里面的數(shù)據(jù)代理到實例上
  Object.keys(data).forEach(function(key){
    this._proxy(key);
  }.bind(this))

  //模版編譯
  new Compiler(this.$el, this);
}

MVVM.prototype = {
  _proxy: function(key){
    var self = this;
    Object.defineProperty(self, key, {
      configureable: false,
      enumerable: true,
      get: function(){
        return self._data[key];
      },
      set: function(val){
        self._data[key] = val;
      }
    })
  },
  $watch: function(expOrFn, cb){
    new Watcher(this, expOrFn, cb);
  }
}

正如前面所說炭分,這里只需要把ObserverCompiler整合一下就可以了捧毛。需要注意的是這里實現(xiàn)了一個代理让网,因為它的數(shù)據(jù)是掛載在vm._data上的,假如我們要改變數(shù)據(jù)的值溃睹,則要用vm._data.a = xxx 這樣的方式來改變,這樣顯示是不符合我們期望的泞辐,我們希望可以直接用vm.a = xxx 這樣的方式來改變數(shù)據(jù)的值。

所以我們增加了一個_proxy函數(shù)铛碑,其實主要還是用Object.defineProperty()這個方法來攔截類似vm.a這樣的屬性,使它變成返回和設(shè)置vm._data.a上的值。至此莉御,一個簡單版的MVVM變完全實現(xiàn)了。

總結(jié)

首先需要說明的是上面的代碼基本上都是各種博客或者源碼里面的牍颈,我這里主要是分析其實現(xiàn)思路琅关。當然我自己也照著這些代碼仿造了一個,但其實代碼內(nèi)容大同小異画机,就沒必要貼上來了新症。

寫這篇文章的目的主要是讓自己明白一個流行的輪子是大概是基于怎樣的思路造出來的,旨在提升一下擼碼水平徒爹。如有不妥的地方大家一起探討。

關(guān)于Vue2.0的源碼其實還有非常多值得學習的地方界阁,例如virtual dom及其diff算法實現(xiàn)胖喳,各種正則的巧妙運用,transition過渡指令集的實現(xiàn)等禀晓。可惜本人水平有限重付,還不能完全參透其原理凫乖,看以后有沒機會解讀弓颈。最后删掀,感謝大家的閱讀!

本文轉(zhuǎn)載自Github_Bless-L披泪。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末控硼,一起剝皮案震驚了整個濱河市艾少,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌幔妨,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,270評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件埂伦,死亡現(xiàn)場離奇詭異思恐,居然都是意外死亡沾谜,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,489評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來描焰,“玉大人,你說我怎么就攤上這事篱竭〔匠瘢” “怎么了?”我有些...
    開封第一講書人閱讀 165,630評論 0 356
  • 文/不壞的土叔 我叫張陵吕喘,是天一觀的道長。 經(jīng)常有香客問我氯质,道長,這世上最難降的妖魔是什么拱礁? 我笑而不...
    開封第一講書人閱讀 58,906評論 1 295
  • 正文 為了忘掉前任蜓陌,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘烛芬。我一直安慰自己,他們只是感情好仆潮,可當我...
    茶點故事閱讀 67,928評論 6 392
  • 文/花漫 我一把揭開白布遣臼。 她就那樣靜靜地躺著,像睡著了一般鹏浅。 火紅的嫁衣襯著肌膚如雪屏歹。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,718評論 1 305
  • 那天季希,我揣著相機與錄音幽纷,去河邊找鬼。 笑死友浸,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的境析。 我是一名探鬼主播,決...
    沈念sama閱讀 40,442評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼链沼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了括勺?” 一聲冷哼從身側(cè)響起曲掰,我...
    開封第一講書人閱讀 39,345評論 0 276
  • 序言:老撾萬榮一對情侶失蹤栏妖,失蹤者是張志新(化名)和其女友劉穎乱豆,沒想到半個月后吊趾,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體论泛,經(jīng)...
    沈念sama閱讀 45,802評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,984評論 3 337
  • 正文 我和宋清朗相戀三年岩榆,在試婚紗的時候發(fā)現(xiàn)自己被綠了坟瓢。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,117評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡粥诫,死狀恐怖崭庸,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情怕享,我是刑警寧澤,帶...
    沈念sama閱讀 35,810評論 5 346
  • 正文 年R本政府宣布沙合,位于F島的核電站跌帐,受9級特大地震影響绊率,放射性物質(zhì)發(fā)生泄漏究履。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,462評論 3 331
  • 文/蒙蒙 一藐俺、第九天 我趴在偏房一處隱蔽的房頂上張望泥彤。 院中可真熱鬧,春花似錦吟吝、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,011評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽野崇。三九已至,卻和暖如春乓梨,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背蕴侣。 一陣腳步聲響...
    開封第一講書人閱讀 33,139評論 1 272
  • 我被黑心中介騙來泰國打工臭觉, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人蝠筑。 一個月前我還...
    沈念sama閱讀 48,377評論 3 373
  • 正文 我出身青樓什乙,卻偏偏與公主長得像,于是被迫代替她去往敵國和親臣镣。 傳聞我的和親對象是個殘疾皇子智亮,可洞房花燭夜當晚...
    茶點故事閱讀 45,060評論 2 355