加载中...

第十四章 前后端实战演练:Web聊天室-前端开发


在上一章的 服务器端开发 中我们定义了模型,实现了几个实体增删改查得功能,也提供了前端访问数据的接口。但在前端的实现过程中又对接口进行了调整,以更符合前端的使用。在真实的开发中也是如此,定义的接口合不合适只有在开发时才知道。

目前代码并没有进行模块的划分,在单js文件(chat.js)中实现了所有逻辑。下一步会进行通过seajs或者requirejs来进行模块管理。

关于前端样式的设计和开发并不在这个系列的计划中,因此就不多做介绍了,只是基于semantic进行了简单的设计,有兴趣的可以自己去看: wechat项目 。

14.1 前端文件结构

前端的结构和前面的项目结构一样,只是添加了chat.js和自定义样式的chat.css文件,我们所有的代码都在这个文件中编写。

  1. ├── static
  2.    ├── css
  3.       ├── body.css
  4.       ├── chat.css
  5.       └── semantic.min.css
  6.    ├── fonts
  7.       ├── basic.icons.svg
  8.       ├── basic.icons.woff
  9.       ├── icons.svg
  10.       └── icons.woff
  11.    ├── img
  12.       └── bg.jpg
  13.    └── js
  14.    ├── backbone.js
  15.    ├── chat.js
  16.    ├── jquery.js
  17.    ├── json2.js
  18.    └── underscore.js
  19. ├── templates
  20.    └── index.html

14.2 Model和Collection定义

我们还是先来定义Model的实现,前端的Model应该和后端的Model定义一样,不然数据传递就会有问题。因为在后端已经明确定义了Model有哪些属性,这里的定义就简单多了。当然这也是动态语言的优势——动态的添加属性。

我们要定义三个Model和两个Collection,因为User这个对象在前端只会存在一份,不需要定义集合。来看具体实现:

  1. var User = Backbone.Model.extend({
  2. urlRoot: '/user',
  3. });
  4. var Topic = Backbone.Model.extend({
  5. urlRoot: '/topic',
  6. });
  7. var Message = Backbone.Model.extend({
  8. urlRoot: '/message',
  9. });
  10. var Topics = Backbone.Collection.extend({
  11. url: '/topic',
  12. model: Topic,
  13. });
  14. var Messages = Backbone.Collection.extend({
  15. url: '/message',
  16. model: Message,
  17. });

我们之定义了基本的属性,这些属性保证了我们可以可以直接通过collection或者model获取到后端的数据。

14.3 视图和模板的定义

定义了基本的Model之后,就相当于是有了数据的获取方式,下一步就是如何显示这些数据了。因此就需要用Backbonejs中的view和template来定义我们的具体显示了。

首先来定义view:

  1. var TopicView = Backbone.View.extend({
  2. tagName: "div class='column'",
  3. templ: _.template($('#topic-template').html()),
  4. // 渲染列表页模板
  5. render: function() {
  6. $(this.el).html(this.templ(this.model.toJSON()));
  7. return this;
  8. },
  9. });
  10. var MessageView = Backbone.View.extend({
  11. tagName: "div class='comment'",
  12. templ: _.template($('#message-template').html()),
  13. // 渲染列表页模板
  14. render: function() {
  15. $(this.el).html(this.templ(this.model.toJSON()));
  16. return this;
  17. },
  18. });
  19. var UserView = Backbone.View.extend({
  20. el: "#user_info",
  21. username: $('#username'),
  22. show: function(username) {
  23. this.username.html(username);
  24. this.$el.show();
  25. },
  26. });

根据定义的三个Model,定义了把数据渲染到模板的方式,对应的模块是什么样的呢,我们来看下:

  1. <script type="text/template" id="topic-template">
  2. <a href="#topic/<%= id %>">
  3. <div class="column">
  4. <div class="ui segment">
  5. <h3><%= title %></h3>
  6. <p>
  7. 创建者:<%= owner_name %>
  8. </p>
  9. <p>
  10. 创建时间:<%= created_time %>
  11. </p>
  12. </div>
  13. </div>
  14. </a>
  15. </script>
  16. <script type="text/template" id="message-template">
  17. <div class="content <% if(is_mine) { %> right <% } %>" data="<%= id %>">
  18. <a class="author"><%= user_name %></a>
  19. <br/>
  20. <div class="metadata">
  21. <span class="date"><%= created_time %></span>
  22. </div>
  23. <div class="text" style="min-width:55px">
  24. <div class="ui pointing label large <% if(is_mine) { %> right <% } %>">
  25. <p><%= content %></p>
  26. </div>
  27. </div>
  28. </div>
  29. </script>

这里并没有定义user的模板,因为目前对user只是做了简单的展现,即仅在顶部栏上加了一个用户名,通过: user_name这个Dom节点的id添加数据。

到目前已经介绍了所有的基础数据:从model到collection,到用来显示数据的view,再到定义的页面模板template。每部分的数据都可以单独的从后台获取,并且渲染。好了,材料都准备好了就差什么了?当然是流程。不过还有一个东西得先说一下,这些数据被塞到页面之后到底长成什么样还不知道。因此得先来看下页面结构。

下面先来看看上面的那些数据最终要被填充到页面的什么部位,然后再来说流程的事。

14.4 页面结构

这里还是从代码上说事,但是最终效果图已经在 wechat 的readme中贴出来了,你可以跳过去看看长相先。

欣赏完外表,来看看内部的骨架,这里只贴主要代码。

顶部的固定栏:

  1. <!-- Top Bar -->
  2. <div class="ui fixed transparent inverted main menu">
  3. <div class="container">
  4. <div class="title item">
  5. <b>We Chat</b> 在线聊天系统
  6. </div>
  7. <div class="right menu">
  8. <div class="title item">
  9. Backbonejs交流群:308466740
  10. </div>
  11. </div>
  12. <div id="user_info" class="right menu hide">
  13. <div class="title item">
  14. <i class="icon user"></i>
  15. <label id="username">the5fire</lable>
  16. </div>
  17. <a class="popup icon github item" href="/logout" title="退出登录">
  18. 退出登录
  19. </a>
  20. </div>
  21. </div>
  22. </div>

登陆注册的代码,纯静态代码:

  1. <div id="wrapper" style="display: block; z-index: 998;">
  2. <div class="container">
  3. <div id="login" class="ui two column relaxed grid">
  4. <div class="column">
  5. <div class="ui fluid form segment">
  6. <h3 class="ui header">登录</h3>
  7. <div class="field">
  8. <label>用户名</label>
  9. <input id="login_username" placeholder="用户名" type="text">
  10. </div>
  11. <div class="field">
  12. <label>密码</label>
  13. <input id="login_pwd" type="password">
  14. </div>
  15. <div class="ui blue login_submit button">登录</div>
  16. </div>
  17. </div>
  18. <div class="column">
  19. <div class="ui fluid form segment">
  20. <h3 class="ui header">注册</h3>
  21. <div class="field">
  22. <label>用户名</label>
  23. <input id="reg_username" placeholder="用户名" type="text">
  24. </div>
  25. <div class="field">
  26. <label>密码</label>
  27. <input id="reg_pwd" type="password">
  28. </div>
  29. <div class="field">
  30. <label>重复密码</label>
  31. <input id="reg_pwd_repeat" type="password">
  32. </div>
  33. <div class="inline field">
  34. <div class="ui checkbox">
  35. <input type="checkbox" id="terms">
  36. <label for="terms">我同意the5fire's WeChat网的服务条款。</label>
  37. </div>
  38. </div>
  39. <div class="ui blue registe_submit button">注册</div>
  40. </div>
  41. </div>
  42. </div>
  43. </div>
  44. </div>

用来展示话题和消息的内容区域:

  1. <!-- Content -->
  2. <div id="main" class="main container">
  3. <!-- Topic List -->
  4. <div id="topic_section">
  5. <div id="topic_list" class="ui three column grid">
  6. <!-- 这里放topic列表 -->
  7. </div>
  8. <div id="topic_form" class="ui error form segment">
  9. <div class="two fields">
  10. <div class="field">
  11. <label>新建Topic</label>
  12. <input id="topic_title" placeholder="topic" type="text">
  13. </div>
  14. </div>
  15. <div class="ui blue submit_topic button">Add</div>
  16. </div>
  17. </div>
  18. <!-- Message -->
  19. <div id="message_section" class="ui column grid hide" style="display:none">
  20. <div class="column">
  21. <div class="circular ui button"><a href="#index">返回列表</a></div>
  22. <div class="ui piled blue segment">
  23. <h2 class="ui header">
  24. #<i id="message_head"></i># <!-- 用来放topic name -->
  25. </h2>
  26. <div id="message_list" class="ui comments">
  27. <!-- comments 列表 -->
  28. </div>
  29. <div class="ui reply form">
  30. <div class="field">
  31. <input type="text" id="comment"/>
  32. </div>
  33. <div id="submit" data="" class="ui fluid blue labeled submit icon button">
  34. <i class="icon edit"></i> 我也来说一句!
  35. </div>
  36. </div>
  37. </div>
  38. </div>
  39. </div>
  40. </div>

页面布局大概介绍了一下,如果你熟悉html,并且也看了我上面链接里给的最终效果, 上面的这些理解上面的这些代码应该很Easy了。如果不熟悉的也没问题,只要关注于我写了注释的地方就行了,这些地方就是上面我们定义的那些模板被渲染好之后的归宿。

14.5 view管理和router管理

上面占了点篇幅介绍了页面的布局,以便对我们数据最终的处理有一个感觉。

有了数据,也有了最后数据的去处,最后当然要说流程了。所谓的流程就是说我要怎么把Model渲染好的模板给塞到对于的页面div节点中,我要怎么来控制不同Model的展示。毕竟是SPA(单页应用), 也只有这一个页面来供数据的展示。因此需要在一个页面上切换的展示不同的视图。

这里我们是通过Backbone的Route和View来做。Route用来做路由分发(也就是URI的匹配,比如:#index匹配到首页)。另外不同于上面用来把Model数据传到Template中的View,这里的View是用来管理其他具体View和Collection的,可以比喻为管家View,就是用来控制这个视图什么时候显示,那个Collection的数据什么时候获取。

但是,需要注意,这个View需要被Route来控制,也就是通过路由控制(根据URI),因此View在具备上述功能的情况下也要提供接口(方法)给Route。

上面介绍了一堆,仿佛说不太清晰,没关系,Talk is cheap, Show you my code。

先来看View管家-AppView, 主要功能就是获取Topic和Message的数据到Collection中,调用Model对应的View把数据填到模板中,然后把最终拼好的数据放到上面介绍的页面对应div中。

  1. var AppView = Backbone.View.extend({
  2. el: "#main",
  3. topic_list: $("#topic_list"),
  4. topic_section: $("#topic_section"),
  5. message_section: $("#message_section"),
  6. message_list: $("#message_list"),
  7. message_head: $("#message_head"),
  8. events: {
  9. 'click .submit': 'saveMessage', // 发送消息
  10. 'click .submit_topic': 'saveTopic', // 新建主题
  11. 'keypress #comment': 'saveMessageEvent', // 键盘事件
  12. },
  13. initialize: function() {
  14. _.bindAll(this, 'addTopic', 'addMessage');
  15. topics.bind('add', this.addTopic);
  16. // 定义消息列表池,每个topic有自己的message collection
  17. // 这样保证每个主题下得消息不冲突
  18. this.message_pool = {};
  19. this.message_list_div = document.getElementById('message_list');
  20. },
  21. addTopic: function(topic) {
  22. var view = new TopicView({model: topic});
  23. this.topic_list.append(view.render().el);
  24. },
  25. addMessage: function(message) {
  26. var view = new MessageView({model: message});
  27. this.message_list.append(view.render().el);
  28. },
  29. saveMessageEvent: function(evt) {
  30. if (evt.keyCode == 13) {
  31. this.saveMessage(evt);
  32. }
  33. },
  34. saveMessage: function(evt) {
  35. var comment_box = $('#comment')
  36. var content = comment_box.val();
  37. if (content == '') {
  38. alert('内容不能为空');
  39. return false;
  40. }
  41. var topic_id = comment_box.attr('topic_id');
  42. var message = new Message({
  43. content: content,
  44. topic_id: topic_id,
  45. });
  46. self = this;
  47. var messages = this.message_pool[topic_id];
  48. message.save(null, {
  49. success: function(model, response, options){
  50. comment_box.val('');
  51. // 重新获取,看服务器端是否有更新
  52. // 比较丑陋的更新机制
  53. messages.fetch({
  54. data: {topic_id: topic_id},
  55. success: function(){
  56. self.message_list.scrollTop(self.message_list_div.scrollHeight);
  57. messages.add(response);
  58. },
  59. });
  60. },
  61. });
  62. },
  63. saveTopic: function(evt) {
  64. var topic_title = $('#topic_title');
  65. if (topic_title.val() == '') {
  66. alert('主题不能为空!');
  67. return false
  68. }
  69. var topic = new Topic({
  70. title: topic_title.val(),
  71. });
  72. self = this;
  73. topic.save(null, {
  74. success: function(model, response, options){
  75. topics.add(response);
  76. topic_title.val('');
  77. },
  78. });
  79. },
  80. showTopic: function(){
  81. // 获取所有主题
  82. topics.fetch();
  83. this.topic_section.show();
  84. this.message_section.hide();
  85. this.message_list.html('');
  86. },
  87. initMessage: function(topic_id) {
  88. // 初始化消息集合,并放到消息池中
  89. var messages = new Messages;
  90. messages.bind('add', this.addMessage);
  91. this.message_pool[topic_id] = messages;
  92. },
  93. showMessage: function(topic_id) {
  94. this.initMessage(topic_id);
  95. this.message_section.show();
  96. this.topic_section.hide();
  97. this.showMessageHead(topic_id);
  98. $('#comment').attr('topic_id', topic_id);
  99. var messages = this.message_pool[topic_id];
  100. messages.fetch({
  101. data: {topic_id: topic_id},
  102. success: function(resp) {
  103. self.message_list.scrollTop(self.message_list_div.scrollHeight)
  104. }
  105. });
  106. },
  107. showMessageHead: function(topic_id) {
  108. var topic = new Topic({id: topic_id});
  109. self = this;
  110. topic.fetch({
  111. success: function(resp, model, options){
  112. self.message_head.html(model.title);
  113. }
  114. });
  115. },
  116. });

上面是所有数据视图的展示的逻辑控制部分,虽然代码很多,但没有复杂逻辑,很直观。这里只是Topic和Message的展示。但是这些所有的数据都是需要用户登录之后才能看到的,那么用户登录和注册部分的逻辑在哪呢?在上面的页面布局部分已经展示了登录注册的页面,下面展示下具体逻辑。

登录注册-LoginView:

  1. var LoginView = Backbone.View.extend({
  2. el: "#login",
  3. wrapper: $('#wrapper'),
  4. events: {
  5. 'keypress #login_pwd': 'loginEvent',
  6. 'click .login_submit': 'login',
  7. 'keypress #reg_pwd_repeat': 'registeEvent',
  8. 'click .registe_submit': 'registe',
  9. },
  10. hide: function() {
  11. this.wrapper.hide();
  12. },
  13. show: function() {
  14. this.wrapper.show();
  15. },
  16. loginEvent: function(evt) {
  17. if (evt.keyCode == 13) {
  18. this.login(evt);
  19. }
  20. },
  21. login: function(evt){
  22. var username_input = $('#login_username');
  23. var pwd_input = $('#login_pwd');
  24. var u = new User({
  25. username: username_input.val(),
  26. password: pwd_input.val(),
  27. });
  28. u.save(null, {
  29. url: '/login',
  30. success: function(model, resp, options){
  31. g_user = resp;
  32. // 跳转到index
  33. appRouter.navigate('index', {trigger: true});
  34. }
  35. });
  36. },
  37. registeEvent: function(evt) {
  38. if (evt.keyCode == 13) {
  39. this.registe(evt);
  40. }
  41. },
  42. registe: function(evt){
  43. var reg_username_input = $('#reg_username');
  44. var reg_pwd_input = $('#reg_pwd');
  45. var reg_pwd_repeat_input = $('#reg_pwd_repeat');
  46. var u = new User({
  47. username: reg_username_input.val(),
  48. password: reg_pwd_input.val(),
  49. password_repeat: reg_pwd_repeat_input.val(),
  50. });
  51. u.save(null, {
  52. success: function(model, resp, options){
  53. g_user = resp;
  54. // 跳转到index
  55. appRouter.navigate('index', {trigger: true});
  56. }
  57. });
  58. },
  59. });

这里的View的主要功能是:注册(保存user数据到后台),登录(发送用户请求到后台,成功则跳到首页),事件监听和处理。很基础的功能。

从上面两部分我们知道了如何控制不同Model对应视图的展示,也知道了如何处理用户登录。下面再来看些Route部分是如何把url匹配到对应的方法上的。

路由部分代码-AppRouter:

  1. var AppRouter = Backbone.Router.extend({
  2. routes: {
  3. "login": "login",
  4. "index": "index",
  5. "topic/:id" : "topic",
  6. },
  7. initialize: function(){
  8. // 初始化项目, 显示首页
  9. this.appView = new AppView();
  10. this.loginView = new LoginView();
  11. this.userView = new UserView();
  12. this.indexFlag = false;
  13. },
  14. login: function(){
  15. this.loginView.show();
  16. },
  17. index: function(){
  18. if (g_user && g_user.id != undefined) {
  19. this.appView.showTopic();
  20. this.userView.show(g_user.username);
  21. this.loginView.hide();
  22. this.indexFlag = true; // 标志已经到达主页了
  23. }
  24. },
  25. topic: function(topic_id) {
  26. if (g_user && g_user.id != undefined) {
  27. this.appView.showMessage(topic_id);
  28. this.userView.show(g_user.username);
  29. this.loginView.hide();
  30. this.indexFlag = true; // 标志已经到达主页了
  31. }
  32. },
  33. });

这里设定了三条路由:login,index,topic,分别对应这个登录视图(LoginView), 主题和Message的视图(由AppView管理)。

在不同的路由中的逻辑大致一样,就是根据当前的条件决定是否现实视图。 比如index中的 if (g_user && g_user.id != undefined) { 就是判断当前环境中是否有g_user这个对象(这个对象是用来存放已登录用户数据的,后面会介绍),根据这个对象判断是否用户已经登录,进而决定是否现实首页——topic列表页。

14.6 启动

当所有的逻辑都定义好之后,页面加载完毕首先要做的就是启动整个流程,怎么启动呢?按照我们的项目结构:AppRouter管理AppView和LoginView,AppView管理TopicView和MessageView,因此,只需要启动AppRouter即可。

启动代码如下:

  1. var appRouter = new AppRouter();
  2. var g_user = new User();
  3. g_user.fetch({
  4. success: function(model, resp, options){
  5. g_user = resp;
  6. Backbone.history.start({pustState: true});
  7. if(g_user === null || g_user.id === undefined) {
  8. // 跳转到登录页面
  9. appRouter.navigate('login', {trigger: true});
  10. } else if (appRouter.indexFlag == false){
  11. // 跳转到首页
  12. appRouter.navigate('index', {trigger: true});
  13. }
  14. },
  15. }); // 获取当前用户

就是这一小段代码,程序可以正常运行了。这段代码中的逻辑是:声明一个全局的appRouter和g_user,然后获取当前用户(服务器端会通过session保存对应浏览器的信息), 之后根据获取到得用户状态做进一步操作(到登录页面或是到首页)。

这里需要注意的是,这段代码只有在页面加载(刷新或重新访问)的时候才会执行。

好了,到此为止整个项目已经介绍完毕了,不知道你是否看懂,或者这么问,我是否把这个项目讲明白了?

14.7 总结

这一篇看起篇幅很长,其实都是代码。而这些代码只有当你真正打算做这么个东西的时候才会主动去理解,因为那些走马观花的人会选择性的忽略代码。

最后还是补充一下整个流程,其实整个项目开始做的时候,项目的设计者就应该有一个具体的需求和用户使用的场景。对于这个项目我自己设想的用户使用流程:

用户打开浏览器,看到登录和注册页面——》输入用户名、密码进行登录(注册)操作——》展示主题列表视图,并显示用户名在顶部——》用户创建并进入某一主题(显示消息列表视图)——》用户发送消息,消息保存的同时获取服务器端的消息到当前视图。

另外一定要说的是,项目没有进行太多优化和代码的精简,还有很多改进的地方。在我写代码的这些年中我始终坚信并践行的一件事就是——获取知识最好的方法就是实践。因此如果你想掌握这个Backbone这个工具,最佳的方式是开始一个项目,并持续的做下去。或者参与一个项目,持续改善项目。

我在边写边实践中写了 WeChat 这个项目,并且已经部署上线,相信会是一个好的开始,因为我没打算把它仅仅作为一个Demo来用。 本文涉及的所有代码均在该项目的basic-version分支可以看到。


还没有评论.