作为一位前端开发者,不管是面试还是在开发过程中,总能耳熟能详的听到MVVM
作为一个前端面试官,我通常都会对前端面试者说:谈谈你对MVVM的理解?
那么,到底什么是MVVM呢?它是怎么实现的呢?
MVVM是什么
- M - 数据模型(Model),简单的JS对象
- VM - 视图模型(ViewModel),连接Model与View
- V - 视图层(View),呈现给用户的DOM渲染界面
我们可以看出最核心的就是ViewModel,它主要的作用:对View中DOM元素的监听和对Model中的数据进行绑定,当View变化会引起Modal中数据的改动,Model中数据的改动会触发View视图重新渲染,从而达到数据双向绑定的效果,这也是Vue最为核心的特性。其中view改变modal,可以通过绑定事件更新数据,那model更新view,是怎么去更新呢?
话不多说,我们直接上例子
这是vue内部实现的
那么如何自己实现一个MVVM呢
源码git地址
实现自己的MVVM
要实现mvvm,我们应该实现以下几点:- 实现一个模板编译-Compile,对每个编译元素和文本节点进行扫描编译,以及绑定相应的更新函数
- 实现一个数据劫持-Observer,将对象的每个属性进行监听,如有变动可拿到最新值并通知订阅者
- 实现一个-Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图
如图:
模板编译-Compile
compile是解析模板指令和文本,将模板中的变量替换成数据,然后初始化渲染整页面视图,并绑定相关的更新
函数,添加监听数据的订阅者,一旦数据变动,收到通知,更新视图首先compile做了哪几件事情呢?
- 将当前根节点的所有节点放入到内存中
- 编译-提取文本节点和元素节点
- 将编译好的节点放回到真实DOM里
// 模板编译
class Compile {
constructor(el, vm) {
/**
* @param {*} el 元素 注意:el选项中有可能是‘#app’字符串也有可能是document.getElementById('#app')
* @param {*} vm 实例
*/
// 判断是不是元素
this.el = this.isElementNode(el) ? el : document.querySelector(el)
this.vm = vm
if (this.el) {
// 1.将当前根节点的所有节点放入到内存中
let fragment = this.node2Fragment(this.el)
// 2.编译-提取文本节点和元素节点
this.compile(fragment)
// 3.将编译好的节点放回到真实DOM里
this.el.appendChild(fragment)
}
}
// 判断编译文本的正则
isTextReg() {
return /\{\{([^}]+)\}\}/g
}
// 判断是不是元素
isElementNode(node) {
return node.nodeType === 1
}
// 判断是不是指令
isDirective(name) {
return name.includes('v-')
}
compileElement(node) {
// 获取元素节点的所有属性
let attrs = node.attributes
Array.from(attrs).forEach(attr => {
let attrName = attr.name // 属性名
let expr = attr.value
// 判断是否存在v-指令
if (this.isDirective(attrName)) {
// todo...
let [, type] = attrName.split('-')
// node message.title this.vm.$data.message.title
compileUtil[type](node, expr, this.vm)
}
})
}
compileText(node) {
// 获取文本节点,并将大括号中的内容替换成数据
let expr = node.textContent
if (this.isTextReg().test(expr)) {
// todo...
compileUtil['text'](node, expr, this.vm)
}
}
compile(fragment) {
// 从文档碎片中获取所有的节点[注意:这里的childNodes只是所有的第一层节点]
let childNodes = fragment.childNodes
Array.from(childNodes).forEach(node => {
// 判断当前节点是文本还是元素
if (this.isElementNode(node)) {
// 元素节点,还需要深入编译
this.compileElement(node)
this.compile(node)
} else {
// 文本节点
this.compileText(node)
}
})
}
node2Fragment(el) {
// 创建一个文档碎片,将所有的孩子都写入到该碎片中
let fragment = document.createDocumentFragment()
let firstChild
// dom节点一个一个的移入内存中
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild)
}
// 内存中的节点,页面上的节点都移除了
return fragment
}
}
compileUtil = {
// 获取值
getVal(expr, vm) {
// message.title
expr = expr.split('.')
return expr.reduce((prev, next) => {
return prev[next]
}, vm.$data)
},
setVal(expr, value) {
expr = expr.split('.')
return expr.reduce((prev, next, currentIndex) => {
if (currentIndex == expr.length - 1) {
return prev[next] = value
}
return prev[next]
}, vm.$data)
},
getTextVal(expr, vm) {
return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
return this.getVal(arguments[1], vm)
})
},
text(node, expr, vm) {
const updateFn = this.updater['textUpdater']
expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
new Watcher(arguments[1], vm, (newValue) => {
// 当数据更新了,文本节点需要重新获取依赖的属性更新文本中的内容(A的值变了,需要重新获取A的值再加上B的值,重新渲染)
updateFn && updateFn(node, this.getTextVal(expr, vm))
})
})
updateFn && updateFn(node, this.getTextVal(expr, vm))
},
/**
* 处理v-model
* @param {*} node 对应的节点
* @param {*} expr 表达式
* @param {*} vm 当前实例
*/
model(node, expr, vm) {
// 不同的指令调取不同的方法
const updateFn = this.updater['modelUpdater']
// 数据更新,会调用watcher的update方法,重新编译元素
new Watcher(expr, vm, (newValue) => {
updateFn && updateFn(node, this.getVal(expr, vm))
})
node.addEventListener('input', (e) => {
let newValue = e.target.value
this.setVal(expr, newValue)
})
updateFn && updateFn(node, this.getVal(expr, vm))
},
updater: {
// 文本更新
textUpdater(node, value) {
node.textContent = value
},
// 元素更新
modelUpdater(node, value) {
node.value = value
}
}
}
数据劫持-Observer
// 数据劫持
class Observer {
constructor(data) {
this.observer(data)
}
observer(data) {
// 判断是否是对象,才监测
if (data instanceof Object) {
for (let key in data) {
this.defineReactive(data, key, data[key])
}
}
}
defineReactive(obj, key, value) {
const _this = this
// 该数组存放所有更新的数据
let dep = new Dep()
// 如果value还是对象,还需要观察
this.observer(value);
Object.defineProperty(obj, key, {
get() {
Dep.target && dep.addSub(Dep.target)
return value
},
set(newValue) {
if (newValue != value) {
_this.observer(newValue); // 如果赋值的也是对象的话 还需要观察
value = newValue
// 通知所有的依赖,数据更新了
dep.notify()
}
}
})
}}
// 依赖收集器
class Dep {
constructor() {
this.subs = []
}
// 添加依赖
addSub(watcher) {
this.subs.push(watcher)
}
// 通知依赖更新
notify() {
this.subs.forEach(watcher => {
watcher.update()
})
}
}
发布订阅-Watcher
class Watcher {
constructor(expr, vm, cb) {
this.vm = vm
this.expr = expr
this.cb = cb // 更新回调
this.value = this.get() // 保存老数据
}
getVal(expr, vm) {
expr = expr.split('.')
return expr.reduce((prev, next) => {
return prev[next]
}, vm.$data)
}
get() {
// 将当前实例赋值给依赖收集的容器
Dep.target = this
// this.expr会进入到getter/setter
let value = this.getVal(this.expr, this.vm)
Dep.target = null // 更新完再将依赖收集清空
return value
}
// 更新函数
update() {
let newValue = this.get(this.expr, this.vm)
let oldValue = this.value
if (newValue != oldValue) {
this.cb()
}
}
}
资源整合-MVVM
MVVM作为数据绑定的入口,整合Observer、Compile和Watcher三者,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据model变更的双向绑定效果。
class MVVM {
constructor(options) {
this.$el = options.el
this.$data = options.data
// 有根节点,再编译
if (this.$el) {
new Observer(this.$data)
// 将this.$data进行代理
this.proxyData(this.$data)
new Compile(this.$el, this)
}
}
proxyData(data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get() {
return data[key]
},
set(newValue) {
return data[key] = newValue
}
})
})
}
}MVVM中有proxyData这样一个函数,为什么呢?
【注意】: 有这样一个问题,在开发中是通过实例+属性(vm.title)来获取数据,而我们自己写的MVVM是通过实例+$data+属性(vm.$data.title)来获取数据,所有为了方便,我们需要将vm.$data代理到vm上面
这样的话,我们实现了MVVM整个的一个实现过程
问题
- Object.defineProperty() 有那些缺点?
- 实现数组的一个监听?
- Vue3 中是如何用 Proxy 实现的?
这些问题,有时间的时候会慢慢补充的,大家可以持续关注噢~~~