# vue中MVVM模式怎么实现的知道吗?
vue框架是典型的MVVM(Model-View-ViewModel)模式的前端框架,它最大的特点就是,View和ViewModel之间做了双向数据绑定,当Model发生变化的时候,绑定Model数据的View会随之发生变化,当View发生变化时,对应的Model也会随之变化。
我们现在就来实现一个小巧简单的mvvm框架吧~ 初步的文件结构设计如下:
# index.html
页面框架,创建MVVM实例,挂载页面元素和数据。分别引入watcher.js
、observer.js
、compile.js
、mvvm.js
文件,后面会介绍具体的作用。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>My MVVM</title>
</head>
<body>
<div id="app">
<!-- 双向数据绑定 -->
<input type="text" v-model='msg'> {{msg}}
</div>
</body>
<script src="./watcher.js"></script>
<script src="./observer.js"></script>
<script src="./compile.js"></script>
<script src="./mvvm.js"></script>
<script>
// mvvm如何实现?
// vue中实现双向绑定 1.模板编译 2.数据劫持 3.Watcher
let vm = new MVVM({
el: '#app', //el:document.getElementById('app')
data: {
msg: 'hello'
}
})
</script>
</html>
# mvvm.js
mvvm.js定义了MVVM
类,首先把可用的属性全部挂载到实例上。如果有要编译的模板就开始编译,涉及到数据劫持、数据代理、模板编译三个阶段。其中,数据劫持是把对象的所有属性改为get、set
;代理数据阶段让this.$data
下的数据都代理到this(MVVM)
中,能让用户方便地从this.xx
进行取值,而不是需要从this.$data.xx
进行取值;模板编译阶段是使用数据和元素进行编译,返回含有完整数据内容的页面。
class MVVM {
constructor(options) {
// 先把可用的东西挂载到实例上
this.$el = options.el
this.$data = options.data
// 如果有要编译的模板就开始编译
if (this.$el) {
new Observer(this.$data) // 数据劫持,把对象的所有属性改为get、set
this.proxyData(this.$data)
new Compile(this.$el, this) // 用数据和元素进行编译
}
}
// 代理数据,因为用户可能要通过this.msg取值,而不是this.$data.msg取值
proxyData(data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get() {
return data[key]
},
set(newVal) {
data[key] = newVal
}
})
})
}
}
# compile.js
compile.js是将数据和页面元素组合起来,返回含有对应数据内容的完整页面,用于浏览器渲染。如果存在模板,则需要进行以下几步:
- 把真实的dom移入到内存,放到fragment(
node2Fragment(el)
函数) - 进行编译(
compile(fragment)函数
),提取元素节点和文本节点,针对元素节点和文本节点实行不同的编译方式 a)如果是元素节点,则需要先进行元素节点编译(compileElement(node)
函数),再进行递归 b)如果是文本节点,则直接编译文本节点(compileText(node)
函数),提取{{}}中的内容进行数据填充 - 把编译好的element放回页面
class Compile {
constructor(el, vm) {
// 判断el是否是元素节点,如果是html的元素节点则直接返回,否则使用document.querySelector找到el节点并返回
this.el = this.isElementNode(el) ? el : document.querySelector(el)
this.vm = vm
if (this.el) {
// 1. 先把真实的dom移入到内存,放到fragment
let fragment = this.node2Fragment(this.el)
// 2. 编译——提取想要的元素节点和文本节点 v-model {{}}
this.compile(fragment)
// 3. 把编译好的element放回页面
this.el.appendChild(fragment)
}
}
// 辅助方法
isElementNode(node) {
return node.nodeType === 1
}
isDirective(name) {
return name.includes('v-')
}
// 核心方法
// 将el元素内容全部放入内存
node2Fragment(el) {
let fragment = document.createDocumentFragment() //文档碎片
let firstChild
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild)
}
return fragment
}
// 编译
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)
}
})
}
// 编译元素 v-model、v-text等
compileElement(node) {
let attrs = node.attributes
Array.from(attrs).forEach(attr => {
// 判断属性名字是否包含v-
let attrName = attr.name
if (this.isDirective(attrName)) {
let expr = attr.value //expr是指令的值
// node this.vm.$data expr
//取到v-后面的名称,如v-model的model,v-text的text等等
// let type = attrName.slice(2)
let [, type] = attrName.split('-')
Util[type](node, this.vm, expr)
}
})
}
// 编译文本,{{}}
compileText(node) {
let expr = node.textContent
let reg = /\{\{([^}]+)\}\}/g //匹配{{}}
if (reg.test(expr)) {
const type = 'text'
Util[type](node, this.vm, expr)
}
}
}
Util = {
// 获取实例上对应的数据,如msg.a.b=>'hello'
// msg.a.b=>this.$data.msg=>this.$data.msg.a=>this.$data.msg.a.b
getVal(vm, expr) {
expr = expr.split('.')
return expr.reduce((prev, next) => {
return prev[next]
}, vm.$data)
},
// 获取编译文本后的结果,如{{msg}}=>'hello'
getTextVal(vm, expr) {
return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
// arguments[1]是正则匹配括号内容,如{{msg}}的msg
return this.getVal(vm, arguments[1])
})
},
// 赋值
// 例如给msg.a.b赋新值,则取到最后再赋value值
setVal(vm, expr, value) {
expr = expr.split('.')
return expr.reduce((prev, next, curIndex) => {
if (curIndex === expr.length - 1) {
return prev[next] = value
}
}, vm.$data)
},
// 文本处理
text(node, vm, expr) {
let updateFn = this.update['textUpdater']
// 拿到{{a}}{{b}}的a、b
expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
new Watcher(vm, arguments[1], newVal => {
// 如果数据变化了, 文本节点需要重新获取依赖的数据来更新文本节点
updateFn && updateFn(node, this.getTextVal(vm, expr))
})
})
let value = this.getTextVal(vm, expr)
updateFn && updateFn(node, value)
},
// 输入框处理
model(node, vm, expr) {
let updateFn = this.update['modelUpdater']
// 这里应该加一个监控,数据变化时,应该调用watcher的callback,将新值传递过来
new Watcher(vm, expr, newVal => {
updateFn && updateFn(node, this.getVal(vm, expr))
})
updateFn && updateFn(node, this.getVal(vm, expr))
node.addEventListener('input', e => {
let newVal = e.target.value
this.setVal(vm, expr, newVal)
})
},
update: {
// 文本更新
textUpdater(node, value) {
node.textContent = value
},
// 输入框更新
modelUpdater(node, value) {
node.value = value
}
}
}
# observer.js
observer.js是将页面中绑定的数据全部变为响应式,即将data数据原有的属性改为get和set的形式,使用defineReactive
函数进行数据劫持(这里要注意,如果劫持的是对象,还要对对象内的属性继续劫持)。在defineReactive
函数中,我们针对每个数据都新建了Dep的实例,Dep是典型的用来发布订阅的类(见下文的watcher.js),可以用来添加订阅者信息和触发数据更新。在这个函数中,使用Object.defineProperty
进行了数据劫持,在数据发生变化时(对应set),通知所有该数据的订阅者数据变化了,会让对应的订阅者进行更新操作。
class Observer {
constructor(data) {
this.observe(data)
}
// 将data数据原有的属性改为get和set的形式
observe(data) {
if (!data || typeof data !== 'object') return
Object.keys(data).forEach(key => {
// 开始劫持
this.defineReactive(data, key, data[key])
// 如果劫持的是对象,还要对对象内的属性继续劫持
this.observe(data[key])
})
}
// 定义响应式
defineReactive(data, key, value) {
let _this = this
let dep = new Dep() //每个变化的数据都会对应一个数组,这个数组是存放所有更新的操作
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
Dep.target && dep.addSub(Dep.target)
return value
},
set(newValue) {
if (newValue !== value) {
// 设置新值时,如果是对象仍然需要劫持
_this.observe(newValue)
value = newValue
dep.notify() //通知所有订阅者数据变化了
}
}
})
}
}
# watcher.js
watcher.js定义了观察者类,用来给需要变化的dom元素增加观察者。使用新值和旧值进行比对,如果发生变化,执行对应的方法(如更新页面)
class Watcher {
constructor(vm, expr, cb) {
this.vm = vm
this.expr = expr
this.cb = cb
this.value = this.get()
}
// 获取实例上对应的数据,如msg.a.b=>'hello'
getVal(vm, expr) {
expr = expr.split('.')
return expr.reduce((prev, next) => {
return prev[next]
}, vm.$data)
}
get() {
Dep.target = this
let value = this.getVal(this.vm, this.expr)
Dep.target = null
return value
}
// 对外暴露的方法
update() {
let newVal = this.getVal(this.vm, this.expr)
let oldVal = this.value
if (newVal !== oldVal) {
this.cb(newVal)
}
}
}
// 发布订阅
class Dep {
constructor() {
// 订阅数组
this.subs = []
}
// 添加订阅
addSub(watcher) {
this.subs.push(watcher)
}
notify() {
this.subs.forEach(watcher => {
watcher.update()
})
}
}
测试html页面,可以成功实现mvvm,view和model已经完成了双向绑定。大功告成~!