通過(guò)樣例來(lái)理解 MVC 模式

參考: 自制前端框架之 MVC
參考: MVC,MVP 和 MVVM 的圖示

如何設(shè)計(jì)一個(gè)程序的結(jié)構(gòu),這是一門(mén)專門(mén)的學(xué)問(wèn)肮帐,叫做"架構(gòu)模式"(architectural pattern)纷责,屬于編程的方法論。MVC 模式就是架構(gòu)模式的一種臣樱,在 UI 編程領(lǐng)域大有大量使用 MVC 模式的開(kāi)發(fā)框架 (Django等后端框架)靶擦,使得開(kāi)發(fā)者能夠借助該模式腮考,構(gòu)建出更易于擴(kuò)展和維護(hù)的應(yīng)用程序。

MVC 簡(jiǎn)介

大體上可將 MVC 模式的結(jié)構(gòu)分為三層玄捕,即 Model(模型)踩蔚、View(視圖)和Controller(控制)

  • Model: 專注數(shù)據(jù)的存取,對(duì)基礎(chǔ)數(shù)據(jù)對(duì)象的封裝枚粘。
  • Controller: 處理具體業(yè)務(wù)邏輯馅闽,即根據(jù)用戶從"視圖層"輸入的指令,從 Model 中存取數(shù)據(jù)馍迄,并渲染到 View 中福也。
  • View : 負(fù)責(zé)界面視圖,供用戶查看和操作攀圈,可理解為【輸入數(shù)據(jù)暴凑,輸出界面】的模塊,在其中通常不涉及的業(yè)務(wù)邏輯量承。

這三層是緊密聯(lián)系在一起的搬设,但又是互相獨(dú)立的,每一層內(nèi)部的變化不影響其他層撕捍。每一層都對(duì)外提供接口(Interface)拿穴,供其他層調(diào)用。這樣一來(lái)忧风,軟件就可以實(shí)現(xiàn)模塊化默色,修改外觀或者變更數(shù)據(jù)都不用修改其他層,大大方便了維護(hù)和升級(jí)狮腿。

需要注意的是腿宰,MVC 僅是一種模式理念,而非具體的規(guī)范缘厢。因此吃度,根據(jù) MVC 的理念所設(shè)計(jì)出的框架,在實(shí)現(xiàn)和使用上可能存在著較大的區(qū)別贴硫。

咱們的目標(biāo)

常見(jiàn)的后端框架所封裝的功能椿每,不外乎對(duì)數(shù)據(jù)的增查改刪與渲染。在前端英遭,我們以一個(gè)非常簡(jiǎn)單的 Todo App 作為示例间护,來(lái)實(shí)際看看 MVC 模式到底是怎樣工作的。

  • Model 模塊實(shí)現(xiàn) Todo 這一數(shù)據(jù)模型的存取挖诸。
  • View 模塊實(shí)現(xiàn)將 Todo 數(shù)據(jù)模型渲染到頁(yè)面汁尺。
  • Controller 模塊實(shí)現(xiàn)對(duì) Todo 數(shù)據(jù)的新增、編輯多律、刪除等操作痴突。

編寫(xiě)代碼

Model 模塊

按照 MVC 模式搂蜓,Model 模塊的主要工作是存取數(shù)據(jù),并且在數(shù)據(jù)變化時(shí)將新數(shù)據(jù)傳給 View 模塊苞也。Model 模塊的核心是訂閱-發(fā)布者模式洛勉。

class Model {
    // 在構(gòu)造器中實(shí)例化數(shù)據(jù)與訂閱者
    // 本例中的數(shù)據(jù)就是 一個(gè)個(gè)的 todo
    constructor() {
        // 數(shù)據(jù)格式 [{id: 1, value: '123'}]
        this.todo = []
        this.todo
        // 【初始化訂閱者】
        this.subscribers = [] 
    }

    // 利用 ES6 class 語(yǔ)法定義模型實(shí)例的 getter
    // 從而在調(diào)用 model.data 時(shí)返回正確的 Todo 數(shù)據(jù)
    get data() {
        return this.todo
    }

    // 利用 ES6 class 語(yǔ)法定義模型實(shí)例的 setter
    // 從而在執(zhí)行形如 modle.data = newData 的賦值時(shí)
    // 能夠通知訂閱了 Model 的模塊進(jìn)行相應(yīng)更新 【數(shù)據(jù)更新時(shí),觸發(fā)訂閱回調(diào)】
    set data(data) {
        this.todo = data
        this.publish(this.todo)
    }

    // 由 Model 實(shí)例調(diào)用的發(fā)布方法
    // 在 Model 中的 setter 更新時(shí)如迟,將新數(shù)據(jù)傳入該方法
    // 由該方法將新數(shù)據(jù)推送到每個(gè)訂閱者提供的回調(diào)中
    // 在 本項(xiàng)目中,訂閱者為 Controller 的 render 方法 【觸發(fā)所有訂閱】
    publish(data) {
        // 此處的訂閱者是 業(yè)務(wù)中的函數(shù)
        this.subscribers.forEach(render => render(data))
    }
}

在示例中可以發(fā)現(xiàn)攻走,所謂的發(fā)布 - 訂閱模式殷勘,其思路和實(shí)現(xiàn)均非常簡(jiǎn)單:

  1. 區(qū)分出【發(fā)布者】和【訂閱者】的概念。本例中 Model 為發(fā)布者昔搂,Controller 為訂閱者玲销。
  2. 在發(fā)布者中維護(hù)【我有哪些訂閱者】信息的數(shù)組,每個(gè)元素為一個(gè)訂閱者提供的回調(diào)摘符。
  3. 發(fā)布者數(shù)據(jù)更新時(shí)贤斜,依次觸發(fā)所有訂閱者的回調(diào)。

不過(guò)逛裤,Model 中的代碼僅實(shí)現(xiàn)了【初始化發(fā)布者】與【觸發(fā)所有訂閱】,【數(shù)據(jù)更新時(shí)瘩绒,觸發(fā)訂閱回調(diào)】的功能,并不是一個(gè)完整的發(fā)布 - 訂閱模式带族。在完整的模式實(shí)現(xiàn)中锁荔,其余代碼包括:

  1. 【訂閱者訂閱發(fā)布者】機(jī)制的實(shí)現(xiàn),其代碼位置為 Controller 中的最后一行 this.model.subscribers.push(this.render)蝙砌,在此將 render 方法作為訂閱者回調(diào)阳堕,提供給了發(fā)布者。
  2. 【訂閱者提供的訂閱方法】的實(shí)現(xiàn)择克,在此即為 Controller 中提供的 this.render 方法恬总。

Controller 模塊

上文中已經(jīng)明確,Controller 模塊需要實(shí)現(xiàn)的功能為:

  • 與 Model / View 實(shí)例的綁定肚邢。
  • 對(duì)點(diǎn)擊事件壹堰、DOM 選擇等底層 API 的封裝。
  • 用于渲染數(shù)據(jù)的 Render 方法道偷。
class Controller {
    constructor(conf) {
        // 根據(jù)實(shí)例化參數(shù)缀旁,定義 Controller 基礎(chǔ)配置
        // 包括 DOM 容器、Model / View 實(shí)例及 onClick 事件等
        this.el = document.querySelector(conf.el)
        this.model = conf.model
        this.view = conf.view
        // 為 容器 dom 設(shè)置事件
        this.bindEvent(this.el)
        // 給 render 函數(shù)綁定 this
        this.render = this.render.bind(this)
        // 在 Model 更新時(shí)執(zhí)行 controller 的 render 方法
        this.model.subscribers.push(this.render)
    }

    // 根據(jù)點(diǎn)擊 btn 的 class 屬性綁定不同的事件回調(diào)
    bindEvent() {
        this.el.addEventListener('click', (event) => {
            let el = event.target
            let id = el.dataset.id
            if (el.classList.contains('todo-delete')) {
                deleteTodo(id)
            } else if (el.classList.contains('todo-update')) {
                updateTodo(el, id)
            } else if (el.classList.contains('todo-add')) {
                addTodo(el)
            }
        })

        // 點(diǎn)擊 add 時(shí)勺鸦,把新的 todo 添加到 model 中
        const addTodo = (el) => {
            let input = el.parentElement.querySelector('.input-add')
            let value = input.value
            if (value !== '') {
                let data = this.model.data
                data.push({
                    id: data.length,
                    value: value
                })
                this.model.data = data
            }
        }

        // 點(diǎn)擊 delete 時(shí)并巍,把對(duì)應(yīng) todo 從 model 中刪除
        const deleteTodo = (id) => {
            this.model.data = this.model.data.filter((todo) => {
                return id !== String(todo.id)
            })
        }

        // 點(diǎn)擊 update 時(shí),把對(duì)應(yīng) todo 在 model 中更新
        const updateTodo = (el, id) => {
            this.model.data = this.model.data.map((todo) => {
                return ({
                    id: todo.id,
                    value: setValue(id, todo)
                })
            })
        }
        
        // 輔助 更新函數(shù)
        const setValue = (id, todo) => {
            if (id === String(todo.id)) {
                let updateInput = document.querySelector(`input[data-id="${todo.id}"]`)
                return updateInput.value
            } else {
                return todo.value
            }
        }
    }

    // 全量重置 DOM 的 naive render 實(shí)現(xiàn)
    render() {
        // 由于 view 是純函數(shù)换途,故而直接對(duì)其傳入 Model 數(shù)據(jù)
        // 將輸出的 HTML 模板作為 Controller DOM 內(nèi)的新?tīng)顟B(tài)
        this.el.innerHTML = this.view(this.model.todo)
    }
}

可以看到 Controller 模塊在點(diǎn)擊事件觸發(fā)時(shí)懊渡,沒(méi)有直接修改 dom, 只是修改了 model 中的數(shù)據(jù)刽射, dom 視圖的改變是在 model 中數(shù)據(jù)變化時(shí)自動(dòng) render 的。

View 模塊

如前文所述剃执, View 模塊實(shí)質(zhì)上就是一個(gè)在 Model 中數(shù)據(jù)變更時(shí)誓禁,由 Controller 在 render 方法中執(zhí)行的一個(gè)純函數(shù)。

function view(todos) {
    const todosList = todos.map(todo => `
    <div>
      <span data-id="${todo.id}">
        ${todo.value}
      </span>
      <button data-id="${todo.id}" class="todo-delete">
        刪除
      </button>
      <span>
        <input data-id="${todo.id}"/>
        <button data-id="${todo.id}" class="todo-update">
          Update
        </button>
      </span>
    </div>
  `).join('')

    return (`
    <main>
      <input class="input-add"/>
      <button class="todo-add">Add</button>
      <div>${todosList}</div>
    </main>
  `)
}

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <div id="app"></div>
    <script src="./mvc.js"></script>
    <script>
        const model = new Model()
        let conf = {
            model,
            view,
            el: '#app',
        }
        const controller = new Controller(conf)
        controller.render()
    </script>
</body>
</html>

總結(jié)

在實(shí)現(xiàn) MVC 模式的 todo app 的過(guò)程中肾档,MVC 模式的特性得到了體現(xiàn),【Model, View】模塊直接只對(duì)外提供接口摹恰, 【Controller】模塊則將兩者連接了起來(lái),而 ES6 所提供的 class 高級(jí)特性則大大簡(jiǎn)化這些特性的實(shí)現(xiàn)復(fù)雜度(setter getter 等)怒见。

最后的最后俗慈,咱們?cè)偈崂硐铝鞒蹋?/p>

點(diǎn)擊按鈕 -> 觸發(fā) controller 里的事件 -> 更改 model 數(shù)據(jù) -> 觸發(fā) render 函數(shù) -> 更新視圖

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市遣耍,隨后出現(xiàn)的幾起案子闺阱,更是在濱河造成了極大的恐慌,老刑警劉巖舵变,帶你破解...
    沈念sama閱讀 217,542評(píng)論 6 504
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件酣溃,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡纪隙,警方通過(guò)查閱死者的電腦和手機(jī)赊豌,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,822評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)瘫拣,“玉大人亿絮,你說(shuō)我怎么就攤上這事◆镏簦” “怎么了派昧?”我有些...
    開(kāi)封第一講書(shū)人閱讀 163,912評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)拢切。 經(jīng)常有香客問(wèn)我蒂萎,道長(zhǎng),這世上最難降的妖魔是什么淮椰? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,449評(píng)論 1 293
  • 正文 為了忘掉前任五慈,我火速辦了婚禮,結(jié)果婚禮上主穗,老公的妹妹穿的比我還像新娘泻拦。我一直安慰自己,他們只是感情好忽媒,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,500評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布争拐。 她就那樣靜靜地躺著,像睡著了一般晦雨。 火紅的嫁衣襯著肌膚如雪架曹。 梳的紋絲不亂的頭發(fā)上隘冲,一...
    開(kāi)封第一講書(shū)人閱讀 51,370評(píng)論 1 302
  • 那天,我揣著相機(jī)與錄音绑雄,去河邊找鬼展辞。 笑死,一個(gè)胖子當(dāng)著我的面吹牛万牺,可吹牛的內(nèi)容都是我干的罗珍。 我是一名探鬼主播,決...
    沈念sama閱讀 40,193評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼脚粟,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼靡砌!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起珊楼,我...
    開(kāi)封第一講書(shū)人閱讀 39,074評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎度液,沒(méi)想到半個(gè)月后厕宗,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,505評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡堕担,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,722評(píng)論 3 335
  • 正文 我和宋清朗相戀三年已慢,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片霹购。...
    茶點(diǎn)故事閱讀 39,841評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡佑惠,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出齐疙,到底是詐尸還是另有隱情膜楷,我是刑警寧澤,帶...
    沈念sama閱讀 35,569評(píng)論 5 345
  • 正文 年R本政府宣布贞奋,位于F島的核電站赌厅,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏轿塔。R本人自食惡果不足惜特愿,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,168評(píng)論 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望勾缭。 院中可真熱鬧揍障,春花似錦、人聲如沸俩由。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,783評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)采驻。三九已至审胚,卻和暖如春匈勋,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背膳叨。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 32,918評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工洽洁, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人菲嘴。 一個(gè)月前我還...
    沈念sama閱讀 47,962評(píng)論 2 370
  • 正文 我出身青樓饿自,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親龄坪。 傳聞我的和親對(duì)象是個(gè)殘疾皇子昭雌,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,781評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容