理解前端 MVC:原生 JS

通过原生 JS 书写简单 ToDoList 并分析的方式,讲解了前端 MVC 的实现要点。

译自:Understanding MVC Services for the Front End: VanillaJS

作者:Carlos Caballero

引言

本文是“理解 MVC 架构如何创建前端应用”系列三篇文章的第一篇。该系列旨在通过“将 JS 脚本写的网页发展为 JS 应用”,来弄清楚如何组织前端应用程序。

在第一篇文章中,将会使用原生 JS 来构建应用,因此 DOM 相关的代码占据较大篇幅。这对于理解应用的各个部分如何联系、整个应用如何组织,是非常重要的。

在第二篇文章中,我们将会使用 TypeScript 重构来升级 JS 代码。

最后一篇文章中,我们会把之前的代码使用 Angular 框架整合。

项目结构

一图胜千言,下图是我们将要构建的应用。

img

当然,完全可以使用单个 JS 文件修改 DOM 来实现所有的操作。但是这样耦合性太强,也并非我们的目的。

所谓 MVC,包含三个部分:

  • Models 用于管理数据。因为在本项目中和 services 关联,所以会“贫血1(be anemic)”,不包含业务逻辑。
  • Views 是 models 的可视化展现。
  • Controllers 连接 services 和 views。

下图是文件目录结构:

img

index.html 将会作为一个“画布”,整个应用都会使用 “root” 元素,在上面动态生成。此外,所有其余文件(JS、CSS)将会引入其中。

其他文件结构如下:

  • user.model.js 包含“用户(user)”的属性。
  • user.controller.js 负责整合 service 和 view。
  • uer.service.js 负责“用户们(users)”的所有操作。
  • user.view.js 负责渲染界面。

HTML 文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<head>
<meta charset="utf-8" />
……
<title>User App</title>
</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>

Models

Models

首先构建的“类(class)”是应用的“模型(models)”,user.model.js。它由“类属性”以及一个生成随机 ID 的“私有方法”组成。

models 包含以下字段:

  • id 唯一值;
  • name 用户名;
  • age 用户年龄;
  • complete 布尔值,用于表示用户是否被从列表中“划掉”。

user.model.js整体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @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)
);
}
}

Services

user.model.js对于“用户们”的操作被拿到了 user.service.js中,service 包含了所有的逻辑负载,从而保证了 models 可以“贫血(to be anemic)”。

service 中将会使用一个数组存储所有的用户,并且构建与“增、查、改、删(CRUD)”相关的四个方法。

注意,service 通过 models ,将从LocalStorage中提取的对象变成了User类的实例。这是因为LocalStorage只能存储字符串类型的键值对,而不能存储对象结构2

service 中的构造函数如下:

1
2
3
4
constructor() {
const users = JSON.parse(localStorage.getItem('users')) || [];
this.users = users.map(user => new User(user));
}

我们定义了一个名为users的“类变量(a class variable)”。当数据从字符串转为 User 类的实例时3users将存储所有的用户。

下面要在 service 里写出 CRUD 操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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);
}

此外还需定义 commit方法,用于数据库的存储操作。(本例使用 LocalStorage 作为数据库)。

1
2
3
4
5
6
7
8
bindUserListChanged(callback) {
this.onUserListChanged = callback;
}

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

这个方法调用了一个在创建 service 时就已经绑定的回调函数,详见bindUserListChanged方法。这个回调函数来自 view,负责更新展现的用户列表。

user.service.js 完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
**
* @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);
}
}

Views

view 是 model 的可视化展现。本例动态创建整个 view,而不是像许多框架一样,创建 HTML 内容并插入。首先要做的是通过 DOM 方法缓存 view 中所有的变量。构造函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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();
}

对于 view 最为重要的在于,view 与 service 中方法的“并联”。例如,bindAddUser方法接收一个“driver”函数作为参数,该参数将执行 service 中描述的addUser操作。

bindXXX这些方法中,view “控件(controls)”的每个EventListener被定义。注意,从 view 中,我们可以通过 handler 函数,获取所有展示“用户”的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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);
}
});
}

view 中的其余代码用于处理 DOM。

全部代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
/**
* @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);
}
});
}
}

Controllers

controller 通过“依赖注入”(dependency injection DI)的方式,接收 service 和 view 两份依赖。这些依赖存储在 controller 的私有变量中。

此外,因为 controller 是唯一可以同时访问二者的,所以其构造函数在 view 和 services 之间建立了显式连接。

user.controller.js所有代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* @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);
};
}

最后是应用的启动器,app.js。应用通过 UserService、UserView 和 UserController 的创建来执行。

1
const app = new UserController(new UserService(), new UserView());

总结:

此篇文章中,我们开发了一个遵循 MVC 架构的 web 应用。该应用使用“贫血模型(anemic models)”,主要逻辑在 services 中。

有必要强调,这篇文章的学习目标是理解项目不同文件、不同功能的结构,以及 view 如何完全独立于 model/service 和 controller。

在下面的文章中,我们会使用 TypeScript 增强 JS,这将会使得 JS 更强大。

此外,原生 JS 的使用导致了许多重复代码来操作 DOM,使用 Angular 框架将会避免这些冗余。

译注

译注

1 Anemic,贫血模型 , 对象只单纯持有数据,没有函数,函数定义在另外的地方。可以参看 WiKi 或者 Martin Fowler 的文章。(PS:我没看过。)

2这里原文是“This is because LocalStorage only stores data and not prototypes of stored data. ”,想不出贴切翻译,就按照自己的意思来了。此外,后面还有一句“The same happens with the data that travels from the back end to the front end: They do not have their classes instantiated.”,我省略了。

3 这里原文是:“Note that we have defined a class variable called users that stores all users once they have been transformed from a flat object to a prototyped object of the User class.”

补充

可以对比 ToDoMVC  这一项目中的 Vanilla ES6,观察其中共同点和差异,来加深理解。

作者

月海

发布于

2020-11-02

更新于

2022-12-06

许可协议

评论