用纯 JavaScript 撸一个 MVC 框架[每日前端夜话0xA5]
我想用 model-view-controller 架构模式在纯 JavaScript 中写一个简单的程序,于是我这样做了。希望它可以帮你理解 MVC,因为当你刚开始接触它时,它是一个难以理解的概念。
我做了这个todo应用程序,这是一个简单小巧的浏览器应用,允许你对待办事项进行CRUD(创建,读取,更新和删除)操作。它只包含 index.html
、 style.css
和 script.js
三个文件,非常简单,无需任何依赖和框架。
先决条件
-
基本的 JavaScript 和 HTML 知识
-
熟悉最新的 JavaScript 语法
目标
用纯 JavaScript 在浏览器中创建一个 todo 应用程序,并熟悉MVC(和 OOP——面向对象编程)的概念。
-
查看程序的演示【 https://taniarascia.github.io/mvc 】
-
查看程序的源代码【 https://github.com/taniarascia/mvc 】
注意:由于此程序使用了最新的 JavaScript 功能(ES2017),因此在某些浏览器(如 Safari)上无法用 Babel 编译为向后兼容的 JavaScript 语法。
什么是 MVC?
MVC 是一种非常受欢迎组织代码的模式。
-
Model(模型)- 管理程序的数据
-
View(视图)- 模型的直观表示
-
Controller(控制器)- 链接用户和系统
模型是数据。在这个 todo 程序中,这将是实际的待办事项,以及将添加、编辑或删除它们的方法。
视图是数据的显示方式。在这个程序中,是 DOM 和 CSS 中呈现的 HTML。
控制器用来连接模型和视图。它需要用户输入,例如单击或键入,并处理用户交互的回调。
模型永远不会触及视图。视图永远不会触及模型。控制器用来连接它们。
我想提一下,为一个简单的 todo 程序做 MVC 实际上是一大堆样板。如果这是你想要创建的程序并且创建了整个系统,那真的会让事情变得过于复杂。关键是要尝试在较小的层面上理解它。
初始设置
这将是一个完全用 JavaScript 写的程序,这意味着一切都将通过 JavaScript 处理,HTML 将只包含根元素。
index.html
1 2 3 4 5 6 7 8Todo App 9 10 11 12 13 14 15 16 17 18
我写了一小部分 CSS 只是为了让它看起来可以接受,你可以找到这个文件并保存到 style.css
。我不打算再写CSS了,因为它不是本文的重点。
好的,现在我们有了HTML和CSS,下面该开始编写程序了。
入门
我会使这个教程简单易懂,使你轻松了解哪个类属于 MVC 的哪个部分。我将创建一个 Model
类, View
类和 Controller
类。该程序将是控制器的实例。
如果你不熟悉类的工作方式,请阅读了解JavaScript中的类【 https://www.taniarascia.com/understanding-classes-in-javascript/ 】。
1class Model { 2 constructor() {} 3} 4 5class View { 6 constructor() {} 7} 8 9class Controller { 10 constructor(model, view) { 11 this.model = model 12 this.view = view 13 } 14} 15 16const app = new Controller(new Model(), new View())
模型
让我们先关注模型,因为它是三个部分中最简单的一个。它不涉及任何事件或 DOM 操作。它只是存储和修改数据。
1//模型 2class Model { 3 constructor() { 4 // The state of the model, an array of todo objects, prepopulated with some data 5 this.todos = [ 6 { id: 1, text: 'Run a marathon', complete: false }, 7 { id: 2, text: 'Plant a garden', complete: false }, 8 ] 9 } 10 11 // Append a todo to the todos array 12 addTodo(todo) { 13 this.todos = [...this.todos, todo] 14 } 15 16 // Map through all todos, and replace the text of the todo with the specified id 17 editTodo(id, updatedText) { 18 this.todos = this.todos.map(todo => 19 todo.id === id ? { id: todo.id, text: updatedText, complete: todo.complete } : todo 20 ) 21 } 22 23 // Filter a todo out of the array by id 24 deleteTodo(id) { 25 this.todos = this.todos.filter(todo => todo.id !== id) 26 } 27 28 // Flip the complete boolean on the specified todo 29 toggleTodo(id) { 30 this.todos = this.todos.map(todo => 31 todo.id === id ? { id: todo.id, text: todo.text, complete: !todo.complete } : todo 32 ) 33 } 34}
我们定义了 addTodo
、 editTodo
、 deleteTodo
和 toggleTodo
。这些都应该是一目了然的:add 添加到数组,edit 找到 todo 的 id 进行编辑和替换,delete 过滤数组中的todo,并切换切换 complete
布尔属性。
由于我们在浏览器中执行此操作,并且可以从窗口(全局)访问,因此你可以轻松地测试这些内容,输入以下内容:
1app.model.addTodo({ id: 3, text: 'Take a nap', complete: false })
将向列表中添加一个待办事项,你可以查看 app.model.todos
的内容。
这对于现在的模型来说已经足够了。最后我们会将待办事项存储在 local storage 中,以使其成为半永久性的,但现在只要刷新页面,todo 就会刷新。
我们可以看到,该模型仅处理并修改实际数据。它不理解或不知道 输入 —— 正在修改它,或 输出 —— 最终会显示什么。
这时如果你通过控制台手动输入所有操作,并在控制台中查看输出,就可以获得功能完善的 CRUD 程序所需的一切。
视图
我们将通过操纵 DOM —— 文档对象模型来创建视图。由于没有 React 的 JSX 或模板语言的帮助,在普通的 JavaScript 中执行此操作,因此它将是冗长和丑陋的,但这是直接操纵 DOM 的本质。
控制器和模型都不应该知道关于 DOM、HTML元素、CSS 或其中任何内容的信息。任何与之相关的内容都应该放在视图中。
如果你不熟悉 DOM 或 DOM 与 HTML 源代码之间有什么不同,请阅读DOM简介【 https://www.taniarascia.com/introduction-to-the-dom/ 】。
要做的第一件事就是创建辅助方法来检索并创建元素。
1//视图 2class View { 3 constructor() {} 4 5 // Create an element with an optional CSS class 6 createElement(tag, className) { 7 const element = document.createElement(tag) 8 if (className) element.classList.add(className) 9 10 return element 11 } 12 13 // Retrieve an element from the DOM 14 getElement(selector) { 15 const element = document.querySelector(selector) 16 17 return element 18 } 19}
到目前为止还挺好。接着在构造函数中,我将为视图设置需要的所有东西:
-
应用程序的根元素 –
#root
-
标题
h1
-
一个表单,输入框和提交按钮,用于添加待办事项 –
form
,input
,button
-
待办事项清单 –
ul
我将在构造函数中创建所有变量,以便可以轻松地引用它们。
1//视图 2class View { 3 constructor() { 4 // The root element 5 this.app = this.getElement('#root') 6 7 // The title of the app 8 this.title = this.createElement('h1') 9 this.title.textContent = 'Todos' 10 11 // The form, with a [type="text"] input, and a submit button 12 this.form = this.createElement('form') 13 14 this.input = this.createElement('input') 15 this.input.type = 'text' 16 this.input.placeholder = 'Add todo' 17 this.input.name = 'todo' 18 19 this.submitButton = this.createElement('button') 20 this.submitButton.textContent = 'Submit' 21 22 // The visual representation of the todo list 23 this.todoList = this.createElement('ul', 'todo-list') 24 25 // Append the input and submit button to the form 26 this.form.append(this.input, this.submitButton) 27 28 // Append the title, form, and todo list to the app 29 this.app.append(this.title, this.form, this.todoList) 30 } 31 // ... 32}
现在,将设置不会被更改的视图部分。
另外两个小东西:输入(new todo)值的 getter 和 resetter。
1// 视图 2get todoText() { 3 return this.input.value 4} 5 6resetInput() { 7 this.input.value = '' 8}
现在所有设置都已完成。最复杂的部分是显示待办事项列表,这是每次对待办事项进行修改时将被更改的部分。
1//视图 2displayTodos(todos) { 3 // ... 4}
displayTodos
方法将创建待办事项列表所包含的 ul
和 li
并显示它们。每次修改、添加或删除 todo 时,都会使用模型中的 todos
再次调用 displayTodos
方法,重置列表并重新显示它们。这将使视图与模型的状态保持同步。
我们要做的第一件事就是每次调用时删除所有 todo 节点。然后检查是否存在待办事项。如果不这样做,我们将会得到一个空的列表消息。
1// 视图 2// Delete all nodes 3while (this.todoList.firstChild) { 4 this.todoList.removeChild(this.todoList.firstChild) 5} 6 7// Show default message 8if (todos.length === 0) { 9 const p = this.createElement('p') 10 p.textContent = 'Nothing to do! Add a task?' 11 this.todoList.append(p) 12} else { 13 // ... 14}
现在循环遍历待办事项并为每个现有待办事项显示复选框、span 和删除按钮。
1// 视图 2else { 3 // Create todo item nodes for each todo in state 4 todos.forEach(todo => { 5 const li = this.createElement('li') 6 li.id = todo.id 7 8 // Each todo item will have a checkbox you can toggle 9 const checkbox = this.createElement('input') 10 checkbox.type = 'checkbox' 11 checkbox.checked = todo.complete 12 13 // The todo item text will be in a contenteditable span 14 const span = this.createElement('span') 15 span.contentEditable = true 16 span.classList.add('editable') 17 18 // If the todo is complete, it will have a strikethrough 19 if (todo.complete) { 20 const strike = this.createElement('s') 21 strike.textContent = todo.text 22 span.append(strike) 23 } else { 24 // Otherwise just display the text 25 span.textContent = todo.text 26 } 27 28 // The todos will also have a delete button 29 const deleteButton = this.createElement('button', 'delete') 30 deleteButton.textContent = 'Delete' 31 li.append(checkbox, span, deleteButton) 32 33 // Append nodes to the todo list 34 this.todoList.append(li) 35 }) 36}
现在设置视图及模型。我们只是没有办法连接它们,因为现在还没有事件监视用户进行输入,也没有处理这种事件的输出的 handle。
控制台仍然作为临时控制器存在,你可以通过它添加和删除待办事项。
控制器
最后,控制器是模型(数据)和视图(用户看到的内容)之间的链接。这是我们到目前为止控制器中的内容。
1//控制器 2class Controller { 3 constructor(model, view) { 4 this.model = model 5 this.view = view 6 } 7}
在视图和模型之间的第一个链接是创建一个每次 todo 更改时调用 displayTodos
的方法。我们也可以在 constructor
中调用它一次,来显示初始的 todos(如果有的话)。
1//控制器 2class Controller { 3 constructor(model, view) { 4 this.model = model 5 this.view = view 6 7 // Display initial todos 8 this.onTodoListChanged(this.model.todos) 9 } 10 11 onTodoListChanged = todos => { 12 this.view.displayTodos(todos) 13 } 14}
控制器将在触发后处理事件。当你提交新的待办事项、单击删除按钮或单击待办事项的复选框时,将触发一个事件。视图必须侦听这些事件,因为它们是视图的用户输入,它会将响应事件所要做的工作分配给控制器。
我们将为事件创建 handler。首先,提交一个 handleAddTodo
事件,当我们创建的待办事项输入表单被提交时,可以通过按 Enter 键或单击“提交”按钮来触发。这是一个 submit
事件。
回到视图中,我们将 this.input.value
的 getter 作为 get todoText
。要确保输入不能为空,然后我们将创建带有 id
、 text
并且 complete
值为 false 的 todo。将 todo 添加到模型中,然后重置输入框。
1// 控制器 2// Handle submit event for adding a todo 3handleAddTodo = event => { 4 event.preventDefault() 5 6 if (this.view.todoText) { 7 const todo = { 8 id: this.model.todos.length > 0 ? this.model.todos[this.model.todos.length - 1].id + 1 : 1, 9 text: this.view.todoText, 10 complete: false, 11 } 12 13 this.model.addTodo(todo) 14 this.view.resetInput() 15 } 16}
删除 todo 的操作类似。它将响应删除按钮上的 click
事件。删除按钮的父元素是 todo li
本身,它附有相应的 id
。我们需要将该数据发送给正确的模型方法。
1// 控制器 2// Handle click event for deleting a todo 3handleDeleteTodo = event => { 4 if (event.target.className === 'delete') { 5 const id = parseInt(event.target.parentElement.id) 6 7 this.model.deleteTodo(id) 8 } 9}
在 JavaScript 中,当你单击复选框来切换它时,会发出 change
事件。按照处理单击删除按钮的方式处理此方法,并调用模型方法。
1// 控制器 2// Handle change event for toggling a todo 3handleToggle = event => { 4 if (event.target.type === 'checkbox') { 5 const id = parseInt(event.target.parentElement.id) 6 7 this.model.toggleTodo(id) 8 } 9}
这些控制器方法有点乱 – 理想情况下它们不应该处理任何逻辑,而是应该简单地调用模型。
设置事件监听器
现在我们有了这三个 handler ,但控制器仍然不知道应该什么时候调用它们。必须把事件侦听器放在视图中的 DOM 元素上。我们将回复表单上的 submit
事件,以及 todo 列表上的 click
和 change
事件。
在 View
中添加一个 bindEvents
方法,该方法将调用这些事件。
1// 视图 2bindEvents(controller) { 3 this.form.addEventListener('submit', controller.handleAddTodo) 4 this.todoList.addEventListener('click', controller.handleDeleteTodo) 5 this.todoList.addEventListener('change', controller.handleToggle) 6}
接着把侦听事件的方法绑定到视图。在 Controller
的 constructor
中,调用 bindEvents
并传递控制器的 this
上下文。
在所有句柄事件上都用了箭头函数。这允许我们可以用控制器的 this
上下文从视图中调用它们。如果不用箭头函数,我们将不得不手动去绑定它们,如 controller.handleAddTodo.bind(this)
。
1// 控制器 2this.view.bindEvents(this)
现在,当指定的元素发生 submit
、 click
或 change
事件时,将会调用相应的 handler。
响应模型中的回调
我们还遗漏了一些东西:事件正在侦听,handler 被调用,但是没有任何反应。这是因为模型不知道视图应该更新,并且不知道如何更新视图。我们在视图上有 displayTodos
方法来解决这个问题,但如前所述,模型和视图不应该彼此了解。
就像侦听事件一样,模型应该回到控制器,让它知道发生了什么。
我们已经在控制器上创建了 onTodoListChanged
方法来处理这个问题,接下来只需让模型知道它。我们将它绑定到模型,就像对视图上的 handler 所做的一样。
在模型中,为 onTodoListChanged
添加 bindEvents
。
1// 模型 2bindEvents(controller) { 3 this.onTodoListChanged = controller.onTodoListChanged 4}
在控制器中,发送 this
上下文。
1// 控制器 2constructor() { 3 // ... 4 this.model.bindEvents(this) 5 this.view.bindEvents(this) 6}
现在,在模型中的每个方法之后,你将调用 onTodoListChanged
回调。
在更复杂的程序中,可能对不同的事件有不同的回调,但在这个简单的待办事项程序中,我们可以在所有方法之间共享一个回调。
1//模型 2addTodo(todo) { 3 this.todos = [...this.todos, todo] 4 5 this.onTodoListChanged(this.todos) 6}
添加 local storage
这时程序的大部分都已完成,所有概念都已经演示过了。我们可以通过将数据保存在浏览器的 local storage 中来对其进行持久化。
如果你不了解 local storage 的工作原理,请阅读如何使用JavaScript local storage【 https://www.taniarascia.com/how-to-use-local-storage-with-javascript/ 】。
现在我们可以将待办事项的初始值设置为本地存储或空数组。
1// 模型 2class Model { 3 constructor() { 4 this.todos = JSON.parse(localStorage.getItem('todos')) || [] 5 } 6}
然后创建一个 update
函数来更新 localStorage
的值。
1//模型 2update() { 3 localStorage.setItem('todos', JSON.stringify(this.todos)) 4}
每次更改 this.todos
后,我们都可以调用它。
1//模型 2addTodo(todo) { 3 this.todos = [...this.todos, todo] 4 this.update() 5 6 this.onTodoListChanged(this.todos) 7}
添加实时编辑功能
这个难题的最后一部分是编辑现有待办事项的能力。编辑总是比添加或删除更棘手。我想简化它,不需要编辑按钮或用 input
或任何东西替换 span
。我们也不想每输入一个字母时都调用 editTodo
,因为它会重新渲染整个待办事项列表UI。
我决定在控制器上创建一个方法,用新的编辑值更新临时状态变量,另一个方法调用模型中的 editTodo
方法。
1//控制器 2constructor() { 3 // ... 4 this.temporaryEditValue 5} 6 7// Update temporary state 8handleEditTodo = event => { 9 if (event.target.className === 'editable') { 10 this.temporaryEditValue = event.target.innerText 11 } 12} 13 14// Send the completed value to the model 15handleEditTodoComplete = event => { 16 if (this.temporaryEditValue) { 17 const id = parseInt(event.target.parentElement.id) 18 19 this.model.editTodo(id, this.temporaryEditValue) 20 this.temporaryEditValue = '' 21 } 22}
我承认这个解决方案有点乱,因为 temporaryEditValue
变量在技术上应该在视图中而不是在控制器中,因为它是与视图相关的状态。
现在我们可以将这些添加到视图的事件侦听器中。当你在 contenteditable
元素输入时, input
事件会被触发,离开 contenteditable
元素时, focusout
会触发。
1//视图 2bindEvents(controller) { 3 this.form.addEventListener('submit', controller.handleAddTodo) 4 this.todoList.addEventListener('click', controller.handleDeleteTodo) 5 this.todoList.addEventListener('input', controller.handleEditTodo) 6 this.todoList.addEventListener('focusout', controller.handleEditTodoComplete) 7 this.todoList.addEventListener('change', controller.handleToggle) 8}
现在,当你单击任何待办事项时,将进入“编辑”模式,这将会更新临时状态变量,当选中或单击待办事项时,将会保存在模型中并重置临时状态。
contenteditable
解决方案很快得到实施。在程序中使用 contenteditable
时需要考虑各种问题,我在这里写过许多内容【 https://www.taniarascia.com/content-editable-elements-in-javascript-react/ 】。
总结
现在你拥有了一个用纯 JavaScript 写的 todo 程序,它演示了模型 – 视图 – 控制器体系结构的概念。以下是演示和源代码的链接。
-
查看程序的演示【 https://taniarascia.github.io/mvc 】
-
查看程序的源代码【 https://github.com/taniarascia/mvc 】
我希望本教程能帮你理解 MVC。使用这种松散耦合的模式可以为程序添加大量的样板和抽象,同时它也是一种开发人员熟悉的模式,是一个通常用于许多框架的重要概念。
原文: https://www.taniarascia.com/javascript-mvc-todo-app/