用纯 JavaScript 撸一个 MVC 框架

前端先锋 2021-06-23 17:39:26
javascript 框架 MVC 一个


// 每日前端夜话 第561 篇
// 正文共:4200 字
// 预计阅读时间:12 分钟

图片

我想用 model-view-controller[1] 架构模式在纯 JavaScript 中写一个简单的程序,于是我这样做了。希望它可以帮你理解 MVC,因为当你刚开始接触它时,它是一个难以理解的概念。

我做了这个todo应用程序[2],这是一个简单小巧的浏览器应用,允许你对待办事项进行CRUD(创建,读取,更新和删除)操作。它只包含 index.htmlstyle.cssscript.js 三个文件,非常简单,无需任何依赖和框架。

先决条件

  • 基本的 JavaScript 和 HTML 知识
  • 熟悉最新的 JavaScript 语法 [3]

目标

用纯 JavaScript 在浏览器中创建一个 todo 应用程序,并熟悉MVC(和 OOP——面向对象编程)的概念。

  • 查看程序的演示 [4]
  • 查看程序的源代码 [5]

注意:由于此程序使用了 ES2017 功能,因此在某些浏览器(如 Safari)上无法用 Babel 编译为向后兼容的 JavaScript 语法。

什么是 MVC?

MVC 是一种非常受欢迎组织代码的模式。

  • Model(模型) - 管理程序的数据
  • View(视图) - 模型的直观表示
  • Controller(控制器)  - 链接用户和系统

模型是数据。在这个 todo 程序中,这将是实际的待办事项,以及将添加、编辑或删除它们的方法。

视图是数据的显示方式。在这个程序中,是 DOM 和 CSS 中呈现的 HTML。

控制器用来连接模型和视图。它需要用户输入,例如单击或键入,并处理用户交互的回调。

模型永远不会触及视图。视图永远不会触及模型。控制器用来连接它们。

我想提一下,为一个简单的 todo 程序做 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 只是为了让它看起来可以接受,你可以找到这个文件[6]并保存到 style.css 。我不打算再写CSS了,因为它不是本文的重点。

好的,现在我们有了HTML和CSS,下面该开始编写程序了。

入门

我会使这个教程简单易懂,使你轻松了解哪个类属于 MVC 的哪个部分。我将创建一个 Model 类,View 类和 Controller 类。该程序将是控制器的实例。

如果你不熟悉类的工作方式,请阅读了解JavaScript中的类[7]

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())

模型

让我们先关注模型,因为它是三个部分中最简单的一个。它不涉及任何事件或 DOM 操作。它只是存储和修改数据。

//模型
class Model {
  constructor() {
    // The state of the model, an array of todo objects, prepopulated with some data
    this.todos = [
      { id1text'Run a marathon'completefalse },
      { id2text'Plant a garden'completefalse },
    ]
  }

  // Append a todo to the todos array
  addTodo(todo) {
    this.todos = [...this.todos, 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
    )
  }
}

我们定义了 addTodoeditTododeleteTodotoggleTodo。这些都应该是一目了然的:add 添加到数组,edit 找到 todo 的 id 进行编辑和替换,delete 过滤数组中的todo,并切换切换 complete 布尔属性。

由于我们在浏览器中执行此操作,并且可以从窗口(全局)访问,因此你可以轻松地测试这些内容,输入以下内容:

app.model.addTodo({ id3text'Take a nap'completefalse })

将向列表中添加一个待办事项,你可以查看 app.model.todos 的内容。

这对于现在的模型来说已经足够了。最后我们会将待办事项存储在 local storage[8] 中,以使其成为半永久性的,但现在只要刷新页面,todo 就会刷新。

我们可以看到,该模型仅处理并修改实际数据。它不理解或不知道输入  —— 正在修改它,或输出  —— 最终会显示什么。

这时如果你通过控制台手动输入所有操作,并在控制台中查看输出,就可以获得功能完善的 CRUD 程序所需的一切。

视图

我们将通过操纵 DOM  —— 文档对象模型来创建视图。由于没有 React 的 JSX 或模板语言的帮助,在普通的 JavaScript 中执行此操作,因此它将是冗长和丑陋的,但这是直接操纵 DOM 的本质。

控制器和模型都不应该知道关于 DOM、HTML元素、CSS 或其中任何内容的信息。任何与之相关的内容都应该放在视图中。

如果你不熟悉 DOM 或 DOM 与 HTML 源代码之间有什么不同,请阅读DOM简介[9]

要做的第一件事就是创建辅助方法来检索并创建元素。

//视图
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

我将在构造函数中创建所有变量,以便可以轻松地引用它们。

//视图
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)
  }
  // ...
}

现在,将设置不会被更改的视图部分。

图片

另外两个小东西:输入(new todo)值的 getter 和 resetter。

// 视图
get todoText() {
  return this.input.value
}

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

现在所有设置都已完成。最复杂的部分是显示待办事项列表,这是每次对待办事项进行修改时将被更改的部分。

//视图
displayTodos(todos) {
  // ...
}

displayTodos 方法将创建待办事项列表所包含的 ulli 并显示它们。每次修改、添加或删除 todo 时,都会使用模型中的 todos 再次调用 displayTodos 方法,重置列表并重新显示它们。这将使视图与模型的状态保持同步。

我们要做的第一件事就是每次调用时删除所有 todo 节点。然后检查是否存在待办事项。如果不这样做,我们将会得到一个空的列表消息。

// 视图
// 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 和删除按钮。

// 视图
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)
  })
}

现在设置视图及模型。我们只是没有办法连接它们,因为现在还没有事件监视用户进行输入,也没有处理这种事件的输出的 handle。

控制台仍然作为临时控制器存在,你可以通过它添加和删除待办事项。

图片

控制器

最后,控制器是模型(数据)和视图(用户看到的内容)之间的链接。这是我们到目前为止控制器中的内容。

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

在视图和模型之间的第一个链接是创建一个每次 todo 更改时调用 displayTodos 的方法。我们也可以在 constructor 中调用它一次,来显示初始的 todos(如果有的话)。

//控制器
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)
  }
}

控制器将在触发后处理事件。当你提交新的待办事项、单击删除按钮或单击待办事项的复选框时,将触发一个事件。视图必须侦听这些事件,因为它们是视图的用户输入,它会将响应事件所要做的工作分配给控制器。

我们将为事件创建 handler。首先,提交一个 handleAddTodo 事件,当我们创建的待办事项输入表单被提交时,可以通过按 Enter 键或单击“提交”按钮来触发。这是一个 submit 事件。

回到视图中,我们将 this.input.value 的 getter 作为 get todoText。要确保输入不能为空,然后我们将创建带有 idtext 并且 complete 值为 false 的 todo。将 todo 添加到模型中,然后重置输入框。

// 控制器
// Handle submit event for adding a todo
handleAddTodo = event => {
  event.preventDefault()

  if (this.view.todoText) {
    const todo = {
      idthis.model.todos.length > 0 ? this.model.todos[this.model.todos.length - 1].id + 1 : 1,
      textthis.view.todoText,
      completefalse,
    }

    this.model.addTodo(todo)
    this.view.resetInput()
  }
}

删除 todo 的操作类似。它将响应删除按钮上的 click 事件。删除按钮的父元素是 todo li 本身,它附有相应的 id。我们需要将该数据发送给正确的模型方法。

// 控制器
// Handle click event for deleting a todo
handleDeleteTodo = event => {
  if (event.target.className === 'delete') {
    const id = parseInt(event.target.parentElement.id)

    this.model.deleteTodo(id)
  }
}

在 JavaScript 中,当你单击复选框来切换它时,会发出 change 事件。按照处理单击删除按钮的方式处理此方法,并调用模型方法。

// 控制器
// Handle change event for toggling a todo
handleToggle = event => {
  if (event.target.type === 'checkbox') {
    const id = parseInt(event.target.parentElement.id)

    this.model.toggleTodo(id)
  }
}

这些控制器方法有点乱 - 理想情况下它们不应该处理任何逻辑,而是应该简单地调用模型。

设置事件监听器

现在我们有了这三个 handler ,但控制器仍然不知道应该什么时候调用它们。必须把事件侦听器放在视图中的 DOM 元素上。我们将回复表单上的submit 事件,以及 todo 列表上的 clickchange事件。

View 中添加一个 bindEvents 方法,该方法将调用这些事件。

// 视图
bindEvents(controller) {
  this.form.addEventListener('submit', controller.handleAddTodo)
  this.todoList.addEventListener('click', controller.handleDeleteTodo)
  this.todoList.addEventListener('change', controller.handleToggle)
}

接着把侦听事件的方法绑定到视图。在 Controllerconstructor 中,调用 bindEvents 并传递控制器的this 上下文。

在所有句柄事件上都用了箭头函数。这允许我们可以用控制器的 this 上下文从视图中调用它们。如果不用箭头函数,我们将不得不手动去绑定它们,如 controller.handleAddTodo.bind(this)

// 控制器
this.view.bindEvents(this)

现在,当指定的元素发生submitclickchange 事件时,将会调用相应的 handler。

响应模型中的回调

我们还遗漏了一些东西:事件正在侦听,handler 被调用,但是没有任何反应。这是因为模型不知道视图应该更新,并且不知道如何更新视图。我们在视图上有 displayTodos 方法来解决这个问题,但如前所述,模型和视图不应该彼此了解。

就像侦听事件一样,模型应该回到控制器,让它知道发生了什么。

我们已经在控制器上创建了 onTodoListChanged 方法来处理这个问题,接下来只需让模型知道它。我们将它绑定到模型,就像对视图上的 handler 所做的一样。

在模型中,为 onTodoListChanged 添加 bindEvents

// 模型
bindEvents(controller) {
  this.onTodoListChanged = controller.onTodoListChanged
}

在控制器中,发送 this 上下文。

// 控制器
constructor() {
  // ...
  this.model.bindEvents(this)
  this.view.bindEvents(this)
}

现在,在模型中的每个方法之后,你将调用 onTodoListChanged 回调。

在更复杂的程序中,可能对不同的事件有不同的回调,但在这个简单的待办事项程序中,我们可以在所有方法之间共享一个回调。

//模型
addTodo(todo) {
  this.todos = [...this.todos, todo]

  this.onTodoListChanged(this.todos)
}

添加 local storage

这时程序的大部分都已完成,所有概念都已经演示过了。我们可以通过将数据保存在浏览器的 local storage 中来对其进行持久化。

如果你不了解 local storage 的工作原理,请阅读如何使用JavaScript local storage[10]

现在我们可以将待办事项的初始值设置为本地存储或空数组。

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

然后创建一个 update 函数来更新 localStorage 的值。

//模型
update() {
  localStorage.setItem('todos'JSON.stringify(this.todos))
}

每次更改 this.todos 后,我们都可以调用它。

//模型
addTodo(todo) {
  this.todos = [...this.todos, todo]
  this.update()

  this.onTodoListChanged(this.todos)
}

添加实时编辑功能

这个难题的最后一部分是编辑现有待办事项的能力。编辑总是比添加或删除更棘手。我想简化它,不需要编辑按钮或用 input 或任何东西替换 span。我们也不想每输入一个字母都调用 editTodo,因为它会重新渲染整个待办事项列表UI。

我决定在控制器上创建一个方法,用新的编辑值更新临时状态变量,另一个方法调用模型中的 editTodo 方法。

//控制器
constructor() {
  // ...
  this.temporaryEditValue
}

// Update temporary state
handleEditTodo = event => {
  if (event.target.className === 'editable') {
    this.temporaryEditValue = event.target.innerText
  }
}

// Send the completed value to the model
handleEditTodoComplete = event => {
  if (this.temporaryEditValue) {
    const id = parseInt(event.target.parentElement.id)

    this.model.editTodo(id, this.temporaryEditValue)
    this.temporaryEditValue = ''
  }
}

我承认这个解决方案有点乱,因为 temporaryEditValue 变量在技术上应该在视图中而不是在控制器中,因为它是与视图相关的状态。

现在我们可以将这些添加到视图的事件侦听器中。当你在 contenteditable 元素输入时,input 事件会被触发,离开contenteditable元素时,focusout 会触发。

//视图
bindEvents(controller) {
  this.form.addEventListener('submit', controller.handleAddTodo)
  this.todoList.addEventListener('click', controller.handleDeleteTodo)
  this.todoList.addEventListener('input', controller.handleEditTodo)
  this.todoList.addEventListener('focusout', controller.handleEditTodoComplete)
  this.todoList.addEventListener('change', controller.handleToggle)
}

现在,当你单击任何待办事项时,将进入“编辑”模式,这将会更新临时状态变量,当选中或单击待办事项时,将会保存在模型中并重置临时状态。

总结

现在你拥有了一个用纯 JavaScript 写的 todo 程序,它演示了模型 - 视图 - 控制器体系结构的概念。以下是演示和源代码的链接。

  • 查看程序的演示 [11]
  • 查看程序的源代码 [12]

我希望本教程能帮你理解 MVC。使用这种松散耦合的模式可以为程序添加大量的样板和抽象,同时它也是一种开发人员熟悉的模式,是一个通常用于许多框架的重要概念。

Reference

[1]

model-view-controller:https://en.wikipedia.org/wiki/Model-view-controller

[2]

这个todo应用程序:https://taniarascia.github.io/mvc

[3]

最新的 JavaScript 语法:https://www.taniarascia.com/es6-syntax-and-feature-overview/

[4]

查看程序的演示:https://taniarascia.github.io/mvc

[5]

查看程序的源代码:https://github.com/taniarascia/mvc

[6]

这个文件:https://github.com/taniarascia/mvc/blob/master/style.css

[7]

了解JavaScript中的类:https://www.taniarascia.com/understanding-classes-in-javascript/

[8]

local storage:https://www.taniarascia.com/how-to-use-local-storage-with-javascript/

[9]

DOM简介:https://www.taniarascia.com/introduction-to-the-dom/

[10]

如何使用JavaScript local storage:https://www.taniarascia.com/how-to-use-local-storage-with-javascript/

[11]

查看程序的演示:https://taniarascia.github.io/mvc

[12]

查看程序的源代码:https://github.com/taniarascia/mvc




强力推荐前端面试刷题神器


图片

图片
精彩文章回顾,点击直达


图片

图片


版权声明
本文为[前端先锋]所创,转载请带上原文链接,感谢
https://toutiao.io/k/rj51l7q

  1. 【计算机网络 12(1),尚学堂马士兵Java视频教程
  2. 【程序猿历程,史上最全的Java面试题集锦在这里
  3. 【程序猿历程(1),Javaweb视频教程百度云
  4. Notes on MySQL 45 lectures (1-7)
  5. [computer network 12 (1), Shang Xuetang Ma soldier java video tutorial
  6. The most complete collection of Java interview questions in history is here
  7. [process of program ape (1), JavaWeb video tutorial, baidu cloud
  8. Notes on MySQL 45 lectures (1-7)
  9. 精进 Spring Boot 03:Spring Boot 的配置文件和配置管理,以及用三种方式读取配置文件
  10. Refined spring boot 03: spring boot configuration files and configuration management, and reading configuration files in three ways
  11. 精进 Spring Boot 03:Spring Boot 的配置文件和配置管理,以及用三种方式读取配置文件
  12. Refined spring boot 03: spring boot configuration files and configuration management, and reading configuration files in three ways
  13. 【递归,Java传智播客笔记
  14. [recursion, Java intelligence podcast notes
  15. [adhere to painting for 386 days] the beginning of spring of 24 solar terms
  16. K8S系列第八篇(Service、EndPoints以及高可用kubeadm部署)
  17. K8s Series Part 8 (service, endpoints and high availability kubeadm deployment)
  18. 【重识 HTML (3),350道Java面试真题分享
  19. 【重识 HTML (2),Java并发编程必会的多线程你竟然还不会
  20. 【重识 HTML (1),二本Java小菜鸟4面字节跳动被秒成渣渣
  21. [re recognize HTML (3) and share 350 real Java interview questions
  22. [re recognize HTML (2). Multithreading is a must for Java Concurrent Programming. How dare you not
  23. [re recognize HTML (1), two Java rookies' 4-sided bytes beat and become slag in seconds
  24. 造轮子系列之RPC 1:如何从零开始开发RPC框架
  25. RPC 1: how to develop RPC framework from scratch
  26. 造轮子系列之RPC 1:如何从零开始开发RPC框架
  27. RPC 1: how to develop RPC framework from scratch
  28. 一次性捋清楚吧,对乱糟糟的,Spring事务扩展机制
  29. 一文彻底弄懂如何选择抽象类还是接口,连续四年百度Java岗必问面试题
  30. Redis常用命令
  31. 一双拖鞋引发的血案,狂神说Java系列笔记
  32. 一、mysql基础安装
  33. 一位程序员的独白:尽管我一生坎坷,Java框架面试基础
  34. Clear it all at once. For the messy, spring transaction extension mechanism
  35. A thorough understanding of how to choose abstract classes or interfaces, baidu Java post must ask interview questions for four consecutive years
  36. Redis common commands
  37. A pair of slippers triggered the murder, crazy God said java series notes
  38. 1、 MySQL basic installation
  39. Monologue of a programmer: despite my ups and downs in my life, Java framework is the foundation of interview
  40. 【大厂面试】三面三问Spring循环依赖,请一定要把这篇看完(建议收藏)
  41. 一线互联网企业中,springboot入门项目
  42. 一篇文带你入门SSM框架Spring开发,帮你快速拿Offer
  43. 【面试资料】Java全集、微服务、大数据、数据结构与算法、机器学习知识最全总结,283页pdf
  44. 【leetcode刷题】24.数组中重复的数字——Java版
  45. 【leetcode刷题】23.对称二叉树——Java版
  46. 【leetcode刷题】22.二叉树的中序遍历——Java版
  47. 【leetcode刷题】21.三数之和——Java版
  48. 【leetcode刷题】20.最长回文子串——Java版
  49. 【leetcode刷题】19.回文链表——Java版
  50. 【leetcode刷题】18.反转链表——Java版
  51. 【leetcode刷题】17.相交链表——Java&python版
  52. 【leetcode刷题】16.环形链表——Java版
  53. 【leetcode刷题】15.汉明距离——Java版
  54. 【leetcode刷题】14.找到所有数组中消失的数字——Java版
  55. 【leetcode刷题】13.比特位计数——Java版
  56. oracle控制用户权限命令
  57. 三年Java开发,继阿里,鲁班二期Java架构师
  58. Oracle必须要启动的服务
  59. 万字长文!深入剖析HashMap,Java基础笔试题大全带答案
  60. 一问Kafka就心慌?我却凭着这份,图灵学院vip课程百度云