我想使用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.less和script.js文件組成,非常棒而且簡單,無需安裝依賴/無需框架就可以學(xué)習(xí)。
前置條件
- 基本的
JavaScript和HTML知識 - 熟悉the latest JavaScript syntax
目標(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)用程序中,這將是DOM和CSS呈現(xiàn)出來的HTML。
controller連接model和view。它接受用戶輸入,比如單擊或者鍵入,并處理用戶交互的回調(diào)。
model永遠(yuǎn)不會(huì)觸及view。view永遠(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)在我們有了HTML和CSS,所以是時(shí)候開始寫這個(gè)應(yīng)用程序了。
開始
我們將使它變得非常好用和簡單,以了解哪些類對應(yīng)MVC的哪部分。
我將創(chuàng)建一個(gè)Model類,一個(gè)View類和一個(gè)Controller類,它們將包含model和view。該應(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, deleteTodo 和 toggleTodo。 這些應(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)建視圖。由于我們在沒有React的JSX或模版語言的情況下使用純JavaScript進(jìn)行此操作的,因此它有些冗長和丑陋,但是這就是直接操作DOM的本質(zhì)。
controller和model都不應(yīng)該了解有關(guān)DOM、HTML元素、CSS或者其他方面的信息。任何與這些信息相關(guān)的東西都應(yīng)該在view層。
如果你不熟悉
DOM或DOM與HTML源碼有何不同,閱讀下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è)置好。
兩個(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)列表所組成的ul和li,并顯示它們。每次更改,添加,或者刪除待辦事項(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)。
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)圖: