本文依据React的评论框教程写成。案例本身以及不少中文解说文字出自React 中文社区对React Tutorial的翻译,略有改动。其余部分为本人原创。上述相关方以及各位读者如果觉得本文有侵权或其它不妥之处,敬请告知taijiweeb@gmail.com,本人将做适当修改,谢谢。

构造一个评论部件

我们将构建一个简单却真实的评论框,你可以将它放入你的博客,类似于 disqus 、 livefyre 、 facebook 中实时评论框,包含一些基础功能。我们将提供以下功能:

  • 展示所有评论的界面
  • 发布评论的表单
  • 后端 API 接口地址,该接口需要自己实现

同时也包含一些简洁的特性:

  • 发布评论的体验优化: 评论在保存到服务器之前就展现在评论列表,因此感觉起来很快。
  • 实时更新: 其他用户的评论将会实时展示在评论框中。
  • Markdown格式: 用户可以使用Markdown语法来编辑评论文本。

运行一个服务器

为了开始本教程,我们将需要一个运行的服务器,提供一个 API 接口,仅用于获取和保存评论数据。为了尽量简便,我们已经准备好了几种脚本语言开发的简单服务器程序,完成了我们需要的功能。你可以查看源码或者下载 zip 文件,里面包含了开始学习教程所需的所有东西。

为了简单,服务器将会使用一个 JSON 文件作为数据库。不能在生产环境中采用这种做法,但是这样做确实简化了模拟后端 API 的工作。一旦启动了这个服务器,它就能提供我们需要的 API 接口,同时也能生成并发送我们需要的页面。


设置html页面

本教程会尽量简单。上述讨论提及的页面是一个 HTML 文件,它包含在服务器源码包中,我们先看看这个文件。使用你最喜欢的编辑器打开 public/index.html ,然后会看见这些内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Domcom Tutorial</title>
<script src="http://cdn.jsdelivr.net/domcom/0.5.2/domcom.min.js"></script>
<script src="https://npmcdn.com/jquery@3.1.0/dist/jquery.min.js"></script>
<!-- domcom 可以很方便地使用 ES5 原生代码来编写,不需要包含babel -->
<!-- script src="https://npmcdn.com/babel-core@5.8.38/browser.min.js"></script -->
<script src="https://npmcdn.com/remarkable@1.6.2/dist/remarkable.min.js"></script>
</head>
<body>
<div id="content"></div>
<script src="scripts/example.js"></script>
<script>
// 在本教程的剩下部分,我们会在这个 script 标签中编写我们的 JavaScript 代码。
// 我们没有任何高端的页面自动加载刷新工具,所以在修改了代码之后,需要手动刷新浏览器来查看变化。
// 下面,启动服务器,在浏览器中打开 http://localhost:3000 。
// 在没对源码进行修改的前提下,第一次打开这个 URL 会看到我们计划构建的应用的一个最终样子。
// 当你完成本教程,需要使用外部javascript文件的时候,只需要删掉这个 `<script>` 标签就可以了。
</script>
</body>
</html>

因为Domcom所有的api都在dc名字空间上,因此使用dc本身不要求使用babel之类的工具。本教程的Domcom代码仅使用了两项ES6特征:解构赋值和const关键词。因此只要把解构赋值语句用ES5语法重写,将const关键词用var替换,代码即已经符合ES5标准。当然,使用ES6会让代码更简洁一些,使用babel预编译还是鼓励的。

为行文方便,文中涉及的dc名字空间的API名称并没有一一声明和导入。这些名字包括div, span, h1, h2, input, text, html, $model, flow(flow.bind, flow.bindings)等。实际编码时需要根据ES6或ES5的不同语法加以处理。请留意。

注意

因为我们想简化 ajax 请求代码,所以在这里引入 jQuery,但是 Domcom 并不依赖于任何其它库,也包括 jQuery 。


第一个部件

Domcom

Domcom是基于部件的框架。所有部件都是模块化,可组装的,组合性以及扩展性都非常好。另外还有一个显著特点,Domcom部件支持通过OOP方式进行扩展。

可以看到,本教程中Domcom表现得非常贴近函数式编程,没有体现出任何与OOP有关的特征,因此必须在这里特别地强调OOP的重要性。在GUI领域,OOP是非常重要的。只要熟悉GUI编程(比如MFC,WPF,Qt,WxWidgets, Android UI,cocoa等等)的人都应该理解这一点,不被某些错误的言论误导。

对于我们的评论框例子,我们将有如下的 Domcom 部件结构:

1
2
3
4
- CommentBox
- CommentList
- Comment
- CommentForm

第一步只是构造一个简单的div。

1
2
3
4
5
const {div} = dc;
var commentBox = div({className: "commentBox"},
"Hello, world! I am a CommentBox."
);
commentBox.mount();

制作部件

让我们为 commentList 和 commentForm 搭建骨架,它们也是由一些简单的 <div> 组成。将这两个部件的代码添加到你的源码文件中:

1
2
3
4
5
6
7
const {div} = dc;
const commentList = div({className: "commentList"},
"Hello, world! I am a CommentList."
);
const CommentForm = div({className: "commentForm"},
"Hello, world! I am a commentForm."
);

接下来,使用新创建的部件更新 commentBox 部件代码:

1
2
3
4
5
const commentBox = div({className: "commentBox"},
h1("Comments"),
commentList,
commentForm
);

传递数据

Domcom中可以直接通过函数参数传递数据。

对于AngularJS,ReactJS的数据传递机制(AngularJS是耦合于Scope的层次,ReactJS是耦合于组件的props和state层次),我联想到TJ Holowaychuk关于AngularJS模块设计机制的疑问:Why not just require?类似地,为什么不简单地利用函数及其参数来传递数据呢?这难道不是各种编程语言都提供的最普遍最自然的构造吗?Domcom正是充分利用这种方法实现了更好的数据传递与数据管理,实现了view和controllor以及model之间更充分的解耦,消除了层次化耦合数据这一重大的限制,从而实现了更灵活、更强大、解耦更彻底的数据传递和数据管理,能更好地支持MVC、MVVM或者flux等设计模式。

1
2
3
4
5
6
7
8
9
10
11
function makeComment(author, content) {
return div({className: "comment"},
h2({className: "commentAuthor"}, author),
content
);
}
const commentList = div({className: "commentList"},
makeComment("张三", "这是条评论"),
makeComment("李四“,”这是**另一条**评论“)
);

添加 Markdown 语法格式的评论

Markdown 是一种简单的格式化内联文本的方式。例如,用星号包裹文本将会使其强调突出。

为了显示Html,Domcom提供了html部件。使用html部件时会将用户内容渲染为dom结构,面对这种需求务必对可能的安全风险保持警惕。

1
2
3
4
5
6
7
const md = Remarkable();
function makeComment(author, content) {
return div({className: "comment"},
h2({className: "commentAuthor"}, author),
html(md.render(content))
);
}

使用html部件会将text渲染为dom结构。因此前一个步骤所使用的html部件的结果已经正好是我们想要的。当然这会存在安全风险,是编码时要注意的。Domcom并没有为此就特意将api搞得更丑,而是在文档中多加提醒。并且在api上更方便让程序员添加转义处理,方法是让html部件函数可以提供一个变换函数作为transform参数。上述代码在Domcom中可以这样实现:

1
2
3
4
5
6
7
8
9
10
const md = Remarkable();
function convertMarkdown(text) {
return md.render(text);
};
const comment = function (author, content) {
return div({className: "comment"},
h2({className: "commentAuthor"}, author),
html(content, convertMarkdown)
);
}

甚至可以更简单:

1
2
3
4
5
6
7
const md = Remarkable();
const comment = function (author, content) {
return div({className: "comment"},
h2({className: "commentAuthor"}, author),
html(content, md.render.bind(md))
);
}

接入数据模型

到目前为止,我们已经在源代码里面直接插入了评论数据。让我们将这些评论数据抽出来,放在一个 JSON 格式的变量中,然后将这个 JSON 数据渲染到评论列表。到最后,数据将会来自服务器,但是现在直接写在源代码中:

1
2
3
4
var data = [
{id: 1, author: "Pete Hunt", text: "This is one comment"},
{id: 2, author: "Jordan Walke", text: "This is *another* comment"}
];

我们将commentList改成函数以便传递数据。利用each部件函数可以动态地监控数据。

1
2
3
4
5
function makeCommentList(comments) {
return each({className: "commentList"}, comments, function(item) {
return comment(item.author, item.text);
})
}

现在需要把commentList在commentBox的使用方式改为函数调用:

1
2
3
4
5
const commentBox = div({className: "commentBox"},
h1("Comments"),
makeCommentList(data),
commentForm
);

在Domcom中并不需要层次化传递数据。我们这里并不一定要把commentBox改成部件函数。本教程会在后面的步骤中做这样的改变。是否这样做完全可以视应用需求而定,不是框架的要求。

从服务器获取数据

现在我们需要改变产生commentBox的方法。为了传入url参数,我们用函数产生部件:

1
2
var commentBox = makeCommentBox("/api/comments");
commentBox.mount()

根据现在的需求,我们把commentBox改成部件函数,把url作为参数传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function makeCommentBox(url) {
const comments = [];
const commentBox = div({className: "commentBox"},
h1("Comments"),
makeCommentList(comments),
commentForm
);
commentBox.on('didMount', function() {
$.ajax({
url: url,
dataType: 'json',
cache: false,
success: function(data) {
comments.replaceAll(data);
dc.render();
},
error: function(xhr, status, err) {
console.error(url, status, err.toString());
}
});
});
return commentBox;
}

上面的代码中,didMount事件的回调函数并不需要象在React的代码中那样引用this.props.url, 也不需要调用this.setState,只是使用函数参数url和局部变量comments,因此不需要把它设计为commentBox的方法,也不需要使用bind绑定this,用闭包函数就好了。

可以看到,Domcom中数据和部件解耦得更加彻底,同时传递和管理数据反而更加灵活便利了。


从服务器动态获取数据

现在实现从服务器动态获取数据的代码。

这里,我们用新的从服务器拿到的评论数组来替换掉老的评论数组,然后 UI 自动更新。有了这种反应机制,实现实时更新就仅需要一小点改动。在这里我们使用简单的轮询,但是你也可以很容易地改为使用 WebSockets 或者其他技术。

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
function makeCommentBox(url = “api/comments”, pollInterval = 2000) {
const comments = [];
const commentBox = div({className: "commentBox"},
h1("Comments"),
makeCommentList(comments),
commentForm
);
function loadCommentsFromServer() {
$.ajax({
url: url,
dataType: 'json',
cache: false,
success: function(data) {
comments.replaceAll(data);
dc.render();
},
error: function(xhr, status, err) {
console.error(url, status, err.toString());
}
});
},
commentBox.on('didMount', function(){
loadCommentsFromServer();
setInterval(loadCommentsFromServer, pollInterval)
});
return commentBox;
}

上面的代码中,我们将有关代码重构为loadCommentsFromServer,因为与前一个步骤类似的理由,依然只要用闭包函数就好,不必将其设定为commentBox的方法。


添加新的评论

现在是时候构造表单了。我们的 CommentForm 部件应该询问用户的名字和评论内容,然后发送一个请求到服务器,保存这条评论。

考虑到后面有传递回调函数参数的需求,现在就直接将commentForm改为用函数实现。

1
2
3
4
5
6
7
function makeCommentForm() {
return form({className: "commentForm"},
text({placeholder: "Your name"}),
text({placeholder: "Say something..."}),
input({type: "submit", value: "Post"})
);
}

处理输入事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function makeCommentForm() {
var newComment = {author: '', text: ''};
return form({className: "commentForm"},
text({
placeholder: "Your name",
value: newComment.author,
onchange: function(event, node){
newComment.author = node.value;
}
}),
text({
placeholder: "Say something...",
value: newComment.text,
onchange: function(event, node){
newComment.text = node.value;
}
}),
input({type: "submit", value: "Post"})
);
}

上面代码中,除了text部件,没有别的地方会改变newComment的内容,text部件不需要依据newComment动态刷新。如果需要text部件自动响应newComment的数据变化,可以使用响应函数, 这里可以用flow.bind, 就象这样:text({..., value: flow.bind(newComment, 'author'), ...})

如果使用指令,上面的代码可以更加简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dc.directives('model', dc.$model);
function makeCommentForm() {
var newComment = {author: '', text: ''};
const {author$, text$} = dc.bindings(newComment);
return form({className: "commentForm"},
text({
placeholder: "Your name",
$model: author$
}),
text({
placeholder: "Say something...",
$model: text$
}),
input({type: "submit", value: "Post"})
);
}

text部件(也包括其它input部件)甚至可以这样写:text({ placeholder: "Your name"}, author$)。这种写法和使用指令的写法都只是不同程度的语法糖而已,本质上和不用指令的代码是一样的。


表单交互

让我们使表单可交互。当用户提交表单的时候,我们应该清空表单,发送一个请求到服务器,然后刷新评论列表。首先,让我们监听表单的提交事件,然后清空表单。

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
function makeCommentForm() {
var newComment = {author: '', text: ''};
return form({className: "commentForm"},
text({
placeholder: "Your name",
value: newComment.author,
onchange:function(event, node){
newComment.author = node.value;
}
}),
text({
placeholder: "Say something...",
value: newComment.text,
onchange: function(event, node){
newComment.text = node.value;
}
}),
input({
type: "submit",
value: "Post",
onsubmit: function(event, node) {
event.preventDefault = true;
var author = newComment.author.trim();
var text = newComment.text.trim();
if (!text || !author) {
return;
}
extend(newComment, {author: '', text: ''});
// TODO: send request to the server
}
})
);
}

现在修改makeCommentBox,向makeCommentForm传递一个回调函数作为参数以便处理提交数据。

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
function makeCommentBox(url, pollInterval = 2000) {
const comments = [];
const commentBox = div({className: "commentBox"},
h1("Comments"),
makeCommentList(comments),
makeCommentForm(function(newComment){
// TODO: submit to the server and refresh the list
})
);
function loadCommentsFromServer() {
$.ajax({
url: url,
dataType: 'json',
cache: false,
success: function(data) {
comments.replaceAll(data);
dc.render();
},
error: function(xhr, status, err) {
console.error(url, status, err.toString());
}
});
},
commentBox.on('didMount', function(){
loadCommentsFromServer();
setInterval(loadCommentsFromServer, pollInterval)
});
return commentBox;
}

调用回调并清除评论

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
function makeCommentForm(submitComment) {
var newComment = {author: '', text: ''};
return form({className: "commentForm"},
text({
placeholder: "Your name",
value: newComment.author,
onchange:function(event, node){
newComment.author = node.value;
}
}),
text({
placeholder: "Say something...",
value: newComment.text,
onchange: function(event, node){
newComment.text = node.value;
}
}),
input({
type: "submit",
value: "Post",
onsubmit: function(event, node) {
event.preventDefault = true;
var author = newComment.author.trim();
var text = newComment.text.trim();
if (!text || !author) {
return;
}
submitComment(newComment);
extend(newComment, {author: '', text: ''});
}
})
);
}

发起POST请求并更新状态

在传递给CommentForm的回调handleCommentSubmit中添加ajax代码,发起服务器POST请求,成功时调用comments.replaceAll。

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
function makeCommentBox(url, pollInterval = 2000) {
const comments = [];
const commentBox = div({className: "commentBox"},
h1("Comments"),
makeCommentList(comments),
makeCommentForm(function(newComment){
$.ajax({
url: url,
dataType: 'json',
type: 'POST',
data: newComment,
success: function(data) {
comments.replaceAll(data);
dc.render();
},
error: function(xhr, status, err) {
console.error(this.props.url, status, err.toString());
}
});
})
);
function loadCommentsFromServer() {
$.ajax({
url: url,
dataType: 'json',
cache: false,
success: function(data) {
comments.replaceAll(data);
dc.render();
},
error: function(xhr, status, err) {
console.error(url, status, err.toString());
}
});
},
commentBox.on('didMount', function(){
loadCommentsFromServer();
setInterval(loadCommentsFromServer, pollInterval)
});
return commentBox;
}

优化:提前更新

我们的应用现在已经完成了所有功能,但是在新添加的评论出现在列表之前,必须等待请求完成,所以感觉很慢。我们可以直接添加这条评论到列表中,从而使应用感觉更快。

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
function makeCommentBox(url, pollInterval = 2000) {
const comments = [];
const commentBox = div({className: "commentBox"},
h1("Comments"),
makeCommentList(comments),
makeCommentForm(function(newComment){
$.ajax({
url: url,
dataType: 'json',
type: 'POST',
data: newComment,
success: function(data) {
comments.replaceAll(data);
dc.render();
},
error: function(xhr, status, err) {
console.error(url, status, err.toString());
}
});
comments.push(newComment);
commentBox.render();
})
);
function loadCommentsFromServer() {
$.ajax({
url: url,
dataType: 'json',
cache: false,
success: function(data) {
comments.replaceAll(data);
dc.render();
},
error: function(xhr, status, err) {
console.error(url, status, err.toString());
}
});
},
commentBox.on('didMount', function(){
loadCommentsFromServer();
setInterval(loadCommentsFromServer, pollInterval);
});
return commentBox;
}

本文用Domcom通过一些步骤来构造评论框。我们看到Domcom的代码虽然没有是用JSX,但是依然很简洁(即使比带JSX的React代码也更简洁),涉及的新概念很少,学习曲线非常平坦,更加贴近javascript最普遍的语言特性。而且我可以告诉大家,Domcom的实现原理可以让它达到比React更好的运行性能。

考虑到当前web前端的主流习惯,本文以javascript作为Domcom演示语言。Domcom框架是以用coffee-script开发的,但从使用上可以良好地用在ES6或ES5环境中,并且以UMD风格支持模块化。如果使用coffee-script语言来开发Domcom的应用程序,代码将更为简洁优雅。


祝贺你你刚刚通过一些简单步骤构造了一个评论框。现在你可以去学习一个更全面的辅导教程, 了解更多关于 Domcom 的概念和原理,或者去看看 Domcomm API参考