使用proxy实现一个简单完整的MVVM库

前言

MVVM 是当前时代前端日常业务开发中的必备模式(相关框架如reactvueangular 等), 使用 MVVM 可以将开发者的精力更专注于业务上的逻辑,而不需要关心如何操作 dom。虽然现在都 9012 年了,mvvm 相关原理的介绍已经烂大街了,但出于学习基础知识的目的(使用 proxy 实现的 vue3.0 还在开发中), 在参考了之前 vue.js 的整体思路之后,自己动手实现了一个简易的通过 proxy 实现的 mvvm

本项目代码已经开源在github,项目正在持续完善中,欢迎交流学习,喜欢请点个 star 吧!

最终效果

1
2
3
4
5
6
7
<html>
<body>
<div id="app">
<div>{{title}}</div>
</div>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
import MVVM from '@fe_korey/mvvm';
new MVVM({
view: document.getElementById('app'),
model: {
title: 'hello mvvm!',
},
mounted() {
console.log('主程编译完成,欢迎使用MVVM!');
},
});

结构概览

  • Complier 模块实现解析、收集指令,并初始化视图
  • Observer 模块实现了数据的监听,包括添加订阅者和通知订阅者
  • Parser 模块实现解析指令,提供该指令的更新视图的更新方法
  • Watcher 模块实现建立指令与数据的关联
  • Dep 模块实现一个订阅中心,负责收集,触发数据模型各值的订阅列表

流程为:Complier收集编译好指令后,根据指令不同选择不同的Parser,根据ParserWatcher中订阅数据的变化并更新初始视图。Observer监听数据变化然后通知给 WatcherWatcher 再将变化结果通知给对应Parser里的 update 刷新函数进行视图的刷新。

mvvm.js整体流程图

模块详解

Complier

  • 将整个数据模型 data 传入Observer模块进行数据监听

    1
    this.$data = new Observer(option.model).getData();
  • 循环遍历整个 dom,对每个 dom 元素的所有指令进行扫描提取

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    function collectDir(element) {
    const children = element.childNodes;
    const childrenLen = children.length;

    for (let i = 0; i < childrenLen; i++) {
    const node = children[i];
    const nodeType = node.nodeType;

    if (nodeType !== 1 && nodeType !== 3) {
    continue;
    }
    if (hasDirective(node)) {
    this.$queue.push(node);
    }
    if (node.hasChildNodes() && !hasLateCompileChilds(node)) {
    collectDir(element);
    }
    }
    }
  • 对每个指令进行编译,选择对应的解析器Parser

    1
    const parser = this.selectParsers({ node, dirName, dirValue, cs: this });
  • 将得到的解析器Parser传入Watcher,并初始化该 dom 节点的视图

    1
    2
    const watcher = new Watcher(parser);
    parser.update({ newVal: watcher.value });
  • 所有指令解析完毕后,触发 MVVM 编译完成回调$mounted()

    1
    this.$mounted();
  • 使用文档碎片document.createDocumentFragment()来代替真实 dom 节点片段,待所有指令编译完成后,再将文档碎片追加回真实 dom 节点

    1
    2
    3
    4
    5
    6
    7
    8
    let child;
    const fragment = document.createDocumentFragment();
    while ((child = this.$element.firstChild)) {
    fragment.appendChild(child);
    }
    //解析完后
    this.$element.appendChild(fragment);
    delete $fragment;

Parser

  • Complier模块编译后的指令,选择不同听解析器解析,目前包括ClassParser,DisplayParser,ForParser,IfParser,StyleParser,TextParser,ModelParser,OnParser,OtherParser等解析模块。

    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
    switch (name) {
    case 'text':
    parser = new TextParser({ node, dirValue, cs });
    break;
    case 'style':
    parser = new StyleParser({ node, dirValue, cs });
    break;
    case 'class':
    parser = new ClassParser({ node, dirValue, cs });
    break;
    case 'for':
    parser = new ForParser({ node, dirValue, cs });
    break;
    case 'on':
    parser = new OnParser({ node, dirName, dirValue, cs });
    break;
    case 'display':
    parser = new DisplayParser({ node, dirName, dirValue, cs });
    break;
    case 'if':
    parser = new IfParser({ node, dirValue, cs });
    break;
    case 'model':
    parser = new ModelParser({ node, dirValue, cs });
    break;
    default:
    parser = new OtherParser({ node, dirName, dirValue, cs });
    }
  • 不同的解析器提供不同的视图刷新函数update(),通过update更新dom视图

    1
    2
    3
    4
    //text.js
    function update(newVal) {
    this.el.textContent = _toString(newVal);
    }
  • OnParser 解析事件绑定,与数据模型中的 methods字段对应

    1
    2
    3
    4
    //详见 https://github.com/zhaoky/mvvm/blob/master/src/core/parser/on.ts
    el.addEventListener(handlerType, (e) => {
    handlerFn(scope, e);
    });
  • ForParser 解析数组

    1
    //详见 https://github.com/zhaoky/mvvm/blob/master/src/core/parser/for.ts
  • ModelParser 解析双向绑定,目前支持input[text/password] & textarea,input[radio],input[checkbox],select四种情况的双向绑定,双绑原理:

    • 数据变化更新表单:跟其他指令更新视图一样,通过update方法触发更新表单的value

      1
      2
      3
      function update({ newVal }) {
      this.model.el.value = _toString(newVal);
      }
    • 表单变化更新数据:监听表单变化事件如input,change,在回调里set数据模型

      1
      2
      3
      this.model.el.addEventListener('input', (e) => {
      model.watcher.set(e.target.value);
      });

Observer

  • MVVM 模型中的核心,一般通过 Object.definePropertygetset 方法进行数据的监听,在 get 里添加订阅者,set 里通知订阅者更新视图。在本项目采用 Proxy 来实现数据监听,好处有三:
    • Proxy 可以直接监听对象而非属性
    • Proxy 可以直接监听数组的变化
    • Proxy 有多达 13 种拦截方法,查阅
      而劣势是兼容性问题,且无法通过 polyfill 磨平。查阅兼容性
  • 注意 Proxy 只会监听自身的每一个属性,如果属性是对象,则该对象不会被监听,所以需要递归监听
  • 设置监听后,返回一个 Proxy 替代原数据对象
1
2
3
4
5
6
7
8
9
10
11
12
var proxy = new Proxy(data, {
get: function (target, key, receiver) {
//如果满足条件则添加订阅者
dep.addDep(curWatcher);
return Reflect.get(target, key, receiver);
},
set: function (target, key, value, receiver) {
//如果满足条件则通知订阅者
dep.notfiy();
return Reflect.set(target, key, value, receiver);
},
});

Watcher

  • Complier 模块里对每一个解析后的 Parser 进行指令与数据模型直接的绑定,并触发 Observerget 监听,添加订阅者(Watcher

    1
    this._getter(this.parser.dirValue)(this.scope || this.parser.cs.$data);
  • 当数据模型变化时,就会触发 -> Observerset 监听 -> Depnotfiy 方法(通知订阅者的所有订阅列表) -> 执行订阅列表所有 Watcherupdate 方法 -> 执行对应 Parserupdate -> 完成更新视图

  • Watcher 里的 set 方法用于设置双向绑定值,注意访问层级

Dep

  • MVVM 的订阅中心,在这里收集数据模型的每个属性的订阅列表
  • 包含添加订阅者,通知订阅者等方法
  • 本质是一种发布/订阅模式
1
2
3
4
5
6
7
8
9
10
11
12
13
class Dep {
constructor() {
this.dependList = [];
}
addDep() {
this.dependList.push(dep);
}
notfiy() {
this.dependList.forEach((item) => {
item.update();
});
}
}

后记

目前该 mvvm 项目只实现了数据绑定视图更新的功能,通过这个简易轮子的实现,对 dom 操作,proxy发布订阅模式等若干基础知识都进行了再次理解,查漏补缺。同时欢迎大家一起探讨交流,后面会继续完善!

---- 本文结束,感谢您的阅读 ----