前端仔的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…

相关文章
相关标签/搜索