理解前端的 MVC 服务:VanillaJS

2025-05-24

理解前端的 MVC 服务:VanillaJS

介绍

这篇文章是三篇系列文章中的第一篇,旨在帮助您理解如何使用 MVC 架构创建前端应用程序。本系列文章的目标是了解如何构建一个前端应用程序,将一个使用 JavaScript 作为脚本语言的网页发展成一个使用 JavaScript 作为面向对象语言的应用程序。

在本篇博文中,我们将使用 VanillaJS 构建应用程序。因此,本文将开发大量与 DOM 相关的代码。然而,了解应用程序各部分之间的关​​系及其结构至关重要。

在第二篇文章中,我们将通过将 JavaScript 代码转换为 TypeScript 版本来强化它。

最后,在最后一篇文章中,我们将转换我们的代码以将其与 Angular 框架集成。

项目架构

没有什么比图像更有价值的,可以帮助我们了解我们要构建的内容,下面有一个 GIF,其中说明了我们要构建的应用程序。

可以使用单个 JavaScript 文件构建此应用程序,该文件修改文档的 DOM 并执行所有操作,但这是一个强耦合的代码,并不是我们打算在本文中应用的。

什么是 MVC 架构?MVC 是一个包含 3 个层级/部分的架构:

  • 模型——管理应用程序的数据。由于模型将被引用到服务,因此它们将是贫血的(它们缺乏功能)。

  • 视图——模型的视觉表示。

  • 控制器——服务和视图之间的链接。

下面,我们展示了我们的问题域中将拥有的文件结构:

index.html 文件将充当画布,整个应用程序将使用根元素在其上动态构建。此外,由于所有文件将在 html 文件本身中链接,因此该文件将充当所有文件的加载器。

最后,我们的文件架构由以下 JavaScript 文件组成:

  • user.model.js — 用户的属性(模型)。

  • user.controller.js — 负责连接服务和视图。

  • user.service.js — 管理对用户的所有操作。

  • user.views.js — 负责刷新和改变显示屏。

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>User App</title>

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

  <body>
    <div id="root"></div>
    <script src="models/user.model.js"></script>
    <script src="services/user.service.js"></script>
    <script src="controllers/user.controller.js"></script>
    <script src="views/user.view.js"></script>
    <script src="app.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

模型(贫血)

本例中构建的第一个类是应用程序模型 user.model.js,它由类属性和生成随机 ID 的私有方法组成(这些 ID 可能来自服务器中的数据库)。

该模型将具有以下字段:

  • id . 唯一值。

  • 名称。用户的名称。

  • 年龄。用户的年龄。

  • 完成。布尔值,让您知道我们是否可以将用户从列表中删除。

user.model.js如下所示:

/**
 * @class Model
 *
 * Manages the data of the application.
 */

class User {
  constructor({ name, age, complete } = { complete: false }) {
    this.id = this.uuidv4();
    this.name = name;
    this.age = age;
    this.complete = complete;
  }

  uuidv4() {
    return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
      (
        c ^
        (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
      ).toString(16)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

对用户执行的操作在服务中执行。服务使得模型能够实现贫血,因为所有逻辑负载都包含在模型中。在本例中,我们将使用一个数组来存储所有用户,并构建与读取、修改、创建和删除(CRUD)用户相关的四个方法。需要注意的是,服务会使用模型,将从 LocalStorage 中提取的对象实例化为 User 类。这是因为 LocalStorage 仅存储数据,而不存储已存储数据的原型。从后端传输到前端的数据也是如此,它们的类没有被实例化。

我们的类的构造函数如下:

  constructor() {
    const users = JSON.parse(localStorage.getItem('users')) || [];
    this.users = users.map(user => new User(user));
  }
Enter fullscreen mode Exit fullscreen mode

请注意,我们已经定义了一个名为 users 的类变量,该变量存储所有用户从平面对象转换为 User 类的原型对象后的内容。

接下来我们必须在服务中定义我们要开发的每个操作。这些操作使用 ECMAScript 编写,无需使用任何 TypeScript 代码:

add(user) {
    this.users.push(new User(user));

    this._commit(this.users);
  }

  edit(id, userToEdit) {
    this.users = this.users.map(user =>
      user.id === id
        ? new User({
            ...user,
            ...userToEdit
          })
        : user
    );

    this._commit(this.users);
  }

  delete(_id) {
    this.users = this.users.filter(({ id }) => id !== _id);

    this._commit(this.users);
  }

  toggle(_id) {
    this.users = this.users.map(user =>
      user.id === _id ? new User({ ...user, complete: !user.complete }) : user
    );

    this._commit(this.users);
  }
Enter fullscreen mode Exit fullscreen mode

仍需定义提交方法,该方法负责存储在我们的数据存储(在我们的例子中是 LocalStorage)中执行的操作。

  bindUserListChanged(callback) {
    this.onUserListChanged = callback;
  }

  _commit(users) {
    this.onUserListChanged(users);
    localStorage.setItem('users', JSON.stringify(users));
  }
Enter fullscreen mode Exit fullscreen mode

该方法调用了在创建服务时绑定的回调函数,正如方法定义中所示bindUserListChanged。我已经告诉过你,这个回调函数来自视图,负责刷新屏幕上的用户列表。

文件user.service.js如下:

/**
 * @class Service
 *
 * Manages the data of the application.
 */
class UserService {
  constructor() {
    const users = JSON.parse(localStorage.getItem('users')) || [];
    this.users = users.map(user => new User(user));
  }

  bindUserListChanged(callback) {
    this.onUserListChanged = callback;
  }

  _commit(users) {
    this.onUserListChanged(users);
    localStorage.setItem('users', JSON.stringify(users));
  }

  add(user) {
    this.users.push(new User(user));

    this._commit(this.users);
  }

  edit(id, userToEdit) {
    this.users = this.users.map(user =>
      user.id === id
        ? new User({
            ...user,
            ...userToEdit
          })
        : user
    );

    this._commit(this.users);
  }

  delete(_id) {
    this.users = this.users.filter(({ id }) => id !== _id);

    this._commit(this.users);
  }

  toggle(_id) {
    this.users = this.users.map(user =>
      user.id === _id ? new User({ ...user, complete: !user.complete }) : user
    );

    this._commit(this.users);
  }
}
Enter fullscreen mode Exit fullscreen mode

视图是模型的可视化表示。我们决定动态创建整个视图,而不是像许多框架那样创建 HTML 内容并注入。首先要做的是通过视图构造函数中所示的 DOM 方法缓存视图的所有变量:

constructor() {
    this.app = this.getElement('#root');

    this.form = this.createElement('form');
    this.createInput({
      key: 'inputName',
      type: 'text',
      placeholder: 'Name',
      name: 'name'
    });
    this.createInput({
      key: 'inputAge',
      type: 'text',
      placeholder: 'Age',
      name: 'age'
    });

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

    this.form.append(this.inputName, this.inputAge, this.submitButton);

    this.title = this.createElement('h1');
    this.title.textContent = 'Users';
    this.userList = this.createElement('ul', 'user-list');
    this.app.append(this.title, this.form, this.userList);

    this._temporaryAgeText = '';
    this._initLocalListeners();
  }
Enter fullscreen mode Exit fullscreen mode

视图的下一个最相关的点是视图与服务方法(将通过控制器发送)的结合。例如,bindAddUser 方法接收一个驱动函数作为参数,该函数将执行服务中描述的 addUser 操作。在 bindXXX 方法中,定义了每个视图控件的 EventListener。请注意,我们可以从视图访问用户在屏幕上提供的所有数据;这些数据通过处理函数连接起来。

 bindAddUser(handler) {
    this.form.addEventListener('submit', event => {
      event.preventDefault();

      if (this._nameText) {
        handler({
          name: this._nameText,
          age: this._ageText
        });
        this._resetInput();
      }
    });
  }

  bindDeleteUser(handler) {
    this.userList.addEventListener('click', event => {
      if (event.target.className === 'delete') {
        const id = event.target.parentElement.id;

        handler(id);
      }
    });
  }

  bindEditUser(handler) {
    this.userList.addEventListener('focusout', event => {
      if (this._temporaryAgeText) {
        const id = event.target.parentElement.id;
        const key = 'age';

        handler(id, { [key]: this._temporaryAgeText });
        this._temporaryAgeText = '';
      }
    });
  }

  bindToggleUser(handler) {
    this.userList.addEventListener('change', event => {
      if (event.target.type === 'checkbox') {
        const id = event.target.parentElement.id;

        handler(id);
      }
    });
  }
Enter fullscreen mode Exit fullscreen mode

视图的其余代码用于处理文档的 DOM。文件 user.view.js 如下:

/**
 * @class View
 *
 * Visual representation of the model.
 */
class UserView {
  constructor() {
    this.app = this.getElement('#root');

    this.form = this.createElement('form');
    this.createInput({
      key: 'inputName',
      type: 'text',
      placeholder: 'Name',
      name: 'name'
    });
    this.createInput({
      key: 'inputAge',
      type: 'text',
      placeholder: 'Age',
      name: 'age'
    });

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

    this.form.append(this.inputName, this.inputAge, this.submitButton);

    this.title = this.createElement('h1');
    this.title.textContent = 'Users';
    this.userList = this.createElement('ul', 'user-list');
    this.app.append(this.title, this.form, this.userList);

    this._temporaryAgeText = '';
    this._initLocalListeners();
  }

  get _nameText() {
    return this.inputName.value;
  }
  get _ageText() {
    return this.inputAge.value;
  }

  _resetInput() {
    this.inputName.value = '';
    this.inputAge.value = '';
  }

  createInput(
    { key, type, placeholder, name } = {
      key: 'default',
      type: 'text',
      placeholder: 'default',
      name: 'default'
    }
  ) {
    this[key] = this.createElement('input');
    this[key].type = type;
    this[key].placeholder = placeholder;
    this[key].name = name;
  }

  createElement(tag, className) {
    const element = document.createElement(tag);

    if (className) element.classList.add(className);

    return element;
  }

  getElement(selector) {
    return document.querySelector(selector);
  }

  displayUsers(users) {
    // Delete all nodes
    while (this.userList.firstChild) {
      this.userList.removeChild(this.userList.firstChild);
    }

    // Show default message
    if (users.length === 0) {
      const p = this.createElement('p');
      p.textContent = 'Nothing to do! Add a user?';
      this.userList.append(p);
    } else {
      // Create nodes
      users.forEach(user => {
        const li = this.createElement('li');
        li.id = user.id;

        const checkbox = this.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.checked = user.complete;

        const spanUser = this.createElement('span');

        const spanAge = this.createElement('span');
        spanAge.contentEditable = true;
        spanAge.classList.add('editable');

        if (user.complete) {
          const strikeName = this.createElement('s');
          strikeName.textContent = user.name;
          spanUser.append(strikeName);

          const strikeAge = this.createElement('s');
          strikeAge.textContent = user.age;
          spanAge.append(strikeAge);
        } else {
          spanUser.textContent = user.name;
          spanAge.textContent = user.age;
        }

        const deleteButton = this.createElement('button', 'delete');
        deleteButton.textContent = 'Delete';
        li.append(checkbox, spanUser, spanAge, deleteButton);

        // Append nodes
        this.userList.append(li);
      });
    }
  }

  _initLocalListeners() {
    this.userList.addEventListener('input', event => {
      if (event.target.className === 'editable') {
        this._temporaryAgeText = event.target.innerText;
      }
    });
  }

  bindAddUser(handler) {
    this.form.addEventListener('submit', event => {
      event.preventDefault();

      if (this._nameText) {
        handler({
          name: this._nameText,
          age: this._ageText
        });
        this._resetInput();
      }
    });
  }

  bindDeleteUser(handler) {
    this.userList.addEventListener('click', event => {
      if (event.target.className === 'delete') {
        const id = event.target.parentElement.id;

        handler(id);
      }
    });
  }

  bindEditUser(handler) {
    this.userList.addEventListener('focusout', event => {
      if (this._temporaryAgeText) {
        const id = event.target.parentElement.id;
        const key = 'age';

        handler(id, { [key]: this._temporaryAgeText });
        this._temporaryAgeText = '';
      }
    });
  }

  bindToggleUser(handler) {
    this.userList.addEventListener('change', event => {
      if (event.target.type === 'checkbox') {
        const id = event.target.parentElement.id;

        handler(id);
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

该架构的最后一个文件是控制器。控制器通过依赖注入 (DI) 接收其拥有的两个依赖项(服务和视图)。这些依赖项存储在控制器的私有变量中。此外,由于控制器是唯一可以访问这两方的元素,因此构造函数会在视图和服务之间建立显式连接。

文件 user.controller.js 如下所示:

/**
 * @class Controller
 *
 * Links the user input and the view output.
 *
 * @param model
 * @param view
 */
class UserController {
  constructor(userService, userView) {
    this.userService = userService;
    this.userView = userView;

    // Explicit this binding
    this.userService.bindUserListChanged(this.onUserListChanged);
    this.userView.bindAddUser(this.handleAddUser);
    this.userView.bindEditUser(this.handleEditUser);
    this.userView.bindDeleteUser(this.handleDeleteUser);
    this.userView.bindToggleUser(this.handleToggleUser);

    // Display initial users
    this.onUserListChanged(this.userService.users);
  }

  onUserListChanged = users => {
    this.userView.displayUsers(users);
  };

  handleAddUser = user => {
    this.userService.add(user);
  };

  handleEditUser = (id, user) => {
    this.userService.edit(id, user);
  };

  handleDeleteUser = id => {
    this.userService.delete(id);
  };

  handleToggleUser = id => {
    this.userService.toggle(id);
  };
}
Enter fullscreen mode Exit fullscreen mode

我们应用程序的最后一部分是应用程序启动器。在本例中,我们将其命名为app.js。应用程序的执行是通过创建不同的元素来实现的:UserServiceUserViewUserController,如文件 所示app.js

const app = new UserController(new UserService(), new UserView());
Enter fullscreen mode Exit fullscreen mode

在第一篇文章中,我们开发了一个 Web 应用程序,其中项目按照 MVC 架构构建,其中使用贫血模型,并且逻辑的责任在于服务。

需要强调的是,这篇文章的教学重点是理解项目在不同文件中不同职责的结构,以及视图如何完全独立于模型/服务和控制器。

在接下来的文章中,我们将使用 TypeScript 来增强 JavaScript,这将为我们提供一种更强大的 Web 应用程序开发语言。使用 JavaScript 导致我们不得不编写大量冗长重复的代码来管理 DOM(使用 Angular 框架可以最大限度地减少这种情况)。

这篇文章的GitHub 分支https://github.com/Caballerog/VanillaJS-MVC-Users

文章来源:https://dev.to/carlillo/understanding-mvc-services-for-frontend-vanillajs-335h
PREV
我的计划是 2019 年成为一名更好的开发人员。
NEXT
npkill - 轻松删除 node_modules 的解决方案,风格简介 npkill 结论