標籤:

開發vue(或類似的MVVM框架)的過程中,需要面對的主要問題有哪些?


既然問的是主要問題,那麼我就說下我在開發一個 MVVM 框架中遇到的幾個最主要的問/難題吧 ~

首先我個人比較喜歡折騰,造了個實現比較完整的 MVVM 框架輪子(指令 API 幾乎是參照 Vue 設計的,實現的核心也大致相同,希望了解 MVVM 原理或者實現一個完整的可以看下我的源代碼或 Vue 源碼)有興趣的同學可以看下

Repository: https://github.com/tangbc/sugar (同時歡迎 Star 和 Contribution)

---------- 以下是主要的回答: ----------

列舉幾點我開發 Sugar 或閱讀 Vue (1.0.26) 源碼時遇到的難題和巧妙之處以及附上我個人極其主觀的難度係數和巧妙係數評估:

難題一:如何控制指令的編譯優先順序?(難度係數 6 巧妙係數 6)

如何掃描/提取指令我就不具體說了,就是寫一個遞歸方法遍歷所有節點,然後找出所有合法指令(以 v- 開頭)放入編譯隊列,控制指令編譯的優先順序的場景大概是比如有兩個節點:

&

&

& &

  • & &

    因為 v-for 和 v-if 指令的編譯優先順序別是最高的,所以在編譯以上 h1 和 li 節點的時候,需要判斷如果有 v-if 或 v-for 必須先編譯他們才能保證相同節點上其他指令的正常取值。到這裡遇到的另一個問題就是假如進入到了 v-if/v-for 的判斷里,如何阻止其他優先順序較低的指令繼續編譯,如果不做處理繼續編譯的話上面的 v-bind:id="item.id" 就會無法正常取值。

    難題二:如何解析和獲取指令表達式的值?(難度係數 8 巧妙係數 5)

    假如指令表達式是更為複雜的帶各種運算符的情況比如:

    &

    &

    & &

  • & &

    這種情況應當如何去獲取表達式的值(或者可以將問題理解為如何執行並獲取字元表達式的值)?我們很容易想到用 new Function() 可以實現,但是直接 new Function("return show || items.length &> 0") 可不行,因為 new Function() 弄出來的函數只能訪問全局變數,所以需要在表達式上做些改動。

    在 MVVM 指令中每個表達式都有一個類似於 JS 中作用域的 scope,所有表達式的取值均在這個作用域上,所以不難得出這個 scope 其實就是數據模型對象或其加工後的對象,我們要得到的就是區分變數和常量並且給變數加上 scope 的表達式比如:

    &

    v-text=""Hello, " + scope.text"
    &>&

    & &

  • & &

    當然如果你設計的框架要求使用者必須全部手動帶上 scope . xxx 的話這個難題就不存在了 。所以這個問題其實就轉化成為了如何把字元串 "show || items.length &> 0" 變成 "scope.show || scope.items.length &> 0",這時候用 new Function("scope", "return scope.show || scope.items.length &> 0") 就可以指定參數作用域進行取值了,由於是用 new Function() 來生成取值函數,所以其他邊緣問題還要考慮限制惡意執行的表達式或 JS 關鍵字,這個問題對於正則表達式玩的溜的人來說絕對不算是難題,因為本人正則比較菜,所以難度係數定在了 8 ~

    另外還有一個也不算是難題但是實現很巧妙的地方就是比如上面 v-for 循環下的 v-bind:id="scope.item.id + scope.text" 的取值問題,這裡的 scope.item.id 是需要在 v-for 作用域上取值的,但是 scope.text 又是頂層數據模型 $data 上的取值!那這個 scope 到底是個什麼樣神奇的東西才能同時擁有「兩個」取值作用域?其實很簡單,就是利用原型!只要將 v-for 中的 vforScope 的原型指向 $data 即可:

    var vforScope = Object.create(vm.$data);

    然後在 vforScope 上定義 item 域(術語為 alias)為數組的選項即可:

    vforScope.item = { id: "xxx" };

    此時這個 vforScope 既可以在自己的取值域(優先)也可以在 $data 上取值,所以當訪問 scope.item.id 就會返回 "xxx" 此時就算是數據模型上有一個同名的 key 比如 $data.item = 123 也不會影響在 v-for 中的取值,因為先訪問自己的再訪問父級的(原型上)的值。

    難題三:如何實現數據訂閱模塊?(難度係數 7 巧妙係數 7)

    數據訂閱是 MVVM 內部的最重要的模塊部件之一,是實現數據驅動的核心。這個不太容易用具體的使用場景例子來說明遇到的問題。但是數據訂閱模塊的實現功能目的非常清晰和明確,就是實現對一個表達式中的任意依賴的變更能產生回調通知,比如:

    new Watcher(""Hello, " + name + ", " + text", function (newValue) {
    // 這裡需要監聽的依賴是 name 和 text
    });

    數據訂閱模塊的內部處理需要結合指令表達式的解析結果和依賴追蹤機制,是一個承上啟下的重要環節,也是有很多的難題和巧妙點,這裡不一一列舉了。

    難題四:如何追蹤指令表達式的所有依賴?(難度係數 4 巧妙係數 9)

    好吧,當我看到 Vue 源碼將 Observer, Depender 和 Watcher 相結合來對任意複雜指令進行依賴追蹤的實現後,我感覺這個實在實在是太太太太太巧妙了!!

    我最初的構思和想法是類似利用一些比較著名的第三方對象變化監測庫(哪怕造一個適合 MVVM 場景的)來實現對 $data 變化的監聽(Object.observe 已狗帶),利用取值的訪問路徑來實現依賴的追蹤,這個方案的確可以實現一個監測依賴變更的 Watcher 並且利用這個思路去實現一個最簡單的 MVVM(我的 Sugar 1.2 版本之前的就是這麼做的), 但是基於訪問路徑的依賴追蹤限制非常的多:不能更新依賴,不能分解依賴,不能實現計算屬性等,其實也不是不能實現,只是實現起來蛋疼無比而且還不穩定代碼量還超級多還難以維護。

    所以要實現一個小巧玲瓏並且功能強大穩定的 MVVM 用對象變化監測模塊+訪問路徑來實現依賴追蹤顯然不合適。其實 Vue 的做法巧妙的地方更接近於「只攔截不監測」,或者換種說法是在攔截中用 depend 模塊來代替監測(依賴驅動)。大致的三個步驟簡要概括下:

    1. 依賴追蹤的前期工作要在 MVVM 編譯之前就應該開始了,做法就是攔截 $data 對象中每一個屬性的 get 和 set(對象存取描述符 Object.defineProperty 目前分析這個的文章應該一搜幾籮筐)。然後在每一次攔截前預先生成一個 depend 實例,這個實例需要做的只有兩件事:一是在當前被攔截的屬性被 get(訪問到)的時候把自己交給數據訂閱模塊 Watcher 作記錄;二是在當前被攔截的屬性被 set(設置新值)的時候告訴跟自己有關係的所有 Watcher :「我的值變成 XXX 了,你們都馬上更新下!」。

    2. 在指令表達式被解析的階段比如 new Function("scope", "return scope.show || scope.items.length &> 0") 時會去訪問 scope (這裡就是 $data) 的 show 和 items,此時 show 和 items 所在的攔截被觸發,負責攔截的 depend 就會分別記住他們兩個(參見步驟 1 中 depend 的職責),一旦發生變化立刻通知給 Watcher。

    3. Watcher 實例中關聯了該表達式的所有的 depend 實例,只要任意一個依賴變化,表達式收到通知後重新求值並回調給用到該 Watcher 的具體指令實例,指令實例在收到變更通知的同時也能拿到新值和舊值,這樣就實現了數據驅動視圖(Model drive View)的模式。

    用 Observer + Depender + Watcer 這個模式來實現依賴追蹤機制加起來不過 600 行左右的代碼就實現了 MVVM 中最最核心的功能。利用這個模式就可以輕而易舉地實現 vm.$watch 這個 API;此外,在這個模式下只用了不超過 30 行 JS 代碼就實現了強大的計算屬性功能。

    暫時列舉這些難題吧,或許對於不同的人來說這些不一定都是難題。其實實現一個 MVVM 還有很多有趣/巧妙的問題,比如數組的變異方法處理、v-for 循環列表的優化或復用、事件綁定函數和參數處理等等。只要解決了難題,難題就變成了巧妙之處,所以有興趣就看源碼吧,有動力就造輪子吧!


    以vue.js為例,最近在編寫re-vue項目,項目初期代碼參考vue.js源碼,在實現一個簡單的mvvm模型後,逐步的實現了一個組件式的框架,到現在為止以實現如下的功能(雖說借鑒vue的思想,但大部分源碼為筆者自己完成,所以可能會與vue的實現方式不同):

    功能特性

    • 計算屬性支持
    • props對象支持
    • components對象支持
    • 預定義指令(v-text、v-show、v-if、v-for)
    • watch對象支持
    • methods對象支持
    • 生命周期支持
    • 組件系統(props通信)

    API

    • $directive: 自定義指令
    • $reactive: 將一個普通對象轉換為響應式對象
    • $watch: 監視對象的變化
    • $destroy: vm實例銷毀
    • vm.component: 註冊全局組件

    整個框架已基本成型,詳情可查看[xiaofuzi/re-vue].

    先說說完成這麼一個MVVM框架需要做什麼:

    * 理解什麼是MVVM架構

    * 基本的編碼能力(javascript、dom、遞歸,樹形數據結構)

    * 程序理論方面的知識,了解語法、語義等相關概念,理解作用域、作用域鏈、上下文及其關係。以javascript為例,知道作用域、作用域鏈、原型、原型鏈在js中是如何表示的,以及js的面向對象編程。因為vue這樣的指令模板式的框架,所以還需要實現一套指令系統,在這個過程中其實就在實現一個簡單的DSL語言,所以需要對語法、語義有了解,需要支持哪些語法(條件結構、循環結構、函數、語句),語法的設計,語義上的表示。

    * MVVM基本架構實現,[可參考這裡的一個精簡實現](https://github.com/xiaofuzi/deep-in-vue/blob/master/src/the-super-tiny-vue.js),在此基礎上理解為Model、viewModel、View的定位,以及從實現的角度去理解指令這一層的抽象,可不不是`v-text`指令,而是這一套指令的抽象概念。

    * 基本的語法解析知識即parser的編寫(re-vue中基於combinator實現了一個簡單的parser,可以參考)

    * 事件系統(re-vue中自帶的一個事件系統也就不到100行代碼),事件在整個框架中用得非常多,所以需要理解事件系統以及能夠獨立實現。這裡除了實現事件的監聽、觸發、廣播等功能外,還要理解事件的冒泡、捕獲等過程。

    * 這裡是參照vue的設計思想實現的,所以還需要對vue了解(computed/watch/component/props等概念是實現中的重點,所以一定要了解)

    在具備上述的知識後,我們就可以進一步的編寫了,在上面的基礎上因為已經實現了一個精簡的數據驅動的mvvm框架,所以接下來只需要進一步完善即可。

    * 指令更新系統

    * watch功能,該功能可以說貫穿整個框架,所以需要優先實現

    watch的實現需要考慮對象、數組以及對象子屬性的更改檢測,對應於vue中的deep watch。

    * 基於watch實現計算屬性

    為了實現watch和計算屬性功能,其實是實現一個更為複雜的事件系統,可以考慮DOM結構以及DOM中的事件傳播過程。還要實現各個節點之間的依賴以及更新通知。

    * v-on/v-if/v-for指令的實現,事件、v-for是比較核心的功能,也是實現難度比較大的點

    * 組件系統(自定義組件、組件通信)


    簡單說說我自己開發migi前後遇到的主要技術難點吧。ipad上打不好代碼,先將就著,後面完善。我寫的是dom based的。

    先是模板語法,jsx有規範標準,照做解析就是了,編譯器是基礎。不過我擴展了下,添加了註解語法,使其完成綁定模型數據到視圖的功能,可以實現模型賦值觸發對應視圖更新。

    這裡主要的就是編譯結果和庫的對接,編譯成什麼樣子的代碼,能夠實現賦值觸發數據更新,然後庫從觸發的通知中得知哪裡要更新。編譯的好的話,可以做很多的優化。比如直接優化掉setState中的整樹對比的缺點,只對比變化的部分。

    class MyComponent extends migi.Component {
    constructor(...data) {
    super(...data);
    this._txt = "like";
    }
    get txt() {
    return this._txt;
    }
    @bind
    set txt(v) {
    this._txt = v;
    }
    handleClick() {
    this.txt = this.txt == "like" ? "unlike" : "like";
    }
    render() {
    return (
    &

    You {this.txt} this. Click to toggle.
    & );
    }
    }

    get/set單向數據綁定

    再是dom diff演算法,我曾經發過一篇長文介紹常見diff的一個缺陷原因,以及如何改寫它,可以搜搜,react官方微博轉發過。就是字元串被span包裹那個。

    https://raw.githubusercontent.com/migijs/migi/master/lib/diff.png

    dom diff中會涉及到垃圾回收,這是個能提升性能的地方。被diff廢棄掉的vd不要不管,存到緩存池裡,下次新生成的時候能直接用。

    然後是移動點擊。雖然有fastclick,但作者已經坑了,看看那n頁的mr就知道。有一些bug存在,但平常可能碰不到,所以現在用fc也沒多大問題。我fork了個單獨的庫出來維護。

    如果要做數據鏈的話,就是組件數據傳遞或者綁定,要考慮數據死循環的問題,有點像循環依賴。不能a組建數據變更引發b組件,再重複引發a組件。每次主動觸發時要生成一個自增的uid和一次變更記錄。記錄中出現過一次斃掉,緩存過程中發現uid小的也斃掉。

    class Select extends migi.Component {
    constructor(...data) {
    super(...data);
    var self = this;
    self._list = [];
    self._value = "";
    self._show = false;
    self.allowPropagation = false;
    self.on(migi.Event.DOM, function() {
    var ajax = self.findChild("Ajax");
    if(ajax) {
    ajax.on("success", function(data) {
    self.list = data;
    });
    }
    window.addEventListener("click", function(e) {
    if(e.target != self.element) {
    self.show = false;
    }
    });
    });
    }
    get list() {
    return this._list;
    }
    @bind
    set list(v) {
    this._list = v;
    if(v.length) {
    this.value = v[0].name;
    }
    else {
    this.value = "";
    }
    }
    get show() {
    return this._show;
    }
    @bind
    set show(v) {
    this._show = v;
    if(v) {
    var cur = this.element.querySelector(".cur");
    if(cur) {
    cur.className = "";
    }
    cur = this.element.querySelector("li[title="" + this.value + ""]");
    if(cur) {
    cur.className = "cur";
    }
    }
    }
    @bind
    set value(v) {
    this._value = v;
    }
    @link(list)
    get value() {
    return this._value;
    }
    handleClick(e) {
    this.show = !this.show;
    if(e.target.tagName == "LI") {
    this.find("strong").html = this.value = e.target.innerHTML;
    }
    this.emit("show");
    }
    handlerOver(e) {
    if(e.target.tagName == "LI" e.target.className != "cur") {
    var cur = this.element.querySelector(".cur");
    if(cur) {
    cur.className = "";
    }
    e.target.className = "cur";
    }
    }
    render() {
    return (
    &
    &
    &
    {
    this.list.map(function(item) {
    return &{ item.name }&;
    })
    }
    & &{ this.value || "nbsp;" }&
    &&
    &

      {
      this.list.map(function(item) {
      return &

    • { item.name }&;
      })
      }
      & & );
      }
      }

      var ol = migi.render(
      & & &
      &
      &

      & bridge: &
      &