从零到一编写MVVM

2,034 阅读3分钟

简介

公司H5页面较多,为了开发效率,通过查阅资料和angular源码,就写了这个小框架,这个只适用于小项目,运行效率和速度上还存在这一些问题,只能做到全量渲染,如果有时间,可以不断的完善它。


分析

它关键点就 Object.defineProperty 在这个方法,通过  get set  来达到数据变更更新视图。

Object.defineProperty(data, key, {
    get: () => {
        return data[key]
    },
    set: (val) => {
        data[key] = val
    }
})

代理数组方法,来达到更新的目的。

defValue(obj, key, val, enumerable) {
    Object.defineProperty(obj, key, {
        value: val,
        enumerable: !!enumerable,
        configurable: true,
        writable: true
    })
}

let arrayProto = Array.prototype;
let arrayMethods = Object.create(arrayProto);
[
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
].forEach(method => {
    let original = arrayMethods[method];
    this.defValue(arrayMethods, method,  function() {
        let result = original.apply(this, arguments);
        return result;
    })
})


模板编译

这边采用的是angular5+的模板方式。

用到了两个比较关键的函数 with、eval ,这两个函数的运行速度很慢,暂时想不出怎么去解析表达式,目前正在看angular的源码,看能不能发现更牛的黑科技,来提升这个小框架的运行速度和灵活性。  

export class Compile {
    constructor(ref, value, dep) {
        this.vm = value;
        this.ref = ref;
        this.dep = dep;
        this.ref.style.display = 'none';
        this.compileElement(this.ref);
        this.ref.style.display = 'block';
    }

    ref;
    vm;
    dep;

    eventReg = /\((.*)\)/;
    attrReg = /\[(.*)\]/;
    valueReg = /\{\{((?:.|\n)+?)\}\}/;

    compileElement(ref, vm = this.vm) {
        let childNodes = ref.childNodes;
        if (!childNodes.length) return;
        Array.from(childNodes).every(node => {
            return this.compileNode(node, vm);
        })
    }

    compileNode(node, vm = this.vm) {
        let text = node.textContent;
        if (node.nodeType === 1) {
            Array.from(node.attributes).every(attr => {
                //事件
                if (this.eventReg.test(attr.nodeName)) {
                    this.compileEvent(node, attr, vm)
                }
                //属性
                if (this.attrReg.test(attr.nodeName)) {
                    this.compileAttr(node, attr, vm);

                    this.dep.add(() => {
                        this.compileAttr(node, attr, vm)
                    })
                }

                //模板 *if
                if (attr.nodeName === '*if') {
                    this.compileIf(node, attr, vm);
                    this.dep.add(() => {
                        this.compileIf(node, attr, vm)
                    })
                    node.removeAttribute(attr.nodeName)
                }

                //模板 *for
                if (attr.nodeName === '*for') {
                    let comment = document.createComment(attr.nodeValue)
                    comment.$node = node;
                    node.parentNode.insertBefore(comment, node);
                    node.parentNode.removeChild(node);
                    let nodes = this.compileFor(comment, attr);
                    this.dep.add(() => {
                        this.compileFor(comment, attr, nodes);
                    })
                }
                return true;
            })
        }

        //绑值表达式 {{}} /\s*(\.)\s*/
        if (node.nodeType === 3 && this.valueReg.test(text)) {
            node.$textContent = node.textContent.replace(/\s*(\.)\s*/, '.');
            this.compileText(node, vm);
            this.dep.add(() => {
                this.compileText(node, vm)
            })
        }
        if (node.childNodes && node.childNodes.length && !~Array.from(node.attributes).map(attr => attr.nodeName).indexOf('*for')) {
            this.compileElement(node, vm);
        }
        return true;
    }

    getForFun(exg) {
        let exgs = exg.split(/;/);
        let vs;
        let is = undefined;
        if (exgs instanceof Array && exgs.length) {
            vs = exgs[0].match(/let\s+(.*)\s+of\s+(.*)/);
            let index = exgs[1].match(/let\s+(.*)\s?=\s?index/);
            if (index instanceof Array && index.length) {
                is = index[1].trim();
            }
        }
        return new Function('vm', `
            return function (fn) {
                for (let ${vs[1]} of vm.${vs[2]}){
                    fn && fn(${vs[1]}, vm.${vs[2]}.indexOf(${vs[1]}), vm, '${vs[1]}', '${is}')
                }
            }
        `)
    }

    compileFor(comment, attr, arr = []) {
        let node = comment.$node;
        if (arr instanceof Array && arr.length) {
            arr.every(n => {
                comment.parentNode.removeChild(n);
                return true;
            });
            arr.length = 0;
        }
        this.getForFun(attr.nodeValue)(this.vm)((a, b, c, d, e) => {
            let copy = node.cloneNode(true);
            copy.removeAttribute('*for');
            copy.style.removeProperty('display');
            if (!copy.getAttribute('style')) copy.removeAttribute('style');
            comment.parentNode.insertBefore(copy, comment);
            arr.push(copy);
            let data = Object.create(this.vm.__proto__);
            data[d] = a;
            data[e] = b;
            this.compileNode(copy, data);
        });
        return arr;
    }

    compileIf(node, attr, vm = this.vm) {
        let bo = !!this.compileFun(attr.nodeValue, vm);
        node.style.display = bo ? 'block' : 'none';
    }

    compileText(node, vm = this.vm) {
        let textContent = node.$textContent;
        let values = textContent.match(new RegExp(this.valueReg, 'ig'));
        values.every(va => {
            textContent.replace(va, value => {
                let t = value.match(this.valueReg);
                let val = this.isBooleanValue(this.compileFun(t[1], vm));
                textContent = textContent.replace(t[0], val)
            });
            return true;
        });
        node.textContent = textContent;
    }

    compileFun(exg, vm) {
        let fun = new Function('vm', `
            with(vm){return eval("${exg.replace(/'/g, '\\\'').replace(/"/g, '\\\"')}")}
        `);
        return fun(vm);
    }

    isBooleanValue(val) {
        switch (val) {
            case true:
                return String(true);
            case false:
                return String(false);
            case null:
                return String();
            case void 0:
                return String();
            default:
                return String(val)
        }
    }

    compileEvent(node, attr, vm = this.vm) {
        let event = attr.nodeName.match(this.eventReg)[1];
        switch (event) {
            case 'model':
                if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
                    switch (node.type) {
                        case 'text':
                            node.oninput = (event) => {
                                this.compileFun(`${attr.nodeValue}='${event.target.value}'`, vm)
                            };
                            break;
                        case 'textarea':
                            node.oninput = (event) => {
                                this.compileFun(`${attr.nodeValue}='${event.target.value}'`, vm)
                            };
                            break;
                        case 'checkbox':
                            node.onchange = (event) => {
                                this.compileFun(`${attr.nodeValue}=${event.target.checked}`, vm)
                            };
                            break;
                        case 'radio':
                            node.onchange = (event) => {
                                this.compileFun(`${attr.nodeValue}='${event.target.value}'`, vm)
                            };
                            break;
                    }
                }
                break;
            default:
                node[`on${event}`] = (event) => {
                    vm.__proto__.$event = event;
                    this.compileFun(attr.nodeValue, vm);
                    Reflect.deleteProperty(vm.__proto__, '$event');
                };
        }
        node.removeAttribute(attr.nodeName)
    }
    compileAttr(node, attr, vm = this.vm) {
        let event = attr.nodeName.match(this.attrReg)[1];
        switch (event) {
            case '(model)':
            case 'model':
                if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
                    switch (node.type) {
                        case 'text':
                        case 'textarea':
                            node.value = this.compileFun(attr.nodeValue, vm);
                            break;
                        case 'checkbox':
                            node.checked = !!this.compileFun(attr.nodeValue, vm);
                            break;
                        case 'radio':
                            if (node.value === String(this.compileFun(attr.nodeValue, vm))) {
                                node.checked = true;
                            }
                            break;
                    }
                }
                break;
            case 'value':
                if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
                    break;
                }
            default:
                let attrs = event.split(/\./);
                let attrValue = this.compileFun(attr.nodeValue, vm);
                if (attrs[0] in node && attrs.length === 1) {
                    node[attrs[0]] = attrValue;
                    break;
                }
                if (attrs.length >= 2) {
                    switch (attrs[0]) {
                        case 'attr':
                            node.setAttribute(attrs[1], attrValue);
                            break;
                        case 'class':
                            if (!!attrValue) {
                                node.classList.add(attrs[1]);
                            } else {
                                node.classList.remove(attrs[1]);
                            }
                            break;
                        case 'style':
                            let val = attrs[2] ? (attrValue ? (attrValue + attrs[2]) : '') : (attrValue || '');
                            if (val) {
                                node.style[attrs[1]] = val;
                            } else {
                                node.style.removeProperty(attrs[1])
                            }
                            break;
                    }
                }
        }

        node.removeAttribute(attr.nodeName)
    }
}

  1. 支持所有的dom事件,比如 (click)等。
  2. 支持属性的值绑定,[attr.xxx]、[class.xxx]、[style.xxx]等。
  3. 支持所有的表达式,比如三元等
  4. 支持双向绑定, [(model)]  和angular的有点不同,用法一样,这样写的原因在于属性的key所有的字母会转为小写,这个比较坑。比如这个 [innerHTML]  也无法使用,有空了再去解决。
  5. 支持dom事件传递当前event对象,比如 (click)="test($event)" 。
        

订阅与发布

在编译的过程中,将需要变更的dom通过订阅的方式保存起来,数据变更后通过发布来达到视图的更新

class Dep {
    constructor() {
    }

    subs = [];

    //添加订阅
    add(sub) {
        this.subs.unshift(sub);
    }

    remove(sub) {
        let index = this.subs.indexOf(sub);
        if (index !== -1) {
            this.subs.splice(index, 1);
        }
    }

    //更新
    notify() {
        this.subs.forEach(sub => {
            if (sub instanceof Function) sub();
        });
    }
}

可以看出,更新的方式是全量更新。

这边再需要一个类将这几个类关联起来

export class MVVM {
    constructor(id, value) {
        if (!id) throw `dom节点不能为空`;
        if (!value) throw `值不能为空`;
        this.vm = value;
        this.ref = id;
        this.dep = new Dep();
        if (!(this.ref instanceof Element)) {
            this.ref = window.document.querySelector(`${this.ref}`)
        }

        /**
         * 解析
         */
        new Compile(this.ref, this.vm, this.dep);


        /**
         * 值变更检测
         */
        this.def(this.vm)
    }

    vm;
    ref;
    dep;

    defValue(obj, key, val, enumerable) {
        Object.defineProperty(obj, key, {
            value: val,
            enumerable: !!enumerable,
            configurable: true,
            writable: true
        })
    }

    copyAugment(target, src, keys) {
        for (let i = 0, l = keys.length; i < l; i++) {
            let key = keys[i];
            this.defValue(target, key, src[key]);
        }
    }

    def(data) {
        if (!data || typeof data !== 'object') {
            return;
        }

        if (data instanceof Array) {
            let arrayProto = Array.prototype;
            let arrayMethods = Object.create(arrayProto);
            [
                'push',
                'pop',
                'shift',
                'unshift',
                'splice',
                'sort',
                'reverse'
            ].forEach(method => {
                let original = arrayMethods[method];
                let that = this;
                this.defValue(arrayMethods, method,  function() {
                    let result = original.apply(this, arguments);
                    that.dep.notify();
                    return result;
                })
            })
            this.copyAugment(data, arrayMethods, Object.getOwnPropertyNames(arrayMethods))
            Object.keys(data).forEach(key => {
                this.def(data[key]);
                data[`_${key}`] = data[key];
                Object.defineProperty(data, key, {
                    get: () => {
                        return data[`_${key}`]
                    },
                    set: (val) => {
                        this.def(val);
                        data[`_${key}`] = val;
                        this.dep.notify()
                    }
                })
            })
        } else {
            Object.keys(data).forEach(key => {
                this.def(data[key]);
                data[`_${key}`] = data[key];
                Object.defineProperty(data, key, {
                    get: () => {
                        return data[`_${key}`]
                    },
                    set: (val) => {
                        this.def(val);
                        data[`_${key}`] = val;
                        this.dep.notify()
                    }
                })
            })
        }
    }
}

写到这,算是完成了,再写个测试用例。

测试用例

class Test {
    constructor(id) {
        this.a = 1;
        this.b = 2;
        this.list = [
            {id: 1, name: '一'},
            {id: 2, name: '二'},
            {id: 3, name: '三'},
            {id: 4, name: '四'},
            {id: 5, name: '五'},
        ];
        new MVVM(id, this);
    }

    test(event,data){
        console.info(event);
    }

    bo(data){
        return data;
    }
}

new Test("#body");
<p [attr.data-id]="a" [style.width.px]="a" [class.test]="bo(false)" [style.z-index]="a" (click)="test($event.target,a)"></p>
<p>{{a?'1111':Math.random() + Math.abs(a-200) + 'a'}}</p>
<p>{{ a + b }} {{ a * b }}</p>
<p *for="let i of list;let index = index;">
    <span>{{index}}</span>
    <a href="javascript:void 0" (click)="test($event,i)">{{i}}</a>
</p>
<p *if="e">*if</p>
<input [(model)]="a" type="text">
<input type="checkbox" [(model)]="b">
<input type="radio" value="1" name="radio1" [(model)]="a">
<input type="radio" value="2" name="radio1" [(model)]="a">
<input type="radio" value="3" name="radio1" [(model)]="a">
<input type="radio" value="4" name="radio1" [(model)]="a">


这个小框架也就能完成简单繁琐的任务,建议不要在大型项目中使用,写H5页面搓搓有余的,还是有些不足的地方,循环模板 *for  内部不能使用当前环境下(即this)的方法,后续有空修复。如果有不足的地方欢迎留言。