前端仔的MV*之路

三段經歷

我是從 16 年接觸前端的,當時大二,常常乾的事情是寫一些簡單有趣的交互,好比 打飛機/坦克大戰/推箱子之類的。 這種東西常常一個腳本寫一千多行就實現了。參數傳來傳去,回調調來調去。快樂極了。javascript

17 年去一家古老的棋牌遊戲公司作前端實習生,整個組就我一個前端,常常乾的事情是寫遊戲抽獎頁面,好比 轉盤/抽紙牌之類的 交互簡單字段少的頁面。捧着 [ie tester] 兼容 ie6,頁面切來切去,和後端模版套來套取,快樂極了。html

18 年畢業了,在如今的公司作前端開發,先後端分離,交互複雜,接口繁多,這才用上了 Vue 全家桶。組件寫來寫去,請求來請求去,快樂極了。前端

先總結如下這三段經歷,java

16 年由於需求簡單,不存在接口請求,因此代碼填鴨在一個文件沒什麼毛病,當時就是 代碼 寫一個腳本里,頻繁的操做 DOMjquery

17 年需求更簡單,雖然存在接口請求了,但數據量極其少且基本都在後端,前端僅有的狀態基本放在全局,因此大量時間交給了頁面切圖和兼容。除了 DOM 操做變爲 jquery,其他好像沒什麼變化。git

18 年接口數量上來了,先後端分離,須要維護的狀態多了,異步事件多了,交互複雜了。接觸組件,視圖邏輯分離......github

個人需求在變化,前端也在不斷變化,層出不窮的 MV* 究竟是怎麼變化的,它們解決了什麼樣的問題,一直困擾着我。web

我認爲解答以上問題,得逐一瞭解每一個庫,看看它們各自解決了什麼問題。ajax

Jquery 工具箱

Jquery 誰不知道呢,豐富的 api,簡化 Dom 的操做,它就像一個齊全的工具箱。
好比下面這個例子。
後端

<html>
  <div id="data"></div>
  <script> $.ajax({ url: '', success(res) { $('#data').text(res.data) } }) </script>
</html>
複製代碼

瞬間就實現了從接口到視圖的轉換。毫無疑問開發簡單的 web 頁面,用它是最快的。

但當咱們的項目變得足夠大時,僅僅靠 jquery 就力不從心了,由於它的視圖和數據是耦合的。

DOM 和 數據耦合的問題

<html>
  <body>
    <div id="name"></div>
    <div id="year"></div>
    <input id="input" />
    <button id="submit">submit</button>
  </body>
  <script> let data = { name: 'qqqdu', year: '24' } $('#name').text(data.name) $('#year').text(data.year) $('#submit').click(() => { // 請求1 $.ajax({ url: 'URL1', data: { val: $('#input').val() }, success(res) { $('#name').text(res.data.name) $('#year').text(res.data.year) } }) }) // some where... // 請求2 也修改了視圖 $('#some ID').click(() => { // 請求1 $.ajax({ url: 'URL2', success(res) { $('#name').text(res.data.name) $('#year').text(res.data.year) } }) }) </script>
</html>
複製代碼

以上的例子,當代碼執行時,會給 name 和 year 渲染默認值,當用戶輸入 input 後,點擊了提交按鈕,dom 將內容直接傳給了請求,更改了後臺的數據。而後請求返回,接口數據直接被更新在 dom #name。你必定注意到了,還有個 URL2 的請求,一樣修改了 DOM #name。
假設你在調試這個頁面,發現頁面的值和請求 URL1 的返回值不一樣,你就困惑了,你得仔細找找代碼裏有哪些地方也修改了這個 DOM。若是代碼量大,找起來確定會頭炸。
好的代碼結構應該是怎麼樣的?我想它確定不會讓人困惑:

當你須要找事件綁定時,當你須要找數據在哪兒變動時,當你找視圖更新時,你腦海中都應該有明確的方向。

那咱們就從這三個方面入手,改造以上代碼:

let app = {
  model: {
    url: {
      'URL1': 'https//xxx/URL1',
      'URL2': 'https//xxx/URL2'
    },
    data: {
      name: 'qqqdu',
      year: '24'
    },
    setYear: data => {
      this.model.data.year = data
      this.view.renderText(this.model.data)
    },
    setName: data => {
      data = 'MR: ' + fullName
      this.model.data.name = data
      this.view.renderText(this.model.data)
    }
  },
  init() {
    this.setData(this.model.data)
    this.view.bindDom()
  },
  view: {
    bindDom() {
      $('#submit').click(() => {
        this.controller.getURL1($('#input').val())
      })
      $('#some ID').click(() => {
        this.controller.getURL2()
      })
      $('#name').input(this.controller.bindName)
      $('#year').input(this.controller.bindYear)
    },
    renderText(options) {
      $('#name').text(options.name)
      $('#year').text(options.year)
    }
  },
  controller: {
    bindName() {
      // 業務處理
      this.setName({
        name: $(this).val()
      })
    },
    bindYear() {
      this.setData({
        year: $(this).val()
      })
    },
    getURL1(data) {
      // 請求1
      $.ajax({
        url: this.model.url.URL1,
        data: {
          val: data
        },
        success: res => {
          // 業務邏輯
          ...
          this.setYear(res.data)
        }
      })
    },
    getURL2() {
      // 請求2
      $.ajax({
        url: this.model.url.URL2,
        success: res => {
          // 業務邏輯
          ...
          this.setData(res.data)
        }
      })
    }
  }
}
複製代碼

通過這樣的改造,看起來清晰多了:

  • 視圖內全部的數據都來自於 app.data,引發數據變動的都在 controller 內,
  • 全部與 DOM 渲染相關的,都在 View 內
  • 全部與事件綁定相關的,都在 View 層,而且事件回調轉給 controller 處理。

到這一步你就知道我想說什麼了。由於維護的數據過多,須要修改的 Dom 變多,兩者耦合在一塊兒難以開發和後期維護。
所以咱們通常會將兩者分層,加入中間層 controller 來給兩者解耦。

這即是咱們常說的 MVC 模式。

前端 MVC

咱們都知道 前端 MVC 是參考了後端 MVC 的實現。但由於前端業務場景和後端有差異,因此在實現時,也有差異。好比傳統的後端 MVC,View 和 Model 層是不會有交互的,它們徹底由 controller 完成交互,經常會引起 controller 臃腫問題。
不一樣的公司、不一樣的團隊對於 臃腫 的處理方式也各不相同。好比我問了咱們的後端:controller 又被分出來一個 service 層,用來作數據驗證/業務處理......
對於前端而言,不一樣的 MVC 框架 的實現也各不相同,以前我一直糾結哪一個框架屬於 MVC,哪一個不屬於,後來我發現這種思考沒多少益處。由於分層以及如何分層 都是要根據業務場景決定的。沒有銀彈。
在下文,MVC 都爲下圖。也只講前端 MVC


先來一張阮一峯大佬畫的圖吧

這張圖跟咱們上面寫的代碼流程一致,當用戶觸發 view 時,view 將指令傳給 controller,controller 修改 model,並由 model 觸發 view 的修改。

由於整個流程是單向的,在維護相似這樣的庫時:

  • View 變動了,必定是 Model 引發的。
  • Model 變動了,必定是 Controller 引發的。
  • Controller 變動了,必定是 View 引發的。

帶着這個思路去維護和開發,相比以前,確定不會凌亂。而且更改 對應 層的代碼時,不用擔憂影響其餘層。

那前端開發,有沒有好用的 mvc 框架呢?在這裏咱們就要引出 jquery 時代,比較火熱的框架 backbone 了。

這個時代名爲:backbone

backbone 的 MVC

相信有一部分前端沒聽過這個框架,它在前端實現了 MVC,首先來看它的 MVC 分層

它給咱們提供了 Model/Collection/View/Router

數據層:Models Collections(想像成 Models 的集合)
視圖層:Views
邏輯層:Router(Controller)

一樣的,放一張阮一峯大佬的Backbone架構圖。

圖片

  1. 用戶能夠向 View 發送指令(DOM 事件),再由 View 直接要求 Model 改變狀態。
  1. 用戶也能夠直接向 Controller 發送指令(改變 URL 觸發 hashChange 事件),再由 Controller 發送給 View。
  1. Controller 很是薄,只起到路由的做用,而 View 很是厚,業務邏輯都部署在 View。因此,Backbone 索性取消了 Controller,只保留一個 Router(路由器) 。

來自:www.ruanyifeng.com/blog/2015/0…

怎麼回事兒,這麼複雜,數據流不是單向的了,Views 能夠做用於 Models,Models 也能夠做用於 Views,這不是又回到以前了嗎。(這裏的 Controller,就是 backbone 的 Router)

在探究這個問題以前咱們先把這張圖修改一下,在不考慮路由變動的狀況下,去掉 Router(Controller)。

也就是說,在去除了 Router 以後,backbone 的分層,只有 View 和 Model。 而且修改是雙向的,那咱們就得問一個問題:它們難道不會耦合嗎?

咱們接着往下看。

熟悉 backbone

Model

const Input = Backbone.Model.extend({})
const input = new Input({
  background: 'black',
  value: 'white'
})
input.set({
  background: 'white'
})
input.get('background')

this.model.bind('change:value', () => {})
複製代碼

經過 Backbone.Model.extend 能夠構造一個 Model 類,在這個類實例化時,傳入數據結構,經過實例的 set 方法來修改 model,經過 get 方法來獲取 model。

而且 model 還能夠綁定 change 事件,當數據變動時,會觸發回調函數。

View

// View...
const DocumentRow = Backbone.View.extend({
  model: input,
  initialize: function() {
    this.model.bind('change:value', this.render, this)
  },
  events: {
    'input input': 'changeValue'
  },
  changeValue: function(e) {
    input.set({
      value: e.target.value
    })
  },
  render: function(res) {
    const { value } = res.changed
    $('input').val(value)
    return this
  }
})
const documents = new DocumentRow()
複製代碼

一樣的,經過 Backbone.View.extend 能夠構造 view 類,而且可選參數 model 能夠綁定對應的 數據。在 initialize 時,能夠監聽 model 的事件變動,而且觸發 render 函數,來手動更新視圖。事件綁定寫在 events 中,當用戶修改了 input 時,會觸發 changeValue 去更新 model。

完整的代碼在:backbone DEMO

回到剛剛的問題, Model 和 View 是雙向的?這樣耦合嗎?

Model 和 View 耦合嗎

先來看看 Backbone 對於兩者的介紹

Models(模型)是任何 Javascript 應用的核心,包括數據交互及與其相關的大量邏輯: 轉換、驗證、計算屬性和訪問控制。 Views(視圖) 這裏能夠寫 HTML 或 CSS, 並能夠配合使用任何 JavaScript 模板庫(默認 underscore)。 通常的想法是將界面組織成邏輯視圖,並由 Models 支持, Models 變化時,每個視圖均可以獨立地進行更新

若是按照這個思路去寫業務,耦合應該是很小的。

可是,咱們極可能會寫成下面的樣子

// views
{
  changeValue: function(e){
    let value = e.target.value
    value = value.split(',').join('.')
    value += ' -> good'
    input.set({
      value
    })
  }
}
複製代碼

當用戶 修改了 input,觸發了 changeValue 函數時 在 changeValue 中,對數據進行了加工,以後才修改了數據。這樣會有兩個問題,一是在項目中,相似的加工變多,View 會變得更加臃腫,二是,對於業務數據的加工,應該也屬於 Model 層,若是放在這裏,View 和 Model 又耦合了。

因此加工應該放在 Model 層,以下:

// model
const InputModel = Backbone.Model.extend({
  editValue(ev) {
    let value = ev.split(',').join('.')
    value += ' -> good'
    this.set({
      value
    })
  }
})
const input = new InputModel({
  background: 'black',
  value: 'white',
})
// views
{
  changeValue: function(e){
    input.editValue(e.target.value)
  }
}
複製代碼

因此實際開發中,咱們要清楚的認識到,分層的用意,以及合理的把代碼寫在對應的 views/models 層上。否則分層就又沒有意義了......

Router(Controller)

再來看看它的 Router(Controller) 層。

var AppRouter = Backbone.Router.extend({
  routes: {
    '': 'index',
    list: 'renderList'
  },
  index: function() {
    view.render('index')
  },
  renderList: function() {
    view.render('list')
  }
})

var router = new AppRouter()
複製代碼

故名思意,Router 是處理路由邏輯的,它引用在 單頁應用,監聽 瀏覽器 URL hash 變化,當用戶觸發了變化,好比改變了 hash 值/回退/前進/刷新...
appRouter.routers 就會執行對應的回調,從而觸發 view 層更新。

好比用戶輸入 http://localhost/index.html,則執行 appRouter.index() 方法,渲染 首頁相關內容。

而後用戶跳轉到 list,http://localhost/index.html#list,則執行 appRouter.list()方法,渲染 列表頁相關內容。

由於 Router 只作簡單的路由處理,這層會比較薄。

千年玄鐵劍,手無縛雞人

Backbone 是一款優秀的類庫。

模版渲染也 由開發者使用 underscore 這樣的模版庫實現。

對於開發者,業務邏輯寫在 View/Model 層都有可能,很容易形成上文的耦合問題。

如何在 Model 二次變動後,讓節點高效渲染也是個問題。是一次性更改全部相關 DOM,仍是判斷對應數據,作小範圍更改?想一想都麻煩。

最重要的是,它依賴 jQuery, DOM 操做 仍是交給開發者。極有可能在一個夜黑風高的加班夜,一個煩躁的小夥子在 Model 層隨意操做 DOM~

對於 Backbone 來講,應用在項目裏的不肯定性過高了,好比像我這種菜鳥很容易踩以上坑,固然它沒落的緣由確定遠不止此(爲何認爲Backbone是現代化前端框架的基石 )。

但在 jquery 還算盛行的年代,它確實給咱們提供了分離 Model 和 View 的解決方案,給咱們提供了 單頁面路由管理方案等等...相信不少維護公司老項目的同窗們確定能找到它的身影,甚至能看到,在不少年前,寫下代碼的人,在不斷的斟酌,如何優雅的將代碼填充在 Backbone 的四肢,讓它穩健、堅固的支撐着大大小小的平臺和應用。

那個時代名爲:Backbone

完全拆解(MVP)

在 mvc 中,無論是一開始說的,C-M-V-C 流向,或者 backbone 的 M-V-M 流向,M 和 V 仍是存在着某種聯繫,那能不能打破這種聯繫,讓兩者之間完全的獨立起來。
咱們指望: V 層只負責更新視圖和用戶事件監聽 M層只負責數據存儲和業務數據加工,
讓 C 來做爲橋樑,M和V的通訊只能經過 C

咱們先改造最開始 jQuery 的例子,實現這種流向。

let app = {
  model: {
    url: {
      'URL1': 'https//xxx/URL1',
      'URL2': 'https//xxx/URL2'
    },
    data: {
      name: 'qqqdu',
      year: '24'
    },
    setYear: data => {
      this.model.data.year = data
    },
    setName: data => {
      data = 'MR: ' + fullName
      this.model.data.name = data
    }
  },
  init() {
    this.setData(this.model.data)
    this.view.bindDom()
  },
  view: {
    bindDom() {
      $('#submit').click(this.controller.getURL1)
      $('#some ID').click(this.controller.getURL2)
      $('#name').input(this.controller.bindName)
      $('#year').input(this.controller.bindYear)
    },
    renderText(options) {
      $('#name').text(options.name)
      $('#year').text(options.year)
    }
  },
  controller: {
    bindName() {
      this.model.setName($(this).val())
      this.view.renderText(this.model)
    },
    bindYear() {
      this.model.setYear($(this).val())
      this.view.renderText(this.model)
    }
  }
}
複製代碼

改造起來很簡單,直接將以前在 model 層觸發的視圖更新放在了 controller 層。這個 controller 形式上有點像一箇中間人,因此這種模式被成爲 MVP,P(Presenter) 仍是放一張 阮一峯 大佬的圖:

mvp

MVP 完全的分離了 ViewModelViewDOM 事件監聽放在了 PresenterModel 變動的觸發以及視圖更新的觸發也放在了 Presenter,能夠預料到,在事件的增多以及數據量變大後,Presenter 會變得臃腫。

因此對於 MVP 來講,臃腫的 Presenter 又致使了不可維護性和複雜度。這好像又回到瞭解放前,BackboneView 充滿了業務邏輯 和 與 Model 層的交互變得臃腫。這樣看好像沒什麼進步呀。

讓咱們先想一想,致使 Presenter 臃腫的緣由是什麼:

  • 業務邏輯
  • Model 的變動,須要手動同步到 View
  • View 的變動,須要手動同步到 Model

假如業務邏輯依然保留在 Presenter。咱們能不能着手優化 第2/3點

解放雙手(MVVM)

在接下來的篇幅裏,咱們嘗試實現 M-V V-M流程自動化。目的是不考慮性能,以最簡單的方式實現這兩者的雙向綁定。

M -> V

咱們先忘掉成熟的 MVVM 或類 MVVM 庫,若是一個數據變動了,想要實時反應在視圖層,最簡單的方式是什麼?

我能想到的就是開一個定時器,不斷的監聽數據,若是數據和對應視圖的值不相等,則更新視圖。

輪訓監聽

_dirtyCheck() {
  requestAnimationFrame(() => {
    this._dirtyCheck()
    this._render()
  })
}
複製代碼

requestAnimationFrame 這個 api 根據系統來決定調用時機,通常和屏幕刷新頻率有關,若是屏幕的刷新頻率是 60HZ,那它會每 1000/60 ms 執行一次。相比 setTimeout 和 setInterval 這種宏任務來講,性能高很多。

在這裏,屏幕每刷新一次,就會執行 _render 方法。去判斷是否更新視圖。這種輪訓機制,應該算最簡單的實現數據監聽的方法了吧。

咱們再實現下 _render 方法。

_render 邏輯

到這一步,咱們要去對比數據和視圖層的差異,不一樣則更新視圖。那數據和視圖勢必要有一個綁定機制。我能想到最簡單的方法就是給 dom 節點加屬性來綁定。

<div data-mv="year"></div>
<div data-mv="name"></div>
複製代碼

假設咱們的 model 結構是如下:

let app = {
  model: {
    data: {
      name: 'qqqdu',
      year: '24'
    }
  },
}
複製代碼

_render 函數就能夠這麼實現,依然是用 jquery

_render() {
    const vm = $("[data-mv]")
    const self = this
    vm.each(renderFn)
    function renderFn() {
      const el = $(this),
          tagName = el[0].tagName
      const key = el.data('mv')
      const data = self.model.data[key]
      if(/INPUT|SELECT/.test(tagName)) {
        if(el.val() !== data) {
          el.val(data)
        }
      } else {
        if(el.text !== data) {
          el.text(data)
        }
      }
    }
  }
複製代碼

遍歷擁有 [data-mv] 的節點,若是節點的 [data-mv]值 不等於 model 中的值,則改變節點的值。固然這裏作了一個正則判斷,若是是表單元素,則更新其 value,不然更新其 innerText

這樣,咱們用最簡單的方式實現了 M -> V 的更新。

固然,2020年幾乎全部的前端er都知道, Angular 用髒檢測來作視圖更新,Vue 用 Object.defineProperty/Proxy來作數據綁定。無論他們解決了什麼其餘問題或是優化了什麼性能,咱們都得知道,最開始他們爲何要這麼作。

V -> M

至於視圖層的變動引發 Model 的改變,咱們平時接觸的最多的就是 input 節點,那咱們就嘗試實現下一個 input 節點數據變動時,實時更新到 Model

一樣的,咱們得知道 input 和 哪一個屬性綁定,跟上一節同樣。咱們能夠再定義一個屬性 data-mvvm,當擁有這個屬性的 input 節點change 時,咱們直接將該節點的 value 賦值給 Model。

// <input data-mvvm="name"/>
_bindMVVM() {
  const vm = $("[data-mvvm]")
  vm.each(ev => {
    const el = $(vm[ev]),
          tagName = el[0].tagName,
          key = el.data('mvvm')
    el[0].addEventListener('input', (ev) => {
      this.model.data[key] = ev.target.value
      console.log(this.model.data, key)
    })
  })
},
複製代碼

以上代碼也很簡單,監聽了節點的 input 事件,而且在回調裏賦值給 model。這樣 v->m 的流程也自動化了。
但對於 input 節點而言,要綁定兩個屬性才能同時實現 v-mm-v。這樣有點麻煩,因此咱們再改造一下 _render 函數。 以讓它能夠更新 [data-mvvm]屬性。

_render() {
    const vm = $("[data-mv]")
    const mvvm = $("[data-mvvm");
    const self = this
    vm.each(renderFn)
    mvvm.each(renderFn)
    function renderFn() {
      const el = $(this),
          tagName = el[0].tagName
      const key = el.data('mv') || el.data('mvvm')
      const data = self.model.data[key]
      if(/INPUT|SELECT/.test(tagName)) {
        if(el.val() !== data) {
          el.val(data)
        }
      } else {
        if(el.text !== data) {
          el.text(data)
        }
      }
    }
  },
複製代碼

MVVM

MVPMVVM,基本上就是咱們剛剛作的改變了,視圖層和數據層的交互原本須要 Presenter 做爲中間人來手動更新,當咱們在框架層面將這個流程自動化後,就變成了MVVM,而 Presenter 則被更名爲 ViewModel

以咱們寫的 demo 舉例子
View 層變爲了 html 模版,它再也不須要開發者操做 DOM,改由框架實現,目前惟一的職責是將 事件綁定回調 委託給了 ViewModel,事實上這點也能夠放在模版去作,咱們最開始不也是這麼作的嗎 <button onClick='tap'/>
對於 Model,是很純粹的數據存儲,也能夠進行數據加工。
對於 ViewModel,承載更多的是業務邏輯,而非同步視圖和數據。


最終,咱們的流向圖變成如下:

完 ##

引用

uinika.github.io/web/broswer…

www.jianshu.com/p/6ef75d044…

相關文章
相關標籤/搜索