💯利用Vue原理实现一个mini版的MVVM框架

💯利用Vue原理实现一个mini版的MVVM框架

更新:最近我开发了一个简化redux开发的框架booto,大家有兴趣可以看参观下

https://github.com/linweiwei123/bootogithub.com

主要介绍

本文将利用Vue的核心原理,如模板解析、Data-binding、Virtual-DOM等知识来实现一个“主要原理五脏俱全的Mue.js”。不吹牛不装逼,用浅显易懂的人话分模块讲解其中奥秘,顺便“含沙射影”的说Vue的原理。不贴大段无用代码,用清晰的思路给读者分享整个MVVM框架Mue.js的实现过程。

阅读完你将获得:

  • 知道Vue的核心思想
  • 对模板解析、Data-binding、Virtual-DOM会有比较深的了解,不再一脸懵逼
  • 你也能够实现一个简单版的MVVM框架😎

我实现一个这样框架的目的也是想亲自感受下Vue/React的奥秘所在。最终觉得“惊艳魔法的背后,实际上都是一些普通的代码”,也有一些比较精彩的代码技巧值得学习。

先来看下最终的实现效果,如下图



本框架命名为Mue,指令都以m-开头。上面效果对应的代码如下:

Javascript代码:

import Mue from '../core/mue';

var template =
    '<div>' +
        '<div>' +
            '<h1>{{ title }}</h1>' +
            '<h2>{{ info.desc }}</h2>' +
        '</div>' +
        '<h2>{{count}}</h2>' +
        '<loading m-if="loading">loading</loading>' +
        '<p class="el_input">输入的内容是:{{inputText}}</p>' +
        '<input type="text" m-model="inputText" />' +
    '</div>';

new Mue({
    el: '#app',
    template,
    data: {
        title: '测试文字',
        info: {
            desc: '描述文字'
        },
        count: 1,
        loading: true,
        inputText: ''
    },
    mounted(){
        setTimeout(()=>{
            this.data.title = 'title新值';
            this.data.info.desc = 'desc新值';
            this.data.loading = false;
        },1500)

        setInterval(()=>{
            this.data.count++
        },100)
    }
});

Html代码:

<body>
    <div id="app"></div>  
    <script src="../dist/mue.js"></script> // 编译后的JS代码文件
</body>

可以看出实际上跟Vue的用法非常相似(因为本身就是mini版的Vue)。功能很简单,只实现了数据驱动、m-if、m-model等指令。代码都在我的GitHub这里。接下来将为你揭秘其中的原理。

实现原理

整体流程



上面是Mue的整体流程。与Vue的非常相似(可以跟github上的一篇Vue原理的文章对比下),核心的技术如上面提到的都已经用上了。但是Mue跟Vue比起来只是一个非常小的玩具,实现较为粗暴,读者需正确看待。

与Vue类似,Mue的实现原理可以分为三个重要部分

1、Data-binding,也称为响应式。是MVVM框架的重要技术。实际上是通过监听数据变化,来触发视图View更新。可以通过数据劫持(Object.defineProperty定义getter、setter方法)、发布订阅模式、代理模式(ES6有个Proxy,es5的polyfills也行)等来实现,本文的Mue与Vue都是第一种方式来实现。

2、模板解析,由于利用了虚拟DOM技术,必须把HTML模板(后文统称template)进行解析转换成Node树(代表HTML的一个结构化数据,JavaScript中的对象),Mue中也用到了m-if、m-modal等指令、{{}}表达式等,所以也必须通过对template的解析,进行处理。有点类似JSX语法的模板转换成hyperapp函数,但是多了对指令、组件的特殊处理。

3、Virtual-DOM技术,该技术就是把HTML模板转换成结构化数据,用简化的Node对象替代复杂的真实DOM Node Element对象,利用diff算法对结构化的新旧nodes对象进行比对,以最低的算法复杂度找出需要修改的Node节点,然后根据Node 数据create、update、remove DOM Element。从而完成View的渲染与更新。实际上Virtual-DOM是为了提升性能而存在。如果只想要实现一个MVVM框架,这个不是必须的。

如果你看完上面的内容还是一脸懵逼的话,不要紧~。我们讲一个复杂的东西一定是先有宏观,再深入细节,最后回过头来再一遍,总结以下,就能够理解了。这就像儿时老师教导的一样:“看一本书先看目录,把书读薄再读厚最后再读薄,就大概都能理解了”。看总体的说,Mue实际上就是利用模板解析生成渲染函数,渲染函数根据data生成真实DOM,Data-binding监听数据变化触发渲染函数再来一遍。这样就完成了整个循环。

主体代码

export default function Mue(options){
    this._init(options); // 初始化Mue对象
}

watchData(Mue); // 给Mue增加data-binding
patchInit(Mue);    // 给Mue增加patch方法,create、update、remove Elment的方法

Mue.prototype._init = function(options){
    const { el, template, data, mounted, methods } = options;
    this.el = el;
    this.data = data;

    // template 解析成nodeObject(非vdom的那个nodeObject)
    const parsedNodes = parse(template);

    // 解析生成的nodeObject生成函数字符串
    let compileStr = 'return ' + buildRenderStr(parsedNodes);

    // 用函数字符串生成compiler函数
    this.compiler = buildCompiler(compileStr);

    // 用compiler生成VDOM,并调用patch方法生成DOM
    this.render();

    // 挂载执行的回调函数
    mounted.call(this);

    // 监听data变化
    this.defineReactive();

};

主体代码逻辑非常清晰,与整体流程图呼应。

模板解析

先从模板解析讲起,比较符合思路。



template转AST

 const parsedNodes = parse(template);

解析HTML字符串转换成AST(词法树),下面是一个比较简单的例子。

模板,含有m-if指令、{{}}表达式



经过解析函数parse解析以后得到下面的AST数据结构

 {
  "type": 1,
  "tag": "div",
  "attrsList": [],
  "attrsMap": {},
  "children": [
    {
      "type": 1,
      "tag": "h2",
      "attrsList": [
        {
          "name": "m-if",
          "value": "count"
        }
      ],
      "attrsMap": {
        "m-if": "count"
      },
      "parent": "[Circular ~]",
      "children": [
        {
          "type": 2,
          "expression": "this._s(this.data.count)",
          "text": "{{count}}"
        }
      ]
    }
  ]
}

可以看出对HTML每个标签进行了分析提炼出了DOM Element的标签类型、标签名tagName、标签上的属性attributes、以及子元素children。这个数据结构与DOM结构是一一对应的,代表了DOM结构。这里的数据结构与vnodes树很像,但是他们是不同的,读完本文你会理解。现在先知道他们是不同的即可。

解析函数我们工作中不怎么会遇到,本文中的Mue的parse函数我也是copy别人的。可以在这里看源码。我们看下伪代码代码,大致了解下。

/**
 * Convert HTML string to AST.
 */
export default function parse (
    template,
    options
){
    // 节点栈
    const stack = []
    // 根节点,最终改回返回
    let root
    // 当前的父节点
    let currentParent

    parseHTML.parse(template, {
        // node 的开始
        start (tag, attrs, unary) {
            // unary 是否一元标签,如 <img/>

            const element = {
                type: 1,
                tag,
                // attrsList 数组形式
                attrsList: attrs,
                // attrsMap 对象形式,如 {id: 'app', 'm-text': 'xxx'}
                attrsMap: makeAttrsMap(attrs),
                parent: currentParent,
                children: []
            }

            // 处理 m-for ,生成 element.for, element.alias
            processFor(element)
            // 处理 m-if ,生成 element.if, element.else, element.elseif
            processIf(element)
            // 处理 m-once ,生成 element.once
            processOnce(element)
            // 处理 key ,生成 element.key
            processKey(element)

            // 处理属性
            // 第一,处理指令:m-bind m-on 以及其他指令的处理
            // 第二,处理普通的 html 属性,如 style class 等
            processAttrs(element)

            // tree management
            if (!root) {
                // 确定根节点
                root = element
            }
            if (currentParent) {
                // 当前有根节点
                currentParent.children.push(element)
                element.parent = currentParent
            }
            if (!unary) {
                // 不是一元标签(<img/> 等)
                currentParent = element
                stack.push(element)
            }
        },
        // node 的结束
        end () {
            // pop stack
            stack.length -= 1
            currentParent = stack[stack.length - 1]
        },
        // 字符
        chars (text) {
            const children = currentParent.children
            let expression
            // 处理字符
            expression = parseText(text)  // 如 '_s(price)+" 元"'
            children.push({
                type: 2,
                expression,
                text
            })
        }
    })
    return root
}

parseHTML操作字符串找出"<"、">"、"/>" 的字符的文字,用正则匹配的方法进行文本的截取找出type、tagName、attribute、attribute的value、文本等。形成node。node就是包含上面这些元素的对象。(tips:看了源码,需要很大的耐心和很溜的正则运用技能才能写出这样的解析代码,有时间有毅力的话也是可以写出来的)。

生成compiler函数

// 解析生成的nodeObject生成函数字符串
let compileStr = 'return ' + buildRenderStr(parsedNodes);

// 用函数字符串生成render函数
this.compiler = buildCompiler(compileStr);

生成了node树,我们需要像JSX一样,将node树替换成compiler函数字符串。

return this._h('div',{},[this._h('h2',{"m-if":"count"},[this._s(this.data.count)])])

通过new Function('str') 的方式生成函数。即

render = new Function('return this._h('div',{},[this._h('h2',{"m-if":"count"},[this._s(this.data.count)])])')

实际上,compiler函数就是

this._h('div',{},
        [this._h('h2',{"m-if":"count"},
                  [this._s(this.data.count)]
                 )
        ]
)

有没有发现跟DOM结构非常像,有没有跟React JSX转换后的代码非常相似?

同时,你一定注意到了_h, _s 两个函数。

_h = function(nodeName, attributes, children) {
    // 省略
    return node
}
_s = function(expression){
    return expression;
}

_h函数代表的是根据tag、attributes、children生成VNode的函数、_s函数代表表达式运行函数。

其中还有this.data.count,这个this指的就是Mue构造出来的对象,this.data 就是传入的data ,如下图所示

new Mue({
    el: '#app',
    template,
    data: {
        count: 1
    }
});

Mue.prototype._init = function(options){
    const { el, template, data, mounted, methods } = options;
    this.data = data;
    // 省略其他  
};

看到这里,可以看出compiler函数一旦运行,就是生成VNode树。模板解析的全部流程就分析完了。

具体的buildRenderStr函数逻辑也很简单,如下所示

function buildRenderStr(node){
    let tempStr = '';
    // 如果node是个dom节点
    if(node.type === 1){

        // 无子元素
        if(node.children.length === 0) {
            tempStr = `this._h('${node.tag}',${JSON.stringify(node.attrsMap)})`;
        }

        // 有子元素
        else {
            let children = node.children;
            let h_childs = [];
            for(let i = 0; i < children.length; i++){
                h_childs.push(buildRenderStr(children[i]));
            }
            h_childs = '[' + h_childs.join(',') + ']';
            tempStr = `this._h('${node.tag}',${JSON.stringify(node.attrsMap)},${h_childs})`;
        }
    }
    // 如果node是文字
    else if(node.type === 2){
        tempStr = node.expression ? node.expression : `'${node.text}'`;
    }
    return tempStr;
}

Virtual-DOM Diff运算后patch生成Real DOM



// 用compiler生成VDOM,并调用patch方法生成DOM
this.render();

// 挂载执行的回调函数
mounted.call(this);

这一步运行了compiler函数,并且调用了patch函数(patch函数会对oldVNodes与VNodes进行diff运行,更新需要修改的Element,当然,第一次oldVNodes为空则是直接生成),下面是render函数

Mue.prototype.render = function(){
    // render函数生成VDOM
    let vNodes = this.compiler();

    // VDOM 生成real DOM
    let container = document.querySelector(this.el);
    let rootElement = (container && container.children[0]) || null;
    let oldNode = rootElement && recycleElement(rootElement);
    this.patch(container, rootElement, oldNode, (oldNode = vNodes));
}

VNodes示例如下,与oldVnodes进行比对,从而找出差异部分进行更新(第一次render没有oldVNodes,直接就生成)。

{
  "nodeName": "div",
  "attributes": {},
  "children": [
    {
      "nodeName": "div",
      "attributes": {},
      "children": [
        {
          "nodeName": "h1",
          "attributes": {},
          "children": [
            "title新值"
          ]
        },
        {
          "nodeName": "h2",
          "attributes": {},
          "children": [
            "desc新值"
          ]
        }
      ]
    },
    {
      "nodeName": "h2",
      "attributes": {},
      "children": [
        1
      ]
    },
    {
      "nodeName": "p",
      "attributes": {
        "class": "el_input"
      },
      "children": [
        "输入的内容是:"
      ]
    },
    {
      "nodeName": "input",
      "attributes": {
        "type": "text",
        "m-model": "inputText"
      },
      "value": ""
    }
  ]
}

diff算法示意图如下:



我的diff算法(在patch函数中)非常粗暴简单,并且性能也很糟糕。大家只需了解即可。大家可以参考比较好的两个开源Virtual-DOM组件。


我的diff算法(在patch函数中)非常粗暴简单,并且性能也很糟糕。大家只需了解即可。大家可以参考比较好的两个开源Virtual-DOM组件。

github.com/Matt-Esch/vi

github.com/snabbdom/sna

还可以参考这篇实现一个virtual-dom的博文

实际上Vue的Virtual-DOM技术也是参考的snabbdom。开源的东西就是让别人参考的。没必要自己再去实现一遍证明自己很牛逼。今后Mue也只会参考其他方式进行优化,变成真正有效率的Virtual-DOM。

具体的Diff算法我就不折腾了。用简单的土话来描述就是“遍历VNodes,比较前后两次VNodes的差异,用了一个比较有效率的比较方法”。找出以后,用CreateElement、UpdateElement、RemoveElement操作真实DOM。

这里举一个CreateElement来说明,代码如下:

    Mue.prototype.createElement = function(node, isSvg) {
        let _mue = this;
        var element =
            typeof node === "string" || typeof node === "number"
                ? document.createTextNode(node)
                : (isSvg = isSvg || node.nodeName === "svg")
                ? document.createElementNS(
                    "http://www.w3.org/2000/svg",
                    node.nodeName
                )
                : document.createElement(node.nodeName)

        // 嵌套创建
        if(node.children instanceof Array){
            for (var i = 0; i < node.children.length; i++) {
                element.appendChild(
                    this.createElement(node.children[i])
                )
            }
        }

        // 如果是input标签
        if(node.nodeName === 'input'){
            element.value = node.value;
            element.addEventListener('input', function (e) {
                let expression = node.attributes["m-model"];
                let val = e.target.value;
                let str = `this.data.${expression}='${val}'`;
                (new Function(str)).call(_mue)
            })
        }

        return element
    }

这里我用了比较简单粗暴的方式,仅仅实现了普通标签、普通文本、input标签、m-model指令等。实际上Vue复杂的多。就是把VNode变成Element,代码也很简单,就不再赘述。

最后Element生成后,调用下传入的mounted回调函数,告诉业务代码组件已经挂到DOM上了,可以做事了。

Data-bindding



重头戏来了!

import { isType } from "../utils/index";

export function watchData(Mue){
    Mue.prototype.defineReactive = function(){

        let _mue = this;

        Object.keys(this.data).forEach(prop => {
           defineProp(this.data, prop, this.data[prop]);
        });

        function defineProp(data, prop, val) {
            if(isType(val, 'object')){
                Object.keys(val).forEach(_prop => {
                    defineProp(val, _prop, val[_prop]);
                });
            }
            else{
                Object.defineProperty(data, prop, {
                    get: function () {
                        return val;
                    },
                    set: function (newVal) {
                        val = newVal
                        _mue.reactiveCollection();
                    }
                })
            }
        }

    }

    Mue.prototype.reactiveCollection = function(){
        let _this = this;
        _this.render();
    }
}

这个已经妇孺皆知的技术,实际上没必要说太多。但是实际上这才是数据驱动框架的基础与点睛之笔,我还是很详细的说下。

JavaaScipt的Object.defineProperty可以给对象属性prop定义set、get。

Object.defineProperty(data, prop, {
      get: function () {
            console.log('调用了get了');
            return val;
      },
      set: function (newVal) {
            val = newVal
            console.log('发现重新赋值了');
      }
})

比如对象

data: {
   count: 1
}

当调用data.count时会运行get,当data.count = 2时,会触发set。Mue利用这个原理set的会触发reactiveCollection函数,进而触发render函数再来一遍。render函数中的compiler函数还记得吗?这里再贴一遍代码,如下所示。

this._h('div',{},
        [this._h('h2',{"m-if":"count"},
                  [this._s(this.data.count)]
                 )
        ]
)

上面已经对data进行了设值,如data.count = 2; 此时运行compile函数对 this.data.count进行重新运算。最终提现在DOM上的就是count的值修改了。至此,完成了视图的更新。完成了数据驱动视图变化。后续的data修改即重复此过程。

由此可见,模板解析只做了一次,后面的render则是监听数据变化不断运行的。diff算法保证了性能最优。还有m-if、m-model则是通过特殊处理;

m-if 在_h函数中觉得node是否存在。

   // 只考虑m-if、m-for 的情况
    directives.forEach(item => {
        if(item.key === 'm-if'){
            let propValue = new Function(`return this.data.${item.prop}`).call(this);
            isNeed = propValue ===  true ? true : false;
        }
        else if(item.key === 'm-model'){
            let propValue = new Function(`return this.data.${item.prop}`).call(this);
            node.value = propValue;
        }
    });

m-model则是在createElement中加事件监听,如果有变化触发this.data的值改变,来实现View-Model的改变。

element.addEventListener('input', function (e) {
    let expression = node.attributes["m-model"];
    let val = e.target.value;
    let str = `this.data.${expression}='${val}'`;
    (new Function(str)).call(_mue)
})

至此,整个MVVM框架的运行过程就结束了。回头看下整个流程,我们再把Mue读薄一次。



Template 解析生成compiler函数 => render运行compiler生成VNodes => Patch进行diff运算并生成与修改Real DOM,同时Data遍历进行双向绑定 => 监听到数据改变则激活Render根据Data新的值再运行一遍触发视图更新。


本文通过运用Vue核心原理实现一个Mue.js,可能存在许多不恰当的地方,还请读者积极批评指正!

还有更多的事件要做

上面就是Mue的整个流程。真的是非常简陋。还有很多事情需要再做:

  1. 如何实现m-for,这个还是需要再深入研究的
  2. Mue还不支持组件化,以及伴随组件的生命周期、组件通信
  3. diff算法性能有点差,新的DOM
  4. .mue文件格式的实现,方便书写模板
  5. 还需要支持更多标签
  6. 还有更多

感悟

实现一个MVVM框架不是很难,但是要做好需要很多条件

  • Javascript基础,开发过程遇到了很多工作中不常见的知识,原型、继承、new Function()、各种正则
  • 全面的知识,如算法,数据结构知识。框架中很多地方都用到了
  • 时间,你需要很多时间去投入,去专研。研究一些现有的优秀框架,学习他们的做法,集大成。
  • 毅力,初次写可能很乱,不断的颠覆自己,一版两版三版,总有一版让你梳理出较好的框架。

当然,是否真有必要去实现一个自己的框架?如果你的框架不具颠覆性,可能自己自娱自乐,因为“没有贡献的东西都没有价值”。

参考文献

本文参考了许多文档、开源项目,很多代码都是直接拉过来了。我只是搬运工。

github.com/jorgebucaran

github.com/wangfupeng19

github.com/snabbdom/sna

ustbhuangyi.github.io/v

github.com/youngwind/bl

编辑于 2019-07-25

文章被以下专栏收录

    关注前端前沿技术,探寻业界深邃思想。https://qianduan.group 欢迎微信/微博搜索『前端外刊评论』,关注我们。欢迎给本专栏投稿,原作译作不限,要求:质量高!如果愿意尝试从事前端技术相关的书籍的编写或翻译工作,请私信外刊君。

    GitHub开源项目更新精选,来自CTOLib码库!