模拟Vue手写MVVM

最近跟着视频学了mvvm设计模式,以及自己写的实现方法。

1、MVVM设计思路图:

图片

从图中可以看出,MVVM模式的整体可以分为四部分,流程基本是:根据new Vue传入的参数,先做两个操作:编译模板(compile)和数据劫持(observer)。 编译模板主要利用到的是文档碎片fragment,原因是在文档碎片中操作dom可以大大减少页面反复重绘回流的情况,提升性能。 数据劫持则是利用了ES5的一个Object.defineProperty方法的set和get方法,遍历出传入的参数data中的各个数据,从而检测出哪些值发生更改。 然后定义一个观察者,里面确定一个update方法,当新值与旧值不同时执行其callback。 最后将这些watcher实例push到一个Dep数组中,当observer中set被调用时,执行dep的notify方法。

2、代码解构:

1)入口mvvm.js

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
29
30
class Mvvm {
    constructor(options) {
        //先将可用的属性或方法挂在实例上
        this.$el = options.el;
        this.$data = options.data;

        //如果传入模板,那么开始编译
        if (this.$el) {
            //数据劫持 将$data的所有属性改成set和get方式
            new Observer(this.$data);
            //用数据和元素进行编译 传入this 可随意获取this上面的数据
            new Compile(this.$el, this);
            //将$data中的数据代理给实例本身
            this.proxyData(this.$data);
        }
    }

    proxyData(data) {
        Object.keys(data).forEach(key=>{
            Object.defineProperty(this, key, {
                get() {
                    return data[key];
                },
                set(newVal) {
                    data[key] = newVal;
                }
            });
        });
    }
}

2)编译(compile)类:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
class Compile {
    constructor(el, vm) {
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        if (this.el) {//当el传入才开始编译
            //编译的三个步骤:
            //1、先把真实的dom过滤到内存中去(fragment) 因为在内存操作dom比较快
            let fragment = this.nodeToFragment(this.el);
            //2、编译=》提取想要的元素节点 v-model 文本节点{{}}
            this.compile(fragment);
            //3、把编译好的fragment再塞回到页面中去
            this.el.appendChild(fragment);
        }
    }

    /** 辅助方法 start **/

    /**
     * 判断是否是元素节点
     * @param node
     * @returns {boolean}
     */
    isElementNode(node) {
        return node.nodeType === 1;//1:元素节点 2:属性节点3:文本节点
    }

    /**
     * 判断某属性名是否包含'v-'关键字
     * @param name
     * @returns {boolean}
     */
    isDirective(name) {
        return name.includes('v-');
    }
    /** 辅助方法 end **/

    /** 核心方法 start **/

    /**
     * 编译元素节点
     * @param node
     */
    compileElement (node) {
        //取出带v-model v-text v-html v-xxx等自定义属性的节点
        let attrs = node.attributes;
        let attrsArr = Array.from(attrs);
        attrsArr.forEach(attr=>{//attr 是属性名和属性值的集合 name=value
            if (this.isDirective(attr.name)) {//判断是否是指令
                //是指令 把相应的值取出来放到节点中
                let expr = attr.value;//message.obj.name
                let [,type] = attr.name.split('-');//截取指令名的后半部分
                CompileUtil[type](node, this.vm, expr);
            }
        });
    }

    /**
     * 编译文本节点
     * @param node
     */
    compileText(node) {
        //这里去筛选包含{{}}这种关键字的文本
        let expr = node.textContent;
        let regexp = /\{\{([^}]+)\}\}/g;
        if (regexp.test(expr)) {
            CompileUtil['text'](node, this.vm, expr);
        }
    }

    /**
     * 编译模板主方法
     * @param fragment
     */
    compile(fragment) {
        let childNodes = fragment.childNodes;
        let childArr = Array.from(childNodes);
        childArr.forEach(node=>{
            if (this.isElementNode(node)) {//是元素节点
                this.compileElement(node);
                //这里需要继续递归 因为元素节点还包含子节点
                this.compile(node);
            } else {//是文本节点
                this.compileText(node);
            }
        });
    }

    /**
     * 将id为app下的元素放入文档碎片中 理由是能够提升操作dom的性能
     * @param el
     * @returns {DocumentFragment}
     */
    nodeToFragment(el) {
        //内存中的文档碎片
        let fragment = document.createDocumentFragment();
        let firstChild;
        while(firstChild = el.firstChild) {
            //每次append 会从el.childNodes中移除对应元素 同时el.firstChild向后移
            fragment.appendChild(firstChild);
        }
        // while(el.firstChild) {
        //     fragment.appendChild(el.firstChild);
        // }
        return fragment;//内存中的节点
    }
    /** 核心方法 end **/
}

CompileUtil = {
    /**
     * 获取实例上对应的数据
     * @param vm
     * @param expr
     * @returns {boolean}
     */
    getVal(vm, expr) {
        expr = expr.split('.');//拆解data中的复杂数据类型 对象数组之类 [message,obj,name]
        return expr.reduce((prev, next)=>{//vm.$data.name
            return prev[next];
        }, vm.$data);
    },
    /**
     * 获取文本编译的结果
     * @param vm
     * @param expr
     * @returns {*|boolean}
     */
    getTextVal(vm, expr) {
        return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments)=>{
            return this.getVal(vm, arguments[1]);
        });
    },
    /**
     * 设置更改后的值到相应元素上
     * @param vm
     * @param expr
     * @param val
     * @returns {*}
     */
    setVal(vm, expr, val) {
        expr = expr.split('.');//拆解data中的复杂数据类型 对象数组之类 [message,obj,name]
        return expr.reduce((prev, next, currentIndex)=>{//vm.$data.name
            if (currentIndex === expr.length-1) {
                return prev[next] = val;
            }
            return prev[next];
        }, vm.$data);
    },
    /**
     * 输入框处理
     * @param node
     * @param vm
     * @param expr
     */
    model(node, vm, expr) {
        let updaterFn = this.updater['modelUpdater'];
        //这里加一个数据监控 当发生变化时调用watcher的callback
        new Watcher(vm, expr, (newValue)=>{
            //当值发生变化时会调用cb 新的值将传递进来
            updaterFn && updaterFn(node, this.getVal(vm, expr));
        });
        node.addEventListener('input', (e)=>{
            let newVal = e.target.value;
            this.setVal(vm, expr, newVal);
        }, false);
        updaterFn && updaterFn(node, this.getVal(vm, expr));
    },
    /**
     * 文本处理
     * @param node
     * @param vm
     * @param expr
     */
    text(node, vm, expr) {
        let updaterFn = this.updater['textUpdater'];
        let value = this.getTextVal(vm, expr);
        expr.replace(/\{\{([^}]+)\}\}/g, (...arguments)=>{
            new Watcher(vm, arguments[1], (newVal)=>{
                //如果数据发生变化 文本节点重新获取依赖的数据
                updaterFn && updaterFn(node, this.getTextVal(vm, expr));
            });
        });
        //这里要解析{{}}中的变量
        updaterFn && updaterFn(node, value);
    },
    /**
     * dom片段更新
     * @param node
     * @param vm
     * @param expr
     */
    html(node, vm, expr) {
        let updaterFn = this.updater['htmlUpdater'];
        let value = this.getTextVal(vm, expr);
        new Watcher(vm, expr, (newValue)=>{
            //当值发生变化时会调用cb 新的值将传递进来
            updaterFn && updaterFn(node, this.getVal(vm, expr));
        });
        updaterFn && updaterFn(node, this.getVal(vm, expr));
    },
    updater:{
        /**
         * 输入框更新
         * @param node
         * @param value
         */
        modelUpdater(node, value) {
            node.value = value;
        },
        /**
         * 文本更新
         * @param node
         * @param value
         */
        textUpdater(node, value) {
            node.textContent = value;
        },
        /**
         * dom片段更新
         * @param node
         * @param value
         */
        htmlUpdater(node, value) {
            node.innerHTML = value;
        },
    }
}

3)数据劫持(observer)类:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class Observer{
    constructor(data) {
        this.observer(data);
    }

    /**
     * 将data中的属性改成set和get方式
     * @param data
     */
    observer(data) {
        //屏蔽错误数据
        if(!data || typeof data !== 'object') {
            return;
        }
        //将数据一一劫持 获取data中的key 、value
        //将对象转成数组
        Object.keys(data).forEach((key)=>{
            this.defineReactive(data, key, data[key]);//劫持
            this.observer(data[key]);//递归劫持
        });
    }

    /**
     * 定义响应式
     * @param obj
     * @param key
     * @param val
     */
    defineReactive(obj, key, value) {
        let _this = this;
        let dep = new Dep();//每个变化的数据都会对应一个数组 这个数组存放所有更新的操作
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get(){
                Dep.target && dep.addSub(Dep.target);
                return value;
            },
            set(newVal) {
                if (newVal != value) {
                    _this.observer(newVal);//如果是对象 继续劫持
                    value = newVal;
                    dep.notify();//通知数据更新
                }
            }
        });
    }
}

//这里是订阅者类
class Dep {
    constructor() {
        this.subs = [];//订阅的数组
    }
    addSub(watcher) {
        this.subs.push(watcher);
    }
    notify() {
        this.subs.forEach(watcher=>watcher.update());
    }
}

4)观察者(watcher)类:

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
29
class Watcher{//观察者 就是要给发生变化的元素执行对应的观察方法
    constructor(vm, expr, cb) {
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        this.value = this.get();
    }
    getVal(vm, expr) {//获取实例上对应的数据
        expr = expr.split('.');//拆解data中的复杂数据类型 对象数组之类 [message,obj,name]
        return expr.reduce((prev, next)=>{//vm.$data.name
            return prev[next];
        }, vm.$data);
    }
    get() {
        Dep.target = this;//将watcher实例赋值给Dep
        let oldVal = this.getVal(this.vm, this.expr);//根据实例和expr获取原来的值
        Dep.target = null;
        return oldVal;
    }
    //对外暴漏的方法 比较老值和新值 不一样就调cb
    update() {
        let newVal = this.getVal(this.vm, this.expr);
        let oldVal = this.value;
        if (newVal != oldVal) {
            this.cb(newVal);//cb 是watcher实例的cb
        }
    }
}

5)index.html:

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
29
30
31
32
33
34
35
36
37
<html>
    <head>
        <meta charset="utf-8">
        <title>vue test</title>
    </head>
    <body>
    <div id="app">
        <input type="text" v-model="message.obj.name">
        <div>
            {{message.obj.name}}
        </div>
        <div>dfwefg</div>
        <ul>
            <li>1</li>
        </ul>
        <div v-html="tpl"></div>
    </div>
    <script src="watcher.js"></script>
    <script src="observer.js"></script>
    <script src="compile.js"></script>
    <script src="mvvm.js"></script>
    <script>
        let vm = new Mvvm({
            el: '#app',
            data: {
                message: {
                    obj: {
                        name: 'hello tianyuan'
                    }
                },
                tpl: "<div><img src='https://xx.xx.cc/imgs/no_data.png' alt=''><p>hhhhhhhhhh</p></div>",
                list: [1,2,3]
            }
        });
    </script>
    </body>
</html>

3、当时对我来说值得关注的技术点:

1)fragment:

js允许我们利用文档碎片操作dom(碎片处于内存之中),这样的操作能够大大的减少直接在dom文档中频繁操作dom引起的重绘、回流问题,能在很大程度上提升性能。

一般的操作是创建一个fragment,对其进行dom操作,然后将处理好的fragment append到我们文档中要操作的dom节点中去。

例:

1
2
3
4
5
6
7
8
9
    var parent = document.getElementById('parent');
    var frag = document.createDocumentFragment();
    for(var i = 0; i < 10000; i++) {
        var child = document.createElement('div');
        var text = document.createTextNode('' + i);
        child.appendChild(text);
        frag.appendChild(child);
    }
    parent.appendChild(frag);

2)textContent:

用来获取节点的文本内容,包括其后代的文本内容。

与innerText的区别在于:

a. textContent会获取style元素里的文本(若有script元素也是这样),而innerText不会

b. textContent会获取display:none的节点的文本;而innerText好像会感知到节点是否呈现一样,不作返回

详见:

3) includes:

ES6的方法,检测一个数组或者字符串是否包含指定的值。

与indexof相比,它的优势是:第一可以判断NaN;;第二它返回更直观,true或者false,更具有可读性。

4) reduce:

//todo

ES6扩展了用法

5) Array.prototype.from: ES6方法

支持三个参数,数据源(为数组对象或可迭代对象)、新数组中的每个元素会执行该回调函数、执行回调的this.

6) 箭头函数的arguments获取:

用ES6规范中的rest参数的形式获取,rest参数搭配的是一个数组,

1
    (...arguments)=>{//...}

7) str.replace(regexp|substr, newSubStr|function)

regexp:正则表达式,对象或字面量的形式。

substr:要替换的字符串,仅第一个匹配项会被替换。

newSubStr:用于替换的新的字符串,可以插入一写特殊变量在其中,如$1、$2等。

function:用来创建新的字符串的函数,返回值替换掉原字符串。

推荐文档