從零到一編寫MVVM

簡介

公司H5頁面較多,爲了開發效率,經過查閱資料和angular源碼,就寫了這個小框架,這個只適用於小項目,運行效率和速度上還存在這一些問題,只能作到全量渲染,若是有時間,能夠不斷的完善它。javascript


分析

它關鍵點就 Object.defineProperty 在這個方法,經過  get set  來達到數據變動更新視圖。java

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

代理數組方法,來達到更新的目的。node

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的源碼,看能不能發現更牛的黑科技,來提高這個小框架的運行速度和靈活性。  bash

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經過訂閱的方式保存起來,數據變動後經過發佈來達到視圖的更新app

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();
        });
    }
}複製代碼

能夠看出,更新的方式是全量更新。框架

這邊再須要一個類將這幾個類關聯起來dom

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)的方法,後續有空修復。若是有不足的地方歡迎留言。測試

相關文章
相關標籤/搜索