【譯】用純JavaScript寫一個(gè)簡單的MVC App

我想使用model-view-controller體系結(jié)構(gòu)模式并用純JavaScript編寫一個(gè)簡單的應(yīng)用程序。所以我著手做了,下面就是。希望能幫你理解MVC,因?yàn)檫@是一個(gè)很難理解的概念,剛接觸時(shí)候會(huì)很疑惑。

我制作了this todo app,它是一個(gè)簡單的瀏覽器小應(yīng)用程序,你可以進(jìn)行CRUD(create, read, update, delete)操作。它僅由index.html,style.lessscript.js文件組成,非常棒而且簡單,無需安裝依賴/無需框架就可以學(xué)習(xí)。

前置條件

目標(biāo)

用純JavaScript在瀏覽器中創(chuàng)建一個(gè)待辦事項(xiàng)程序(a todo app),并且熟悉MVC概念(和OOP-object-oriented programming,面向?qū)ο缶幊蹋?/p>

因?yàn)檫@個(gè)程序使用了最新的JavaScript特性(ES2017),在不使用Babel編譯為向后兼容的JavaScript語法的情況下,在Safari這樣的瀏覽器上無法按照預(yù)期工作。

什么是MVC?

MVC是組織代碼的一種模式。它是受歡迎的模式之一。

  • Model - 管理應(yīng)用程序的數(shù)據(jù)
  • View - Model的可視化表示(也就是視圖)
  • Controller - 連接用戶和系統(tǒng)

model就是數(shù)據(jù)。在此代辦事項(xiàng)應(yīng)用程序中,這將是實(shí)際的待辦事項(xiàng),以及將會(huì)添加、編輯和刪除它們的方法。

view是數(shù)據(jù)的顯示方式。在此代辦事項(xiàng)應(yīng)用程序中,這將是DOMCSS呈現(xiàn)出來的HTML。

controller連接modelview。它接受用戶輸入,比如單擊或者鍵入,并處理用戶交互的回調(diào)。

model永遠(yuǎn)不會(huì)觸及viewview永遠(yuǎn)不會(huì)觸及model。controller將它們連接起來。

我想說的是,在這個(gè)簡單的 todo app 中使用 MVC 大才小用。如果這是你要?jiǎng)?chuàng)建的應(yīng)用程序,并且整個(gè)系統(tǒng)都由你自己開發(fā),那確實(shí)會(huì)使得事情變得過于復(fù)雜。重點(diǎn)是嘗試從一個(gè)較小的角度了解它,以便你可以理解為什么一個(gè)可伸縮迭代的系統(tǒng)會(huì)使用它。

初始化設(shè)置

這將是一個(gè)完全的JavaScript的應(yīng)用程序,這就意味著所有的內(nèi)容將通過JavaScript處理,而HTML在主體中僅包含一個(gè)根元素。

<!--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>Todo App</title>

    <link rel="stylesheet" href="style.css" />
  </head>

  <body>
    <div id="root"></div>

    <script src="script.js"></script>
  </body>
</html>

我寫了些CSS讓它看起來更加可被接受,你可以通過連接找到它,并且放在style.css中保存。我不會(huì)再寫更多關(guān)于CSS的東西,因?yàn)樗皇潜疚牡慕裹c(diǎn)。

好了,現(xiàn)在我們有了HTMLCSS,所以是時(shí)候開始寫這個(gè)應(yīng)用程序了。

開始

我們將使它變得非常好用和簡單,以了解哪些類對應(yīng)MVC的哪部分。

我將創(chuàng)建一個(gè)Model類,一個(gè)View類和一個(gè)Controller類,它們將包含modelview。該應(yīng)用是控制器的一個(gè)實(shí)例。

如果你不熟悉類是怎么工作的,先去讀下Understanding Classes in JavaScript文章

class Model {
  constructor() {}
}

class View {
  constructor() {}
}

class Controller {
  constructor(model, view) {
    this.model = model
    this.view = view
  }
}

const app = new Controller(new Model(), new View())

非常棒,而且抽象。

Model

我們先來處理model先,因?yàn)樗侨糠种凶詈唵蔚?。它并不涉及任何事件?code>DOM操作。它只是存儲(chǔ)和修改數(shù)據(jù)。

// Model
class Model {
  constructor() {
    // The state of the model, an array of todo objects, prepopulated with some data
    this.todos = [
      { id: 1, text: 'Run a marathon', complete: false },
      { id: 2, text: 'Plant a garden', complete: false },
    ]
  }

  addTodo(todoText) {
    const todo = {
      id: this.todos.length > 0 ? this.todos[this.todos.length - 1].id + 1 : 1,
      text: todoText,
      complete: false,
    }

    this.todos.push(todo)
  }

  // Map through all todos, and replace the text of the todo with the specified id
  editTodo(id, updatedText) {
    this.todos = this.todos.map(todo =>
      todo.id === id ? { id: todo.id, text: updatedText, complete: todo.complete } : todo
    )
  }

  // Filter a todo out of the array by id
  deleteTodo(id) {
    this.todos = this.todos.filter(todo => todo.id !== id)
  }

  // Flip the complete boolean on the specified todo
  toggleTodo(id) {
    this.todos = this.todos.map(todo =>
      todo.id === id ? { id: todo.id, text: todo.text, complete: !todo.complete } : todo
    )
  }
}

我們有一個(gè) addTodo, editTodo, deleteTodotoggleTodo。 這些應(yīng)該都很容易解析 - 添加一個(gè)新的待辦事項(xiàng)到數(shù)組,編輯查找要編輯的待辦事項(xiàng)的ID并替換它,刪除并過濾器篩選出數(shù)組中的待辦事項(xiàng),以及切換complete的布爾值。

因?yàn)槲覀兌际窃跒g覽器中進(jìn)行此操作,并且可以從window(golbal)中訪問應(yīng)用程序,因此你可以輕松地進(jìn)行測試,鍵入以下內(nèi)容:

app.model.addTodo('Take a nap')

上面的命令行將添加一件待辦事項(xiàng)到列表中,你可以打印出app.model.todos查看。

這對于當(dāng)前的model已經(jīng)足夠了。最后,我們將待辦事項(xiàng)存儲(chǔ)在local storage中,使其成為永久性文件,但目前,待辦事項(xiàng)只要刷新頁面就可以刷新了。

如我們所見,model只是處理實(shí)際的數(shù)據(jù),并修改數(shù)據(jù)。它不了解或不知道輸入 - 正在修改的內(nèi)容,或輸出 - 最終將顯示的內(nèi)容。

此時(shí),如果你通過控制臺(tái)手動(dòng)鍵入所有操作并在控制臺(tái)中查看輸出,則你的app具備了功能全面的CRUD。

View

我們將通過操作DOM(文檔對象模型)來創(chuàng)建視圖。由于我們在沒有ReactJSX或模版語言的情況下使用純JavaScript進(jìn)行此操作的,因此它有些冗長和丑陋,但是這就是直接操作DOM的本質(zhì)。

controllermodel都不應(yīng)該了解有關(guān)DOM、HTML元素、CSS或者其他方面的信息。任何與這些信息相關(guān)的東西都應(yīng)該在view層。

如果你不熟悉DOMDOMHTML源碼有何不同,閱讀下Introduction to the DOM文章。

我要做的第一件事情就是創(chuàng)建輔助方法檢索一個(gè)元素并創(chuàng)建一個(gè)元素。

// View
class View {
  constructor() {}

  // Create an element with an optional CSS class
  createElement(tag, className) {
    const element = document.createElement(tag)
    if (className) element.classList.add(className)

    return element
  }

  // Retrieve an element from the DOM
  getElement(selector) {
    const element = document.querySelector(selector)

    return element
  }
}

到目前為止一切順利。在構(gòu)造器中,我將設(shè)置我所需的全部內(nèi)容。那將會(huì):

  • 應(yīng)用程序的根元素 - #root
  • 標(biāo)題 - h1
  • 一個(gè)表單,輸入框和提交按鈕去添加事項(xiàng) - form,input,button
  • 待辦列表 - ul

我將使它們成為構(gòu)造函數(shù)中的所有變量,以便我們可以輕松地引用它們。

// View
class View {
  constructor() {
    // The root element
    this.app = this.getElement('#root')

    // The title of the app
    this.title = this.createElement('h1')
    this.title.textContent = 'Todos'

    // The form, with a [type="text"] input, and a submit button
    this.form = this.createElement('form')

    this.input = this.createElement('input')
    this.input.type = 'text'
    this.input.placeholder = 'Add todo'
    this.input.name = 'todo'

    this.submitButton = this.createElement('button')
    this.submitButton.textContent = 'Submit'

    // The visual representation of the todo list
    this.todoList = this.createElement('ul', 'todo-list')

    // Append the input and submit button to the form
    this.form.append(this.input, this.submitButton)

    // Append the title, form, and todo list to the app
    this.app.append(this.title, this.form, this.todoList)
  }
  // ...
}

現(xiàn)在,視圖不變的部分已經(jīng)設(shè)置好。

view1

兩個(gè)小事情 - 輸入(新待辦事項(xiàng))值的獲取和重置。

我在方法名稱中使用下劃線表示它們是私有(本地)的方法,不會(huì)在類外部使用。

// View
get _todoText() {
  return this.input.value
}

_resetInput() {
  this.input.value = ''
}

現(xiàn)在所有的設(shè)置已經(jīng)完成了。最復(fù)雜的部分是顯示待辦事項(xiàng)列表,這是每次更改待辦事項(xiàng)都會(huì)更改的部分。

// View
displayTodos(todos) {
  // ...
}

displayTodos方法將創(chuàng)建待辦事項(xiàng)列表所組成的ulli,并顯示它們。每次更改,添加,或者刪除待辦事項(xiàng)時(shí),都會(huì)使用模型中的待辦事項(xiàng)todos,再次調(diào)用displayTodos方法,重置列表并顯示它們。這將使得視圖和模型的狀態(tài)保持同步。

我們要做的第一件事是每次調(diào)用時(shí)都會(huì)刪除所有待辦事項(xiàng)的節(jié)點(diǎn)。然后我們將檢查是否有待辦事項(xiàng)。如果沒有,我們將顯示一個(gè)空列表消息。

// View
// Delete all nodes
while (this.todoList.firstChild) {
  this.todoList.removeChild(this.todoList.firstChild)
}

// Show default message
if (todos.length === 0) {
  const p = this.createElement('p')
  p.textContent = 'Nothing to do! Add a task?'
  this.todoList.append(p)
} else {
  // ...
}

現(xiàn)在,我們將遍歷待辦事項(xiàng),并為每個(gè)現(xiàn)有待辦事項(xiàng)顯示一個(gè)復(fù)選框,span和刪除按鈕。

// View
else {
  // Create todo item nodes for each todo in state
  todos.forEach(todo => {
    const li = this.createElement('li')
    li.id = todo.id

    // Each todo item will have a checkbox you can toggle
    const checkbox = this.createElement('input')
    checkbox.type = 'checkbox'
    checkbox.checked = todo.complete

    // The todo item text will be in a contenteditable span
    const span = this.createElement('span')
    span.contentEditable = true
    span.classList.add('editable')

    // If the todo is complete, it will have a strikethrough
    if (todo.complete) {
      const strike = this.createElement('s')
      strike.textContent = todo.text
      span.append(strike)
    } else {
      // Otherwise just display the text
      span.textContent = todo.text
    }

    // The todos will also have a delete button
    const deleteButton = this.createElement('button', 'delete')
    deleteButton.textContent = 'Delete'
    li.append(checkbox, span, deleteButton)

    // Append nodes to the todo list
    this.todoList.append(li)
  })
}

現(xiàn)在視圖和模型都設(shè)置好了。我們只是還沒辦法連接它們 - 沒有事件監(jiān)聽用戶的輸入,也沒有處理程序來處理此類事件的輸出。

控制臺(tái)仍然作為臨時(shí)控制器存在,你可以通過它添加和刪除待辦事項(xiàng)。

view2

Controller

最后,控制器是模型(數(shù)據(jù))和視圖(用戶所見)之間的連接。到目前為止,下面就是控制器中的內(nèi)容。

// Controller
class Controller {
  constructor(model, view) {
    this.model = model
    this.view = view
  }
}

視圖和模型之間的第一個(gè)連接是創(chuàng)建一個(gè)方法,該方法在每次待辦事項(xiàng)更改時(shí)調(diào)用displayTodos。我們也可以在構(gòu)造函數(shù)中調(diào)用一次,以顯示初始待辦事項(xiàng),如果有。

// Controller
class Controller {
  constructor(model, view) {
    this.model = model
    this.view = view

    // Display initial todos
    this.onTodoListChanged(this.model.todos)
  }

  onTodoListChanged = todos => {
    this.view.displayTodos(todos)
  }
}

觸發(fā)事件之后,控制器將對其進(jìn)行處理。當(dāng)你提交新的待辦事項(xiàng),單擊刪除按鈕或單擊待辦事項(xiàng)的復(fù)選框時(shí),將觸發(fā)一個(gè)事件。視圖必須監(jiān)聽那些事件,因?yàn)樗且晥D中用戶的輸入,但是它將把響應(yīng)該事件將要發(fā)生的事情責(zé)任派發(fā)到控制器。

我們將在控制器中為事項(xiàng)創(chuàng)建處理程序。

// View
handleAddTodo = todoText => {
  this.model.addTodo(todoText)
}

handleEditTodo = (id, todoText) => {
  this.model.editTodo(id, todoText)
}

handleDeleteTodo = id => {
  this.model.deleteTodo(id)
}

handleToggleTodo = id => {
  this.model.toggleTodo(id)
}

設(shè)置事件監(jiān)聽器

現(xiàn)在我們有了這些處理程序,但是控制器仍然不知道何時(shí)調(diào)用它們。我們必須將事件監(jiān)聽器放在視圖的DOM元素上。我們將響應(yīng)表單上的submit事件,然后單擊click并更改change待辦事項(xiàng)列表上的事件。(由于略為復(fù)雜,我這里略過"編輯")。

// View
bindAddTodo(handler) {
  this.form.addEventListener('submit', event => {
    event.preventDefault()

    if (this._todoText) {
      handler(this._todoText)
      this._resetInput()
    }
  })
}

bindDeleteTodo(handler) {
  this.todoList.addEventListener('click', event => {
    if (event.target.className === 'delete') {
      const id = parseInt(event.target.parentElement.id)

      handler(id)
    }
  })
}

bindToggleTodo(handler) {
  this.todoList.addEventListener('change', event => {
    if (event.target.type === 'checkbox') {
      const id = parseInt(event.target.parentElement.id)

      handler(id)
    }
  })
}

我們需要從視圖中調(diào)用處理程序,因此我們將監(jiān)聽事件的方法綁定到視圖。

我們使用箭頭函數(shù)來處理事件。這允許我們直接使用controller的上下文this來調(diào)用view中的表單。如果你不使用箭頭函數(shù),我們需要手動(dòng)bind綁定它們,比如this.view.bindAddTodo(this.handleAddTodo.bind(this))。咦~

// Controller
this.view.bindAddTodo(this.handleAddTodo)
this.view.bindDeleteTodo(this.handleDeleteTodo)
this.view.bindToggleTodo(this.handleToggleTodo)
// this.view.bindEditTodo(this.handleEditTodo) - We'll do this one last

現(xiàn)在,當(dāng)一個(gè)submit,click或者change事件在特定的元素中觸發(fā),相應(yīng)的處理事件將被喚起。

響應(yīng)模型中的回調(diào)

我們遺漏了一些東西 - 事件正在監(jiān)聽,處理程序被調(diào)用,但是什么也沒有發(fā)生。這是因?yàn)槟P筒恢酪晥D應(yīng)該更新,也不知道如何進(jìn)行視圖的更新。我們在視圖上有displayTodos方法來解決此問題,但是如前所述,模型和視圖不互通。

就像監(jiān)聽起那樣,模型應(yīng)該觸發(fā)回來控制器這里,以便其知道發(fā)生了某些事情。

我們已經(jīng)在控制器上創(chuàng)建了onTodoListChanged方法來處理此問題,我們只需要使模型知道它就可以了。我們將其綁定到模型上,就像綁定到視圖的方式一樣。

在模型上,為onTodoListChanged添加bindTodoListChanged方法。

// Model
bindTodoListChanged(callback) {
  this.onTodoListChanged = callback
}

然后將其綁定到控制器中,就像與視圖一樣。

// Controller
this.model.bindTodoListChanged(this.onTodoListChanged)

現(xiàn)在,在模型中的每個(gè)方法之后,你將調(diào)用onTodoListChanged回調(diào)。

// Model
deleteTodo(id) {
  this.todos = this.todos.filter(todo => todo.id !== id)

  this.onTodoListChanged(this.todos)
}

添加 local storage

至此,該應(yīng)用程序已基本完成,所有概念都已演示。通過將數(shù)據(jù)持久保存在瀏覽器的本地存儲(chǔ)中,我們可以使其更加持久,因此刷新后將在本地持久保存。

如果你不熟悉local storage是怎么工作的,閱讀下How to Use Local Storage with JavaScript文章。

現(xiàn)在,我們可以將初始化待辦事項(xiàng)設(shè)置為本地存儲(chǔ)或空數(shù)組中的值。

// Model
class Model {
  constructor() {
    this.todos = JSON.parse(localStorage.getItem('todos')) || []
  }
}

我們將創(chuàng)建一個(gè)commit的私有方法來更新localStorage的值,作為模型的狀態(tài)。

_commit(todos) {
  this.onTodoListChanged(todos)
  localStorage.setItem('todos', JSON.stringify(todos))
}

在每次this.todos發(fā)生更改之后,我們可以調(diào)用它。

deleteTodo(id) {
  this.todos = this.todos.filter(todo => todo.id !== id)

  this._commit(this.todos)
}

添加實(shí)時(shí)編輯功能

這個(gè)難題的最后一部分是編現(xiàn)有待辦事項(xiàng)的能力。與添加和刪除相比,編輯總是有些棘手。我想簡化它,不需要編輯按鈕,用輸入框input或其他來代替span。我們也不想每次輸入時(shí)都調(diào)用editTodo,因?yàn)樗鼘秩菊麄€(gè)待辦事項(xiàng)列表UI

我決定在視圖上創(chuàng)建一個(gè)方法,用新的編輯值更新一個(gè)臨時(shí)狀態(tài)變量,然后在視圖中創(chuàng)建一個(gè)方法,該方法在控制器中調(diào)用handleEditTodo方法來更新模型。輸入事件是當(dāng)你鍵入contenteditable元素時(shí)觸發(fā)事件,而foucesout在你離開contenteditable元素時(shí)候觸發(fā)的事件。

// View
constructor() {
  // ...
  this._temporaryTodoText
  this._initLocalListeners()
}

// Update temporary state
_initLocalListeners() {
  this.todoList.addEventListener('input', event => {
    if (event.target.className === 'editable') {
      this._temporaryTodoText = event.target.innerText
    }
  })
}

// Send the completed value to the model
bindEditTodo(handler) {
  this.todoList.addEventListener('focusout', event => {
    if (this._temporaryTodoText) {
      const id = parseInt(event.target.parentElement.id)

      handler(id, this._temporaryTodoText)
      this._temporaryTodoText = ''
    }
  })
}

現(xiàn)在,當(dāng)你單擊任何待辦事項(xiàng)時(shí),你將進(jìn)入"編輯"模式,這將更新臨時(shí)臨時(shí)狀態(tài)變量,并且在你選擇或者單擊離開待辦事件時(shí),它將保存在模型中并重置臨時(shí)狀態(tài)。
只需要確保綁定editTodo處理程序就可以。

this.view.bindEditTodo(this.handleEditTodo)

contenteditable解決方案得以快速實(shí)施。在生產(chǎn)環(huán)境中使用contenteditable時(shí),你需要考慮各種問題,many of which I've written about here

總結(jié)

現(xiàn)在實(shí)現(xiàn)它了。使用純JavaScript的無依賴待辦事項(xiàng)應(yīng)用程序,演示了模型-視圖-控制器結(jié)構(gòu)的概念。下面再次放出完整案例和源碼地址。

小Demo

翻譯到此結(jié)束,上面是經(jīng)典的todo list展示,引入localstorage也許有些不好理解。我們來個(gè)小的案例鞏固下:

<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>demo</title>
</head>
<body>
  <div id="root"></div>
</body>
<script>
  class Model {
    constructor() {
      this.count = 0
    }
    increastCount() {
      this.count += 1
      console.log(this.count)
      this.showCount(this.count)
    }
    bindCountChange(cb) {
      this.showCount = cb
    }
  }
  class View {
    constructor() {
      this.app = document.querySelector('#root')
      this.div = document.createElement('div')
      this.button = document.createElement('button')
      this.button.textContent = 'increase'
      this.button.classList = 'increase'
      this.app.append(this.div)
      this.app.append(this.button)
    }

    bindIncrease(handler) {
      this.button.addEventListener('click', event => {
        if(event.target.className === 'increase') {
          handler()
        }
      })
    }

    displayCount(count) {
      this.div.textContent = `current count is ${count}`
    }
  }
  class Controller {
    constructor(model, view) {
      this.model = model
      this.view = view

      this.model.bindCountChange(this.showCount)
      this.view.bindIncrease(this.handleIncrease)

      this.showCount(this.model.count)
    }

    handleIncrease = () => {
      this.model.increastCount()
    }

    showCount = count => {
      this.view.displayCount(count)
    }
  }
  const app = new Controller(new Model(), new View())
</script>
</html>

上面實(shí)現(xiàn)的功能是點(diǎn)擊increase按鈕遞增數(shù)字,如下動(dòng)圖:

mvc demo

后話

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容