Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从一个简单的todo-app开始:MV*实战 #71

Closed
6 tasks done
murphywuwu opened this issue Feb 20, 2021 · 1 comment
Closed
6 tasks done

从一个简单的todo-app开始:MV*实战 #71

murphywuwu opened this issue Feb 20, 2021 · 1 comment
Labels
js question Further information is requested react vue

Comments

@murphywuwu
Copy link
Owner

murphywuwu commented Feb 20, 2021

  • MVC
    • 为什么会有MVC:解决了什么场景下的什么问题 (2.19)
    • 在MVC架构下实现todo app
  • MVVM
    • 为什么会有MVVM:解决了什么场景下的什么问题 (2.20)
    • 在MVVM架构下实现todo app
@issuelabeler issuelabeler bot added js question Further information is requested react vue labels Feb 20, 2021
@murphywuwu
Copy link
Owner Author

murphywuwu commented Feb 20, 2021

MVVM框架

从实现一个简单的todo开始。效果如下

我们先现在用最原始的方式来实现它。代码如下:

// 原始版
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .del {
      margin-left: 5px;
    }
    .completed {
      text-decoration: line-through;
    }
  </style>
</head>
<body>
  <section class="todoapp">
    <h1>Todo App</h1>
    <input type="text" class="new-todo" placeholder="what needs to be done?" autofocus="autofocus">
    <ul class="todo-list">
    </ul>
  </section>
  <footer>
    <span class="todo-count"></span>
  </footer>
  <script>


    // 模板解析
    let Template = {
      getTodoTemplate(todo) {
        let template = '<li class="{{completed}}">' 
            + '<label>{{value}}</label>' 
            + '<button class="del">x</button>'
          '</li>';
        template = template.replace('{{value}}', todo.title);
        template = template.replace('{{completed}}', todo.completed ? 'completed' : '');

        return template;
      },
      getTodoCounterTemplate(len) {
        return '待办事项还剩' + '<strong>'  + len + '</strong>' + ' 件';
      }
    }

    // 获取DOM元素
    let newTodo = document.querySelector('.new-todo');
    let todoList = document.querySelector('.todo-list');
    let todoCount = document.querySelector('.todo-count');

    // 给DOM元素绑定事件,响应交互
    newTodo.addEventListener('keydown', (e) => {
      if (e.keyCode === 13) {
        let value = e.target.value;
        let completed = false;

        if (!value.trim()) return;
                
        // 操作DOM
        let todoTemplate = Template.getTodoTemplate({ title: value, completed });
        todoList.innerHTML += todoTemplate;
        newTodo.value = '';
        
        
        // 通过dom获取状态
        let unCompletedTodos = Array.prototype.slice.call(todoList.children).filter((el) => !el.className);
        
        // 操作DOM
        let todoCounterTemplate = Template.getTodoCounterTemplate(unCompletedTodos.length);
        todoCount.innerHTML = todoCounterTemplate;
        
      }
    });               

    todoList.addEventListener('click', (e) => {
      let target = e.target;

      if (target.className === 'del') {
        let li = target.parentElement;

        // 操作DOM
        todoList.removeChild(li);
      
        // 通过dom获取状态
        let unCompletedTodos = Array.prototype.slice.call(todoList.children).filter((el) => !el.className);

        // 操作DOM
        todoCount.innerHTML =  Template.getTodoCounterTemplate(unCompletedTodos.length);
      };

      if (target.tagName.toLowerCase() === 'label') {
        let li = target.parentElement;
        let completed = li.className ? true : false;

        // 操作DOM
        li.className = completed ? '' : 'completed';
        
        // 通过dom获取状态
        let unCompletedTodos = Array.prototype.slice.call(todoList.children).filter((el) => !el.className);
        todoCount.innerHTML =  Template.getTodoCounterTemplate(unCompletedTodos.length);
      }
    });
  </script>
</body>
</html>

这样的代码的复杂度在于dom结构和状态耦合在一起。耦合意味着某个数据的结果依赖于其他数据的计算。也就是说dom的修改可能会引起状态的变化。而这个变化不一定是我们期待的变化,即会引出bug。

比如当我们在todoTemplate的li元素上的class属性上添加todo,代码如下

let todoTemplate = '<li class="{{completed}} todo">' 
  + '<label>{{value}}</label>' 
  + '<button class="del">x</button>'
'</li>';

原来我们是通过!el.className逻辑来筛选未完成的待办事项。而当我们对todoTemplate做了如上修改后,便会出现bug。

MVC

现在我们使用MVC模式来重构代码:

// html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>MVC</title>
  <style>
    .del {
      margin-left: 5px;
    }
    .completed {
      text-decoration: line-through;
    }
  </style>
</head>
<body>
  <section class="todoapp">
    <h1>Todo App</h1>
    <input type="text" class="new-todo" placeholder="what needs to be done?" autofocus="autofocus">
    <ul class="todo-list"></ul>
  </section>
  <footer>
    <span class="todo-count"></span>
  </footer>

  <script src="./eventemitter.js"></script>
  <script src="./model.js"></script>
  <script src="./template.js"></script>
  <script src="./view.js"></script>
  <script src="./controller.js"></script>
  <script>
    var controller = new Controller();
  </script>
</body>
</html>
// model.js
(function(root) {
  class Model extends root.EventEmitter {
    constructor() {
      super();
      
      this.todos = [];
    }

    add(todo) {
      this.todos.push(todo);

      this.emit('add', todo);
    }

    remove(id) {
      let index = this.todos.findIndex((todo) => {
        return todo.id == id;
      });

      if (index !== -1) {
        this.todos.splice(index, 1);
      }

      this.emit('remove', id);
    }

    find(query) {
      return this.todos.filter((todo) => {
        for (var q in query) {
          if (query[q] !== todo[q]) {
            return false;
          }
        }

        return true;
      });
    }

    update(id, data) {
      let index= this.todos.findIndex((todo) => {
        return todo.id == id;
      });

      let completed;

      if (index !== -1) {
        for (let key in data) {
          let todo = this.todos[index];
          todo[key] = data[key];
          if (key == 'completed') {
            completed = todo[key];
          }
        }

        this.emit('update', id, completed);
      }
    }
  }

  root.Model = Model;
})(window)
(function(root) {
  
  class EventEmitter {
    constructor() {
      // super();
      this.events = {};
    }

    on(evt, handler) {
      if (typeof evt === 'string' && evt) {
        let handlers = this.events[evt];
        
        if (typeof handler === 'function') {
          if (handlers) {
            return this.events[evt].push(handler);
          }
          return this.events[evt] = handler;
        }
      }
    }

    off(evt) {
      let handlers = this.events[evt];

      if (typeof evt === 'string' && evt) {
        delete this.events[evt];
      }
    }

    emit(evt) {

      if (typeof evt === 'string' && evt) {
        let args = [].slice.call(arguments);
        let handlers = this.events[evt];

        if (Array.isArray(handlers)) {
          for (let i = 0; i < handlers.length; i++) {
             let handler = handlers[i];
             if (typeof handler === 'function') {
               handler(...args.slice(1));
             }
          }
        }

        if (typeof handlers === 'function') {
          handlers(...args.slice(1));
        }
      }
    }
  }

  root.EventEmitter = EventEmitter;
})(window)
// view.js
(function(root) {
  class View {
    constructor() {

      this.newTodo = document.querySelector('.new-todo');
      this.todoList = document.querySelector('.todo-list');
      this.todoCount = document.querySelector('.todo-count');
      this.template = new Template();
    }

    addItem(todo) {
      var template = this.template.getTodoTemplate(todo);

      this.todoList.innerHTML += template;
      this.newTodo.value = '';
    }

    editItem(id, completed) {
      var todo = document.querySelector(`[data-id='${id}']`);
      
      todo.className = completed ? 'completed' : ''; 
    }

    removeItem(id) {
      var todo = document.querySelector(`[data-id='${id}']`);

      this.todoList.removeChild(todo);
    }

    renderItemCounter (len){
      var template = this.template.getTodoCounterTemplate(len);

      this.todoCount.innerHTML = template;
    }

    bind(event, handler) {
      
      if (event === 'addItem') {
        this.newTodo.addEventListener('keydown', handler);
      }
      else if (event === 'editItem') {
        this.todoList.addEventListener('click', handler);
      }
      else if (event === 'removeItem') {
        this.todoList.addEventListener('click', handler);
      }
    }
  }


  root.View = View;
})(window)
// template.js
(function(root) {
  class Template {
    getTodoTemplate(todo) {
      let template = '<li data-id="{{id}}" class="{{completed}}">' 
          + '<label>{{value}}</label>' 
          + '<button class="del">x</button>'
        '</li>';
      template = template.replace('{{id}}', todo.id);
      template = template.replace('{{value}}', todo.title);
      template = template.replace('{{completed}}', todo.completed ? 'completed' : '');

      return template;
    }
    getTodoCounterTemplate(len) {
      return '待办事项还剩' + '<strong>'  + len + '</strong>' + ' 件';
    }
  }

  root.Template = Template;
})(window)
// controller.js
(function(root) {
  class Controller {
    constructor() {
      this.view = new View();
      this.model = new Model();

      this.view.bind('addItem', this.addItem.bind(this));
      this.view.bind('editItem', this.editItem.bind(this));
      this.view.bind('removeItem', this.removeItem.bind(this))
      
      this.model.on('add', (todo) => {
        let unCompletedTodos = this.model.find({ completed: false });
      
        this.view.addItem(todo);
        this.view.renderItemCounter(unCompletedTodos.length);
      });
      this.model.on('update', (id, completed) => {
        let unCompletedTodos = this.model.find({ completed: false });


        this.view.editItem(id, completed);
        this.view.renderItemCounter(unCompletedTodos.length);
      });
      this.model.on('remove', (id) => {
        let unCompletedTodos = this.model.find({ completed: false });

        this.view.removeItem(id);
        this.view.renderItemCounter(unCompletedTodos.length);
      });
    }

    addItem(e) {
      if (e.keyCode === 13) {
        let value = e.target.value;
        let completed = false;

        if (!value.trim()) return;

        let todo = { title: value, completed, id: new Date().getTime() };

        this.model.add(todo);
      }
    }

    editItem(e) {
      let target = e.target;
      if (target.tagName.toLowerCase() === 'label') {
        let li = target.parentElement;
        let id = li.dataset.id;
        let completed = li.className ? true : false;
        
        this.model.update(id, { completed: !completed });
      }
    }

    removeItem(e) {
      let target = e.target;

      if (target.className === 'del') {
        let li = target.parentElement;
        let id = li.dataset.id;
        
        this.model.remove(id);
      }
    }
  }

  root.Controller = Controller;
})(window)

我们可以看出MVC模式下的代码。状态(状态即model)和dom进行了解耦。
Model负责管理数据的增删查改。View层则负责管理DOM的增删查改。controller层则用于响应用户的交互,修改model。并同时监听model的变化同步到view层。在MVC这样的代码架构设计下,对代码中各个角色的职责作了明确的划分,实现了Model层与View层的解耦。

虚线表示监听关系。
实线表示调用关系。

image

图中两条虚线分别表示:

  • controller层监听view层用户交互的变化
  • view层监听model层的变化。

1,2,3则表示当界面上发生用户交互后,它们三则之间的调用顺序。如下:

  1. 首先界面上发生用户交互后,controller会作出响应调用相应的函数
  2. 接着controller层会调用model层的接口,对model层的数据进行增删查改
  3. 当model层的数据发生变化时,通过调用view层的render函数同步变化到view层

通过MVC模式编写代码,我们需要手动操作DOM,随着view的膨胀,我们需要操作的DOM就越来越多,代码的复杂度也越高。

MVVM

现在我们使用MVVM框架vue来重构代码。

// MVVM版
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>MVVM</title>
  <style>
    .del {
      margin-left: 5px;
    }
    .completed {
      text-decoration: line-through;
    }
  </style>
</head>
<body>  
  <div id="app">
    <section class="todoapp">
      <h1>Todo App</h1>
      <input v-model="newTodo" v-on:keydown.enter="addTodo" type="text" class="new-todo" placeholder="what needs to be done?" autofocus="autofocus">
      <ul class="todo-list">
        <li
          v-for="todo in todos"
          :class="{completed: todo.completed}"
        >
          <label v-on:click="editTodo(todo)">{{ todo.title }}</label>
          <button v-on:click="removeTodo(todo)" class="del">x</button>
        </li>
      </ul>
    </section>
    <footer>
      <span class="todo-count">
        待办事项还剩
        <strong>{{unCompletedTodos.length}}</strong> 件
      </span>
    </footer>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
  <script>
    var app = new Vue({
      el: '#app',
      data: {
        todos: [],
        newTodo: '',
      },
      methods: {
        addTodo: function() {
          var value = this.newTodo && this.newTodo.trim();

          if (!value) return;
          
          this.todos.push({ id: Date.now(),title: value, completed: false });
          this.newTodo = '';
        },

        removeTodo: function(todo) {
          var index = this.todos.indexOf(todo);
          this.todos.splice(index, 1);
        },
        editTodo: function(todo) {
          todo.completed = !todo.completed;
        }
      },
      computed: {
        unCompletedTodos: function() {
          return this.todos.filter(todo => !todo.completed);
        }
      }
    });
  </script>
</body>
</html>

所以MVVM的目的是当model层发生变化时,view层能够自动更新。而不用我们手动操作DOM,从而减少代码的复杂度。

浅析前端开发中的 MVC/MVP/MVVM 模式

界面之下:还原真实的MV*模式 #

相比于原生 JavaScript,现在流行的 JS 框架 React 和 Vue 都解决了什么问题?

【javascript激增的思考03】MVVM与Knockout

250行实现一个简单的MVVM

mvc-mvp-mvvm

Building React From Scratch

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
js question Further information is requested react vue
Projects
None yet
Development

No branches or pull requests

1 participant