250 lines to implement a simple MVVM

Keywords: Javascript angular Vue Mobile github

MVVM has aroused a boom in the front-end stage in the past two years. The hot Vue and Angular have brought numerous conveniences to developers. In this paper, we will implement a simple MVVM and explore the secret of MVVM with more than 200 lines of code. You can First click on JS Bin in this article to see the effect

What is MVVM?

MVVM is a kind of program architecture design. Take it apart and look at it as Model-View-View Model.

Model

Model refers to the data layer, which is pure data. For the front end, it is often a simple object. For example:

{
  name: 'mirone',
  age: 20,
  friends: ['singleDogA', 'singleDogB'],
  details: {
    type: 'notSingleDog',
    tags: ['fff', 'sox']
  }
}

The data layer is the data that we need to render and present to users. The data layer itself is variable. The data layer should not assume the functions of logical operation and calculation.

View

View refers to the view layer, which is directly presented to the user. Simply put, HTML is the front end. For example, in the data layer above, the corresponding view layer may be:

<div>
  <p>
    <b>name: </b>
    <span>mirone</span>
  </p>
  <p>
    <b>age: </b>
    <span>20</span>
  </p>
  <ul>
    <li>singleDogA</li>
    <li>singleDogB</li>
  </ul>
  <div>
    <p>notSingleDog</p>
    <ul>
      <li>fff</li>
      <li>sox</li>
    </ul>
  </div>
</div>

Of course, the view layer is variable, and you can add elements at will. This will not change the data layer, but the way the view layer presents the data. The view layer should be completely separated from the data layer.

ViewModel

Since the view layer should be separated from the data layer, we need to design a structure to establish some connection between them. When we modify the Model, the ViewModel automatically synchronizes the changes to the View layer. Similarly, when we modify View, the Model is also automatically modified by ViewModel.

It can be seen that how to design a View Model which can automatically synchronize View and Model efficiently is the core and difficulty of the whole MVVM framework.

Principle of MVVM

difference

Different frameworks have different implementations of MVVM.

Data hijacking

Vue is implemented by hijacking data (Model). When the data changes, the data will trigger the method of binding when hijacking and update the view.

Visceral examination mechanism

The implementation of Angular is that when an event (such as input) occurs, Angular checks whether the new data structure and the previous data structure have changed to decide whether to update the view.

Publish and Subscribe Mode

Knockout implements a publishing subscriber. When parsing, it binds the subscriber to the corresponding view node, and binds the publisher to the data. When modifying the data, it starts the publisher and updates the view accordingly.

Same points

But there are many similarities. They all have three steps:

  • Parse template

  • Analytical data

  • Binding Templates and Data

Parse template

What is a template? Let's take a look at the API s of the mainstream MVVM:


<!-- Vue -->
<div id="mobile-list">
  <h1 v-text="title"></h1>
  <ul>
    <li v-for="item in brands">
      <b v-text="item.name"></b>
      <span v-show="showRank">Rank: {{item.rank}}</span>
    </li>
  </ul>
</div>
<!-- Angular -->
<ul>
  <li ng-repeat="phone in phones">
    {{phone.name}}
    <p>{{phone.snippet}}</p>
  </li>
</ul>
<!-- Knockout -->
<tbody data-bind="foreach: seats">
  <tr>
    <td data-bind="text: name"></td>
    <td data-bind="text: meal().mealName"></td>
    <td data-bind="text: meal().price"></td>
  </tr>    
</tbody>

It can be seen that they all define their own template keywords. The function of this module is to parse the template according to these keywords and to map the template to the desired data structure.

Analytical data

Data in Model is parsed by hijacking or binding publishers. Data parser should be written in consideration of the implementation of VM, but in any case, only one thing should be done to parse data: to define the object to be notified when the data changes. The consistency of parsed data should be guaranteed when parsing data, and the interfaces exposed after each data parsing should be consistent.

Binding Templates and Data

This part defines how data structures bind to templates, which is the legendary "two-way binding". When we directly manipulate the data after binding, the application can automatically update the view. Data and templates are often many-to-many relationships, and different templates update data in different ways. For example, there are text nodes that change tags, and className that changes tags.

Manual Implementation of MVVM

After some analysis, let's start to implement MVVM.

Desired effect

For my MVVM, I want to correspond to a data structure:


let data = {
  title: 'todo list',
  user: 'mirone',
  todos: [
    {
      creator: 'mirone',
      content: 'write mvvm'
      done: 'undone',
      date: '2016-11-17',
      members: [
        {
          name: 'kaito'
        }
      ]
    }
  ]
}

I can write templates accordingly:


<div id="root">
  <h1 data-model="title"></h1>
  <div>
    <div data-model="user"></div>
    <ul data-list="todos">
      <li data-list-item="todos">
        <p data-class="todos:done" data-model="todos:creator"></p>
        <p data-model="todos:date"></p>
        <p data-model="todos:content"></p>
        <ul data-list="todos:members">
          <li data-list-item="todos:members">
            <span data-model="todos:members:name"></span>
          </li>
        </ul>
      </li>
    </ul>
  </div>
</div>

Then by calling:

new Parser('#root', data)

You can complete the binding of mvvm, and then you can directly manipulate the data object to change the View.

Parse template

Template parsing is actually a tree traversal process.

ergodic

As we all know, DOM is a tree structure, which is why it is called "DOM tree".
For tree traversal, as long as recursion, it can easily complete a depth-first traversal, see the code:


function scan(node) {
  console.log(node)
  for(let i = 0; i < node.children.length; i++) {
    const _thisNode  = node.children[i]
    console.log(_thisNode)
    if(_thisNode.children.length) {
      scan(_thisNode)
    }
  }
}

Traversing through different structures

Knowing how to traverse a DOM tree, how do we get the DOM tree that needs to be analyzed?
According to the previous idea, we need several kinds of logos:

  • data-model -- Used to replace DOM text nodes with content formulation

  • data-class -- Used to replace DOM's className with content development

  • data-list -- Used to identify a list that will appear next, which is structured

  • data-list-item -- Internal structure used to identify list items

  • data-event -- Used to specify events for binding DOM nodes

Simply categorize: data-model, data-class, and data-event should be one category, all of which affect only the current node; data-list and data-item should be considered separately as lists. Then we can go through it like this:


function scan(node) {
  if(!node.getAttribute('data-list')) {
    for(let i = 0; i < node.children.length; i++) {
      const _thisNode = node.children[i]
      parseModel(node)
      parseClass(node)
      parseEvent(node)
      if(_thisNode.children.length) {
        scan(_thisNode)
      }
    }
  } else {
    parseList(node)
  }
}
function parseModel(node) {
  //TODO: Parsing Model Nodes
}
function parseClass(node) {
  //TODO: Resolving className
}
function parseEvent(node) {
  //TODO: Parsing events
}
function parseList(node) {
  //TODO: Parsing List
}

So we've built the general framework of the traversal.

Processing methods for different structures

parseModel, parseClass and parseEvent are treated in a similar way. The only thing worth noting is the processing of nested elements. Recall our template design:

<! - - encounter nested parts - > ___________
<div data-model="todos:date"></div>

The todos:date here is actually a great convenience for us to parse the template, because it shows the location of the current data in the Model structure.


//Evet should have an EvetList, roughly structured as:
const eventList = {
  typeWriter: {
    type: 'input', //Types of events
    fn: function() {
      //The event handler, this of the function, represents the DOM node bound by the function
    }
  }
}
function parseEvent(node) {
  if(node.getAttribute('data-event')) {
    const eventName = node.getAttribute('data-event')
    node.addEventListener(eventList[eventName].type, eventList[eventName].fn.bind(node))
  }
}
//According to the position resolution template in the template, the Path here is an array that represents the position of the current data in the Model.
function parseData(str, node) {
  const _list = str.split(':')
  let _data,
    _path
  let p = []
  _list.forEach((key, index) => {
    if(index === 0) {
      _data = data[key]
      p.push(key)
    } else {
      _path = node.path[index-1]
      p.push(_path)
      _data = _data[_path][key]
      p.push(key)
    }
  })
  return {
    path: p,
    data: _data
  }
}
function parseModel(node) {
  if(node.getAttribute('data-model')) {
    const modelName = node.getAttribute('data-model')
    const _data = parseData(modelName, node)
    if(node.tagName === 'INPUT') {
      node.value = _data.data
    } else {
      node.innerText = _data.data
    }
  }
}
function parseClass(node) {
  if(node.getAttribute('data-class')) {
    const className = node.getAttribute('data-class')
    const _data = parseData(className, node)
    if(!node.classList.contains(_data.data)) {
      node.classList.add(_data.data)
    }
  }
}

Next, we parse the list. When we encounter the list, we should first recursively find out the structure of the list items.


parseListItem(node) {
  let target
  !function getItem(node) {
    for(let i = 0; i < node.children.length; i++) {
      const _thisNode = node.children[i]
      if(node.path) {
        _thisNode.path = node.path.slice()
      }
      parseEvent(_thisNode)
      parseClass(_thisNode)
      parseModel(_thisNode)
      if(_thisNode.getAttribute('data-list-item')) {
        target = _thisNode
      } else {
        getItem(_thisNode)
      } 
    }
  }(node)
  return target
}

Then use this list item to copy a certain number of list items on demand and fill in the data.


function parseList(node) {
  const _item = parseListItem(node)
  const _list = node.getAttribute('data-list')
  const _listData = parseData(_list, node)
  _listData.data.forEach((_dataItem, index) => {
    const _copyItem = _item.cloneNode(true)
    if(node.path) {
      _copyItem.path = node.path.slice()
    }
    if(!_copyItem.path) {
      _copyItem.path = []
    }
    _copyItem.path.push(index)
    scan(_copyItem)
    node.insertBefore(_copyItem, _item)
  })
  node.removeChild(_item)
}

In this way, we have finished the rendering of the template. The scan function scans the template to render the template.
(In an interlude, this article is reproduced from: https://lovin0730.github.io/2...

Analytical data

After parsing the template, we will study how to parse the data. Here I use the method of hijacking the data.

Hijacking of Ordinary Objects

How to hijack data? Generally, data hijacking is done by Object.defineProperty method. Let's first look at a small example:


var obj = {
  name: 'mi'
}
function observe(obj, key) {
  let old = obj[key]
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function() {
      return old
    },
    set: function(now) {
      if(now !== old) {
        console.log(`${old} ---> ${now}`)
        old = now
      }
    }
  })
}
observe(obj, 'name')
obj.name = 'mirone'
//Output results:
//"mi ---> mirone"

So we hijacked the data through object.defineProperty. If we want to customize the operation when hijacking the data, we just need to add a callback function parameter.


function observer(obj, k, callback) {
  let old = obj[k]  
  Object.defineProperty(obj, k, {
    enumerable: true,
    configurable: true,
    get: function() {
      return old
    },
    set: function(now) {
      if(now !== old) {
        callback(old, now)
      }
      old = now
    }
  })
}

Hijacking of nested objects

For objects in objects, we need to do one more step, using recursion to hijack objects in objects:


//Implement an observeAllKey function that hijacks all attributes of the object
function observeAllKey(obj, callback) {
  Object.keys(obj).forEach(function(key){
    observer(obj, key, callback)
  })
}
function observer(obj, k, callback) {
  let old = obj[k]
  if (old.toString() === '[object Object]') {
    observeAllKey(old, callback)
  } else {
    //... Same as before, omitted
  }
}

Hijacking of Arrays in Objects

For arrays in objects, we use the method of overwriting the prototype of the array to hijack it.


function observeArray(arr, callback) {
  const oam = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
  const arrayProto = Array.prototype
  const hackProto = Object.create(Array.prototype)
  oam.forEach(function(method){
    Object.defineProperty(hackProto, method, {
      writable: true,
      enumerable: true,
      configurable: true,
      value: function(...arg) {
        let old = arr.slice()
        let now = arrayProto[method].call(this, ...arg)
        callback(old, this, ...arg)
        return now
      },
    })
  })
  arr.__proto__ = hackProto
}

After writing the function of the hijacking array, add it to the main function:


function observer(obj, k, callback) {
  let old = obj[k]
  if(Object.prototype.toString.call(old) === '[object Array]') {
    observeArray(old, callback)
  } else if (old.toString() === '[object Object]') {
    observeAllKey(old, callback)
  } else {
    //...
  }
}

Processing path parameters

All of our methods used to face a single key value. Recall that our template has many paths such as todos:todo:member. We should allow an array of paths to be passed in to listen for the specified object data according to the path array.


function observePath(obj, path, callback) {
  let _path = obj
  let _key
  path.forEach((p, index) => {
    if(parseInt(p) === p) {
      p = parseInt(p)
    }
    if(index < path.length - 1) {
      _path = _path[p]
    } else {
      _key = p
    }
  })
  observer(_path, _key, callback)
}

Then add it to the main function:


function observer(obj, k, callback) {
  if(Object.prototype.toString.call(k) === '[object Array]') {
    observePath(obj, k, callback)
  } else {
    let old = obj[k]
    if(Object.prototype.toString.call(old) === '[object Array]') {
      observeArray(old, callback)
    } else if (old.toString() === '[object Object]') {
      observeAllKey(old, callback)
    } else {
      //...
    }
  }
}

In this way, we have completed the listening function.

Implementation of multi-to-one monitoring

It is possible to bind more than one node of a data structure. Sometimes we need to notify all nodes when the modification is completed. Then we need a separate module to notify all nodes. We call it Register module, which is responsible for registering one or more callback functions for different modules.

Implementation of Monitor

class Register {
  constructor() {
    //Store all callback objects. The callback objects consist of three keys: obj, key and fn, where FN should be an array with all callback functions to be executed when changes occur.
    this.routes = []
  }
  //Add a callback
  regist(obj, k, fn) {
    const _i = this.routes.find(function(el) {
      if((el.key === k || el.key.toString() === k.toString()) 
        && Object.is(el.obj, obj)) {
        return el
      }
    })
    if(_i) {
      //If an object composed of the obj and key already exists
      _i.fn.push(fn)
    } else {
      //If it does not exist yet
      this.routes.push({
        obj: obj,
        key: k,
        fn: [fn]
      })
    }
  }
  //Call at the end of parsing, bind all callbacks
  build() {
    this.routes.forEach((route) => {
      observer(route.obj, route.key, route.fn)
    })
  }
}

Modification of bserver module

Since a key may now correspond to multiple callback operations, the observer needs to be modified:


function observer(obj, k, callback) {
  //The same as before.
  if(now !== old) {
    callback.forEach((fn) => {
      fn(old, now)
    })
  }
}
function observerArray(arr, callback) {
  //The same as before.
  //Replace the original callback (old, this,... Arg) with
  callback.forEach((fn) => {
    fn(old, this, ...arg)
  })
}

Binding Templates and Data

Now, we need to add data monitoring in the parsing process. Do you remember the previous parse series functions?


const register = new Register()
function parseModel(node) {
  if(node.getAttribute('data-model')) {
    //The logic is unchanged before...
    register.regist(data, _data.path, function(old, now) {
      if(node.tagName === 'INPUT') {
        node.value = now
      } else {
        node.innerText = now
      }
      //Add console to facilitate debugging
      console.log(`${old} ---> ${now}`)
    })
  }
}
function parseClass(node) {
  if(node.getAttribute('data-class')) {
    //...
    register.regist(data, _data.path, function(old, now) {
      node.classList.remove(old)
      node.classList.add(now)
      console.log(`${old} ---> ${now}`)
    })
  }
}
//When the list changes, the current list is reproduced directly for simplicity
function parseList(node) {
  //...
  register.regist(data, _listData.path, () => {
    while(node.firstChild) {
      node.removeChild(node.firstChild)
    }
    const _listData = parseData(_list, node)
    node.appendChild(_item)
    _listData.data.forEach((_dataItem, index) => {
      const _copyItem = _item.cloneNode(true)
      if(node.path) {
        _copyItem.path = node.path.slice()
      }
      if(!_copyItem.path) {
        _copyItem.path = []
      }
      _copyItem.path.push(index)
      scan(_copyItem)
      node.insertBefore(_copyItem, _item)
    })
    node.removeChild(_item)
  })
}
//Bind all events when template parsing is over
register.build()

So far we have basically completed a simple MVVM, and then I did a little bit of detail optimization, the source code placed in
My Gist Up. You can also go to this tutorial. JSBin View Effect.

Posted by feckless on Sat, 13 Apr 2019 11:03:32 -0700