理解前端 MVC 服务:TypeScript
介绍
本文是三篇系列文章中的第二篇,旨在帮助读者理解MVC架构如何用于创建前端应用程序。本文的目标是通过逐步演进,将使用JavaScript作为脚本语言的网页开发,最终构建出一个使用JavaScript/TypeScript作为面向对象语言的应用程序,从而理解前端应用程序的架构方式。
在第二篇文章中,我们将使用 TypeScript 构建第一个版本的应用程序。因此,本文将介绍如何将应用程序从原生 JavaScript 迁移到 TypeScript。然而,理解应用程序各个部分之间的关系以及其结构至关重要。
最后,在最后一篇文章中,我们将修改我们的代码,使其与 Angular 框架集成。
-
第三部分:理解前端的 MVC 服务:Angular
项目架构
没有什么比图片更能帮助我们理解我们将要构建的东西了,下面是一个 GIF 动画,展示了我们正在构建的应用程序。
这个应用程序可以使用单个 TypeScript 文件构建,该文件修改文档的 DOM 并执行所有操作,但这是一种强耦合代码,并非我们在本文中打算应用的代码。
什么是MVC架构?MVC是一种包含3个层次/部分的架构:
-
模型——用于管理应用程序的数据。由于模型需要调用服务,因此它们将是不完善的(缺乏功能)。
-
视图——模型的视觉呈现。
-
控制器——服务和视图之间的连接。
下面展示的是我们问题域中的文件结构:
index.html 文件将作为画布,整个应用程序将基于根元素在其上动态构建。此外,由于所有文件都将在 HTML 文件本身中链接,因此该文件还将充当所有文件的加载器。
最后,我们的文件架构由以下 TypeScript 文件组成:
-
user.model.ts — 用户的属性(模型)。
-
user.controller.ts — 负责将服务和视图连接起来。
-
user.service.ts — 管理用户的所有操作。
-
user.views.ts — 负责刷新和更改显示屏幕。
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="css/style.min.css" />
</head>
<body>
<div id="root"></div>
</body>
<script src="bundle.js"></script>
</html>
您可以看到,只有一个名为“bundle.js”的文件被链接,该文件是在 TypeScript 转译为 JavaScript 并应用最小化任务后生成的。
虽然我们不会专注于构建应用程序的工具,而是会展示负责执行项目所有转换任务的gulpfile文件。
在这种情况下,我们决定使用 Gulp 工具,因为它拥有多年的经验,并取得了卓越的成果。如果您想深入了解 Gulp,我建议您访问其网站,那里有丰富的插件可供选择。无论如何,如果您了解 JavaScript,您将能够阅读代码,并且几乎可以完全理解我们执行的任务。在我们的示例中,我们使用了browserify插件来打包、创建模块系统以及执行 TypeScript 到 JavaScript 的转译。
const gulp = require('gulp');
const browserify = require('browserify');
const source = require('vinyl-source-stream');
const buffer = require('vinyl-buffer');
const sourcemaps = require('gulp-sourcemaps');
const concat = require('gulp-concat');
const minifyCSS = require('gulp-minify-css');
const autoprefixer = require('gulp-autoprefixer');
const useref = require('gulp-useref');
const rename = require('gulp-rename');
const { server, reload } = require('gulp-connect');
gulp.task('watch', function() {
gulp.watch('src/**/*.ts', gulp.series('browserify'));
gulp.watch('src/**/*.html', gulp.series('html'));
gulp.watch('src/**/*.css', gulp.series('css'));
});
gulp.task('html', function() {
return gulp
.src('src/*.html')
.pipe(useref())
.pipe(gulp.dest('dist'))
.pipe(reload());
});
gulp.task('css', function() {
return gulp
.src('src/**/*.css')
.pipe(minifyCSS())
.pipe(autoprefixer('last 2 version', 'safari 5', 'ie 8', 'ie 9'))
.pipe(concat('style.min.css'))
.pipe(gulp.dest('dist/css'))
.pipe(reload());
});
gulp.task('images', function() {
gulp.src('src/**/*.jpg').pipe(gulp.dest('dist'));
return gulp.src('src/**/*.png').pipe(gulp.dest('dist'));
});
gulp.task('serve', () => {
server({
name: 'Dev Game',
root: './dist',
port: 5000,
livereload: true,
});
});
gulp.task('browserify', function() {
return browserify({
entries: './src/app.ts',
})
.plugin('tsify')
.bundle()
.on('error', function(err) {
console.log(err.message);
})
.pipe(source('bundle.js'))
.pipe(buffer())
.pipe(sourcemaps.init({ loadMaps: true }))
.pipe(sourcemaps.write('./'))
.pipe(gulp.dest('dist'))
.pipe(reload());
});
gulp.task(
'default',
gulp.series(['browserify', 'html', 'css', 'images', gulp.parallel('serve', 'watch')]),
);
模型(贫血)
本示例中构建的第一个类是应用程序模型 user.model.ts,它由类属性和一个生成随机 ID 的私有方法组成(这些 ID 可以来自服务器中的数据库)。
模型将包含以下字段:
-
id。唯一值。
-
名称。用户的名称。
-
年龄。用户的年龄。
-
完成。布尔值,用于告知是否可以将用户从列表中划掉。
User类已使用 TypeScript 进行类型定义。但是,User 构造函数接收一个普通对象,该对象将从 LocalStorage 或通过表单获取的用户数据中获取。此普通对象必须符合UserDto接口,也就是说,只有满足该接口定义的普通对象才能被实例化。
user.model.ts 文件如下所示:
/**
* @class Model
*
* Manages the data of the application.
*/
export interface UserDto {
name: string;
age: string;
complete: boolean;
}
export class User {
public id: string;
public name: string;
public age: string;
public complete: boolean;
constructor(
{ name, age, complete }: UserDto = {
name: null,
age: null,
complete: false
}
) {
this.id = this.uuidv4();
this.name = name;
this.age = age;
this.complete = complete;
}
uuidv4(): string {
return (([1e7] as any) + -1e3 + -4e3 + -8e3 + -1e11).replace(
/[018]/g,
(c: number) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16)
);
}
}
服务
对用户执行的操作都在服务中完成。服务使得模型可以做到“贫血”,因为所有逻辑负载都包含在模型中。在这个例子中,我们将使用一个数组来存储所有用户,并构建与读取、修改、创建和删除(CRUD)用户相关的四个方法。需要注意的是,服务会使用模型,将从 LocalStorage 中提取的对象实例化为 User 类。这是因为 LocalStorage 只存储数据,而不存储数据的原型。从后端传输到前端的数据也是如此,它们没有实例化自己的类。
我们类的构造函数如下:
constructor() {
const users: UserDto[] = JSON.parse(localStorage.getItem('users')) || [];
this.users = users.map(user => new User(user));
}
请注意,我们定义了一个名为 users 的类变量,用于存储所有用户,这些用户已从普通对象 ( UserDto ) 转换为 User 类的原型对象。
接下来,我们需要在服务中定义我们想要开发的每个操作。以下使用 TypeScript 展示了这些操作:
add(user: User) {
this.users.push(new User(user));
this._commit(this.users);
}
edit(id: string, userToEdit: User) {
this.users = this.users.map(user =>
user.id === id
? new User({
...user,
...userToEdit
})
: user
);
this._commit(this.users);
}
delete(_id: string) {
this.users = this.users.filter(({ id }) => id !== _id);
this._commit(this.users);
}
toggle(_id: string) {
this.users = this.users.map(user =>
user.id === _id ? new User({ ...user, complete: !user.complete }) : user
);
this._commit(this.users);
}
还需要定义负责将执行的操作存储到我们的数据存储(在本例中为 LocalStorage)中的提交方法。
bindUserListChanged(callback: Function) {
this.onUserListChanged = callback;
}
_commit(users: User[]) {
this.onUserListChanged(users);
localStorage.setItem('users', JSON.stringify(users));
}
此方法会调用在创建服务时绑定的回调函数,这可以从 bindUserListChanged 方法的定义中看出。我可以提前告诉你,这个回调函数来自视图,负责刷新屏幕上的用户列表。
用户服务文件 user.service.ts 的内容如下:
import { User, UserDto } from '../models/user.model';
/**
* @class Service
*
* Manages the data of the application.
*/
export class UserService {
public users: User[];
private onUserListChanged: Function;
constructor() {
const users: UserDto[] = JSON.parse(localStorage.getItem('users')) || [];
this.users = users.map(user => new User(user));
}
bindUserListChanged(callback: Function) {
this.onUserListChanged = callback;
}
_commit(users: User[]) {
this.onUserListChanged(users);
localStorage.setItem('users', JSON.stringify(users));
}
add(user: User) {
this.users.push(new User(user));
this._commit(this.users);
}
edit(id: string, userToEdit: User) {
this.users = this.users.map(user =>
user.id === id
? new User({
...user,
...userToEdit
})
: user
);
this._commit(this.users);
}
delete(_id: string) {
this.users = this.users.filter(({ id }) => id !== _id);
this._commit(this.users);
}
toggle(_id: string) {
this.users = this.users.map(user =>
user.id === _id ? new User({ ...user, complete: !user.complete }) : user
);
this._commit(this.users);
}
}
浏览量
视图是模型的视觉呈现。我们没有像许多框架那样创建 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();
}
接下来,视图最重要的部分是视图与服务方法(通过控制器发送)的结合。例如,`bindAddUser` 方法接收一个驱动函数作为参数,该函数将执行服务中定义的 `addUser` 操作。在 `bindXXX` 方法中,定义了每个视图控件的事件监听器。请注意,通过视图,我们可以访问用户从屏幕提供的所有数据;这些数据通过处理函数连接起来。
bindAddUser(handler: Function) {
this.form.addEventListener('submit', event => {
event.preventDefault();
if (this._nameText) {
handler({
name: this._nameText,
age: this._ageText
});
this._resetInput();
}
});
}
bindDeleteUser(handler: Function) {
this.userList.addEventListener('click', event => {
if ((event.target as any).className === 'delete') {
const id = (event.target as any).parentElement.id;
handler(id);
}
});
}
bindEditUser(handler: Function) {
this.userList.addEventListener('focusout', event => {
if (this._temporaryAgeText) {
const id = (event.target as any).parentElement.id;
const key = 'age';
handler(id, { [key]: this._temporaryAgeText });
this._temporaryAgeText = '';
}
});
}
bindToggleUser(handler: Function) {
this.userList.addEventListener('change', event => {
if ((event.target as any).type === 'checkbox') {
const id = (event.target as any).parentElement.id;
handler(id);
}
});
}
视图的其余代码负责处理文档的 DOM。user.view.ts 文件内容如下:
import { User } from '../models/user.model';
/**
* @class View
*
* Visual representation of the model.
*/
interface Input {
key: string;
type: string;
placeholder: string;
name: string;
}
export class UserView {
private app: HTMLElement;
private form: HTMLElement;
private submitButton: HTMLElement;
private inputName: HTMLInputElement;
private inputAge: HTMLInputElement;
private title: HTMLElement;
private userList: HTMLElement;
private _temporaryAgeText: string;
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 }: Input = {
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: string, className?: string) {
const element = document.createElement(tag);
if (className) element.classList.add(className);
return element;
}
getElement(selector: string): HTMLElement {
return document.querySelector(selector);
}
displayUsers(users: User[]) {
// 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') as HTMLInputElement;
checkbox.type = 'checkbox';
checkbox.checked = user.complete;
const spanUser = this.createElement('span');
const spanAge = this.createElement('span') as HTMLInputElement;
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 as any).className === 'editable') {
this._temporaryAgeText = (event.target as any).innerText;
}
});
}
bindAddUser(handler: Function) {
this.form.addEventListener('submit', event => {
event.preventDefault();
if (this._nameText) {
handler({
name: this._nameText,
age: this._ageText
});
this._resetInput();
}
});
}
bindDeleteUser(handler: Function) {
this.userList.addEventListener('click', event => {
if ((event.target as any).className === 'delete') {
const id = (event.target as any).parentElement.id;
handler(id);
}
});
}
bindEditUser(handler: Function) {
this.userList.addEventListener('focusout', event => {
if (this._temporaryAgeText) {
const id = (event.target as any).parentElement.id;
const key = 'age';
handler(id, { [key]: this._temporaryAgeText });
this._temporaryAgeText = '';
}
});
}
bindToggleUser(handler: Function) {
this.userList.addEventListener('change', event => {
if ((event.target as any).type === 'checkbox') {
const id = (event.target as any).parentElement.id;
handler(id);
}
});
}
}
控制器
该架构的最后一个文件是控制器。控制器通过依赖注入(DI)接收其两个依赖项(服务和视图)。这些依赖项存储在控制器的私有变量中。此外,由于控制器是唯一可以访问视图和服务的元素,因此构造函数会显式地建立视图和服务之间的连接。
文件 user.controller.ts 如下所示:
import { User } from '../models/user.model';
import { UserService } from '../services/user.service';
import { UserView } from '../views/user.view';
/**
* @class Controller
*
* Links the user input and the view output.
*
* @param model
* @param view
*/
export class UserController {
constructor(private userService: UserService, private 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: User[]) => {
this.userView.displayUsers(users);
};
handleAddUser = (user: User) => {
this.userService.add(user);
};
handleEditUser = (id: string, user: User) => {
this.userService.edit(id, user);
};
handleDeleteUser = (id: string) => {
this.userService.delete(id);
};
handleToggleUser = (id: string) => {
this.userService.toggle(id);
};
}
App.ts
应用程序的最后一部分是应用程序启动器。在本例中,我们将其命名为 app.ts。应用程序通过创建不同的元素来运行:UserService、UserView 和 UserController,如 app.ts 文件中所示。
import { UserController } from './controllers/user.controller';
import { UserService } from './services/user.service';
import { UserView } from './views/user.view';
const app = new UserController(new UserService(), new UserView());
结论
在第二篇文章中,我们开发了一个 Web 应用程序,该项目按照 MVC 架构构建,其中使用了贫血模型,逻辑责任由服务承担。
非常重要的是要强调,这篇文章的教学意义在于理解项目在不同文件中的结构,以及不同文件的职责,并说明视图是如何完全独立于模型/服务和控制器的。
值得注意的是,在本文中,我们将应用程序从 JavaScript 迁移到了 TypeScript,从而获得了类型化的代码,这有助于开发人员最大限度地减少错误并了解代码的每个部分的作用。
在本系列的下一篇文章中,我们将把 TypeScript 代码迁移到 Angular。迁移到框架后,我们就无需再处理操作 DOM 的复杂性和重复性工作。
本文的GitHub分支地址为: https://github.com/Caballerog/TypeScript-MVC-Users
原文发表于http://carloscaballero.io。
文章来源:https://dev.to/carlillo/understanding-mvc-services-for-frontend-typescript-24e5

