我想使用model-view-controller体系结构模式并用纯JavaScript
编写一个简单的应用程序。所以我着手做了,下面就是。希望能帮你理解MVC,因为这是一个很难理解的概念,刚接触时候会很疑惑。
我制作了this todo app,它是一个简单的浏览器小应用程序,你可以进行CRUD(create, read, update, delete)操作。它仅由index.html
,style.less
和script.js
文件组成,非常棒而且简单,无需安装依赖/无需框架就可以学习。
前置条件
- 基本的
JavaScript
和HTML
知识 - 熟悉the latest JavaScript syntax
目标
用纯JavaScript
在浏览器中创建一个待办事项程序(a todo app),并且熟悉MVC
概念(和OOP-object-oriented programming,面向对象编程)。
因为这个程序使用了最新的
JavaScript
特性(ES2017),在不使用Babel
编译为向后兼容的JavaScript
语法的情况下,在Safari
这样的浏览器上无法按照预期工作。
什么是MVC?
MVC
是组织代码的一种模式。它是受欢迎的模式之一。
- Model - 管理应用程序的数据
- View - Model的可视化表示(也就是视图)
- Controller - 连接用户和系统
model就是数据。在此代办事项应用程序中,这将是实际的待办事项,以及将会添加、编辑和删除它们的方法。
view是数据的显示方式。在此代办事项应用程序中,这将是DOM
和CSS
呈现出来的HTML
。
controller连接model和view。它接受用户输入,比如单击或者键入,并处理用户交互的回调。
model永远不会触及view。view永远不会触及model。controller将它们连接起来。
我想说的是,在这个简单的 todo app 中使用 MVC 大才小用。如果这是你要创建的应用程序,并且整个系统都由你自己开发,那确实会使得事情变得过于复杂。重点是尝试从一个较小的角度了解它,以便你可以理解为什么一个可伸缩迭代的系统会使用它。
初始化设置
这将是一个完全的JavaScript
的应用程序,这就意味着所有的内容将通过JavaScript
处理,而HTML
在主体中仅包含一个根元素。
<!--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
中保存。我不会再写更多关于CSS
的东西,因为它不是本文的焦点。
好了,现在我们有了HTML
和CSS
,所以是时候开始写这个应用程序了。
开始
我们将使它变得非常好用和简单,以了解哪些类对应MVC
的哪部分。
我将创建一个Model
类,一个View
类和一个Controller
类,它们将包含model和view。该应用是控制器的一个实例。
如果你不熟悉类是怎么工作的,先去读下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先,因为它是三部分中最简单的。它并不涉及任何事件和DOM
操作。它只是存储和修改数据。
// 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 ) } } 复制代码
我们有一个 addTodo
, editTodo
, deleteTodo
和 toggleTodo
。 这些应该都很容易解析 - 添加一个新的待办事项到数
组,编辑查找要编辑的待办事项的ID
并替换它,删除并过滤器筛选出数组中的待办事项,以及切换complete
的布尔值。
因为我们都是在浏览器中进行此操作,并且可以从window(golbal)
中访问应用程序,因此你可以轻松地进行测试,键入以下内容:
app.model.addTodo('Take a nap') 复制代码
上面的命令行将添加一件待办事项到列表中,你可以打印出app.model.todos
查看。
这对于当前的model
已经足够了。最后,我们将待办事项存储在local storage中,使其成为永久性文件,但目前,待办事项只要刷新页面就可以刷新了。
如我们所见,model
只是处理实际的数据,并修改数据。它不了解或不知道输入 - 正在修改的内容,或输出 - 最终将显示的内容。
此时,如果你通过控制台手动键入所有操作并在控制台中查看输出,则你的app
具备了功能全面的CRUD
。
View
我们将通过操作DOM(文档对象模型)来创建视图。由于我们在没有React
的JSX
或模版语言的情况下使用纯JavaScript
进行此操作的,因此它有些冗长和丑陋,但是这就是直接操作DOM
的本质。
controller和model都不应该了解有关DOM
、HTML
元素、CSS
或者其他方面的信息。任何与这些信息相关的东西都应该在view层。
如果你不熟悉
DOM
或DOM
与HTML
源码有何不同,阅读下Introduction to the DOM文章。
我要做的第一件事情就是创建辅助方法检索一个元素并创建一个元素。
// 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 } } 复制代码
到目前为止一切顺利。在构造器中,我将设置我所需的全部内容。那将会:
- 应用程序的根元素 -
#root
- 标题 -
h1
- 一个表单,输入框和提交按钮去添加事项 -
form
,input
,button
- 待办列表 -
ul
我将使它们成为构造函数中的所有变量,以便我们可以轻松地引用它们。
// 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) } // ... } 复制代码
现在,视图不变的部分已经设置好。
两个小事情 - 输入(新待办事项)值的获取和重置。
我在方法名称中使用下划线表示它们是私有(本地)的方法,不会在类外部使用。
// View get _todoText() { return this.input.value } _resetInput() { this.input.value = '' } 复制代码
现在所有的设置已经完成了。最复杂的部分是显示待办事项列表,这是每次更改待办事项都会更改的部分。
// View displayTodos(todos) { // ... } 复制代码
displayTodos
方法将创建待办事项列表所组成的ul
和li
,并显示它们。每次更改,添加,或者删除待办事项时,都会使用模型中的待办事项todos
,再次调用displayTodos
方法,重置列表并显示它们。这将使得视图和模型的状态保持同步。
我们要做的第一件事是每次调用时都会删除所有待办事项的节点。然后我们将检查是否有待办事项。如果没有,我们将显示一个空列表消息。
// 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 { // ... } 复制代码
现在,我们将遍历待办事项,并为每个现有待办事项显示一个复选框,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) }) } 复制代码
现在视图和模型都设置好了。我们只是还没办法连接它们 - 没有事件监听用户的输入,也没有处理程序来处理此类事件的输出。
控制台仍然作为临时控制器存在,你可以通过它添加和删除待办事项。
Controller
最后,控制器是模型(数据)和视图(用户所见)之间的连接。到目前为止,下面就是控制器中的内容。
// Controller class Controller { constructor(model, view) { this.model = model this.view = view } } 复制代码
视图和模型之间的第一个连接是创建一个方法,该方法在每次待办事项更改时调用displayTodos
。我们也可以在构造函数中调用一次,以显示初始待办事项,如果有。
// 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) } } 复制代码
触发事件之后,控制器将对其进行处理。当你提交新的待办事项,单击删除按钮或单击待办事项的复选框时,将触发一个事件。视图必须监听那些事件,因为它是视图中用户的输入,但是它将把响应该事件将要发生的事情责任派发到控制器。
我们将在控制器中为事项创建处理程序。
// 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) } 复制代码
设置事件监听器
现在我们有了这些处理程序,但是控制器仍然不知道何时调用它们。我们必须将事件监听器放在视图的DOM
元素上。我们将响应表单上的submit
事件,然后单击click
并更改change
待办事项列表上的事件。(由于略为复杂,我这里略过"编辑")。
// 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) } }) } 复制代码
我们需要从视图中调用处理程序,因此我们将监听事件的方法绑定到视图。
我们使用箭头函数来处理事件。这允许我们直接使用
controller
的上下文this
来调用view
中的表单。如果你不使用箭头函数,我们需要手动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 复制代码
现在,当一个submit
,click
或者change
事件在特定的元素中触发,相应的处理事件将被唤起。
响应模型中的回调
我们遗漏了一些东西 - 事件正在监听,处理程序被调用,但是什么也没有发生。这是因为模型不知道视图应该更新,也不知道如何进行视图的更新。我们在视图上有displayTodos
方法来解决此问题,但是如前所述,模型和视图不互通。
就像监听起那样,模型应该触发回来控制器这里,以便其知道发生了某些事情。
我们已经在控制器上创建了onTodoListChanged
方法来处理此问题,我们只需要使模型知道它就可以了。我们将其绑定到模型上,就像绑定到视图的方式一样。
在模型上,为onTodoListChanged
添加bindTodoListChanged
方法。
// Model bindTodoListChanged(callback) { this.onTodoListChanged = callback } 复制代码
然后将其绑定到控制器中,就像与视图一样。
// Controller this.model.bindTodoListChanged(this.onTodoListChanged) 复制代码
现在,在模型中的每个方法之后,你将调用onTodoListChanged
回调。
// Model deleteTodo(id) { this.todos = this.todos.filter(todo => todo.id !== id) this.onTodoListChanged(this.todos) } 复制代码
添加 local storage
至此,该应用程序已基本完成,所有概念都已演示。通过将数据持久保存在浏览器的本地存储中,我们可以使其更加持久,因此刷新后将在本地持久保存。
如果你不熟悉
local storage
是怎么工作的,阅读下How to Use Local Storage with JavaScript文章。
现在,我们可以将初始化待办事项设置为本地存储或空数组中的值。
// Model class Model { constructor() { this.todos = JSON.parse(localStorage.getItem('todos')) || [] } } 复制代码
我们将创建一个commit
的私有方法来更新localStorage
的值,作为模型的状态。
_commit(todos) { this.onTodoListChanged(todos) localStorage.setItem('todos', JSON.stringify(todos)) } 复制代码
在每次this.todos
发生更改之后,我们可以调用它。
deleteTodo(id) { this.todos = this.todos.filter(todo => todo.id !== id) this._commit(this.todos) } 复制代码
添加实时编辑功能
这个难题的最后一部分是编现有待办事项的能力。与添加和删除相比,编辑总是有些棘手。我想简化它,不需要编辑按钮,用输入框input
或其他来代替span
。我们也不想每次输入时都调用editTodo
,因为它将渲染整个待办事项列表UI
。
我决定在视图上创建一个方法,用新的编辑值更新一个临时状态变量,然后在视图中创建一个方法,该方法在控制器中调用handleEditTodo
方法来更新模型。输入事件是当你键入contenteditable
元素时触发事件,而foucesout
在你离开contenteditable
元素时候触发的事件。
// 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 = '' } }) } 复制代码
现在,当你单击任何待办事项时,你将进入"编辑"模式,这将更新临时临时状态变量,并且在你选择或者单击离开待办事件时,它将保存在模型中并重置临时状态。
只需要确保绑定editTodo
处理程序就可以。
this.view.bindEditTodo(this.handleEditTodo) 复制代码
该
contenteditable
解决方案得以快速实施。在生产环境中使用contenteditable
时,你需要考虑各种问题,many of which I've written about here
总结
现在实现它了。使用纯JavaScript
的无依赖待办事项应用程序,演示了模型-视图-控制器结构的概念。下面再次放出完整案例和源码地址。
小Demo
翻译到此结束,上面是经典的todo list
展示,引入localstorage
也许有些不好理解。我们来个小的案例巩固下:
<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> 复制代码
上面实现的功能是点击increase
按钮递增数字,如下动图: