项目地址
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 入口函数
声明构造函数Sthpath: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方法等
|