三段經歷
我是從 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
先來一張阮一峯大佬畫的圖吧
![](http://static.javashuo.com/static/loading.gif)
這張圖跟咱們上面寫的代碼流程一致,當用戶觸發 view 時,view 將指令傳給 controller,controller 修改 model,並由 model 觸發 view 的修改。
由於整個流程是單向的,在維護相似這樣的庫時:
- View 變動了,必定是 Model 引發的。
- Model 變動了,必定是 Controller 引發的。
- Controller 變動了,必定是 View 引發的。
帶着這個思路去維護和開發,相比以前,確定不會凌亂。而且更改 對應 層的代碼時,不用擔憂影響其餘層。
那前端開發,有沒有好用的 mvc 框架呢?在這裏咱們就要引出 jquery 時代,比較火熱的框架 backbone
了。
這個時代名爲:backbone
![](http://static.javashuo.com/static/loading.gif)
backbone 的 MVC
相信有一部分前端沒聽過這個框架,它在前端實現了 MVC,首先來看它的 MVC 分層
它給咱們提供了 Model/Collection/View/Router
數據層:Models Collections(想像成 Models 的集合)
視圖層:Views
邏輯層:Router(Controller)
一樣的,放一張阮一峯大佬的Backbone架構圖。
![圖片](http://static.javashuo.com/static/loading.gif)
- 用戶能夠向 View 發送指令(DOM 事件),再由 View 直接要求 Model 改變狀態。
- 用戶也能夠直接向 Controller 發送指令(改變 URL 觸發 hashChange 事件),再由 Controller 發送給 View。
- Controller 很是薄,只起到路由的做用,而 View 很是厚,業務邏輯都部署在 View。因此,Backbone 索性取消了 Controller,只保留一個 Router(路由器) 。
怎麼回事兒,這麼複雜,數據流不是單向的了,Views 能夠做用於 Models,Models 也能夠做用於 Views,這不是又回到以前了嗎。(這裏的 Controller,就是 backbone 的 Router)
在探究這個問題以前咱們先把這張圖修改一下,在不考慮路由變動的狀況下,去掉 Router(Controller)。
![](http://static.javashuo.com/static/loading.gif)
也就是說,在去除了 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](http://static.javashuo.com/static/loading.gif)
MVP
完全的分離了 View
和 Model
,View
將 DOM
事件監聽放在了 Presenter
,Model
變動的觸發以及視圖更新的觸發也放在了 Presenter
,能夠預料到,在事件的增多以及數據量變大後,Presenter
會變得臃腫。
因此對於 MVP
來講,臃腫的 Presenter
又致使了不可維護性和複雜度。這好像又回到瞭解放前,Backbone
的 View
充滿了業務邏輯 和 與 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-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) } } } }, 複製代碼
MVVM
從 MVP
到 MVVM
,基本上就是咱們剛剛作的改變了,視圖層和數據層的交互原本須要 Presenter
做爲中間人來手動更新,當咱們在框架層面將這個流程自動化後,就變成了MVVM
,而 Presenter
則被更名爲 ViewModel
以咱們寫的 demo 舉例子
View
層變爲了 html
模版,它再也不須要開發者操做 DOM
,改由框架實現,目前惟一的職責是將 事件綁定回調 委託給了 ViewModel
,事實上這點也能夠放在模版去作,咱們最開始不也是這麼作的嗎 <button onClick='tap'/>
。
對於 Model
,是很純粹的數據存儲,也能夠進行數據加工。
對於 ViewModel
,承載更多的是業務邏輯,而非同步視圖和數據。
最終,咱們的流向圖變成如下:
![](http://static.javashuo.com/static/loading.gif)
完 ##