实现一个迷你MVVM
在近几年,MVVM模式一直很火热,其全称为"Model-View-ViewModel",MVVM火的主要原因就是在用这种模式开发的,更多的只需要关注数据层的东西,不需要花额外的时间去维护视图,包括angular/Vue都是这种开发模式,但是两者的原理是截然不同的,现在参考Vue的实现原理,简单实现一个MVVM框架,支持的指令有"vm-modle"、“vm-click”、“vm-bind”,代码量大概在300行左右,先来看下用法:
// HTML
...
<div id="app">
<input type="text" class="text-filed" vm-model="text" />
<p class="bind-text" vm-bind="text"></p>
<div class="click-area" vm-click="clicked()">点我</div>
<div class="click-area" vm-click="clickWitharguments(text)">点我(带参数)</div>
</div>
...
// javascript
window.onload = function() {
var clickTimes = 0, e;
// 调用MVVM
MVVM({
el: document.querySelector("#app"),
data: {
text: "I'm an attribute named 'text' under data"
},
methods: {
clicked: function () {
e = event;
clickTimes ++;
e.target.innerHTML += "<p>第" + clickTimes + "次点击 - " + Date.now() + "</p>";
},
clickWitharguments: function (text) {
e = event;
e.target.innerHTML += "<p>此时data.text = " + text + "</p>";
}
}
});
};
上面就是一个简单的调用了,下面我们先实现几个工具方法和定义一些基本变量:
// MVVM.js
// 用匿名函数自执行的方式,前面加分号的原因是为了防止压缩后的保存
;(function(root) {
// ...
// 匹配指令开头("vm-click","vm-model"等)
var direcivePrefix = /^vm\-/,
// 缓存document
doc = document,
// 缓存document.body
body = doc.body,
// 指令Map对象,用于分类存储每个表达式,方便后期更新视图
dirsMap = {},
// 缓存Array.prototype
arr2 = [],
// 缓存Object.prototype
obj2 = {};
/**
* 获取对象上的类名
* @param obj
* @return {String}
*/
function typeOf(obj) {
return obj2.toString.call(obj).slice(8, -1);
}
/**
* 把元素上绑定的指令转换成数组返回
* @param el
* @return {Array.<Object>}
*/
function mapAttributeToArray(el) {
var res = [],
attributes, i, len;
if (el && el.nodeType === 1) {
attributes = arr2.slice.call(el.attributes);
attributes.forEach(function (attr) {
if (direcivePrefix.test(attr.name)) {
res.push({
name: attr.name,
value: attr.value,
el: el
});
}
});
}
return res;
}
/**
* 转换成驼峰写法(vm-bind -> VMBind)
* @param str
*/
function toCamelCase(str) {
if (str.length) {
return str.split("-").map(function (str, index) {
if(index > 0) {
return str.slice(0,1).toUpperCase() + str.slice(1);
}
return str.toUpperCase();
}).join("");
}
return "";
}
/**
* 递归扫描节点
* @param rootEl 根节点
* @param callback 扫描后的回调
*/
function scanNode(rootEl, callback) {
var child = arr2.slice.call(rootEl.childNodes),
deepChild;
if(child.length) {
child.forEach(function (el) {
if(el.nodeType === 1 && !el.vmcomplied) {
callback(el);
scanNode(el, callback);
}
});
}
}
/**
* ES5(Object.defineProperty)
* @param target
* @param key
*/
function defineProperty(target, key) {
// 同时设置一个"_" + key的属性值,后面取值直接用
target["_" + key] = target[key];
Object.defineProperty(target, key, {
get: function() {
return this["_" + key];
},
set: function(newV) {
// 用之前设置的"_" + key的值来比较
if(newV !== this["_" + key]) {
this["_" + key] = newV;
this[key] = newV;
// 取得当前属性绑定的指令并且判断,更新视图
if(typeOf(dirsMap[key]) === "Array" && dirsMap[key].length) {
dirsMap[key].forEach(function(dir) {
dir.update();
});
}
}
}
});
return target;
}
// ...
})(window)
上面实现了一些工具方法,完成的功能主要有扫描子节点,把元素上绑定 的属性绑定的指令取出来变成一个数组,转驼峰等等,下面就是MVVM的入口了:
// MVVM.js
// ...
/**
* MVVM构造函数
* @param opt
* @return {MVVM.init}
* @constructor
*/
function MVVM(opt) {
// 模仿jQuery中的无"new"操作符
return new MVVM.fn.init(opt);
}
MVVM.fn = MVVM.prototype = {
// 修正原型下的构造器
constructor: MVVM,
/**
* MVVM入口
* @param opt
*/
init: function (opt) {
// 参数校验,转换
this.el = (opt.el && opt.el.nodeType === 1) ? opt.el : body;
this.data = (typeOf(opt.data) === "Object") ? opt.data : {};
this.methods = (typeOf(opt.methods) === "Object") ? opt.methods : {};
this.scan();
},
/**
* 扫描编译
*/
scan: function () {
// 从根节点开始扫描
scanNode(this.el, function (currentEl) {
// 取得当前元素上的指令数组
var dirList = mapAttributeToArray(currentEl);
if(dirList.length) {
// 循环编译指令
dirList.forEach(function (dir) {
dir.dirName = toCamelCase(dir.name);
if(directiveMap[dir.dirName]) {
// 实例化指令
dir.dirIns = new directiveMap[dir.dirName](currentEl, dir.value, this.data, this.methods);
// 给当前属性指定getter/setter
if(this.data[dir.value]) {
defineProperty(this.data, dir.value);
}
// dirsMap[dir.value]类型判断
if(typeOf(dirsMap[dir.value]) !== "Array") {
dirsMap[dir.value] = [];
}
// vm-modle之类不需要更新视图
if(dir.name !== "vm-model") {
dirsMap[dir.value].push(dir.dirIns);
}
} else {
// 没有找到相关指令构造函数
throw new Error("unsupported directive" + dir.name + "!");
}
}, this);
}
}.bind(this));
}
};
// 修改MVVM.fn.init的prototype
MVVM.fn.init.prototype = MVVM.fn;
// 挂载到全局对象
root.MVVM = MVVM;
// ...
上面就是MVVM.js的全部内容了,MVVM的入口算是完成了,下面我们一起构造之前提到的指令:
// directive.js
;(function (root) {
// 匹配指令值中的"clicked()"后面的"()"
var bracketsReg = /\(\)/,
// 匹配指令值中的"clickWitharguments(text)"后面的"(text)"
bracketsArguReg = /\([\s\S]+\)/;
/**
* vm-bind指令
* @param el
* @param expr
* @param data
* @param methods
* @constructor
*/
function VMBind(el, expr, data, methods) {
this.el = el;
this.expr = expr;
this.data = data;
this.methods = methods;
this.init();
}
VMBind.prototype = {
constructor: VMBind,
/**
* 初始化方法
*/
init: function () {
this.el.textContent = this.data["_" + this.expr];
},
/**
* 更新视图
*/
update: function () {
this.init();
}
};
/**
* vm-model指令
* @param el
* @param expr
* @param data
* @param methods
* @constructor
*/
function VMModel(el, expr, data, methods) {
this.oldVal = "";
this.el = el;
this.expr = expr;
this.data = data;
this.methods = methods;
this.init();
}
VMModel.prototype = {
constructor: VMModel,
/**
* 初始化方法
*/
init: function () {
var currentVal;
this.oldVal = this.data[this.expr];
this.el.value = this.oldVal;
// input元素的校验
if (this.el.tagName.toLowerCase() === "input") {
this.el.addEventListener("keyup", function () {
currentVal = this.el.value;
// 输入值较之前有变化
if (currentVal !== this.oldVal) {
this.update(currentVal);
this.oldVal = currentVal;
}
}.bind(this), false);
}
},
/**
* 更新model中的相关属性值,触发其他指令实例下的update方法
*/
update: function (newV) {
this.data[this.expr] = newV;
}
};
/**
* vm-click指令
* @param el
* @param expr
* @param data
* @param methods
* @constructor
*/
function VMClick(el, expr, data, methods) {
this.el = el;
this.expr = expr;
this.data = data;
this.methods = methods;
this.init();
}
VMClick.prototype = {
constructor: VMClick,
/**
* 初始化方法
*/
init: function () {
// 取得方法相对于methods中的指针
var callback = this.methods[this.expr.replace(bracketsReg, "").replace(bracketsArguReg, "")],
data = this.data,
tmp = "",
args = [];
// 类型校验
if (typeof callback === "function") {
this.el.addEventListener("click", function (e) {
// 参数处理,当前指令对应的值是"abc(de)"而不是"abc"的形式
if(!bracketsReg.test(this.expr) && bracketsArguReg.test(this.expr)) {
tmp = this.expr.match(bracketsArguReg)[0].replace(")", "").replace("(", "").split(",");
// 依次取得相关参数
args = tmp.map(function (name) {
return data["_" + name.trim()];
});
}
// 执行相关方法
callback.apply(root, args);
}.bind(this), false);
}
}
};
// 挂载到window对象下
root.directiveMap = {
VMBind: VMBind,
VMModel: VMModel,
VMClick: VMClick,
};
})(window)
好了,到这里我们的MVVM入口和指令都全部实现好了,下面一起看下效果吧: