我是从 16 年接触前端的,当时大二,常常干的事情是写一些简单有趣的交互,好比 打飞机/坦克大战/推箱子之类的。 这种东西常常一个脚本写一千多行就实现了。参数传来传去,回调调来调去。快乐极了。javascript
17 年去一家古老的棋牌游戏公司作前端实习生,整个组就我一个前端,常常干的事情是写游戏抽奖页面,好比 转盘/抽纸牌之类的 交互简单字段少的页面。捧着 [ie tester] 兼容 ie6,页面切来切去,和后端模版套来套取,快乐极了。html
18 年毕业了,在如今的公司作前端开发,先后端分离,交互复杂,接口繁多,这才用上了 Vue 全家桶。组件写来写去,请求来请求去,快乐极了。前端
先总结如下这三段经历,java
16 年由于需求简单,不存在接口请求,因此代码填鸭在一个文件没什么毛病,当时就是 代码 写一个脚本里,频繁的操做 DOMjquery
17 年需求更简单,虽然存在接口请求了,但数据量极其少且基本都在后端,前端仅有的状态基本放在全局,因此大量时间交给了页面切图和兼容。除了 DOM 操做变为 jquery,其他好像没什么变化。git
18 年接口数量上来了,先后端分离,须要维护的状态多了,异步事件多了,交互复杂了。接触组件,视图逻辑分离......github
个人需求在变化,前端也在不断变化,层出不穷的 MV* 究竟是怎么变化的,它们解决了什么样的问题,一直困扰着我。web
我认为解答以上问题,得逐一了解每一个库,看看它们各自解决了什么问题。ajax
Jquery 谁不知道呢,丰富的 api,简化 Dom 的操做,它就像一个齐全的工具箱。
好比下面这个例子。后端
<html>
<div id="data"></div>
<script> $.ajax({ url: '', success(res) { $('#data').text(res.data) } }) </script>
</html>
复制代码
瞬间就实现了从接口到视图的转换。毫无疑问开发简单的 web 页面,用它是最快的。
但当咱们的项目变得足够大时,仅仅靠 jquery 就力不从心了,由于它的视图和数据是耦合的。
<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)
}
})
}
}
}
复制代码
通过这样的改造,看起来清晰多了:
到这一步你就知道我想说什么了。由于维护的数据过多,须要修改的 Dom 变多,两者耦合在一块儿难以开发和后期维护。
所以咱们通常会将两者分层,加入中间层 controller 来给两者解耦。
这即是咱们常说的 MVC 模式。
咱们都知道 前端 MVC 是参考了后端 MVC 的实现。但由于前端业务场景和后端有差异,因此在实现时,也有差异。好比传统的后端 MVC,View 和 Model 层是不会有交互的,它们彻底由 controller 完成交互,经常会引起 controller 臃肿问题。
不一样的公司、不一样的团队对于 臃肿 的处理方式也各不相同。好比我问了咱们的后端:controller 又被分出来一个 service 层,用来作数据验证/业务处理......
对于前端而言,不一样的 MVC 框架 的实现也各不相同,以前我一直纠结哪一个框架属于 MVC,哪一个不属于,后来我发现这种思考没多少益处。由于分层以及如何分层 都是要根据业务场景决定的。没有银弹。
在下文,MVC 都为下图。也只讲前端 MVC
先来一张阮一峰大佬画的图吧
这张图跟咱们上面写的代码流程一致,当用户触发 view 时,view 将指令传给 controller,controller 修改 model,并由 model 触发 view 的修改。
由于整个流程是单向的,在维护相似这样的库时:
带着这个思路去维护和开发,相比以前,确定不会凌乱。而且更改 对应 层的代码时,不用担忧影响其余层。
那前端开发,有没有好用的 mvc 框架呢?在这里咱们就要引出 jquery 时代,比较火热的框架 backbone
了。
相信有一部分前端没听过这个框架,它在前端实现了 MVC,首先来看它的 MVC 分层
它给咱们提供了 Model/Collection/View/Router
数据层:Models Collections(想像成 Models 的集合)
视图层:Views
逻辑层:Router(Controller)
一样的,放一张阮一峰大佬的Backbone架构图。
- 用户能够向 View 发送指令(DOM 事件),再由 View 直接要求 Model 改变状态。
- 用户也能够直接向 Controller 发送指令(改变 URL 触发 hashChange 事件),再由 Controller 发送给 View。
- Controller 很是薄,只起到路由的做用,而 View 很是厚,业务逻辑都部署在 View。因此,Backbone 索性取消了 Controller,只保留一个 Router(路由器) 。
怎么回事儿,这么复杂,数据流不是单向的了,Views 能够做用于 Models,Models 也能够做用于 Views,这不是又回到以前了吗。(这里的 Controller,就是 backbone 的 Router)
在探究这个问题以前咱们先把这张图修改一下,在不考虑路由变动的状况下,去掉 Router(Controller)。
也就是说,在去除了 Router 以后,backbone 的分层,只有 View 和 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...
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 是双向的?这样耦合吗?
先来看看 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) 层。
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
在 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
完全的分离了 View
和 Model
,View
将 DOM
事件监听放在了 Presenter
,Model
变动的触发以及视图更新的触发也放在了 Presenter
,能够预料到,在事件的增多以及数据量变大后,Presenter
会变得臃肿。
因此对于 MVP
来讲,臃肿的 Presenter
又致使了不可维护性和复杂度。这好像又回到了解放前,Backbone
的 View
充满了业务逻辑 和 与 Model
层的交互变得臃肿。这样看好像没什么进步呀。
让咱们先想一想,致使 Presenter
臃肿的缘由是什么:
Model
的变动,须要手动同步到 View
View
的变动,须要手动同步到 Model
假如业务逻辑依然保留在 Presenter
。咱们能不能着手优化 第2/3点
在接下来的篇幅里,咱们尝试实现 M-V V-M流程自动化。目的是不考虑性能,以最简单的方式实现这两者的双向绑定。
咱们先忘掉成熟的 MVVM 或类 MVVM 库,若是一个数据变动了,想要实时反应在视图层,最简单的方式是什么?
我能想到的就是开一个定时器,不断的监听数据,若是数据和对应视图的值不相等,则更新视图。
_dirtyCheck() {
requestAnimationFrame(() => {
this._dirtyCheck()
this._render()
})
}
复制代码
requestAnimationFrame
这个 api 根据系统来决定调用时机,通常和屏幕刷新频率有关,若是屏幕的刷新频率是 60HZ,那它会每 1000/60 ms 执行一次。相比 setTimeout 和 setInterval 这种宏任务来讲,性能高很多。
在这里,屏幕每刷新一次,就会执行 _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
来作数据绑定。无论他们解决了什么其余问题或是优化了什么性能,咱们都得知道,最开始他们为何要这么作。
至于视图层的变动引发 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-m
和 m-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)
}
}
}
},
复制代码
从 MVP
到 MVVM
,基本上就是咱们刚刚作的改变了,视图层和数据层的交互原本须要 Presenter
做为中间人来手动更新,当咱们在框架层面将这个流程自动化后,就变成了MVVM
,而 Presenter
则被更名为 ViewModel
以咱们写的 demo 举例子
View
层变为了 html
模版,它再也不须要开发者操做 DOM
,改由框架实现,目前惟一的职责是将 事件绑定回调 委托给了 ViewModel
,事实上这点也能够放在模版去作,咱们最开始不也是这么作的吗 <button onClick='tap'/>
。
对于 Model
,是很纯粹的数据存储,也能够进行数据加工。
对于 ViewModel
,承载更多的是业务逻辑,而非同步视图和数据。
最终,咱们的流向图变成如下:
完 ##