This article was originally posted on my blog, please give credit to the author.

This article will implement a simple MVVM with 200 + lines of code to explore the secrets of MVVM. You can start by clicking the JS Bin in this article to see the effect. The code uses ES6, so you may need to transcode.

What is MVVM?

MVVM is a program architecture design. If you break it down, it should be model-view-viewModel.

Model

Model refers to the data layer, pure data. To the front end, it tends to be a simple object. Such as:

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

The data layer is the data we need to render and present to the user. The data layer itself is mutable. The data layer should not be responsible for logical operations and calculations.

View

View refers to the View layer, which is the part that is presented directly to the user, or simply, the HTML for the front end. For example, the data layer above, its corresponding view layer might be:


      

name: mirone

age: 20

  • singleDogA
  • singleDogB

notSingleDog

  • fff
  • sox
Copy the code

Of course, the view layer is mutable, and you can add anything you want to it. This doesn’t change the data layer, just the way the view layer renders the data. The view layer should be completely separate from the data layer.

ViewModel

Since the view layer should be separated from the data layer, we need to design a structure that connects them. When we make changes to the Model, the ViewModel automatically synchronizes the changes to the View layer. Also when we modify the View, the Model is automatically modified by the ViewModel.

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

The principle of MVVM

differences

Different frameworks have different implementations of MVVM.

The data was hijacked

The implementation of Vue hijacks the data (Model). When the data changes, the data will trigger the methods bound at the time of hijacks to update the view.

Dirty inspection mechanism

Angular implementation: When an event (such as input) occurs, Angular checks whether the new data structure and the previous data structure have changed to update the view.

Publish and subscribe model

Knockout implements a publication subscriber that is bound to the corresponding view node during parsing and to the publisher on the data. When the data is modified, the publisher is launched and the view receives the corresponding update.

The same

But there are a lot of similarities. They all have three steps:

  • Parsing the template

  • Analytical data

  • Bind templates and data

Parsing the template

What is a template? We can take a look at the main MVVM templates:

Source: author: mirone links: https://zhuanlan.zhihu.com/p/24451202 zhihu copyright owned by the author. Commercial reprint please contact the author for authorization, non-commercial reprint please indicate the source.
      

  • Rank: {{item.rank}}
  • {{phone.name}}

    {{phone.snippet}}

Copy the code

As you can see, they all define their own template keywords. The function of this module is to parse the template based on these keywords and map the template to the desired data structure.

Analytical data

The data in the Model is parsed by hijacking or binding publishers. Data parsers are written with the VM in mind, but parsing data in any case requires only one thing: defining the objects to be notified of when the data changes. Data parsing must be consistent, and interfaces exposed after data parsing must be consistent.

Bind templates and data

This section defines how data structures are bound to templates, known as “bidirectional binding.” After binding, the application can update the view automatically when we operate directly on the data. Data and templates often have a many-to-many relationship, and different templates often update data differently. For example, change the text node of the label, or change the className of the label.

Start implementing MVVM

After some analysis, let’s start implementing MVVM.

Expect 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'
        }
      ]
    }
  ]
}
Copy the code

I can write the corresponding template:


      

Copy the code

Then call:

new Parser('#root', data)
Copy the code

The MVVM binding is complete, and you can then directly manipulate the Data object to make changes to the View.

Parsing the template

Parsing a template is actually a tree traversal.

traverse

As we all know, the DOM is a tree structure, which is why it is called a “DOM tree.” For tree traversal, it is easy to complete a depth-first traversal by recursion, as shown in 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)
    }
  }
}
Copy the code

This function iterates over a DOM node, printing the resulting nodes in turn.

Walk through different structures

Knowing how to traverse a DOM tree, how do we get the DOM tree we need to analyze? According to the previous idea, we need the following signs:

  • Data-model — Used to replace text nodes in the DOM with explicit content

  • Data-class — Used to replace the className of the DOM with specified content

  • Data-list – Used to indicate that a list will appear next, and the list is the formulation structure

  • Data-list-item — Identifies the internal structure of a list item

  • Data-event — Used to specify events for DOM node bindings

Data-model, data-class, and data-Event should be of the same type, and they all affect only the current node. Data-list and data-item should be considered separately as lists. So we can iterate 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: resolves Model nodes} function parsecass (node) {//TODO: resolves className} function parseEvent(node) {//TODO: resolves events} function ParseList (node) {//TODO: parseList}Copy the code

So we have the general framework of the traverser

Different structure processing methods

ParseModel, parsecass, and parseEvent are handled similarly, the only thing noteworthy being the handling of nested elements. Recall our template design:



      
Copy the code

Todos: Date is actually a great way to parse the template because it shows where the current data is in the Model structure.

{typeWriter: {type: 'input', const eventList = {typeWriter: {type: 'input', // Fn: Function () {// The event handler, Function parseEvent(node) {if(node.getAttribute('data-event')) {const eventName = node.getAttribute('data-event') node.addEventListener(eventList[eventName].type, EventList [eventName].fn.bind(node))}} eventList[eventName].fn.bind(node))}} 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) } } }Copy the code

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

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 }Copy the code

This list item is then used to copy out as many list items as needed and populate 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) }Copy the code

This completes the template rendering, and the scan function scans the template to render the template

Analytical data

After parsing the template, it’s time to figure out how to parse the data, and here I’m hijacking the data.

Hijacking of ordinary objects

How to hijack data? Usually data hijacking is done using the Object.defineProperty method. Let’s start with 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) {the console log (` ${old} - > ${now} `) old = now}}}}) observe (obj, 'name') obj. Name = 'mirone' / / output:  //"mi ---> mirone"Copy the code

So we hijacked data via Object.defineProperty. If we wanted to customize what happens when we hijacked data, we could just add a callback function argument:

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 } }) }Copy the code

Hijacking of nested objects

For objects within objects, we need to do one more step, using recursively hijack objects within objects:

// Implement an observeAllKey function, Function observeAllKey(obj, callback) {object.keys (obj). ForEach (function(key){observer(obj, key, callback) callback) }) } function observer(obj, k, callback) { let old = obj[k] if (old.toString() === '[object Object]') { observeAllKey(old, callback) } else { //... As before, omit}}Copy the code

Object to hijack an array

For an array of objects, we hijack it using a method that overrides the array’s Prototype

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 me = this let old = arr.slice() let now = arrayProto[method].call(me, ... arg) callback(old, me, ... arg) return now }, }) }) arr.__proto__ = hackProto }Copy the code

After writing the function that hijacks an 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 { //... }}Copy the code

Processing path parameters

All of our methods have dealt with a single key. If you think back to our template, there are many paths like todos:todo:member. We should allow you to pass in an array of paths to listen for specified object data

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) }Copy the code

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 { //... }}}Copy the code

This completes our listener function.

Bind templates and data

Now that we’re ready to add data monitoring during parsing, remember the parse family of functions?

function parseModel(node) { if(node.getAttribute('data-model')) { //... Observer (data, _data.path, function(old, Now) {if(node.tagName === 'INPUT') {node.value = now} else {node.innertext = now} // Add console for debugging console.log(`${old} ---> ${now}`) }) } } function parseClass(node) { if(node.getAttribute('data-class')) { //... observer(data, _data.path, function(old, Remove (old) node.classlist.add (now) console.log(' ${old} --> ${now} ')})}} Function parseList(node) {//... observer(data, _listData.path, () => { while(node.firstChild) { node.removeChild(node.firstChild) } const _listData = parseData(_list, node) _listData.data.forEach((_dataItem, index) => { node.appendChild(_item) 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) }) }Copy the code

At this point we basically have a simple MVVM, after which I optimized the source code in a little bit of detail for my Gist. You can also go to JSBin for this tutorial. Level is limited, welcome to ridicule.

Thank you for reading, and please give me a like if it helps.


Did this article help you? Welcome to join the front End learning Group wechat group: