什么是MVVM
MVVM 设计模式,是由 MVC、MVP 等设计模式进化而来其中:
- M - 数据模型(Model),简单的JS对象。
- VM - 视图模型(ViewModel),连接Model与View。
- V - 视图层(View),呈现给用户的DOM渲染界面。
以上是MVVM模式的示例图,核心设计是当view修改时,即用户在页面dom上交互操作时,通过viewModel的机制,可以实时修改model数据。同理,当时js实例(model)修改,时,通过viewModel可以立即,更新渲染在view上。
VUE是MVVM吗?
其实这是业内一个公开的无需讨论的问题,因为VUE官网里就有提到。防止有的朋友还不理解,贴出官网中相关部分:
vue在设计上是遵循MVVM模型的,可是他留了一个口,就是ref。用户可以通过ref直接操作dom,跳过viewModel的环节。所以说VUE没有完全遵循MVVM模型。
手写MVVM
template模板
目前业内大大小小的框架其实不少,但国内使用最广泛是VUE,因此这里我们的模仿对象也选择VUE。在html模板部分用{{}}定义。
...
<body>
<div id="app">
<input type="text" v-model='name'>
<input type="text" v-model='age'>
<h2>姓名是{{ name }}</h2>
<h2>年龄是{{age}}</h2>
</div>
</body>
...
复制代码
我们在用id定义一个dom节点作为渲染入口,这里简单的用2个输入框作为例子。在2个输入框中输入值,会即时渲染下方的h2标签内容。整理的写法跟vue一致。
渲染入口
定义一个渲染入口,并且把初始数据,渲染dom传进去。
let vm = new MyVUE({
el:'#app',
data:{
name:"张三",
age:18
}
});
复制代码
渲染类
可以肯定的是MyVue是一个类,我们先来确认MyVue需要做什么?
- 找到传进来的dom选择内容,并能正确找到该dom元素。
- 把传进来的data管理起来
- 把data与页面模板中匹配的部分关联起来
- 把模板内容真正渲染到dom上
根据以上的信息,我们先定以下内容:
class MyVue {
constructor(options) {
this.$el = document.querySelector(options.el);//获取页面上的dom入口
this.$data = options.data; //初始化data
this.observe(this.$data); //观察管理data内容
this.nodeToFragment(this.$el, this); //把我们的模板node渲染到dom上
}
observe(){
...
}
nodeToFragment(){
...
}
...
}
复制代码
观察管理
思路是递归观察整个data,并数据劫持每一项。
observe(data) {
// 数据校验,只处理对象和数组
if (typeof data !== 'object') return;
for (const key in data) {
// 数据劫持对象中的项
this.defineReactive(data, key, data[key]);
}
}
复制代码
这里延申出了一个新的方法,专门做数据劫持的工作。这里我们用vue2同款思路defineProperty。(Vue3用的是proxy)。每个value都会有自己的订阅实例和watch实例。
defineReactive(obj, key, value) {
const ctx = this;
// 递归处理obj
ctx.observe(value);
//需要一个订阅通知机制
let dep = new Dep;
Object.defineProperty(obj, key, {
get() {
if (Dep.watchIns) {
//加入订阅
dep.addSub(Dep.watchIns)
}
return value
},
set(newVal) {
// 当值被修改后,需要做三件事。1.把原来的value换成新的。2.重新观察新的value。3.触发通知。
if (value !== newVal) {
value = newVal;
ctx.observe(value);
dep.notify();
}
}
})
}
复制代码
订阅通知
我们用一个类专门做订阅通知的工作,内容很简单就是管理一个订阅事件队列。可以在这边插入通知触发后需要做的事件,当通知发生后,遍历事件队列触发事件。在defineReactive中可以看到我们是在每一个value的观察中都会new 一个新的Dep的,让每个value的事件队列独立。
class Dep {
constructor() {
this.subs = [];
}
addSub(sub) {
this.subs.push(sub)
}
notify() {
this.subs.forEach(item => {
// 让对应的事件 做更新操作
item.update();
})
}
}
复制代码
编译
这样我们的第一步,管理数据就算完成了。接下来开始编译页面模板。编译我们做的就是把入口dom下的所有内容转成fragment,进行编译,然后重新渲染dom。
nodeToFragment(node, vm) {
let child;
//创建文档碎片
let fragment = document.createDocumentFragment();
// while循环 把node中的每一个子节点 都转移到了fragment上
while (child = node.firstChild) {
fragment.appendChild(child)
// 编译内容
this.compile(child, vm)
}
// 转移完成之后 页面中的node节点里边就没有元素了
// 把fragment上的所有节点还给了node
node.appendChild(fragment)
}
复制代码
compile方法做的是具体的页面模板内容校验,如果找到需要监听的变量,则建立监听。这里主要分2类,如果node内容是普通文本。则直接建立监听,触发通知时,修改文本内容。如果node内容是input,则建立监听之后,需要给dom加上事件,如果dom事件触发就修改data内容。从而触发通知,修改文本内容。
compile(node) {
const vm = this;
// 判断node的节点类型 看他是不是元素节点
if (node.nodeType == 1) {
//证明是元素节点 那么 我们要去处理行内属性
let attrs = node.attributes;// 所有的行内属性,然后看那个是v-开头的
[...attrs].forEach(item => {
//校验这个属性是不是v-开头的
if (/^v-/.test(item.nodeName)) {
// 获取变量名
let valName = item.nodeValue;
// 如果data中没有该变量则报错
if (typeof vm.$data[valName] === 'undefined') throw valName + ' is not defined';
// 监听变量
new Watcher(node, this, valName)
// 获取对应的值
let val = vm.$data[valName];
//把值这放到input框中;
node.value = val;
//建立dom的监听
node.addEventListener('input', (e) => {
//要把更改之后的input框的内容 设置给name
vm.$data[valName] = e.target.value
})
}
});
[...node.childNodes].forEach(item => {
//针对有子节点的元素 接着进行编译
this.compile(item);
})
} else {
// 这是文本节点
let str = node.textContent;
// 把原来的模板字样存起来
node.str = str;
//校验我们的{{}}语法
if (/{{(.+?)}}/.test(str)) {
str = str.replace(/{{(.+?)}}/g, (a, b) => {
// 获取{{}}中的变量
b = b.replace(/^ +| +$/g, '');// 去除首尾空格
// 如果data中没有该变量则报错
if (typeof vm.$data[b] === 'undefined') throw b + ' is not defined';
// 监听变量
new Watcher(node, vm, b)
return vm.$data[b]
})
node.textContent = str
}
}
}
复制代码
观察者
观察者的作用是给dom上每一个绑定了的node建立观察,整体逻辑是用watchIns标识,触发value 的get方法,添加订阅,然后去掉标识。当时通知触发时,会触发update方法。
class Watcher {
constructor(node, vm, key) {
// 这里用watchIns标识,然后触发data的get,实现监听。
Dep.watchIns = this;
this.node = node;
this.vm = vm;
this.key = key;
this.setInit();
// 结束后把watchIns标识去掉。
Dep.watchIns = null;
}
update() {
this.setInit();
if (this.node.nodeType == 1) {
// 内容是input
this.node.value = this.value
} else {
let str = this.node.str;
str = str.replace(/{{(.+?)}}/g, (a, b) => {
b = b.trim();
return this.vm.$data[b]
})
this.node.textContent = str
}
}
setInit() {
this.value = this.vm.$data[this.key]
}
}
复制代码
补充:这里的node.str会保持是一开的{{name}},而node.textContent则可以真正渲染在dom中。
整体代码
// 订阅器
class Dep {
constructor() {
this.subs = [];
}
addSub(sub) {
this.subs.push(sub)
}
notify() {
this.subs.forEach(item => {
// 让对应的事件 做更新操作
item.update();
})
}
}
// 观察者
class Watcher {
constructor(node, vm, key) {
// 这里用watchIns标识,然后触发data的get,实现监听。
Dep.watchIns = this;
this.node = node;
this.vm = vm;
this.key = key;
this.setInit();
// 结束后把watchIns标识去掉。
Dep.watchIns = null;
}
update() {
this.setInit();
if (this.node.nodeType == 1) {
// 内容是input
this.node.value = this.value
} else {
let str = this.node.str;
str = str.replace(/{{(.+?)}}/g, (a, b) => {
b = b.trim();
return this.vm.$data[b]
})
this.node.textContent = str
}
}
setInit() {
this.value = this.vm.$data[this.key]
}
}
// 我们的框架入口
class MyVue {
constructor(options) {
this.$el = document.querySelector(options.el);
this.$data = options.data;
this.observe(this.$data);
this.nodeToFragment(this.$el, this);
}
observe(data) {
// 数据校验,只处理对象和数组
if (typeof data !== 'object') return;
for (const key in data) {
// 数据劫持对象中的项
this.defineReactive(data, key, data[key]);
}
}
defineReactive(obj, key, value) {
const ctx = this;
// 递归处理obj
ctx.observe(value);
let dep = new Dep;
Object.defineProperty(obj, key, {
get() {
if (Dep.watchIns) {
//Dep.target 就是watcher实例
dep.addSub(Dep.watchIns)
}
return value
},
set(newVal) {
if (value !== newVal) {
value = newVal;
ctx.observe(value);
dep.notify();
}
}
})
}
nodeToFragment(node, vm) {
let child;
//创建文档碎片
let fragment = document.createDocumentFragment();
// while循环 把node中的每一个子节点 都转移到了fragment上
while (child = node.firstChild) {
fragment.appendChild(child)
// 编译内容
this.compile(child, vm)
}
// 转移完成之后 页面中的node节点里边就没有元素了
// 把fragment上的所有节点还给了node
node.appendChild(fragment)
}
compile(node) {
const vm = this;
// 判断node的节点类型 看他是不是元素节点
if (node.nodeType == 1) {
//证明是元素节点 那么 我们要去处理行内属性
let attrs = node.attributes;// 所有的行内属性,然后看那个是v-开头的
[...attrs].forEach(item => {
//校验这个属性是不是v-开头的
if (/^v-/.test(item.nodeName)) {
// 获取变量名
let valName = item.nodeValue;
// 如果data中没有该变量则报错
if (typeof vm.$data[valName] === 'undefined') throw valName + ' is not defined';
// 监听变量
new Watcher(node, this, valName)
// 获取对应的值
let val = vm.$data[valName];
//把值这放到input框中;
node.value = val;
//建立dom的监听
node.addEventListener('input', (e) => {
//要把更改之后的input框的内容 设置给name
vm.$data[valName] = e.target.value
})
}
});
[...node.childNodes].forEach(item => {
//针对有子节点的元素 接着进行编译
this.compile(item);
})
} else {
// 这是文本节点
let str = node.textContent;
// 把原来的模板字样存起来
node.str = str;
//校验我们的{{}}语法
if (/{{(.+?)}}/.test(str)) {
str = str.replace(/{{(.+?)}}/g, (a, b) => {
// 获取{{}}中的变量
b = b.replace(/^ +| +$/g, '');// 去除首尾空格
// 如果data中没有该变量则报错
if (typeof vm.$data[b] === 'undefined') throw b + ' is not defined';
// 监听变量
new Watcher(node, vm, b)
return vm.$data[b]
})
node.textContent = str
}
}
}
}
// 渲染入口
let vm = new MyVue({
el: '#app',
data: {
name: "张三",
age: 18,
}
});
复制代码
总结
实践下来,总体的思路其实就是MVVM的模式,我们先是监听model中的data,利用defineProperty为他建立一个订阅列表。再给View中的用到data的内容建立watch,当view中的input等交互发生,会让model修改,从而修改view中的文本内容。
为了实现这套流程,我们用到了一些关键技术:
- 把dom转为fragment,为的是减少直接在dom上的修改。
- 实现订阅通知机制,实际上利用的是发布订阅者的设计模式。
- 实现变量的监听用的是defineProperty方法。
- 为了每次渲染dom模板我们需要把原来的模板内容保存在node本身。