自制简易 mvvm 框架

394 阅读4分钟

自制简易 mvvm 框架

为了深入理解 mvvm 框架,笔者自己动手实现一个简易的 mvvm 框架,并以 vue2 作为仿照的模板。

第一步,以 vue2 模板作为目标

让我们把 vue2 的模板作为我们要实现的目标,我们就有了具体的实现需求,如我们将创建一个 mvvm 实例,选项中支持挂载元素 el,数据 data,且数据是响应式的。而且我们应该保证代码的可维护性,容许增加更多的功能。

</html>
<body>
    <div id="app">
        <p>{{number}}<button id="addButton">+1</button></p>
    </div>
    <script type="module">
        import Mvvm from './lib/mvvm.js'
        let mvvm = new Mvvm({
            el: '#app',
            data: { 
                number:0
            }
        });
        const addButton=document.querySelector('#addButton')
        addButton.addEventListener('click',()=>{
            mvvm.number+=1
        })
    </script>
</body>
</html>

第二步,实现数据劫持监听

要做到数据响应式的大前提是监视你的数据,监视获取数据和修改数据的操作。vue2 实现的核心是Object.defineProperty(),这是属于 ES5 的语法,因此支持版本低于 ES5 的浏览器是无法正常运行的 vue2 的。

通过observe方法,我们将递归 data 下的所有数据,所有数据的读的操作将通过 getter,写的操作将通过 setter。

function observe(data={}){
    if(typeof data !== 'object')return
    for(let key in data){
        let val =data[key]
        if(typeof val === 'object')observe(val)
        Object.defineProperty(data,key,{
            configurable:true,
            get(){
                return val
            },
            set(newVal){
                if(val===newVal)return
                val =newVal
                observe(newVal)
                // 当你修改数据时,你将打印出'数据更新了'
                console.log('数据更新了')
            }
        })    
    }
}

第三步,数据代理

使用 vue2 进行开发时,我们会发现选项中 data 下的数据,会直接在实例下找到。实现思路如下:

function proxy(data,vm){
    for(let key in data){
        Object.defineProperty(vm,key,{
            configurable:true,
            get(){
                return vm._data[key]
            },
            set(newVal){
                vm._data[key]=newVal
            }
        })
    }
}

第四步,模板编译

vue2 中,要求我们在模板中将数据写在{{}}之中,很明显 vue2 为我们将符合规则的部分进行了转换。

核心实现思路为,使用正则匹配模板中含有{{}}的文本节点,拿到其中表示数据的字符串,替换为实例上对应的数据。其中重要的一个优化点是将挂载元素中所有的节点放入 fragment,即内存中,因此不会频繁引起回流,优化性能。

function compile(el, vm) {
    vm.$el = document.querySelector(el);
    let fragment = document.createDocumentFragment();
    // 将 DOM 树中的节点放到内存中
    while (child = vm.$el.firstChild) {
        fragment.appendChild(child);
    }
    replace(fragment);
    // 对整个代码片段替换的方法
    function replace(frag) {
        Array.from(frag.childNodes).forEach(function (node) {
            let txt = node.textContent;
            let reg = /\{\{(.*?)\}\}/g;
            //  即是文本节点又有{{}}
            if (node.nodeType === 3 && reg.test(txt)) { 
                // 对一个符合的文本节点替换的方法
                !function replaceTxt() {
                    node.textContent = txt.replace(reg, (matched, placeholder) => {
                        return placeholder.split('.').reduce((val, key) => {
                            return val[key];
                        }, vm);
                    });
                }();
            }
            // 如果该节点拥有子节点,那么递归 replace
            if (node.childNodes && node.childNodes.length) {
                replace(node);
            }
        });
    }
    vm.$el.appendChild(fragment);
}

第五步,发布订阅模式

到此为止我们的数据依旧不是响应式的,模板编译后修改数据不会引起 DOM 树的任何改变。 因此我们需要在数据的修改和DOM 树的修改之间建立联系。

核心实现思路,利用 Watcher 创建一个侦听器实例,侦听器上放更新 DOM 的方法,以及执行该方法所需要的信息。然后利用 Dep 创建一个依赖实例,依赖实例有一个存放侦听器的队列,以及添加侦听器和触发侦听器队列的方法。

// Watcher
function Watcher(vm, exp, fn) {
    this.fn = fn;
}
Watcher.prototype.update = function () {
    this.fn(val);  // newVal
};
// Dep
function Dep() {
    this.subs = [];
}
Dep.prototype.addSub = function (sub) {
    this.subs.push(sub);
};
Dep.prototype.notify = function () {
    this.subs.forEach(sub=>sub.update())
};

第六步,数据响应式

有了这个发布订阅模式,现在我们就可以着手连接数据和视图了。

首先,我们在 complie 函数中,为所有参加了编译的文本节点添加侦听器

// compile函数中
!function replaceTxt() {
                    node.textContent = txt.replace(reg, (matched, placeholder) => {
                    // 新增代码:添加侦听器
+                   	new Watcher(vm, placeholder, replaceTxt); 
                        return placeholder.split('.').reduce((val, key) => {
                            return val[key];
                        }, vm);
                    });
}();

然后,我们需要改造 Watcher,确保侦听器上有执行更新方法所需要的参数 vm,exp。一个 watcher 侦听器制作好了之后,我们就需要将它放到依赖的队列中,我们通过 Dep.target 和一个小操作将侦听器传递到 observe 中。

function Watcher(vm, exp, fn) {
    this.fn = fn;
    this.vm = vm;
    this.exp = exp;
    // 通过以下方法添加到依赖队列中
    Dep.target = this;
    let val = vm;
    let arr = exp.split('.');
    arr.forEach(key => {
        val = val[key];
    });
    Dep.target = null;
}

Watcher.prototype.update = function () {
    let val = this.vm;
    let arr = this.exp.split('.');
    arr.forEach(key => {
        val = val[key];
    });
    this.fn(val);  // newVal
};

接下来在 observe 中接应,getter 中将传递的侦听器添加侦听器队列中,setter 即数据改动时执行执行侦听器队列。

function observe(data={}){
    let dep =new Dep()
    // 省略了未改动代码
        get(){
            Dep.target && dep.addSub(Dep.target);   // [watcher]
            return val
        },
        set(newVal){
            if(val===newVal)return
            val =newVal
            observe(newVal)
            console.log('数据更新了')
            dep.notify();
        }
}

如此我们就实现了一个具备数据劫持、数据代理、数据响应式功能的简单 mvvm 框架。最后可以 rollup 一下,发布到 npm 上再看看效果。

往后可以实现更多功能

相关资料

试试看?

yarn add friday-mvvvm

源码地址:github.com/deib0/myMvv…