我们首先封装一个响应式处理的方法defineReactive,通过defineProperty这个方法重新定义对象属性的get和set描述符,来实现对数据的劫持,每次读取数据的时候都会触发get,每次更新数据的时候都会触发set,所以我们可以在set中触发更新视图的方法update来实现一个基本的响应式处理。
/**
* @param {*} obj 目标对象
* @param {*} key 目标对象的一个属性
* @param {*} val 目标对象的一个属性的初始值
*/
function defineReactive(obj, key, val) {
// 通过该方法拦截数据
Object.defineProperty(obj, key, {
// 读取数据的时候会走这里
get() {
console.log('🚀🚀~ get:', key);
return val
},
// 更新数据的时候会走这里
set(newVal) {
// 只有当新值和旧值不同的时候 才会触发重新赋值操作
if (newVal !== val) {
console.log('🚀🚀~ set:', key);
val = newVal
// 这里是触发视图更新的地方
update()
}
}
})
}
我们写点代码来测试一下,每1s修改一次obj.foo的值 , 并定义一个update方法来修改app节点的内容。
// html
<div id='app'>123</div>
// js
// 劫持 obj.foo 属性
const obj = {}
defineReactive(obj, 'foo', '')
// 给 obj.foo 一个初始值
obj.foo = new Date().toLocaleTimeString()
// 定时器修改 obj.foo
setInterval(() => {
obj.foo = new Date().toLocaleTimeString()
}, 1000)
// 更新视图
function update() {
app.innerHTML = obj.foo
}
可以看到,每次修改obj.foo的时候,都会触发我们定义的get和set,并调用update方法更新了视图,到这里,一个最简单的响应式处理就完成了。
处理深层次的嵌套
一个对象通常情况下不止一个属性,所以当我们要给每个属性添加响应式的时候,就需要遍历这个对象的所有属性,给每个key调用defineReactive进行处理。
/**
* @param {*} obj 目标对象
*/
function observe(obj) {
// 先判断类型, 响应式处理的目标一定要是个对象类型
if (typeof obj !== 'object' || obj === null) {
return
}
// 遍历 obj, 对 obj 的每个属性进行响应式处理
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
// 定义对象 obj
const obj = {
foo: 'foo',
bar: 'bar',
friend: {
name: 'aa'
}
}
// 访问 obj 的属性 , foo 和 bar 都被劫持到,就不在浏览器演示了。
obj.bar = 'barrrrrrrr' // => 🚀🚀~ set: bar
obj.foo = 'fooooooooo' // => 🚀🚀~ set: foo
// 访问 obj 的属性 obj.friend.name
obj.friend.name = 'bb' // => 🚀🚀~ get: friend
当我们访问obj.friend.name的时候,也只是打印出来get: friend,而不是friend.name, 所以我们还要进行个递归,把深层次的属性同样也做响应式处理。
function defineReactive(obj, key, val) {
// 递归
observe(val)
// 继续执行 Object.defineProperty...
Object.defineProperty(obj, key, {
... ...
})
}
// 再次访问 obj.friend.name
obj.friend.name = 'bb' // => 🚀🚀~ set: name
复制代码
递归的时机在defineReactive这个方法中,如果value是对象就进行递归,如果不是对象直接返回,继续执行下面的代码,保证obj中嵌套的属性都进行响应式的处理,所以当我们再次访问obj.friend.name的时候,就打印出了set: name 。
处理直接赋值一个对象
上面已经实现了对深层属性的响应式处理,那么如果我直接给属性赋值一个对象呢?
const obj = {
friend: {
name: 'aa'
}
}
obj.friend = { // => 🚀🚀~ set: friend
name: 'bb'
}
obj.friend.name = 'cc' // => 🚀🚀~ get: friend
复制代码
这种赋值方式还是只打印出了get: friend,并没有劫持到obj.friend.name,那怎么办呢?我们只需要在 触发set的时候,判断一下value的类型,如果它是个对象类型,我们就对他执行observe方法。
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
... ...
set(newVal) {
// 只有当新值和旧值不同的时候 才会触发重新赋值操作
if (newVal !== val) {
console.log('🚀🚀~ set:', key);
// 如果 newVal 是个对象类型,再次做响应式处理。
if (typeof obj === 'object' && obj !== null) {
observe(newVal)
}
val = newVal
}
}
})
}
// 再次给 obj.friend 赋值一个对象
obj.friend = {
name: 'bb'
}
// 再次访问 obj.friend.name , 这个时候就成功的劫持到了 name 属性
obj.friend.name = 'cc' //=> 🚀~ set: name
复制代码
处理新添加一个属性
上面的例子都是操作已经存在的属性,那么如果我们新添加一个属性呢?
const obj = {}
obj.age = 18
obj.age = 20
复制代码
当我们试图修改obj.age的时候,什么都没有打印出来,说明并没有对obj.age进行响应式处理。这里也非常好理解,因为新增加的属性并没有经过defineReactive的处理,所以我们就需要一个方法来手动处理新添加属性这种情况。
/**
* @param {*} obj 目标对象
* @param {*} key 目标对象的一个属性
* @param {*} val 目标对象的一个属性的初始值
*/
function $set(obj, key, val) {
// vue 中在这进行了很多判断,val 是对象还是数组等等,我们就从简了
defineReactive(obj, key, val)
}
// 调用 $set 方法给 obj 添加新的属性
$set(obj, 'age', 18)
// 再次访问 obj.age
obj.age = 20 //=> 🚀🚀~ set: age
复制代码
新定义的$set方法,内部也是把目标属性进行了defineReactive处理,这时我们再次更新obj.age的时候,就打印出了set: age, 也就实现了一个响应式的处理。
VUE 中的数据响应式
实现简易的 Vue
这是Vue中最基本的使用方式,创建一个Vue的实例,然后就可以在模板中使用data中定义的响应式数据了,今天我们就来完成一个简易版的Vue。
<div id='app'>
<p>{{counter}}</p>
<p>{{counter}}</p>
<p>{{counter}}</p>
<p my-text='counter'></p>
<p my-html='desc'></p>
<button @click='add'>点击增加</button>
<p>{{name}}</p>
<input type="text" my-model='name'>
</div>
<script>
const app = new MyVue({
el: "#app",
data: {
counter: 1,
desc: `<span style='color:red' >一尾流莺</span>`
},
methods: {
add() {
this.counter++
}
}
})
</script>
复制代码
原理
设计类型介绍
MyVue:框架构造函数
Observer:执行数据响应化(区分数据是对象还是数组)
Compile:编译模板,初始化视图,收集依赖(更新函数,创建watcher)
Watcher:执行更新函数(更新dom)
Dep:管理多个Watcher批量更新
流程解析
初始化时通过Observer对数据进行响应式处理,在Observer的get的时候创建一个Dep的实例,用来通知更新。
初始化时通过Compile进行编译,解析模板语法,找到其中动态绑定的数据,从data中获取数据并初始化视图,把模板语法替换成数据。
同时进行一次订阅,创建一个Watcher,定义一个更新函数 ,将来数据发生变化时,Watcher会调用更新函数 把Watcher添加到dep中 。
Watcher是一对一的负责某个具体的元素,data中的某个属性在一个视图中可能会出现多次,也就是会创建多个Watcher,所以一个Dep中会管理多个Watcher。
当Observer监听到数据发生变化时,Dep通知所有的Watcher进行视图更新。
代码实现 - 第一回合 数据响应式
observe
observe方法相对于上面,做了一小点的改动,不是直接遍历调用defineReactive了,而是创建一个Observer类的实例 。
// 遍历obj 对其每个属性进行响应式处理
function observe(obj) {
// 先判断类型, 响应式处理的目标一定要是个对象类型
if (typeof obj !== 'object' || obj === null) {
return
}
new Observer(obj)
}
复制代码
Observer 类
Observer类之前有解释过,它就是用来做数据响应式的,在它内部区分了数据是对象还是数组,然后执行不同的响应式方案。
// 根据传入value的类型做响应的响应式处理
class Observer {
constructor(value) {
this.value = value
if (Array.isArray(value)) {
// todo 这个分支是数组的响应式处理方式 不是本文重点 暂时忽略
} else {
// 这个分支是对象的响应式处理方式
this.walk(value)
}
}
// 对象的响应式处理 跟前面讲到过的一样,再封装一层函数而已
walk(obj) {
// 遍历 obj, 对 obj 的每个属性进行响应式处理
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
}
复制代码
MVVM 类(MyVue)
这一回合我们就先在实例初始化的时候,对data进行响应式处理,为了能用this.key的方式访问this.$data.key,我们需要做一层代理。
class MyVue {
constructor(options) {
// 把数据存一下
this.$options = options
this.$data = options.data
// data响应式处理
observe(this.$data)
// 代理 把 this.$data 上的属性 全部挂载到 vue实例上 可以通过 this.key 访问 this.$data.key
proxy(this)
}
}
复制代码
proxy代理也非常容易理解,就是通过Object.defineProperty改变一下引用。
/**
* 代理 把 this.$data 上的属性 全部挂载到 vue实例上 可以通过 this.key 访问 this.$data.key
* @param {*} vm vue 实例
*/
function proxy(vm) {
Object.keys(vm.$data).forEach(key => {
// 通过 Object.defineProperty 方法进行代理 这样访问 this.key 等价于访问 this.$data.key
Object.defineProperty(vm, key, {
get() {
return vm.$data[key]
},
set(newValue) {
vm.$data[key] = newValue
}
})
})
}
复制代码
代码实现 - 第二回合 模板编译
这一趴要实现下面这个流程,VNode不是本文的重点,所以先去掉Vnode的环节,内容都在注释里啦~
// 解析模板语法
// 1.处理插值表达式{{}}
// 2.处理指令和事件
// 3.以上两者初始化和更新
class Compile {
/**
* @param {*} el 宿主元素
* @param {*} vm vue实例
*/
constructor(el, vm) {
this.$vm = vm
this.$el = document.querySelector(el)
// 如果元素存在,执行编译
if (this.$el) {
this.compile(this.$el)
}
}
// 编译
compile(el) {
// 获取 el 的子节点,判断它们的类型做相应的处理
const childNodes = el.childNodes
childNodes.forEach(node => {
// 判断节点的类型 本文以元素和文本为主要内容 不考虑其他类型
if (node.nodeType === 1) { // 这个分支代表节点的类型是元素
// 获取到元素上的属性
const attrs = node.attributes
// 把 attrs 转换成真实数组
Array.from(attrs).forEach(attr => {
// 指令长 my-xxx = 'abc' 这个样子
// 获取节点属性名
const attrName = attr.name
// 获取节点属性值
const exp = attr.value
// 判断节点属性是不是一个指令
if (attrName.startsWith('my-')) {
// 获取具体的指令类型 也就是 my-xxx 后面的 xxx 部分
const dir = attrName.substring(3)
// 如果this[xxx]指令存在 执行这个指令
this[dir] && this[dir](node, exp)
}
})
} else if (this.isInter(node)) { // 这个分支代表节点的类型是文本 并且是个插值语法{{}}
// 文本的初始化
this.compileText(node)
}
// 递归遍历 dom 树
if (node.childNodes) {
this.compile(node)
}
})
}
// 编译文本
compileText(node) {
// 可以通过 RegExp.$1 来获取到 插值表达式中间的内容 {{key}}
// this.$vm[RegExp.$1] 等价于 this.$vm[key]
// 然后把这个 this.$vm[key] 的值 赋值给文本 就完成了 文本的初始化
node.textContent = this.$vm[RegExp.$1]
}
// my-text 指令对应的方法
text(node, exp) {
// 这个指令用来修改节点的文本,这个指令长这样子 my-text = 'key'
// 把 this.$vm[key] 赋值给文本 即可
node.textContent = this.$vm[exp]
}
// my-html 指令对应的方法
html(node, exp) {
// 这个指令用来修改节点的文本,这个指令长这样子 my-html = 'key'
// 把 this.$vm[key] 赋值给innerHTML 即可
node.innerHTML = this.$vm[exp]
}
// 是否是插值表达式{{}}
isInter(node) {
return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent)
}
}
复制代码
代码实现 - 第三回合 收集依赖
视图中会用到的data中的属性key的地方,都可以被称为一个依赖,同一个key可能会出现多次,每次出现都会创建一个Watcher进行维护,这些Watcher需要收集起来统一管理,这个过程叫做收集依赖。
同一个key创建的多个Watcher需要一个Dep来管理,需要更新时由Dep统一进行通知。
上面这段代码中,name1用到了两次, 创建了两个Watcher,Dep1收集了这两个Watcher,name2用到了一次, 创建了一个Watcher,Dep2收集了这一个Watcher。
收集依赖的思路
defineReactive时为每一个key创建一个Dep实例
初始化视图时,读取某个key,例如name1,创建一个Watcher1
由于触发name1的getter方法,便将Watcher1添加到name1对应的Dep中
当name1发生更新时,会触发setter,便可通过对应的Dep通知其管理的所有Watcher进行视图的更新
Watcher 类
收集依赖的过程,在Watcher实例创建的时候,首先把实例赋值给Dep.target,手动读一下data.key的值 ,触发defineReactive中的get,把当前的Watcher实例添加到Dep中进行管理,然后再把Dep.target赋值为null。
// 监听器:负责依赖的更新
class Watcher {
/**
* @param {*} vm vue 实例
* @param {*} key Watcher实例对应的 data.key
* @param {*} cb 更新函数
*/
constructor(vm, key, updateFn) {
this.vm = vm
this.key = key
this.updateFn = updateFn
// 触发依赖收集 把当前 Watcher 赋值给 Dep 的静态属性 target
Dep.target = this
// 故意读一下 data.key 的值 为了触发 defineReactive 中的 get
this.vm[this.key]
// 收集依赖以后 再置为null
Dep.target = null
}
// 更新方法 未来被 Dep 调用
update() {
// 执行实际的更新操作
this.updateFn.call(this.vm, this.vm[this.key])
}
}
复制代码
Dep 类
addDep方法把Watchers收集起来 放在deps中进行管理,notify方法通知deps中的所有Watchers进行视图的更新。
class Dep {
constructor() {
this.deps = [] // 存放 Watchers
}
// 收集 Watchers
addDep(dep) {
this.deps.push(dep)
}
// 通知所有的 Watchers 进行更新 这里的 dep 指的就是收集起来的 Watcher
notify() {
this.deps.forEach(dep => dep.update())
}
}
复制代码
升级 Compile
在第二回合中,我们的Compile类只实现了视图的初始化,所以在第三回合中要把它升级一下,支持视图的更新。
Watcher实例就是在初始化后创建的,用来监听更新。
class Compile {
... ... // 省略号的地方都没有发生改变
// 下面是发生改变的代码
/**
* 根据指令的类型操作 dom 节点
* @param {*} node dom节点
* @param {*} exp 表达式 this.$vm[key]
* @param {*} dir 指令
*/
update(node, exp, dir) {
// 1.初始化 获取到指令对应的实操函数
const fn = this[dir + 'Updater']
// 如果函数存在就执行
fn && fn(node, this.$vm[exp])
// 2.更新 再次调用指令对应的实操函数 值由外面传入
new Watcher(this.$vm, exp, function(val) {
fn && fn(node, val)
})
}
// 编译文本 {{xxx}}
compileText(node) {
// 可以通过 RegExp.$1 来获取到 插值表达式中间的内容 {{key}}
// this.$vm[RegExp.$1] 等价于 this.$vm[key]
// 然后把这个 this.$vm[key] 的值 赋值给文本 就完成了 文本的初始化
this.update(node, RegExp.$1, 'text')
}
// my-text 指令
text(node, exp) {
this.update(node, exp, 'text')
}
// my-text 指令对应的实操
textUpdater(node, value) {
// 这个指令用来修改节点的文本,这个指令长这样子 my-text = 'key'
// 把 this.$vm[key] 赋值给文本 即可
node.textContent = value
}
// my-html 指令
html(node, exp) {
this.update(node, exp, 'html')
}
// my-html 指令对应的实操
htmlUpdater(node, value) {
// 这个指令用来修改节点的文本,这个指令长这样子 my-html = 'key'
// 把 this.$vm[key] 赋值给innerHTML 即可
node.innerHTML = value
}
// 是否是插值表达式{{}}
isInter(node) {
return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent)
}
}
复制代码
Watcher 和 Dep 建立关联
首先在defineReactive中创建Dep实例,与data.key是一一对应的关系,然后再get中 调用dep.addDep进行依赖的收集,Dep.target就是一个Watcher。在set中 调用dep.notify()通知所有的Watchers更新视图。
function defineReactive(obj, key, val) {
... ...
// 创建 Dep 实例 , 与 key 一一对应
const dep = new Dep()
// 通过该方法拦截数据
Object.defineProperty(obj, key, {
// 读取数据的时候会走这里
get() {
console.log('🚀🚀~ get:', key);
// 依赖收集 Dep.target 就是 一个Watcher
Dep.target && dep.addDep(Dep.target)
return val
},
// 更新数据的时候会走这里
set(newVal) {
// 只有当新值和旧值不同的时候 才会触发重新赋值操作
if (newVal !== val) {
console.log('🚀🚀~ set:', key);
// 如果 newVal 是个对象类型,再次做响应式处理。
if (typeof obj === 'object' && obj !== null) {
observe(newVal)
}
val = newVal
// 通知更新
dep.notify()
}
}
})
}
复制代码
代码实现 - 第四回合 事件和双向绑定
事件绑定
事件绑定也很好理解,首先判断节点的属性是不是以@开头,然后拿到事件的类型,也就是例子中的click, 再根据函数名找到methods中定义的函数体,最后添加事件监听就行了。
class Compile {
... ... // 省略号的地方都没有发生改变
compile(el) {
// 判断节点属性是不是一个事件
if (this.isEvent(attrName)) {
// @click="onClick"
const dir = attrName.substring(1) // click
// 事件监听
this.eventHandler(node, exp, dir)
}
}
... ...
// 判断节点是不是一个事件 也就是以@开头
isEvent(dir) {
return dir.indexOf("@") === 0
}
eventHandler(node, exp, dir) {
// 根据函数名字在配置项中获取函数体
const fn = this.$vm.$options.methods && this.$vm.$options.methods[exp]
// 添加事件监听
node.addEventListener(dir, fn.bind(this.$vm))
}
... ...
}
复制代码
双向绑定
my-model其实也是一个指令,走的也是指令相关的处理逻辑,所以我们只需要添加一个model指令和对应的modelUpdater处理函数就行了。
my-model双向绑定其实就是事件绑定和修改value的一个语法糖,本文以input为例,其它的表单元素绑定的事件会有不同,但是道理是一样的。
class Compile {
// my-model指令 my-model='xxx'
model(node, exp) {
// update 方法只完成赋值和更新
this.update(node, exp, 'model')
// 事件监听
node.addEventListener('input', e => {
// 将新的值赋值给 data.key 即可
this.$vm[exp] = e.target.value
})
}
modelUpdater(node, value) {
// 给表单元素赋值
node.value = value
}
}
复制代码
现在也可以更新一下模板编译的流程图啦~
最后
如果你觉得此文对你有一丁点帮助,点个赞。
如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点 star:http://github.crmeb.net/u/lsq 不胜感激 !
完整源码下载地址:http://github.crmeb.net/u/lsq
PHP 学习手册:https://doc.crmeb.com
技术交流论坛:https://q.crmeb.com