黑马程序员技术交流社区

标题: 如何从0到1开发自己的mvvm框架 [打印本页]

作者: 逆风TO    时间: 2020-3-31 10:37
标题: 如何从0到1开发自己的mvvm框架
项目地址
Sth框架源码地址
https://github.com/shangth/MyVue
基于Sth的toDoList地址
https://shangth.github.io/MyVue/todoList.html

写在前面
此框架是一个自己开发的mvvm框架,大量参考了vue源码,实现了虚拟dom,虚拟节点,数据代理,计算属性,双向绑定,数据改动局部重新渲染视图,v-for,v-bind(: ),v-on(@),生命周期钩子等功能,实现了一部分语法分析,但是相比真正的Vue还有一定差距。
但是可以帮助我们理解框架的内部原理,虚拟dom的概念。
下面,来讲一讲我是如何开发这个框架的。

框架目录
[Java] 纯文本查看 复制代码
|-core
    |-grammer
        |=vbind.js          分析处理v-bind指令
        |=vfor.js           分析处理v-for指令
        |=vmodel.js         分析处理v-model指令
        |=von.js            分析处理v-on指令
    |-instance
        |=index.js          STH框架主函数
        |=init.js           给Sth构造函数加入初始化方法
        |=mount.js          DFS算法构建虚拟dom树
        |=proxy.js          代理data对象
        |=render.js         渲染页面
    |-util
        |=code.js           编译工具
        |=objectUtil.js     其他工具函数
    |-vdom
        |=vnode.js          虚拟dom构造函数
    |=index.js              入口函数

声明构造函数Sth

path:instance/index.js
这个模块完成的主要任务是

[Java] 纯文本查看 复制代码
import {initMixIn} from './init.js';
import {renderMixIn} from './render.js';


function Sth(options) {
    // 初始化Sth
    this._init(options);
    // 渲染
    this._render()
}
// 添加初始化方法
initMixIn(Sth)
renderMixIn(Sth)

export default Sth

初始化sth对象
在主模块中,我们引入并执行了initMixIn方法,那么这个方法主要做了什么呢?
首先给Sth原型添加_init方法,_init方法主要做了以下几件事

给这个sth对象设置uid属性(uid唯一)
给sth设置isSth = true,代表这是一个sth对象
初始化生命周期函数beforeCreate方法,如果有该方法,就去执行
初始化data,通过递归代理
初始化methods
初始化computed
初始化生命周期函数created方法,如果有该方法,就去执行
初始化生命周期函数update方法
初始化生命周期函数beforeMount方法
检查是否有el,挂载节点,完成后执行生命周期函数beforeMount方法
uid与isSth比较简单,代码如下,不过多赘述
[Java] 纯文本查看 复制代码
let uid = 0;
export function initMixIn(Sth) {
        Sth.prototype._init = function(options) {
                const vm = this;
                // Sth唯一编号
                this.uid = uid++;
                // 记录一个对象是不是Sth对象
        this.isSth = true;
        };
}


这是init所有要做的事,但是当前我们只需要去关注如何代理data

代理data
代理data主要使用的是get与set
proxy.js中有三个核心函数
分别为

construtionProxy 代理不知道是对象还是数组的对象
construtionObjectProxy 代理对象 代理数组主要做的是递归代理自己和自己下面的属性
construtionArrayProxy 代理数组 代理数组主要做的是代理数组本身和自己的方法
这三个函数的逻辑如下
[JavaScript] 纯文本查看 复制代码
function construtionProxy() {
    if (当前数据是数组) {
        construtionArrayProxy()
    } else if (当前数据是对象) {
        construtionObjectProxy()
    } else {
        抛出错误
    }
}


代理的目的是,当我们修改了数据时,我们可以检测到数据的变化,从而做一些处理

代理对象
代理对象与代理数组不同,代理对象是代理对象下的每一个属性,如果该对象下的属性不是一个简单数据类型,那就再通过construtionProxy代理它,在vue中,data对象代理到了两个地方,一个是vue实例的_data中,一个是vue实例本身上,所以我们也将data代理到这两个地方
[JavaScript] 纯文本查看 复制代码
// 对象代理方法
function construtionObjectProxy(vm, obj, namespace) {
    let proxyObj = {};
    for (let prop in obj) {
        Object.defineProperty(proxyObj, prop, {
            configurable: true,
            get() {
                return obj[prop]
            },
            set(value) {
                console.log(`${getNameSpace(namespace, prop)}属性修改,新的值为${value}`)
                obj[prop] = value;
                renderData(vm, getNameSpace(namespace, prop));
                // 生命周期update
                if (vm._update != null) {
                    vm._update.call(vm);
                }
            }
        })
        Object.defineProperty(vm, prop, {
            configurable: true,
            get() {
                return obj[prop]
            },
            set(value) {
                console.log(`${getNameSpace(namespace, prop)}属性修改,新的值为${value}`)
                obj[prop] = value;
                renderData(vm, getNameSpace(namespace, prop));
                // 生命周期update
                if (vm._update != null) {
                    vm._update.call(vm);
                }
            }
        })
        // 递归 由于不知道obj[prop]是数组还是对象,所以使用construtionProxy
        if (obj[prop] instanceof Object) {
            proxyObj[prop] = construtionProxy(vm, obj[prop], getNameSpace(namespace, prop))
        }
    }
    return proxyObj
}


代理数组

代理数组除了要代理数组每一项之外,还要代理数组的方法,因为当数组通过push,pop等方法被改变时,我们也要检测到

[JavaScript] 纯文本查看 复制代码
// 代理数组
function construtionArrayProxy(vm, arr, namespace) {
    let obj = {
        eletype: "Array",
        toString: () => {
            let result = '';
            for (let i = 0; i < arr.length; i++) {
                result += arr + ', '
            }
            return result.slice(0, -2)
        },
        push() {},
        pop() {},
        shift() {},
        unshift() {},
        splice() {},
    }
    defArrayFunc.call(vm, obj, 'push', namespace, vm);
    defArrayFunc.call(vm, obj, 'pop', namespace, vm);
    defArrayFunc.call(vm, obj, 'shift', namespace, vm);
    defArrayFunc.call(vm, obj, 'unshift', namespace, vm);
    defArrayFunc.call(vm, obj, 'splice', namespace, vm);

    arr.__proto__ = obj;
    return arr
}

// 代理数组方法
const arrayProto = Array.prototype;
function defArrayFunc(obj, funcName, namespace, vm) {
    Object.defineProperty(obj, funcName, {
        enumerable: true,
        configurable: true,
        value: function(...args) {
            let originFun = arrayProto[funcName];
            const result = originFun.apply(this, args);
            console.log(`${funcName}方法被调用`);
            rebuild(vm, getNameSpace(namespace, ''));
            renderData(vm, getNameSpace(namespace, ''));
            // 生命周期update
            if (vm._update != null) {
                vm._update.call(vm);
            }
            return result
        }
    })
}

代理时还有一个很关键的概念是命名空间,通过命名空间可以准确定位被修改的值

之后预渲染时会创建节点与数据的映射关系,用到的也是命名空间

这样,通过递归,就代理了data对象


生成虚拟dom数

这是框架的核心,虚拟dom,通过虚拟dom,可以检测数据改变时,需要重新渲染的节点。

首先我们需要虚拟dom构造函数

[JavaScript] 纯文本查看 复制代码
let num = 0
export default class VNode{
    constructor(
        tag,  // 标签名,例如DIV,SPAN,#TEXT
        elm,  // 真实节点
        children,  // 子节点
        text,  // 文本(仅文本节点存在)
        data,  // 暂时保留(v-for构建虚拟节点时,用来储存需要用到的数组命名空间)
        parent,  // 父级节点
        nodeType,  // 节点类型
    ) {
        this.tag = tag;
        this.elm = elm;
        this.children = children;
        this.text = text;
        this.data = data;
        this.parent = parent;
        this.nodeType = nodeType;
        this.env = {},  // 环境变量
        this.instructions = null;  // 存放指令
        this.template = [];  // 涉及模板
        this.num = num++
    }
}

除此之外还需要一些dom节点的基本知识
节点的nodeType 标签节点为1,文本节点为3,模板在文本节点中,所以渲染只需要渲染文本节点即可
dfs算法构建虚拟dom树

[JavaScript] 纯文本查看 复制代码
export function initMount(Sth) {
    Sth.prototype.$mount = function (el) {
        let vm = this;
        let rootDom = document.getElementById(el);
        mount(vm, rootDom);
    }
}
export function mount(vm, el) {
    // 进行挂载(生成虚拟dom树)
    vm._vnode = constructVNode(vm, el, null);
    // 进行预备渲染(将模板转换为对应的值)
    prepareRender(vm, vm._vnode)
}

function constructVNode(vm, elm, parent) { // 深搜
    let vnode = analysisAttr(vm, elm, parent);
    if (!vnode) {
        let children = [];
        let text = getNodeText(elm);
        let data = null;
        let nodeType = elm.nodeType;
        let tag = elm.nodeName;
        vnode = new VNode(tag, elm, children, text, data, parent, nodeType);
        if (elm.nodeType == 1 && elm.getAttribute('env')) {
            vnode.env = mergeAttr(vnode.env, JSON.parse(elm.getAttribute('env')))
        } else {
            vnode.env = mergeAttr(vnode.env, parent ? parent.env : {})
        }
    }
    let childs = vnode.elm.childNodes;
    for (let i = 0; i < childs.length; i++) {
        let childNodes = constructVNode(vm, childs, vnode);
        if (childNodes instanceof VNode) {
            vnode.children.push(childNodes)
        } else { // v-for 返回节点数组
            vnode.children = vnode.children.concat(childNodes)
        }
    }
    return vnode
}

虚拟dom树就是与真实dom树一一对应的树形结构,用对象表示相对应的dom节点

预备渲染

[JavaScript] 纯文本查看 复制代码
// 预备渲染(创建了两个映射)
export function prepareRender(vm, vnode) {
        if (vnode == null) {
                return;
        }
        if (vnode.nodeType == 3) {
                // 文本节点
                analysisTemplateString(vnode);
        }
    if (vnode.nodeType == 0) {
        setTemplate2vnode(vnode, vnode.data);
        setVnode2template(vnode, vnode.data);
    }
        analysisAttr(vm, vnode);
        for (let i = 0; i < vnode.children.length; i++) {
                prepareRender(vm, vnode.children);
        }
}

预备渲染主要构建了两个映射


模板到虚拟dom的映射(主要用于当模板值修改后,获取哪些节点需要修改)

虚拟dom到模板的映射(主要用于渲染时,将模板替换为对应真实值)

渲染

渲染主要渲染的是文本节点

将文本节点中的模板换成对应真实值即可

但是模板的真实值来自哪?模板真实值不一定来自sth._data,也有可能来自v-for声明的局部变量,还有可能来自计算属性,所以在取值时要注意

getTemplateValue方法就是可以从多个变量取到真实值的方法

[JavaScript] 纯文本查看 复制代码
function renderNode(vm, vnode) {
        if (vnode.nodeType == 3) {
                // 拿到这个文本节点用到的模板
                let templates = vnode2template.get(vnode);
                if (templates != null) {
                        let result = vnode.text;
                        for (let i = 0; i < templates.length; i++) {
                                let templateValue = getTemplateValue(
                                        [vm._data, vnode.env, vm._computed],
                                        templates
                );
                if (typeof templateValue == 'function') {
                    templateValue = templateValue.call(vm)
                }
                                if (templateValue != null) {
                                        result = result.replace(
                                                "{{" + templates + "}}",
                                                templateValue.toString()
                                        );
                                }
                        }
                        vnode.elm.nodeValue = result;
                }
        } else if (vnode.nodeType == 1 && vnode.tag == "INPUT") {
                // 拿到这个文本节点用到的模板
                let templates = vnode2template.get(vnode);
                if (templates != null) {
                        for (let i = 0; i < templates.length; i++) {
                                let templateValue = getTemplateValue(
                                        [vm._data, vnode.env],
                                        templates
                                );
                                if (templateValue != null) {
                                        vnode.elm.value = templateValue;
                                }
                        }
                }
        } else {
                for (let i = 0; i < vnode.children.length; i++) {
                        renderNode(vm, vnode.children);
                }
        }
}

这样就可以完成渲染

同时当数据修改后,可以找到使用到这个数据的节点,并且for循环这些节点,


这里有一个遗留的bug,就是当计算属性所需要的数据改变时,计算属性模板不会重新渲染,这是由于当数据改变时,只重新渲染了使用这个数据的模板,没有重新渲染相关计算属性模板,这里有个优化思路,在初始化计算属性的时候,进行语法分析,分析后建立一个属性到计算属性的映射,当数据修改后,同时查看有没有计算属性用到了这个数据,如果有,重新渲染计算属性对应的节点


v-model

v-model是一个较为简单的指令

只需要在递归挂载的时候,遇到标签节点就分析attribute,如果有v-model属性并且节点为input就去执行此方法

只需要给节点绑定onkeyup即可

[JavaScript] 纯文本查看 复制代码
export function vmodel(vm, elm, data) {
    elm.onkeyup = function (event) {
        setValue(vm._data, data, elm.value)
    }
}

v-for

v-for是整个框架最难的部分,逻辑十分复杂

大致思路是当遇到含有v-for属性的标签时,先在真实dom中删除这个标签,然后拿到v-for循环的那个数组,通过数组长度来建立一批一模一样的节点,并且将源节点的属性也复制过来,接下来给每个节点设置局部变量,保存在vnode的env中,这样在取值的时候,可以在env中拿到对应数据

代码见文章顶部的github地址

这存在一个bug,含有v-for的dom节点的兄弟节点会被解析两遍,对应的v-on事件也会被绑定两遍,暂无很好的解决方法


数组修改后,重新渲染

当数组修改后找到用到了这个数组的节点,重新构建这个节点的父节点下的所有内容,然后清空之前的虚拟节点与vnode的映射,重新建立映射,重新建立映射是因为由于数组改变之前的映射关系可能就不准确了,这时候需要重新建立整个映射关系,由于建立映射关系不需要操作dom所以这个过程并不会慢,最后局部重新渲染即可

代码见文章顶部的github地址


v-bind

v-bind的解析需要等虚拟dom构建完成以后再解析,因为绑定的变量有可能来自v-for产生的局部变量

获取v-bind:xxx的属性值后有两种情况,分别来说一下


属性值为变量

这种比较好处理,直接获取到变量对应的值,直接赋值即可

属性为表达式

这种情况就比较麻烦,值有可能为{red:obj.x > 0};

这时候需要进行语法分析,这里用了一个很奇妙的处理方法

首先构建一个执行环境(字符串),执行环境中声明这个节点能访问到的左右变量,并且声明一个bool,初始值为false,然后将判断条件作为字符串拼接进去,eval()执行这个字符串,获取bool,如果为真,将该属性加入返回结果中,如果是为false,就不加

代码过长,见文章顶部的github地址

v-on

v-on实现较为简单,与v-bind大致相同,只是把设置attribute改成了绑定事件。

这里只需要注意使用bind改变事件的this指向即可

如果时间有参数的话,需要做一个判断,检查@xxx的属性值是否存在’(’

如果存在,就老办法获取到每一个属性值对应的真实值,并且bind的时候拼接在后面

[JavaScript] 纯文本查看 复制代码
export function checkVOn(vm, vnode) {
    if (vnode.nodeType == 1) {
        let attrNames = vnode.elm.getAttributeNames();
        let filterAttrNames = attrNames.filter((item) => item.startsWith('v-on:') || item.startsWith('@'));
        for (let i = 0; i < filterAttrNames.length; i++) {
            let eventName = filterAttrNames.includes(':') ? filterAttrNames.split(':')[1] : filterAttrNames.split('@')[1];
            von(vm, vnode, eventName, vnode.elm.getAttribute(filterAttrNames))
        }
    }
}

function von(vm, vnode, eventName, name) {
    let argList = []
    let index = name.indexOf('(');
    let funName = name
    if (index >= 0) {
        funName = name.slice(0, index);
        argList = name.slice(index + 1, -1).split(',');
        console.log(argList);
    }
    for (let i = 0; i < argList.length; i++) {
        argList = getTemplateValue([vm._data, vnode.env],argList)
    }
    let method = getValue(vm._methods, funName);
    if (method) {
        vnode.elm.addEventListener(eventName, method.bind(vm, ...argList))
    }
}

生命周期函数

生命周期函数很简单,只需要在init的时候初始化,并且在特定时间调用即可,注意修改this指向
例如init.js中,代理data前执行beforeCreate方法
代理后执行created方法
挂载前执行beforeMount方法等




作者: kdhdjdj    时间: 2020-4-7 10:22
666666666666666666666666
作者: 王锦    时间: 2020-4-7 10:28

作者: 你不爱我    时间: 2020-4-7 10:43
厉害了                    
作者: 逆风TO    时间: 2020-4-7 10:56
感谢分享  棒棒哒
作者: sdjadyhm    时间: 2020-4-7 11:05
66666666666666
作者: 我是小圆圆    时间: 2020-4-7 11:05
可以的,奥利给!!!
作者: hongping    时间: 2020-4-7 11:10

感谢分享  棒棒哒
作者: daoqin    时间: 2020-4-7 11:25

可以的,奥利给!!!
作者: 哦嗨呦    时间: 2020-4-7 11:31
好人一生平安
作者: Emmmmm~    时间: 2020-4-7 11:36


                                                                                                  
键盘敲烂,月薪过万
作者: 我是小圆圆    时间: 2020-4-7 11:51
可以的,奥利给!!!
作者: 孙丽    时间: 2020-4-7 11:52
66666666666666666666666
作者: 殷凯老师    时间: 2020-4-7 14:04
6666666666666666
作者: manyihang    时间: 2020-4-7 14:29
666666666666
作者: jsnoob    时间: 2020-4-7 14:53
加油加油加油!!!
作者: 大安    时间: 2020-4-7 15:17

可以的,奥利给!!!
作者: 章鱼顶呱呱    时间: 2020-4-8 09:55
666666666666666
作者: 耙丫丫    时间: 2020-4-9 08:45
666666666666666666666666
作者: lvxinvip    时间: 2020-4-9 09:22

作者: longyu3    时间: 2020-4-9 09:51
棒棒哒 加油 完美入行
作者: duanshaobo    时间: 2020-4-9 10:00
在这春暖花开的季节
作者: mydorling11    时间: 2020-4-9 10:00
666666666666666666666666666666
作者: 半个程序员    时间: 2020-4-9 14:04


可以的,奥利给!!!
作者: 举个栗子    时间: 2020-4-9 14:45
666666666666666666666666666666
作者: json0314    时间: 2020-4-9 14:50
加油哦.加油哦!
作者: 小公举    时间: 2020-4-9 15:29
666666666666666666
作者: yujq    时间: 2020-4-9 18:28
66666666666666666
作者: 零度☆黎明    时间: 2020-4-9 23:15
不错, 不错 .................. ..................
作者: zplxwl    时间: 2020-4-10 00:32
666666666666666666666666
作者: 大智叔叔    时间: 2020-4-10 09:27

可以的,奥利给!!!
作者: 九月丫    时间: 2020-4-10 09:35
66666666666666666666666666666666
作者: lzq123    时间: 2020-4-10 09:38
6666666666666666666666666666666666666
作者: 我爱我1022    时间: 2020-4-10 09:39

作者: 影@子~    时间: 2020-4-10 10:02

作者: 竹竹竹竹    时间: 2020-4-10 10:05
66666666666666666666666
作者: 竹竹竹竹    时间: 2020-4-10 10:06
666666666666666666666666666
作者: 王微    时间: 2020-4-10 10:17

作者: 霍尔    时间: 2020-4-10 10:55
666666666666666666666
作者: zhaosongzhi    时间: 2020-4-10 11:03
6666666666666666666
作者: hello!!!    时间: 2020-4-10 11:09

作者: 黑马程序员啊    时间: 2020-4-10 12:59
6666666666666666666666666666666666666
作者: 雨落轻舟    时间: 2020-4-10 18:36
6666666666666666666666666666666666666666666666666666666666
作者: 素问    时间: 2020-4-12 22:13
谢谢分享,加油~~~~!!!!




欢迎光临 黑马程序员技术交流社区 (http://bbs.itheima.com/) 黑马程序员IT技术论坛 X3.2