理解MVVM
MVVM(Model-View-ViewModel)是基于MVC和MVP的体系结构模式,它目的在于更清楚地将用户界面(UI)的开发与 应用程序中业务逻辑和行为的开发区分开来。所以,MVVM模式的许多实现都使用声明性 数据绑定来允许从其他层分离视图上的工作。
直白一点就是,这个模式让Model,View,ViewModel不纠缠在一起,他们分工明确,这是一个很佛系的模式,而且还能保证项目在架构层面,稳定,干净。
MVVM三要素:数据响应式,模版引擎以及渲染
- 数据响应式:监听数据变化并在视图中更新
- Object.defineProperty()
- Proxy()
- 模版引擎:提供描述视图的模版语法
- 插值:{{}}
- 指令:v-for, v-if, v-on, v-model
- ...
- 渲染:如何将模板转换为html
- 模板 => vdom => dom
数据响应式原理
vue3之前是使用Object.defineProperty这个api来对要改变的数据进行拦截,添加getter和setter。
- 创建方法defineReactive,该方法接收3个参数,要拦截的数据对象obj,要设置的属性名称key, 要设置的值val。然后在Object.defineProperty方法的get方法中直接返回要设置的值,set方法中判断新进来的值是否与原来的值相等,不相等则将新值覆盖旧值,这样get里面拿到的始终是最新的值。
- 创建一个observe方法, 来递归遍历所有的obj, 动态拦截obj的所有的key
- 在observe方法中创建Observer类,这样每出现一个对象,都会创建一个Observer实例。
- 在Observer中判断传入的obj的类型,如果是数组,则需要通过hash的方法处理,暂时只处理了为对象的情况。如果是对象,则遍历对象上的每一个key然后遍历,在对所有的key做响应式处理。
描述的不够清楚,没关系,下面贴上了代码,可以结合代码和注释一起看。
/**
* 将传入的obj,动态的设置一个key,它的值是val
* @param {Object} obj 要接收的对象
* @param {String} key 要设置的键
* @param {*} val 要设置的值
*/
function defineReactive(obj, key, val) {
// * 如果原始对象的值是对象,那么还是需要递归遍历该值
observe(val);
// - 要拦截的对象obj
// - 要设置的属性名称
// - descriptor属性描述器,是一个对象,里面有set和get方法
Object.defineProperty(obj, key, {
get() {
// 返回的是最新的值
console.log('get key', key); // sy-log
return val;
},
set(v) {
// 如果老的值与新传进来的值不相等 设置为最新的值。这样get里面拿到的就是最新的值
if (val !== v) {
console.log('set key', key); // sy-log
// 因为传入的新值v可能还是一个对象, 所以需要遍历。
observe(v);
val = v;
}
},
});
}
/**
* 递归遍历obj, 动态拦截obj的所有的key
* @param {Object} obj
*/
function observe(obj) {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 每出现一个对象 创建一个Observer实例
new Observer(obj)
}
/**
* 判断obj传入的类型,并做响应式处理
*/
class Observer {
constructor(obj) {
this.value = obj
if (Array.isArray(obj)) {
// todo
} else {
this.walk(obj)
}
}
/**
* 将对象响应式处理
* @param {Object} obj 传入的对象
*/
walk(obj) {
Object.keys(obj).forEach(k => {
defineReactive(obj, k, obj[k])
})
}
}
Vue中的数据响应化
数据能响应式的改变了,接下来看看在vue中的应用。我们先来分析vue的实现思路。
- new Vue()首先初始化,用Observer劫持监听所有属性来对data执行响应式变化处理,同时用Compile对模板变执行编译,找到其中动态绑定的数据,从data中获取并初始化视图。
- 数据能渲染了,但是数据改变了还是不能实时的显示的页面上,接下来定义一个更新函数Updater和Watcher,数据变化时Watcher会调用更新函数
- data中的某个key可能在同一视图中出现多次,所以每个key都需要Dep来管理多个Watcher
- data数据变化,先找到对应的Dep,通过Watcher执行更新函数。
vue部分功能的简单实现
<div id="app">
<p>{{counter}}</p>
<p h-text="counter" @click="onclick"></p>
<p h-html="desc"></p>
</div>
<script src="./hvue.js"></script>
<script>
const app = new HVue({
el: '#app',
data: {
counter: 1,
desc: '<span style="color: red">哈哈哈哈哈</span>'
},
})
setInterval(() => {
app.counter++
}, 1000);
</script>
vue实现出现之后,应用的代码如上。
- 创建一个HVue类,这样才能被实例化,
- 在构造器中,用data来保存options里面的data对象。
- 数据响应式处理,再次我们直接调用之前已经实现的observe方法,传入data即可。
- 代理data到hvue实例上,这样app.counter就能直接取到值,不然只能通过app.$options.counter才能取到。
class HVue {
constructor(options) {
// 保存选项
this.$options = options
this.$data = options.data
// 数据响应式处理
observe(this.$data)
// 代理data到hvue实例上
proxy(this)
}
}
- 接下来实现代理data到hvue实例的过程。
- 创建proxy方法,我们传上下文进去。遍历data。我们遍历data上的key,然后用Object.defineProperty来拦截key,get直接返回拦截的data上面的key,set方法则是将data上的key赋值为set拿到的值,这样就实现来代理$data到hvue实际例子。
/**
* 代理data到hvue实例上
* @param {Object} vm 上下文 this
*/
function proxy(vm) {
Object.keys(vm.$data).forEach(key => {
Object.defineProperty(vm, key, {
get() {
return vm.$data[key]
},
set(v) {
vm.$data[key] = v
}
})
})
}
- 数据能响应式来,我们接着实现编译的过程。
- 创建一个Compile类,接收的参数是el元素和this上下文
- 在构造器中保存上下文和元素。
class Compile {
constructor(el, vm) {
this.$el = document.querySelector(el)
this.$vm = vm
}
- 创建一个编译的方法,入参是dom元素。遍历el dom树,遍历所有的子节点,可以拿到node,然后判断是元素还是文本。如果是元素节点,则需要处理元素上的属性和子节点,然后如果子节点还有元素,则需要递归子节点,直到是文本。 如果是文本,则获取表达式的值并赋值给node。代码如下:
constructor(el, vm) {
...
// 根据dom渲染
this.compile(this.$el)
}
/**
* 遍历el dom树
* @param {Object} el el dom树
*/
compile(el) {
el.childNodes.forEach(node => {
if (this.isElement(node)) { // 如果是元素
// console.log("编译元素", node.nodeName);
// 处理属性和子节点
this.compileElement(node)
// 递归子节点
if (node.childNodes && node.childNodes.length) {
this.compile(node)
}
} else if (this.isInter(node)) { // 如果是文本
console.log("编译插值表达式", node.textContent);
// 获取表达式的值并赋值给node
this.compileText(node)
}
})
}
/**
* 判断节点是不是元素(标签)
* @param {Object} node 节点
*/
isElement(node) {
return node.nodeType === 1
}
/**
* 判断节点是不是文本 {{xxx}}
* @param {Object} node 节点
*/
isInter(node) {
return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent);
}
/**
* 判断是否是一个指令
* @param {String} attr 属性名称
*/
isDir(attr) {
return attr.startsWith('h-')
}
// 编译文本
compileText(node) {
this.update(node, RegExp.$1, 'text')
}
// h-text处理函数
text(node, exp) {
this.update(node, exp, 'text')
}
update(node, exp, dir) {
// init
// 每一个指令都有一个updater方法,比如h-text有textUpdater方法,该方法接收2个参数,node和值
const fn = this[dir + 'Updater']
fn && fn(node, this.$vm[exp])
}
依赖收集
视图中会⽤到data中某key,这称为依赖。同⼀个key可能出现多次,每次都需要收集出来⽤⼀个
Watcher来维护它们,此过程称为依赖收集。
多个Watcher需要⼀个Dep来管理,需要更新时由Dep统⼀通知。
实现思路:
- defineReactive时为每⼀个key创建⼀个Dep实例
- 初始化视图时读取某个key,例如name1,创建⼀个watcher1
- 由于触发name1的getter⽅法,便将watcher1添加到name1对应的Dep中
- 当name1更新,setter触发时,便可通过对应Dep通知其管理所有Watcher更新
- 创建Dep
// 依赖:和响应式对象的每个key一一对应
class Dep {
constructor() {
this.deps = []
}
addDep(dep) {
this.deps.push(dep)
}
notify() {
this.deps.forEach(dep => dep.update())
}
}
- 创建watcher
class Watcher {
constructor(vm, key, updateFn) {
this.vm = vm
this.key = key
this.updateFn = updateFn
// 读取一下key的值,触发其get,从而收集依赖
Dep.target = this
this.vm[this.key]
Dep.target = null
}
update() {
this.updateFn.call(this.vm, this.vm[this.key])
}
}
然后在defineReacive方法中创建Dep, 在get中依赖收集,set进行通知调用watcher里面的通知函数。
function defineReactive(obj, key, val) {
// 递归
observe(val);
// Dep在这创建
const dep = new Dep() // 加入了此行
Object.defineProperty(obj, key, {
get() {
console.log("get", key);
// 依赖收集
Dep.target && dep.addDep(Dep.target) // 加入了此行
return val;
},
set(v) {
if (val !== v) {
console.log("set", key);
// 传入新值v可能还是对象
observe(v);
val = v;
dep.notify() // 加入了此行
}
},
});
}
在Compile类中,在更新函数中创建watcher实例,然后在回调函数中调用xxxUpdater方法, 这样就能实现数据的实时更新啦。
完整代码如下。
class HVue {
constructor(options) {
// 保存选项
this.$options = options
this.$data = options.data
// 数据响应式处理
observe(this.$data)
// 代理data到hvue实例上
proxy(this)
// 编译
new Compile(options.el, this)
}
}
class Compile {
constructor(el, vm) {
this.$el = document.querySelector(el)
this.$vm = vm
// 根据dom渲染
this.compile(this.$el)
}
/**
* 遍历el dom树
* @param {Object} el el dom树
*/
compile(el) {
el.childNodes.forEach(node => {
if (this.isElement(node)) { // 如果是元素
// console.log("编译元素", node.nodeName);
// 处理属性和子节点
this.compileElement(node)
// 递归子节点
if (node.childNodes && node.childNodes.length) {
this.compile(node)
}
} else if (this.isInter(node)) { // 如果是文本
console.log("编译插值表达式", node.textContent);
// 获取表达式的值并赋值给node
this.compileText(node)
}
})
}
/**
* 判断节点是不是元素(标签)
* @param {Object} node 节点
*/
isElement(node) {
return node.nodeType === 1
}
/**
* 判断节点是不是文本 {{xxx}}
* @param {Object} node 节点
*/
isInter(node) {
return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent);
}
/**
* 判断是否是一个指令
* @param {String} attr 属性名称
*/
isDir(attr) {
return attr.startsWith('h-')
}
update(node, exp, dir) {
// init
// 每一个指令都有一个updater方法,比如h-text有textUpdater方法,该方法接收2个参数,node和值
const fn = this[dir + 'Updater']
fn && fn(node, this.$vm[exp])
// update: 创建watcher
new Watcher(this.$vm, exp, function(val) {
fn && fn(node, val)
})
}
/**
* 处理元素的所有动态属性
* @param {Object} node 节点
*/
compileElement(node) {
// node.attributes是一个类数组对象 需先转换成数组
Array.from(node.attributes).forEach(attr => {
console.log(attr)
// 获取属性名称. h-text="count"则可以获取到h-text
const attrName = attr.name
// 获取属性值 对应的data上的key. h-text="count"则可以获取到count
const exp = attr.value
// 判断是否是一个指令
if (this.isDir(attrName)) {
// 执行指令处理函数
// h-text 关心text
const dir = attrName.substring(2)
this[dir] && this[dir](node, exp)
}
})
}
// 编译文本
compileText(node) {
this.update(node, RegExp.$1, 'text')
}
// h-text处理函数
text(node, exp) {
this.update(node, exp, 'text')
}
textUpdater(node, val) {
node.textContent = val
}
// h-html处理函数
html(node, exp) {
this.update(node, exp, 'html')
}
htmlUpdater(node, val) {
node.innerHTML = val
}
}
/**
* 递归遍历obj, 动态拦截obj的所有的key
* @param {Object} obj
*/
function observe(obj) {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 每出现一个对象 创建一个Observer实例
new Observer(obj)
}
/**
* 代理data到hvue实例上
* @param {Object} vm 上下文 this
*/
function proxy(vm) {
Object.keys(vm.$data).forEach(key => {
Object.defineProperty(vm, key, {
get() {
return vm.$data[key]
},
set(v) {
vm.$data[key] = v
}
})
})
}
/**
* 判断obj传入的类型,并做响应式处理
*/
class Observer {
constructor(obj) {
this.value = obj
if (Array.isArray(obj)) {
// todo
} else {
this.walk(obj)
}
}
/**
* 将对象响应式处理
* @param {Object} obj 传入的对象
*/
walk(obj) {
Object.keys(obj).forEach(k => {
defineReactive(obj, k, obj[k])
})
}
}
class Watcher {
constructor(vm, key, updateFn) {
// hvue实例
this.vm = vm
// 依赖key
this.key = key
// 更新函数
this.updateFn = updateFn
// 读取key的值,触发get 收集依赖
Dep.target = this
this.vm[this.key]
Dep.target = null
}
// 更新
update() {
// 改变hvue依赖的key的指向
this.updateFn.call(this.vm, this.vm[this.key])
}
}
class Dep {
constructor() {
this.deps = []
}
addDep(dep) {
this.deps.push(dep)
}
notify() {
this.deps.forEach(dep => {
dep.update()
})
}
}
/**
* 将传入的obj,动态的设置一个key,它的值是val
* @param {Object} obj 要接收的对象
* @param {String} key 要设置的键
* @param {*} val 要设置的值
*/
function defineReactive(obj, key, val) {
// * 如果原始对象的值是对象,那么还是需要递归遍历该值
observe(val);
const dep = new Dep()
// - 要拦截的对象obj
// - 要设置的属性名称
// - descriptor属性描述器,是一个对象,里面有set和get方法
Object.defineProperty(obj, key, {
get() {
// 返回的是最新的值
console.log('get key', key); // sy-log
// 依赖收集
Dep.target && dep.addDep(Dep.target)
return val;
},
set(v) {
// 如果老的值与新传进来的值不相等 设置为最新的值。这样get里面拿到的就是最新的值
if (val !== v) {
console.log('set key', key); // sy-log
// 因为传入的新值v可能还是一个对象, 所以需要遍历。
observe(v);
val = v;
dep.notify()
}
},
});
}