從零到一編寫MVVM

釋迦摩尼發表於2019-03-02

簡介

公司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 = /{{((?:.|
)+?)}}/;

    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(/lets+(.*)s+ofs+(.*)/);
            let index = exgs[1].match(/lets+(.*)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)的方法,後續有空修復。如果有不足的地方歡迎留言。

相關文章