写一个简单的mvvm框架

The truth that you leave - Pianoboy高至豪--:-- / 03:43
(*+﹏+*)

前言

距离我的上篇博客已经过去了20天,有一说一,这20天里我确实挺咸鱼,翻了翻《webpack入门,进阶,和调优》(但我还是只会照着文档配),一边摸鱼一边写了下工作室的项目,然后就没干啥了,最近几天才突然醒悟,于是结合寒假上半段学到的知识,模拟了一些vue的功能,实现了一个简易的mvvm框架。

然后实现的功能有数据响应式 + computed计算属性 + watch监听属性 + bind,on,model几个指令,使用方法如下

<body>

    <div id="app" class="container" data-tar="sakura">
        <div s-on:click="onDivClick">
            a : {{a}}
            b : {{b}}
        </div>
        <div>
            <span>
                obj.c : {{obj.c}}
            </span>
        </div>
        <p>arr : {{arr}}</p>
        <p>arr[1] : {{arr[1]}}</p>
        <div>
            <p>{{name}}</p>
            <input s-model="name" type="text"/>
        </div>
        <p>{{sum}}</p>
        <p s-if="flag">flag</p>
        <p>{{doubleSum}}</p>
        <img s-bind:src="imgSrc" alt="" width="400px" />
    </div>


    <script type="module">

        import Sue from "./src/index.js";
        import {setVal} from "./src/compiler/util.js";


        window.vm = new Sue({
            el : "#app",
            data : {
                a : 1,
                b : 2,
                obj : {
                    c : 3
                },
                name : "123",
                age : 16,
                arr : [1, 2, 3, 4],
                imgSrc : "https://img.beixibaobao.cn/images/BS4Hq.jpg",
                flag : false,
            },
            computed : {
                doubleSum() {
		    return this.sum * 2;
		},
                sum () {
                    let a = this.a;
                    let b = this.b;
                    let c = this.obj.c;
                    return a + b + c;
                },

            },
            watch : {
                age : function (newAge, oldAge) {
                    console.log(newAge, oldAge, this);
                }
            },
            method : {
                onDivClick(event) {
                    console.log("点击");
                }
            },
            mounted() {
                console.log("挂载完成",this);
            }
        });

    </script>
</body>

效果图

是不是效果和vue差不多?当然,我写这个框架的出发点只是总结下知识体系,所以只实现了一些基本原理,没有考虑太多复杂情况,如果你有什么想法,非常欢迎在下方留言。

源代码地址: https://github.com/sliyoxn/sue/

数据响应式原理

先简单说说什么是数据响应式,数据响应式指的是在修改数据模型时,会触发对应的视图更新。这里的数据模型,就是普通的JS对象。

在JS中,侦测数据变化的方法有两个,ES5的Object.defineProperty和ES6的Proxy,在vue2.x版本中,vue使用的是 Object.defineProperty,vue的作者尤大大说要在vue3中用Proxy重写这部分代码,因为Proxy相对于 Object.defineProperty 在数据变化侦测方面有诸多优势,简单提一下有两点

  1. 可以监听对象非已有的属性的添加和已有属性的删除
  2. 可以监听到直接通过修改数组下标和通过length改变数组的行为

这里我也赶一波潮流,用proxy来写数据响应式的部分。

实现数据响应式需要三个重要的角色,分别是数据变动监听器Observer,观察者Watcher,还有订阅器Dep,他们之间的关系是这样的,Dep订阅器中有一个列表用于保存Watcher,Watcher可以把自己保存到Dep中,当Observer监听到数据变动时,就通知对应的Dep,Dep再通知列表中的Watcher,触发响应的更新。

具体到某个数据属性来看,Proxy担任了Observe的角色,Proxy可以代理属性的set操作,从而得知数据何时发生变动。在set函数中通知Dep数据发生变化,就可以把数据修改的信息传达给Dep,最后Dep再通知Watcher。

好了,说完了大概的思想,然后我们来上代码,先说说Proxy的使用。

let data = {
    a : 1,
    b : 2,
    c : 3
};

let proxy = new Proxy(data, {
    /**
     * 代理get
     * @param target proxy代理的对象
     * @param prop 本次访问的属性
     * @param proxy proxy本身
     * @returns {*}
     */
    get(target, prop, proxy) {
        console.log(`访问了data对象的${prop}属性, 属性的值是${target[prop]}`);
        return target[prop];
    },
    /**
     * 代理set
     * @param target proxy代理的对象
     * @param prop 本次访问的属性
     * @param value 新value
     * @param proxy proxy本身
     */
    set(target, prop, value, proxy) {
        console.log(`设置了data对象的${prop}属性, 属性的新值是${value}`);
        target[prop] = value;
    }
});


console.log(proxy.a);
proxy.a = 10;
console.log(proxy.a);

上面演示了proxy的基本使用,通过设置get和set函数,可以对对象的set的get行为进行代理,在get函数中返回被代理对象的数据就可以完成默认行为,在set函数中设置被代理对象的数据就可以完成默认行为,当然除了完成默认行为,你可以在get或者set函数中添加其他行为。值得注意的是,只有通过proxy对data进行操作才能让get和set函数生效,直接操作原来的对象不会触发get和set函数。

下面是Observe,Dep,Watcher的实现

Dep的作用就是缓存订阅者和通知订阅者,addSub方法用于添加订阅者,notify方法用于通知订阅者。

export default class Dep {
    constructor() {
        // 初始化订阅者列表
        this.subs = new Set();
    }
    addSub(sub) {
        // 添加订阅者
        this.subs.add(sub);
    }
    notify() {
        this.subs.forEach((sub) => {
            // 通知订阅者
            sub.update();
        })
    }
}

Observer没有对应的实体类,他的职责由Proxy完成,Proxy代理的set函数,可以监听到数据的变动,并且在set函数里通知Dep,在get函数里还进行了一些其他的处理,这里会和Watcher一起说明。

import {global, targetSymbol} from "../public/Variable.js";
import Dep from "./bean/Dep.js";

function observe(obj) {
    if (typeof obj !== "object") return;
    let proxy = getProxyObj(obj);
    // proxy是浅代理
    // 所以需要循环所有的属性,进行深代理,这样才能把整个对象都转化成响应式的
    Object.keys(proxy).forEach((key) => {
        if (typeof proxy[key] === "object") {
            proxy[key] = observe(proxy[key]);
        }
    });
    return proxy;
}
function getProxyObj(obj) {
    // 用于存放属性的Dep, dep[key]就是这个属性的Dep
    let depMap = {};
    return new Proxy(obj, {
        get(target, key, proxy) {
            // 去指定位置上寻找Watcher
            // 如果找到就把它缓存起来
            if (global[targetSymbol]) {
                let dep = depMap[key] ?
                    depMap[key] : (depMap[key] = new Dep());
                dep.addSub(global[targetSymbol]);
            }
            return target[key];
        },
        set(target, key, value, proxy) {
            target[key] = value;
            // 通知Dep
            depMap[key] && depMap[key].notify(value);
            return true;
        }
    });
}

构建Watcher对象需要的参数有三个,vm(new Vue()得到的就是vm对象),exp是表达式,如果这个Watcher监听的是vm.obj.name这个属性,那么exp就应该是"obj.name",cb是回调函数,当Watcher被通知数据更新时,会把新旧数据传入cb并执行。

import {global, targetSymbol} from "../../public/Variable.js";
import {getVal} from "../../compiler/util.js";
/**
 *
 * @param vm Sue实例
 * @param exp 要监听的属性的表达式
 * @param cb
 * @constructor
 */
export default class Watcher {
    constructor(vm, exp, cb) {
        this.cb = cb;
        this.vm = vm;
        this.exp = exp;
        // 把自己添加到订阅器Dep里
        this.value = this.get();
    };

    update() {
        this.run();
    }

    run() {
        let value = getVal(this.vm, this.exp);
        let oldVal = this.value;
        if (value !== oldVal) {
            // 只有新旧值不一样时才触发回调
            this.value = value;
            this.cb.call(this.vm, value, oldVal);
        }
    }

    get() {
        global[targetSymbol] = this;
        let value = getVal(this.vm, this.exp);
        global[targetSymbol] = null;
        return value;
    }
}

Watcher的get方法用于把自己加到对应的Dep里,get方法执行过程中,先把Watcher自身放到global[targetSymbol]上,然后调用getVal方法获取值,getVal是一个封装好的工具函数,只要传入对象和表达式就能获取相应的值,在获取值时,会执行Proxy的get方法,get方法会去 global[targetSymbol] 上寻找Watcher,如果找到就会把Watcher添加到Dep里,这样就完成了依赖收集的操作。在使用时,只要new Watcher,就可以调用get函数,只要传入proxy对象和表达式,就可以获取对应的值和完成依赖收集的操作。

另外,因为我们平时是用vm.xxx这样的方式来访问数据的,所以我们还需要在vm上设置一些访问器属性。通过设置访问器属性,可以把访问vm.xxx代理到访问proxy.xxx上,具体的方法如下。

使用Object.defineProperty可以完成访问器属性的设置

function proxyKeys(target, proxyTo) {
    Object.keys(proxyTo).forEach((key) => {
        Object.defineProperty(target, key, {
            configurable : true,
            enumerable : true,
            get() {
                return proxyTo[key];
            },
            set(v) {
                proxyTo[key] = v;
            }
        })
    })
}
// observe会返回一个proxy
// 把vm.xxx代理到proxy.xxx上
proxyKeys(vm, observe(data));

节点编译

其实说节点编译有点不是那么准确,但是好像找不出其他的词语了,所以暂时先用这个。这一步的主要目的是确定HTML中依赖了了哪些数据,然后建立依赖关系,实现代码如下。

function compiler(el, vm) {
    let context = {
        vm
    };
    domToVNode(el, context);
}


function domToVNode(el, context) {
    let element = getDomEle(el);
    if (!element) return;
    return compilerElementNode(element, context);
}

function compilerElementNode(element, context) {
    let tag = element.tagName;
    let children = [];
    let parent = stack[stack.length];
    let elem = element;
    let render = null;
    let attrMap = {};
    let childNode = element.childNodes;

    let attrs = element.attributes;
    for (let attr of attrs) {
        attrMap[attr.name] = attr.value;
    }
    analysisDirective(attrMap, element, context);

    childNode.forEach((node) => {
        let vnode;
        if (node.nodeType === 1) {
            vnode = compilerElementNode(node, context);
        } else if (node.nodeType === 3) {
            vnode = compilerTextNode(node, context);
        }
        children.push(vnode);
    });
    return createElementVNode({
        tag,
        children,
        parent,
        elem,
        render,
        attr: attrMap
    });
}

function compilerTextNode(node, context) {
    let template = node.nodeValue;
    let relyArr = getRely(template, context);
    let renderFun = getRenderFun(context.vm, template);
    let parent = null;
    let textNode = createTextNode({
        elem : node,
        render : function () {
            node.nodeValue = renderFun();
        },
        parent,
    });
    let watcherArr = [];
    for (let i = 0; i < relyArr.length; i++) {
        let watcher = new Watcher(context.vm, relyArr[i], textNode.render);
        watcherArr.push(watcher);
    }
    textNode.render();
    return textNode;
}
export {compiler}

我们一个个函数来看,compiler函数是编译的入口,在新建了一个上下文对象并把vm放入后,就调用domToVNode函数把Dom转化成一棵VNode树,VNode的作用是用JS对象描述Dom,确认Dom的依赖并构建依赖关系,完成依赖收集就是在这个函数中完成的。

domToVNode传入了两个参数,分别是new Sue(类似new Vue)时传入的el和context,我们需要先根据el获取真正的dom,然后根据节点类型调用对应的函数进行编译(nodeType为1是元素节点,nodeType为3是文本节点),然后直接把编译得到的Vnode返回即可。

接着我们来看看compilerElementNode和compilerTextNode这两个方法,先看 compilerTextNode,先通过nodeValue拿到文本节点的文本,作为文本节点重新渲染的模板,然后通过一个工具方法getRely获取模板中依赖了什么数据,比如说

a : {{a}}
b : {{b}}

这个文本节点,通过getRely得到的结果是这样的

然后我们使用getRenderFun获得渲染函数, 实现如下

export function getRenderFun(vm, template) {
    return function () {
        return template.replace(/{{(.+?)}}/gm , function (target, name ,...arg) {
            return `${getVal(vm, name)}`;
        });
    }
}

getRenderFun 是一个高阶函数,他会根据传入的vm和template生成一个新的渲染函数并返回,这个新的渲染函数执行时,会根据最新的数据,重新渲染出最新状态的字符串。

还是上面的文本节点,得到的渲染函数和渲染函数执行时返回的字符串如下

然后我们调用createTextNode创建一个text类型的VNode节点,这不是重点,就不过多讲解了。然后我们循环relyArr获取依赖的表达式,比如上面的文本节点就依赖了a和b,然后我们给每个依赖创建一个watcher,并且把更新文本节点的函数作为回调传入,这样在数据更新时,回调就会执行,文本节点就会更新。

接着我们讲compilerElementNode,这个函数一开始定义了一堆变量,这些变量都是之后生成VNode时要用的,第一步是先把建立一个属性的对象,就是把属性转化成key-value的方式,便于后续访问,然后调用analysisDirective用于解析属性上有没有一些指令(v-if,v-model这些),指令等等再说,先继续往下看,因为是元素节点,所以可能有子元素,所以我们遍历子节点来递归创建VNode,最后返回创建的VNode就行了。

然后我们讲讲analysisDirective,实现代码如下

import analysis_s_model from "./s-model.js";
import analysis_s_on from "./s-on.js";
import {analysis_s_bind} from "./s-bind.js";
import analysis_s_if from "./s-if.js";

const map = {
    "s-model" : analysis_s_model,
    "s-on" : analysis_s_on,
    "s-bind" : analysis_s_bind,
    "s-if" : analysis_s_if,
};


export function analysisDirective(attrMap, element, context) {
    Object.keys(attrMap).forEach(directiveKey => {
        let directiveValue = attrMap[directiveKey];
        let directiveName = directiveKey.split(":")[0];
        map[directiveName] && map[directiveName](directiveKey, directiveValue, element, context);
    })
}

analysisDirective会遍历所有的属性,看看能不能匹配到map的属性,如果可以,就调用对应的函数去处理,我目前只实现了model,on,bind,if四个指令,下面来看看实现吧。

export default function analysis_s_model(directiveKey, directiveValue, element,context, payload = {}) {
    if (element.tagName !== "INPUT") return;
    element.value = getVal(context.vm, directiveValue);
    element.addEventListener("input", function (event) {
        let value = event.target.value;
        setVal(context.vm, directiveValue, value);
    });
    element.removeAttribute(directiveKey);
}

model的代码很简单,先判断传入的element的是不是input元素,然后把element的value设置为绑定的数据的值,然后给element设置input事件,在input框的值更新时更新对应的数据的值,这样就完成了双向数据绑定的过程。

function analysis_s_on(directiveKey, directiveValue, element, context) {
    let eventName = directiveKey.split(":")[1];
    let vm = context.vm;
    let cb = vm[directiveValue];

    element.addEventListener(eventName, cb.bind(vm));
    element.removeAttribute(directiveKey);
}

on只要获取要绑定的事件名和事件,然后使用addEventListener绑定即可。

function analysis_s_bind(directiveKey, directiveValue, element, context) {

    let attrName = directiveKey.split(":")[1];
    let vm = context.vm;
    let watcher = new Watcher(vm, directiveValue, function (newVal) {
        element.setAttribute(attrName, newVal);
    }.bind(vm));

    element.setAttribute(attrName, getVal(vm, directiveValue));
    element.removeAttribute(directiveKey);
    
}

bind只要把依赖的数据解析出来,然后new Watcher进行依赖添加就可以了,回调函数的功能是更新属性的功能,最后把属性的值设置成对应数据的值就可以了。

其他功能

computed

我之前写过一篇computed大致原理的博客,现在的版本和之前的版本区别不大,只更新了computed之间可以相 互依赖的功能

const computedDepMap = {};
const depCollectFuncArr = [];
function initComputed(vm, computedObj) {
    if (typeof computedObj !== "object") return;
    Object.keys(computedObj).forEach((key) => {
        if (typeof computedObj[key] === "function") {
            wrapComputed(computedObj, key, vm);
        }
    });
    for (let depCollectFunc of depCollectFuncArr) {
        depCollectFunc();
    }
    while (depCollectFuncArr.length) {
        depCollectFuncArr.pop()();
    }
}

function wrapComputed(computedObj, key, vm) {
    let valCache = undefined;
    let originFun = computedObj[key];
    let watcher = new Watcher(vm, key, function () {
        valCache = originFun.call(this);
        computedDepMap[key] && computedDepMap[key].notify();
    }.bind(vm));
    depCollectFuncArr.push((function () {
        global[targetSymbol] = watcher;
        valCache = originFun.call(vm);
        global[targetSymbol] = null;
    }));
    Object.defineProperty(vm, key, {
        get() {
            if (global[targetSymbol]) {
                let dep = computedDepMap[key] ? computedDepMap[key] : (computedDepMap[key] = new Dep());
                dep.addSub(global[targetSymbol]);
            }
            return valCache;
        },
        set(v) {
            valCache = v;
            computedDepMap[key] && computedDepMap[key].notify();
        }
    });
}


export {initComputed}

computedDepMap是用于保存计算属性的Dep的,depCollectFuncArr用于缓存收集该计算属性的依赖的函数。在initComputed中,遍历设置计算属性时传入的函数,调用wrapComputed进行处理,在wrapComputed中,先创建一个Watcher,然后把收集这个计算属性依赖于哪些数据的函数添加进depCollectFuncArr中,然后用Object.defineProperty设置访问器属性,使得使用vm.xxx这种方式可以直接访问计算属性。在 wrapComputed 处理完后,把 depCollectFuncArr里的函数顺着执行一遍,再逆序执行一遍,逆序执行一遍的目的是,处理前一个计算属性依赖于后面的计算属性的情况。如下

computed : {
    doubleSum() {
	return this.sum * 2;
    },
    sum () {
	let a = this.a;
	let b = this.b;
	let c = this.obj.c;
	return a + b + c;
     },
},

watch

import Watcher from "../observe/bean/Watcher.js";
function initWatch(vm, watchObj) {
        Object.keys(watchObj).forEach((key) => {
        let cb = watchObj[key];
        let watcher = new Watcher(vm, key, cb);
    });
}

export {initWatch}

water的实现比较简单,遍历设置的watch,然后new Wacther注册依赖即可。

method

import {proxyKeys} from "../observe/observe.js";

function initMethod(vm, methodObj = {}) {
    proxyKeys(vm, methodObj);
}

export {initMethod};

method的实现也比较简单,直接把method的访问代理到vm上即可。

后记

那么到这里就大功告成了, 如果你有什么想法,欢迎在下方留言,如果你想要源代码,可以在这里获取。最后希望本文能对你有所帮助。

点赞

发表评论

昵称和uid可以选填一个,填邮箱必填(留言回复后将会发邮件给你)
tips:输入uid可以快速获得你的昵称和头像