A股上市公司传智教育(股票代码 003032)旗下技术交流社区北京昌平校区

 找回密码
 加入黑马

QQ登录

只需一步,快速开始

项目地址
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
这个模块完成的主要任务是

  • 声明Sth函数;
  • 引入initMixIn,renderMixIn函数,给Sth原型上添加初始化以及渲染方法
[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[i] + ', '
            }
            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[i], 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[i]);
	}
}

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


模板到虚拟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[i]
                );
                if (typeof templateValue == 'function') {
                    templateValue = templateValue.call(vm)
                }
				if (templateValue != null) {
					result = result.replace(
						"{{" + templates[i] + "}}",
						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[i]
				);
				if (templateValue != null) {
					vnode.elm.value = templateValue;
				}
			}
		}
	} else {
		for (let i = 0; i < vnode.children.length; i++) {
			renderNode(vm, vnode.children[i]);
		}
	}
}

这样就可以完成渲染

同时当数据修改后,可以找到使用到这个数据的节点,并且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[i].includes(':') ? filterAttrNames[i].split(':')[1] : filterAttrNames[i].split('@')[1];
            von(vm, vnode, eventName, vnode.elm.getAttribute(filterAttrNames[i]))
        }
    }
}

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[i] = getTemplateValue([vm._data, vnode.env],argList[i])
    }
    let method = getValue(vm._methods, funName);
    if (method) {
        vnode.elm.addEventListener(eventName, method.bind(vm, ...argList))
    }
}

生命周期函数

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



43 个回复

倒序浏览
666666666666666666666666
回复 使用道具 举报
回复 使用道具 举报
厉害了                    
回复 使用道具 举报
感谢分享  棒棒哒
回复 使用道具 举报
66666666666666
回复 使用道具 举报
可以的,奥利给!!!
回复 使用道具 举报

感谢分享  棒棒哒
回复 使用道具 举报

可以的,奥利给!!!
回复 使用道具 举报
好人一生平安
回复 使用道具 举报


                                                                                                  
键盘敲烂,月薪过万
回复 使用道具 举报
可以的,奥利给!!!
回复 使用道具 举报
66666666666666666666666
回复 使用道具 举报
6666666666666666
回复 使用道具 举报
666666666666
回复 使用道具 举报
加油加油加油!!!
回复 使用道具 举报

可以的,奥利给!!!
回复 使用道具 举报
666666666666666
回复 使用道具 举报
666666666666666666666666
回复 使用道具 举报
回复 使用道具 举报
123下一页
您需要登录后才可以回帖 登录 | 加入黑马